@object-ui/data-objectstack 17.3.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.
- package/CHANGELOG.md +669 -0
- package/README.md +3 -11
- package/dist/index.cjs +1258 -767
- package/dist/index.d.cts +1367 -906
- package/dist/index.d.ts +1367 -906
- package/dist/index.js +1210 -726
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +5 -5
- package/src/apiAccessDenied.test.ts +233 -0
- package/src/appAccessProbe.test.ts +147 -0
- package/src/cloud-surface-retired-4152.pin.test.ts +171 -0
- package/src/deleteView.homes.test.ts +389 -0
- package/src/deleteViewContract.types.test.ts +246 -0
- package/src/filter-operator-ast-parity.test.ts +75 -11
- package/src/httpFailureLogging.test.ts +109 -0
- package/src/index.ts +879 -95
- package/src/listViewOverrides.test.ts +241 -0
- package/src/metadata-client.get-envelope.test.ts +171 -0
- package/src/metadata-client.saveAdvisories.test.ts +226 -0
- package/src/metadata-client.ts +208 -11
- package/src/onMutation.test.ts +29 -2
- package/src/onSaveAdvisory.test.ts +311 -0
- package/src/queryDataset.test.ts +411 -0
- package/src/spec-symbol-batch6.test.ts +21 -1
- package/src/updateView.draft.test.ts +242 -0
- package/src/v3-compat.test.ts +10 -21
- package/src/viewCacheInvalidation.pin.test.ts +260 -0
- package/src/viewInvalidationSeam.pin.test.ts +162 -0
- package/src/cloud.ts +0 -109
|
@@ -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
|
+
});
|
package/src/v3-compat.test.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
+
});
|