@object-ui/data-objectstack 17.3.0 → 17.6.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +888 -0
  2. package/README.md +3 -11
  3. package/dist/index.cjs +1511 -1115
  4. package/dist/index.d.cts +1222 -824
  5. package/dist/index.d.ts +1222 -824
  6. package/dist/index.js +1503 -1111
  7. package/package.json +5 -6
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/bulkDelete.test.ts +0 -75
  10. package/src/bulkUpdate.test.ts +0 -93
  11. package/src/cache/MetadataCache.test.ts +0 -477
  12. package/src/cache/MetadataCache.ts +0 -289
  13. package/src/cloud.ts +0 -109
  14. package/src/connection.test.ts +0 -188
  15. package/src/contracts.ts +0 -115
  16. package/src/errors.test.ts +0 -426
  17. package/src/errors.ts +0 -293
  18. package/src/exportDownload.test.ts +0 -141
  19. package/src/filter-entry-translation.test.ts +0 -360
  20. package/src/filter-operator-ast-parity.test.ts +0 -71
  21. package/src/getObjectSchema.test.ts +0 -145
  22. package/src/importJob.test.ts +0 -71
  23. package/src/index.ts +0 -3658
  24. package/src/integration.ts +0 -200
  25. package/src/listViews.test.ts +0 -175
  26. package/src/metadata-client.test.ts +0 -303
  27. package/src/metadata-client.ts +0 -849
  28. package/src/occ.test.ts +0 -101
  29. package/src/onMutation.test.ts +0 -562
  30. package/src/onWriteWarning.test.ts +0 -273
  31. package/src/orderby-serialization.test.ts +0 -115
  32. package/src/queryDataset.test.ts +0 -132
  33. package/src/searchAll.test.ts +0 -120
  34. package/src/security.ts +0 -243
  35. package/src/spec-symbol-batch6.test.ts +0 -306
  36. package/src/studio.ts +0 -152
  37. package/src/userState.test.ts +0 -315
  38. package/src/userState.ts +0 -238
  39. package/src/v3-compat.test.ts +0 -240
  40. package/src/validation-error.test.ts +0 -94
@@ -1,273 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- /**
10
- * 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
- // ── no-op strips are not news (objectui#3484) ─────────────────────────────
161
- //
162
- // The server is right to report a strip — the client DID send the key. But
163
- // "was not saved" is only worth a toast when something was LOST. The console
164
- // edit form used to round-trip the whole record, so every save of a
165
- // state-locked record warned about fields the user never touched whose values
166
- // never changed. When the echoed record proves the stripped value equals what
167
- // was sent, stay quiet.
168
-
169
- it('suppresses the warning when every stripped value equals what the record already holds', async () => {
170
- const update = vi.fn().mockResolvedValue({
171
- record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment', title: 'B' },
172
- droppedFields: [
173
- { object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
174
- ],
175
- });
176
- const ds = makeDS({ update });
177
- const events: WriteWarningEvent[] = [];
178
- ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
179
-
180
- await ds.update('andon', 'r1', {
181
- title: 'B',
182
- type: 'quality_abnormal',
183
- source_method: 'qc_judgment',
184
- });
185
-
186
- expect(events).toEqual([]);
187
- });
188
-
189
- it('still warns about the fields that really did change, and only those', async () => {
190
- const update = vi.fn().mockResolvedValue({
191
- record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment' },
192
- droppedFields: [
193
- { object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
194
- ],
195
- });
196
- const ds = makeDS({ update });
197
- const events: WriteWarningEvent[] = [];
198
- ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
199
-
200
- // The user really did try to change `type`; `source_method` round-tripped.
201
- await ds.update('andon', 'r1', { type: 'equipment_fault', source_method: 'qc_judgment' });
202
-
203
- expect(events).toEqual([
204
- {
205
- operation: 'update',
206
- resource: 'andon',
207
- id: 'r1',
208
- droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
209
- },
210
- ]);
211
- });
212
-
213
- it('keeps the warning when the no-op cannot be PROVEN (record echo lacks the key)', async () => {
214
- const update = vi.fn().mockResolvedValue({
215
- record: { id: 'r1' },
216
- droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
217
- });
218
- const ds = makeDS({ update });
219
- const events: WriteWarningEvent[] = [];
220
- ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
221
-
222
- await ds.update('andon', 'r1', { type: 'quality_abnormal' });
223
-
224
- expect(events).toHaveLength(1);
225
- });
226
-
227
- it('does not treat a type change as a no-op (1 is not "1")', async () => {
228
- const update = vi.fn().mockResolvedValue({
229
- record: { id: 'r1', tax_rate: '9' },
230
- droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
231
- });
232
- const ds = makeDS({ update });
233
- const events: WriteWarningEvent[] = [];
234
- ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
235
-
236
- await ds.update('invoice', 'r1', { tax_rate: 9 });
237
-
238
- expect(events).toHaveLength(1);
239
- });
240
-
241
- it('suppresses a no-op strip on the cross-object batch path too', async () => {
242
- const batchTransaction = vi.fn().mockResolvedValue({
243
- results: [{ id: 'acc1' }, { id: 'inv1', tax_rate: 9 }],
244
- droppedFields: [
245
- { object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
246
- ],
247
- });
248
- const ds = makeDS({ batchTransaction });
249
- ds.atomicBatchCapability = true;
250
- const events: WriteWarningEvent[] = [];
251
- ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
252
-
253
- await ds.batchTransaction([
254
- { object: 'account', action: 'create', data: { name: 'Acme' } },
255
- { object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
256
- ]);
257
-
258
- expect(events).toEqual([]);
259
- });
260
-
261
- it('isolates a throwing listener so the write still resolves and other listeners fire', async () => {
262
- const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
263
- const ds = makeDS({ update });
264
- const good = vi.fn();
265
- ds.onWriteWarning(() => { throw new Error('boom'); });
266
- ds.onWriteWarning(good);
267
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
268
-
269
- await expect(ds.update('approval_case', 'r1', { approval_status: 'x' })).resolves.toBeTruthy();
270
- expect(good).toHaveBeenCalledTimes(1);
271
- warn.mockRestore();
272
- });
273
- });
@@ -1,115 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- /**
10
- * 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
- });
@@ -1,132 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import { describe, it, expect, beforeEach, vi } from 'vitest';
10
- import {
11
- ObjectStackAdapter,
12
- clearSharedDiscoveryCache,
13
- isAnalyticsNotInstalledError,
14
- } from './index';
15
-
16
- /** A fetch mock that answers discovery + records the dataset-query POST. */
17
- function makeFetch(datasetResponse: { ok: boolean; status?: number; body: unknown }) {
18
- const calls: Array<{ url: string; init?: any }> = [];
19
- const fetchImpl = vi.fn(async (url: any, init?: any) => {
20
- const u = String(url);
21
- calls.push({ url: u, init });
22
- if (u.includes('/api/v1/discovery')) {
23
- return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
24
- }
25
- if (u.includes('/api/v1/analytics/dataset/query')) {
26
- return {
27
- ok: datasetResponse.ok,
28
- status: datasetResponse.status ?? (datasetResponse.ok ? 200 : 400),
29
- statusText: datasetResponse.ok ? 'OK' : 'Bad Request',
30
- json: async () => datasetResponse.body,
31
- } as any;
32
- }
33
- return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
34
- });
35
- return { fetchImpl, calls };
36
- }
37
-
38
- const inlineDataset = {
39
- name: 'sales', label: 'Sales', object: 'opportunity', include: ['account'],
40
- dimensions: [{ name: 'region', field: 'account.region', type: 'string' }],
41
- measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
42
- };
43
- const selection = { dimensions: ['region'], measures: ['revenue'] };
44
-
45
- describe('ObjectStackAdapter.queryDataset', () => {
46
- beforeEach(() => clearSharedDiscoveryCache());
47
-
48
- it('POSTs the inline dataset + selection and returns rows/fields', async () => {
49
- const { fetchImpl, calls } = makeFetch({
50
- ok: true,
51
- body: { rows: [{ region: 'NA', revenue: 100 }], fields: [{ name: 'revenue', type: 'number' }] },
52
- });
53
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', token: 'tok_123', autoReconnect: false, fetch: fetchImpl as any });
54
-
55
- const result = await adapter.queryDataset(inlineDataset as any, selection);
56
-
57
- expect(result.rows).toEqual([{ region: 'NA', revenue: 100 }]);
58
- expect(result.fields).toEqual([{ name: 'revenue', type: 'number' }]);
59
-
60
- const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
61
- expect(post.url).toBe('http://localhost:3000/api/v1/analytics/dataset/query');
62
- expect(post.init.method).toBe('POST');
63
- expect(post.init.headers.Authorization).toBe('Bearer tok_123');
64
- expect(JSON.parse(post.init.body)).toEqual({ dataset: inlineDataset, selection });
65
- });
66
-
67
- it('sends datasetName when given a string', async () => {
68
- const { fetchImpl, calls } = makeFetch({ ok: true, body: { rows: [], fields: [] } });
69
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
70
- await adapter.queryDataset('sales', selection);
71
- const post = calls.find((c) => c.url.includes('/analytics/dataset/query'))!;
72
- expect(JSON.parse(post.init.body)).toEqual({ datasetName: 'sales', selection });
73
- });
74
-
75
- it('unwraps a { success, data } envelope', async () => {
76
- const { fetchImpl } = makeFetch({ ok: true, body: { success: true, data: { rows: [{ x: 1 }], fields: [] } } });
77
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
78
- const result = await adapter.queryDataset(inlineDataset as any, selection);
79
- expect(result.rows).toEqual([{ x: 1 }]);
80
- });
81
-
82
- it('throws (no silent fallback) with the server message on a 4xx', async () => {
83
- const { fetchImpl } = makeFetch({ ok: false, status: 400, body: { code: 'DATASET_INVALID', message: 'relationship not declared' } });
84
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
85
- await expect(adapter.queryDataset(inlineDataset as any, selection)).rejects.toThrow(/relationship not declared/);
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
- });
132
- });
@@ -1,120 +0,0 @@
1
- /**
2
- * ObjectUI
3
- * Copyright (c) 2024-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import { describe, it, expect, beforeEach, vi } from 'vitest';
10
- import { ObjectStackAdapter, clearSharedDiscoveryCache } from './index';
11
-
12
- /** A fetch mock that answers discovery + records the global-search GET. */
13
- function makeFetch(searchResponse: { ok: boolean; status?: number; body: unknown }) {
14
- const calls: Array<{ url: string; init?: any }> = [];
15
- const fetchImpl = vi.fn(async (url: any, init?: any) => {
16
- const u = String(url);
17
- calls.push({ url: u, init });
18
- if (u.includes('/api/v1/discovery')) {
19
- return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
20
- }
21
- if (u.includes('/api/v1/search')) {
22
- return {
23
- ok: searchResponse.ok,
24
- status: searchResponse.status ?? (searchResponse.ok ? 200 : 400),
25
- statusText: searchResponse.ok ? 'OK' : 'Error',
26
- json: async () => searchResponse.body,
27
- } as any;
28
- }
29
- return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
30
- });
31
- return { fetchImpl, calls };
32
- }
33
-
34
- const WAYNE_HITS = {
35
- query: 'Wayne',
36
- hits: [
37
- { object: 'crm_account', id: 'a1', title: 'Wayne Enterprises', snippet: 'Wayne Enterprises', record: { id: 'a1', account_number: 'ACC-000005' } },
38
- { object: 'crm_opportunity', id: 'o1', title: 'Wayne Q1 Expansion', record: { id: 'o1' } },
39
- ],
40
- };
41
-
42
- describe('ObjectStackAdapter.searchAll', () => {
43
- beforeEach(() => clearSharedDiscoveryCache());
44
-
45
- it('GETs /api/v1/search?q= and returns normalized ranked hits', async () => {
46
- const { fetchImpl, calls } = makeFetch({ ok: true, body: WAYNE_HITS });
47
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', token: 'tok_123', autoReconnect: false, fetch: fetchImpl as any });
48
-
49
- const result = await adapter.searchAll('Wayne');
50
-
51
- expect(result.query).toBe('Wayne');
52
- expect(result.hits.map((h) => `${h.object}:${h.id}`)).toEqual(['crm_account:a1', 'crm_opportunity:o1']);
53
- expect(result.hits[0].title).toBe('Wayne Enterprises');
54
- expect(result.hits[0].record).toEqual({ id: 'a1', account_number: 'ACC-000005' });
55
-
56
- const get = calls.find((c) => c.url.includes('/api/v1/search'))!;
57
- const url = new URL(get.url);
58
- expect(url.pathname).toBe('/api/v1/search');
59
- expect(url.searchParams.get('q')).toBe('Wayne');
60
- expect(get.init.method).toBe('GET');
61
- expect(get.init.headers.Authorization).toBe('Bearer tok_123');
62
- });
63
-
64
- it('trims the query and forwards limit + objects scoping', async () => {
65
- const { fetchImpl, calls } = makeFetch({ ok: true, body: { query: 'Wayne', hits: [] } });
66
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
67
-
68
- await adapter.searchAll(' Wayne ', { limit: 12, objects: ['crm_account', 'crm_opportunity'] });
69
-
70
- const url = new URL(calls.find((c) => c.url.includes('/api/v1/search'))!.url);
71
- expect(url.searchParams.get('q')).toBe('Wayne');
72
- expect(url.searchParams.get('limit')).toBe('12');
73
- expect(url.searchParams.get('objects')).toBe('crm_account,crm_opportunity');
74
- });
75
-
76
- it('short-circuits an empty query without hitting the network', async () => {
77
- const { fetchImpl, calls } = makeFetch({ ok: true, body: { query: '', hits: [] } });
78
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
79
-
80
- const result = await adapter.searchAll(' ');
81
-
82
- expect(result).toEqual({ query: '', hits: [] });
83
- expect(calls.some((c) => c.url.includes('/api/v1/search'))).toBe(false);
84
- });
85
-
86
- it('unwraps a { success, data } envelope', async () => {
87
- const { fetchImpl } = makeFetch({ ok: true, body: { success: true, data: WAYNE_HITS } });
88
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
89
- const result = await adapter.searchAll('Wayne');
90
- expect(result.hits).toHaveLength(2);
91
- });
92
-
93
- it('treats a 404 (search plugin absent) as an empty result, not an error', async () => {
94
- const { fetchImpl } = makeFetch({ ok: false, status: 404, body: { message: 'not found' } });
95
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
96
- await expect(adapter.searchAll('Wayne')).resolves.toEqual({ query: 'Wayne', hits: [] });
97
- });
98
-
99
- it('throws with the server message on a non-404 error', async () => {
100
- const { fetchImpl } = makeFetch({ ok: false, status: 500, body: { error: { message: 'index unavailable' } } });
101
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
102
- await expect(adapter.searchAll('Wayne')).rejects.toThrow(/index unavailable/);
103
- });
104
-
105
- it('drops malformed hits (missing object or id)', async () => {
106
- const { fetchImpl } = makeFetch({
107
- ok: true,
108
- body: { query: 'x', hits: [
109
- { object: 'crm_account', id: 'a1', title: 'Keep' },
110
- { id: 'no_object' },
111
- { object: 'crm_lead' },
112
- null,
113
- { object: 'crm_contact', record: { id: 'c9' } },
114
- ] },
115
- });
116
- const adapter = new ObjectStackAdapter({ baseUrl: 'http://localhost:3000', autoReconnect: false, fetch: fetchImpl as any });
117
- const result = await adapter.searchAll('x');
118
- expect(result.hits.map((h) => `${h.object}:${h.id}`)).toEqual(['crm_account:a1', 'crm_contact:c9']);
119
- });
120
- });