@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.
- package/CHANGELOG.md +772 -0
- package/README.md +3 -11
- package/dist/index.cjs +1232 -699
- package/dist/index.d.cts +1367 -906
- package/dist/index.d.ts +1367 -906
- package/dist/index.js +1222 -696
- 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 +967 -100
- 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/onWriteWarning.test.ts +101 -0
- package/src/queryDataset.test.ts +411 -0
- package/src/spec-symbol-batch6.test.ts +25 -2
- 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,389 @@
|
|
|
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
|
+
import { MetadataClient } from './metadata-client';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `deleteView` removes every home the view has (#4479).
|
|
15
|
+
*
|
|
16
|
+
* A view has two possible homes — the pending per-item DRAFT
|
|
17
|
+
* (`DELETE /meta/view/:name?state=draft`) and the PUBLISHED overlay
|
|
18
|
+
* (`DELETE /meta/view/:name`) — and `deleteView` used to address only the
|
|
19
|
+
* published one. The three cases, and what each used to do:
|
|
20
|
+
*
|
|
21
|
+
* | case | before #4479 |
|
|
22
|
+
* |--------------------|-----------------------------------------------------|
|
|
23
|
+
* | draft-only view | BUG — hits the published overlay, `reset:false`, the |
|
|
24
|
+
* | | draft survives and the tab is back after reload |
|
|
25
|
+
* | published-only | correct — `reset:true`, tab gone |
|
|
26
|
+
* | published + draft | ACCIDENTALLY correct — the published row goes, so |
|
|
27
|
+
* | | the tab goes; the orphan draft is left behind |
|
|
28
|
+
*
|
|
29
|
+
* The third row is why the mirror of #4139 is NOT mechanical.
|
|
30
|
+
* `persistRuntimeMetadata` (app-shell) stages **every** runtime edit as a
|
|
31
|
+
* draft, so "publish a view, then edit it" routinely produces a pair — and a
|
|
32
|
+
* naive draft-FIRST-only mirror would have discarded just the draft there,
|
|
33
|
+
* silently downgrading **Delete view** into **Discard draft**, which is a
|
|
34
|
+
* different operation that already exists (`discardRuntimeDraft`).
|
|
35
|
+
*
|
|
36
|
+
* The ruling on #4479: delete BOTH homes, draft first. Draft first because a
|
|
37
|
+
* failure between the two calls then leaves the PUBLISHED overlay intact and
|
|
38
|
+
* the operation cleanly retryable — the reverse order strands a draft-only
|
|
39
|
+
* view, which is this card's original bug shape.
|
|
40
|
+
*
|
|
41
|
+
* ## The wire these pins model
|
|
42
|
+
*
|
|
43
|
+
* Every receipt below is the framework's own, read verbatim from
|
|
44
|
+
* `packages/metadata-protocol/src/protocol.ts` (`deleteMetaItem`) — the
|
|
45
|
+
* no-row branch and the delete-ful branch. Both no-row answers are a **200**
|
|
46
|
+
* carrying `reset:false`, NOT a 404:
|
|
47
|
+
*
|
|
48
|
+
* - no draft pending -> `{success:true, reset:false, message:"No pending draft for view/x."}`
|
|
49
|
+
* - no published row -> `{success:true, reset:false, message:"No view 'x' found — nothing to delete."}`
|
|
50
|
+
*
|
|
51
|
+
* That measurement is what makes a blind two-call implementation legitimate
|
|
52
|
+
* here: neither "missing home" answer is an error, so there is nothing for a
|
|
53
|
+
* probe to protect against and the probe `updateView` needs (its read must
|
|
54
|
+
* resolve the row it is about to merge onto) has no counterpart for a delete.
|
|
55
|
+
*
|
|
56
|
+
* Nothing here mocks `deleteView`. The adapter is real and drives a
|
|
57
|
+
* wire-shaped `fetch`; the SDK stand-in on `ds.client.meta.deleteItem`
|
|
58
|
+
* mirrors the real client's transport (same URL, same `unwrapResponse`) so
|
|
59
|
+
* that the pins address the REQUEST, not the transport that issued it.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
const VIEW = 'account.my_pipeline';
|
|
63
|
+
|
|
64
|
+
/** Framework receipt: a draft row existed and was discarded. */
|
|
65
|
+
const DRAFT_DISCARDED = {
|
|
66
|
+
success: true,
|
|
67
|
+
reset: true,
|
|
68
|
+
seq: 41,
|
|
69
|
+
message: `Draft discarded — view/${VIEW}. [seq=41]`,
|
|
70
|
+
};
|
|
71
|
+
/** Framework receipt: nothing pending. 200, not 404. */
|
|
72
|
+
const NO_DRAFT = {
|
|
73
|
+
success: true,
|
|
74
|
+
reset: false,
|
|
75
|
+
message: `No pending draft for view/${VIEW}.`,
|
|
76
|
+
};
|
|
77
|
+
/** Framework receipt: a runtime-only published row existed and is gone. */
|
|
78
|
+
const PUBLISHED_DELETED = {
|
|
79
|
+
success: true,
|
|
80
|
+
reset: true,
|
|
81
|
+
seq: 42,
|
|
82
|
+
message: `Deleted view '${VIEW}' — it no longer exists. [seq=42]`,
|
|
83
|
+
};
|
|
84
|
+
/** Framework receipt: no published row. 200, not 404 — the answer #4479 quoted. */
|
|
85
|
+
const NO_PUBLISHED = {
|
|
86
|
+
success: true,
|
|
87
|
+
reset: false,
|
|
88
|
+
message: `No view '${VIEW}' found — nothing to delete.`,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
interface Call {
|
|
92
|
+
/** `'draft'` when the request carried `?state=draft`, else `'published'`. */
|
|
93
|
+
home: 'draft' | 'published';
|
|
94
|
+
method: string;
|
|
95
|
+
url: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface Harness {
|
|
99
|
+
ds: any;
|
|
100
|
+
/** Every DELETE against `/meta/view/...`, in issue order. */
|
|
101
|
+
deletes: Call[];
|
|
102
|
+
/** Just the home of each DELETE, in issue order — the ordering pin. */
|
|
103
|
+
order: () => Array<'draft' | 'published'>;
|
|
104
|
+
/** Keys passed to `metadataCache.invalidate`, in order. */
|
|
105
|
+
invalidated: string[];
|
|
106
|
+
baseUrl: string;
|
|
107
|
+
fetchImpl: any;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Adapter whose two delete addresses answer independently.
|
|
112
|
+
*
|
|
113
|
+
* @param opts.draft receipt (or Error/status) for `DELETE ...?state=draft`
|
|
114
|
+
* @param opts.published receipt (or Error/status) for `DELETE ...` (active)
|
|
115
|
+
*/
|
|
116
|
+
function makeDS(opts: {
|
|
117
|
+
draft?: unknown | { failStatus: number };
|
|
118
|
+
published?: unknown | { failStatus: number };
|
|
119
|
+
} = {}): Harness {
|
|
120
|
+
const deletes: Call[] = [];
|
|
121
|
+
const invalidated: string[] = [];
|
|
122
|
+
const baseUrl = 'http://test.local';
|
|
123
|
+
|
|
124
|
+
const json = (body: unknown, status = 200) =>
|
|
125
|
+
new Response(JSON.stringify(body), {
|
|
126
|
+
status,
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const answer = (spec: unknown) => {
|
|
131
|
+
if (spec && typeof spec === 'object' && 'failStatus' in (spec as any)) {
|
|
132
|
+
const status = (spec as any).failStatus as number;
|
|
133
|
+
return json({ error: { message: 'boom', code: 'server_error' } }, status);
|
|
134
|
+
}
|
|
135
|
+
return json(spec);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
139
|
+
const url = String(input);
|
|
140
|
+
const method = init?.method ?? 'GET';
|
|
141
|
+
if (url.includes('/meta/view/')) {
|
|
142
|
+
if (method === 'DELETE') {
|
|
143
|
+
const home = url.includes('state=draft') ? 'draft' : 'published';
|
|
144
|
+
deletes.push({ home, method, url });
|
|
145
|
+
return answer(home === 'draft' ? opts.draft ?? NO_DRAFT : opts.published ?? NO_PUBLISHED);
|
|
146
|
+
}
|
|
147
|
+
return json({ error: 'not found' }, 404);
|
|
148
|
+
}
|
|
149
|
+
return json({ success: true, data: { capabilities: {}, routes: {} } });
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const ds: any = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl });
|
|
153
|
+
ds.connected = true;
|
|
154
|
+
ds.connectionState = 'connected';
|
|
155
|
+
|
|
156
|
+
ds.metadataCache = {
|
|
157
|
+
get: async (_key: string, loader: () => Promise<any>) => loader(),
|
|
158
|
+
invalidate: (key: string) => {
|
|
159
|
+
invalidated.push(key);
|
|
160
|
+
},
|
|
161
|
+
getCachedSync: () => undefined,
|
|
162
|
+
getStats: () => ({}),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// SDK transport stand-in. NOT a stub of the behaviour under test: it issues
|
|
166
|
+
// the same real request the shipped `client.meta.deleteItem` issues
|
|
167
|
+
// (`${baseUrl}/api/v1/meta/:type/:name`, DELETE) and unwraps the
|
|
168
|
+
// `{success, data}` envelope exactly as `unwrapResponse` does, so the
|
|
169
|
+
// published half is observed at the WIRE whichever transport carries it.
|
|
170
|
+
ds.client = {
|
|
171
|
+
meta: {
|
|
172
|
+
deleteItem: async (type: string, name: string) => {
|
|
173
|
+
const res = await fetchImpl(
|
|
174
|
+
`${baseUrl}/api/v1/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}`,
|
|
175
|
+
{ method: 'DELETE' },
|
|
176
|
+
);
|
|
177
|
+
const body: any = await res.json();
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
throw Object.assign(new Error(body?.error?.message ?? 'request failed'), {
|
|
180
|
+
httpStatus: res.status,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (body && typeof body.success === 'boolean' && 'data' in body) return body.data;
|
|
184
|
+
return body;
|
|
185
|
+
},
|
|
186
|
+
getItem: vi.fn(async () => {
|
|
187
|
+
throw Object.assign(new Error('Metadata item not found'), { httpStatus: 404 });
|
|
188
|
+
}),
|
|
189
|
+
saveItem: vi.fn(async () => ({ success: true })),
|
|
190
|
+
getItems: vi.fn(async () => ({ items: [] })),
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
ds,
|
|
196
|
+
deletes,
|
|
197
|
+
order: () => deletes.map((c) => c.home),
|
|
198
|
+
invalidated,
|
|
199
|
+
baseUrl,
|
|
200
|
+
fetchImpl,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
describe('deleteView removes every home the view has (#4479)', () => {
|
|
205
|
+
// ── Row 1: draft-only — the reported bug ────────────────────────────────
|
|
206
|
+
describe('draft-only view (the #4479 bug row)', () => {
|
|
207
|
+
it('deletes the draft home, so the view is actually gone', async () => {
|
|
208
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: NO_PUBLISHED });
|
|
209
|
+
|
|
210
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
211
|
+
|
|
212
|
+
// The user-visible symptom first: before #4479 the only request issued
|
|
213
|
+
// was the unqualified one, the server answered `reset:false` /
|
|
214
|
+
// "nothing to delete", and this receipt came back `{deleted:false}`
|
|
215
|
+
// with the draft untouched and the tab back after reload.
|
|
216
|
+
expect(receipt.deleted).toBe(true);
|
|
217
|
+
// ...because the draft home was never addressed.
|
|
218
|
+
expect(h.order()).toContain('draft');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('reports the draft home as the one that had the row', async () => {
|
|
222
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: NO_PUBLISHED });
|
|
223
|
+
|
|
224
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
225
|
+
|
|
226
|
+
expect(receipt.draft?.removed).toBe(true);
|
|
227
|
+
expect(receipt.published?.removed).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── Row 2: published-only — correct before, must stay correct ───────────
|
|
232
|
+
describe('published-only view (correct before #4479)', () => {
|
|
233
|
+
it('still deletes the published home and still reports deleted', async () => {
|
|
234
|
+
const h = makeDS({ draft: NO_DRAFT, published: PUBLISHED_DELETED });
|
|
235
|
+
|
|
236
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
237
|
+
|
|
238
|
+
expect(h.order()).toContain('published');
|
|
239
|
+
// Unchanged from before #4479 — this half was never broken.
|
|
240
|
+
expect(receipt.deleted).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('addresses both homes even when only one answers with a row', async () => {
|
|
244
|
+
const h = makeDS({ draft: NO_DRAFT, published: PUBLISHED_DELETED });
|
|
245
|
+
|
|
246
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
247
|
+
|
|
248
|
+
expect(h.order()).toEqual(['draft', 'published']);
|
|
249
|
+
expect(receipt.draft?.removed).toBe(false);
|
|
250
|
+
expect(receipt.published?.removed).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── Row 3: the pair — accidentally correct before; MUST NOT REGRESS ─────
|
|
255
|
+
describe('published + pending draft pair (must-not-change + the anti-mirror guard)', () => {
|
|
256
|
+
it('MUST NOT CHANGE: the published home is still deleted, so the tab still goes', async () => {
|
|
257
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
258
|
+
|
|
259
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
260
|
+
|
|
261
|
+
// This is the assertion a naive draft-first mirror of #4139 would have
|
|
262
|
+
// broken: it would have discarded the draft and returned, leaving the
|
|
263
|
+
// published row serving the view and the tab present after reload.
|
|
264
|
+
expect(h.order()).toContain('published');
|
|
265
|
+
expect(receipt.deleted).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('also discards the orphan draft, so a republish cannot resurrect the view', async () => {
|
|
269
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
270
|
+
|
|
271
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
272
|
+
|
|
273
|
+
expect(h.order()).toEqual(['draft', 'published']);
|
|
274
|
+
expect(receipt.draft?.removed).toBe(true);
|
|
275
|
+
expect(receipt.published?.removed).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('invalidates both view cache keys exactly once (#4363 rule, unchanged)', async () => {
|
|
279
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
280
|
+
|
|
281
|
+
await h.ds.deleteView('account', VIEW);
|
|
282
|
+
|
|
283
|
+
// Two homes deleted, still ONE ordered pair: the #4363 rule is per
|
|
284
|
+
// WRITE METHOD, not per request.
|
|
285
|
+
expect(h.invalidated).toEqual([`view:account:${VIEW}`, 'view-overrides:account']);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ── Row 4: neither home — the control ───────────────────────────────────
|
|
290
|
+
it('MUST NOT CHANGE: a view with no home at all reports deleted:false', async () => {
|
|
291
|
+
const h = makeDS({ draft: NO_DRAFT, published: NO_PUBLISHED });
|
|
292
|
+
|
|
293
|
+
const receipt = await h.ds.deleteView('account', VIEW);
|
|
294
|
+
|
|
295
|
+
// Both homes answer 200 `reset:false`. Nothing was removed and the
|
|
296
|
+
// receipt has never claimed otherwise — that answer is preserved.
|
|
297
|
+
expect(receipt.deleted).toBe(false);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── Ordering ────────────────────────────────────────────────────────────
|
|
301
|
+
it('addresses the draft home strictly before the published one', async () => {
|
|
302
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
303
|
+
|
|
304
|
+
await h.ds.deleteView('account', VIEW);
|
|
305
|
+
|
|
306
|
+
expect(h.order()).toEqual(['draft', 'published']);
|
|
307
|
+
expect(h.deletes[0].url).toContain('state=draft');
|
|
308
|
+
expect(h.deletes[1].url).not.toContain('state=draft');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ── One transport, one error contract ───────────────────────────────────
|
|
312
|
+
it('sends both deletes to the same `/api/v1/meta/view/:name` route', async () => {
|
|
313
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
314
|
+
|
|
315
|
+
await h.ds.deleteView('account', VIEW);
|
|
316
|
+
|
|
317
|
+
const base = `${h.baseUrl}/api/v1/meta/view/${encodeURIComponent(VIEW)}`;
|
|
318
|
+
expect(h.deletes[0].url).toBe(`${base}?state=draft`);
|
|
319
|
+
expect(h.deletes[1].url).toBe(base);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── Failure paths — updateView's convention: throw, never degrade ───────
|
|
323
|
+
describe('partial failure', () => {
|
|
324
|
+
it('a failing draft delete throws and leaves the published home untouched', async () => {
|
|
325
|
+
const h = makeDS({ draft: { failStatus: 500 }, published: PUBLISHED_DELETED });
|
|
326
|
+
|
|
327
|
+
await expect(h.ds.deleteView('account', VIEW)).rejects.toThrow();
|
|
328
|
+
|
|
329
|
+
// The whole reason the ruling put the draft FIRST: a mid-operation
|
|
330
|
+
// failure must leave the published overlay serving the view, so the
|
|
331
|
+
// user retries a delete rather than being left with the draft-only
|
|
332
|
+
// shape that is this card's original bug.
|
|
333
|
+
expect(h.order()).toEqual(['draft']);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('a failing published delete throws rather than reporting deleted:true', async () => {
|
|
337
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: { failStatus: 500 } });
|
|
338
|
+
|
|
339
|
+
await expect(h.ds.deleteView('account', VIEW)).rejects.toThrow();
|
|
340
|
+
|
|
341
|
+
expect(h.order()).toEqual(['draft', 'published']);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('the published-half failure carries the partial outcome, never rounds it to true', async () => {
|
|
345
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: { failStatus: 500 } });
|
|
346
|
+
|
|
347
|
+
const err: any = await h.ds.deleteView('account', VIEW).catch((e: any) => e);
|
|
348
|
+
|
|
349
|
+
// "draft gone, overlay left" is exactly what the old
|
|
350
|
+
// `{ deleted: boolean }` could not express.
|
|
351
|
+
expect(err.outcome?.deleted).toBe(false);
|
|
352
|
+
expect(err.outcome?.draft?.removed).toBe(true);
|
|
353
|
+
expect(err.outcome?.published?.removed).toBe(false);
|
|
354
|
+
expect(err.cause).toBeDefined();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('still invalidates the view cache keys when a half fails', async () => {
|
|
358
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: { failStatus: 500 } });
|
|
359
|
+
|
|
360
|
+
await h.ds.deleteView('account', VIEW).catch(() => undefined);
|
|
361
|
+
|
|
362
|
+
// The draft row IS gone, so a cache still holding it is stale for the
|
|
363
|
+
// full TTL. #4363's asymmetry decides it: an unnecessary invalidation
|
|
364
|
+
// costs one refetch, a missed one costs five minutes of stale overrides.
|
|
365
|
+
expect(h.invalidated).toEqual([`view:account:${VIEW}`, 'view-overrides:account']);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ── discardRuntimeDraft stays a DIFFERENT operation ─────────────────────
|
|
370
|
+
it('stays distinct from Discard draft, which touches only the draft home', async () => {
|
|
371
|
+
// `discardRuntimeDraft` (app-shell `runtime-metadata-persistence.ts`) is
|
|
372
|
+
// documented as "the published overlay is untouched" and is exactly this
|
|
373
|
+
// one call. Driven here through the same primitive rather than imported,
|
|
374
|
+
// because app-shell is downstream of this package — the point is that the
|
|
375
|
+
// two operations issue DIFFERENT request sets against the same wire.
|
|
376
|
+
const h = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
377
|
+
|
|
378
|
+
await new MetadataClient({ baseUrl: h.baseUrl, fetch: h.fetchImpl }).reset('view', VIEW, {
|
|
379
|
+
state: 'draft',
|
|
380
|
+
});
|
|
381
|
+
expect(h.order()).toEqual(['draft']);
|
|
382
|
+
|
|
383
|
+
// Same wire, same view: Delete view addresses BOTH homes. If these two
|
|
384
|
+
// ever issue the same request set, one of the operations has been lost.
|
|
385
|
+
const fresh = makeDS({ draft: DRAFT_DISCARDED, published: PUBLISHED_DELETED });
|
|
386
|
+
await fresh.ds.deleteView('account', VIEW);
|
|
387
|
+
expect(fresh.order()).toEqual(['draft', 'published']);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
@@ -0,0 +1,246 @@
|
|
|
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 type { DataSource, DeleteViewResult, ViewHomeDeleteOutcome } from '@object-ui/types';
|
|
11
|
+
import {
|
|
12
|
+
ObjectStackAdapter,
|
|
13
|
+
type DeleteViewResult as AdapterDeleteViewResult,
|
|
14
|
+
type ViewHomeDeleteOutcome as AdapterViewHomeDeleteOutcome,
|
|
15
|
+
} from './index';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The shared `DataSource` contract carries `deleteView`'s per-home outcomes
|
|
19
|
+
* (#4564).
|
|
20
|
+
*
|
|
21
|
+
* #4479 / PR #4562 widened THIS adapter's `deleteView` to return
|
|
22
|
+
* `DeleteViewResult { deleted, draft?, published? }`, but the shared interface
|
|
23
|
+
* in `@object-ui/types` still declared the narrow `{ deleted: boolean }`. The
|
|
24
|
+
* adapter's wider return is assignable to the narrower declaration, so nothing
|
|
25
|
+
* failed to compile — which is exactly what made the gap invisible: a consumer
|
|
26
|
+
* reaching the adapter THROUGH `DataSource` was handed a type that had already
|
|
27
|
+
* thrown the per-home outcomes away. The one real call site today (app-shell's
|
|
28
|
+
* `ObjectView` delete handler) reads nothing off the receipt, so the loss was
|
|
29
|
+
* latent rather than broken.
|
|
30
|
+
*
|
|
31
|
+
* #4564's ruling moved the canonical shapes DOWN to `@object-ui/types` beside
|
|
32
|
+
* the interface (the dependency runs data-objectstack -> types, so the shapes
|
|
33
|
+
* could not be imported upward), widened `DataSource.deleteView?`'s declared
|
|
34
|
+
* return to `DeleteViewResult`, and left this package re-exporting both names
|
|
35
|
+
* so every existing importer keeps compiling.
|
|
36
|
+
*
|
|
37
|
+
* ## Where these pins get their colour
|
|
38
|
+
*
|
|
39
|
+
* Most of this file is COMPILE-time. That is not a weaker pin, it is the only
|
|
40
|
+
* pin that can observe this defect: the bug was never a wrong value at runtime,
|
|
41
|
+
* it was a declaration that could not describe the value. `vitest` transpiles
|
|
42
|
+
* with esbuild and erases types, so the runtime case below passed just as
|
|
43
|
+
* happily against the narrow declaration — its `receipt.draft` read was a
|
|
44
|
+
* `tsc` error and a green test at the same time. The colour therefore comes
|
|
45
|
+
* from `pnpm --filter @object-ui/data-objectstack type-check`, and this
|
|
46
|
+
* package's `tsconfig.json` includes its whole `src/**` (tests included), so
|
|
47
|
+
* these assertions are checked there and by CI's Type Check job with no
|
|
48
|
+
* separate `tsconfig.typetests.json` — the same property `queryDataset.test.ts`
|
|
49
|
+
* and `spec-symbol-batch6.test.ts` document and rely on.
|
|
50
|
+
*
|
|
51
|
+
* ## The discrimination control
|
|
52
|
+
*
|
|
53
|
+
* `NarrowLegacyResult` below is `deleteView`'s declared return BEFORE #4564,
|
|
54
|
+
* kept as a live control rather than described in prose. Every "the contract
|
|
55
|
+
* carries the per-home outcomes" assertion is paired with the control failing
|
|
56
|
+
* to carry them. Undo the move and the paired assertions go red together,
|
|
57
|
+
* which is what stops this file from degrading into a restatement of whatever
|
|
58
|
+
* the interface happens to say.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const VIEW = 'account.my_pipeline';
|
|
62
|
+
|
|
63
|
+
/** Framework receipt: a draft row existed and was discarded. */
|
|
64
|
+
const DRAFT_DISCARDED = {
|
|
65
|
+
success: true,
|
|
66
|
+
reset: true,
|
|
67
|
+
seq: 41,
|
|
68
|
+
message: `Draft discarded — view/${VIEW}. [seq=41]`,
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Framework receipt: no published row. A 200 carrying `reset:false`, not a 404. */
|
|
72
|
+
const NO_PUBLISHED = {
|
|
73
|
+
success: true,
|
|
74
|
+
reset: false,
|
|
75
|
+
message: `No view '${VIEW}' found — nothing to delete.`,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The smallest adapter that can answer both view-delete addresses.
|
|
80
|
+
*
|
|
81
|
+
* Deliberately NOT a second copy of `deleteView.homes.test.ts`'s harness: that
|
|
82
|
+
* file owns the behaviour (15 pins over both homes, the ordering, the failure
|
|
83
|
+
* paths) and stays untouched. This one exists only to drive one call through a
|
|
84
|
+
* `DataSource`-typed handle, so the per-home read below is served by the shared
|
|
85
|
+
* declaration and by nothing else.
|
|
86
|
+
*/
|
|
87
|
+
function makeAdapter(): ObjectStackAdapter {
|
|
88
|
+
const baseUrl = 'http://test.local';
|
|
89
|
+
const json = (body: unknown) =>
|
|
90
|
+
new Response(JSON.stringify(body), {
|
|
91
|
+
status: 200,
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
96
|
+
const url = String(input);
|
|
97
|
+
if (url.includes('/meta/view/') && (init?.method ?? 'GET') === 'DELETE') {
|
|
98
|
+
return json(url.includes('state=draft') ? DRAFT_DISCARDED : NO_PUBLISHED);
|
|
99
|
+
}
|
|
100
|
+
return json({ success: true, data: { capabilities: {}, routes: {} } });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const adapter = new ObjectStackAdapter({ baseUrl, fetch: fetchImpl });
|
|
104
|
+
const poke = adapter as any;
|
|
105
|
+
poke.connected = true;
|
|
106
|
+
poke.connectionState = 'connected';
|
|
107
|
+
poke.metadataCache = {
|
|
108
|
+
get: async (_key: string, loader: () => Promise<any>) => loader(),
|
|
109
|
+
invalidate: () => undefined,
|
|
110
|
+
getCachedSync: () => undefined,
|
|
111
|
+
getStats: () => ({}),
|
|
112
|
+
};
|
|
113
|
+
return adapter;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* -------------------------------------------------------------------------- */
|
|
117
|
+
/* Compile-time vocabulary — the repo's idiom (queryDataset.test.ts). */
|
|
118
|
+
/* -------------------------------------------------------------------------- */
|
|
119
|
+
|
|
120
|
+
type Assert<T extends true> = T;
|
|
121
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
|
122
|
+
? true
|
|
123
|
+
: false;
|
|
124
|
+
type HasKey<T, K extends PropertyKey> = K extends keyof T ? true : false;
|
|
125
|
+
|
|
126
|
+
/** The shared contract's declared return, read off the interface itself. */
|
|
127
|
+
type ContractDeleteView = NonNullable<DataSource['deleteView']>;
|
|
128
|
+
type ContractReturn = Awaited<ReturnType<ContractDeleteView>>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `DataSource.deleteView`'s declared return BEFORE #4564 — the control.
|
|
132
|
+
* Kept as a type, not a comment, so "the contract used to lose the per-home
|
|
133
|
+
* outcomes" is an assertion the compiler re-checks on every run.
|
|
134
|
+
*/
|
|
135
|
+
interface NarrowLegacyResult {
|
|
136
|
+
deleted: boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('DataSource.deleteView declares the per-home outcomes (#4564)', () => {
|
|
140
|
+
it('the shared contract returns the canonical DeleteViewResult, not a restatement', () => {
|
|
141
|
+
// `Equal` rather than a two-way `extends`: a structurally overlapping
|
|
142
|
+
// re-declaration in `types` would satisfy mutual assignability while
|
|
143
|
+
// drifting, and a hand copy is precisely what the one-resolver rule and
|
|
144
|
+
// this card's ruling rejected in favour of moving the declaration.
|
|
145
|
+
type _IsTheCanonicalShape = Assert<Equal<ContractReturn, DeleteViewResult>>;
|
|
146
|
+
|
|
147
|
+
expect(true).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('carries `draft` and `published`, where the pre-#4564 declaration carried neither', () => {
|
|
151
|
+
type _CarriesDraft = Assert<Equal<HasKey<ContractReturn, 'draft'>, true>>;
|
|
152
|
+
type _CarriesPublished = Assert<Equal<HasKey<ContractReturn, 'published'>, true>>;
|
|
153
|
+
|
|
154
|
+
// The control, and the whole reason this file is a pin rather than an echo:
|
|
155
|
+
// the shape the interface used to declare cannot answer either read. If the
|
|
156
|
+
// move is reverted, the two assertions above go red and these two stay
|
|
157
|
+
// green — the difference between them IS #4564's delta.
|
|
158
|
+
type _ControlHasNoDraft = Assert<Equal<HasKey<NarrowLegacyResult, 'draft'>, false>>;
|
|
159
|
+
type _ControlHasNoPublished = Assert<Equal<HasKey<NarrowLegacyResult, 'published'>, false>>;
|
|
160
|
+
type _ContractIsNotTheNarrowShape = Assert<
|
|
161
|
+
Equal<Equal<ContractReturn, NarrowLegacyResult>, false>
|
|
162
|
+
>;
|
|
163
|
+
|
|
164
|
+
expect(true).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('types the per-home outcomes as ViewHomeDeleteOutcome, optional on both homes', () => {
|
|
168
|
+
type _DraftIsTheOutcome = Assert<Equal<ContractReturn['draft'], ViewHomeDeleteOutcome | undefined>>;
|
|
169
|
+
type _PublishedIsTheOutcome = Assert<
|
|
170
|
+
Equal<ContractReturn['published'], ViewHomeDeleteOutcome | undefined>
|
|
171
|
+
>;
|
|
172
|
+
// `deleted` is unchanged — the growth is additive, so a consumer reading
|
|
173
|
+
// only `deleted` is untouched by the widening.
|
|
174
|
+
type _DeletedUnchanged = Assert<Equal<ContractReturn['deleted'], boolean>>;
|
|
175
|
+
|
|
176
|
+
expect(true).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('MUST NOT CHANGE: deleteView stays OPTIONAL, with the same two parameters', () => {
|
|
180
|
+
// The widening is to the RETURN only. An adapter that never implemented
|
|
181
|
+
// `deleteView` must stay a legal `DataSource`.
|
|
182
|
+
type _StillOptional = Assert<undefined extends DataSource['deleteView'] ? true : false>;
|
|
183
|
+
type _SameParameters = Assert<Equal<Parameters<ContractDeleteView>, [string, string]>>;
|
|
184
|
+
|
|
185
|
+
expect(true).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('the data-objectstack spellings survive the move (#4564)', () => {
|
|
190
|
+
it('re-exports both names as the SAME declaration, not a structural twin', () => {
|
|
191
|
+
// The re-export contract: every importer that PR #4562 left pointing at
|
|
192
|
+
// `@object-ui/data-objectstack` keeps compiling, and keeps getting the type
|
|
193
|
+
// the shared interface now speaks. `Equal` is the assertion that matters —
|
|
194
|
+
// two identical-looking declarations are mutually assignable, so only
|
|
195
|
+
// identity can tell a re-export from a copy that will drift.
|
|
196
|
+
type _ResultIsTheTypesSpelling = Assert<Equal<AdapterDeleteViewResult, DeleteViewResult>>;
|
|
197
|
+
type _OutcomeIsTheTypesSpelling = Assert<
|
|
198
|
+
Equal<AdapterViewHomeDeleteOutcome, ViewHomeDeleteOutcome>
|
|
199
|
+
>;
|
|
200
|
+
|
|
201
|
+
expect(true).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('the adapter method still satisfies the widened contract', () => {
|
|
205
|
+
const adapter = makeAdapter();
|
|
206
|
+
|
|
207
|
+
// Explicit, because `class ObjectStackAdapter implements DataSource` would
|
|
208
|
+
// have gone on compiling with the NARROW declaration too — a wider return
|
|
209
|
+
// is assignable to a narrower one, which is how the gap survived #4562's
|
|
210
|
+
// review in the first place. This states the direction the card cares
|
|
211
|
+
// about instead of inheriting it from the class heritage clause.
|
|
212
|
+
const deleteView = adapter.deleteView.bind(adapter) satisfies ContractDeleteView;
|
|
213
|
+
expect(typeof deleteView).toBe('function');
|
|
214
|
+
|
|
215
|
+
type _AdapterAssignableToContract = Assert<
|
|
216
|
+
ObjectStackAdapter['deleteView'] extends ContractDeleteView ? true : false
|
|
217
|
+
>;
|
|
218
|
+
// ...and no longer merely assignable: the two returns are now one type, so
|
|
219
|
+
// the adapter cannot widen further without the contract following it.
|
|
220
|
+
type _ReturnsAreOneType = Assert<
|
|
221
|
+
Equal<Awaited<ReturnType<ObjectStackAdapter['deleteView']>>, ContractReturn>
|
|
222
|
+
>;
|
|
223
|
+
|
|
224
|
+
expect(true).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('a consumer holding only the shared DataSource (#4564)', () => {
|
|
229
|
+
it('reads the per-home outcomes the adapter actually returns', async () => {
|
|
230
|
+
const adapter = makeAdapter();
|
|
231
|
+
|
|
232
|
+
// The narrowing that makes this a #4564 pin rather than a duplicate of
|
|
233
|
+
// `deleteView.homes.test.ts`: the receipt below is typed by `DataSource`,
|
|
234
|
+
// not by `ObjectStackAdapter`. Before the move this assignment compiled
|
|
235
|
+
// just as well and threw the per-home outcomes away on the way through.
|
|
236
|
+
const ds: DataSource = adapter;
|
|
237
|
+
|
|
238
|
+
const receipt = await ds.deleteView!('account', VIEW);
|
|
239
|
+
|
|
240
|
+
// Draft-only view: the draft home held the row, the published one did not.
|
|
241
|
+
expect(receipt.deleted).toBe(true);
|
|
242
|
+
expect(receipt.draft?.removed).toBe(true);
|
|
243
|
+
expect(receipt.published?.removed).toBe(false);
|
|
244
|
+
expect(receipt.draft?.message).toContain('Draft discarded');
|
|
245
|
+
});
|
|
246
|
+
});
|