@object-ui/data-objectstack 17.5.0 → 17.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/contracts.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Contracts module integration for @objectstack/spec v3.0.0
|
|
11
|
-
* Provides plugin contract validation and marketplace publishing utilities.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface PluginContract {
|
|
15
|
-
/** Plugin name */
|
|
16
|
-
name: string;
|
|
17
|
-
/** Plugin version */
|
|
18
|
-
version: string;
|
|
19
|
-
/** Required peer dependencies */
|
|
20
|
-
peerDependencies?: Record<string, string>;
|
|
21
|
-
/** Exported component types */
|
|
22
|
-
exports: PluginExport[];
|
|
23
|
-
/** Required permissions */
|
|
24
|
-
permissions?: string[];
|
|
25
|
-
/** API surface contract */
|
|
26
|
-
api?: PluginAPIContract;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface PluginExport {
|
|
30
|
-
/** Export name */
|
|
31
|
-
name: string;
|
|
32
|
-
/** Export type */
|
|
33
|
-
type: 'component' | 'hook' | 'utility' | 'provider';
|
|
34
|
-
/** Description */
|
|
35
|
-
description?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface PluginAPIContract {
|
|
39
|
-
/** Consumed data sources */
|
|
40
|
-
dataSources?: string[];
|
|
41
|
-
/** Required object schemas */
|
|
42
|
-
requiredSchemas?: string[];
|
|
43
|
-
/** Event subscriptions */
|
|
44
|
-
events?: string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ContractValidationResult {
|
|
48
|
-
valid: boolean;
|
|
49
|
-
errors: ContractValidationError[];
|
|
50
|
-
warnings: string[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface ContractValidationError {
|
|
54
|
-
field: string;
|
|
55
|
-
message: string;
|
|
56
|
-
code: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Validate a plugin contract against the ObjectStack spec.
|
|
61
|
-
*/
|
|
62
|
-
export function validatePluginContract(contract: PluginContract): ContractValidationResult {
|
|
63
|
-
const errors: ContractValidationError[] = [];
|
|
64
|
-
const warnings: string[] = [];
|
|
65
|
-
|
|
66
|
-
if (!contract.name || contract.name.trim().length === 0) {
|
|
67
|
-
errors.push({ field: 'name', message: 'Plugin name is required', code: 'MISSING_NAME' });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!contract.version || !/^\d+\.\d+\.\d+/.test(contract.version)) {
|
|
71
|
-
errors.push({ field: 'version', message: 'Valid semver version is required', code: 'INVALID_VERSION' });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (!contract.exports || contract.exports.length === 0) {
|
|
75
|
-
errors.push({ field: 'exports', message: 'At least one export is required', code: 'NO_EXPORTS' });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (contract.exports) {
|
|
79
|
-
const validTypes = ['component', 'hook', 'utility', 'provider'];
|
|
80
|
-
for (const exp of contract.exports) {
|
|
81
|
-
if (!exp.name) {
|
|
82
|
-
errors.push({ field: 'exports.name', message: 'Export name is required', code: 'MISSING_EXPORT_NAME' });
|
|
83
|
-
}
|
|
84
|
-
if (!validTypes.includes(exp.type)) {
|
|
85
|
-
errors.push({ field: 'exports.type', message: `Invalid export type: ${exp.type}`, code: 'INVALID_EXPORT_TYPE' });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (!contract.permissions || contract.permissions.length === 0) {
|
|
91
|
-
warnings.push('No permissions declared — plugin will have minimal access');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Generate a plugin contract manifest for marketplace publishing.
|
|
99
|
-
*/
|
|
100
|
-
export function generateContractManifest(contract: PluginContract): Record<string, unknown> {
|
|
101
|
-
return {
|
|
102
|
-
$schema: 'https://objectui.org/schemas/plugin-contract-v1.json',
|
|
103
|
-
name: contract.name,
|
|
104
|
-
version: contract.version,
|
|
105
|
-
peerDependencies: contract.peerDependencies ?? {},
|
|
106
|
-
exports: contract.exports.map(exp => ({
|
|
107
|
-
name: exp.name,
|
|
108
|
-
type: exp.type,
|
|
109
|
-
description: exp.description ?? '',
|
|
110
|
-
})),
|
|
111
|
-
permissions: contract.permissions ?? [],
|
|
112
|
-
api: contract.api ?? {},
|
|
113
|
-
generatedAt: new Date().toISOString(),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
@@ -1,389 +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
|
-
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
|
-
});
|