@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/index.cjs +262 -357
  3. package/dist/index.d.cts +385 -448
  4. package/dist/index.d.ts +385 -448
  5. package/dist/index.js +258 -350
  6. package/package.json +5 -6
  7. package/dist/tsconfig.tsbuildinfo +0 -1
  8. package/src/aggregate-capability.test.ts +0 -177
  9. package/src/apiAccessDenied.test.ts +0 -233
  10. package/src/appAccessProbe.test.ts +0 -147
  11. package/src/bulkDelete.test.ts +0 -75
  12. package/src/bulkUpdate.test.ts +0 -93
  13. package/src/cache/MetadataCache.test.ts +0 -477
  14. package/src/cache/MetadataCache.ts +0 -289
  15. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  16. package/src/connection.test.ts +0 -188
  17. package/src/contracts.ts +0 -115
  18. package/src/deleteView.homes.test.ts +0 -389
  19. package/src/deleteViewContract.types.test.ts +0 -246
  20. package/src/errors.test.ts +0 -426
  21. package/src/errors.ts +0 -293
  22. package/src/exportDownload.test.ts +0 -141
  23. package/src/filter-entry-translation.test.ts +0 -360
  24. package/src/filter-operator-ast-parity.test.ts +0 -135
  25. package/src/getObjectSchema.test.ts +0 -145
  26. package/src/httpFailureLogging.test.ts +0 -109
  27. package/src/importJob.test.ts +0 -71
  28. package/src/index.ts +0 -4442
  29. package/src/integration.ts +0 -200
  30. package/src/listViewOverrides.test.ts +0 -241
  31. package/src/listViews.test.ts +0 -175
  32. package/src/metadata-client.get-envelope.test.ts +0 -171
  33. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  34. package/src/metadata-client.test.ts +0 -303
  35. package/src/metadata-client.ts +0 -1046
  36. package/src/occ.test.ts +0 -101
  37. package/src/onMutation.test.ts +0 -589
  38. package/src/onSaveAdvisory.test.ts +0 -311
  39. package/src/onWriteWarning.test.ts +0 -273
  40. package/src/orderby-serialization.test.ts +0 -115
  41. package/src/queryDataset.test.ts +0 -543
  42. package/src/searchAll.test.ts +0 -120
  43. package/src/security.ts +0 -243
  44. package/src/spec-symbol-batch6.test.ts +0 -326
  45. package/src/studio.ts +0 -152
  46. package/src/updateView.draft.test.ts +0 -242
  47. package/src/userState.test.ts +0 -315
  48. package/src/userState.ts +0 -238
  49. package/src/v3-compat.test.ts +0 -229
  50. package/src/validation-error.test.ts +0 -94
  51. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  52. 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
- });