Skip to content

Commit 881e65b

Browse files
authored
feat(import): server-side coercion + write-mode for /data/:object/import (#2505)
1 parent 9e47d12 commit 881e65b

15 files changed

Lines changed: 2729 additions & 59 deletions

packages/client/src/client.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,3 +1051,62 @@ describe('ObjectStackClient locale → Accept-Language', () => {
10511051
expect(lastHeaders(fetchMock)['Accept-Language']).toBeUndefined();
10521052
});
10531053
});
1054+
1055+
describe('Import-job namespace', () => {
1056+
it('createImportJob POSTs the payload to /data/:object/import/jobs', async () => {
1057+
const { client, fetchMock } = createMockClient({ jobId: 'imp_x', object: 'task', status: 'pending', total: 3, createdAt: '2026-07-01T00:00:00Z' });
1058+
const res = await client.data.createImportJob('task', { format: 'json', rows: [{ id: 'a' }] } as any);
1059+
const [url, init] = fetchMock.mock.calls[0];
1060+
expect(url).toBe('http://localhost:3000/api/v1/data/task/import/jobs');
1061+
expect(init.method).toBe('POST');
1062+
expect(res).toMatchObject({ jobId: 'imp_x', status: 'pending', total: 3 });
1063+
});
1064+
1065+
it('getImportJobProgress GETs /data/import/jobs/:jobId', async () => {
1066+
const { client, fetchMock } = createMockClient({ jobId: 'imp_x', object: 'task', status: 'running', percentComplete: 40 });
1067+
const res = await client.data.getImportJobProgress('imp_x');
1068+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/data/import/jobs/imp_x');
1069+
expect(res.percentComplete).toBe(40);
1070+
});
1071+
1072+
it('getImportJobResults GETs the /results sub-route', async () => {
1073+
const { client, fetchMock } = createMockClient({ jobId: 'imp_x', status: 'succeeded', results: [{ row: 1, ok: true, action: 'created' }], resultsTruncated: false });
1074+
const res = await client.data.getImportJobResults('imp_x');
1075+
expect(fetchMock.mock.calls[0][0]).toBe('http://localhost:3000/api/v1/data/import/jobs/imp_x/results');
1076+
expect(res.results).toHaveLength(1);
1077+
expect(res.resultsTruncated).toBe(false);
1078+
});
1079+
1080+
it('listImportJobs builds the query string and unwraps the jobs array', async () => {
1081+
const { client, fetchMock } = createMockClient({ jobs: [{ jobId: 'imp_x', object: 'task', status: 'succeeded' }] });
1082+
const jobs = await client.data.listImportJobs({ object: 'task', status: 'succeeded', limit: 10, offset: 5 });
1083+
const url = fetchMock.mock.calls[0][0] as string;
1084+
expect(url.startsWith('http://localhost:3000/api/v1/data/import/jobs?')).toBe(true);
1085+
expect(url).toContain('object=task');
1086+
expect(url).toContain('status=succeeded');
1087+
expect(url).toContain('limit=10');
1088+
expect(url).toContain('offset=5');
1089+
expect(jobs).toHaveLength(1);
1090+
expect(jobs[0].jobId).toBe('imp_x');
1091+
});
1092+
1093+
it('cancelImportJob POSTs the /cancel sub-route', async () => {
1094+
const { client, fetchMock } = createMockClient({ success: true });
1095+
const res = await client.data.cancelImportJob('imp_x');
1096+
const [url, init] = fetchMock.mock.calls[0];
1097+
expect(url).toBe('http://localhost:3000/api/v1/data/import/jobs/imp_x/cancel');
1098+
expect(init.method).toBe('POST');
1099+
expect(res.success).toBe(true);
1100+
});
1101+
1102+
it('undoImportJob POSTs the /undo sub-route', async () => {
1103+
const { client, fetchMock } = createMockClient({ success: true, jobId: 'imp_x', object: 'task', deleted: 3, restored: 2, failed: 0 });
1104+
const res = await client.data.undoImportJob('imp_x');
1105+
const [url, init] = fetchMock.mock.calls[0];
1106+
expect(url).toBe('http://localhost:3000/api/v1/data/import/jobs/imp_x/undo');
1107+
expect(init.method).toBe('POST');
1108+
expect(res.success).toBe(true);
1109+
expect(res.deleted).toBe(3);
1110+
expect(res.restored).toBe(2);
1111+
});
1112+
});

packages/client/src/index.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,16 @@ import {
8282
UnsubscribeResponse,
8383
WellKnownCapabilities,
8484
ApiRoutes,
85+
ImportRequest,
86+
ImportResponse,
87+
CreateImportJobRequest,
88+
CreateImportJobResponse,
89+
ImportJobProgress,
90+
ImportJobResults,
91+
ImportJobSummary,
92+
ListImportJobsRequest,
93+
ListImportJobsResponse,
94+
UndoImportJobResponse,
8595
} from '@objectstack/spec/api';
8696
import type {
8797
ApprovalRequestRow,
@@ -3046,6 +3056,95 @@ export class ObjectStackClient {
30463056
return this.unwrapResponse<T[]>(res);
30473057
},
30483058

3059+
/**
3060+
* Bulk-import rows (CSV text or JSON row objects) into an object.
3061+
*
3062+
* The server coerces each cell to its storage value using the object's field
3063+
* metadata (booleans, numbers, dates→ISO, select label→code, lookup name→id),
3064+
* so callers send raw spreadsheet values plus an optional column `mapping`.
3065+
* `writeMode` selects insert / update / upsert (the latter two need
3066+
* `matchFields`); `dryRun` validates + previews without persisting. The
3067+
* response carries per-row outcomes for an import report.
3068+
*/
3069+
import: async (object: string, request: ImportRequest): Promise<ImportResponse> => {
3070+
const route = this.getRoute('data');
3071+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/import`, {
3072+
method: 'POST',
3073+
body: JSON.stringify(request),
3074+
});
3075+
return this.unwrapResponse<ImportResponse>(res);
3076+
},
3077+
3078+
/**
3079+
* Import-job namespace — the asynchronous counterpart to {@link import} for
3080+
* large files (up to 50,000 rows). `createImportJob` posts the whole payload
3081+
* once and returns immediately with a `jobId`; a server worker processes the
3082+
* batch in the background. Poll {@link getImportJobProgress} for live
3083+
* counters, {@link getImportJobResults} for the capped per-row report, and
3084+
* {@link listImportJobs} for history. {@link cancelImportJob} stops a
3085+
* pending/running job cooperatively.
3086+
*
3087+
* These routes require a server new enough to expose them — older servers
3088+
* return 404, which surfaces here as a rejected promise. Callers that want
3089+
* graceful degradation should feature-detect (e.g. try the job, fall back
3090+
* to the synchronous {@link import} on 404).
3091+
*/
3092+
createImportJob: async (object: string, request: CreateImportJobRequest): Promise<CreateImportJobResponse> => {
3093+
const route = this.getRoute('data');
3094+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/import/jobs`, {
3095+
method: 'POST',
3096+
body: JSON.stringify(request),
3097+
});
3098+
return this.unwrapResponse<CreateImportJobResponse>(res);
3099+
},
3100+
3101+
getImportJobProgress: async (jobId: string): Promise<ImportJobProgress> => {
3102+
const route = this.getRoute('data');
3103+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}`);
3104+
return this.unwrapResponse<ImportJobProgress>(res);
3105+
},
3106+
3107+
getImportJobResults: async (jobId: string): Promise<ImportJobResults> => {
3108+
const route = this.getRoute('data');
3109+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}/results`);
3110+
return this.unwrapResponse<ImportJobResults>(res);
3111+
},
3112+
3113+
listImportJobs: async (query: Partial<ListImportJobsRequest> = {}): Promise<ImportJobSummary[]> => {
3114+
const route = this.getRoute('data');
3115+
const qs = new URLSearchParams();
3116+
if (query.object) qs.set('object', query.object);
3117+
if (query.status) qs.set('status', query.status);
3118+
if (query.limit != null) qs.set('limit', String(query.limit));
3119+
if (query.offset != null) qs.set('offset', String(query.offset));
3120+
const suffix = qs.toString() ? `?${qs.toString()}` : '';
3121+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs${suffix}`);
3122+
const body = await this.unwrapResponse<ListImportJobsResponse>(res);
3123+
return body.jobs;
3124+
},
3125+
3126+
cancelImportJob: async (jobId: string): Promise<{ success: boolean }> => {
3127+
const route = this.getRoute('data');
3128+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}/cancel`, {
3129+
method: 'POST',
3130+
});
3131+
return this.unwrapResponse<{ success: boolean }>(res);
3132+
},
3133+
3134+
/**
3135+
* Logically roll back a finished import: delete the records it created and
3136+
* restore the fields it updated to their pre-import values. Only jobs that
3137+
* captured an undo log (small, non-dry-run, not yet reverted) are undoable —
3138+
* others return 422. See {@link ImportJobProgress.undoable}.
3139+
*/
3140+
undoImportJob: async (jobId: string): Promise<UndoImportJobResponse> => {
3141+
const route = this.getRoute('data');
3142+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}/undo`, {
3143+
method: 'POST',
3144+
});
3145+
return this.unwrapResponse<UndoImportJobResponse>(res);
3146+
},
3147+
30493148
update: async <T = any>(
30503149
object: string,
30513150
id: string,
@@ -3456,6 +3555,64 @@ export class ScopedProjectClient {
34563555
});
34573556
return this.parent._unwrap<T[]>(res);
34583557
},
3558+
/**
3559+
* Bulk-import rows (CSV text or JSON row objects) into an object. The server
3560+
* coerces each cell to its storage value from field metadata (booleans,
3561+
* numbers, dates→ISO, select label→code, lookup name→id); callers send raw
3562+
* values plus an optional column `mapping`. `writeMode` selects
3563+
* insert/update/upsert (update/upsert need `matchFields`); `dryRun`
3564+
* validates + previews without persisting.
3565+
*/
3566+
import: async (object: string, request: ImportRequest): Promise<ImportResponse> => {
3567+
const res = await this.parent._fetch(this.url(`/data/${object}/import`), {
3568+
method: 'POST',
3569+
body: JSON.stringify(request),
3570+
});
3571+
return this.parent._unwrap<ImportResponse>(res);
3572+
},
3573+
/**
3574+
* Asynchronous import jobs (scoped) — see the top-level `data.createImportJob`
3575+
* for semantics. Large payloads are posted once; a server worker processes
3576+
* them in the background while callers poll progress / results / history.
3577+
*/
3578+
createImportJob: async (object: string, request: CreateImportJobRequest): Promise<CreateImportJobResponse> => {
3579+
const res = await this.parent._fetch(this.url(`/data/${object}/import/jobs`), {
3580+
method: 'POST',
3581+
body: JSON.stringify(request),
3582+
});
3583+
return this.parent._unwrap<CreateImportJobResponse>(res);
3584+
},
3585+
getImportJobProgress: async (jobId: string): Promise<ImportJobProgress> => {
3586+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}`));
3587+
return this.parent._unwrap<ImportJobProgress>(res);
3588+
},
3589+
getImportJobResults: async (jobId: string): Promise<ImportJobResults> => {
3590+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/results`));
3591+
return this.parent._unwrap<ImportJobResults>(res);
3592+
},
3593+
listImportJobs: async (query: Partial<ListImportJobsRequest> = {}): Promise<ImportJobSummary[]> => {
3594+
const qs = new URLSearchParams();
3595+
if (query.object) qs.set('object', query.object);
3596+
if (query.status) qs.set('status', query.status);
3597+
if (query.limit != null) qs.set('limit', String(query.limit));
3598+
if (query.offset != null) qs.set('offset', String(query.offset));
3599+
const suffix = qs.toString() ? `?${qs.toString()}` : '';
3600+
const res = await this.parent._fetch(this.url(`/data/import/jobs${suffix}`));
3601+
const body = await this.parent._unwrap<ListImportJobsResponse>(res);
3602+
return body.jobs;
3603+
},
3604+
cancelImportJob: async (jobId: string): Promise<{ success: boolean }> => {
3605+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/cancel`), {
3606+
method: 'POST',
3607+
});
3608+
return this.parent._unwrap<{ success: boolean }>(res);
3609+
},
3610+
undoImportJob: async (jobId: string): Promise<UndoImportJobResponse> => {
3611+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/undo`), {
3612+
method: 'POST',
3613+
});
3614+
return this.parent._unwrap<UndoImportJobResponse>(res);
3615+
},
34593616
update: async <T = any>(object: string, id: string, data: Partial<T>): Promise<UpdateDataResult<T>> => {
34603617
const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), {
34613618
method: 'PATCH',
@@ -3638,6 +3795,14 @@ export type {
36383795
AuthProviderInfo,
36393796
EmailPasswordConfigPublic,
36403797
AuthFeaturesConfig,
3798+
CreateImportJobRequest,
3799+
CreateImportJobResponse,
3800+
ImportJobProgress,
3801+
ImportJobResults,
3802+
ImportJobSummary,
3803+
ListImportJobsRequest,
3804+
ListImportJobsResponse,
3805+
UndoImportJobResponse,
36413806
} from '@objectstack/spec/api';
36423807

36433808
// Approval runtime types (ADR-0019) — surfaced so SDK consumers can type the

packages/platform-objects/src/audit/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export { SysReportSchedule } from './sys-report-schedule.object.js';
1919
export { SysJob } from './sys-job.object.js';
2020
export { SysJobRun } from './sys-job-run.object.js';
2121
export { SysJobQueue } from './sys-job-queue.object.js';
22+
export { SysImportJob } from './sys-import-job.object.js';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_import_job — Asynchronous Data Import Job
7+
*
8+
* Each row tracks one bulk import submitted through the async import API
9+
* (`POST /data/:object/import/jobs`). The client sends the whole payload
10+
* (rows[] or a base64 xlsx) in one request; the server persists this row,
11+
* processes the batch in the background, and streams progress by updating the
12+
* counters below. Readers poll `progress` / `results` and list history.
13+
*
14+
* Persisting to the DB (rather than in-memory) means progress and history
15+
* survive a server restart and are queryable per object / per user.
16+
*
17+
* Writers: the rest-server import-job worker.
18+
* Readers: the Import Wizard (progress + history), dashboards.
19+
*
20+
* @namespace sys
21+
*/
22+
export const SysImportJob = ObjectSchema.create({
23+
name: 'sys_import_job',
24+
label: 'Import Job',
25+
pluralLabel: 'Import Jobs',
26+
icon: 'upload',
27+
isSystem: true,
28+
managedBy: 'system',
29+
description: 'Asynchronous bulk-import job state, progress, and history',
30+
displayNameField: 'object_name',
31+
nameField: 'object_name', // [ADR-0079] canonical primary-title pointer
32+
titleFormat: '{object_name} import @ {created_at}',
33+
compactLayout: ['object_name', 'status', 'processed_rows', 'total_rows', 'created_at'],
34+
35+
fields: {
36+
id: Field.text({ label: 'Job ID', required: true, readonly: true, group: 'System' }),
37+
38+
object_name: Field.text({
39+
label: 'Object',
40+
required: true,
41+
maxLength: 255,
42+
searchable: true,
43+
description: 'API name of the object being imported into',
44+
group: 'Identity',
45+
}),
46+
47+
status: Field.select(
48+
['pending', 'running', 'succeeded', 'failed', 'cancelled'],
49+
{ label: 'Status', required: true, defaultValue: 'pending', group: 'State' },
50+
),
51+
52+
// ── progress counters (updated as the worker streams through the batch) ──
53+
total_rows: Field.number({ label: 'Total Rows', required: true, defaultValue: 0, group: 'Progress' }),
54+
processed_rows: Field.number({ label: 'Processed Rows', required: true, defaultValue: 0, group: 'Progress' }),
55+
created_count: Field.number({ label: 'Created', required: false, defaultValue: 0, group: 'Progress' }),
56+
updated_count: Field.number({ label: 'Updated', required: false, defaultValue: 0, group: 'Progress' }),
57+
skipped_count: Field.number({ label: 'Skipped', required: false, defaultValue: 0, group: 'Progress' }),
58+
error_count: Field.number({ label: 'Errors', required: false, defaultValue: 0, group: 'Progress' }),
59+
60+
// ── request echo (so history is self-describing without the payload) ──
61+
write_mode: Field.select(
62+
['insert', 'update', 'upsert'],
63+
{ label: 'Write Mode', required: false, defaultValue: 'insert', group: 'Request' },
64+
),
65+
dry_run: Field.boolean({ label: 'Dry Run', required: false, defaultValue: false, group: 'Request' }),
66+
run_automations: Field.boolean({ label: 'Run Automations', required: false, defaultValue: false, group: 'Request' }),
67+
68+
// ── outcome ──
69+
error: Field.textarea({ label: 'Fatal Error', required: false, group: 'Outcome' }),
70+
results: Field.json({
71+
label: 'Row Results (sample)',
72+
required: false,
73+
description: 'Capped sample of per-row results (failures first) for the UI',
74+
group: 'Outcome',
75+
}),
76+
77+
// ── undo / logical rollback ──
78+
undo_log: Field.json({
79+
label: 'Undo Log',
80+
required: false,
81+
description: 'Reversal instructions ({created:[ids], updated:[{id,before}]}) captured for small non-dry-run jobs so the import can be undone',
82+
group: 'Outcome',
83+
}),
84+
reverted_at: Field.datetime({
85+
label: 'Reverted At',
86+
required: false,
87+
description: 'Set when the import was undone (created records deleted, updated records restored)',
88+
group: 'Outcome',
89+
}),
90+
91+
// ── lifecycle timestamps ──
92+
started_at: Field.datetime({ label: 'Started At', required: false, group: 'State' }),
93+
completed_at: Field.datetime({ label: 'Completed At', required: false, group: 'State' }),
94+
created_by: Field.text({ label: 'Created By', required: false, readonly: true, group: 'System' }),
95+
created_at: Field.datetime({
96+
label: 'Created At',
97+
required: true,
98+
defaultValue: 'NOW()',
99+
readonly: true,
100+
group: 'System',
101+
}),
102+
},
103+
104+
indexes: [
105+
{ fields: ['object_name', 'created_at'] },
106+
{ fields: ['status', 'created_at'] },
107+
{ fields: ['created_by', 'created_at'] },
108+
],
109+
});

packages/rest/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@objectstack/core": "workspace:*",
23+
"@objectstack/platform-objects": "workspace:*",
2324
"@objectstack/service-package": "workspace:*",
2425
"@objectstack/spec": "workspace:*",
2526
"exceljs": "^4.4.0",

0 commit comments

Comments
 (0)