Skip to content

Commit fc65eb4

Browse files
committed
feat(client): async import-job methods on both data namespaces
Add createImportJob / getImportJobProgress / getImportJobResults / listImportJobs / cancelImportJob to the top-level and scoped `data` namespaces, mirroring the async REST routes. Large payloads are posted once via createImportJob (returns a jobId immediately); callers poll progress / results / history and can cancel. Older servers return 404 → rejected promise, so consumers can feature-detect and fall back to the synchronous `import`. 5 client tests assert route/method/query-string shaping and response unwrapping.
1 parent 004ce58 commit fc65eb4

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

packages/client/src/client.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1051,3 +1051,51 @@ 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+
});

packages/client/src/index.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ import {
8484
ApiRoutes,
8585
ImportRequest,
8686
ImportResponse,
87+
CreateImportJobRequest,
88+
CreateImportJobResponse,
89+
ImportJobProgress,
90+
ImportJobResults,
91+
ImportJobSummary,
92+
ListImportJobsRequest,
93+
ListImportJobsResponse,
8794
} from '@objectstack/spec/api';
8895
import type {
8996
ApprovalRequestRow,
@@ -3067,6 +3074,62 @@ export class ObjectStackClient {
30673074
return this.unwrapResponse<ImportResponse>(res);
30683075
},
30693076

3077+
/**
3078+
* Import-job namespace — the asynchronous counterpart to {@link import} for
3079+
* large files (up to 50,000 rows). `createImportJob` posts the whole payload
3080+
* once and returns immediately with a `jobId`; a server worker processes the
3081+
* batch in the background. Poll {@link getImportJobProgress} for live
3082+
* counters, {@link getImportJobResults} for the capped per-row report, and
3083+
* {@link listImportJobs} for history. {@link cancelImportJob} stops a
3084+
* pending/running job cooperatively.
3085+
*
3086+
* These routes require a server new enough to expose them — older servers
3087+
* return 404, which surfaces here as a rejected promise. Callers that want
3088+
* graceful degradation should feature-detect (e.g. try the job, fall back
3089+
* to the synchronous {@link import} on 404).
3090+
*/
3091+
createImportJob: async (object: string, request: CreateImportJobRequest): Promise<CreateImportJobResponse> => {
3092+
const route = this.getRoute('data');
3093+
const res = await this.fetch(`${this.baseUrl}${route}/${object}/import/jobs`, {
3094+
method: 'POST',
3095+
body: JSON.stringify(request),
3096+
});
3097+
return this.unwrapResponse<CreateImportJobResponse>(res);
3098+
},
3099+
3100+
getImportJobProgress: async (jobId: string): Promise<ImportJobProgress> => {
3101+
const route = this.getRoute('data');
3102+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}`);
3103+
return this.unwrapResponse<ImportJobProgress>(res);
3104+
},
3105+
3106+
getImportJobResults: async (jobId: string): Promise<ImportJobResults> => {
3107+
const route = this.getRoute('data');
3108+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}/results`);
3109+
return this.unwrapResponse<ImportJobResults>(res);
3110+
},
3111+
3112+
listImportJobs: async (query: Partial<ListImportJobsRequest> = {}): Promise<ImportJobSummary[]> => {
3113+
const route = this.getRoute('data');
3114+
const qs = new URLSearchParams();
3115+
if (query.object) qs.set('object', query.object);
3116+
if (query.status) qs.set('status', query.status);
3117+
if (query.limit != null) qs.set('limit', String(query.limit));
3118+
if (query.offset != null) qs.set('offset', String(query.offset));
3119+
const suffix = qs.toString() ? `?${qs.toString()}` : '';
3120+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs${suffix}`);
3121+
const body = await this.unwrapResponse<ListImportJobsResponse>(res);
3122+
return body.jobs;
3123+
},
3124+
3125+
cancelImportJob: async (jobId: string): Promise<{ success: boolean }> => {
3126+
const route = this.getRoute('data');
3127+
const res = await this.fetch(`${this.baseUrl}${route}/import/jobs/${encodeURIComponent(jobId)}/cancel`, {
3128+
method: 'POST',
3129+
});
3130+
return this.unwrapResponse<{ success: boolean }>(res);
3131+
},
3132+
30703133
update: async <T = any>(
30713134
object: string,
30723135
id: string,
@@ -3492,6 +3555,43 @@ export class ScopedProjectClient {
34923555
});
34933556
return this.parent._unwrap<ImportResponse>(res);
34943557
},
3558+
/**
3559+
* Asynchronous import jobs (scoped) — see the top-level `data.createImportJob`
3560+
* for semantics. Large payloads are posted once; a server worker processes
3561+
* them in the background while callers poll progress / results / history.
3562+
*/
3563+
createImportJob: async (object: string, request: CreateImportJobRequest): Promise<CreateImportJobResponse> => {
3564+
const res = await this.parent._fetch(this.url(`/data/${object}/import/jobs`), {
3565+
method: 'POST',
3566+
body: JSON.stringify(request),
3567+
});
3568+
return this.parent._unwrap<CreateImportJobResponse>(res);
3569+
},
3570+
getImportJobProgress: async (jobId: string): Promise<ImportJobProgress> => {
3571+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}`));
3572+
return this.parent._unwrap<ImportJobProgress>(res);
3573+
},
3574+
getImportJobResults: async (jobId: string): Promise<ImportJobResults> => {
3575+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/results`));
3576+
return this.parent._unwrap<ImportJobResults>(res);
3577+
},
3578+
listImportJobs: async (query: Partial<ListImportJobsRequest> = {}): Promise<ImportJobSummary[]> => {
3579+
const qs = new URLSearchParams();
3580+
if (query.object) qs.set('object', query.object);
3581+
if (query.status) qs.set('status', query.status);
3582+
if (query.limit != null) qs.set('limit', String(query.limit));
3583+
if (query.offset != null) qs.set('offset', String(query.offset));
3584+
const suffix = qs.toString() ? `?${qs.toString()}` : '';
3585+
const res = await this.parent._fetch(this.url(`/data/import/jobs${suffix}`));
3586+
const body = await this.parent._unwrap<ListImportJobsResponse>(res);
3587+
return body.jobs;
3588+
},
3589+
cancelImportJob: async (jobId: string): Promise<{ success: boolean }> => {
3590+
const res = await this.parent._fetch(this.url(`/data/import/jobs/${encodeURIComponent(jobId)}/cancel`), {
3591+
method: 'POST',
3592+
});
3593+
return this.parent._unwrap<{ success: boolean }>(res);
3594+
},
34953595
update: async <T = any>(object: string, id: string, data: Partial<T>): Promise<UpdateDataResult<T>> => {
34963596
const res = await this.parent._fetch(this.url(`/data/${object}/${id}`), {
34973597
method: 'PATCH',
@@ -3674,6 +3774,13 @@ export type {
36743774
AuthProviderInfo,
36753775
EmailPasswordConfigPublic,
36763776
AuthFeaturesConfig,
3777+
CreateImportJobRequest,
3778+
CreateImportJobResponse,
3779+
ImportJobProgress,
3780+
ImportJobResults,
3781+
ImportJobSummary,
3782+
ListImportJobsRequest,
3783+
ListImportJobsResponse,
36773784
} from '@objectstack/spec/api';
36783785

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

0 commit comments

Comments
 (0)