@object-ui/data-objectstack 17.5.0 → 17.7.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 +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/onMutation.test.ts
DELETED
|
@@ -1,589 +0,0 @@
|
|
|
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
|
-
* Regression: ObjectStackAdapter must implement `onMutation` and emit an event
|
|
11
|
-
* after every successful create/update/delete (and bulk variant).
|
|
12
|
-
*
|
|
13
|
-
* Real-world symptom (framework app-showcase): inline-edit a grid cell, click
|
|
14
|
-
* "全部保存" (Save All) — the write persisted but the list did NOT auto-refresh.
|
|
15
|
-
* ListView auto-refreshes by subscribing to `dataSource.onMutation`; the
|
|
16
|
-
* ObjectStack adapter never implemented it, so the per-row `update` writes
|
|
17
|
-
* issued by ObjectGrid's default batch-save were silent and the grid kept
|
|
18
|
-
* showing stale rows until a manual reload.
|
|
19
|
-
*/
|
|
20
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
21
|
-
import {
|
|
22
|
-
ObjectStackAdapter,
|
|
23
|
-
clearSharedDiscoveryCache,
|
|
24
|
-
readTransactionalBatchCapability,
|
|
25
|
-
} from './index';
|
|
26
|
-
import type { DataSourceMutationEvent } from '@object-ui/types';
|
|
27
|
-
|
|
28
|
-
function makeDS(stub: Record<string, any>) {
|
|
29
|
-
const ds: any = new ObjectStackAdapter({
|
|
30
|
-
baseUrl: 'http://test.local',
|
|
31
|
-
fetch: vi.fn(async () => {
|
|
32
|
-
const body = { success: true, data: { capabilities: {}, routes: {} } };
|
|
33
|
-
return new Response(JSON.stringify(body), {
|
|
34
|
-
status: 200,
|
|
35
|
-
headers: { 'Content-Type': 'application/json' },
|
|
36
|
-
});
|
|
37
|
-
}),
|
|
38
|
-
});
|
|
39
|
-
ds.connected = true;
|
|
40
|
-
ds.connectionState = 'connected';
|
|
41
|
-
ds.client = { data: stub };
|
|
42
|
-
return ds;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
describe('ObjectStackAdapter.onMutation', () => {
|
|
46
|
-
it('emits an update event after a successful update (the inline-edit save path)', async () => {
|
|
47
|
-
const update = vi
|
|
48
|
-
.fn()
|
|
49
|
-
.mockResolvedValue({ record: { id: 'r1', account: 'acc-1' } });
|
|
50
|
-
const ds = makeDS({ update });
|
|
51
|
-
|
|
52
|
-
const events: DataSourceMutationEvent[] = [];
|
|
53
|
-
const unsub = ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
54
|
-
|
|
55
|
-
await ds.update('showcase_project', 'r1', { account: 'acc-1' });
|
|
56
|
-
|
|
57
|
-
expect(events).toEqual([
|
|
58
|
-
{ type: 'update', resource: 'showcase_project', id: 'r1', record: { id: 'r1', account: 'acc-1' } },
|
|
59
|
-
]);
|
|
60
|
-
|
|
61
|
-
// Unsubscribe stops delivery.
|
|
62
|
-
unsub();
|
|
63
|
-
await ds.update('showcase_project', 'r1', { account: 'acc-2' });
|
|
64
|
-
expect(events).toHaveLength(1);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('emits a create event with the new record', async () => {
|
|
68
|
-
const create = vi.fn().mockResolvedValue({ record: { id: 'new', name: 'X' } });
|
|
69
|
-
const ds = makeDS({ create });
|
|
70
|
-
const events: DataSourceMutationEvent[] = [];
|
|
71
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
72
|
-
|
|
73
|
-
await ds.create('showcase_project', { name: 'X' });
|
|
74
|
-
|
|
75
|
-
expect(events).toEqual([
|
|
76
|
-
{ type: 'create', resource: 'showcase_project', record: { id: 'new', name: 'X' } },
|
|
77
|
-
]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('emits a delete event only when the server confirms deletion', async () => {
|
|
81
|
-
// `success`, not `deleted` (objectstack#5638). This mock is why the bug it
|
|
82
|
-
// fixed was invisible here: `DeleteDataResult` DECLARED a `deleted` key that
|
|
83
|
-
// no schema has ever declared and no server path has ever returned, and this
|
|
84
|
-
// fixture faithfully reproduced the declaration rather than the wire. So the
|
|
85
|
-
// adapter read `result.deleted`, got `undefined` against a real server —
|
|
86
|
-
// emitting no event and resolving `undefined` from a `Promise<boolean>` —
|
|
87
|
-
// while this test stayed green against the fiction. Pinning the shape the
|
|
88
|
-
// server actually sends is what makes the assertion mean anything.
|
|
89
|
-
const del = vi
|
|
90
|
-
.fn()
|
|
91
|
-
.mockResolvedValueOnce({ object: 'showcase_project', id: 'r1', success: true })
|
|
92
|
-
.mockResolvedValueOnce({ object: 'showcase_project', id: 'r2', success: false });
|
|
93
|
-
const ds = makeDS({ delete: del });
|
|
94
|
-
const events: DataSourceMutationEvent[] = [];
|
|
95
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
96
|
-
|
|
97
|
-
await ds.delete('showcase_project', 'r1');
|
|
98
|
-
await ds.delete('showcase_project', 'r2');
|
|
99
|
-
|
|
100
|
-
expect(events).toEqual([
|
|
101
|
-
{ type: 'delete', resource: 'showcase_project', id: 'r1' },
|
|
102
|
-
]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// REVERSE PIN for objectui#3412 / objectstack#5638. The bug was not that the
|
|
106
|
-
// guard was missing — it was that the guard read a key the wire never carries,
|
|
107
|
-
// so it was dead while looking alive. A response spelling the old `deleted`
|
|
108
|
-
// must therefore emit NOTHING: if someone re-introduces a tolerant
|
|
109
|
-
// `result.success ?? result.deleted` read, this goes red. Reading both keys is
|
|
110
|
-
// exactly what contract-first forbids (AGENTS.md #0.1) and what objectstack#5638's
|
|
111
|
-
// own triage ruled out — the producer has one shape, so the consumer gets one.
|
|
112
|
-
it('emits nothing for a response carrying the retired `deleted` key', async () => {
|
|
113
|
-
const del = vi.fn().mockResolvedValue({ object: 'showcase_project', id: 'r1', deleted: true });
|
|
114
|
-
const ds = makeDS({ delete: del });
|
|
115
|
-
const events: DataSourceMutationEvent[] = [];
|
|
116
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
117
|
-
|
|
118
|
-
const returned = await ds.delete('showcase_project', 'r1');
|
|
119
|
-
|
|
120
|
-
expect(events).toEqual([]);
|
|
121
|
-
expect(returned).toBeUndefined();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('emits a single bulk event per bulkUpdate call (not per id)', async () => {
|
|
125
|
-
const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
|
|
126
|
-
const ds = makeDS({ updateMany });
|
|
127
|
-
const events: DataSourceMutationEvent[] = [];
|
|
128
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
129
|
-
|
|
130
|
-
await ds.bulkUpdate('showcase_project', ['a', 'b', 'c'], { archived: true });
|
|
131
|
-
|
|
132
|
-
expect(events).toEqual([{ type: 'update', resource: 'showcase_project' }]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('does not emit when a bulk operation affected zero rows', async () => {
|
|
136
|
-
const updateMany = vi.fn().mockResolvedValue({ succeeded: 0, failed: 0, results: [] });
|
|
137
|
-
const ds = makeDS({ updateMany });
|
|
138
|
-
const events: DataSourceMutationEvent[] = [];
|
|
139
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
140
|
-
|
|
141
|
-
await ds.bulkUpdate('showcase_project', ['a'], { archived: true });
|
|
142
|
-
|
|
143
|
-
expect(events).toEqual([]);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('isolates a throwing listener so the mutation still resolves and others fire', async () => {
|
|
147
|
-
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' } });
|
|
148
|
-
const ds = makeDS({ update });
|
|
149
|
-
const good = vi.fn();
|
|
150
|
-
ds.onMutation(() => { throw new Error('boom'); });
|
|
151
|
-
ds.onMutation(good);
|
|
152
|
-
|
|
153
|
-
await expect(ds.update('showcase_project', 'r1', { x: 1 })).resolves.toBeTruthy();
|
|
154
|
-
expect(good).toHaveBeenCalledTimes(1);
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Regression for #2582: a ModalForm over an object WITH subforms delegates to
|
|
160
|
-
* MasterDetailForm, which persists parent + child rows through ONE
|
|
161
|
-
* `batchTransaction` (`POST /api/v1/batch`). That path never emitted
|
|
162
|
-
* MutationEvents, so the related list and the "相关" tab count badge stayed
|
|
163
|
-
* stale after a successful create/edit until a full page reload. The adapter
|
|
164
|
-
* must emit one event per committed operation — and nothing when the
|
|
165
|
-
* transaction failed (it rolled back entirely).
|
|
166
|
-
*/
|
|
167
|
-
describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
168
|
-
function makeBatchDS(responder: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
|
|
169
|
-
const ds: any = new ObjectStackAdapter({
|
|
170
|
-
baseUrl: 'http://test.local',
|
|
171
|
-
fetch: vi.fn(responder),
|
|
172
|
-
});
|
|
173
|
-
ds.connected = true;
|
|
174
|
-
ds.connectionState = 'connected';
|
|
175
|
-
return ds;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
it('emits one event per op after a committed master-detail CREATE batch', async () => {
|
|
179
|
-
const ds = makeBatchDS(async () =>
|
|
180
|
-
new Response(
|
|
181
|
-
JSON.stringify({
|
|
182
|
-
results: [
|
|
183
|
-
{ id: 'p1', name: 'Task version A' },
|
|
184
|
-
{ id: 'c1', task: 'p1', label: 'Check item 1' },
|
|
185
|
-
],
|
|
186
|
-
}),
|
|
187
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
188
|
-
),
|
|
189
|
-
);
|
|
190
|
-
const events: DataSourceMutationEvent[] = [];
|
|
191
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
192
|
-
|
|
193
|
-
await ds.batchTransaction([
|
|
194
|
-
{ object: 'ehr_task_version', action: 'create', data: { name: 'Task version A' } },
|
|
195
|
-
{ object: 'ehr_task_check_item', action: 'create', data: { task: { $ref: 0 }, label: 'Check item 1' } },
|
|
196
|
-
]);
|
|
197
|
-
|
|
198
|
-
expect(events).toEqual([
|
|
199
|
-
{ type: 'create', resource: 'ehr_task_version', record: { id: 'p1', name: 'Task version A' } },
|
|
200
|
-
{ type: 'create', resource: 'ehr_task_check_item', record: { id: 'c1', task: 'p1', label: 'Check item 1' } },
|
|
201
|
-
]);
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('emits update/create/delete events after a committed master-detail EDIT batch', async () => {
|
|
205
|
-
const ds = makeBatchDS(async () =>
|
|
206
|
-
new Response(
|
|
207
|
-
JSON.stringify({
|
|
208
|
-
results: [{ id: 'p1' }, { id: 'c-new' }, { id: 'c1' }, { deleted: true }],
|
|
209
|
-
}),
|
|
210
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
211
|
-
),
|
|
212
|
-
);
|
|
213
|
-
const events: DataSourceMutationEvent[] = [];
|
|
214
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
215
|
-
|
|
216
|
-
await ds.batchTransaction([
|
|
217
|
-
{ object: 'ehr_task_version', action: 'update', id: 'p1', data: { name: 'Renamed' } },
|
|
218
|
-
{ object: 'ehr_task_check_item', action: 'create', data: { task: 'p1' } },
|
|
219
|
-
{ object: 'ehr_task_check_item', action: 'update', id: 'c1', data: { label: 'Edited' } },
|
|
220
|
-
{ object: 'ehr_task_check_item', action: 'delete', id: 'c2' },
|
|
221
|
-
]);
|
|
222
|
-
|
|
223
|
-
expect(events).toEqual([
|
|
224
|
-
{ type: 'update', resource: 'ehr_task_version', id: 'p1', record: { id: 'p1' } },
|
|
225
|
-
{ type: 'create', resource: 'ehr_task_check_item', record: { id: 'c-new' } },
|
|
226
|
-
{ type: 'update', resource: 'ehr_task_check_item', id: 'c1', record: { id: 'c1' } },
|
|
227
|
-
{ type: 'delete', resource: 'ehr_task_check_item', id: 'c2' },
|
|
228
|
-
]);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it('treats an op without an explicit action as a create (the server default)', async () => {
|
|
232
|
-
const ds = makeBatchDS(async () =>
|
|
233
|
-
new Response(JSON.stringify({ results: [{ id: 'p1' }] }), {
|
|
234
|
-
status: 200,
|
|
235
|
-
headers: { 'Content-Type': 'application/json' },
|
|
236
|
-
}),
|
|
237
|
-
);
|
|
238
|
-
const events: DataSourceMutationEvent[] = [];
|
|
239
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
240
|
-
|
|
241
|
-
await ds.batchTransaction([{ object: 'ehr_task_version', data: { name: 'X' } }]);
|
|
242
|
-
|
|
243
|
-
expect(events).toEqual([
|
|
244
|
-
{ type: 'create', resource: 'ehr_task_version', record: { id: 'p1' } },
|
|
245
|
-
]);
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it('emits NOTHING when the batch failed — the transaction rolled back', async () => {
|
|
249
|
-
const ds = makeBatchDS(async () =>
|
|
250
|
-
new Response(JSON.stringify({ error: 'validation failed' }), {
|
|
251
|
-
status: 422,
|
|
252
|
-
headers: { 'Content-Type': 'application/json' },
|
|
253
|
-
}),
|
|
254
|
-
);
|
|
255
|
-
const events: DataSourceMutationEvent[] = [];
|
|
256
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
257
|
-
|
|
258
|
-
await expect(
|
|
259
|
-
ds.batchTransaction([{ object: 'ehr_task_version', action: 'create', data: {} }]),
|
|
260
|
-
).rejects.toThrow('validation failed');
|
|
261
|
-
|
|
262
|
-
expect(events).toEqual([]);
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* #2679 / #2694: cross-object saves go through the SDK's
|
|
268
|
-
* `client.data.batchTransaction` (no hand-rolled POST /api/v1/batch). When the
|
|
269
|
-
* SDK reports this backend can't do a transactional batch — it throws an error
|
|
270
|
-
* carrying HTTP 404/405 (endpoint missing) or 501 (runtime without
|
|
271
|
-
* transactions) — the adapter degrades to a client-side, NON-atomic emulation
|
|
272
|
-
* instead of failing the save. This is the isolated home of the non-atomic
|
|
273
|
-
* fallback — the form no longer carries it. Real errors (any other 4xx/5xx)
|
|
274
|
-
* must still surface, never be silently downgraded.
|
|
275
|
-
*/
|
|
276
|
-
describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', () => {
|
|
277
|
-
// The SDK's batchTransaction signals "backend can't do a transactional batch"
|
|
278
|
-
// by throwing an error decorated with the HTTP status (the @objectstack/client
|
|
279
|
-
// convention that `errorStatusOf` reads).
|
|
280
|
-
function makeFallbackDS(opts: { batchStatus: number; clientData: Record<string, any> }) {
|
|
281
|
-
const batchTransaction = vi.fn(async () => {
|
|
282
|
-
throw Object.assign(new Error('nope'), { httpStatus: opts.batchStatus });
|
|
283
|
-
});
|
|
284
|
-
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: vi.fn() });
|
|
285
|
-
ds.connected = true;
|
|
286
|
-
ds.connectionState = 'connected';
|
|
287
|
-
ds.client = { data: { batchTransaction, ...opts.clientData } };
|
|
288
|
-
return { ds, batchTransaction };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
it('404 → warns once, saves via the create primitive, and caches the detection', async () => {
|
|
292
|
-
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
293
|
-
const { ds, batchTransaction } = makeFallbackDS({ batchStatus: 404, clientData: { create } });
|
|
294
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
295
|
-
const events: DataSourceMutationEvent[] = [];
|
|
296
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
297
|
-
|
|
298
|
-
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
299
|
-
|
|
300
|
-
expect(res.results[0]).toMatchObject({ id: 'p1', name: 'A' });
|
|
301
|
-
expect(create).toHaveBeenCalledWith('proj', { name: 'A' });
|
|
302
|
-
expect(warn).toHaveBeenCalledTimes(1);
|
|
303
|
-
// Events come from the create primitive ONCE — not also re-emitted by the
|
|
304
|
-
// committed-batch path (no double emission).
|
|
305
|
-
expect(events).toEqual([{ type: 'create', resource: 'proj', record: { id: 'p1', name: 'A' } }]);
|
|
306
|
-
|
|
307
|
-
// A second batch short-circuits: the endpoint is known-unsupported, so we
|
|
308
|
-
// never call the SDK batch method again.
|
|
309
|
-
expect(batchTransaction).toHaveBeenCalledTimes(1);
|
|
310
|
-
await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'B' } }]);
|
|
311
|
-
expect(batchTransaction).toHaveBeenCalledTimes(1);
|
|
312
|
-
warn.mockRestore();
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
it('501 (runtime without transactions) also degrades to emulation', async () => {
|
|
316
|
-
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
317
|
-
const { ds } = makeFallbackDS({ batchStatus: 501, clientData: { create } });
|
|
318
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
319
|
-
await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
320
|
-
expect(create).toHaveBeenCalledTimes(1);
|
|
321
|
-
warn.mockRestore();
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
it('a real error (400) surfaces — never downgraded to emulation', async () => {
|
|
325
|
-
const create = vi.fn();
|
|
326
|
-
const { ds } = makeFallbackDS({ batchStatus: 400, clientData: { create } });
|
|
327
|
-
await expect(
|
|
328
|
-
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
329
|
-
).rejects.toThrow(/nope/);
|
|
330
|
-
// No client-side writes happened — the batch was rejected, not retried.
|
|
331
|
-
expect(create).not.toHaveBeenCalled();
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it('routes a committed batch through the SDK method and never hand-rolls a fetch', async () => {
|
|
335
|
-
const sdkBatch = vi.fn(async () => ({ results: [{ id: 'p1', name: 'A' }] }));
|
|
336
|
-
const fetchMock = vi.fn();
|
|
337
|
-
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchMock });
|
|
338
|
-
ds.connected = true;
|
|
339
|
-
ds.connectionState = 'connected';
|
|
340
|
-
ds.client = { data: { batchTransaction: sdkBatch } };
|
|
341
|
-
const events: DataSourceMutationEvent[] = [];
|
|
342
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
343
|
-
|
|
344
|
-
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
345
|
-
|
|
346
|
-
expect(sdkBatch).toHaveBeenCalledTimes(1);
|
|
347
|
-
expect(fetchMock).not.toHaveBeenCalled(); // no raw POST /api/v1/batch
|
|
348
|
-
expect(res.results[0]).toMatchObject({ id: 'p1' });
|
|
349
|
-
// Committed via the SDK → one event per op (emitted once).
|
|
350
|
-
expect(events).toEqual([{ type: 'create', resource: 'proj', record: { id: 'p1', name: 'A' } }]);
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Regression for #2582 (fallback path): when the server lacks the transactional
|
|
356
|
-
* batch endpoint, MasterDetailForm persists child rows via `bulk(child,
|
|
357
|
-
* 'create', rows)` (applyDetail → createMany). `bulk` only emitted progress
|
|
358
|
-
* events, so those writes were equally invisible to the invalidation bus. Each
|
|
359
|
-
* branch now emits ONE resource-level event, matching bulkUpdate/bulkDelete.
|
|
360
|
-
*/
|
|
361
|
-
describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
362
|
-
it('emits a single create event per bulk create call', async () => {
|
|
363
|
-
const createMany = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
|
|
364
|
-
const ds = makeDS({ createMany });
|
|
365
|
-
const events: DataSourceMutationEvent[] = [];
|
|
366
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
367
|
-
|
|
368
|
-
await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }, { label: 'b' }]);
|
|
369
|
-
|
|
370
|
-
expect(events).toEqual([{ type: 'create', resource: 'ehr_task_check_item' }]);
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
it('does not emit when a bulk create returned zero records', async () => {
|
|
374
|
-
const createMany = vi.fn().mockResolvedValue([]);
|
|
375
|
-
const ds = makeDS({ createMany });
|
|
376
|
-
const events: DataSourceMutationEvent[] = [];
|
|
377
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
378
|
-
|
|
379
|
-
await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }]);
|
|
380
|
-
|
|
381
|
-
expect(events).toEqual([]);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it('emits a single delete event per bulk delete call', async () => {
|
|
385
|
-
const deleteMany = vi.fn().mockResolvedValue(undefined);
|
|
386
|
-
const ds = makeDS({ deleteMany });
|
|
387
|
-
const events: DataSourceMutationEvent[] = [];
|
|
388
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
389
|
-
|
|
390
|
-
await ds.bulk('ehr_task_check_item', 'delete', [{ id: 'c1' }, { id: 'c2' }] as any);
|
|
391
|
-
|
|
392
|
-
expect(events).toEqual([{ type: 'delete', resource: 'ehr_task_check_item' }]);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('emits a single update event per bulk update call', async () => {
|
|
396
|
-
const updateMany = vi.fn().mockResolvedValue([{ id: 'c1' }]);
|
|
397
|
-
const ds = makeDS({ updateMany });
|
|
398
|
-
const events: DataSourceMutationEvent[] = [];
|
|
399
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
400
|
-
|
|
401
|
-
await ds.bulk('ehr_task_check_item', 'update', [{ id: 'c1', label: 'x' }] as any);
|
|
402
|
-
|
|
403
|
-
expect(events).toEqual([{ type: 'update', resource: 'ehr_task_check_item' }]);
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
it('bulk create failure emits nothing', async () => {
|
|
407
|
-
const createMany = vi.fn().mockRejectedValue(new Error('boom'));
|
|
408
|
-
const ds = makeDS({ createMany });
|
|
409
|
-
const events: DataSourceMutationEvent[] = [];
|
|
410
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
411
|
-
|
|
412
|
-
await expect(ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }])).rejects.toThrow();
|
|
413
|
-
|
|
414
|
-
expect(events).toEqual([]);
|
|
415
|
-
});
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* #2693 / framework#3298 — declarative capability negotiation for atomic batch.
|
|
420
|
-
*
|
|
421
|
-
* The adapter reads `capabilities.transactionalBatch` from discovery at
|
|
422
|
-
* connect(). When the backend DECLARES support it TRUSTS server atomicity and
|
|
423
|
-
* never degrades to the non-atomic client emulation — a batch failure (even
|
|
424
|
-
* 404/405/501) is a real error. When the capability is ABSENT (backend predates
|
|
425
|
-
* #3298) or explicitly `false`, the legacy runtime-probe + emulation fallback
|
|
426
|
-
* stays active so a save is still possible (#2679 compatibility constraint).
|
|
427
|
-
*/
|
|
428
|
-
describe('ObjectStackAdapter.batchTransaction capability gate (#2693 / framework#3298)', () => {
|
|
429
|
-
// The discovery cache is module-level and shared across files in the (unit)
|
|
430
|
-
// project — reset it so each case reads its own advertised capability.
|
|
431
|
-
beforeEach(() => clearSharedDiscoveryCache());
|
|
432
|
-
|
|
433
|
-
function makeCapabilityDS(opts: {
|
|
434
|
-
/** Value placed at `capabilities.transactionalBatch`; omit to leave it absent. */
|
|
435
|
-
capability?: boolean | { enabled: boolean };
|
|
436
|
-
batchStatus: number;
|
|
437
|
-
/** Result the SDK batch resolves with on a 2xx `batchStatus`. */
|
|
438
|
-
batchBody?: unknown;
|
|
439
|
-
clientData?: Record<string, any>;
|
|
440
|
-
}) {
|
|
441
|
-
const capabilities: Record<string, unknown> = {};
|
|
442
|
-
if (opts.capability !== undefined) capabilities.transactionalBatch = opts.capability;
|
|
443
|
-
// connect() still fetches discovery to read the #3298 capability; the batch
|
|
444
|
-
// itself now goes through the SDK method (no raw POST /api/v1/batch).
|
|
445
|
-
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
446
|
-
const url = String(input);
|
|
447
|
-
if (url.includes('/api/v1/discovery')) {
|
|
448
|
-
return new Response(
|
|
449
|
-
JSON.stringify({ success: true, data: { name: 't', version: '1.0.0', capabilities } }),
|
|
450
|
-
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
return new Response('{}', { status: 404, headers: { 'Content-Type': 'application/json' } });
|
|
454
|
-
});
|
|
455
|
-
// The SDK batch resolves on a 2xx status and otherwise throws a
|
|
456
|
-
// status-decorated error (the @objectstack/client convention `errorStatusOf`
|
|
457
|
-
// reads) — the signal the capability gate + fallback branch on.
|
|
458
|
-
const batchTransaction = vi.fn(async () => {
|
|
459
|
-
if (opts.batchStatus >= 200 && opts.batchStatus < 300) {
|
|
460
|
-
return opts.batchBody ?? { results: [] };
|
|
461
|
-
}
|
|
462
|
-
throw Object.assign(new Error('nope'), { httpStatus: opts.batchStatus });
|
|
463
|
-
});
|
|
464
|
-
const ds: any = new ObjectStackAdapter({
|
|
465
|
-
baseUrl: 'http://test.local',
|
|
466
|
-
autoReconnect: false,
|
|
467
|
-
fetch: fetchMock,
|
|
468
|
-
});
|
|
469
|
-
// SDK batch method + emulation primitives live on the stubbed client.
|
|
470
|
-
ds.client = { data: { batchTransaction, ...(opts.clientData ?? {}) } };
|
|
471
|
-
return { ds, fetchMock, batchTransaction };
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
it('declared transactionalBatch:true → a 404 is a hard error, NOT downgraded to emulation', async () => {
|
|
475
|
-
const create = vi.fn();
|
|
476
|
-
const { ds } = makeCapabilityDS({ capability: { enabled: true }, batchStatus: 404, clientData: { create } });
|
|
477
|
-
|
|
478
|
-
await expect(
|
|
479
|
-
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
480
|
-
).rejects.toThrow(/nope/);
|
|
481
|
-
// A declared-atomic backend is trusted: no client-side compensation ran.
|
|
482
|
-
expect(create).not.toHaveBeenCalled();
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it('declared transactionalBatch:true → 501 is also a hard error (no emulation)', async () => {
|
|
486
|
-
const create = vi.fn();
|
|
487
|
-
const { ds } = makeCapabilityDS({ capability: { enabled: true }, batchStatus: 501, clientData: { create } });
|
|
488
|
-
|
|
489
|
-
await expect(
|
|
490
|
-
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
491
|
-
).rejects.toThrow();
|
|
492
|
-
expect(create).not.toHaveBeenCalled();
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it('accepts the flat boolean capability shape too', async () => {
|
|
496
|
-
const create = vi.fn();
|
|
497
|
-
const { ds } = makeCapabilityDS({ capability: true, batchStatus: 404, clientData: { create } });
|
|
498
|
-
|
|
499
|
-
await expect(
|
|
500
|
-
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
501
|
-
).rejects.toThrow();
|
|
502
|
-
expect(create).not.toHaveBeenCalled();
|
|
503
|
-
});
|
|
504
|
-
|
|
505
|
-
it('declared support → a committed batch commits via /batch and emits one event per op', async () => {
|
|
506
|
-
const { ds } = makeCapabilityDS({
|
|
507
|
-
capability: { enabled: true },
|
|
508
|
-
batchStatus: 200,
|
|
509
|
-
batchBody: { results: [{ id: 'p1', name: 'A' }, { id: 'c1', proj: 'p1' }] },
|
|
510
|
-
});
|
|
511
|
-
const events: DataSourceMutationEvent[] = [];
|
|
512
|
-
ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
|
|
513
|
-
|
|
514
|
-
const res = await ds.batchTransaction([
|
|
515
|
-
{ object: 'proj', action: 'create', data: { name: 'A' } },
|
|
516
|
-
{ object: 'item', action: 'create', data: { proj: { $ref: 0 } } },
|
|
517
|
-
]);
|
|
518
|
-
|
|
519
|
-
expect(res.results).toHaveLength(2);
|
|
520
|
-
expect(events).toEqual([
|
|
521
|
-
{ type: 'create', resource: 'proj', record: { id: 'p1', name: 'A' } },
|
|
522
|
-
{ type: 'create', resource: 'item', record: { id: 'c1', proj: 'p1' } },
|
|
523
|
-
]);
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
it('declared transactionalBatch:false → 404 still degrades to non-atomic emulation', async () => {
|
|
527
|
-
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
528
|
-
const { ds } = makeCapabilityDS({ capability: { enabled: false }, batchStatus: 404, clientData: { create } });
|
|
529
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
530
|
-
|
|
531
|
-
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
532
|
-
|
|
533
|
-
expect(create).toHaveBeenCalledWith('proj', { name: 'A' });
|
|
534
|
-
expect(res.results[0]).toMatchObject({ id: 'p1', name: 'A' });
|
|
535
|
-
warn.mockRestore();
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it('capability ABSENT (backend predates #3298) → 404 degrades to emulation (back-compat)', async () => {
|
|
539
|
-
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
540
|
-
const { ds } = makeCapabilityDS({ batchStatus: 404, clientData: { create } }); // no capability advertised
|
|
541
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
542
|
-
|
|
543
|
-
await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
544
|
-
|
|
545
|
-
expect(create).toHaveBeenCalledWith('proj', { name: 'A' });
|
|
546
|
-
warn.mockRestore();
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
it('declared support → a real error (400) surfaces unchanged', async () => {
|
|
550
|
-
const create = vi.fn();
|
|
551
|
-
const { ds } = makeCapabilityDS({ capability: { enabled: true }, batchStatus: 400, clientData: { create } });
|
|
552
|
-
|
|
553
|
-
await expect(
|
|
554
|
-
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
555
|
-
).rejects.toThrow(/nope/);
|
|
556
|
-
expect(create).not.toHaveBeenCalled();
|
|
557
|
-
});
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Unit coverage for the discovery capability reader — it must accept both the
|
|
562
|
-
* hierarchical `{ enabled }` wire shape (what the framework producers emit) and
|
|
563
|
-
* the flat boolean the client SDK normalizes to, and return `undefined` for a
|
|
564
|
-
* pre-#3298 backend that advertises nothing.
|
|
565
|
-
*/
|
|
566
|
-
describe('readTransactionalBatchCapability (#3298 shape reader)', () => {
|
|
567
|
-
it('reads the hierarchical { enabled } form', () => {
|
|
568
|
-
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: { enabled: true } } })).toBe(true);
|
|
569
|
-
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: { enabled: false } } })).toBe(false);
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
it('reads the flat boolean form', () => {
|
|
573
|
-
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: true } })).toBe(true);
|
|
574
|
-
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: false } })).toBe(false);
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
it('returns undefined when the capability (or the whole map) is absent', () => {
|
|
578
|
-
expect(readTransactionalBatchCapability({ capabilities: {} })).toBeUndefined();
|
|
579
|
-
expect(readTransactionalBatchCapability({ capabilities: { comments: { enabled: true } } })).toBeUndefined();
|
|
580
|
-
expect(readTransactionalBatchCapability({})).toBeUndefined();
|
|
581
|
-
expect(readTransactionalBatchCapability(null)).toBeUndefined();
|
|
582
|
-
expect(readTransactionalBatchCapability(undefined)).toBeUndefined();
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
it('ignores a malformed capability value', () => {
|
|
586
|
-
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: { enabled: 'yes' } } })).toBeUndefined();
|
|
587
|
-
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: 'true' } })).toBeUndefined();
|
|
588
|
-
});
|
|
589
|
-
});
|