@object-ui/data-objectstack 17.2.0 → 17.5.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.
@@ -0,0 +1,242 @@
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
+ * `updateView` read/write addressing (#4139).
14
+ *
15
+ * ADR-0034 routes a runtime-created view through the per-item DRAFT seam
16
+ * (`createRuntimeMetadata` → `metadataClient.save(..., { mode: 'draft' })`),
17
+ * so a view made from the `+` tab exists ONLY as a draft row until an
18
+ * explicit Publish. `updateView` used to read *and* write the published
19
+ * overlay on both halves: the read 404ed, a `catch {}` labelled
20
+ * "treat missing as create-equivalent" substituted `current = {}`, and the
21
+ * merge degenerated into a `{label, name, object}` partial that the server
22
+ * rejects (422) — while the draft row the UI actually reads back through
23
+ * `?preview=draft` kept the old label. The rename was silently lost.
24
+ *
25
+ * These pins fix the addressing on BOTH halves: whichever row the read
26
+ * resolved is the row the write updates.
27
+ */
28
+
29
+ /** The canonical ViewItem envelope `viewEnvelope()` stages as a draft. */
30
+ const DRAFT_VIEW = {
31
+ name: 'crm_activity.kanban_board',
32
+ object: 'crm_activity',
33
+ viewKind: 'list',
34
+ label: 'Kanban Board',
35
+ config: {
36
+ type: 'kanban',
37
+ columns: ['subject', 'status', 'due_date'],
38
+ kanban: { columns: ['subject', 'status', 'due_date'], groupBy: 'status' },
39
+ data: { provider: 'object', object: 'crm_activity' },
40
+ },
41
+ };
42
+
43
+ interface Harness {
44
+ ds: any;
45
+ /** Published-overlay writes (`client.meta.saveItem`) — the wrong address for a draft. */
46
+ saveItem: ReturnType<typeof vi.fn>;
47
+ /** Draft-addressed writes: `[url, parsedBody]` per `PUT ...?mode=draft`. */
48
+ draftPuts: Array<[string, any]>;
49
+ /** Every metadata URL the adapter fetched, in order. */
50
+ urls: string[];
51
+ }
52
+
53
+ /**
54
+ * Build an adapter whose two metadata addresses answer independently.
55
+ *
56
+ * @param opts.draft body served at `GET /meta/view/:name?state=draft`
57
+ * (`null` → 404, i.e. nothing pending), wrapped in the
58
+ * `{type,name,item}` envelope the framework sends for
59
+ * draft reads.
60
+ * @param opts.published what `client.meta.getItem` (the published read) does:
61
+ * a body to resolve, or an Error to throw.
62
+ */
63
+ function makeDS(opts: { draft: any | null; published: any | Error }): Harness {
64
+ const draftPuts: Array<[string, any]> = [];
65
+ const urls: string[] = [];
66
+
67
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
68
+ const url = String(input);
69
+ const json = (body: unknown, status = 200) =>
70
+ new Response(JSON.stringify(body), {
71
+ status,
72
+ headers: { 'Content-Type': 'application/json' },
73
+ });
74
+ if (url.includes('/meta/view/')) {
75
+ urls.push(url);
76
+ if ((init?.method ?? 'GET') === 'PUT') {
77
+ draftPuts.push([url, JSON.parse(String(init?.body ?? '{}'))]);
78
+ return json({ success: true, version: 2 });
79
+ }
80
+ if (url.includes('state=draft')) {
81
+ if (opts.draft == null) return json({ error: 'not found' }, 404);
82
+ // Draft reads answer the `{type,name,item}` envelope (unlike the
83
+ // bare-body published read) — the asymmetry `unwrapDraftBody` exists for.
84
+ return json({ type: 'view', name: opts.draft.name, item: opts.draft });
85
+ }
86
+ return json({ error: 'not found' }, 404);
87
+ }
88
+ return json({ success: true, data: { capabilities: {}, routes: {} } });
89
+ });
90
+
91
+ const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
92
+ ds.connected = true;
93
+ ds.connectionState = 'connected';
94
+ const saveItem = vi.fn(async () => ({ success: true }));
95
+ ds.client = {
96
+ meta: {
97
+ getItem: vi.fn(async () => {
98
+ if (opts.published instanceof Error) throw opts.published;
99
+ return { item: opts.published };
100
+ }),
101
+ saveItem,
102
+ },
103
+ };
104
+ return { ds, saveItem, draftPuts, urls };
105
+ }
106
+
107
+ /** A published read that 404s, decorated the way the SDK client decorates. */
108
+ function notFound(): Error {
109
+ return Object.assign(new Error('Metadata item not found'), { httpStatus: 404 });
110
+ }
111
+
112
+ describe('ObjectStackDataSource.updateView — draft addressing (#4139)', () => {
113
+ it('renames a draft-only view by merging onto the DRAFT body and writing the draft back', async () => {
114
+ const { ds, saveItem, draftPuts } = makeDS({ draft: DRAFT_VIEW, published: notFound() });
115
+
116
+ await ds.updateView('crm_activity', DRAFT_VIEW.name, { label: 'Pipeline Board' });
117
+
118
+ // The write must be draft-addressed — the published overlay is the wrong
119
+ // row, and writing it would publish a view the user never published.
120
+ expect(saveItem).not.toHaveBeenCalled();
121
+ expect(draftPuts).toHaveLength(1);
122
+ const [url, body] = draftPuts[0];
123
+ expect(url).toContain('mode=draft');
124
+ expect(url).toContain(encodeURIComponent(DRAFT_VIEW.name));
125
+
126
+ // …and it must carry the FULL merged document, not the
127
+ // `{label, name, object}` partial the create-equivalent path produced.
128
+ expect(body.label).toBe('Pipeline Board');
129
+ expect(body.viewKind).toBe('list');
130
+ expect(body.name).toBe(DRAFT_VIEW.name);
131
+ expect(body.object).toBe('crm_activity');
132
+ expect(body.config).toEqual(DRAFT_VIEW.config);
133
+ // The pre-fix partial had exactly these three keys — pin the shape so a
134
+ // regression cannot pass by carrying the label alone.
135
+ expect(Object.keys(body).sort()).toEqual(
136
+ ['config', 'label', 'name', 'object', 'viewKind'].sort(),
137
+ );
138
+ });
139
+
140
+ it('probes the draft BEFORE the published overlay, so a pending draft is never shadowed', async () => {
141
+ // A view with both a published overlay and a pending draft: the edit
142
+ // belongs to the draft. Writing the published row instead would be
143
+ // overwritten at publish time — the rename would vanish on Publish.
144
+ const published = { ...DRAFT_VIEW, label: 'Old Published Label' };
145
+ const { ds, saveItem, draftPuts } = makeDS({ draft: DRAFT_VIEW, published });
146
+
147
+ await ds.updateView('crm_activity', DRAFT_VIEW.name, { label: 'Pipeline Board' });
148
+
149
+ expect(saveItem).not.toHaveBeenCalled();
150
+ expect(draftPuts).toHaveLength(1);
151
+ expect(draftPuts[0][1].label).toBe('Pipeline Board');
152
+ });
153
+
154
+ it('carries the full document for a set-default patch too, not just renames', async () => {
155
+ // `handleSetDefaultView` drives the same read-merge-write cycle with
156
+ // `{isDefault}`, so it degraded into the same partial write (#4139).
157
+ const { ds, draftPuts } = makeDS({ draft: DRAFT_VIEW, published: notFound() });
158
+
159
+ await ds.updateView('crm_activity', DRAFT_VIEW.name, { isDefault: true });
160
+
161
+ expect(draftPuts).toHaveLength(1);
162
+ const body = draftPuts[0][1];
163
+ expect(body.isDefault).toBe(true);
164
+ expect(body.label).toBe('Kanban Board');
165
+ expect(body.config).toEqual(DRAFT_VIEW.config);
166
+ });
167
+
168
+ it('CONTROL — a published view with no pending draft still updates the published overlay', async () => {
169
+ const published = {
170
+ name: 'crm_activity.all',
171
+ object: 'crm_activity',
172
+ viewKind: 'list',
173
+ label: 'All Activities',
174
+ config: { type: 'grid', data: { object: 'crm_activity' } },
175
+ };
176
+ const { ds, saveItem, draftPuts } = makeDS({ draft: null, published });
177
+
178
+ await ds.updateView('crm_activity', 'crm_activity.all', { label: 'Everything' });
179
+
180
+ // No draft row → the published path is unchanged.
181
+ expect(draftPuts).toHaveLength(0);
182
+ expect(saveItem).toHaveBeenCalledTimes(1);
183
+ const [type, name, body] = saveItem.mock.calls[0];
184
+ expect(type).toBe('view');
185
+ expect(name).toBe('crm_activity.all');
186
+ expect(body.label).toBe('Everything');
187
+ expect(body.viewKind).toBe('list');
188
+ expect(body.config).toEqual(published.config);
189
+ });
190
+
191
+ it('surfaces a genuinely-missing view instead of writing a create-equivalent partial', async () => {
192
+ const { ds, saveItem, draftPuts } = makeDS({ draft: null, published: notFound() });
193
+
194
+ await expect(
195
+ ds.updateView('crm_activity', 'crm_activity.ghost', { label: 'Nope' }),
196
+ ).rejects.toThrow(/crm_activity\.ghost/);
197
+
198
+ // The whole point: no partial write is emitted on the way out.
199
+ expect(saveItem).not.toHaveBeenCalled();
200
+ expect(draftPuts).toHaveLength(0);
201
+ });
202
+
203
+ it('surfaces a transport failure on the published read rather than degrading to a partial write', async () => {
204
+ const boom = Object.assign(new Error('Internal Server Error'), { httpStatus: 500 });
205
+ const { ds, saveItem, draftPuts } = makeDS({ draft: null, published: boom });
206
+
207
+ await expect(
208
+ ds.updateView('crm_activity', 'crm_activity.all', { label: 'Nope' }),
209
+ ).rejects.toThrow('Internal Server Error');
210
+
211
+ expect(saveItem).not.toHaveBeenCalled();
212
+ expect(draftPuts).toHaveLength(0);
213
+ });
214
+
215
+ it('surfaces a transport failure on the DRAFT probe rather than falling through to the published row', async () => {
216
+ // A 500 on the draft probe is not "no draft pending" — falling through
217
+ // would write the published overlay while a draft may still shadow it.
218
+ const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
219
+ const url = String(input);
220
+ if (url.includes('/meta/view/') && url.includes('state=draft')) {
221
+ return new Response(JSON.stringify({ error: 'boom' }), {
222
+ status: 503,
223
+ headers: { 'Content-Type': 'application/json' },
224
+ });
225
+ }
226
+ return new Response(JSON.stringify({ success: true, data: {} }), {
227
+ status: 200,
228
+ headers: { 'Content-Type': 'application/json' },
229
+ });
230
+ });
231
+ const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
232
+ ds.connected = true;
233
+ ds.connectionState = 'connected';
234
+ const saveItem = vi.fn(async () => ({ success: true }));
235
+ ds.client = { meta: { getItem: vi.fn(async () => ({ item: DRAFT_VIEW })), saveItem } };
236
+
237
+ await expect(
238
+ ds.updateView('crm_activity', DRAFT_VIEW.name, { label: 'Nope' }),
239
+ ).rejects.toThrow();
240
+ expect(saveItem).not.toHaveBeenCalled();
241
+ });
242
+ });
@@ -6,30 +6,19 @@ import { describe, it, expect } from 'vitest';
6
6
  import { validatePluginContract, generateContractManifest } from './contracts';
7
7
  import { IntegrationManager } from './integration';
8
8
  import { SecurityManager } from './security';
9
- import { CloudOperations } from './cloud';
10
9
  import { createDefaultCanvasConfig, snapToGrid, calculateAutoLayout } from './studio';
11
10
 
12
- describe('v3.0.0 Compatibility', () => {
13
- describe('Cloud namespace (replacing Hub)', () => {
14
- it('should create cloud operations instance', () => {
15
- const ops = new CloudOperations(() => ({}));
16
- expect(ops).toBeDefined();
17
- });
18
-
19
- it('should handle deploy when client has no cloud support', async () => {
20
- const ops = new CloudOperations(() => ({}));
21
- const result = await ops.deploy('app-1', { environment: 'production' });
22
- expect(result).toHaveProperty('deploymentId');
23
- expect(result).toHaveProperty('status');
24
- });
25
-
26
- it('should handle marketplace search when client has no cloud support', async () => {
27
- const ops = new CloudOperations(() => ({}));
28
- const results = await ops.searchMarketplace('grid');
29
- expect(Array.isArray(results)).toBe(true);
30
- });
31
- });
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.
32
20
 
21
+ describe('v3.0.0 Compatibility', () => {
33
22
  describe('Contracts module', () => {
34
23
  it('should validate a valid plugin contract', () => {
35
24
  const contract = {
@@ -0,0 +1,260 @@
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
+ });