@object-ui/data-objectstack 16.1.0 → 17.2.0

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