@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,241 @@
|
|
|
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
|
+
* objectui#3774 — the view-override read and write must share one key space.
|
|
11
|
+
*
|
|
12
|
+
* `updateViewConfig` persists a personalization (density, column widths, sort,
|
|
13
|
+
* hidden columns, inlineEdit …) as `PUT /api/v1/meta/view/{viewId}`, i.e. a
|
|
14
|
+
* `sys_metadata` row with `type='view'`. `listViewOverrides` used to enumerate
|
|
15
|
+
* `GET /api/v1/meta/{objectName}` — the OBJECT name in the metadata TYPE slot.
|
|
16
|
+
* The two key spaces are disjoint, so the batch map came back empty for every
|
|
17
|
+
* object forever and every saved preference read back as "setting didn't save".
|
|
18
|
+
*
|
|
19
|
+
* The measurement that picked the fix: `GET /meta/:type` accepts `?package=`
|
|
20
|
+
* and `?preview=draft` and NOTHING else (framework
|
|
21
|
+
* `packages/rest/src/rest-server.ts`, and `client.meta.getItems(type, {
|
|
22
|
+
* packageId })`). There is no `?object=` to push the filter into, so the
|
|
23
|
+
* correct route is the one `listViews()` already uses over the same rows:
|
|
24
|
+
* enumerate `type='view'` once, narrow to the object client-side.
|
|
25
|
+
*
|
|
26
|
+
* The suite is written round-trip on purpose — a stub `sys_metadata` that the
|
|
27
|
+
* adapter's own write path fills and its own read path drains. Asserting the
|
|
28
|
+
* read against a hand-written fixture would have stayed green through the
|
|
29
|
+
* entire bug, because the fixture is where the namespace mistake lived.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
33
|
+
import { ObjectStackAdapter } from './index';
|
|
34
|
+
|
|
35
|
+
/** A stub metadata store keyed the way `sys_metadata` is: `type` + `name`. */
|
|
36
|
+
function makeMetaStore() {
|
|
37
|
+
const rows = new Map<string, any>();
|
|
38
|
+
const getItemsTypes: string[] = [];
|
|
39
|
+
|
|
40
|
+
const meta = {
|
|
41
|
+
getItems: vi.fn(async (type: string) => {
|
|
42
|
+
getItemsTypes.push(type);
|
|
43
|
+
const items = [...rows.entries()]
|
|
44
|
+
.filter(([k]) => k.startsWith(`${type}::`))
|
|
45
|
+
.map(([, v]) => v);
|
|
46
|
+
return { type, items };
|
|
47
|
+
}),
|
|
48
|
+
getItem: vi.fn(async (type: string, name: string) => {
|
|
49
|
+
const item = rows.get(`${type}::${name}`);
|
|
50
|
+
if (!item) {
|
|
51
|
+
const err: any = new Error(`Not found: ${type}/${name}`);
|
|
52
|
+
err.status = 404;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
return { type, name, item };
|
|
56
|
+
}),
|
|
57
|
+
saveItem: vi.fn(async (type: string, name: string, item: any) => {
|
|
58
|
+
rows.set(`${type}::${name}`, { ...item });
|
|
59
|
+
return { success: true, item: rows.get(`${type}::${name}`) };
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return { meta, rows, getItemsTypes };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeDS(meta: any) {
|
|
67
|
+
const ds: any = new ObjectStackAdapter({
|
|
68
|
+
baseUrl: 'http://test.local',
|
|
69
|
+
fetch: vi.fn(async () =>
|
|
70
|
+
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
71
|
+
status: 200,
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
})),
|
|
74
|
+
});
|
|
75
|
+
ds.connected = true;
|
|
76
|
+
ds.connectionState = 'connected';
|
|
77
|
+
ds.client = { meta };
|
|
78
|
+
return ds;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What ObjectView's `persistViewPatch` actually sends on a density toggle +
|
|
83
|
+
* column sort: the view's raw config plus the personalization state. The
|
|
84
|
+
* adapter stamps `object` / `name` on top (and the framework's overlay heals
|
|
85
|
+
* the same identity fields onto rows that lack them — objectstack#2555).
|
|
86
|
+
*/
|
|
87
|
+
const PERSONALIZATION = {
|
|
88
|
+
type: 'grid',
|
|
89
|
+
data: { provider: 'object', object: 'crm_lead' },
|
|
90
|
+
columns: ['name', 'status'],
|
|
91
|
+
density: 'compact',
|
|
92
|
+
sort: [{ field: 'created_at', order: 'desc' }],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
describe('ObjectStackAdapter.listViewOverrides — read/write key space (#3774)', () => {
|
|
96
|
+
it('round-trips: a config saved through updateViewConfig comes back from listViewOverrides', async () => {
|
|
97
|
+
const { meta, getItemsTypes } = makeMetaStore();
|
|
98
|
+
const ds = makeDS(meta);
|
|
99
|
+
|
|
100
|
+
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
101
|
+
const map = await ds.listViewOverrides('crm_lead');
|
|
102
|
+
|
|
103
|
+
// The bug in one assertion: pre-fix this was `{}`.
|
|
104
|
+
expect(Object.keys(map)).toEqual(['crm_lead.all']);
|
|
105
|
+
expect(map['crm_lead.all']).toMatchObject({
|
|
106
|
+
name: 'crm_lead.all',
|
|
107
|
+
object: 'crm_lead',
|
|
108
|
+
density: 'compact',
|
|
109
|
+
sort: [{ field: 'created_at', order: 'desc' }],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// …and the type slot itself, which is the actual defect. The write went to
|
|
113
|
+
// `saveItem('view', …)`; the read must enumerate the SAME type.
|
|
114
|
+
expect(meta.saveItem).toHaveBeenCalledWith('view', 'crm_lead.all', expect.any(Object));
|
|
115
|
+
expect(getItemsTypes).toEqual(['view']);
|
|
116
|
+
expect(getItemsTypes).not.toContain('crm_lead');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('keys the map by the identity getView reads back by (drop-in for the per-view fetch)', async () => {
|
|
120
|
+
const { meta } = makeMetaStore();
|
|
121
|
+
const ds = makeDS(meta);
|
|
122
|
+
|
|
123
|
+
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
124
|
+
|
|
125
|
+
const batched = (await ds.listViewOverrides('crm_lead'))['crm_lead.all'];
|
|
126
|
+
const perView = await ds.getView('crm_lead', 'crm_lead.all');
|
|
127
|
+
|
|
128
|
+
// Same key space, same document — that equality is what lets a caller
|
|
129
|
+
// substitute one call for N without changing what it renders.
|
|
130
|
+
expect(batched).toEqual(perView);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('finds an override whose only object association is the top-level `object` stamp', async () => {
|
|
134
|
+
const { meta } = makeMetaStore();
|
|
135
|
+
const ds = makeDS(meta);
|
|
136
|
+
|
|
137
|
+
// A saved view with no `data.object` (e.g. a kanban personalization whose
|
|
138
|
+
// provider block was never sent) — `updateViewConfig`'s stamp is the only
|
|
139
|
+
// association, and it must be enough.
|
|
140
|
+
await ds.updateViewConfig('crm_lead', 'crm_lead.pipeline', { type: 'kanban', density: 'comfortable' });
|
|
141
|
+
const map = await ds.listViewOverrides('crm_lead');
|
|
142
|
+
|
|
143
|
+
expect(map['crm_lead.pipeline']).toMatchObject({ object: 'crm_lead', density: 'comfortable' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('narrows the shared `type=view` enumeration to the requested object', async () => {
|
|
147
|
+
const { meta } = makeMetaStore();
|
|
148
|
+
const ds = makeDS(meta);
|
|
149
|
+
|
|
150
|
+
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
151
|
+
await ds.updateViewConfig('crm_account', 'crm_account.all', {
|
|
152
|
+
type: 'grid', data: { provider: 'object', object: 'crm_account' }, density: 'compact',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(Object.keys(await ds.listViewOverrides('crm_lead'))).toEqual(['crm_lead.all']);
|
|
156
|
+
expect(Object.keys(await ds.listViewOverrides('crm_account'))).toEqual(['crm_account.all']);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('skips rows the enumeration returns without a name (nothing can address them)', async () => {
|
|
160
|
+
const { meta, rows } = makeMetaStore();
|
|
161
|
+
const ds = makeDS(meta);
|
|
162
|
+
rows.set('view::anon', { object: 'crm_lead', density: 'compact' });
|
|
163
|
+
|
|
164
|
+
expect(await ds.listViewOverrides('crm_lead')).toEqual({});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('ObjectStackAdapter.listViewOverrides — empty vs unknown (#3774)', () => {
|
|
169
|
+
it('an EMPTY successful enumeration resolves to {} — that answer is authoritative', async () => {
|
|
170
|
+
const { meta } = makeMetaStore();
|
|
171
|
+
const ds = makeDS(meta);
|
|
172
|
+
|
|
173
|
+
// No overrides exist for this object. The caller may trust this and skip
|
|
174
|
+
// the per-view reads; the batch optimization depends on it not throwing.
|
|
175
|
+
await expect(ds.listViewOverrides('crm_lead')).resolves.toEqual({});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a FAILING enumeration rejects — it must not masquerade as an empty map', async () => {
|
|
179
|
+
const { meta } = makeMetaStore();
|
|
180
|
+
meta.getItems.mockRejectedValueOnce(new Error('503 metadata store unavailable'));
|
|
181
|
+
const ds = makeDS(meta);
|
|
182
|
+
|
|
183
|
+
// Pre-fix this resolved to `{}`, which is indistinguishable from the
|
|
184
|
+
// authoritative "no overrides" above — that is what made ObjectView's
|
|
185
|
+
// per-view getView fallback unreachable code.
|
|
186
|
+
await expect(ds.listViewOverrides('crm_lead')).rejects.toThrow('503 metadata store unavailable');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('a rejection is not cached — the next call re-reads instead of pinning the failure', async () => {
|
|
190
|
+
const { meta } = makeMetaStore();
|
|
191
|
+
const ds = makeDS(meta);
|
|
192
|
+
await ds.updateViewConfig('crm_lead', 'crm_lead.all', { ...PERSONALIZATION });
|
|
193
|
+
meta.getItems.mockRejectedValueOnce(new Error('transient'));
|
|
194
|
+
|
|
195
|
+
await expect(ds.listViewOverrides('crm_lead')).rejects.toThrow('transient');
|
|
196
|
+
// The store recovered; a cached failure would have starved the view of its
|
|
197
|
+
// overrides for the whole TTL.
|
|
198
|
+
expect(Object.keys(await ds.listViewOverrides('crm_lead'))).toEqual(['crm_lead.all']);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('ObjectStackAdapter.listViewOverrides — wire route (#3774)', () => {
|
|
203
|
+
/** Let the REAL `client.meta` run so the request URL is observed, not assumed. */
|
|
204
|
+
function makeHttpDS(items: any[]) {
|
|
205
|
+
const urls: string[] = [];
|
|
206
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
207
|
+
const url = String(input);
|
|
208
|
+
urls.push(url);
|
|
209
|
+
if (url.includes('/meta/')) {
|
|
210
|
+
return new Response(JSON.stringify({ type: 'view', items }), {
|
|
211
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
215
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
const ds: any = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: fetchImpl });
|
|
219
|
+
ds.connected = true;
|
|
220
|
+
ds.connectionState = 'connected';
|
|
221
|
+
return { ds, urls };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
it('GETs the view TYPE, never a route built from the object name', async () => {
|
|
225
|
+
const { ds, urls } = makeHttpDS([
|
|
226
|
+
{ name: 'crm_lead.all', object: 'crm_lead', ...PERSONALIZATION },
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const map = await ds.listViewOverrides('crm_lead');
|
|
230
|
+
expect(map['crm_lead.all']).toMatchObject({ density: 'compact' });
|
|
231
|
+
|
|
232
|
+
const metaUrls = urls.filter((u) => u.includes('/meta/'));
|
|
233
|
+
expect(metaUrls.length).toBeGreaterThan(0);
|
|
234
|
+
for (const u of metaUrls) {
|
|
235
|
+
expect(u).toMatch(/\/meta\/view(\?|$)/);
|
|
236
|
+
// The pre-fix route. Pinned by shape so it cannot come back as
|
|
237
|
+
// `/meta/crm_lead` under a different spelling of the same mistake.
|
|
238
|
+
expect(u).not.toContain('/meta/crm_lead');
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
* objectui#4271 — `MetadataClient.get()` honors its documented unwrapped-body
|
|
11
|
+
* contract.
|
|
12
|
+
*
|
|
13
|
+
* The framework answers `GET /meta/:type/:name` with the spec-declared
|
|
14
|
+
* envelope `{ type, name, item, ...protection fields }`
|
|
15
|
+
* (`GetMetaItemResponseSchema`; objectstack#5563 collapsed the read to that ONE
|
|
16
|
+
* shape). `get()` used to hand that envelope straight back while its own
|
|
17
|
+
* docblock promised the unwrapped body — so every consumer reading `obj.fields`
|
|
18
|
+
* saw `undefined`, which is how the whole field half of the permission matrix
|
|
19
|
+
* went dead for every object.
|
|
20
|
+
*
|
|
21
|
+
* The asymmetry that IS real and stays: `getDraft()` documents the envelope and
|
|
22
|
+
* its callers read `.item`, so it must keep returning what the server sent.
|
|
23
|
+
* A11/A10 below are the pair that pins both halves.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
27
|
+
import { MetadataClient, type MetadataError } from './metadata-client';
|
|
28
|
+
|
|
29
|
+
function mockFetch(handler: (url: string, init?: RequestInit) => Promise<Response>): typeof fetch {
|
|
30
|
+
return vi.fn(handler) as unknown as typeof fetch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
34
|
+
return new Response(JSON.stringify(body), {
|
|
35
|
+
status: 200,
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
...init,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A client whose single-item read answers `body`, recording the URLs it hit. */
|
|
42
|
+
function clientAnswering(body: unknown, init?: ResponseInit) {
|
|
43
|
+
const urls: string[] = [];
|
|
44
|
+
const c = new MetadataClient({
|
|
45
|
+
baseUrl: 'http://localhost:3000',
|
|
46
|
+
fetch: mockFetch(async (url) => {
|
|
47
|
+
urls.push(url);
|
|
48
|
+
return init ? new Response(JSON.stringify(body), init) : jsonResponse(body);
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
return { c, urls };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The card's live shape: 21 fields under `item`, as showcase_project answers. */
|
|
55
|
+
const OBJECT_BODY = {
|
|
56
|
+
name: 'showcase_project',
|
|
57
|
+
label: 'Project',
|
|
58
|
+
fields: { name: { type: 'text' }, stage: { type: 'select' } },
|
|
59
|
+
};
|
|
60
|
+
const ENVELOPE = { type: 'object', name: 'showcase_project', item: OBJECT_BODY };
|
|
61
|
+
|
|
62
|
+
describe('objectui#4271 · MetadataClient.get() unwraps the spec envelope', () => {
|
|
63
|
+
it('A1: unwraps `{type,name,item}` to the item body', async () => {
|
|
64
|
+
const { c } = clientAnswering(ENVELOPE);
|
|
65
|
+
const obj = await c.get<Record<string, unknown>>('object', 'showcase_project');
|
|
66
|
+
expect(obj).toEqual(OBJECT_BODY);
|
|
67
|
+
// The headline consequence: the field map is reachable again.
|
|
68
|
+
expect(Object.keys((obj as any)?.fields ?? {})).toEqual(['name', 'stage']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('A2: unwraps when the ADR-0008 protection carriers ride along', async () => {
|
|
72
|
+
// `GetMetaItemResponseSchema` spreads MetadataProtectionEnvelopeFields, so
|
|
73
|
+
// a real envelope carries more than three keys. Unwrapping must not be
|
|
74
|
+
// conditioned on the key COUNT.
|
|
75
|
+
const { c } = clientAnswering({
|
|
76
|
+
...ENVELOPE,
|
|
77
|
+
lock: { locked: false, reason: 'ok' },
|
|
78
|
+
provenance: { origin: 'package' },
|
|
79
|
+
});
|
|
80
|
+
expect(await c.get('object', 'showcase_project')).toEqual(OBJECT_BODY);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('A3: an envelope carrying a null item reads as null', async () => {
|
|
84
|
+
const { c } = clientAnswering({ type: 'object', name: 'ghost', item: null });
|
|
85
|
+
expect(await c.get('object', 'ghost')).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('objectui#4271 · non-envelope responses pass through UNCHANGED', () => {
|
|
90
|
+
it('A4: a bare body (older server / already-unwrapped) is returned as-is', async () => {
|
|
91
|
+
const { c } = clientAnswering(OBJECT_BODY);
|
|
92
|
+
expect(await c.get('object', 'showcase_project')).toEqual(OBJECT_BODY);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('A5: type+name WITHOUT an `item` key is not unwrapped', async () => {
|
|
96
|
+
// A metadata document may legitimately carry `type` and `name` of its own
|
|
97
|
+
// (a view is `{name, type: "grid", ...}`). Absent `item`, it is the body.
|
|
98
|
+
const view = { name: 'all_projects', type: 'grid', columns: ['name'] };
|
|
99
|
+
const { c } = clientAnswering(view);
|
|
100
|
+
expect(await c.get('view', 'all_projects')).toEqual(view);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('A6: an `item` key WITHOUT the envelope identity is not unwrapped', async () => {
|
|
104
|
+
// Presence-checked against the spec shape, not duck-guessed off `item`
|
|
105
|
+
// alone — a body whose own schema has an `item` property stays whole.
|
|
106
|
+
const body = { name: 'cart', item: { sku: 'x' } };
|
|
107
|
+
const { c } = clientAnswering(body);
|
|
108
|
+
expect(await c.get('object', 'cart')).toEqual(body);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('A7: an array response passes through', async () => {
|
|
112
|
+
const { c } = clientAnswering([{ name: 'a' }]);
|
|
113
|
+
expect(await c.get('object', 'weird')).toEqual([{ name: 'a' }]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('A8: 404 still reads as null', async () => {
|
|
117
|
+
const { c } = clientAnswering({}, { status: 404 });
|
|
118
|
+
expect(await c.get('object', 'missing')).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('A9: error responses still throw with status, code and message intact', async () => {
|
|
122
|
+
const { c } = clientAnswering(
|
|
123
|
+
{ error: { code: 'RESOURCE_FORBIDDEN', message: 'Nope.' } },
|
|
124
|
+
{ status: 403, headers: { 'content-type': 'application/json' } },
|
|
125
|
+
);
|
|
126
|
+
await expect(c.get('object', 'secret')).rejects.toThrow('Nope.');
|
|
127
|
+
const err = await c.get('object', 'secret').catch((e: MetadataError) => e);
|
|
128
|
+
expect((err as MetadataError).status).toBe(403);
|
|
129
|
+
expect((err as MetadataError).code).toBe('RESOURCE_FORBIDDEN');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('objectui#4271 · the getDraft() envelope asymmetry is preserved', () => {
|
|
134
|
+
it('A10: getDraft() returns the RAW envelope, as its docblock promises', async () => {
|
|
135
|
+
// Load-bearing: ~11 call sites read `.item` off getDraft (StudioDesignSurface,
|
|
136
|
+
// ResourceEditPage, PackageOwdOverviewPanel, ObjectHooksPanel,
|
|
137
|
+
// PermissionMatrixEditor). Routing getDraft through the unwrapping get()
|
|
138
|
+
// would silently empty every one of them.
|
|
139
|
+
const draftEnvelope = { type: 'object', name: 'showcase_project', item: OBJECT_BODY };
|
|
140
|
+
const { c, urls } = clientAnswering(draftEnvelope);
|
|
141
|
+
const draft = await c.getDraft<Record<string, unknown>>('object', 'showcase_project');
|
|
142
|
+
expect(draft).toEqual(draftEnvelope);
|
|
143
|
+
expect((draft as any)?.item).toEqual(OBJECT_BODY);
|
|
144
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('A11: get(state:draft) unwraps like any other get()', async () => {
|
|
148
|
+
// The SHAPE follows the method's published contract, not the query param:
|
|
149
|
+
// `get()` promises the body on every path it serves.
|
|
150
|
+
const { c, urls } = clientAnswering(ENVELOPE);
|
|
151
|
+
expect(await c.get('object', 'showcase_project', { state: 'draft' })).toEqual(OBJECT_BODY);
|
|
152
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?state=draft');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('A12: URL construction is untouched by the unwrap', async () => {
|
|
156
|
+
const { c, urls } = clientAnswering(ENVELOPE);
|
|
157
|
+
await c.get('object', 'has spaces', { packageId: 'app.crm' });
|
|
158
|
+
expect(urls[0]).toBe('http://localhost:3000/api/v1/meta/object/has%20spaces?package=app.crm');
|
|
159
|
+
const preview = new MetadataClient({
|
|
160
|
+
baseUrl: 'http://localhost:3000',
|
|
161
|
+
previewDrafts: true,
|
|
162
|
+
fetch: mockFetch(async (url) => {
|
|
163
|
+
urls.push(url);
|
|
164
|
+
return jsonResponse(ENVELOPE);
|
|
165
|
+
}),
|
|
166
|
+
});
|
|
167
|
+
// The overlay flag still rides, and the preview read unwraps too.
|
|
168
|
+
expect(await preview.get('object', 'showcase_project')).toEqual(OBJECT_BODY);
|
|
169
|
+
expect(urls[1]).toBe('http://localhost:3000/api/v1/meta/object/showcase_project?preview=draft');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
* `MetadataClient.save` must surface the runtime authoring gate's advisory
|
|
11
|
+
* findings (objectui#4133; backend objectstack#7435, landed as `89d7b35a7`).
|
|
12
|
+
*
|
|
13
|
+
* The save SUCCEEDED — a 200, the row persisted, the version bumped. The gate
|
|
14
|
+
* simply noticed something worth telling the author, and before this the
|
|
15
|
+
* findings were parsed off the wire and dropped on the floor client-side. These
|
|
16
|
+
* pins are the red-first evidence: with the emit removed from `save()`, the
|
|
17
|
+
* "renders the findings" cases below fail because no event ever arrives.
|
|
18
|
+
*
|
|
19
|
+
* The measured example the card is built on: a `nightly_purge` flow whose only
|
|
20
|
+
* defect is a `delete_record` node with `multi: true` and no filter yields
|
|
21
|
+
* `errors = 0 / advisories = 1` — a 200 the author currently learns nothing from.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
25
|
+
import {
|
|
26
|
+
MetadataClient,
|
|
27
|
+
readSaveAdvisories,
|
|
28
|
+
type MetadataSaveAdvisoryEvent,
|
|
29
|
+
type RuntimeAuthoringIssue,
|
|
30
|
+
} from './metadata-client';
|
|
31
|
+
|
|
32
|
+
/** The measured `nightly_purge` finding, in the spec's D3 shape. */
|
|
33
|
+
const PURGE_ADVISORY: RuntimeAuthoringIssue = {
|
|
34
|
+
severity: 'warning',
|
|
35
|
+
rule: 'flow/delete-without-filter',
|
|
36
|
+
where: 'flow "nightly_purge" · node "purge old rows"',
|
|
37
|
+
path: 'flows[0].nodes[2].config.filters',
|
|
38
|
+
message: 'this delete_record node sets multi: true with no filter, so it deletes every row',
|
|
39
|
+
hint: 'add a filter, or set multi: false to delete a single record',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function saveResponse(body: unknown): Response {
|
|
43
|
+
return new Response(JSON.stringify(body), {
|
|
44
|
+
status: 200,
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clientWith(
|
|
50
|
+
responseBody: unknown,
|
|
51
|
+
onSaveAdvisory?: (ev: MetadataSaveAdvisoryEvent) => void,
|
|
52
|
+
) {
|
|
53
|
+
return new MetadataClient({
|
|
54
|
+
baseUrl: 'http://test.local',
|
|
55
|
+
fetch: vi.fn(async () => saveResponse(responseBody)) as unknown as typeof fetch,
|
|
56
|
+
...(onSaveAdvisory ? { onSaveAdvisory } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CLEAN_BODY = { success: true, version: 'v2', seq: 4, state: 'active' };
|
|
61
|
+
|
|
62
|
+
describe('MetadataClient.save — runtime authoring gate advisories (#4133)', () => {
|
|
63
|
+
it('emits the findings a successful save returned', async () => {
|
|
64
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
65
|
+
const client = clientWith(
|
|
66
|
+
{ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] },
|
|
67
|
+
(e) => events.push(e),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
await client.save('flow', 'nightly_purge', { name: 'nightly_purge' });
|
|
71
|
+
|
|
72
|
+
expect(events).toHaveLength(1);
|
|
73
|
+
expect(events[0]).toEqual({
|
|
74
|
+
type: 'flow',
|
|
75
|
+
name: 'nightly_purge',
|
|
76
|
+
mode: 'publish',
|
|
77
|
+
advisories: [PURGE_ADVISORY],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('carries rule, message and hint through verbatim — they are server prose', async () => {
|
|
82
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
83
|
+
const client = clientWith(
|
|
84
|
+
{ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] },
|
|
85
|
+
(e) => events.push(e),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await client.save('flow', 'nightly_purge', {});
|
|
89
|
+
|
|
90
|
+
const [finding] = events[0]!.advisories;
|
|
91
|
+
expect(finding!.rule).toBe('flow/delete-without-filter');
|
|
92
|
+
expect(finding!.message).toBe(PURGE_ADVISORY.message);
|
|
93
|
+
expect(finding!.hint).toBe(PURGE_ADVISORY.hint);
|
|
94
|
+
// Never `error` on this channel — an error-severity finding is a 422.
|
|
95
|
+
expect(finding!.severity).toBe('warning');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('says nothing on a clean save — the server omits the key entirely', async () => {
|
|
99
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
100
|
+
const client = clientWith(CLEAN_BODY, (e) => events.push(e));
|
|
101
|
+
|
|
102
|
+
await client.save('object', 'account', { name: 'account' });
|
|
103
|
+
|
|
104
|
+
expect(events).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('says nothing when the array is present but empty', async () => {
|
|
108
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
109
|
+
const client = clientWith({ ...CLEAN_BODY, advisories: [] }, (e) => events.push(e));
|
|
110
|
+
|
|
111
|
+
await client.save('object', 'account', {});
|
|
112
|
+
|
|
113
|
+
expect(events).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('still returns the save response unchanged', async () => {
|
|
117
|
+
const body = { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] };
|
|
118
|
+
const client = clientWith(body, () => {});
|
|
119
|
+
|
|
120
|
+
const result = await client.save('flow', 'nightly_purge', {});
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual(body);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('a throwing sink never fails a save the server already committed', async () => {
|
|
126
|
+
const client = clientWith({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }, () => {
|
|
127
|
+
throw new Error('renderer exploded');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await expect(client.save('flow', 'nightly_purge', {})).resolves.toBeTruthy();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The honest control for the coverage statement (#4133 requirement 3).
|
|
135
|
+
*
|
|
136
|
+
* A `mode: 'draft'` save is a PUT through the same save door, but the
|
|
137
|
+
* framework's gate returns at its D1 early-return before running a single
|
|
138
|
+
* rule — `runtime-authoring-gate.ts`:
|
|
139
|
+
*
|
|
140
|
+
* // D1 — drafts are never gated. Publishing one runs this same function.
|
|
141
|
+
* if (args.state !== 'active') return null;
|
|
142
|
+
*
|
|
143
|
+
* so the server produces no findings and the response carries no key. The
|
|
144
|
+
* client is not what suppresses this; there is nothing to suppress. Pinned
|
|
145
|
+
* because Studio's designer saves as draft on EVERY edit, which is why the
|
|
146
|
+
* affordance renders nothing on that flow today.
|
|
147
|
+
*/
|
|
148
|
+
it('a draft save carries no findings — the gate never ran (D1)', async () => {
|
|
149
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
150
|
+
const client = clientWith({ ...CLEAN_BODY, state: 'draft' }, (e) => events.push(e));
|
|
151
|
+
|
|
152
|
+
await client.save('flow', 'nightly_purge', {}, { mode: 'draft' });
|
|
153
|
+
|
|
154
|
+
expect(events).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('labels the mode when a draft save does somehow advise', async () => {
|
|
158
|
+
// Not reachable through today's server (see D1 above), but the event's
|
|
159
|
+
// `mode` must tell the truth about which door it came through rather than
|
|
160
|
+
// hard-coding one, so the field is pinned on its own.
|
|
161
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
162
|
+
const client = clientWith(
|
|
163
|
+
{ ...CLEAN_BODY, state: 'draft', advisories: [PURGE_ADVISORY] },
|
|
164
|
+
(e) => events.push(e),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await client.save('flow', 'nightly_purge', {}, { mode: 'draft' });
|
|
168
|
+
|
|
169
|
+
expect(events[0]!.mode).toBe('draft');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('survives the withEnvironment clone — console clients are all env-scoped', async () => {
|
|
173
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
174
|
+
const base = new MetadataClient({
|
|
175
|
+
baseUrl: 'http://test.local',
|
|
176
|
+
fetch: vi.fn(async () =>
|
|
177
|
+
saveResponse({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }),
|
|
178
|
+
) as unknown as typeof fetch,
|
|
179
|
+
onSaveAdvisory: (e) => events.push(e),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await base.withEnvironment('env_1').save('flow', 'nightly_purge', {});
|
|
183
|
+
|
|
184
|
+
expect(events).toHaveLength(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('survives the withPreviewDrafts clone', async () => {
|
|
188
|
+
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
189
|
+
const base = new MetadataClient({
|
|
190
|
+
baseUrl: 'http://test.local',
|
|
191
|
+
fetch: vi.fn(async () =>
|
|
192
|
+
saveResponse({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] }),
|
|
193
|
+
) as unknown as typeof fetch,
|
|
194
|
+
onSaveAdvisory: (e) => events.push(e),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await base.withPreviewDrafts(true).save('flow', 'nightly_purge', {});
|
|
198
|
+
|
|
199
|
+
expect(events).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('readSaveAdvisories', () => {
|
|
204
|
+
it('reads a well-formed array', () => {
|
|
205
|
+
expect(readSaveAdvisories({ advisories: [PURGE_ADVISORY] })).toEqual([PURGE_ADVISORY]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('returns empty for a response with no advisories key', () => {
|
|
209
|
+
expect(readSaveAdvisories(CLEAN_BODY)).toEqual([]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it.each([
|
|
213
|
+
['null body', null],
|
|
214
|
+
['a string body', 'nope'],
|
|
215
|
+
['a non-array advisories', { advisories: { rule: 'x' } }],
|
|
216
|
+
])('returns empty for %s', (_label, body) => {
|
|
217
|
+
expect(readSaveAdvisories(body)).toEqual([]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('drops half-shaped findings rather than rendering blanks at the author', () => {
|
|
221
|
+
const out = readSaveAdvisories({
|
|
222
|
+
advisories: [PURGE_ADVISORY, { rule: 'only-a-rule' }, null, 'string'],
|
|
223
|
+
});
|
|
224
|
+
expect(out).toEqual([PURGE_ADVISORY]);
|
|
225
|
+
});
|
|
226
|
+
});
|