@object-ui/data-objectstack 17.5.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 (52) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/index.cjs +262 -357
  3. package/dist/index.d.cts +385 -448
  4. package/dist/index.d.ts +385 -448
  5. package/dist/index.js +258 -350
  6. package/package.json +5 -6
  7. package/dist/tsconfig.tsbuildinfo +0 -1
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/apiAccessDenied.test.ts +0 -233
  10. package/src/appAccessProbe.test.ts +0 -147
  11. package/src/bulkDelete.test.ts +0 -75
  12. package/src/bulkUpdate.test.ts +0 -93
  13. package/src/cache/MetadataCache.test.ts +0 -477
  14. package/src/cache/MetadataCache.ts +0 -289
  15. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  16. package/src/connection.test.ts +0 -188
  17. package/src/contracts.ts +0 -115
  18. package/src/deleteView.homes.test.ts +0 -389
  19. package/src/deleteViewContract.types.test.ts +0 -246
  20. package/src/errors.test.ts +0 -426
  21. package/src/errors.ts +0 -293
  22. package/src/exportDownload.test.ts +0 -141
  23. package/src/filter-entry-translation.test.ts +0 -360
  24. package/src/filter-operator-ast-parity.test.ts +0 -135
  25. package/src/getObjectSchema.test.ts +0 -145
  26. package/src/httpFailureLogging.test.ts +0 -109
  27. package/src/importJob.test.ts +0 -71
  28. package/src/index.ts +0 -4442
  29. package/src/integration.ts +0 -200
  30. package/src/listViewOverrides.test.ts +0 -241
  31. package/src/listViews.test.ts +0 -175
  32. package/src/metadata-client.get-envelope.test.ts +0 -171
  33. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  34. package/src/metadata-client.test.ts +0 -303
  35. package/src/metadata-client.ts +0 -1046
  36. package/src/occ.test.ts +0 -101
  37. package/src/onMutation.test.ts +0 -589
  38. package/src/onSaveAdvisory.test.ts +0 -311
  39. package/src/onWriteWarning.test.ts +0 -273
  40. package/src/orderby-serialization.test.ts +0 -115
  41. package/src/queryDataset.test.ts +0 -543
  42. package/src/searchAll.test.ts +0 -120
  43. package/src/security.ts +0 -243
  44. package/src/spec-symbol-batch6.test.ts +0 -326
  45. package/src/studio.ts +0 -152
  46. package/src/updateView.draft.test.ts +0 -242
  47. package/src/userState.test.ts +0 -315
  48. package/src/userState.ts +0 -238
  49. package/src/v3-compat.test.ts +0 -229
  50. package/src/validation-error.test.ts +0 -94
  51. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  52. package/src/viewInvalidationSeam.pin.test.ts +0 -162
@@ -1,229 +0,0 @@
1
- /**
2
- * v3.0.0 compatibility tests for @objectstack dependencies.
3
- * Validates that all integration modules work correctly with the spec.
4
- */
5
- import { describe, it, expect } from 'vitest';
6
- import { validatePluginContract, generateContractManifest } from './contracts';
7
- import { IntegrationManager } from './integration';
8
- import { SecurityManager } from './security';
9
- import { createDefaultCanvasConfig, snapToGrid, calculateAutoLayout } from './studio';
10
-
11
- // The `Cloud namespace (replacing Hub)` block that used to open this file is
12
- // gone with its subject (objectui#4152). Its three cases constructed
13
- // `CloudOperations` against a client stub of `{}` and asserted that the results
14
- // had the right KEYS — which is precisely how the surface stayed green for as
15
- // long as it did: every method optional-chained into a `cloud` namespace no
16
- // released `@objectstack/client` has ever exported, so what the assertions were
17
- // reading was the fallback literal, not a deployment. The replacement is a
18
- // negative pin next door, `cloud-surface-retired-4152.pin.test.ts`, which fails
19
- // if the exports come back.
20
-
21
- describe('v3.0.0 Compatibility', () => {
22
- describe('Contracts module', () => {
23
- it('should validate a valid plugin contract', () => {
24
- const contract = {
25
- name: 'my-plugin',
26
- version: '1.0.0',
27
- exports: [{ name: 'MyComponent', type: 'component' as const }],
28
- permissions: ['data.read'],
29
- };
30
- const result = validatePluginContract(contract);
31
- expect(result.valid).toBe(true);
32
- expect(result.errors).toHaveLength(0);
33
- });
34
-
35
- it('should reject invalid plugin contract', () => {
36
- const contract = {
37
- name: '',
38
- version: 'invalid',
39
- exports: [],
40
- };
41
- const result = validatePluginContract(contract);
42
- expect(result.valid).toBe(false);
43
- expect(result.errors.length).toBeGreaterThan(0);
44
- });
45
-
46
- it('should generate contract manifest', () => {
47
- const contract = {
48
- name: 'test-plugin',
49
- version: '2.0.0',
50
- exports: [{ name: 'GridView', type: 'component' as const, description: 'A grid view' }],
51
- permissions: ['data.read', 'data.write'],
52
- };
53
- const manifest = generateContractManifest(contract);
54
- expect(manifest.$schema).toBeDefined();
55
- expect(manifest.name).toBe('test-plugin');
56
- expect(manifest.version).toBe('2.0.0');
57
- });
58
- });
59
-
60
- describe('Integration module', () => {
61
- it('should register and retrieve integrations', () => {
62
- const manager = new IntegrationManager();
63
- manager.register('slack-1', {
64
- provider: 'slack',
65
- enabled: true,
66
- config: { webhookUrl: 'https://hooks.slack.com/test' },
67
- triggers: [{ event: 'record.created' }],
68
- });
69
-
70
- const all = manager.getAll();
71
- expect(all.size).toBe(1);
72
- });
73
-
74
- it('should filter integrations by event', () => {
75
- const manager = new IntegrationManager();
76
- manager.register('webhook-1', {
77
- provider: 'webhook',
78
- enabled: true,
79
- config: { url: 'https://example.com/hook', method: 'POST' },
80
- triggers: [{ event: 'record.created' }],
81
- });
82
- manager.register('webhook-2', {
83
- provider: 'webhook',
84
- enabled: true,
85
- config: { url: 'https://example.com/hook2', method: 'POST' },
86
- triggers: [{ event: 'record.deleted' }],
87
- });
88
-
89
- const createMatches = manager.getForEvent('record.created');
90
- expect(createMatches).toHaveLength(1);
91
- });
92
-
93
- it('should unregister integrations', () => {
94
- const manager = new IntegrationManager();
95
- manager.register('test', {
96
- provider: 'webhook',
97
- enabled: true,
98
- config: { url: 'https://example.com', method: 'POST' },
99
- });
100
- manager.unregister('test');
101
- expect(manager.getAll().size).toBe(0);
102
- });
103
- });
104
-
105
- describe('Security module', () => {
106
- it('should generate CSP header', () => {
107
- const manager = new SecurityManager({
108
- csp: {
109
- scriptSrc: ["'self'", "'unsafe-inline'"],
110
- styleSrc: ["'self'", "'unsafe-inline'"],
111
- imgSrc: ["'self'", 'data:', 'https:'],
112
- connectSrc: ["'self'", 'https://api.example.com'],
113
- fontSrc: ["'self'"],
114
- },
115
- });
116
-
117
- const header = manager.generateCSPHeader();
118
- expect(header).toContain("script-src 'self' 'unsafe-inline'");
119
- expect(header).toContain("connect-src 'self' https://api.example.com");
120
- });
121
-
122
- it('should record and filter audit logs', () => {
123
- const manager = new SecurityManager({
124
- auditLog: {
125
- enabled: true,
126
- events: ['data.create', 'data.read', 'auth.login'],
127
- destination: 'console',
128
- },
129
- });
130
-
131
- manager.recordAudit({ event: 'data.create', userId: 'user-1', resource: 'accounts' });
132
- manager.recordAudit({ event: 'auth.login', userId: 'user-2' });
133
- manager.recordAudit({ event: 'data.create', userId: 'user-1', resource: 'contacts' });
134
-
135
- const allLogs = manager.getAuditLog();
136
- expect(allLogs).toHaveLength(3);
137
-
138
- const userLogs = manager.getAuditLog({ userId: 'user-1' });
139
- expect(userLogs).toHaveLength(2);
140
-
141
- const loginLogs = manager.getAuditLog({ event: 'auth.login' });
142
- expect(loginLogs).toHaveLength(1);
143
- });
144
-
145
- it('should mask record data', () => {
146
- const manager = new SecurityManager({
147
- dataMasking: {
148
- rules: [
149
- { field: 'ssn', strategy: 'partial', visibleChars: 4 },
150
- { field: 'password', strategy: 'redact' },
151
- { field: 'email', strategy: 'full' },
152
- { field: 'phone', strategy: 'partial', visibleChars: 3, exemptRoles: ['admin'] },
153
- ],
154
- },
155
- });
156
-
157
- const record = {
158
- ssn: '123-45-6789',
159
- password: 'secret123',
160
- email: 'john@example.com',
161
- phone: '555-1234',
162
- name: 'John',
163
- };
164
-
165
- const masked = manager.maskRecord(record);
166
- expect(masked.ssn).toBe('123-*******');
167
- expect(masked.password).toBe('[REDACTED]');
168
- expect(masked.email).toBe('****************');
169
- expect(masked.name).toBe('John'); // Not masked
170
-
171
- // Admin sees phone unmasked
172
- const adminMasked = manager.maskRecord(record, ['admin']);
173
- expect(adminMasked.phone).toBe('555-1234');
174
- });
175
- });
176
-
177
- describe('Studio module', () => {
178
- it('should create default canvas config', () => {
179
- const config = createDefaultCanvasConfig();
180
- expect(config.width).toBe(1200);
181
- expect(config.height).toBe(800);
182
- expect(config.snapToGrid).toBe(true);
183
- expect(config.zoom.min).toBe(0.25);
184
- expect(config.zoom.max).toBe(3);
185
- });
186
-
187
- it('should create canvas config with overrides', () => {
188
- const config = createDefaultCanvasConfig({ width: 1600, showMinimap: true });
189
- expect(config.width).toBe(1600);
190
- expect(config.showMinimap).toBe(true);
191
- expect(config.height).toBe(800); // Default
192
- });
193
-
194
- it('should snap positions to grid', () => {
195
- expect(snapToGrid(13, 27, 8)).toEqual({ x: 16, y: 24 });
196
- expect(snapToGrid(0, 0, 8)).toEqual({ x: 0, y: 0 });
197
- expect(snapToGrid(4, 4, 8)).toEqual({ x: 8, y: 8 });
198
- });
199
-
200
- it('should calculate auto-layout positions', () => {
201
- const items = [
202
- { id: '1', width: 200, height: 150 },
203
- { id: '2', width: 200, height: 100 },
204
- { id: '3', width: 200, height: 200 },
205
- ];
206
- const positions = calculateAutoLayout(items, 1200);
207
- expect(positions).toHaveLength(3);
208
- expect(positions[0].x).toBe(40); // padding
209
- expect(positions[0].y).toBe(40); // padding
210
- expect(positions[1].x).toBeGreaterThan(positions[0].x);
211
- });
212
- });
213
-
214
- describe('PaginatedResult API (records/total/hasMore)', () => {
215
- it('should support v3.0.0 PaginatedResult fields', () => {
216
- // Verify the QueryResult type supports records/total/hasMore
217
- const result = {
218
- data: [{ id: '1' }],
219
- total: 10,
220
- page: 1,
221
- pageSize: 5,
222
- hasMore: true,
223
- };
224
- expect(result.data).toHaveLength(1);
225
- expect(result.total).toBe(10);
226
- expect(result.hasMore).toBe(true);
227
- });
228
- });
229
- });
@@ -1,94 +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 } from 'vitest';
10
-
11
- import { normaliseClientError } from './index';
12
- import { DataApiValidationError } from './errors';
13
-
14
- /**
15
- * The server has always sent per-field rejection detail; the adapter used to
16
- * hand callers the raw client error, so `fields[]` had no typed home and every
17
- * form fell back to one undirected toast.
18
- */
19
- describe('normaliseClientError — VALIDATION_FAILED', () => {
20
- // `@objectstack/rest` serves a thrown validation failure as 400 with `fields[]`
21
- // verbatim; `@objectstack/client` sets `error.details` to the parsed body's
22
- // `details` OR — as here, since the envelope has no such key — the whole body.
23
- const upstream = () =>
24
- Object.assign(new Error('Name is required; Stage is not a valid option'), {
25
- code: 'VALIDATION_FAILED',
26
- httpStatus: 400,
27
- details: {
28
- error: 'Name is required; Stage is not a valid option',
29
- code: 'VALIDATION_FAILED',
30
- fields: [
31
- { field: 'name', code: 'required', message: 'Name is required' },
32
- { field: 'stage', code: 'invalid_option', message: 'Stage is not a valid option' },
33
- ],
34
- object: 'crm_opportunity',
35
- },
36
- });
37
-
38
- it('wraps it into a typed DataApiValidationError', () => {
39
- const normalised = normaliseClientError(upstream());
40
- expect(normalised).toBeInstanceOf(DataApiValidationError);
41
- });
42
-
43
- it('carries every field entry through', () => {
44
- const normalised = normaliseClientError(upstream()) as DataApiValidationError;
45
- expect(normalised.validationErrors).toEqual([
46
- { field: 'name', message: 'Name is required' },
47
- { field: 'stage', message: 'Stage is not a valid option' },
48
- ]);
49
- });
50
-
51
- it('keeps the server message verbatim — it names every field', () => {
52
- const normalised = normaliseClientError(upstream()) as DataApiValidationError;
53
- expect(normalised.message).toBe('Name is required; Stage is not a valid option');
54
- });
55
-
56
- it('exposes the first offending field on `.field`', () => {
57
- const normalised = normaliseClientError(upstream()) as DataApiValidationError;
58
- expect(normalised.field).toBe('name');
59
- });
60
-
61
- it('falls back to the machine code when an entry has no message', () => {
62
- const err = Object.assign(new Error('Validation failed'), {
63
- code: 'VALIDATION_FAILED',
64
- details: { fields: [{ field: 'name', code: 'required' }] },
65
- });
66
- const normalised = normaliseClientError(err) as DataApiValidationError;
67
- expect(normalised.validationErrors).toEqual([{ field: 'name', message: 'required' }]);
68
- });
69
-
70
- it('drops entries with no field rather than inventing one', () => {
71
- const err = Object.assign(new Error('Validation failed'), {
72
- code: 'VALIDATION_FAILED',
73
- details: { fields: [{ message: 'something is wrong' }] },
74
- });
75
- const normalised = normaliseClientError(err) as DataApiValidationError;
76
- expect(normalised.validationErrors).toEqual([]);
77
- });
78
-
79
- it('still wraps when the error carries `fields` directly (hand-rolled shape)', () => {
80
- // The server duck-types on `code`/`name`, so a hook throwing the bare shape
81
- // is served identically — the client must not be pickier than the server.
82
- const err = Object.assign(new Error('nope'), {
83
- name: 'ValidationError',
84
- fields: [{ field: 'amount', message: 'Amount must be positive' }],
85
- });
86
- const normalised = normaliseClientError(err) as DataApiValidationError;
87
- expect(normalised.validationErrors).toEqual([{ field: 'amount', message: 'Amount must be positive' }]);
88
- });
89
-
90
- it('leaves an unrelated 400 untouched', () => {
91
- const err = Object.assign(new Error('bad request'), { code: 'BAD_REQUEST', httpStatus: 400 });
92
- expect(normaliseClientError(err)).toBe(err);
93
- });
94
- });
@@ -1,260 +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, vi } from 'vitest';
10
- import { ObjectStackAdapter } from './index';
11
-
12
- /**
13
- * View metadata cache keys — invalidation matches the read keys (objectui#3778).
14
- *
15
- * `ObjectStackAdapter` caches exactly two view-shaped reads:
16
- *
17
- * | reader | cache key |
18
- * |---------------------|------------------------------|
19
- * | `getView` | `view:{object}:{viewId}` |
20
- * | `listViewOverrides` | `view-overrides:{object}` |
21
- *
22
- * `listViews` is **not** one of them: it fetches `meta.getItems('view')`
23
- * directly on every call, with no `metadataCache.get` wrapper. Five write
24
- * paths nevertheless used to invalidate a `views:{object}` key that no read
25
- * path has ever populated — five permanent no-ops. They are gone; these pins
26
- * keep both halves of that honest:
27
- *
28
- * 1. the surviving invalidations still name the keys that DO have readers, so
29
- * the deletion cannot be mistaken for "cache invalidation was dropped"; and
30
- * 2. `listViews` keeps its uncached behavior — the deletion changed no
31
- * request count, which is what makes it a pure dead-code removal rather
32
- * than a caching change. (Whether `listViews` SHOULD be cached is a
33
- * separate product question, deliberately not settled here.)
34
- *
35
- * ## objectui#4363 — every write path names BOTH keys
36
- *
37
- * Removing the dead key made the surviving asymmetry visible: only
38
- * `updateViewConfig` invalidated `view-overrides:{object}`, so `createView` /
39
- * `updateView` / `deleteView` left the batch map stale for the cache's
40
- * 5-minute TTL. It does not self-heal — `loadViewOverrides` (app-shell
41
- * `ObjectView`) treats a RESOLVED map as authoritative and deliberately does
42
- * not re-probe per view (#3774), so the per-view `getView` fallback that would
43
- * have masked a stale map is by design unreachable.
44
- *
45
- * So the rule these pins now enforce is uniform and per-METHOD, not
46
- * per-branch: **a write to a view row invalidates the per-view key and the
47
- * object's override map.** Four paths (five call sites — `updateView` has a
48
- * draft half and a published half) emit the same ordered pair. The two sweep
49
- * pins are untouched controls: `listViews` stays uncached, and no path names a
50
- * `views:` key.
51
- */
52
-
53
- interface Harness {
54
- ds: any;
55
- /** Every key passed to `metadataCache.invalidate`, in order. */
56
- invalidated: string[];
57
- /** Every key passed to `metadataCache.get`, in order. */
58
- cacheReads: string[];
59
- getItems: ReturnType<typeof vi.fn>;
60
- saveItem: ReturnType<typeof vi.fn>;
61
- deleteItem: ReturnType<typeof vi.fn>;
62
- }
63
-
64
- /**
65
- * Adapter with a recording metadata cache.
66
- *
67
- * @param opts.items what `client.meta.getItems('view')` returns
68
- * @param opts.published what `client.meta.getItem` answers (body, or an
69
- * Error to throw — a 404 means "no published overlay")
70
- * @param opts.draft body served at `GET /meta/view/:name?state=draft`
71
- * (`null` → 404, i.e. nothing pending)
72
- */
73
- function makeDS(opts: {
74
- items?: any[];
75
- published?: any | Error;
76
- draft?: any | null;
77
- } = {}): Harness {
78
- const invalidated: string[] = [];
79
- const cacheReads: string[] = [];
80
-
81
- const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
82
- const url = String(input);
83
- const json = (body: unknown, status = 200) =>
84
- new Response(JSON.stringify(body), {
85
- status,
86
- headers: { 'Content-Type': 'application/json' },
87
- });
88
- if (url.includes('/meta/view/')) {
89
- if ((init?.method ?? 'GET') === 'PUT') return json({ success: true, version: 2 });
90
- // #4479 — `deleteView` addresses BOTH homes over this wire, so the
91
- // harness has to answer DELETE. The framework never 404s a missing
92
- // home: it reports one with a 200 carrying `reset:false`. Modelled
93
- // faithfully (a home "has a row" exactly when this harness was told to
94
- // serve one) so the pins below stay pins on INVALIDATION rather than
95
- // becoming accidental assertions about the transport.
96
- if ((init?.method ?? 'GET') === 'DELETE') {
97
- const hasRow = url.includes('state=draft')
98
- ? opts.draft != null
99
- : opts.published != null && !(opts.published instanceof Error);
100
- return json({ success: true, reset: hasRow });
101
- }
102
- if (url.includes('state=draft')) {
103
- if (opts.draft == null) return json({ error: 'not found' }, 404);
104
- return json({ type: 'view', name: opts.draft.name, item: opts.draft });
105
- }
106
- return json({ error: 'not found' }, 404);
107
- }
108
- return json({ success: true, data: { capabilities: {}, routes: {} } });
109
- });
110
-
111
- const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
112
- ds.connected = true;
113
- ds.connectionState = 'connected';
114
-
115
- // Recording stand-in for the real MetadataCache: `get` records the key and
116
- // always misses (runs the loader), `invalidate` records the key.
117
- ds.metadataCache = {
118
- get: async (key: string, loader: () => Promise<any>) => {
119
- cacheReads.push(key);
120
- return loader();
121
- },
122
- invalidate: (key: string) => {
123
- invalidated.push(key);
124
- },
125
- getCachedSync: () => undefined,
126
- getStats: () => ({}),
127
- };
128
-
129
- const getItems = vi.fn(async () => ({ items: opts.items ?? [] }));
130
- const saveItem = vi.fn(async () => ({ success: true }));
131
- const deleteItem = vi.fn(async () => ({ deleted: true }));
132
- ds.client = {
133
- meta: {
134
- getItems,
135
- saveItem,
136
- deleteItem,
137
- getItem: vi.fn(async () => {
138
- if (opts.published instanceof Error) throw opts.published;
139
- return { item: opts.published ?? { name: 'v1', object: 'account' } };
140
- }),
141
- },
142
- };
143
-
144
- return { ds, invalidated, cacheReads, getItems, saveItem, deleteItem };
145
- }
146
-
147
- /** A published read that 404s, decorated the way the SDK client decorates. */
148
- function notFound(): Error {
149
- return Object.assign(new Error('Metadata item not found'), { httpStatus: 404 });
150
- }
151
-
152
- const VIEW = {
153
- name: 'account.all',
154
- object: 'account',
155
- viewKind: 'list',
156
- label: 'All Accounts',
157
- config: { type: 'grid', data: { object: 'account' } },
158
- };
159
-
160
- describe('view metadata cache — invalidation names only keys with readers (#3778)', () => {
161
- it('listViews reads the transport on every call and consults no cache key', async () => {
162
- const { ds, getItems, cacheReads } = makeDS({ items: [VIEW] });
163
-
164
- await ds.listViews('account');
165
- await ds.listViews('account');
166
-
167
- // Uncached: two calls, two round trips. This is the behavior the removed
168
- // `views:{object}` invalidations pretended to manage.
169
- expect(getItems).toHaveBeenCalledTimes(2);
170
- expect(getItems).toHaveBeenCalledWith('view');
171
- expect(cacheReads).toEqual([]);
172
- });
173
-
174
- it('updateViewConfig invalidates exactly the two keys that have readers', async () => {
175
- const { ds, invalidated } = makeDS();
176
-
177
- await ds.updateViewConfig('account', 'v1', { label: 'Renamed' });
178
-
179
- // `view:{object}:{viewId}` → getView; `view-overrides:{object}` →
180
- // listViewOverrides. Nothing else is read back under a view-shaped key.
181
- expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
182
- });
183
-
184
- it('listViewOverrides reads back under the key the write paths invalidate', async () => {
185
- // The pairing, not the string: every assertion below names
186
- // `view-overrides:account` because THIS is the key the batch reader caches
187
- // under. An invalidation that named anything else would be the `views:`
188
- // mistake again, one rename later.
189
- const { ds, cacheReads } = makeDS({ items: [VIEW] });
190
-
191
- await ds.listViewOverrides('account');
192
-
193
- expect(cacheReads).toEqual(['view-overrides:account']);
194
- });
195
-
196
- it('createView invalidates the per-view key and the override map (#4363)', async () => {
197
- const { ds, invalidated } = makeDS();
198
-
199
- await ds.createView('account', { name: 'account.mine', object: 'account' });
200
-
201
- // A created view is a new row in the batch map; nothing else notices.
202
- // `saveItem` is an upsert, so the per-view key is named too — an explicit
203
- // `name` that already exists overwrites a row `getView` may hold cached.
204
- expect(invalidated).toEqual(['view:account:account.mine', 'view-overrides:account']);
205
- });
206
-
207
- it('updateView (published overlay) invalidates both keys (#4363)', async () => {
208
- const { ds, invalidated } = makeDS({ published: { name: 'v1', object: 'account' } });
209
-
210
- await ds.updateView('account', 'v1', { label: 'Renamed' });
211
-
212
- expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
213
- });
214
-
215
- it('updateView (pending draft) invalidates both keys (#4363)', async () => {
216
- const { ds, invalidated } = makeDS({ draft: VIEW, published: notFound() });
217
-
218
- await ds.updateView('account', VIEW.name, { label: 'Renamed' });
219
-
220
- // Deliberate over-invalidation on this half: both readers enumerate
221
- // PUBLISHED rows, so a draft write stales neither — as was already true of
222
- // the per-view line this pairs with. Pinned so the uniform per-method rule
223
- // is a decision on the record, not an oversight the next reader "fixes".
224
- expect(invalidated).toEqual([
225
- `view:account:${VIEW.name}`,
226
- 'view-overrides:account',
227
- ]);
228
- });
229
-
230
- it('deleteView invalidates both keys (#4363)', async () => {
231
- const { ds, invalidated } = makeDS();
232
-
233
- await ds.deleteView('account', 'v1');
234
-
235
- // The deleted row leaves the override map too — a ghost entry there is what
236
- // the object page would keep applying for the rest of the TTL.
237
- expect(invalidated).toEqual(['view:account:v1', 'view-overrides:account']);
238
- });
239
-
240
- it('no write path invalidates a `views:` key — nothing populates one', async () => {
241
- // createView is the path whose ONLY invalidation was the dead key. #3778
242
- // asserted "no `views:` key" here rather than "no invalidation at all",
243
- // deliberately leaving the override-map question to its own card — and
244
- // #4363 answered it: createView now names both live keys (pinned above).
245
- // The ASSERTION is unchanged, which is the point of the slot: the dead key
246
- // stays dead however the live key set grows.
247
- const created = makeDS();
248
- await created.ds.createView('account', { name: 'account.mine', object: 'account' });
249
-
250
- const config = makeDS();
251
- await config.ds.updateViewConfig('account', 'v1', { label: 'Renamed' });
252
-
253
- const removed = makeDS();
254
- await removed.ds.deleteView('account', 'v1');
255
-
256
- for (const { invalidated } of [created, config, removed]) {
257
- expect(invalidated.filter((k) => k.startsWith('views:'))).toEqual([]);
258
- }
259
- });
260
- });