@object-ui/data-objectstack 17.5.0 → 17.7.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 (53) hide show
  1. package/CHANGELOG.md +2311 -0
  2. package/README.md +557 -11
  3. package/dist/index.cjs +1663 -463
  4. package/dist/index.d.cts +1780 -518
  5. package/dist/index.d.ts +1780 -518
  6. package/dist/index.js +1648 -457
  7. package/package.json +9 -9
  8. package/dist/tsconfig.tsbuildinfo +0 -1
  9. package/src/aggregate-capability.test.ts +0 -177
  10. package/src/apiAccessDenied.test.ts +0 -233
  11. package/src/appAccessProbe.test.ts +0 -147
  12. package/src/bulkDelete.test.ts +0 -75
  13. package/src/bulkUpdate.test.ts +0 -93
  14. package/src/cache/MetadataCache.test.ts +0 -477
  15. package/src/cache/MetadataCache.ts +0 -289
  16. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  17. package/src/connection.test.ts +0 -188
  18. package/src/contracts.ts +0 -115
  19. package/src/deleteView.homes.test.ts +0 -389
  20. package/src/deleteViewContract.types.test.ts +0 -246
  21. package/src/errors.test.ts +0 -426
  22. package/src/errors.ts +0 -293
  23. package/src/exportDownload.test.ts +0 -141
  24. package/src/filter-entry-translation.test.ts +0 -360
  25. package/src/filter-operator-ast-parity.test.ts +0 -135
  26. package/src/getObjectSchema.test.ts +0 -145
  27. package/src/httpFailureLogging.test.ts +0 -109
  28. package/src/importJob.test.ts +0 -71
  29. package/src/index.ts +0 -4442
  30. package/src/integration.ts +0 -200
  31. package/src/listViewOverrides.test.ts +0 -241
  32. package/src/listViews.test.ts +0 -175
  33. package/src/metadata-client.get-envelope.test.ts +0 -171
  34. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  35. package/src/metadata-client.test.ts +0 -303
  36. package/src/metadata-client.ts +0 -1046
  37. package/src/occ.test.ts +0 -101
  38. package/src/onMutation.test.ts +0 -589
  39. package/src/onSaveAdvisory.test.ts +0 -311
  40. package/src/onWriteWarning.test.ts +0 -273
  41. package/src/orderby-serialization.test.ts +0 -115
  42. package/src/queryDataset.test.ts +0 -543
  43. package/src/searchAll.test.ts +0 -120
  44. package/src/security.ts +0 -243
  45. package/src/spec-symbol-batch6.test.ts +0 -326
  46. package/src/studio.ts +0 -152
  47. package/src/updateView.draft.test.ts +0 -242
  48. package/src/userState.test.ts +0 -315
  49. package/src/userState.ts +0 -238
  50. package/src/v3-compat.test.ts +0 -229
  51. package/src/validation-error.test.ts +0 -94
  52. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  53. package/src/viewInvalidationSeam.pin.test.ts +0 -162
@@ -1,360 +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
- * Object-form filter entries (`[{ field, operator, value }, ...]`) → filter AST.
11
- *
12
- * `find()` has TWO routes to the server and both translate the filter: a plain
13
- * read goes through `convertQueryParams`, while a read with `$expand`/`$search`
14
- * goes through `rawFindWithPopulate`. They used to carry independent copies of
15
- * the "is this object form?" test, and the copies had already drifted — so the
16
- * tests below run every case down BOTH routes and assert they agree.
17
- *
18
- * The stakes are the same as objectstack#3948: a filter this layer fails to
19
- * translate is not a filter it may skip. Skipping one entry of an `and` returns
20
- * a superset of the rows asked for, and skipping the last one sends no filter
21
- * at all — every row in the table, reported as success.
22
- */
23
-
24
- import { describe, it, expect, beforeEach, vi } from 'vitest';
25
- import { isFilterAST } from '@objectstack/spec/data';
26
- import { ObjectStackAdapter, clearSharedDiscoveryCache, isMalformedFilterError } from './index';
27
-
28
- function makeAdapter() {
29
- const calls: string[] = [];
30
- const fetchImpl = vi.fn(async (url: any) => {
31
- const u = String(url);
32
- calls.push(u);
33
- if (u.includes('/api/v1/discovery')) {
34
- return {
35
- ok: true, status: 200, statusText: 'OK',
36
- json: async () => ({ success: true, data: { version: 'v1', routes: {} } }),
37
- } as any;
38
- }
39
- return {
40
- ok: true, status: 200, statusText: 'OK',
41
- json: async () => ({ success: true, data: { object: 'account', records: [], total: 0 } }),
42
- } as any;
43
- });
44
- const adapter = new ObjectStackAdapter({
45
- baseUrl: 'http://localhost:3000', token: 't', autoReconnect: false, fetch: fetchImpl as any,
46
- });
47
- return { adapter, calls };
48
- }
49
-
50
- /** The `filter=` this filter produced on the wire, or `undefined` if none was sent. */
51
- async function filterOnWire(
52
- $filter: unknown,
53
- route: 'plain' | 'expand',
54
- ): Promise<unknown | undefined> {
55
- const { adapter, calls } = makeAdapter();
56
- await adapter.find('account', {
57
- $filter,
58
- ...(route === 'expand' ? { $expand: ['owner'] } : {}),
59
- } as any);
60
- const dataCall = calls.filter((u) => u.includes('/data/account')).pop();
61
- const raw = dataCall ? new URL(dataCall).searchParams.get('filter') : null;
62
- return raw === null ? undefined : JSON.parse(raw);
63
- }
64
-
65
- /** Run one input down both `find()` routes. */
66
- function bothRoutes(name: string, $filter: unknown, assert: (wire: unknown | undefined) => void) {
67
- for (const route of ['plain', 'expand'] as const) {
68
- it(`${name} (${route} route)`, async () => assert(await filterOnWire($filter, route)));
69
- }
70
- }
71
-
72
- async function findRejects($filter: unknown, route: 'plain' | 'expand') {
73
- const { adapter } = makeAdapter();
74
- return adapter
75
- .find('account', { $filter, ...(route === 'expand' ? { $expand: ['owner'] } : {}) } as any)
76
- .then(() => null, (e) => e);
77
- }
78
-
79
- describe('object-form filter entries reach the wire as an AST', () => {
80
- beforeEach(() => clearSharedDiscoveryCache());
81
-
82
- bothRoutes(
83
- 'translates a single rule, mapping the operator alias',
84
- [{ field: 'stage', operator: 'greater_than_or_equal', value: 3 }],
85
- (wire) => expect(wire).toEqual(['stage', '>=', 3]),
86
- );
87
-
88
- bothRoutes(
89
- 'joins several rules with `and`',
90
- [
91
- { field: 'stage', operator: 'eq', value: 'won' },
92
- { field: 'amount', operator: 'gt', value: 100 },
93
- ],
94
- (wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], ['amount', '>', 100]]),
95
- );
96
-
97
- bothRoutes(
98
- 'accepts the `op` shorthand key',
99
- [{ field: 'owner', op: 'eq', value: 'me' }],
100
- (wire) => expect(wire).toEqual(['owner', '=', 'me']),
101
- );
102
-
103
- bothRoutes(
104
- 'passes an AST tuple through untouched',
105
- ['stage', '=', 'won'],
106
- (wire) => expect(wire).toEqual(['stage', '=', 'won']),
107
- );
108
-
109
- bothRoutes(
110
- 'passes a logical AST node through untouched',
111
- ['or', ['stage', '=', 'won'], ['stage', '=', 'lost']],
112
- (wire) => expect(wire).toEqual(['or', ['stage', '=', 'won'], ['stage', '=', 'lost']]),
113
- );
114
-
115
- bothRoutes('sends no filter at all for an empty array', [], (wire) => expect(wire).toBeUndefined());
116
- });
117
-
118
- describe('a `name`-keyed entry is not an accepted shape', () => {
119
- beforeEach(() => clearSharedDiscoveryCache());
120
-
121
- /**
122
- * `objectFilterEntryToAST` read `entry.field ?? entry.name` while the shape
123
- * sniff keyed on `field` alone, so the `name` half could never be reached —
124
- * dead from the commit that introduced it. The spec agrees it is not a real
125
- * shape: `ViewFilterRuleSchema.field` is required, so such a rule cannot be
126
- * saved as view metadata. It reaches the server unchanged and is refused
127
- * there (objectstack#4121) rather than being quietly half-understood here.
128
- */
129
- bothRoutes(
130
- 'is passed through rather than translated',
131
- [{ name: 'stage', operator: 'eq', value: 'won' }],
132
- (wire) => expect(wire).toEqual([{ name: 'stage', operator: 'eq', value: 'won' }]),
133
- );
134
- });
135
-
136
- describe('an untranslatable entry fails instead of narrowing the filter', () => {
137
- beforeEach(() => clearSharedDiscoveryCache());
138
-
139
- for (const route of ['plain', 'expand'] as const) {
140
- it(`rejects a rule with a blank field rather than dropping it (${route})`, async () => {
141
- // Survives `ViewFilterRuleSchema` (`field: z.string()` admits ''), so it
142
- // can be genuine stored metadata. It used to be dropped, and dropping the
143
- // only rule sent no filter — the whole table came back.
144
- const err = await findRejects([{ field: '', operator: 'eq', value: 'x' }], route);
145
- expect(err).toBeInstanceOf(Error);
146
- expect(isMalformedFilterError(err)).toBe(true);
147
- });
148
-
149
- it(`rejects a bad entry rather than silently widening the AND (${route})`, async () => {
150
- const err = await findRejects(
151
- [
152
- { field: 'stage', operator: 'eq', value: 'won' },
153
- { field: 'amount', operator: 42 as any, value: 1 },
154
- ],
155
- route,
156
- );
157
- expect(isMalformedFilterError(err)).toBe(true);
158
- // Names which entry, so the author can find it in the view config.
159
- expect(String((err as Error).message)).toContain('entry 1');
160
- });
161
- }
162
-
163
- it('carries the status and code that classify it as a rejected request', async () => {
164
- const err: any = await findRejects([{ field: '', operator: 'eq' }], 'plain');
165
- // plugin-list's `classifyLoadError` reads these to pick the error panel;
166
- // without them a malformed filter reads as "check your connection" (#3066).
167
- expect(err.code).toBe('INVALID_FILTER');
168
- expect(err.httpStatus).toBe(400);
169
- });
170
- });
171
-
172
- describe('object-form rules nested inside a logical node are translated too', () => {
173
- beforeEach(() => clearSharedDiscoveryCache());
174
-
175
- /**
176
- * This is what a list builds the moment a view with a stored filter meets a
177
- * user filter from the panel — `['and', <ViewFilterRule[]>, <AST tuples>]`.
178
- * Translating only the top level shipped the rules raw, and the server has no
179
- * good answer for that: `isFilterAST` rejects the shape (so a server with
180
- * objectstack#4121 returns 400 and the list fails to load), while
181
- * `parseFilterAST` on the same array returns `{ amount: { $gt: 1 } }` — the
182
- * view's own `stage = won` dropped without a word.
183
- */
184
- bothRoutes(
185
- 'translates a rule group nested under `and`',
186
- ['and', [{ field: 'stage', operator: 'eq', value: 'won' }], [['amount', '>', 1]]],
187
- (wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], [['amount', '>', 1]]]),
188
- );
189
-
190
- bothRoutes(
191
- 'translates under `or`, and through more than one level',
192
- ['or', ['and', [{ field: 'stage', operator: 'not_in', value: ['lost'] }]], ['x', '=', 1]],
193
- (wire) => expect(wire).toEqual(['or', ['and', ['stage', 'nin', ['lost']]], ['x', '=', 1]]),
194
- );
195
-
196
- bothRoutes(
197
- 'translates a group inside a legacy flat array of children',
198
- [[{ field: 'stage', operator: 'eq', value: 'won' }], ['amount', '>', 1]],
199
- (wire) => expect(wire).toEqual([['stage', '=', 'won'], ['amount', '>', 1]]),
200
- );
201
-
202
- bothRoutes(
203
- 'leaves a field literally named `and` alone',
204
- ['and', '=', true],
205
- (wire) => expect(wire).toEqual(['and', '=', true]),
206
- );
207
-
208
- /**
209
- * A mixed array used to lose the tuple: it did not translate as a rule, so it
210
- * was filtered out and the query ran on the remaining condition alone —
211
- * broader than asked for. Keep both rather than dropping one or failing the
212
- * whole query.
213
- */
214
- bothRoutes(
215
- 'keeps both halves of a rule/tuple mixed array',
216
- [{ field: 'stage', operator: 'eq', value: 'won' }, ['amount', '>', 1]],
217
- (wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], ['amount', '>', 1]]),
218
- );
219
-
220
- it('rejects a malformed rule nested under `and` as well', async () => {
221
- const err = await findRejects(['and', [{ field: '', operator: 'eq', value: 1 }]], 'plain');
222
- expect(isMalformedFilterError(err)).toBe(true);
223
- });
224
- });
225
-
226
- describe('what this adapter emits passes the server’s own gate', () => {
227
- beforeEach(() => clearSharedDiscoveryCache());
228
-
229
- /**
230
- * The real contract is not a literal shape, it is "the server accepts this".
231
- * `isFilterAST` is the function the protocol gates on, so assert against it
232
- * directly rather than restating what it happens to accept today — a filter
233
- * it rejects is a 400 (objectstack#4121), and was an unfiltered query before
234
- * that (objectstack#3948).
235
- */
236
- const TRANSLATABLE: Array<[string, unknown]> = [
237
- ['a single rule', [{ field: 'stage', operator: 'eq', value: 'won' }]],
238
- ['several rules', [
239
- { field: 'stage', operator: 'contains', value: 'w' },
240
- { field: 'amount', operator: 'less_than_or_equal', value: 9 },
241
- ]],
242
- ['rules under `and` beside a tuple', [
243
- 'and', [{ field: 'stage', operator: 'before', value: '2024-01-01' }], ['amount', '>', 1],
244
- ]],
245
- ['rules inside a legacy flat array', [
246
- [{ field: 'stage', operator: 'is_null', value: true }], ['amount', '>', 1],
247
- ]],
248
- ['a mixed rule/tuple array', [{ field: 'stage', operator: 'eq', value: 'won' }, ['a', '>', 1]]],
249
- ];
250
-
251
- for (const [name, input] of TRANSLATABLE) {
252
- for (const route of ['plain', 'expand'] as const) {
253
- it(`${name} → accepted by isFilterAST (${route})`, async () => {
254
- expect(isFilterAST(await filterOnWire(input, route))).toBe(true);
255
- });
256
- }
257
- }
258
- });
259
-
260
- describe('a bare rule object directly under a logical node', () => {
261
- beforeEach(() => clearSharedDiscoveryCache());
262
-
263
- /**
264
- * Produced by any caller that SPREADS a `ViewFilterRule[]` into an `and`
265
- * rather than wrapping it — `['and', ...rules, ...tuples]`. The rules land as
266
- * bare objects where the AST expects nodes, `isFilterAST` rejects the whole
267
- * node (a 400 since objectstack#4121), and `parseFilterAST` reads the rule as
268
- * a Mongo condition on columns literally named `field`/`operator`/`value`.
269
- * objectui's own producer was fixed to wrap; this is the chokepoint defence.
270
- */
271
- bothRoutes(
272
- 'is translated in place',
273
- ['and', { field: 'stage', operator: 'eq', value: 'won' }, ['owner', '=', 'me']],
274
- (wire) => expect(wire).toEqual(['and', ['stage', '=', 'won'], ['owner', '=', 'me']]),
275
- );
276
-
277
- bothRoutes(
278
- 'leaves a genuine MongoDB condition child alone',
279
- // No `field` key, so it is a condition on a column named `status` — not a
280
- // rule. Translating it would invent a filter the caller never wrote.
281
- ['and', { status: 'active' }, ['owner', '=', 'me']],
282
- (wire) => expect(wire).toEqual(['and', { status: 'active' }, ['owner', '=', 'me']]),
283
- );
284
- });
285
-
286
- describe('an OBJECT filter reaches the same predicate on both routes', () => {
287
- beforeEach(() => clearSharedDiscoveryCache());
288
-
289
- /**
290
- * The array branch was single-sourced in #3072; the object branch was not.
291
- * `convertQueryParams` converted a MongoDB-style filter to AST while
292
- * `translateFilterToAST` returned it verbatim — so the same `$filter` went out
293
- * in two formats, decided by whether the query expanded a lookup.
294
- *
295
- * Measured across 21 operator shapes, four diverged. `$exists` vs `$null` was
296
- * a cosmetic difference the server treats identically, but the other three
297
- * were not: see below.
298
- */
299
- bothRoutes(
300
- 'converts a plain equality object',
301
- { status: 'active' },
302
- (wire) => expect(wire).toEqual(['status', '=', 'active']),
303
- );
304
-
305
- bothRoutes(
306
- 'converts an operator object',
307
- { age: { $gte: 18 } },
308
- (wire) => expect(wire).toEqual(['age', '>=', 18]),
309
- );
310
-
311
- bothRoutes(
312
- 'leaves a top-level Mongo logical node alone',
313
- // `mergeFilters` (dashboard scope filters) produces this. It survives as a
314
- // `['$and', '=', [...]]` comparison that `parseFilterAST` reads back as a
315
- // real `$and` — verified, and the reason this is NOT rewritten here.
316
- { $and: [{ a: 1 }, { b: 2 }] },
317
- (wire) => expect(wire).toEqual(['$and', '=', [{ a: 1 }, { b: 2 }]]),
318
- );
319
-
320
- for (const route of ['plain', 'expand'] as const) {
321
- it(`refuses an unknown operator on the ${route} route`, async () => {
322
- // The guard exists "to avoid silent failure" — it used to run on one
323
- // route only, so a typo shipped silently whenever a lookup was expanded.
324
- const err = await findRejects({ s: { $bogus: 1 } }, route);
325
- expect(err).toBeInstanceOf(Error);
326
- expect(String((err as Error).message)).toContain('$bogus');
327
- expect(isMalformedFilterError(err)).toBe(true);
328
- });
329
-
330
- it(`refuses $regex rather than answering a different question (${route})`, async () => {
331
- // Was silently rewritten to `contains`: `$regex: 'a.c'` matches "abc",
332
- // `contains 'a.c'` does not. The spec has no `$regex` to translate into.
333
- const err = await findRejects({ s: { $regex: 'a.c' } }, route);
334
- expect(isMalformedFilterError(err)).toBe(true);
335
- expect(String((err as Error).message)).toMatch(/regex/i);
336
- });
337
- }
338
- });
339
-
340
- describe('the two routes agree on shapes that are neither form', () => {
341
- beforeEach(() => clearSharedDiscoveryCache());
342
-
343
- /**
344
- * The inline copy of the sniff omitted the `!== null` guard that this one
345
- * had, so `[null]` threw a TypeError on the plain route and was handled on
346
- * the `$expand` route — the same stored filter, decided by whether the view
347
- * happened to expand a lookup.
348
- */
349
- bothRoutes(
350
- 'does not crash on a null entry',
351
- [null],
352
- (wire) => expect(wire).toEqual([null]),
353
- );
354
-
355
- bothRoutes(
356
- 'leaves a nested legacy AST alone',
357
- [['stage', '=', 'won'], ['amount', '>', 1]],
358
- (wire) => expect(wire).toEqual([['stage', '=', 'won'], ['amount', '>', 1]]),
359
- );
360
- });
@@ -1,135 +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
- * Adapter operator table → filter-AST parity (#2901, objectstack#3948, #3641).
11
- *
12
- * `FILTER_OPERATOR_ALIASES` is the last translation a filter passes through
13
- * before it goes on the wire, and `normalizeFilterOperator` ends in `?? op` —
14
- * an unmapped operator is emitted verbatim. The server then rejects the shape
15
- * at `isFilterAST()`, passes the array through unconverted, and driver-sql
16
- * skips it entirely: **no WHERE clause, no error, every row returned.**
17
- *
18
- * So a missing row in this table is not a validation failure, it is an
19
- * unfiltered query. `before`/`after` — canonical members of the spec's
20
- * `VIEW_FILTER_OPERATORS` — were missing, which is exactly how a stored
21
- * "close_date before X" view came back unfiltered.
22
- *
23
- * That failure description is historical, and one part of it has moved (measured
24
- * for #3641, not assumed): on the published `@objectstack/driver-sql@17.0.0-rc.5`
25
- * the array form is lowered by `parseFilterAST()` at the engine and protocol
26
- * doors, and an array reaching the driver anyway is refused with a 400
27
- * (`unsupportedFilterError`, objectstack#5158) rather than skipped. What the
28
- * protocol door does with an array the AST gate REJECTS is not measurable from
29
- * this repo, so whether a missing row costs an unfiltered read or a hard 400
30
- * today is deliberately left open. Both are a broken query.
31
- *
32
- * ## What the coverage guarantee rests on (#3641)
33
- *
34
- * The coverage sweep below used to mirror production's `?? op` tail: resolve the
35
- * operator through the table, fall back to the raw view spelling, then ask
36
- * whether the result was a member of `VALID_AST_OPERATORS`. That reads as a
37
- * coverage assertion and is not one. `VALID_AST_OPERATORS` is derived upstream
38
- * from `AST_OPERATOR_MAP`, it grew until it spelled the canonical view operators
39
- * verbatim — `before` and `after` included — and from that moment the fallback
40
- * arm was always AST-valid. Emptying this entire table left the sweep green, so
41
- * the one thing it existed to catch, a missing row, had become invisible to it.
42
- *
43
- * The sweep therefore asserts the row EXISTS, with no fallback: the header above
44
- * says a missing row is an unfiltered query rather than a validation failure, so
45
- * a missing row is what is asserted, directly. What the rows resolve TO is a
46
- * separate question, kept as its own assertion below — and the pair that
47
- * actually regressed is pinned by spelling, not by membership.
48
- */
49
- import { describe, it, expect } from 'vitest';
50
- import { VALID_AST_OPERATORS } from '@objectstack/spec/data';
51
- import { VIEW_FILTER_OPERATORS } from '@objectstack/spec/ui';
52
- import { FILTER_OPERATOR_ALIASES } from './index';
53
-
54
- /**
55
- * View operators this adapter is not the bridge for — the value-shape ones the
56
- * view layer resolves to a null comparison before an operator is ever emitted.
57
- *
58
- * Every token here must still be a member of `VIEW_FILTER_OPERATORS` — the
59
- * ratchet below enforces it. Subtracting a name the spec has retired excuses
60
- * nothing and must be deleted rather than left as a dead subtraction (#3628).
61
- */
62
- const NOT_THIS_ADAPTERS_JOB = new Set(['is_empty', 'is_not_empty']);
63
-
64
- describe('FILTER_OPERATOR_ALIASES lands inside the spec AST vocabulary', () => {
65
- it('reads both vocabularies from the spec', () => {
66
- expect(VIEW_FILTER_OPERATORS.length).toBeGreaterThan(0);
67
- expect(VALID_AST_OPERATORS.size).toBeGreaterThan(0);
68
- });
69
-
70
- // The exclusion ratchet (#3628). The coverage sweep further down subtracts a
71
- // hand-written set from a spec-derived vocabulary, and that subtraction only
72
- // excuses something while the spec still lists the subtracted tokens. Once
73
- // upstream retires or renames one, the sweep stays green (it is still total
74
- // over what remains) but the row becomes dead weight, and its comment goes on
75
- // telling the next reader that the view layer resolves this one to a null
76
- // comparison — about an operator no author can declare any more. That is the
77
- // shape that rotted 37 of 82 deny-list entries in #3601 with nothing to report
78
- // it: a hand-written list beside a spec-derived vocabulary and no assertion
79
- // that its members still exist in that vocabulary.
80
- //
81
- // Collected rather than asserted per entry on purpose (same call as PR #3623):
82
- // vocabulary retirements land as whole families, and failing on the first entry
83
- // would hide the rest.
84
- it('every NOT_THIS_ADAPTERS_JOB token is still in the spec view vocabulary', () => {
85
- const vocabulary = new Set<string>(VIEW_FILTER_OPERATORS);
86
- const retired = [...NOT_THIS_ADAPTERS_JOB].filter((op) => !vocabulary.has(op));
87
- expect(
88
- retired,
89
- `VIEW_FILTER_OPERATORS no longer lists these NOT_THIS_ADAPTERS_JOB tokens: `
90
- + `${retired.join(', ')}. The spec has retired them, so subtracting them from `
91
- + 'the coverage sweep below excuses nothing — delete each from the set (with '
92
- + 'the comment claiming the view layer resolves it) rather than leaving a dead '
93
- + 'subtraction',
94
- ).toEqual([]);
95
- });
96
-
97
- it('every alias target is an operator the AST gate accepts', () => {
98
- const bad = Object.entries(FILTER_OPERATOR_ALIASES)
99
- .filter(([, target]) => !VALID_AST_OPERATORS.has(String(target).toLowerCase()))
100
- .map(([alias, target]) => `${alias} → ${target}`);
101
- expect(
102
- bad,
103
- 'these aliases translate to operators VALID_AST_OPERATORS rejects, so the '
104
- + 'server drops the filter silently instead of erroring',
105
- ).toEqual([]);
106
- });
107
-
108
- it('has a mapping row for every canonical view operator the spec defines', () => {
109
- // Resolution mirrors `normalizeFilterOperator` — lowercased spelling first,
110
- // then the operator as written — but stops short of its `?? op` tail. That
111
- // tail is production behaviour and must stay there; reproducing it HERE is
112
- // what cancelled this assertion (#3641), because the value it falls back to
113
- // is the raw view spelling and the AST vocabulary now accepts all of those.
114
- const hasRow = (op: string) =>
115
- Object.prototype.hasOwnProperty.call(FILTER_OPERATOR_ALIASES, op.toLowerCase())
116
- || Object.prototype.hasOwnProperty.call(FILTER_OPERATOR_ALIASES, op);
117
-
118
- const missing = VIEW_FILTER_OPERATORS
119
- .filter((op) => !NOT_THIS_ADAPTERS_JOB.has(op))
120
- .filter((op) => !hasRow(op));
121
- expect(
122
- missing,
123
- 'FILTER_OPERATOR_ALIASES has no row for these. An author can declare them on a '
124
- + 'ViewFilterRule and the spec validates them, so they reach the wire as the raw '
125
- + 'view spelling. Do not settle for "the AST gate happens to accept that" — the '
126
- + 'gate accepting a spelling is not the driver compiling it into a WHERE clause, '
127
- + 'and an unmapped operator is how this adapter shipped an unfiltered query',
128
- ).toEqual([]);
129
- });
130
-
131
- it('maps the date comparisons that regressed', () => {
132
- expect(FILTER_OPERATOR_ALIASES.before).toBe('<');
133
- expect(FILTER_OPERATOR_ALIASES.after).toBe('>');
134
- });
135
- });
@@ -1,145 +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
- /**
13
- * A fetch mock that answers discovery + records the `GET /meta/object/:name`
14
- * read so we can assert how it was issued.
15
- */
16
- function makeFetch(objectBody: unknown) {
17
- const calls: Array<{ url: string; init?: any }> = [];
18
- const fetchImpl = vi.fn(async (url: any, init?: any) => {
19
- const u = String(url);
20
- calls.push({ url: u, init });
21
- if (u.includes('/api/v1/discovery')) {
22
- return { ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data: { version: 'v1', routes: {} } }) } as any;
23
- }
24
- if (u.includes('/api/v1/meta/object/')) {
25
- return { ok: true, status: 200, statusText: 'OK', json: async () => objectBody } as any;
26
- }
27
- return { ok: true, status: 200, statusText: 'OK', json: async () => ({}) } as any;
28
- });
29
- return { fetchImpl, calls };
30
- }
31
-
32
- describe('ObjectStackAdapter.getObjectSchema', () => {
33
- beforeEach(() => clearSharedDiscoveryCache());
34
-
35
- it('reads with cache: "no-cache" so a freshly published field is not masked by the server max-age', async () => {
36
- // The server marks single-object metadata `public, max-age=3600`; without
37
- // revalidation the browser HTTP cache would keep serving the pre-publish
38
- // schema and the create/edit form would miss fields published this session.
39
- const { fetchImpl, calls } = makeFetch({
40
- name: 'leave_request',
41
- fields: { employee_name: { type: 'text' }, department: { type: 'text' } },
42
- });
43
- const adapter = new ObjectStackAdapter({
44
- baseUrl: 'http://localhost:3000',
45
- token: 'tok_123',
46
- autoReconnect: false,
47
- fetch: fetchImpl as any,
48
- });
49
-
50
- const schema: any = await adapter.getObjectSchema('leave_request');
51
-
52
- // Returns the (unwrapped) object schema with its fields.
53
- expect(Object.keys(schema.fields)).toEqual(['employee_name', 'department']);
54
-
55
- const get = calls.find((c) => c.url.includes('/api/v1/meta/object/'))!;
56
- expect(get.url).toBe('http://localhost:3000/api/v1/meta/object/leave_request');
57
- expect(get.init.method).toBe('GET');
58
- // The crux of the fix: revalidate the HTTP cache instead of serving the
59
- // stale `max-age` body.
60
- expect(get.init.cache).toBe('no-cache');
61
- expect(get.init.headers.Authorization).toBe('Bearer tok_123');
62
- });
63
-
64
- it('unwraps the { item } envelope when the server wraps the object', async () => {
65
- const { fetchImpl } = makeFetch({ item: { name: 'account', fields: { x: { type: 'text' } } } });
66
- const adapter = new ObjectStackAdapter({
67
- baseUrl: 'http://localhost:3000',
68
- autoReconnect: false,
69
- fetch: fetchImpl as any,
70
- });
71
-
72
- const schema: any = await adapter.getObjectSchema('account');
73
-
74
- expect(schema.name).toBe('account');
75
- expect(Object.keys(schema.fields)).toEqual(['x']);
76
- });
77
-
78
- it('canonicalizes the ObjectStack-convention `reference` key onto `reference_to` (and back)', async () => {
79
- // The server names a relational field's target `reference`
80
- // (showcase_project.account → { type: 'lookup', reference: 'showcase_account' }),
81
- // while most consumers read `reference_to` (#2407 / PR #2587). getObjectSchema
82
- // is the choke point every schema read goes through, so both keys must come
83
- // back stamped — regardless of which convention the served schema used.
84
- const { fetchImpl } = makeFetch({
85
- name: 'showcase_project',
86
- fields: {
87
- name: { type: 'text' },
88
- account: { type: 'lookup', reference: 'showcase_account' },
89
- team_members: { type: 'user', reference: 'sys_user', multiple: true },
90
- legacy: { type: 'master_detail', reference_to: 'showcase_order' },
91
- },
92
- });
93
- const adapter = new ObjectStackAdapter({
94
- baseUrl: 'http://localhost:3000',
95
- autoReconnect: false,
96
- fetch: fetchImpl as any,
97
- });
98
-
99
- const schema: any = await adapter.getObjectSchema('showcase_project');
100
-
101
- expect(schema.fields.account.reference_to).toBe('showcase_account');
102
- expect(schema.fields.team_members.reference_to).toBe('sys_user');
103
- // Mirror direction: `.reference`-only readers (e.g. attachInlineSubforms)
104
- // must also see the target on a reference_to-authored schema.
105
- expect(schema.fields.legacy.reference).toBe('showcase_order');
106
- // Non-relational fields stay untouched.
107
- expect('reference_to' in schema.fields.name).toBe(false);
108
- });
109
-
110
- it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
111
- // Inert-metadata regression guard. The inline select editor filters its
112
- // options by the object's `state_machine` validation (objectui#2110). That
113
- // feature only works because `getObjectSchema` passes the served
114
- // `validations` through untouched — the server enforces the same rule. If a
115
- // future change here started stripping or reshaping top-level metadata, the
116
- // filter would silently no-op (valid-but-inert) with no error. Pin the
117
- // pass-through so the data path can't go dark unnoticed.
118
- const { fetchImpl } = makeFetch({
119
- name: 'showcase_task',
120
- fields: { status: { type: 'select', options: [{ value: 'done', label: 'Done' }] } },
121
- validations: [
122
- {
123
- type: 'state_machine',
124
- field: 'status',
125
- transitions: { in_review: ['done', 'in_progress'], done: ['in_progress'] },
126
- },
127
- ],
128
- });
129
- const adapter = new ObjectStackAdapter({
130
- baseUrl: 'http://localhost:3000',
131
- autoReconnect: false,
132
- fetch: fetchImpl as any,
133
- });
134
-
135
- const schema: any = await adapter.getObjectSchema('showcase_task');
136
-
137
- const sm = (schema.validations ?? []).find((v: any) => v?.type === 'state_machine');
138
- expect(sm).toBeTruthy();
139
- expect(sm.field).toBe('status');
140
- // The exact map the consumer filters on — `done` only transitions to
141
- // `in_progress` (so the editor must not offer `in_review`).
142
- expect(sm.transitions.done).toEqual(['in_progress']);
143
- expect(sm.transitions.in_review).toEqual(['done', 'in_progress']);
144
- });
145
- });