@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.
@@ -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,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 { ObjectStackAdapter } from './index';
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
+ });