@object-ui/data-objectstack 17.2.0 → 17.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ });