@object-ui/data-objectstack 16.0.0 → 17.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.
@@ -143,6 +143,10 @@ export class IntegrationManager {
143
143
  }
144
144
  } catch (e) {
145
145
  if (e instanceof TypeError) {
146
+ // A malformed URL surfaces as its own message; we can't attach `e`
147
+ // as the `Error` `cause` because this package targets ES2020, whose
148
+ // lib types the 1-arg `Error` constructor only. Scoped disable.
149
+ // eslint-disable-next-line preserve-caught-error
146
150
  throw new Error(`Invalid webhook URL: ${url}`);
147
151
  }
148
152
  throw e;
@@ -168,6 +172,10 @@ export class IntegrationManager {
168
172
  }
169
173
  } catch (e) {
170
174
  if (e instanceof TypeError) {
175
+ // A malformed URL surfaces as its own message; we can't attach `e`
176
+ // as the `Error` `cause` because this package targets ES2020, whose
177
+ // lib types the 1-arg `Error` constructor only. Scoped disable.
178
+ // eslint-disable-next-line preserve-caught-error
171
179
  throw new Error(`Invalid Slack webhook URL: ${url}`);
172
180
  }
173
181
  throw e;
@@ -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');
@@ -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');
@@ -17,9 +17,13 @@
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 { ObjectStackAdapter } from './index';
22
- import type { MutationEvent } from '@object-ui/types';
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';
23
27
 
24
28
  function makeDS(stub: Record<string, any>) {
25
29
  const ds: any = new ObjectStackAdapter({
@@ -45,8 +49,8 @@ describe('ObjectStackAdapter.onMutation', () => {
45
49
  .mockResolvedValue({ record: { id: 'r1', account: 'acc-1' } });
46
50
  const ds = makeDS({ update });
47
51
 
48
- const events: MutationEvent[] = [];
49
- const unsub = ds.onMutation((e: MutationEvent) => events.push(e));
52
+ const events: DataSourceMutationEvent[] = [];
53
+ const unsub = ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
50
54
 
51
55
  await ds.update('showcase_project', 'r1', { account: 'acc-1' });
52
56
 
@@ -63,8 +67,8 @@ describe('ObjectStackAdapter.onMutation', () => {
63
67
  it('emits a create event with the new record', async () => {
64
68
  const create = vi.fn().mockResolvedValue({ record: { id: 'new', name: 'X' } });
65
69
  const ds = makeDS({ create });
66
- const events: MutationEvent[] = [];
67
- ds.onMutation((e: MutationEvent) => events.push(e));
70
+ const events: DataSourceMutationEvent[] = [];
71
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
68
72
 
69
73
  await ds.create('showcase_project', { name: 'X' });
70
74
 
@@ -79,8 +83,8 @@ describe('ObjectStackAdapter.onMutation', () => {
79
83
  .mockResolvedValueOnce({ deleted: true })
80
84
  .mockResolvedValueOnce({ deleted: false });
81
85
  const ds = makeDS({ delete: del });
82
- const events: MutationEvent[] = [];
83
- ds.onMutation((e: MutationEvent) => events.push(e));
86
+ const events: DataSourceMutationEvent[] = [];
87
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
84
88
 
85
89
  await ds.delete('showcase_project', 'r1');
86
90
  await ds.delete('showcase_project', 'r2');
@@ -93,8 +97,8 @@ describe('ObjectStackAdapter.onMutation', () => {
93
97
  it('emits a single bulk event per bulkUpdate call (not per id)', async () => {
94
98
  const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
95
99
  const ds = makeDS({ updateMany });
96
- const events: MutationEvent[] = [];
97
- ds.onMutation((e: MutationEvent) => events.push(e));
100
+ const events: DataSourceMutationEvent[] = [];
101
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
98
102
 
99
103
  await ds.bulkUpdate('showcase_project', ['a', 'b', 'c'], { archived: true });
100
104
 
@@ -104,8 +108,8 @@ describe('ObjectStackAdapter.onMutation', () => {
104
108
  it('does not emit when a bulk operation affected zero rows', async () => {
105
109
  const updateMany = vi.fn().mockResolvedValue({ succeeded: 0, failed: 0, results: [] });
106
110
  const ds = makeDS({ updateMany });
107
- const events: MutationEvent[] = [];
108
- ds.onMutation((e: MutationEvent) => events.push(e));
111
+ const events: DataSourceMutationEvent[] = [];
112
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
109
113
 
110
114
  await ds.bulkUpdate('showcase_project', ['a'], { archived: true });
111
115
 
@@ -156,8 +160,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
156
160
  { status: 200, headers: { 'Content-Type': 'application/json' } },
157
161
  ),
158
162
  );
159
- const events: MutationEvent[] = [];
160
- ds.onMutation((e: MutationEvent) => events.push(e));
163
+ const events: DataSourceMutationEvent[] = [];
164
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
161
165
 
162
166
  await ds.batchTransaction([
163
167
  { object: 'ehr_task_version', action: 'create', data: { name: 'Task version A' } },
@@ -179,8 +183,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
179
183
  { status: 200, headers: { 'Content-Type': 'application/json' } },
180
184
  ),
181
185
  );
182
- const events: MutationEvent[] = [];
183
- ds.onMutation((e: MutationEvent) => events.push(e));
186
+ const events: DataSourceMutationEvent[] = [];
187
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
184
188
 
185
189
  await ds.batchTransaction([
186
190
  { object: 'ehr_task_version', action: 'update', id: 'p1', data: { name: 'Renamed' } },
@@ -204,8 +208,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
204
208
  headers: { 'Content-Type': 'application/json' },
205
209
  }),
206
210
  );
207
- const events: MutationEvent[] = [];
208
- ds.onMutation((e: MutationEvent) => events.push(e));
211
+ const events: DataSourceMutationEvent[] = [];
212
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
209
213
 
210
214
  await ds.batchTransaction([{ object: 'ehr_task_version', data: { name: 'X' } }]);
211
215
 
@@ -221,8 +225,8 @@ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
221
225
  headers: { 'Content-Type': 'application/json' },
222
226
  }),
223
227
  );
224
- const events: MutationEvent[] = [];
225
- ds.onMutation((e: MutationEvent) => events.push(e));
228
+ const events: DataSourceMutationEvent[] = [];
229
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
226
230
 
227
231
  await expect(
228
232
  ds.batchTransaction([{ object: 'ehr_task_version', action: 'create', data: {} }]),
@@ -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: DataSourceMutationEvent[] = [];
269
+ ds.onMutation((e: DataSourceMutationEvent) => 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: DataSourceMutationEvent[] = [];
315
+ ds.onMutation((e: DataSourceMutationEvent) => 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,
@@ -243,8 +335,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
243
335
  it('emits a single create event per bulk create call', async () => {
244
336
  const createMany = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
245
337
  const ds = makeDS({ createMany });
246
- const events: MutationEvent[] = [];
247
- ds.onMutation((e: MutationEvent) => events.push(e));
338
+ const events: DataSourceMutationEvent[] = [];
339
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
248
340
 
249
341
  await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }, { label: 'b' }]);
250
342
 
@@ -254,8 +346,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
254
346
  it('does not emit when a bulk create returned zero records', async () => {
255
347
  const createMany = vi.fn().mockResolvedValue([]);
256
348
  const ds = makeDS({ createMany });
257
- const events: MutationEvent[] = [];
258
- ds.onMutation((e: MutationEvent) => events.push(e));
349
+ const events: DataSourceMutationEvent[] = [];
350
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
259
351
 
260
352
  await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }]);
261
353
 
@@ -265,8 +357,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
265
357
  it('emits a single delete event per bulk delete call', async () => {
266
358
  const deleteMany = vi.fn().mockResolvedValue(undefined);
267
359
  const ds = makeDS({ deleteMany });
268
- const events: MutationEvent[] = [];
269
- ds.onMutation((e: MutationEvent) => events.push(e));
360
+ const events: DataSourceMutationEvent[] = [];
361
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
270
362
 
271
363
  await ds.bulk('ehr_task_check_item', 'delete', [{ id: 'c1' }, { id: 'c2' }] as any);
272
364
 
@@ -276,8 +368,8 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
276
368
  it('emits a single update event per bulk update call', async () => {
277
369
  const updateMany = vi.fn().mockResolvedValue([{ id: 'c1' }]);
278
370
  const ds = makeDS({ updateMany });
279
- const events: MutationEvent[] = [];
280
- ds.onMutation((e: MutationEvent) => events.push(e));
371
+ const events: DataSourceMutationEvent[] = [];
372
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
281
373
 
282
374
  await ds.bulk('ehr_task_check_item', 'update', [{ id: 'c1', label: 'x' }] as any);
283
375
 
@@ -287,11 +379,184 @@ describe('ObjectStackAdapter.bulk mutation events', () => {
287
379
  it('bulk create failure emits nothing', async () => {
288
380
  const createMany = vi.fn().mockRejectedValue(new Error('boom'));
289
381
  const ds = makeDS({ createMany });
290
- const events: MutationEvent[] = [];
291
- ds.onMutation((e: MutationEvent) => events.push(e));
382
+ const events: DataSourceMutationEvent[] = [];
383
+ ds.onMutation((e: DataSourceMutationEvent) => events.push(e));
292
384
 
293
385
  await expect(ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }])).rejects.toThrow();
294
386
 
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: DataSourceMutationEvent[] = [];
485
+ ds.onMutation((e: DataSourceMutationEvent) => 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
+ });