@object-ui/data-objectstack 16.1.0 → 17.2.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 +638 -1
- package/README.md +9 -4
- package/dist/index.cjs +417 -91
- package/dist/index.d.cts +294 -21
- package/dist/index.d.ts +294 -21
- package/dist/index.js +412 -90
- package/package.json +5 -4
- package/src/aggregate-capability.test.ts +177 -0
- package/src/cache/MetadataCache.ts +12 -3
- package/src/errors.test.ts +12 -12
- package/src/errors.ts +23 -5
- package/src/exportDownload.test.ts +42 -0
- package/src/filter-entry-translation.test.ts +360 -0
- package/src/filter-operator-ast-parity.test.ts +71 -0
- package/src/index.ts +736 -120
- package/src/metadata-client.ts +17 -3
- package/src/onMutation.test.ts +35 -35
- package/src/onWriteWarning.test.ts +172 -0
- package/src/orderby-serialization.test.ts +115 -0
- package/src/queryDataset.test.ts +50 -1
- package/src/searchAll.test.ts +120 -0
- package/src/security.ts +18 -5
- package/src/spec-symbol-batch6.test.ts +303 -0
- package/src/userState.test.ts +25 -4
- package/src/userState.ts +10 -6
- package/src/validation-error.test.ts +94 -0
package/src/metadata-client.ts
CHANGED
|
@@ -75,7 +75,21 @@ export interface MetadataDraftHeader {
|
|
|
75
75
|
updatedBy: string | null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Options for {@link MetadataClient.save} — a WRITE OVER HTTP to
|
|
80
|
+
* `/api/v1/meta/:type/:name`.
|
|
81
|
+
*
|
|
82
|
+
* NOT the spec's `MetadataSaveOptions` (`@objectstack/spec/system` and
|
|
83
|
+
* `/kernel`), whose name this interface wore until objectui#3160
|
|
84
|
+
* (objectstack#4115 ledger batch 6). Both spec copies describe writing a
|
|
85
|
+
* metadata item to a FILE — `format: json|yaml|ts`, `path`, `indent`,
|
|
86
|
+
* `prettify`, `sortKeys`, `backup`, `atomic`, `loader`. Not one of those keys
|
|
87
|
+
* exists here, and not one of these exists there: this is the REST client's
|
|
88
|
+
* request envelope — optimistic concurrency (`ifMatch` → `If-Match`), actor
|
|
89
|
+
* attribution, the destructive-change override, the ADR-0033 draft/publish
|
|
90
|
+
* mode, and the owning package. Same words, different layer.
|
|
91
|
+
*/
|
|
92
|
+
export interface MetadataClientSaveOptions {
|
|
79
93
|
/**
|
|
80
94
|
* Optimistic concurrency token (the `checksum` returned by the last
|
|
81
95
|
* read). When present, sent as the `If-Match` header so concurrent
|
|
@@ -122,7 +136,7 @@ export interface MetadataGetOptions {
|
|
|
122
136
|
packageId?: string;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
|
-
export interface MetadataDeleteOptions extends
|
|
139
|
+
export interface MetadataDeleteOptions extends MetadataClientSaveOptions {
|
|
126
140
|
/**
|
|
127
141
|
* Target state. `'draft'` discards the pending draft (keeps the
|
|
128
142
|
* published overlay intact). Omit to reset the active overlay back
|
|
@@ -527,7 +541,7 @@ export class MetadataClient {
|
|
|
527
541
|
type: string,
|
|
528
542
|
name: string,
|
|
529
543
|
item: unknown,
|
|
530
|
-
options:
|
|
544
|
+
options: MetadataClientSaveOptions = {},
|
|
531
545
|
): Promise<T> {
|
|
532
546
|
if (!name || !String(name).trim()) {
|
|
533
547
|
// The `PUT /meta/:type/:name` route rejects a missing `:name` segment
|
package/src/onMutation.test.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
clearSharedDiscoveryCache,
|
|
24
24
|
readTransactionalBatchCapability,
|
|
25
25
|
} from './index';
|
|
26
|
-
import type {
|
|
26
|
+
import type { DataSourceMutationEvent } from '@object-ui/types';
|
|
27
27
|
|
|
28
28
|
function makeDS(stub: Record<string, any>) {
|
|
29
29
|
const ds: any = new ObjectStackAdapter({
|
|
@@ -49,8 +49,8 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
49
49
|
.mockResolvedValue({ record: { id: 'r1', account: 'acc-1' } });
|
|
50
50
|
const ds = makeDS({ update });
|
|
51
51
|
|
|
52
|
-
const events:
|
|
53
|
-
const unsub = ds.onMutation((e:
|
|
52
|
+
const events: DataSourceMutationEvent[] = [];
|
|
53
|
+
const unsub = ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
54
54
|
|
|
55
55
|
await ds.update('showcase_project', 'r1', { account: 'acc-1' });
|
|
56
56
|
|
|
@@ -67,8 +67,8 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
67
67
|
it('emits a create event with the new record', async () => {
|
|
68
68
|
const create = vi.fn().mockResolvedValue({ record: { id: 'new', name: 'X' } });
|
|
69
69
|
const ds = makeDS({ create });
|
|
70
|
-
const events:
|
|
71
|
-
ds.onMutation((e:
|
|
70
|
+
const events: DataSourceMutationEvent[] = [];
|
|
71
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
72
72
|
|
|
73
73
|
await ds.create('showcase_project', { name: 'X' });
|
|
74
74
|
|
|
@@ -83,8 +83,8 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
83
83
|
.mockResolvedValueOnce({ deleted: true })
|
|
84
84
|
.mockResolvedValueOnce({ deleted: false });
|
|
85
85
|
const ds = makeDS({ delete: del });
|
|
86
|
-
const events:
|
|
87
|
-
ds.onMutation((e:
|
|
86
|
+
const events: DataSourceMutationEvent[] = [];
|
|
87
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
88
88
|
|
|
89
89
|
await ds.delete('showcase_project', 'r1');
|
|
90
90
|
await ds.delete('showcase_project', 'r2');
|
|
@@ -97,8 +97,8 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
97
97
|
it('emits a single bulk event per bulkUpdate call (not per id)', async () => {
|
|
98
98
|
const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
|
|
99
99
|
const ds = makeDS({ updateMany });
|
|
100
|
-
const events:
|
|
101
|
-
ds.onMutation((e:
|
|
100
|
+
const events: DataSourceMutationEvent[] = [];
|
|
101
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
102
102
|
|
|
103
103
|
await ds.bulkUpdate('showcase_project', ['a', 'b', 'c'], { archived: true });
|
|
104
104
|
|
|
@@ -108,8 +108,8 @@ describe('ObjectStackAdapter.onMutation', () => {
|
|
|
108
108
|
it('does not emit when a bulk operation affected zero rows', async () => {
|
|
109
109
|
const updateMany = vi.fn().mockResolvedValue({ succeeded: 0, failed: 0, results: [] });
|
|
110
110
|
const ds = makeDS({ updateMany });
|
|
111
|
-
const events:
|
|
112
|
-
ds.onMutation((e:
|
|
111
|
+
const events: DataSourceMutationEvent[] = [];
|
|
112
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
113
113
|
|
|
114
114
|
await ds.bulkUpdate('showcase_project', ['a'], { archived: true });
|
|
115
115
|
|
|
@@ -160,8 +160,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
|
160
160
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
161
161
|
),
|
|
162
162
|
);
|
|
163
|
-
const events:
|
|
164
|
-
ds.onMutation((e:
|
|
163
|
+
const events: DataSourceMutationEvent[] = [];
|
|
164
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
165
165
|
|
|
166
166
|
await ds.batchTransaction([
|
|
167
167
|
{ object: 'ehr_task_version', action: 'create', data: { name: 'Task version A' } },
|
|
@@ -183,8 +183,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
|
183
183
|
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
184
184
|
),
|
|
185
185
|
);
|
|
186
|
-
const events:
|
|
187
|
-
ds.onMutation((e:
|
|
186
|
+
const events: DataSourceMutationEvent[] = [];
|
|
187
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
188
188
|
|
|
189
189
|
await ds.batchTransaction([
|
|
190
190
|
{ object: 'ehr_task_version', action: 'update', id: 'p1', data: { name: 'Renamed' } },
|
|
@@ -208,8 +208,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
|
208
208
|
headers: { 'Content-Type': 'application/json' },
|
|
209
209
|
}),
|
|
210
210
|
);
|
|
211
|
-
const events:
|
|
212
|
-
ds.onMutation((e:
|
|
211
|
+
const events: DataSourceMutationEvent[] = [];
|
|
212
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
213
213
|
|
|
214
214
|
await ds.batchTransaction([{ object: 'ehr_task_version', data: { name: 'X' } }]);
|
|
215
215
|
|
|
@@ -225,8 +225,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
|
225
225
|
headers: { 'Content-Type': 'application/json' },
|
|
226
226
|
}),
|
|
227
227
|
);
|
|
228
|
-
const events:
|
|
229
|
-
ds.onMutation((e:
|
|
228
|
+
const events: DataSourceMutationEvent[] = [];
|
|
229
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
230
230
|
|
|
231
231
|
await expect(
|
|
232
232
|
ds.batchTransaction([{ object: 'ehr_task_version', action: 'create', data: {} }]),
|
|
@@ -265,8 +265,8 @@ describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', (
|
|
|
265
265
|
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
266
266
|
const { ds, batchTransaction } = makeFallbackDS({ batchStatus: 404, clientData: { create } });
|
|
267
267
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
268
|
-
const events:
|
|
269
|
-
ds.onMutation((e:
|
|
268
|
+
const events: DataSourceMutationEvent[] = [];
|
|
269
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
270
270
|
|
|
271
271
|
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
272
272
|
|
|
@@ -311,8 +311,8 @@ describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', (
|
|
|
311
311
|
ds.connected = true;
|
|
312
312
|
ds.connectionState = 'connected';
|
|
313
313
|
ds.client = { data: { batchTransaction: sdkBatch } };
|
|
314
|
-
const events:
|
|
315
|
-
ds.onMutation((e:
|
|
314
|
+
const events: DataSourceMutationEvent[] = [];
|
|
315
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
316
316
|
|
|
317
317
|
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
318
318
|
|
|
@@ -335,8 +335,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
|
335
335
|
it('emits a single create event per bulk create call', async () => {
|
|
336
336
|
const createMany = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
|
|
337
337
|
const ds = makeDS({ createMany });
|
|
338
|
-
const events:
|
|
339
|
-
ds.onMutation((e:
|
|
338
|
+
const events: DataSourceMutationEvent[] = [];
|
|
339
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
340
340
|
|
|
341
341
|
await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }, { label: 'b' }]);
|
|
342
342
|
|
|
@@ -346,8 +346,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
|
346
346
|
it('does not emit when a bulk create returned zero records', async () => {
|
|
347
347
|
const createMany = vi.fn().mockResolvedValue([]);
|
|
348
348
|
const ds = makeDS({ createMany });
|
|
349
|
-
const events:
|
|
350
|
-
ds.onMutation((e:
|
|
349
|
+
const events: DataSourceMutationEvent[] = [];
|
|
350
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
351
351
|
|
|
352
352
|
await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }]);
|
|
353
353
|
|
|
@@ -357,8 +357,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
|
357
357
|
it('emits a single delete event per bulk delete call', async () => {
|
|
358
358
|
const deleteMany = vi.fn().mockResolvedValue(undefined);
|
|
359
359
|
const ds = makeDS({ deleteMany });
|
|
360
|
-
const events:
|
|
361
|
-
ds.onMutation((e:
|
|
360
|
+
const events: DataSourceMutationEvent[] = [];
|
|
361
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
362
362
|
|
|
363
363
|
await ds.bulk('ehr_task_check_item', 'delete', [{ id: 'c1' }, { id: 'c2' }] as any);
|
|
364
364
|
|
|
@@ -368,8 +368,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
|
368
368
|
it('emits a single update event per bulk update call', async () => {
|
|
369
369
|
const updateMany = vi.fn().mockResolvedValue([{ id: 'c1' }]);
|
|
370
370
|
const ds = makeDS({ updateMany });
|
|
371
|
-
const events:
|
|
372
|
-
ds.onMutation((e:
|
|
371
|
+
const events: DataSourceMutationEvent[] = [];
|
|
372
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
373
373
|
|
|
374
374
|
await ds.bulk('ehr_task_check_item', 'update', [{ id: 'c1', label: 'x' }] as any);
|
|
375
375
|
|
|
@@ -379,8 +379,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
|
379
379
|
it('bulk create failure emits nothing', async () => {
|
|
380
380
|
const createMany = vi.fn().mockRejectedValue(new Error('boom'));
|
|
381
381
|
const ds = makeDS({ createMany });
|
|
382
|
-
const events:
|
|
383
|
-
ds.onMutation((e:
|
|
382
|
+
const events: DataSourceMutationEvent[] = [];
|
|
383
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
384
384
|
|
|
385
385
|
await expect(ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }])).rejects.toThrow();
|
|
386
386
|
|
|
@@ -481,8 +481,8 @@ describe('ObjectStackAdapter.batchTransaction capability gate (#2693 / framework
|
|
|
481
481
|
batchStatus: 200,
|
|
482
482
|
batchBody: { results: [{ id: 'p1', name: 'A' }, { id: 'c1', proj: 'p1' }] },
|
|
483
483
|
});
|
|
484
|
-
const events:
|
|
485
|
-
ds.onMutation((e:
|
|
484
|
+
const events: DataSourceMutationEvent[] = [];
|
|
485
|
+
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
486
486
|
|
|
487
487
|
const res = await ds.batchTransaction([
|
|
488
488
|
{ object: 'proj', action: 'create', data: { name: 'A' } },
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* ObjectStackAdapter must surface the framework's write-observability channel
|
|
11
|
+
* (#3431/#3455): when a create/update response carries `droppedFields` — fields
|
|
12
|
+
* the backend LEGALLY stripped (read-only / locked-by-state) — the adapter emits
|
|
13
|
+
* a WriteWarningEvent so the app shell can toast the user instead of letting the
|
|
14
|
+
* dropped value vanish silently. The write itself still succeeded.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
+
import { ObjectStackAdapter } from './index';
|
|
18
|
+
import type { WriteWarningEvent } from './index';
|
|
19
|
+
|
|
20
|
+
function makeDS(stub: Record<string, any>) {
|
|
21
|
+
const ds: any = new ObjectStackAdapter({
|
|
22
|
+
baseUrl: 'http://test.local',
|
|
23
|
+
fetch: vi.fn(async () =>
|
|
24
|
+
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
});
|
|
30
|
+
ds.connected = true;
|
|
31
|
+
ds.connectionState = 'connected';
|
|
32
|
+
ds.client = { data: stub };
|
|
33
|
+
return ds;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ONE_DROP = [{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }];
|
|
37
|
+
|
|
38
|
+
describe('ObjectStackAdapter.onWriteWarning', () => {
|
|
39
|
+
it('emits a create write-warning carrying the dropped fields and the new id', async () => {
|
|
40
|
+
const create = vi.fn().mockResolvedValue({ record: { id: 'r1', title: 'A' }, droppedFields: ONE_DROP });
|
|
41
|
+
const ds = makeDS({ create });
|
|
42
|
+
const events: WriteWarningEvent[] = [];
|
|
43
|
+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
44
|
+
|
|
45
|
+
const rec = await ds.create('approval_case', { title: 'A', approval_status: 'approved' });
|
|
46
|
+
|
|
47
|
+
// The write still returns the plain record (unchanged behaviour).
|
|
48
|
+
expect(rec).toEqual({ id: 'r1', title: 'A' });
|
|
49
|
+
expect(events).toEqual([
|
|
50
|
+
{ operation: 'create', resource: 'approval_case', id: 'r1', droppedFields: ONE_DROP },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('emits an update write-warning carrying the target id', async () => {
|
|
55
|
+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
|
|
56
|
+
const ds = makeDS({ update });
|
|
57
|
+
const events: WriteWarningEvent[] = [];
|
|
58
|
+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
59
|
+
|
|
60
|
+
await ds.update('approval_case', 'r1', { approval_status: 'approved' });
|
|
61
|
+
|
|
62
|
+
expect(events).toEqual([
|
|
63
|
+
{ operation: 'update', resource: 'approval_case', id: 'r1', droppedFields: ONE_DROP },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does NOT emit when the response carries no droppedFields (the common case)', async () => {
|
|
68
|
+
const create = vi.fn().mockResolvedValue({ record: { id: 'r1', title: 'A' } });
|
|
69
|
+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' } });
|
|
70
|
+
const ds = makeDS({ create, update });
|
|
71
|
+
const events: WriteWarningEvent[] = [];
|
|
72
|
+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
73
|
+
|
|
74
|
+
await ds.create('approval_case', { title: 'A' });
|
|
75
|
+
await ds.update('approval_case', 'r1', { title: 'B' });
|
|
76
|
+
|
|
77
|
+
expect(events).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('does NOT emit for an empty or malformed droppedFields payload', async () => {
|
|
81
|
+
const create = vi
|
|
82
|
+
.fn()
|
|
83
|
+
.mockResolvedValueOnce({ record: { id: 'r1' }, droppedFields: [] })
|
|
84
|
+
.mockResolvedValueOnce({ record: { id: 'r2' }, droppedFields: 'nope' })
|
|
85
|
+
.mockResolvedValueOnce({ record: { id: 'r3' }, droppedFields: [{ object: 'x', fields: [] }] });
|
|
86
|
+
const ds = makeDS({ create });
|
|
87
|
+
const events: WriteWarningEvent[] = [];
|
|
88
|
+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
89
|
+
|
|
90
|
+
await ds.create('x', {});
|
|
91
|
+
await ds.create('x', {});
|
|
92
|
+
await ds.create('x', {}); // event present but its fields[] is empty → filtered out
|
|
93
|
+
|
|
94
|
+
expect(events).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── cross-object batch (framework #3794) ─────────────────────────────────
|
|
98
|
+
//
|
|
99
|
+
// `batchTransaction` is the console record form's save path for a
|
|
100
|
+
// master-detail record, so this is where a `readonlyWhen` strip actually
|
|
101
|
+
// reaches a user editing a form. Its response tags each event with the index
|
|
102
|
+
// of the operation that produced it.
|
|
103
|
+
|
|
104
|
+
it('emits a write-warning per event on a cross-object batch, resolving the op', async () => {
|
|
105
|
+
const batchTransaction = vi.fn().mockResolvedValue({
|
|
106
|
+
results: [{ id: 'acc1' }, { id: 'inv1' }],
|
|
107
|
+
droppedFields: [
|
|
108
|
+
{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
const ds = makeDS({ batchTransaction });
|
|
112
|
+
ds.atomicBatchCapability = true;
|
|
113
|
+
const events: WriteWarningEvent[] = [];
|
|
114
|
+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
115
|
+
|
|
116
|
+
await ds.batchTransaction([
|
|
117
|
+
{ object: 'account', action: 'create', data: { name: 'Acme' } },
|
|
118
|
+
{ object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
expect(events).toEqual([
|
|
122
|
+
{
|
|
123
|
+
operation: 'update',
|
|
124
|
+
resource: 'invoice',
|
|
125
|
+
id: 'inv1',
|
|
126
|
+
droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('does NOT emit for a clean batch or a malformed droppedFields list', async () => {
|
|
132
|
+
const batchTransaction = vi
|
|
133
|
+
.fn()
|
|
134
|
+
.mockResolvedValueOnce({ results: [{ id: 'a' }] })
|
|
135
|
+
.mockResolvedValueOnce({ results: [{ id: 'a' }], droppedFields: [{ object: 'x', fields: [], index: 0 }] });
|
|
136
|
+
const ds = makeDS({ batchTransaction });
|
|
137
|
+
ds.atomicBatchCapability = true;
|
|
138
|
+
const events: WriteWarningEvent[] = [];
|
|
139
|
+
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
140
|
+
|
|
141
|
+
await ds.batchTransaction([{ object: 'x', action: 'create', data: {} }]);
|
|
142
|
+
await ds.batchTransaction([{ object: 'x', action: 'create', data: {} }]);
|
|
143
|
+
|
|
144
|
+
expect(events).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('stops delivering after unsubscribe', async () => {
|
|
148
|
+
const create = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
|
|
149
|
+
const ds = makeDS({ create });
|
|
150
|
+
const events: WriteWarningEvent[] = [];
|
|
151
|
+
const unsub = ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
152
|
+
|
|
153
|
+
await ds.create('approval_case', {});
|
|
154
|
+
unsub();
|
|
155
|
+
await ds.create('approval_case', {});
|
|
156
|
+
|
|
157
|
+
expect(events).toHaveLength(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('isolates a throwing listener so the write still resolves and other listeners fire', async () => {
|
|
161
|
+
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
|
|
162
|
+
const ds = makeDS({ update });
|
|
163
|
+
const good = vi.fn();
|
|
164
|
+
ds.onWriteWarning(() => { throw new Error('boom'); });
|
|
165
|
+
ds.onWriteWarning(good);
|
|
166
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
167
|
+
|
|
168
|
+
await expect(ds.update('approval_case', 'r1', { approval_status: 'x' })).resolves.toBeTruthy();
|
|
169
|
+
expect(good).toHaveBeenCalledTimes(1);
|
|
170
|
+
warn.mockRestore();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* Every shape `QueryParams['$orderby']` declares reaches the wire as a sort.
|
|
11
|
+
*
|
|
12
|
+
* `find()` has TWO routes to the server — a plain read goes through
|
|
13
|
+
* `convertQueryParams` and the client SDK, a read with `$expand`/`$search`
|
|
14
|
+
* through `rawFindWithPopulate` — and each used to carry its own copy of the
|
|
15
|
+
* "which `$orderby` shape is this?" fold. Both copies handled three of the four
|
|
16
|
+
* declared shapes; both missed the bare string, and missed it the same
|
|
17
|
+
* spectacular way: the string fell into the `Record<field, direction>` branch,
|
|
18
|
+
* where `Object.entries('name asc')` enumerates character indices and the
|
|
19
|
+
* request went out as `sort=0,1,2,3,4,5,6,7`.
|
|
20
|
+
*
|
|
21
|
+
* That is not a cosmetic mistranslation. Since objectstack#4226 the server
|
|
22
|
+
* refuses a sort it cannot read (`400 INVALID_SORT`) rather than dropping it,
|
|
23
|
+
* so the whole list fails to load — and `"${field} ${order}"` is exactly what a
|
|
24
|
+
* standalone `ObjectGrid` builds from its view metadata's `sort`.
|
|
25
|
+
*
|
|
26
|
+
* Both routes are asserted for every shape, because "the two routes agree" is
|
|
27
|
+
* the property that was actually missing; one serializer now backs both.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
31
|
+
import { ObjectStackAdapter, clearSharedDiscoveryCache, serializeOrderBy } from './index';
|
|
32
|
+
|
|
33
|
+
function makeAdapter() {
|
|
34
|
+
const calls: string[] = [];
|
|
35
|
+
const fetchImpl = vi.fn(async (url: any) => {
|
|
36
|
+
const u = String(url);
|
|
37
|
+
calls.push(u);
|
|
38
|
+
if (u.includes('/api/v1/discovery')) {
|
|
39
|
+
return {
|
|
40
|
+
ok: true, status: 200, statusText: 'OK',
|
|
41
|
+
json: async () => ({ success: true, data: { version: 'v1', routes: {} } }),
|
|
42
|
+
} as any;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ok: true, status: 200, statusText: 'OK',
|
|
46
|
+
json: async () => ({ success: true, data: { object: 'account', records: [], total: 0 } }),
|
|
47
|
+
} as any;
|
|
48
|
+
});
|
|
49
|
+
const adapter = new ObjectStackAdapter({
|
|
50
|
+
baseUrl: 'http://localhost:3000', token: 't', autoReconnect: false, fetch: fetchImpl as any,
|
|
51
|
+
});
|
|
52
|
+
return { adapter, calls };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The `sort=` this `$orderby` produced on the wire, or `undefined` if none was sent. */
|
|
56
|
+
async function sortOnWire($orderby: unknown, route: 'plain' | 'expand'): Promise<string | undefined> {
|
|
57
|
+
const { adapter, calls } = makeAdapter();
|
|
58
|
+
await adapter.find('account', {
|
|
59
|
+
$orderby,
|
|
60
|
+
...(route === 'expand' ? { $expand: ['owner'] } : {}),
|
|
61
|
+
} as any);
|
|
62
|
+
const dataCall = calls.filter((u) => u.includes('/data/account')).pop();
|
|
63
|
+
const raw = dataCall ? new URL(dataCall).searchParams.get('sort') : null;
|
|
64
|
+
return raw === null ? undefined : raw;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Run one input down both `find()` routes and assert they agree. */
|
|
68
|
+
function bothRoutes(name: string, $orderby: unknown, expected: string | undefined) {
|
|
69
|
+
for (const route of ['plain', 'expand'] as const) {
|
|
70
|
+
it(`${name} (${route} route)`, async () => {
|
|
71
|
+
expect(await sortOnWire($orderby, route)).toBe(expected);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('$orderby reaches the wire for every declared shape', () => {
|
|
77
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
78
|
+
|
|
79
|
+
// The regression: ObjectGrid's own `"${field} ${order}"`.
|
|
80
|
+
bothRoutes('a bare "field order" string', 'name asc', 'name asc');
|
|
81
|
+
bothRoutes('a bare string with a descending key', 'created_at desc', 'created_at desc');
|
|
82
|
+
bothRoutes('a bare string already in `-field` shorthand', '-created_at', '-created_at');
|
|
83
|
+
bothRoutes('a bare multi-key string', 'status asc,-created_at', 'status asc,-created_at');
|
|
84
|
+
|
|
85
|
+
bothRoutes('a SortNode[]', [{ field: 'name', order: 'asc' }], 'name');
|
|
86
|
+
bothRoutes(
|
|
87
|
+
'a SortNode[] with a descending key',
|
|
88
|
+
[{ field: 'status', order: 'asc' }, { field: 'created_at', order: 'desc' }],
|
|
89
|
+
'status,-created_at',
|
|
90
|
+
);
|
|
91
|
+
bothRoutes('a string[]', ['name', '-age'], 'name,-age');
|
|
92
|
+
bothRoutes('a Record<field, direction>', { name: 'asc', age: 'desc' }, 'name,-age');
|
|
93
|
+
|
|
94
|
+
bothRoutes('no sort at all', undefined, undefined);
|
|
95
|
+
bothRoutes('an empty array', [], undefined);
|
|
96
|
+
bothRoutes('an empty object', {}, undefined);
|
|
97
|
+
bothRoutes('a whitespace-only string', ' ', undefined);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('serializeOrderBy', () => {
|
|
101
|
+
it('never enumerates a string as an object — the defect this replaced', () => {
|
|
102
|
+
// `Object.entries('name asc')` → [['0','n'],['1','a'],…]. The old fold
|
|
103
|
+
// reached that branch for every string input.
|
|
104
|
+
expect(serializeOrderBy('name asc')).toBe('name asc');
|
|
105
|
+
expect(serializeOrderBy('name asc')).not.toMatch(/^0,1,2/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reads `order` case-insensitively', () => {
|
|
109
|
+
expect(serializeOrderBy([{ field: 'name', order: 'DESC' as any }])).toBe('-name');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('defaults a missing direction to ascending', () => {
|
|
113
|
+
expect(serializeOrderBy([{ field: 'name' }])).toBe('name');
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/queryDataset.test.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ObjectStackAdapter,
|
|
12
|
+
clearSharedDiscoveryCache,
|
|
13
|
+
isAnalyticsNotInstalledError,
|
|
14
|
+
} from './index';
|
|
11
15
|
|
|
12
16
|
/** A fetch mock that answers discovery + records the dataset-query POST. */
|
|
13
17
|
function makeFetch(datasetResponse: { ok: boolean; status?: number; body: unknown }) {
|
|
@@ -80,4 +84,49 @@ describe('ObjectStackAdapter.queryDataset', () => {
|
|
|
80
84
|
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
81
85
|
await expect(adapter.queryDataset(inlineDataset as any, selection)).rejects.toThrow(/relationship not declared/);
|
|
82
86
|
});
|
|
87
|
+
|
|
88
|
+
// framework#3891 retired the degraded in-kernel analytics fallback, so a
|
|
89
|
+
// deployment without @objectstack/service-analytics answers 501 (REST route
|
|
90
|
+
// present, no service) or 404 (framework#4019 stopped mounting the routes).
|
|
91
|
+
// Neither is an authoring mistake — the caller gets a typed, readable error
|
|
92
|
+
// instead of `Dataset query failed: 501 Not Implemented — …`.
|
|
93
|
+
describe('analytics capability absent', () => {
|
|
94
|
+
it('maps 501 NOT_IMPLEMENTED to a readable ANALYTICS_NOT_INSTALLED error', async () => {
|
|
95
|
+
const { fetchImpl } = makeFetch({
|
|
96
|
+
ok: false,
|
|
97
|
+
status: 501,
|
|
98
|
+
body: {
|
|
99
|
+
code: 'NOT_IMPLEMENTED',
|
|
100
|
+
message: 'Analytics dataset query is not available on this deployment (no analytics service with queryDataset).',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
104
|
+
|
|
105
|
+
const err = await adapter.queryDataset(inlineDataset as any, selection).catch((e) => e);
|
|
106
|
+
|
|
107
|
+
expect(isAnalyticsNotInstalledError(err)).toBe(true);
|
|
108
|
+
expect(err.code).toBe('ANALYTICS_NOT_INSTALLED');
|
|
109
|
+
expect(err.message).toContain('Analytics capability is not installed');
|
|
110
|
+
expect(err.message).toContain('@objectstack/service-analytics');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('maps a 404 (routes not mounted) the same way', async () => {
|
|
114
|
+
const { fetchImpl } = makeFetch({ ok: false, status: 404, body: { error: 'Not found' } });
|
|
115
|
+
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
116
|
+
|
|
117
|
+
const err = await adapter.queryDataset(inlineDataset as any, selection).catch((e) => e);
|
|
118
|
+
|
|
119
|
+
expect(isAnalyticsNotInstalledError(err)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('leaves a real compile error alone (it is NOT a missing capability)', async () => {
|
|
123
|
+
const { fetchImpl } = makeFetch({ ok: false, status: 400, body: { message: 'relationship not declared in include' } });
|
|
124
|
+
const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
|
|
125
|
+
|
|
126
|
+
const err = await adapter.queryDataset(inlineDataset as any, selection).catch((e) => e);
|
|
127
|
+
|
|
128
|
+
expect(isAnalyticsNotInstalledError(err)).toBe(false);
|
|
129
|
+
expect(String(err.message)).toContain('relationship not declared');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
83
132
|
});
|