@object-ui/data-objectstack 17.5.0 → 17.6.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 +219 -0
- package/dist/index.cjs +262 -357
- package/dist/index.d.cts +385 -448
- package/dist/index.d.ts +385 -448
- package/dist/index.js +258 -350
- package/package.json +5 -6
- 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
|
@@ -1,311 +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
|
-
* The SECOND client class's metadata save door must surface the runtime
|
|
11
|
-
* authoring gate's advisory findings (objectui#4237; backend objectstack#7435).
|
|
12
|
-
*
|
|
13
|
-
* `ObjectStackClient.meta.saveItem` writes through the same
|
|
14
|
-
* `PUT /api/v1/meta/:type/:name` as `MetadataClient.save`, but it is a
|
|
15
|
-
* different class and #4133/#4236 only covered the other one — every caller
|
|
16
|
-
* here awaited the call and discarded the response, so an `advisories[]` the
|
|
17
|
-
* server attached was parsed off the wire and dropped. These pins are the
|
|
18
|
-
* red-first evidence: with `installSaveAdvisoryInterceptor()` reverted, every
|
|
19
|
-
* "emits" case below fails because no event is ever produced.
|
|
20
|
-
*
|
|
21
|
-
* ## Why these drive a REAL SDK client through a fake `fetch`
|
|
22
|
-
*
|
|
23
|
-
* The sibling `onWriteWarning.test.ts` replaces `ds.client` with a stub, which
|
|
24
|
-
* is right for the data door. It would be wrong here: the whole premise of this
|
|
25
|
-
* fix is a measurement about the wire — that the SDK's `unwrapResponse` hands
|
|
26
|
-
* the save body back VERBATIM (it strips its `{ success, data }` envelope only
|
|
27
|
-
* when the body has a `data` key, and this one does not), so `advisories` is
|
|
28
|
-
* still at the top level where the shared `readSaveAdvisories` looks for it.
|
|
29
|
-
* Stubbing `meta` would assume exactly the thing under test. Driving the real
|
|
30
|
-
* client keeps that measured on every run.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
34
|
-
import { ObjectStackAdapter } from './index';
|
|
35
|
-
import type {
|
|
36
|
-
MetadataSaveAdvisoryEvent,
|
|
37
|
-
RuntimeAuthoringIssue,
|
|
38
|
-
WriteWarningEvent,
|
|
39
|
-
} from './index';
|
|
40
|
-
|
|
41
|
-
/** The measured `nightly_purge` finding, in the spec's D3 shape. */
|
|
42
|
-
const PURGE_ADVISORY: RuntimeAuthoringIssue = {
|
|
43
|
-
severity: 'warning',
|
|
44
|
-
rule: 'flow/delete-without-filter',
|
|
45
|
-
where: 'flow "nightly_purge" · node "purge old rows"',
|
|
46
|
-
path: 'flows[0].nodes[2].config.filters',
|
|
47
|
-
message: 'this delete_record node sets multi: true with no filter, so it deletes every row',
|
|
48
|
-
hint: 'add a filter, or set multi: false to delete a single record',
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The clean part of a real save response. `success`/`version`/`seq`/`state` are
|
|
53
|
-
* REQUIRED by `SaveMetaItemResponseSchema` (#5745) — spelled out here because
|
|
54
|
-
* their presence is what makes the `unwrapResponse` measurement above
|
|
55
|
-
* meaningful: a `success` boolean with NO sibling `data` key.
|
|
56
|
-
*/
|
|
57
|
-
const CLEAN_BODY = { success: true, version: 'v2', seq: 4, state: 'active' as const };
|
|
58
|
-
|
|
59
|
-
/** A `fetch` that answers every metadata PUT with `body`, and records the calls. */
|
|
60
|
-
function fetchAnswering(body: unknown) {
|
|
61
|
-
return vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
|
62
|
-
new Response(JSON.stringify(body), {
|
|
63
|
-
status: 200,
|
|
64
|
-
headers: { 'content-type': 'application/json' },
|
|
65
|
-
}),
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* A connected adapter whose client talks to `fetchImpl`. `connected` is forced
|
|
71
|
-
* rather than discovered, exactly as the sibling write-warning suite does — the
|
|
72
|
-
* save door needs no discovery (`getRoute('metadata')` falls back to
|
|
73
|
-
* `/api/v1/meta`), and skipping it keeps the module-level discovery cache out
|
|
74
|
-
* of these tests.
|
|
75
|
-
*/
|
|
76
|
-
function makeAdapter(body: unknown) {
|
|
77
|
-
const fetchImpl = fetchAnswering(body);
|
|
78
|
-
const ds: any = new ObjectStackAdapter({
|
|
79
|
-
baseUrl: 'http://test.local',
|
|
80
|
-
fetch: fetchImpl as unknown as typeof fetch,
|
|
81
|
-
});
|
|
82
|
-
ds.connected = true;
|
|
83
|
-
ds.connectionState = 'connected';
|
|
84
|
-
return { ds, fetchImpl };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
describe('ObjectStackAdapter.onSaveAdvisory — the second client class (#4237)', () => {
|
|
88
|
-
it('emits the findings a successful save returned', async () => {
|
|
89
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
90
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
91
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
92
|
-
|
|
93
|
-
await ds.getClient().meta.saveItem('flow', 'nightly_purge', { name: 'nightly_purge' });
|
|
94
|
-
|
|
95
|
-
expect(events).toEqual([
|
|
96
|
-
{
|
|
97
|
-
type: 'flow',
|
|
98
|
-
name: 'nightly_purge',
|
|
99
|
-
mode: 'publish',
|
|
100
|
-
advisories: [PURGE_ADVISORY],
|
|
101
|
-
},
|
|
102
|
-
]);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('carries rule, message and hint through verbatim — they are server prose', async () => {
|
|
106
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
107
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
108
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
109
|
-
|
|
110
|
-
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
111
|
-
|
|
112
|
-
const finding = events[0]!.advisories[0]!;
|
|
113
|
-
expect(finding.rule).toBe('flow/delete-without-filter');
|
|
114
|
-
expect(finding.message).toBe(PURGE_ADVISORY.message);
|
|
115
|
-
expect(finding.hint).toBe(PURGE_ADVISORY.hint);
|
|
116
|
-
// Never `error` on this channel — an error-severity finding is a 422.
|
|
117
|
-
expect(finding.severity).toBe('warning');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* The response-shape measurement the fix rests on, asserted rather than
|
|
122
|
-
* assumed: this body is handed back whole. If a future SDK started wrapping
|
|
123
|
-
* the save response in `{ success, data }`, THIS is the case that goes red
|
|
124
|
-
* and says so, instead of the advisory channel quietly emitting nothing.
|
|
125
|
-
*/
|
|
126
|
-
it('hands the caller the save response unchanged — the SDK does not unwrap this body', async () => {
|
|
127
|
-
const body = { ...CLEAN_BODY, advisories: [PURGE_ADVISORY] };
|
|
128
|
-
const { ds } = makeAdapter(body);
|
|
129
|
-
|
|
130
|
-
const result = await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
131
|
-
|
|
132
|
-
expect(result).toEqual(body);
|
|
133
|
-
expect(result.version).toBe('v2');
|
|
134
|
-
expect(result.advisories).toEqual([PURGE_ADVISORY]);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('says nothing on a clean save — the server omits the key entirely', async () => {
|
|
138
|
-
const { ds } = makeAdapter(CLEAN_BODY);
|
|
139
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
140
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
141
|
-
|
|
142
|
-
await ds.getClient().meta.saveItem('object', 'account', { name: 'account' });
|
|
143
|
-
|
|
144
|
-
expect(events).toEqual([]);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('says nothing when the array is present but empty', async () => {
|
|
148
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [] });
|
|
149
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
150
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
151
|
-
|
|
152
|
-
await ds.getClient().meta.saveItem('object', 'account', {});
|
|
153
|
-
|
|
154
|
-
expect(events).toEqual([]);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('drops half-shaped findings rather than rendering blanks at the author', async () => {
|
|
158
|
-
const { ds } = makeAdapter({
|
|
159
|
-
...CLEAN_BODY,
|
|
160
|
-
advisories: [PURGE_ADVISORY, { rule: 'only-a-rule' }, null, 'string'],
|
|
161
|
-
});
|
|
162
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
163
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
164
|
-
|
|
165
|
-
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
166
|
-
|
|
167
|
-
// The SAME reader `MetadataClient.save` uses — one reader, two call sites.
|
|
168
|
-
expect(events[0]!.advisories).toEqual([PURGE_ADVISORY]);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('a throwing listener never fails a save the server already committed', async () => {
|
|
172
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
173
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
174
|
-
const seen: MetadataSaveAdvisoryEvent[] = [];
|
|
175
|
-
ds.onSaveAdvisory(() => {
|
|
176
|
-
throw new Error('renderer exploded');
|
|
177
|
-
});
|
|
178
|
-
// Registered second: a throwing listener must not starve the others.
|
|
179
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => seen.push(e));
|
|
180
|
-
|
|
181
|
-
await expect(
|
|
182
|
-
ds.getClient().meta.saveItem('flow', 'nightly_purge', {}),
|
|
183
|
-
).resolves.toBeTruthy();
|
|
184
|
-
expect(seen).toHaveLength(1);
|
|
185
|
-
warn.mockRestore();
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it('unsubscribes', async () => {
|
|
189
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
190
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
191
|
-
const off = ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
192
|
-
|
|
193
|
-
await ds.getClient().meta.saveItem('flow', 'a', {});
|
|
194
|
-
off();
|
|
195
|
-
await ds.getClient().meta.saveItem('flow', 'b', {});
|
|
196
|
-
|
|
197
|
-
expect(events).toHaveLength(1);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Control for the #4133/#4236 sibling channel: a metadata save is NOT a
|
|
202
|
-
* record write, and must not appear on the dropped-fields channel. This is
|
|
203
|
-
* what makes the "existing `onWriteWarning` consumers unchanged" claim
|
|
204
|
-
* checkable rather than asserted.
|
|
205
|
-
*/
|
|
206
|
-
it('does not leak onto the write-warning channel', async () => {
|
|
207
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
208
|
-
const warnings: WriteWarningEvent[] = [];
|
|
209
|
-
ds.onWriteWarning((e: WriteWarningEvent) => warnings.push(e));
|
|
210
|
-
|
|
211
|
-
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
212
|
-
|
|
213
|
-
expect(warnings).toEqual([]);
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* Draft-door honesty (D1), carried over from #4236's finding.
|
|
219
|
-
*
|
|
220
|
-
* Drafts are never gated — the framework returns at
|
|
221
|
-
* `runtime-authoring-gate.ts`'s early return:
|
|
222
|
-
*
|
|
223
|
-
* // D1 — drafts are never gated. Publishing one runs this same function.
|
|
224
|
-
* if (args.state !== 'active') return null;
|
|
225
|
-
*
|
|
226
|
-
* so a draft save produces no findings at all rather than producing some that
|
|
227
|
-
* get withheld. Unlike the other client class, THIS one has no draft door to
|
|
228
|
-
* begin with: the SDK's `saveItem(type, name, item)` takes no mode, so every
|
|
229
|
-
* caller enumerated on #4237 writes the active door and the gate does run for
|
|
230
|
-
* all of them. `mode` is therefore read off the response's own `state`.
|
|
231
|
-
*/
|
|
232
|
-
describe('save-advisory mode reflects the door the server reports (D1)', () => {
|
|
233
|
-
it('a draft-state response with no findings emits nothing — the gate never ran', async () => {
|
|
234
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, state: 'draft' });
|
|
235
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
236
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
237
|
-
|
|
238
|
-
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
239
|
-
|
|
240
|
-
expect(events).toEqual([]);
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('labels the mode from the response state when a draft does somehow advise', async () => {
|
|
244
|
-
// Not reachable through today's server (see D1 above), but the event must
|
|
245
|
-
// tell the truth about which door it came through rather than hard-coding
|
|
246
|
-
// one, so the field is pinned on its own.
|
|
247
|
-
const { ds } = makeAdapter({
|
|
248
|
-
...CLEAN_BODY,
|
|
249
|
-
state: 'draft',
|
|
250
|
-
advisories: [PURGE_ADVISORY],
|
|
251
|
-
});
|
|
252
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
253
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
254
|
-
|
|
255
|
-
await ds.getClient().meta.saveItem('flow', 'nightly_purge', {});
|
|
256
|
-
|
|
257
|
-
expect(events[0]!.mode).toBe('draft');
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Caller coverage. The point of putting ONE emitter at the client seam is that
|
|
263
|
-
* the enumerated call sites need no edit — so the pins that matter are the ones
|
|
264
|
-
* that never mention the interceptor.
|
|
265
|
-
*/
|
|
266
|
-
describe('the enumerated callers are covered without a per-site edit', () => {
|
|
267
|
-
it("covers the adapter's own view save path (updateViewConfig)", async () => {
|
|
268
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
269
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
270
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
271
|
-
|
|
272
|
-
await ds.updateViewConfig('lead', 'all_leads', { object: 'lead' });
|
|
273
|
-
|
|
274
|
-
expect(events).toHaveLength(1);
|
|
275
|
-
expect(events[0]!.type).toBe('view');
|
|
276
|
-
expect(events[0]!.name).toBe('all_leads');
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("covers the adapter's own dashboard save path (updateDashboard)", async () => {
|
|
280
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
281
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
282
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
283
|
-
|
|
284
|
-
await ds.updateDashboard('crm_overview_dashboard', { widgets: [] });
|
|
285
|
-
|
|
286
|
-
expect(events).toHaveLength(1);
|
|
287
|
-
expect(events[0]!.type).toBe('dashboard');
|
|
288
|
-
expect(events[0]!.name).toBe('crm_overview_dashboard');
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* `getClient()` hands back the adapter's own long-lived instance, which is
|
|
293
|
-
* how every caller OUTSIDE this package reaches the save door
|
|
294
|
-
* (`MetadataService`, `useNavigationSync`, plugin-designer's app wizard).
|
|
295
|
-
* If that ever started returning a fresh or wrapped client, the interceptor
|
|
296
|
-
* would stop covering them — so the identity is pinned here.
|
|
297
|
-
*/
|
|
298
|
-
it('getClient() is the intercepted instance every external caller uses', async () => {
|
|
299
|
-
const { ds } = makeAdapter({ ...CLEAN_BODY, advisories: [PURGE_ADVISORY] });
|
|
300
|
-
const events: MetadataSaveAdvisoryEvent[] = [];
|
|
301
|
-
ds.onSaveAdvisory((e: MetadataSaveAdvisoryEvent) => events.push(e));
|
|
302
|
-
|
|
303
|
-
expect(ds.getClient()).toBe(ds.getClient());
|
|
304
|
-
|
|
305
|
-
// The `saveItem('app', …)` shape `useNavigationSync` and the plugin-designer
|
|
306
|
-
// app wizard both use.
|
|
307
|
-
await ds.getClient().meta.saveItem('app', 'crm', { name: 'crm' });
|
|
308
|
-
|
|
309
|
-
expect(events[0]).toMatchObject({ type: 'app', name: 'crm' });
|
|
310
|
-
});
|
|
311
|
-
});
|
|
@@ -1,273 +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
|
-
* ObjectStackAdapter must surface the framework's write-observability channel
|
|
11
|
-
* (#3431/#3455): when a create/update response carries `droppedFields` — fields
|
|
12
|
-
* the backend LEGALLY stripped (read-only / locked-by-state) — the adapter emits
|
|
13
|
-
* a WriteWarningEvent so the app shell can toast the user instead of letting the
|
|
14
|
-
* dropped value vanish silently. The write itself still succeeded.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
17
|
-
import { ObjectStackAdapter } from './index';
|
|
18
|
-
import type { WriteWarningEvent } from './index';
|
|
19
|
-
|
|
20
|
-
function makeDS(stub: Record<string, any>) {
|
|
21
|
-
const ds: any = new ObjectStackAdapter({
|
|
22
|
-
baseUrl: 'http://test.local',
|
|
23
|
-
fetch: vi.fn(async () =>
|
|
24
|
-
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
|
|
25
|
-
status: 200,
|
|
26
|
-
headers: { 'Content-Type': 'application/json' },
|
|
27
|
-
}),
|
|
28
|
-
),
|
|
29
|
-
});
|
|
30
|
-
ds.connected = true;
|
|
31
|
-
ds.connectionState = 'connected';
|
|
32
|
-
ds.client = { data: stub };
|
|
33
|
-
return ds;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const ONE_DROP = [{ object: 'approval_case', fields: ['approval_status'], reason: 'readonly' }];
|
|
37
|
-
|
|
38
|
-
describe('ObjectStackAdapter.onWriteWarning', () => {
|
|
39
|
-
it('emits a create write-warning carrying the dropped fields and the new id', async () => {
|
|
40
|
-
const create = vi.fn().mockResolvedValue({ record: { id: 'r1', title: 'A' }, droppedFields: ONE_DROP });
|
|
41
|
-
const ds = makeDS({ create });
|
|
42
|
-
const events: WriteWarningEvent[] = [];
|
|
43
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
44
|
-
|
|
45
|
-
const rec = await ds.create('approval_case', { title: 'A', approval_status: 'approved' });
|
|
46
|
-
|
|
47
|
-
// The write still returns the plain record (unchanged behaviour).
|
|
48
|
-
expect(rec).toEqual({ id: 'r1', title: 'A' });
|
|
49
|
-
expect(events).toEqual([
|
|
50
|
-
{ operation: 'create', resource: 'approval_case', id: 'r1', droppedFields: ONE_DROP },
|
|
51
|
-
]);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('emits an update write-warning carrying the target id', async () => {
|
|
55
|
-
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
|
|
56
|
-
const ds = makeDS({ update });
|
|
57
|
-
const events: WriteWarningEvent[] = [];
|
|
58
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
59
|
-
|
|
60
|
-
await ds.update('approval_case', 'r1', { approval_status: 'approved' });
|
|
61
|
-
|
|
62
|
-
expect(events).toEqual([
|
|
63
|
-
{ operation: 'update', resource: 'approval_case', id: 'r1', droppedFields: ONE_DROP },
|
|
64
|
-
]);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('does NOT emit when the response carries no droppedFields (the common case)', async () => {
|
|
68
|
-
const create = vi.fn().mockResolvedValue({ record: { id: 'r1', title: 'A' } });
|
|
69
|
-
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' } });
|
|
70
|
-
const ds = makeDS({ create, update });
|
|
71
|
-
const events: WriteWarningEvent[] = [];
|
|
72
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
73
|
-
|
|
74
|
-
await ds.create('approval_case', { title: 'A' });
|
|
75
|
-
await ds.update('approval_case', 'r1', { title: 'B' });
|
|
76
|
-
|
|
77
|
-
expect(events).toEqual([]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('does NOT emit for an empty or malformed droppedFields payload', async () => {
|
|
81
|
-
const create = vi
|
|
82
|
-
.fn()
|
|
83
|
-
.mockResolvedValueOnce({ record: { id: 'r1' }, droppedFields: [] })
|
|
84
|
-
.mockResolvedValueOnce({ record: { id: 'r2' }, droppedFields: 'nope' })
|
|
85
|
-
.mockResolvedValueOnce({ record: { id: 'r3' }, droppedFields: [{ object: 'x', fields: [] }] });
|
|
86
|
-
const ds = makeDS({ create });
|
|
87
|
-
const events: WriteWarningEvent[] = [];
|
|
88
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
89
|
-
|
|
90
|
-
await ds.create('x', {});
|
|
91
|
-
await ds.create('x', {});
|
|
92
|
-
await ds.create('x', {}); // event present but its fields[] is empty → filtered out
|
|
93
|
-
|
|
94
|
-
expect(events).toEqual([]);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
// ── cross-object batch (framework #3794) ─────────────────────────────────
|
|
98
|
-
//
|
|
99
|
-
// `batchTransaction` is the console record form's save path for a
|
|
100
|
-
// master-detail record, so this is where a `readonlyWhen` strip actually
|
|
101
|
-
// reaches a user editing a form. Its response tags each event with the index
|
|
102
|
-
// of the operation that produced it.
|
|
103
|
-
|
|
104
|
-
it('emits a write-warning per event on a cross-object batch, resolving the op', async () => {
|
|
105
|
-
const batchTransaction = vi.fn().mockResolvedValue({
|
|
106
|
-
results: [{ id: 'acc1' }, { id: 'inv1' }],
|
|
107
|
-
droppedFields: [
|
|
108
|
-
{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
|
|
109
|
-
],
|
|
110
|
-
});
|
|
111
|
-
const ds = makeDS({ batchTransaction });
|
|
112
|
-
ds.atomicBatchCapability = true;
|
|
113
|
-
const events: WriteWarningEvent[] = [];
|
|
114
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
115
|
-
|
|
116
|
-
await ds.batchTransaction([
|
|
117
|
-
{ object: 'account', action: 'create', data: { name: 'Acme' } },
|
|
118
|
-
{ object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
|
|
119
|
-
]);
|
|
120
|
-
|
|
121
|
-
expect(events).toEqual([
|
|
122
|
-
{
|
|
123
|
-
operation: 'update',
|
|
124
|
-
resource: 'invoice',
|
|
125
|
-
id: 'inv1',
|
|
126
|
-
droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
|
|
127
|
-
},
|
|
128
|
-
]);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('does NOT emit for a clean batch or a malformed droppedFields list', async () => {
|
|
132
|
-
const batchTransaction = vi
|
|
133
|
-
.fn()
|
|
134
|
-
.mockResolvedValueOnce({ results: [{ id: 'a' }] })
|
|
135
|
-
.mockResolvedValueOnce({ results: [{ id: 'a' }], droppedFields: [{ object: 'x', fields: [], index: 0 }] });
|
|
136
|
-
const ds = makeDS({ batchTransaction });
|
|
137
|
-
ds.atomicBatchCapability = true;
|
|
138
|
-
const events: WriteWarningEvent[] = [];
|
|
139
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
140
|
-
|
|
141
|
-
await ds.batchTransaction([{ object: 'x', action: 'create', data: {} }]);
|
|
142
|
-
await ds.batchTransaction([{ object: 'x', action: 'create', data: {} }]);
|
|
143
|
-
|
|
144
|
-
expect(events).toEqual([]);
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it('stops delivering after unsubscribe', async () => {
|
|
148
|
-
const create = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
|
|
149
|
-
const ds = makeDS({ create });
|
|
150
|
-
const events: WriteWarningEvent[] = [];
|
|
151
|
-
const unsub = ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
152
|
-
|
|
153
|
-
await ds.create('approval_case', {});
|
|
154
|
-
unsub();
|
|
155
|
-
await ds.create('approval_case', {});
|
|
156
|
-
|
|
157
|
-
expect(events).toHaveLength(1);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// ── no-op strips are not news (objectui#3484) ─────────────────────────────
|
|
161
|
-
//
|
|
162
|
-
// The server is right to report a strip — the client DID send the key. But
|
|
163
|
-
// "was not saved" is only worth a toast when something was LOST. The console
|
|
164
|
-
// edit form used to round-trip the whole record, so every save of a
|
|
165
|
-
// state-locked record warned about fields the user never touched whose values
|
|
166
|
-
// never changed. When the echoed record proves the stripped value equals what
|
|
167
|
-
// was sent, stay quiet.
|
|
168
|
-
|
|
169
|
-
it('suppresses the warning when every stripped value equals what the record already holds', async () => {
|
|
170
|
-
const update = vi.fn().mockResolvedValue({
|
|
171
|
-
record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment', title: 'B' },
|
|
172
|
-
droppedFields: [
|
|
173
|
-
{ object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
|
|
174
|
-
],
|
|
175
|
-
});
|
|
176
|
-
const ds = makeDS({ update });
|
|
177
|
-
const events: WriteWarningEvent[] = [];
|
|
178
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
179
|
-
|
|
180
|
-
await ds.update('andon', 'r1', {
|
|
181
|
-
title: 'B',
|
|
182
|
-
type: 'quality_abnormal',
|
|
183
|
-
source_method: 'qc_judgment',
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
expect(events).toEqual([]);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('still warns about the fields that really did change, and only those', async () => {
|
|
190
|
-
const update = vi.fn().mockResolvedValue({
|
|
191
|
-
record: { id: 'r1', type: 'quality_abnormal', source_method: 'qc_judgment' },
|
|
192
|
-
droppedFields: [
|
|
193
|
-
{ object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' },
|
|
194
|
-
],
|
|
195
|
-
});
|
|
196
|
-
const ds = makeDS({ update });
|
|
197
|
-
const events: WriteWarningEvent[] = [];
|
|
198
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
199
|
-
|
|
200
|
-
// The user really did try to change `type`; `source_method` round-tripped.
|
|
201
|
-
await ds.update('andon', 'r1', { type: 'equipment_fault', source_method: 'qc_judgment' });
|
|
202
|
-
|
|
203
|
-
expect(events).toEqual([
|
|
204
|
-
{
|
|
205
|
-
operation: 'update',
|
|
206
|
-
resource: 'andon',
|
|
207
|
-
id: 'r1',
|
|
208
|
-
droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
|
|
209
|
-
},
|
|
210
|
-
]);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('keeps the warning when the no-op cannot be PROVEN (record echo lacks the key)', async () => {
|
|
214
|
-
const update = vi.fn().mockResolvedValue({
|
|
215
|
-
record: { id: 'r1' },
|
|
216
|
-
droppedFields: [{ object: 'andon', fields: ['type'], reason: 'readonly_when' }],
|
|
217
|
-
});
|
|
218
|
-
const ds = makeDS({ update });
|
|
219
|
-
const events: WriteWarningEvent[] = [];
|
|
220
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
221
|
-
|
|
222
|
-
await ds.update('andon', 'r1', { type: 'quality_abnormal' });
|
|
223
|
-
|
|
224
|
-
expect(events).toHaveLength(1);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
it('does not treat a type change as a no-op (1 is not "1")', async () => {
|
|
228
|
-
const update = vi.fn().mockResolvedValue({
|
|
229
|
-
record: { id: 'r1', tax_rate: '9' },
|
|
230
|
-
droppedFields: [{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when' }],
|
|
231
|
-
});
|
|
232
|
-
const ds = makeDS({ update });
|
|
233
|
-
const events: WriteWarningEvent[] = [];
|
|
234
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
235
|
-
|
|
236
|
-
await ds.update('invoice', 'r1', { tax_rate: 9 });
|
|
237
|
-
|
|
238
|
-
expect(events).toHaveLength(1);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
it('suppresses a no-op strip on the cross-object batch path too', async () => {
|
|
242
|
-
const batchTransaction = vi.fn().mockResolvedValue({
|
|
243
|
-
results: [{ id: 'acc1' }, { id: 'inv1', tax_rate: 9 }],
|
|
244
|
-
droppedFields: [
|
|
245
|
-
{ object: 'invoice', fields: ['tax_rate'], reason: 'readonly_when', index: 1 },
|
|
246
|
-
],
|
|
247
|
-
});
|
|
248
|
-
const ds = makeDS({ batchTransaction });
|
|
249
|
-
ds.atomicBatchCapability = true;
|
|
250
|
-
const events: WriteWarningEvent[] = [];
|
|
251
|
-
ds.onWriteWarning((e: WriteWarningEvent) => events.push(e));
|
|
252
|
-
|
|
253
|
-
await ds.batchTransaction([
|
|
254
|
-
{ object: 'account', action: 'create', data: { name: 'Acme' } },
|
|
255
|
-
{ object: 'invoice', action: 'update', id: 'inv1', data: { status: 'paid', tax_rate: 9 } },
|
|
256
|
-
]);
|
|
257
|
-
|
|
258
|
-
expect(events).toEqual([]);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('isolates a throwing listener so the write still resolves and other listeners fire', async () => {
|
|
262
|
-
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP });
|
|
263
|
-
const ds = makeDS({ update });
|
|
264
|
-
const good = vi.fn();
|
|
265
|
-
ds.onWriteWarning(() => { throw new Error('boom'); });
|
|
266
|
-
ds.onWriteWarning(good);
|
|
267
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
268
|
-
|
|
269
|
-
await expect(ds.update('approval_case', 'r1', { approval_status: 'x' })).resolves.toBeTruthy();
|
|
270
|
-
expect(good).toHaveBeenCalledTimes(1);
|
|
271
|
-
warn.mockRestore();
|
|
272
|
-
});
|
|
273
|
-
});
|