@object-ui/data-objectstack 11.2.0 → 11.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/index.cjs +299 -46
- package/dist/index.d.cts +76 -2
- package/dist/index.d.ts +76 -2
- package/dist/index.js +299 -46
- package/package.json +4 -4
- package/src/exportDownload.test.ts +93 -0
- package/src/getObjectSchema.test.ts +36 -0
- package/src/importJob.test.ts +71 -0
- package/src/index.ts +312 -9
- package/src/metadata-client.test.ts +30 -0
- package/src/metadata-client.ts +37 -7
- package/src/onMutation.test.ts +125 -0
- package/src/userState.test.ts +53 -0
- package/src/userState.ts +64 -37
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { ObjectStackAdapter } from './index';
|
|
11
|
+
|
|
12
|
+
/** Build an adapter whose fetch is a spy returning the given Response. */
|
|
13
|
+
function makeDS(fetchImpl: any) {
|
|
14
|
+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
15
|
+
ds.connected = true;
|
|
16
|
+
ds.connectionState = 'connected';
|
|
17
|
+
return ds;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function csvResponse(body = 'ID,Name\n1,Acme') {
|
|
21
|
+
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/csv' } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ObjectStackAdapter.exportDownload', () => {
|
|
25
|
+
it('GETs the /export route with format, fields, orderby, header and limit', async () => {
|
|
26
|
+
const fetchImpl = vi.fn(async () => csvResponse());
|
|
27
|
+
const ds = makeDS(fetchImpl);
|
|
28
|
+
|
|
29
|
+
const blob = await ds.exportDownload('task', {
|
|
30
|
+
format: 'xlsx',
|
|
31
|
+
fields: ['title', 'owner'],
|
|
32
|
+
sort: [{ field: 'title', direction: 'desc' }, { field: 'owner' }],
|
|
33
|
+
includeHeaders: false,
|
|
34
|
+
limit: 5000,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
38
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
39
|
+
const [url, init] = fetchImpl.mock.calls[0];
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
expect(parsed.pathname).toBe('/api/v1/data/task/export');
|
|
42
|
+
expect(parsed.searchParams.get('format')).toBe('xlsx');
|
|
43
|
+
expect(parsed.searchParams.get('fields')).toBe('title,owner');
|
|
44
|
+
// direction defaults to asc when omitted.
|
|
45
|
+
expect(parsed.searchParams.get('orderby')).toBe('title:desc,owner:asc');
|
|
46
|
+
expect(parsed.searchParams.get('header')).toBe('false');
|
|
47
|
+
expect(parsed.searchParams.get('limit')).toBe('5000');
|
|
48
|
+
expect(init.method).toBe('GET');
|
|
49
|
+
expect(init.credentials).toBe('include');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('defaults to csv and omits optional params when not provided', async () => {
|
|
53
|
+
const fetchImpl = vi.fn(async () => csvResponse());
|
|
54
|
+
const ds = makeDS(fetchImpl);
|
|
55
|
+
|
|
56
|
+
await ds.exportDownload('task', {});
|
|
57
|
+
|
|
58
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
expect(parsed.searchParams.get('format')).toBe('csv');
|
|
61
|
+
expect(parsed.searchParams.get('fields')).toBeNull();
|
|
62
|
+
expect(parsed.searchParams.get('orderby')).toBeNull();
|
|
63
|
+
expect(parsed.searchParams.get('header')).toBeNull();
|
|
64
|
+
expect(parsed.searchParams.get('limit')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('serializes the filter to a JSON AST query param', async () => {
|
|
68
|
+
const fetchImpl = vi.fn(async () => csvResponse());
|
|
69
|
+
const ds = makeDS(fetchImpl);
|
|
70
|
+
|
|
71
|
+
await ds.exportDownload('task', { filter: [['status', '=', 'open']] });
|
|
72
|
+
|
|
73
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
74
|
+
const raw = new URL(url).searchParams.get('filter');
|
|
75
|
+
expect(raw).toBeTruthy();
|
|
76
|
+
expect(JSON.parse(raw!)).toEqual([['status', '=', 'open']]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws an error carrying the server message and status on failure', async () => {
|
|
80
|
+
const fetchImpl = vi.fn(async () =>
|
|
81
|
+
new Response(JSON.stringify({ error: { message: 'Permission denied' } }), {
|
|
82
|
+
status: 403,
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
const ds = makeDS(fetchImpl);
|
|
87
|
+
|
|
88
|
+
await expect(ds.exportDownload('task', { format: 'csv' })).rejects.toMatchObject({
|
|
89
|
+
message: 'Permission denied',
|
|
90
|
+
status: 403,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -74,4 +74,40 @@ describe('ObjectStackAdapter.getObjectSchema', () => {
|
|
|
74
74
|
expect(schema.name).toBe('account');
|
|
75
75
|
expect(Object.keys(schema.fields)).toEqual(['x']);
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
|
|
79
|
+
// Inert-metadata regression guard. The inline select editor filters its
|
|
80
|
+
// options by the object's `state_machine` validation (objectui#2110). That
|
|
81
|
+
// feature only works because `getObjectSchema` passes the served
|
|
82
|
+
// `validations` through untouched — the server enforces the same rule. If a
|
|
83
|
+
// future change here started stripping or reshaping top-level metadata, the
|
|
84
|
+
// filter would silently no-op (valid-but-inert) with no error. Pin the
|
|
85
|
+
// pass-through so the data path can't go dark unnoticed.
|
|
86
|
+
const { fetchImpl } = makeFetch({
|
|
87
|
+
name: 'showcase_task',
|
|
88
|
+
fields: { status: { type: 'select', options: [{ value: 'done', label: 'Done' }] } },
|
|
89
|
+
validations: [
|
|
90
|
+
{
|
|
91
|
+
type: 'state_machine',
|
|
92
|
+
field: 'status',
|
|
93
|
+
transitions: { in_review: ['done', 'in_progress'], done: ['in_progress'] },
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
const adapter = new ObjectStackAdapter({
|
|
98
|
+
baseUrl: 'http://localhost:3000',
|
|
99
|
+
autoReconnect: false,
|
|
100
|
+
fetch: fetchImpl as any,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const schema: any = await adapter.getObjectSchema('showcase_task');
|
|
104
|
+
|
|
105
|
+
const sm = (schema.validations ?? []).find((v: any) => v?.type === 'state_machine');
|
|
106
|
+
expect(sm).toBeTruthy();
|
|
107
|
+
expect(sm.field).toBe('status');
|
|
108
|
+
// The exact map the consumer filters on — `done` only transitions to
|
|
109
|
+
// `in_progress` (so the editor must not offer `in_review`).
|
|
110
|
+
expect(sm.transitions.done).toEqual(['in_progress']);
|
|
111
|
+
expect(sm.transitions.in_review).toEqual(['done', 'in_progress']);
|
|
112
|
+
});
|
|
77
113
|
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { ObjectStackAdapter } from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapter over a mock client `data` namespace. The async import-job methods are
|
|
14
|
+
* thin pass-throughs to the client SDK, so we assert delegation + argument
|
|
15
|
+
* shaping, plus graceful degradation when the client lacks the job API.
|
|
16
|
+
*/
|
|
17
|
+
function makeDS(stub: Record<string, any>) {
|
|
18
|
+
const ds: any = new ObjectStackAdapter({
|
|
19
|
+
baseUrl: 'http://test.local',
|
|
20
|
+
fetch: vi.fn(async () => new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: { 'Content-Type': 'application/json' },
|
|
23
|
+
})),
|
|
24
|
+
});
|
|
25
|
+
ds.connected = true;
|
|
26
|
+
ds.connectionState = 'connected';
|
|
27
|
+
ds.client = { data: stub };
|
|
28
|
+
return ds;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('ObjectStackAdapter async import jobs', () => {
|
|
32
|
+
it('createImportJob delegates to client.data.createImportJob', async () => {
|
|
33
|
+
const createImportJob = vi.fn().mockResolvedValue({ jobId: 'imp_1', object: 'task', status: 'pending', total: 2 });
|
|
34
|
+
const ds = makeDS({ createImportJob });
|
|
35
|
+
const req = { format: 'json' as const, rows: [{ a: 1 }, { a: 2 }], writeMode: 'upsert' as const, matchFields: ['a'] };
|
|
36
|
+
const res = await ds.createImportJob('task', req);
|
|
37
|
+
expect(createImportJob).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(createImportJob.mock.calls[0]).toEqual(['task', req]);
|
|
39
|
+
expect(res).toMatchObject({ jobId: 'imp_1', status: 'pending', total: 2 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('getImportJobProgress / getImportJobResults / cancelImportJob delegate by jobId', async () => {
|
|
43
|
+
const getImportJobProgress = vi.fn().mockResolvedValue({ jobId: 'imp_1', status: 'running', percentComplete: 50 });
|
|
44
|
+
const getImportJobResults = vi.fn().mockResolvedValue({ jobId: 'imp_1', status: 'succeeded', results: [], resultsTruncated: false });
|
|
45
|
+
const cancelImportJob = vi.fn().mockResolvedValue({ success: true });
|
|
46
|
+
const ds = makeDS({ createImportJob: vi.fn(), getImportJobProgress, getImportJobResults, cancelImportJob });
|
|
47
|
+
|
|
48
|
+
expect((await ds.getImportJobProgress('imp_1')).percentComplete).toBe(50);
|
|
49
|
+
expect(getImportJobProgress).toHaveBeenCalledWith('imp_1');
|
|
50
|
+
|
|
51
|
+
expect((await ds.getImportJobResults('imp_1')).resultsTruncated).toBe(false);
|
|
52
|
+
expect(getImportJobResults).toHaveBeenCalledWith('imp_1');
|
|
53
|
+
|
|
54
|
+
await ds.cancelImportJob('imp_1');
|
|
55
|
+
expect(cancelImportJob).toHaveBeenCalledWith('imp_1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('listImportJobs forwards filters and returns the jobs array', async () => {
|
|
59
|
+
const listImportJobs = vi.fn().mockResolvedValue([{ jobId: 'imp_1', object: 'task', status: 'succeeded' }]);
|
|
60
|
+
const ds = makeDS({ createImportJob: vi.fn(), listImportJobs });
|
|
61
|
+
const jobs = await ds.listImportJobs({ object: 'task', status: 'succeeded', limit: 10 });
|
|
62
|
+
expect(listImportJobs).toHaveBeenCalledWith({ object: 'task', status: 'succeeded', limit: 10 });
|
|
63
|
+
expect(jobs).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws UNSUPPORTED_OPERATION when the client lacks the job API', async () => {
|
|
67
|
+
const ds = makeDS({ import: vi.fn() }); // sync import only, no createImportJob
|
|
68
|
+
await expect(ds.createImportJob('task', { format: 'json', rows: [] })).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' });
|
|
69
|
+
await expect(ds.getImportJobProgress('imp_1')).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' });
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,22 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
DataSource,
|
|
12
|
+
MutationEvent,
|
|
13
|
+
QueryParams,
|
|
14
|
+
QueryResult,
|
|
15
|
+
FileUploadResult,
|
|
16
|
+
ExportDownloadRequest,
|
|
17
|
+
ImportRequestOptions,
|
|
18
|
+
ImportRecordsResult,
|
|
19
|
+
CreateImportJobResult,
|
|
20
|
+
ImportJobProgressInfo,
|
|
21
|
+
ImportJobResultsInfo,
|
|
22
|
+
ImportJobSummaryInfo,
|
|
23
|
+
ImportJobUndoResult,
|
|
24
|
+
ListImportJobsOptions,
|
|
25
|
+
} from '@object-ui/types';
|
|
11
26
|
import { convertFiltersToAST } from '@object-ui/core';
|
|
12
27
|
import { MetadataCache } from './cache/MetadataCache';
|
|
13
28
|
import {
|
|
@@ -389,6 +404,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
389
404
|
// so optional collections like sys_presence don't hammer the server with
|
|
390
405
|
// failing requests on every record open / panel render.
|
|
391
406
|
private missingResources = new Set<string>();
|
|
407
|
+
// Subscribers registered via onMutation(). Emitted after each successful
|
|
408
|
+
// create/update/delete so data-bound views (ListView, ObjectView, kanban,
|
|
409
|
+
// calendar) auto-refresh — the interface ListView relies on to reflect
|
|
410
|
+
// inline-edit "Save All" writes without a manual reload.
|
|
411
|
+
private mutationListeners = new Set<(event: MutationEvent<T>) => void>();
|
|
392
412
|
|
|
393
413
|
constructor(config: {
|
|
394
414
|
baseUrl: string;
|
|
@@ -692,9 +712,37 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
692
712
|
/**
|
|
693
713
|
* Create a new record.
|
|
694
714
|
*/
|
|
715
|
+
/**
|
|
716
|
+
* Notify all mutation subscribers. A throwing listener must not break the
|
|
717
|
+
* mutation or starve the other subscribers, so each is isolated.
|
|
718
|
+
*/
|
|
719
|
+
private emitMutation(event: MutationEvent<T>): void {
|
|
720
|
+
for (const listener of this.mutationListeners) {
|
|
721
|
+
try {
|
|
722
|
+
listener(event);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
console.warn('ObjectStackAdapter: mutation listener error', err);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Subscribe to create/update/delete events on any resource. Returns an
|
|
731
|
+
* unsubscribe function. Data-bound views use this to auto-refresh after a
|
|
732
|
+
* mutation (e.g. inline-edit "Save All", which writes through `update` and
|
|
733
|
+
* must repaint the list without a manual reload).
|
|
734
|
+
*/
|
|
735
|
+
onMutation(callback: (event: MutationEvent<T>) => void): () => void {
|
|
736
|
+
this.mutationListeners.add(callback);
|
|
737
|
+
return () => {
|
|
738
|
+
this.mutationListeners.delete(callback);
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
695
742
|
async create(resource: string, data: Partial<T>): Promise<T> {
|
|
696
743
|
await this.connect();
|
|
697
744
|
const result = await this.client.data.create<T>(resource, data);
|
|
745
|
+
this.emitMutation({ type: 'create', resource, record: { ...result.record } });
|
|
698
746
|
return result.record;
|
|
699
747
|
}
|
|
700
748
|
|
|
@@ -723,6 +771,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
723
771
|
data,
|
|
724
772
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
725
773
|
);
|
|
774
|
+
this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
|
|
726
775
|
return result.record;
|
|
727
776
|
} catch (err) {
|
|
728
777
|
throw normaliseClientError(err);
|
|
@@ -748,6 +797,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
748
797
|
String(id),
|
|
749
798
|
opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
|
|
750
799
|
);
|
|
800
|
+
if (result.deleted) {
|
|
801
|
+
this.emitMutation({ type: 'delete', resource, id });
|
|
802
|
+
}
|
|
751
803
|
return result.deleted;
|
|
752
804
|
} catch (err) {
|
|
753
805
|
throw normaliseClientError(err);
|
|
@@ -778,6 +830,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
778
830
|
if (!ids || ids.length === 0) return 0;
|
|
779
831
|
const records = ids.map((id) => ({ id: String(id), data: patch as any }));
|
|
780
832
|
|
|
833
|
+
// Notify subscribers once for the whole batch (not per-id) so a single
|
|
834
|
+
// "mark all read"/"archive selected" refreshes bound views exactly once.
|
|
835
|
+
const emitBulk = (count: number): number => {
|
|
836
|
+
if (count > 0) this.emitMutation({ type: 'update', resource });
|
|
837
|
+
return count;
|
|
838
|
+
};
|
|
839
|
+
|
|
781
840
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
782
841
|
const updateMany = (this.client.data as any).updateMany;
|
|
783
842
|
if (typeof updateMany === 'function') {
|
|
@@ -786,10 +845,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
786
845
|
// The server returns BatchUpdateResponse { succeeded, failed, ... };
|
|
787
846
|
// fall back to ids.length on adapters that return a bare array.
|
|
788
847
|
if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
|
|
789
|
-
return (res as any).succeeded as number;
|
|
848
|
+
return emitBulk((res as any).succeeded as number);
|
|
790
849
|
}
|
|
791
|
-
if (Array.isArray(res)) return (res as any[]).length;
|
|
792
|
-
return ids.length;
|
|
850
|
+
if (Array.isArray(res)) return emitBulk((res as any[]).length);
|
|
851
|
+
return emitBulk(ids.length);
|
|
793
852
|
} catch (err) {
|
|
794
853
|
throw normaliseClientError(err);
|
|
795
854
|
}
|
|
@@ -805,7 +864,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
805
864
|
// continueOnError semantics — swallow per-row errors
|
|
806
865
|
}
|
|
807
866
|
}
|
|
808
|
-
return succeeded;
|
|
867
|
+
return emitBulk(succeeded);
|
|
809
868
|
}
|
|
810
869
|
|
|
811
870
|
/**
|
|
@@ -823,17 +882,23 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
823
882
|
if (!ids || ids.length === 0) return 0;
|
|
824
883
|
const strIds = ids.map((id) => String(id));
|
|
825
884
|
|
|
885
|
+
// Notify subscribers once for the whole batch (see bulkUpdate).
|
|
886
|
+
const emitBulk = (count: number): number => {
|
|
887
|
+
if (count > 0) this.emitMutation({ type: 'delete', resource });
|
|
888
|
+
return count;
|
|
889
|
+
};
|
|
890
|
+
|
|
826
891
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
827
892
|
const deleteMany = (this.client.data as any).deleteMany;
|
|
828
893
|
if (typeof deleteMany === 'function') {
|
|
829
894
|
try {
|
|
830
895
|
const res = await deleteMany(resource, strIds, { continueOnError: true });
|
|
831
896
|
if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
|
|
832
|
-
return (res as any).succeeded as number;
|
|
897
|
+
return emitBulk((res as any).succeeded as number);
|
|
833
898
|
}
|
|
834
|
-
if (Array.isArray(res)) return (res as any[]).length;
|
|
899
|
+
if (Array.isArray(res)) return emitBulk((res as any[]).length);
|
|
835
900
|
// deleteMany historically returns void on success — assume all hit.
|
|
836
|
-
return strIds.length;
|
|
901
|
+
return emitBulk(strIds.length);
|
|
837
902
|
} catch (err) {
|
|
838
903
|
throw normaliseClientError(err);
|
|
839
904
|
}
|
|
@@ -849,7 +914,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
849
914
|
// continueOnError semantics — swallow per-row errors
|
|
850
915
|
}
|
|
851
916
|
}
|
|
852
|
-
return succeeded;
|
|
917
|
+
return emitBulk(succeeded);
|
|
853
918
|
}
|
|
854
919
|
|
|
855
920
|
/**
|
|
@@ -1047,6 +1112,183 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1047
1112
|
}
|
|
1048
1113
|
}
|
|
1049
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* Bulk-import raw spreadsheet rows in a single server round-trip via
|
|
1117
|
+
* `POST /api/v1/data/:object/import`. The server performs all value coercion
|
|
1118
|
+
* (booleans, numbers, dates→ISO, select label→code, lookup name→id) from the
|
|
1119
|
+
* object's field metadata, so this method forwards the request verbatim and
|
|
1120
|
+
* returns the aggregate + per-row result untouched.
|
|
1121
|
+
*
|
|
1122
|
+
* Requires `@objectstack/client` with `data.import` (server `/import` route).
|
|
1123
|
+
* Callers should feature-detect (`typeof dataSource.importRecords`) and fall
|
|
1124
|
+
* back to a per-row `create` loop when unavailable.
|
|
1125
|
+
*/
|
|
1126
|
+
async importRecords(
|
|
1127
|
+
resource: string,
|
|
1128
|
+
request: ImportRequestOptions,
|
|
1129
|
+
): Promise<ImportRecordsResult> {
|
|
1130
|
+
await this.connect();
|
|
1131
|
+
const importFn = (this.client.data as { import?: unknown }).import;
|
|
1132
|
+
if (typeof importFn !== 'function') {
|
|
1133
|
+
throw new ObjectStackError(
|
|
1134
|
+
'The connected @objectstack/client does not support data.import(). ' +
|
|
1135
|
+
'Upgrade the client, or import via a per-row create fallback.',
|
|
1136
|
+
'UNSUPPORTED_OPERATION',
|
|
1137
|
+
400,
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
const result = await (importFn as (
|
|
1142
|
+
object: string,
|
|
1143
|
+
req: ImportRequestOptions,
|
|
1144
|
+
) => Promise<ImportRecordsResult>).call(this.client.data, resource, request);
|
|
1145
|
+
return result;
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
throw normaliseClientError(err);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Feature-detect the async import-job API on the connected client. Older
|
|
1153
|
+
* clients/servers lack these routes; callers fall back to {@link importRecords}.
|
|
1154
|
+
*/
|
|
1155
|
+
private importJobApi(): {
|
|
1156
|
+
createImportJob: (object: string, req: ImportRequestOptions) => Promise<CreateImportJobResult>;
|
|
1157
|
+
getImportJobProgress: (jobId: string) => Promise<ImportJobProgressInfo>;
|
|
1158
|
+
getImportJobResults: (jobId: string) => Promise<ImportJobResultsInfo>;
|
|
1159
|
+
listImportJobs: (query: ListImportJobsOptions) => Promise<ImportJobSummaryInfo[]>;
|
|
1160
|
+
cancelImportJob: (jobId: string) => Promise<{ success: boolean }>;
|
|
1161
|
+
undoImportJob: (jobId: string) => Promise<ImportJobUndoResult>;
|
|
1162
|
+
} | undefined {
|
|
1163
|
+
const d = this.client.data as Record<string, unknown>;
|
|
1164
|
+
if (typeof d.createImportJob !== 'function') return undefined;
|
|
1165
|
+
return d as any;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Start an asynchronous import job — the large-file counterpart to
|
|
1170
|
+
* {@link importRecords}. Posts the whole payload once; the server processes
|
|
1171
|
+
* rows in the background. Requires an `@objectstack/client` new enough to
|
|
1172
|
+
* expose `data.createImportJob` (server `/import/jobs` route). Callers should
|
|
1173
|
+
* feature-detect (`typeof dataSource.createImportJob`) and fall back to the
|
|
1174
|
+
* synchronous path when unavailable.
|
|
1175
|
+
*/
|
|
1176
|
+
async createImportJob(
|
|
1177
|
+
resource: string,
|
|
1178
|
+
request: ImportRequestOptions,
|
|
1179
|
+
): Promise<CreateImportJobResult> {
|
|
1180
|
+
await this.connect();
|
|
1181
|
+
const api = this.importJobApi();
|
|
1182
|
+
if (!api) {
|
|
1183
|
+
throw new ObjectStackError(
|
|
1184
|
+
'The connected @objectstack/client does not support async import jobs (data.createImportJob). ' +
|
|
1185
|
+
'Upgrade the client, or use the synchronous importRecords() path.',
|
|
1186
|
+
'UNSUPPORTED_OPERATION',
|
|
1187
|
+
400,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
return await api.createImportJob.call(this.client.data, resource, request);
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
throw normaliseClientError(err);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Poll an import job's progress. Requires {@link createImportJob} support. */
|
|
1198
|
+
async getImportJobProgress(jobId: string): Promise<ImportJobProgressInfo> {
|
|
1199
|
+
await this.connect();
|
|
1200
|
+
const api = this.importJobApi();
|
|
1201
|
+
if (!api) {
|
|
1202
|
+
throw new ObjectStackError(
|
|
1203
|
+
'The connected @objectstack/client does not support async import jobs.',
|
|
1204
|
+
'UNSUPPORTED_OPERATION',
|
|
1205
|
+
400,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
try {
|
|
1209
|
+
return await api.getImportJobProgress.call(this.client.data, jobId);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
throw normaliseClientError(err);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** Fetch an import job's capped per-row results. */
|
|
1216
|
+
async getImportJobResults(jobId: string): Promise<ImportJobResultsInfo> {
|
|
1217
|
+
await this.connect();
|
|
1218
|
+
const api = this.importJobApi();
|
|
1219
|
+
if (!api) {
|
|
1220
|
+
throw new ObjectStackError(
|
|
1221
|
+
'The connected @objectstack/client does not support async import jobs.',
|
|
1222
|
+
'UNSUPPORTED_OPERATION',
|
|
1223
|
+
400,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
return await api.getImportJobResults.call(this.client.data, jobId);
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
throw normaliseClientError(err);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** List recent import jobs (history), newest first. */
|
|
1234
|
+
async listImportJobs(options: ListImportJobsOptions = {}): Promise<ImportJobSummaryInfo[]> {
|
|
1235
|
+
await this.connect();
|
|
1236
|
+
const api = this.importJobApi();
|
|
1237
|
+
if (!api) {
|
|
1238
|
+
throw new ObjectStackError(
|
|
1239
|
+
'The connected @objectstack/client does not support async import jobs.',
|
|
1240
|
+
'UNSUPPORTED_OPERATION',
|
|
1241
|
+
400,
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
try {
|
|
1245
|
+
return await api.listImportJobs.call(this.client.data, options);
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
throw normaliseClientError(err);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** Cancel a pending/running import job (cooperative). */
|
|
1252
|
+
async cancelImportJob(jobId: string): Promise<void> {
|
|
1253
|
+
await this.connect();
|
|
1254
|
+
const api = this.importJobApi();
|
|
1255
|
+
if (!api) {
|
|
1256
|
+
throw new ObjectStackError(
|
|
1257
|
+
'The connected @objectstack/client does not support async import jobs.',
|
|
1258
|
+
'UNSUPPORTED_OPERATION',
|
|
1259
|
+
400,
|
|
1260
|
+
);
|
|
1261
|
+
}
|
|
1262
|
+
try {
|
|
1263
|
+
await api.cancelImportJob.call(this.client.data, jobId);
|
|
1264
|
+
} catch (err) {
|
|
1265
|
+
throw normaliseClientError(err);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* Logically roll back a finished import job — delete the records it created
|
|
1271
|
+
* and restore the records it updated to their pre-import values. Requires an
|
|
1272
|
+
* `@objectstack/client` new enough to expose `data.undoImportJob`, and a job
|
|
1273
|
+
* the server captured an undo log for (see {@link ImportJobProgressInfo.undoable}).
|
|
1274
|
+
*/
|
|
1275
|
+
async undoImportJob(jobId: string): Promise<ImportJobUndoResult> {
|
|
1276
|
+
await this.connect();
|
|
1277
|
+
const api = this.importJobApi();
|
|
1278
|
+
if (!api || typeof (api as { undoImportJob?: unknown }).undoImportJob !== 'function') {
|
|
1279
|
+
throw new ObjectStackError(
|
|
1280
|
+
'The connected @objectstack/client does not support undoing import jobs (data.undoImportJob).',
|
|
1281
|
+
'UNSUPPORTED_OPERATION',
|
|
1282
|
+
400,
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
try {
|
|
1286
|
+
return await api.undoImportJob.call(this.client.data, jobId);
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
throw normaliseClientError(err);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1050
1292
|
/**
|
|
1051
1293
|
* Normalize the result from data.find() or data.query() into a consistent QueryResult.
|
|
1052
1294
|
*/
|
|
@@ -1182,6 +1424,66 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
|
|
|
1182
1424
|
return body;
|
|
1183
1425
|
}
|
|
1184
1426
|
|
|
1427
|
+
/**
|
|
1428
|
+
* Synchronously download a server-streamed export (csv / json / xlsx).
|
|
1429
|
+
*
|
|
1430
|
+
* Hits `GET /api/v1/data/:object/export`, which streams matching rows in the
|
|
1431
|
+
* requested format, formats values for readability (lookup → name, select →
|
|
1432
|
+
* label, boolean → 是/否, dates formatted) and enforces permissions. The
|
|
1433
|
+
* filter / sort are translated the same way as `rawFindWithPopulate` so the
|
|
1434
|
+
* exported file mirrors the active list view. Returns the file as a Blob;
|
|
1435
|
+
* the caller triggers the browser download.
|
|
1436
|
+
*/
|
|
1437
|
+
async exportDownload(resource: string, request: ExportDownloadRequest = {}): Promise<Blob> {
|
|
1438
|
+
const queryParams = new URLSearchParams();
|
|
1439
|
+
|
|
1440
|
+
const format = request.format === 'xlsx' ? 'xlsx' : request.format === 'json' ? 'json' : 'csv';
|
|
1441
|
+
queryParams.set('format', format);
|
|
1442
|
+
|
|
1443
|
+
if (request.fields && request.fields.length > 0) {
|
|
1444
|
+
queryParams.set('fields', request.fields.join(','));
|
|
1445
|
+
}
|
|
1446
|
+
if (request.limit && request.limit > 0) {
|
|
1447
|
+
queryParams.set('limit', String(request.limit));
|
|
1448
|
+
}
|
|
1449
|
+
if (request.includeHeaders === false) {
|
|
1450
|
+
queryParams.set('header', 'false');
|
|
1451
|
+
}
|
|
1452
|
+
// Sort → server `orderby` shorthand: "field:dir,field2:dir".
|
|
1453
|
+
if (request.sort && request.sort.length > 0) {
|
|
1454
|
+
const orderby = request.sort
|
|
1455
|
+
.filter(s => s && s.field)
|
|
1456
|
+
.map(s => `${s.field}:${s.direction === 'desc' ? 'desc' : 'asc'}`)
|
|
1457
|
+
.join(',');
|
|
1458
|
+
if (orderby) queryParams.set('orderby', orderby);
|
|
1459
|
+
}
|
|
1460
|
+
// Filter → AST tuples, same translation the list GET path uses.
|
|
1461
|
+
if (request.filter !== undefined && request.filter !== null) {
|
|
1462
|
+
const translated = translateFilterToAST(request.filter);
|
|
1463
|
+
if (translated !== undefined) {
|
|
1464
|
+
queryParams.set('filter', JSON.stringify(translated));
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const baseUrl = this.baseUrl.replace(/\/$/, '');
|
|
1469
|
+
// Avoid doubling /api/v1 if baseUrl already includes the version suffix.
|
|
1470
|
+
const hasApiVersionSuffix = /\/api\/v\d+$/i.test(baseUrl);
|
|
1471
|
+
const dataPath = hasApiVersionSuffix ? '/data' : '/api/v1/data';
|
|
1472
|
+
const url = `${baseUrl}${dataPath}/${encodeURIComponent(resource)}/export?${queryParams.toString()}`;
|
|
1473
|
+
|
|
1474
|
+
const headers: Record<string, string> = { ...this.getAuthHeaders() };
|
|
1475
|
+
// `credentials: 'include'` carries the session cookie for the browser
|
|
1476
|
+
// console (which authenticates by cookie, not a bearer token).
|
|
1477
|
+
const res = await this.fetchImpl(url, { method: 'GET', headers, credentials: 'include' });
|
|
1478
|
+
if (!res.ok) {
|
|
1479
|
+
const errorBody = await res.json().catch(() => ({ message: res.statusText }));
|
|
1480
|
+
const err = new Error(errorBody?.error?.message || errorBody?.message || res.statusText) as any;
|
|
1481
|
+
err.status = res.status;
|
|
1482
|
+
throw err;
|
|
1483
|
+
}
|
|
1484
|
+
return await res.blob();
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1185
1487
|
/**
|
|
1186
1488
|
* Convert ObjectUI QueryParams to ObjectStack QueryOptions.
|
|
1187
1489
|
* Maps OData-style conventions to ObjectStack conventions.
|
|
@@ -2334,6 +2636,7 @@ export type {
|
|
|
2334
2636
|
MetadataDeleteOptions,
|
|
2335
2637
|
MetadataHistoryOptions,
|
|
2336
2638
|
MetadataError,
|
|
2639
|
+
MetadataValidationIssue,
|
|
2337
2640
|
MetadataLayered,
|
|
2338
2641
|
MetadataReference,
|
|
2339
2642
|
MetadataDiagnostics,
|
|
@@ -122,6 +122,36 @@ describe('MetadataClient', () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it('extracts message, code + field-anchored issues from the dispatcher error shape', async () => {
|
|
126
|
+
// The dispatcher sends `error` as an OBJECT { message, code, details:{ code, issues } }.
|
|
127
|
+
// Reading `.message` (not String(object) → "[object Object]") and surfacing
|
|
128
|
+
// `details.issues` is what lets the Studio point at the offending field.
|
|
129
|
+
const c = new MetadataClient({
|
|
130
|
+
baseUrl: 'http://localhost:3000',
|
|
131
|
+
fetch: mockFetch(async () =>
|
|
132
|
+
new Response(
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
success: false,
|
|
135
|
+
error: {
|
|
136
|
+
message: '[invalid_metadata] object/bad failed spec validation: label: Required',
|
|
137
|
+
code: 422,
|
|
138
|
+
details: {
|
|
139
|
+
code: 'invalid_metadata',
|
|
140
|
+
issues: [{ path: 'label', message: 'Required', code: 'invalid_type' }],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
{ status: 422, headers: { 'content-type': 'application/json' } },
|
|
145
|
+
)),
|
|
146
|
+
});
|
|
147
|
+
await expect(c.save('object', 'bad', {})).rejects.toMatchObject({
|
|
148
|
+
status: 422,
|
|
149
|
+
code: 'invalid_metadata',
|
|
150
|
+
message: '[invalid_metadata] object/bad failed spec validation: label: Required',
|
|
151
|
+
issues: [{ path: 'label', message: 'Required', code: 'invalid_type' }],
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
125
155
|
it('listDrafts requests /meta/_drafts with packageId + type and parses {drafts}', async () => {
|
|
126
156
|
const seen: string[] = [];
|
|
127
157
|
const c = new MetadataClient({
|