@object-ui/data-objectstack 15.0.0 → 16.1.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 +210 -0
- package/README.md +63 -0
- package/dist/index.cjs +1022 -872
- package/dist/index.d.cts +79 -14
- package/dist/index.d.ts +79 -14
- package/dist/index.js +1021 -872
- package/package.json +4 -4
- package/src/exportDownload.test.ts +9 -3
- package/src/index.ts +259 -105
- package/src/integration.ts +8 -0
- package/src/listViews.test.ts +97 -0
- package/src/metadata-client.test.ts +8 -0
- package/src/metadata-client.ts +9 -0
- package/src/onMutation.test.ts +267 -2
package/src/listViews.test.ts
CHANGED
|
@@ -30,6 +30,33 @@ function makeDS(items: any[]) {
|
|
|
30
30
|
return ds;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A DS whose HTTP layer answers the `?preview=draft` metadata route with a
|
|
35
|
+
* pre-overlaid list (the shape the framework already returns: draft wins by
|
|
36
|
+
* name, each draft tagged `_draft`). The published `client.meta.getItems` stub
|
|
37
|
+
* is present but MUST NOT be used in preview mode.
|
|
38
|
+
*/
|
|
39
|
+
function makeDraftDS(overlaid: any[]) {
|
|
40
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
41
|
+
const url = String(input);
|
|
42
|
+
if (url.includes('/meta/view')) {
|
|
43
|
+
return new Response(JSON.stringify({ items: overlaid }), {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: { 'Content-Type': 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return new Response(JSON.stringify({ success: true, data: {} }), {
|
|
49
|
+
status: 200,
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
54
|
+
ds.connected = true;
|
|
55
|
+
ds.connectionState = 'connected';
|
|
56
|
+
ds.client = { meta: { getItems: vi.fn(async () => ({ items: [] })) } };
|
|
57
|
+
return { ds, fetchImpl };
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
describe('ObjectStackDataSource.listViews', () => {
|
|
34
61
|
// The crm_activity view set as the framework expands it.
|
|
35
62
|
const listAll = {
|
|
@@ -75,4 +102,74 @@ describe('ObjectStackDataSource.listViews', () => {
|
|
|
75
102
|
const views = await ds.listViews('crm_activity');
|
|
76
103
|
expect(views.map((v: any) => v.name).sort()).toEqual(['saved_grid', 'wrapped_grid']);
|
|
77
104
|
});
|
|
105
|
+
|
|
106
|
+
// ── #2767 draft-preview branch ───────────────────────────────────────────
|
|
107
|
+
describe('previewDrafts (#2767 P2/P3)', () => {
|
|
108
|
+
it('reads the draft-overlaid list in a SINGLE preview=draft request', async () => {
|
|
109
|
+
// Server response overlays a draft edit of `all` (draft wins by name) and
|
|
110
|
+
// a brand-new draft-only `mine`; `calendar` is untouched/published.
|
|
111
|
+
const overlaid = [
|
|
112
|
+
{
|
|
113
|
+
name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list',
|
|
114
|
+
label: 'All (edited)', config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
115
|
+
_draft: true,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
|
|
119
|
+
label: 'Activity Calendar', config: { type: 'calendar', data: { object: 'crm_activity' } },
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'crm_activity.mine', object: 'crm_activity', viewKind: 'list',
|
|
123
|
+
label: 'Mine', config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
124
|
+
_draft: true,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
const { ds, fetchImpl } = makeDraftDS(overlaid);
|
|
128
|
+
const views = await ds.listViews('crm_activity', { previewDrafts: true });
|
|
129
|
+
|
|
130
|
+
// Exactly one request, and it carries the preview flag.
|
|
131
|
+
const metaCalls = fetchImpl.mock.calls.filter(([u]: any[]) => String(u).includes('/meta/view'));
|
|
132
|
+
expect(metaCalls).toHaveLength(1);
|
|
133
|
+
expect(String(metaCalls[0][0])).toContain('preview=draft');
|
|
134
|
+
// Preview mode must NOT also hit the published path (that's the P2 dupe bug).
|
|
135
|
+
expect(ds.client.meta.getItems).not.toHaveBeenCalled();
|
|
136
|
+
|
|
137
|
+
// One tab per name — the draft edit of `all` does not duplicate it.
|
|
138
|
+
expect(views.map((v: any) => v.name).sort()).toEqual([
|
|
139
|
+
'crm_activity.all', 'crm_activity.calendar', 'crm_activity.mine',
|
|
140
|
+
]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('flows the _draft provenance flag through for the switcher badge', async () => {
|
|
144
|
+
const overlaid = [
|
|
145
|
+
{
|
|
146
|
+
name: 'crm_activity.mine', object: 'crm_activity', viewKind: 'list',
|
|
147
|
+
label: 'Mine', config: { type: 'grid', data: { object: 'crm_activity' } }, _draft: true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
name: 'crm_activity.calendar', object: 'crm_activity', viewKind: 'list',
|
|
151
|
+
label: 'Cal', config: { type: 'calendar', data: { object: 'crm_activity' } },
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
const { ds } = makeDraftDS(overlaid);
|
|
155
|
+
const views = await ds.listViews('crm_activity', { previewDrafts: true });
|
|
156
|
+
expect(views.find((v: any) => v.name === 'crm_activity.mine')?._draft).toBe(true);
|
|
157
|
+
expect(views.find((v: any) => v.name === 'crm_activity.calendar')?._draft).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('non-preview mode uses the published getItems path (no draft fetch)', async () => {
|
|
161
|
+
const { ds, fetchImpl } = makeDraftDS([]);
|
|
162
|
+
ds.client.meta.getItems = vi.fn(async () => ({
|
|
163
|
+
items: [{
|
|
164
|
+
name: 'crm_activity.all', object: 'crm_activity', viewKind: 'list',
|
|
165
|
+
config: { type: 'grid', data: { object: 'crm_activity' } },
|
|
166
|
+
}],
|
|
167
|
+
}));
|
|
168
|
+
const views = await ds.listViews('crm_activity');
|
|
169
|
+
expect(ds.client.meta.getItems).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(fetchImpl.mock.calls.filter(([u]: any[]) => String(u).includes('/meta/view'))).toHaveLength(0);
|
|
171
|
+
expect(views.map((v: any) => v.name)).toEqual(['crm_activity.all']);
|
|
172
|
+
expect(views[0]._draft).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
78
175
|
});
|
|
@@ -59,6 +59,14 @@ describe('MetadataClient', () => {
|
|
|
59
59
|
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object');
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
it('save() rejects an empty name before any request (#2767 empty-name 405)', async () => {
|
|
63
|
+
const c = client('http://localhost:3000');
|
|
64
|
+
await expect(c.save('view', '', { columns: [] })).rejects.toThrow(/name must be non-empty/);
|
|
65
|
+
await expect(c.save('view', ' ', { columns: [] })).rejects.toThrow(/name must be non-empty/);
|
|
66
|
+
// No malformed `PUT /meta/view/` ever reached the transport.
|
|
67
|
+
expect(urls).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
62
70
|
it('builds scoped URLs when environmentId is set', async () => {
|
|
63
71
|
await client('http://localhost:3000', 'env_42').list('object');
|
|
64
72
|
expect(urls[0]).toBe('http://localhost:3000/api/v1/environments/env_42/meta/object');
|
package/src/metadata-client.ts
CHANGED
|
@@ -529,6 +529,15 @@ export class MetadataClient {
|
|
|
529
529
|
item: unknown,
|
|
530
530
|
options: MetadataSaveOptions = {},
|
|
531
531
|
): Promise<T> {
|
|
532
|
+
if (!name || !String(name).trim()) {
|
|
533
|
+
// The `PUT /meta/:type/:name` route rejects a missing `:name` segment
|
|
534
|
+
// with 405. Fail with a clear contextual error at the call boundary
|
|
535
|
+
// instead of firing a malformed request at the server (#2767).
|
|
536
|
+
throw new Error(
|
|
537
|
+
`MetadataClient.save: name must be non-empty (type="${type}").` +
|
|
538
|
+
' The PUT /meta/:type/:name route requires a name segment.',
|
|
539
|
+
);
|
|
540
|
+
}
|
|
532
541
|
const params: string[] = [];
|
|
533
542
|
if (options.force) params.push('force=true');
|
|
534
543
|
if (options.mode === 'draft') params.push('mode=draft');
|
package/src/onMutation.test.ts
CHANGED
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
* issued by ObjectGrid's default batch-save were silent and the grid kept
|
|
18
18
|
* showing stale rows until a manual reload.
|
|
19
19
|
*/
|
|
20
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
21
|
-
import {
|
|
20
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
21
|
+
import {
|
|
22
|
+
ObjectStackAdapter,
|
|
23
|
+
clearSharedDiscoveryCache,
|
|
24
|
+
readTransactionalBatchCapability,
|
|
25
|
+
} from './index';
|
|
22
26
|
import type { MutationEvent } from '@object-ui/types';
|
|
23
27
|
|
|
24
28
|
function makeDS(stub: Record<string, any>) {
|
|
@@ -232,6 +236,94 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
|
|
|
232
236
|
});
|
|
233
237
|
});
|
|
234
238
|
|
|
239
|
+
/**
|
|
240
|
+
* #2679 / #2694: cross-object saves go through the SDK's
|
|
241
|
+
* `client.data.batchTransaction` (no hand-rolled POST /api/v1/batch). When the
|
|
242
|
+
* SDK reports this backend can't do a transactional batch — it throws an error
|
|
243
|
+
* carrying HTTP 404/405 (endpoint missing) or 501 (runtime without
|
|
244
|
+
* transactions) — the adapter degrades to a client-side, NON-atomic emulation
|
|
245
|
+
* instead of failing the save. This is the isolated home of the non-atomic
|
|
246
|
+
* fallback — the form no longer carries it. Real errors (any other 4xx/5xx)
|
|
247
|
+
* must still surface, never be silently downgraded.
|
|
248
|
+
*/
|
|
249
|
+
describe('ObjectStackAdapter.batchTransaction fallback (no server atomicity)', () => {
|
|
250
|
+
// The SDK's batchTransaction signals "backend can't do a transactional batch"
|
|
251
|
+
// by throwing an error decorated with the HTTP status (the @objectstack/client
|
|
252
|
+
// convention that `errorStatusOf` reads).
|
|
253
|
+
function makeFallbackDS(opts: { batchStatus: number; clientData: Record<string, any> }) {
|
|
254
|
+
const batchTransaction = vi.fn(async () => {
|
|
255
|
+
throw Object.assign(new Error('nope'), { httpStatus: opts.batchStatus });
|
|
256
|
+
});
|
|
257
|
+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: vi.fn() });
|
|
258
|
+
ds.connected = true;
|
|
259
|
+
ds.connectionState = 'connected';
|
|
260
|
+
ds.client = { data: { batchTransaction, ...opts.clientData } };
|
|
261
|
+
return { ds, batchTransaction };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('404 → warns once, saves via the create primitive, and caches the detection', async () => {
|
|
265
|
+
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
266
|
+
const { ds, batchTransaction } = makeFallbackDS({ batchStatus: 404, clientData: { create } });
|
|
267
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
268
|
+
const events: MutationEvent[] = [];
|
|
269
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
270
|
+
|
|
271
|
+
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
272
|
+
|
|
273
|
+
expect(res.results[0]).toMatchObject({ id: 'p1', name: 'A' });
|
|
274
|
+
expect(create).toHaveBeenCalledWith('proj', { name: 'A' });
|
|
275
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
276
|
+
// Events come from the create primitive ONCE — not also re-emitted by the
|
|
277
|
+
// committed-batch path (no double emission).
|
|
278
|
+
expect(events).toEqual([{ type: 'create', resource: 'proj', record: { id: 'p1', name: 'A' } }]);
|
|
279
|
+
|
|
280
|
+
// A second batch short-circuits: the endpoint is known-unsupported, so we
|
|
281
|
+
// never call the SDK batch method again.
|
|
282
|
+
expect(batchTransaction).toHaveBeenCalledTimes(1);
|
|
283
|
+
await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'B' } }]);
|
|
284
|
+
expect(batchTransaction).toHaveBeenCalledTimes(1);
|
|
285
|
+
warn.mockRestore();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('501 (runtime without transactions) also degrades to emulation', async () => {
|
|
289
|
+
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
290
|
+
const { ds } = makeFallbackDS({ batchStatus: 501, clientData: { create } });
|
|
291
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
292
|
+
await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
293
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
294
|
+
warn.mockRestore();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('a real error (400) surfaces — never downgraded to emulation', async () => {
|
|
298
|
+
const create = vi.fn();
|
|
299
|
+
const { ds } = makeFallbackDS({ batchStatus: 400, clientData: { create } });
|
|
300
|
+
await expect(
|
|
301
|
+
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
302
|
+
).rejects.toThrow(/nope/);
|
|
303
|
+
// No client-side writes happened — the batch was rejected, not retried.
|
|
304
|
+
expect(create).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('routes a committed batch through the SDK method and never hand-rolls a fetch', async () => {
|
|
308
|
+
const sdkBatch = vi.fn(async () => ({ results: [{ id: 'p1', name: 'A' }] }));
|
|
309
|
+
const fetchMock = vi.fn();
|
|
310
|
+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchMock });
|
|
311
|
+
ds.connected = true;
|
|
312
|
+
ds.connectionState = 'connected';
|
|
313
|
+
ds.client = { data: { batchTransaction: sdkBatch } };
|
|
314
|
+
const events: MutationEvent[] = [];
|
|
315
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
316
|
+
|
|
317
|
+
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
318
|
+
|
|
319
|
+
expect(sdkBatch).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(fetchMock).not.toHaveBeenCalled(); // no raw POST /api/v1/batch
|
|
321
|
+
expect(res.results[0]).toMatchObject({ id: 'p1' });
|
|
322
|
+
// Committed via the SDK → one event per op (emitted once).
|
|
323
|
+
expect(events).toEqual([{ type: 'create', resource: 'proj', record: { id: 'p1', name: 'A' } }]);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
235
327
|
/**
|
|
236
328
|
* Regression for #2582 (fallback path): when the server lacks the transactional
|
|
237
329
|
* batch endpoint, MasterDetailForm persists child rows via `bulk(child,
|
|
@@ -295,3 +387,176 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
|
|
|
295
387
|
expect(events).toEqual([]);
|
|
296
388
|
});
|
|
297
389
|
});
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* #2693 / framework#3298 — declarative capability negotiation for atomic batch.
|
|
393
|
+
*
|
|
394
|
+
* The adapter reads `capabilities.transactionalBatch` from discovery at
|
|
395
|
+
* connect(). When the backend DECLARES support it TRUSTS server atomicity and
|
|
396
|
+
* never degrades to the non-atomic client emulation — a batch failure (even
|
|
397
|
+
* 404/405/501) is a real error. When the capability is ABSENT (backend predates
|
|
398
|
+
* #3298) or explicitly `false`, the legacy runtime-probe + emulation fallback
|
|
399
|
+
* stays active so a save is still possible (#2679 compatibility constraint).
|
|
400
|
+
*/
|
|
401
|
+
describe('ObjectStackAdapter.batchTransaction capability gate (#2693 / framework#3298)', () => {
|
|
402
|
+
// The discovery cache is module-level and shared across files in the (unit)
|
|
403
|
+
// project — reset it so each case reads its own advertised capability.
|
|
404
|
+
beforeEach(() => clearSharedDiscoveryCache());
|
|
405
|
+
|
|
406
|
+
function makeCapabilityDS(opts: {
|
|
407
|
+
/** Value placed at `capabilities.transactionalBatch`; omit to leave it absent. */
|
|
408
|
+
capability?: boolean | { enabled: boolean };
|
|
409
|
+
batchStatus: number;
|
|
410
|
+
/** Result the SDK batch resolves with on a 2xx `batchStatus`. */
|
|
411
|
+
batchBody?: unknown;
|
|
412
|
+
clientData?: Record<string, any>;
|
|
413
|
+
}) {
|
|
414
|
+
const capabilities: Record<string, unknown> = {};
|
|
415
|
+
if (opts.capability !== undefined) capabilities.transactionalBatch = opts.capability;
|
|
416
|
+
// connect() still fetches discovery to read the #3298 capability; the batch
|
|
417
|
+
// itself now goes through the SDK method (no raw POST /api/v1/batch).
|
|
418
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
419
|
+
const url = String(input);
|
|
420
|
+
if (url.includes('/api/v1/discovery')) {
|
|
421
|
+
return new Response(
|
|
422
|
+
JSON.stringify({ success: true, data: { name: 't', version: '1.0.0', capabilities } }),
|
|
423
|
+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
return new Response('{}', { status: 404, headers: { 'Content-Type': 'application/json' } });
|
|
427
|
+
});
|
|
428
|
+
// The SDK batch resolves on a 2xx status and otherwise throws a
|
|
429
|
+
// status-decorated error (the @objectstack/client convention `errorStatusOf`
|
|
430
|
+
// reads) — the signal the capability gate + fallback branch on.
|
|
431
|
+
const batchTransaction = vi.fn(async () => {
|
|
432
|
+
if (opts.batchStatus >= 200 && opts.batchStatus < 300) {
|
|
433
|
+
return opts.batchBody ?? { results: [] };
|
|
434
|
+
}
|
|
435
|
+
throw Object.assign(new Error('nope'), { httpStatus: opts.batchStatus });
|
|
436
|
+
});
|
|
437
|
+
const ds: any = new ObjectStackAdapter({
|
|
438
|
+
baseUrl: 'http://test.local',
|
|
439
|
+
autoReconnect: false,
|
|
440
|
+
fetch: fetchMock,
|
|
441
|
+
});
|
|
442
|
+
// SDK batch method + emulation primitives live on the stubbed client.
|
|
443
|
+
ds.client = { data: { batchTransaction, ...(opts.clientData ?? {}) } };
|
|
444
|
+
return { ds, fetchMock, batchTransaction };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
it('declared transactionalBatch:true → a 404 is a hard error, NOT downgraded to emulation', async () => {
|
|
448
|
+
const create = vi.fn();
|
|
449
|
+
const { ds } = makeCapabilityDS({ capability: { enabled: true }, batchStatus: 404, clientData: { create } });
|
|
450
|
+
|
|
451
|
+
await expect(
|
|
452
|
+
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
453
|
+
).rejects.toThrow(/nope/);
|
|
454
|
+
// A declared-atomic backend is trusted: no client-side compensation ran.
|
|
455
|
+
expect(create).not.toHaveBeenCalled();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('declared transactionalBatch:true → 501 is also a hard error (no emulation)', async () => {
|
|
459
|
+
const create = vi.fn();
|
|
460
|
+
const { ds } = makeCapabilityDS({ capability: { enabled: true }, batchStatus: 501, clientData: { create } });
|
|
461
|
+
|
|
462
|
+
await expect(
|
|
463
|
+
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
464
|
+
).rejects.toThrow();
|
|
465
|
+
expect(create).not.toHaveBeenCalled();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('accepts the flat boolean capability shape too', async () => {
|
|
469
|
+
const create = vi.fn();
|
|
470
|
+
const { ds } = makeCapabilityDS({ capability: true, batchStatus: 404, clientData: { create } });
|
|
471
|
+
|
|
472
|
+
await expect(
|
|
473
|
+
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
474
|
+
).rejects.toThrow();
|
|
475
|
+
expect(create).not.toHaveBeenCalled();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('declared support → a committed batch commits via /batch and emits one event per op', async () => {
|
|
479
|
+
const { ds } = makeCapabilityDS({
|
|
480
|
+
capability: { enabled: true },
|
|
481
|
+
batchStatus: 200,
|
|
482
|
+
batchBody: { results: [{ id: 'p1', name: 'A' }, { id: 'c1', proj: 'p1' }] },
|
|
483
|
+
});
|
|
484
|
+
const events: MutationEvent[] = [];
|
|
485
|
+
ds.onMutation((e: MutationEvent) => events.push(e));
|
|
486
|
+
|
|
487
|
+
const res = await ds.batchTransaction([
|
|
488
|
+
{ object: 'proj', action: 'create', data: { name: 'A' } },
|
|
489
|
+
{ object: 'item', action: 'create', data: { proj: { $ref: 0 } } },
|
|
490
|
+
]);
|
|
491
|
+
|
|
492
|
+
expect(res.results).toHaveLength(2);
|
|
493
|
+
expect(events).toEqual([
|
|
494
|
+
{ type: 'create', resource: 'proj', record: { id: 'p1', name: 'A' } },
|
|
495
|
+
{ type: 'create', resource: 'item', record: { id: 'c1', proj: 'p1' } },
|
|
496
|
+
]);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('declared transactionalBatch:false → 404 still degrades to non-atomic emulation', async () => {
|
|
500
|
+
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
501
|
+
const { ds } = makeCapabilityDS({ capability: { enabled: false }, batchStatus: 404, clientData: { create } });
|
|
502
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
503
|
+
|
|
504
|
+
const res = await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
505
|
+
|
|
506
|
+
expect(create).toHaveBeenCalledWith('proj', { name: 'A' });
|
|
507
|
+
expect(res.results[0]).toMatchObject({ id: 'p1', name: 'A' });
|
|
508
|
+
warn.mockRestore();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it('capability ABSENT (backend predates #3298) → 404 degrades to emulation (back-compat)', async () => {
|
|
512
|
+
const create = vi.fn(async (_o: string, d: any) => ({ record: { id: 'p1', ...d } }));
|
|
513
|
+
const { ds } = makeCapabilityDS({ batchStatus: 404, clientData: { create } }); // no capability advertised
|
|
514
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
515
|
+
|
|
516
|
+
await ds.batchTransaction([{ object: 'proj', action: 'create', data: { name: 'A' } }]);
|
|
517
|
+
|
|
518
|
+
expect(create).toHaveBeenCalledWith('proj', { name: 'A' });
|
|
519
|
+
warn.mockRestore();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('declared support → a real error (400) surfaces unchanged', async () => {
|
|
523
|
+
const create = vi.fn();
|
|
524
|
+
const { ds } = makeCapabilityDS({ capability: { enabled: true }, batchStatus: 400, clientData: { create } });
|
|
525
|
+
|
|
526
|
+
await expect(
|
|
527
|
+
ds.batchTransaction([{ object: 'proj', action: 'create', data: {} }]),
|
|
528
|
+
).rejects.toThrow(/nope/);
|
|
529
|
+
expect(create).not.toHaveBeenCalled();
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Unit coverage for the discovery capability reader — it must accept both the
|
|
535
|
+
* hierarchical `{ enabled }` wire shape (what the framework producers emit) and
|
|
536
|
+
* the flat boolean the client SDK normalizes to, and return `undefined` for a
|
|
537
|
+
* pre-#3298 backend that advertises nothing.
|
|
538
|
+
*/
|
|
539
|
+
describe('readTransactionalBatchCapability (#3298 shape reader)', () => {
|
|
540
|
+
it('reads the hierarchical { enabled } form', () => {
|
|
541
|
+
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: { enabled: true } } })).toBe(true);
|
|
542
|
+
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: { enabled: false } } })).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('reads the flat boolean form', () => {
|
|
546
|
+
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: true } })).toBe(true);
|
|
547
|
+
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: false } })).toBe(false);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('returns undefined when the capability (or the whole map) is absent', () => {
|
|
551
|
+
expect(readTransactionalBatchCapability({ capabilities: {} })).toBeUndefined();
|
|
552
|
+
expect(readTransactionalBatchCapability({ capabilities: { comments: { enabled: true } } })).toBeUndefined();
|
|
553
|
+
expect(readTransactionalBatchCapability({})).toBeUndefined();
|
|
554
|
+
expect(readTransactionalBatchCapability(null)).toBeUndefined();
|
|
555
|
+
expect(readTransactionalBatchCapability(undefined)).toBeUndefined();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('ignores a malformed capability value', () => {
|
|
559
|
+
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: { enabled: 'yes' } } })).toBeUndefined();
|
|
560
|
+
expect(readTransactionalBatchCapability({ capabilities: { transactionalBatch: 'true' } })).toBeUndefined();
|
|
561
|
+
});
|
|
562
|
+
});
|