@object-ui/data-objectstack 14.0.0 → 14.1.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 14.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [0890fa7]
8
+ - Updated dependencies [2ded18c]
9
+ - Updated dependencies [e628d1f]
10
+ - Updated dependencies [5523fc4]
11
+ - Updated dependencies [887062c]
12
+ - Updated dependencies [9e2d58f]
13
+ - Updated dependencies [dea65f7]
14
+ - Updated dependencies [d5b1bc0]
15
+ - Updated dependencies [f0f10f5]
16
+ - @object-ui/core@14.1.0
17
+ - @object-ui/types@14.1.0
18
+
3
19
  ## 14.0.0
4
20
 
5
21
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -119,6 +119,24 @@ function convertFiltersToAST(filter) {
119
119
  return ["and", ...conditions];
120
120
  }
121
121
 
122
+ // ../core/src/utils/reference-keys.ts
123
+ function normalizeFieldReferenceKeys(fieldDef) {
124
+ if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
125
+ const f = fieldDef;
126
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
127
+ if (target == null || target === "") return fieldDef;
128
+ if (f.reference_to === void 0) f.reference_to = target;
129
+ if (f.reference === void 0) f.reference = target;
130
+ return fieldDef;
131
+ }
132
+ function normalizeSchemaReferenceKeys(schema) {
133
+ const fields = schema && typeof schema === "object" ? schema.fields : null;
134
+ if (!fields || typeof fields !== "object") return schema;
135
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
136
+ for (const def of defs) normalizeFieldReferenceKeys(def);
137
+ return schema;
138
+ }
139
+
122
140
  // src/cache/MetadataCache.ts
123
141
  var MetadataCache = class {
124
142
  /**
@@ -1922,7 +1940,7 @@ var ObjectStackAdapter = class {
1922
1940
  */
1923
1941
  async batchTransaction(operations) {
1924
1942
  const url = `${this.baseUrl}/api/v1/batch`;
1925
- const response = await fetch(url, {
1943
+ const response = await this.fetchImpl(url, {
1926
1944
  method: "POST",
1927
1945
  headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
1928
1946
  // Send the session cookie too: in the browser console auth is cookie-based
@@ -1940,7 +1958,20 @@ var ObjectStackAdapter = class {
1940
1958
  response.status
1941
1959
  );
1942
1960
  }
1943
- return response.json();
1961
+ const payload = await response.json();
1962
+ const results = Array.isArray(payload?.results) ? payload.results : [];
1963
+ operations.forEach((op, i) => {
1964
+ const action = op.action ?? "create";
1965
+ const echo = results[i];
1966
+ if (action === "create") {
1967
+ this.emitMutation({ type: "create", resource: op.object, record: echo });
1968
+ } else if (action === "update") {
1969
+ this.emitMutation({ type: "update", resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
1970
+ } else if (action === "delete") {
1971
+ this.emitMutation({ type: "delete", resource: op.object, id: op.id });
1972
+ }
1973
+ });
1974
+ return payload;
1944
1975
  }
1945
1976
  async bulk(resource, operation, data) {
1946
1977
  await this.connect();
@@ -1967,6 +1998,7 @@ var ObjectStackAdapter = class {
1967
1998
  completed = created.length;
1968
1999
  failed = total - completed;
1969
2000
  emitProgress();
2001
+ if (completed > 0) this.emitMutation({ type: "create", resource });
1970
2002
  return created;
1971
2003
  }
1972
2004
  case "delete": {
@@ -1985,6 +2017,7 @@ var ObjectStackAdapter = class {
1985
2017
  completed = ids.length;
1986
2018
  failed = total - completed;
1987
2019
  emitProgress();
2020
+ if (completed > 0) this.emitMutation({ type: "delete", resource });
1988
2021
  return [];
1989
2022
  }
1990
2023
  case "update": {
@@ -1996,6 +2029,7 @@ var ObjectStackAdapter = class {
1996
2029
  completed = updated.length;
1997
2030
  failed = total - completed;
1998
2031
  emitProgress();
2032
+ if (completed > 0) this.emitMutation({ type: "update", resource });
1999
2033
  return updated;
2000
2034
  } catch {
2001
2035
  }
@@ -2023,6 +2057,7 @@ var ObjectStackAdapter = class {
2023
2057
  emitProgress();
2024
2058
  }
2025
2059
  }
2060
+ if (completed > 0) this.emitMutation({ type: "update", resource });
2026
2061
  if (errors.length > 0) {
2027
2062
  throw new BulkOperationError(
2028
2063
  "update",
@@ -2432,6 +2467,7 @@ var ObjectStackAdapter = class {
2432
2467
  objectName,
2433
2468
  () => this.fetchObjectSchemaFresh(objectName)
2434
2469
  );
2470
+ normalizeSchemaReferenceKeys(schema);
2435
2471
  this.applyFieldWidgetOverrides(objectName, schema);
2436
2472
  return schema;
2437
2473
  } catch (error) {
package/dist/index.js CHANGED
@@ -71,6 +71,24 @@ function convertFiltersToAST(filter) {
71
71
  return ["and", ...conditions];
72
72
  }
73
73
 
74
+ // ../core/src/utils/reference-keys.ts
75
+ function normalizeFieldReferenceKeys(fieldDef) {
76
+ if (!fieldDef || typeof fieldDef !== "object") return fieldDef;
77
+ const f = fieldDef;
78
+ const target = f.reference_to ?? f.reference ?? f.referenceTo;
79
+ if (target == null || target === "") return fieldDef;
80
+ if (f.reference_to === void 0) f.reference_to = target;
81
+ if (f.reference === void 0) f.reference = target;
82
+ return fieldDef;
83
+ }
84
+ function normalizeSchemaReferenceKeys(schema) {
85
+ const fields = schema && typeof schema === "object" ? schema.fields : null;
86
+ if (!fields || typeof fields !== "object") return schema;
87
+ const defs = Array.isArray(fields) ? fields : Object.values(fields);
88
+ for (const def of defs) normalizeFieldReferenceKeys(def);
89
+ return schema;
90
+ }
91
+
74
92
  // src/cache/MetadataCache.ts
75
93
  var MetadataCache = class {
76
94
  /**
@@ -1874,7 +1892,7 @@ var ObjectStackAdapter = class {
1874
1892
  */
1875
1893
  async batchTransaction(operations) {
1876
1894
  const url = `${this.baseUrl}/api/v1/batch`;
1877
- const response = await fetch(url, {
1895
+ const response = await this.fetchImpl(url, {
1878
1896
  method: "POST",
1879
1897
  headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
1880
1898
  // Send the session cookie too: in the browser console auth is cookie-based
@@ -1892,7 +1910,20 @@ var ObjectStackAdapter = class {
1892
1910
  response.status
1893
1911
  );
1894
1912
  }
1895
- return response.json();
1913
+ const payload = await response.json();
1914
+ const results = Array.isArray(payload?.results) ? payload.results : [];
1915
+ operations.forEach((op, i) => {
1916
+ const action = op.action ?? "create";
1917
+ const echo = results[i];
1918
+ if (action === "create") {
1919
+ this.emitMutation({ type: "create", resource: op.object, record: echo });
1920
+ } else if (action === "update") {
1921
+ this.emitMutation({ type: "update", resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
1922
+ } else if (action === "delete") {
1923
+ this.emitMutation({ type: "delete", resource: op.object, id: op.id });
1924
+ }
1925
+ });
1926
+ return payload;
1896
1927
  }
1897
1928
  async bulk(resource, operation, data) {
1898
1929
  await this.connect();
@@ -1919,6 +1950,7 @@ var ObjectStackAdapter = class {
1919
1950
  completed = created.length;
1920
1951
  failed = total - completed;
1921
1952
  emitProgress();
1953
+ if (completed > 0) this.emitMutation({ type: "create", resource });
1922
1954
  return created;
1923
1955
  }
1924
1956
  case "delete": {
@@ -1937,6 +1969,7 @@ var ObjectStackAdapter = class {
1937
1969
  completed = ids.length;
1938
1970
  failed = total - completed;
1939
1971
  emitProgress();
1972
+ if (completed > 0) this.emitMutation({ type: "delete", resource });
1940
1973
  return [];
1941
1974
  }
1942
1975
  case "update": {
@@ -1948,6 +1981,7 @@ var ObjectStackAdapter = class {
1948
1981
  completed = updated.length;
1949
1982
  failed = total - completed;
1950
1983
  emitProgress();
1984
+ if (completed > 0) this.emitMutation({ type: "update", resource });
1951
1985
  return updated;
1952
1986
  } catch {
1953
1987
  }
@@ -1975,6 +2009,7 @@ var ObjectStackAdapter = class {
1975
2009
  emitProgress();
1976
2010
  }
1977
2011
  }
2012
+ if (completed > 0) this.emitMutation({ type: "update", resource });
1978
2013
  if (errors.length > 0) {
1979
2014
  throw new BulkOperationError(
1980
2015
  "update",
@@ -2384,6 +2419,7 @@ var ObjectStackAdapter = class {
2384
2419
  objectName,
2385
2420
  () => this.fetchObjectSchemaFresh(objectName)
2386
2421
  );
2422
+ normalizeSchemaReferenceKeys(schema);
2387
2423
  this.applyFieldWidgetOverrides(objectName, schema);
2388
2424
  return schema;
2389
2425
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "14.0.0",
3
+ "version": "14.1.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,8 +23,8 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@objectstack/client": "^14.6.0",
26
- "@object-ui/core": "14.0.0",
27
- "@object-ui/types": "14.0.0"
26
+ "@object-ui/core": "14.1.0",
27
+ "@object-ui/types": "14.1.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.1",
@@ -75,6 +75,38 @@ describe('ObjectStackAdapter.getObjectSchema', () => {
75
75
  expect(Object.keys(schema.fields)).toEqual(['x']);
76
76
  });
77
77
 
78
+ it('canonicalizes the ObjectStack-convention `reference` key onto `reference_to` (and back)', async () => {
79
+ // The server names a relational field's target `reference`
80
+ // (showcase_project.account → { type: 'lookup', reference: 'showcase_account' }),
81
+ // while most consumers read `reference_to` (#2407 / PR #2587). getObjectSchema
82
+ // is the choke point every schema read goes through, so both keys must come
83
+ // back stamped — regardless of which convention the served schema used.
84
+ const { fetchImpl } = makeFetch({
85
+ name: 'showcase_project',
86
+ fields: {
87
+ name: { type: 'text' },
88
+ account: { type: 'lookup', reference: 'showcase_account' },
89
+ team_members: { type: 'user', reference: 'sys_user', multiple: true },
90
+ legacy: { type: 'master_detail', reference_to: 'showcase_order' },
91
+ },
92
+ });
93
+ const adapter = new ObjectStackAdapter({
94
+ baseUrl: 'http://localhost:3000',
95
+ autoReconnect: false,
96
+ fetch: fetchImpl as any,
97
+ });
98
+
99
+ const schema: any = await adapter.getObjectSchema('showcase_project');
100
+
101
+ expect(schema.fields.account.reference_to).toBe('showcase_account');
102
+ expect(schema.fields.team_members.reference_to).toBe('sys_user');
103
+ // Mirror direction: `.reference`-only readers (e.g. attachInlineSubforms)
104
+ // must also see the target on a reference_to-authored schema.
105
+ expect(schema.fields.legacy.reference).toBe('showcase_order');
106
+ // Non-relational fields stay untouched.
107
+ expect('reference_to' in schema.fields.name).toBe(false);
108
+ });
109
+
78
110
  it('preserves `validations` (incl. state_machine transitions) — the seam the inline editor depends on', async () => {
79
111
  // Inert-metadata regression guard. The inline select editor filters its
80
112
  // options by the object's `state_machine` validation (objectui#2110). That
package/src/index.ts CHANGED
@@ -23,7 +23,7 @@ import type {
23
23
  ImportJobUndoResult,
24
24
  ListImportJobsOptions,
25
25
  } from '@object-ui/types';
26
- import { convertFiltersToAST } from '@object-ui/core';
26
+ import { convertFiltersToAST, normalizeSchemaReferenceKeys } from '@object-ui/core';
27
27
  import { MetadataCache } from './cache/MetadataCache';
28
28
  import {
29
29
  ObjectStackError,
@@ -936,7 +936,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
936
936
  operations: Array<{ object: string; action?: 'create' | 'update' | 'delete'; data?: any; id?: string }>,
937
937
  ): Promise<{ results: any[] }> {
938
938
  const url = `${this.baseUrl}/api/v1/batch`;
939
- const response = await fetch(url, {
939
+ const response = await this.fetchImpl(url, {
940
940
  method: 'POST',
941
941
  headers: { 'Content-Type': 'application/json', ...this.getAuthHeaders() },
942
942
  // Send the session cookie too: in the browser console auth is cookie-based
@@ -954,7 +954,27 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
954
954
  response.status,
955
955
  );
956
956
  }
957
- return response.json();
957
+ const payload: { results: any[] } = await response.json();
958
+ // The whole transaction committed — emit one MutationEvent per operation so
959
+ // the invalidation bus (#2269) sees writes that went through /batch exactly
960
+ // like single create/update/delete calls (master-detail ModalForm saves
961
+ // otherwise leave related lists and count badges stale, #2582). `results`
962
+ // is index-aligned with `operations`; creates take id/record from the
963
+ // server echo. A failed batch rolled back entirely and throws above, so
964
+ // nothing is emitted for it.
965
+ const results = Array.isArray(payload?.results) ? payload.results : [];
966
+ operations.forEach((op, i) => {
967
+ const action = op.action ?? 'create';
968
+ const echo = results[i];
969
+ if (action === 'create') {
970
+ this.emitMutation({ type: 'create', resource: op.object, record: echo });
971
+ } else if (action === 'update') {
972
+ this.emitMutation({ type: 'update', resource: op.object, id: op.id ?? echo?.id ?? echo?._id, record: echo });
973
+ } else if (action === 'delete') {
974
+ this.emitMutation({ type: 'delete', resource: op.object, id: op.id });
975
+ }
976
+ });
977
+ return payload;
958
978
  }
959
979
 
960
980
  async bulk(resource: string, operation: 'create' | 'update' | 'delete', data: Partial<T>[]): Promise<T[]> {
@@ -986,6 +1006,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
986
1006
  completed = created.length;
987
1007
  failed = total - completed;
988
1008
  emitProgress();
1009
+ // One resource-level event for the whole batch (same contract as
1010
+ // bulkUpdate/bulkDelete) so bound views refresh after subform child
1011
+ // rows are created through this path (#2582).
1012
+ if (completed > 0) this.emitMutation({ type: 'create', resource });
989
1013
  return created;
990
1014
  }
991
1015
 
@@ -1010,6 +1034,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1010
1034
  completed = ids.length;
1011
1035
  failed = total - completed;
1012
1036
  emitProgress();
1037
+ // One resource-level event per batch — see the create branch.
1038
+ if (completed > 0) this.emitMutation({ type: 'delete', resource });
1013
1039
  return [] as T[];
1014
1040
  }
1015
1041
 
@@ -1025,6 +1051,8 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1025
1051
  completed = updated.length;
1026
1052
  failed = total - completed;
1027
1053
  emitProgress();
1054
+ // One resource-level event per batch — see the create branch.
1055
+ if (completed > 0) this.emitMutation({ type: 'update', resource });
1028
1056
  return updated;
1029
1057
  } catch {
1030
1058
  // If updateMany is not supported, fall back to individual updates
@@ -1060,6 +1088,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1060
1088
  }
1061
1089
  }
1062
1090
 
1091
+ // Rows that DID persist must still reach subscribers, even when the
1092
+ // batch as a whole reports failure below (continueOnError semantics
1093
+ // — the successful writes are not rolled back).
1094
+ if (completed > 0) this.emitMutation({ type: 'update', resource });
1095
+
1063
1096
  // If there were any errors, throw BulkOperationError
1064
1097
  if (errors.length > 0) {
1065
1098
  throw new BulkOperationError(
@@ -1070,7 +1103,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1070
1103
  { resource, totalRecords: data.length }
1071
1104
  );
1072
1105
  }
1073
-
1106
+
1074
1107
  return results;
1075
1108
  }
1076
1109
 
@@ -1609,6 +1642,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1609
1642
  this.fetchObjectSchemaFresh(objectName),
1610
1643
  );
1611
1644
 
1645
+ // Canonicalize the relational-target key: the server names it
1646
+ // `reference` (ObjectStack convention) while most consumers read
1647
+ // `reference_to` (#2407 / PR #2587). Stamping both here — the choke
1648
+ // point every schema read goes through — means no per-consumer
1649
+ // dual-key fallback can drift. Idempotent on the cached object.
1650
+ normalizeSchemaReferenceKeys(schema);
1651
+
1612
1652
  // ADR-0056 P2 (epic #2398): stamp structured-widget hints onto specific
1613
1653
  // platform fields. This is the single choke point both the record form
1614
1654
  // (ObjectForm) and the detail view (DetailView/DetailSection) read the
@@ -123,3 +123,175 @@ describe('ObjectStackAdapter.onMutation', () => {
123
123
  expect(good).toHaveBeenCalledTimes(1);
124
124
  });
125
125
  });
126
+
127
+ /**
128
+ * Regression for #2582: a ModalForm over an object WITH subforms delegates to
129
+ * MasterDetailForm, which persists parent + child rows through ONE
130
+ * `batchTransaction` (`POST /api/v1/batch`). That path never emitted
131
+ * MutationEvents, so the related list and the "相关" tab count badge stayed
132
+ * stale after a successful create/edit until a full page reload. The adapter
133
+ * must emit one event per committed operation — and nothing when the
134
+ * transaction failed (it rolled back entirely).
135
+ */
136
+ describe('ObjectStackAdapter.batchTransaction mutation events', () => {
137
+ function makeBatchDS(responder: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
138
+ const ds: any = new ObjectStackAdapter({
139
+ baseUrl: 'http://test.local',
140
+ fetch: vi.fn(responder),
141
+ });
142
+ ds.connected = true;
143
+ ds.connectionState = 'connected';
144
+ return ds;
145
+ }
146
+
147
+ it('emits one event per op after a committed master-detail CREATE batch', async () => {
148
+ const ds = makeBatchDS(async () =>
149
+ new Response(
150
+ JSON.stringify({
151
+ results: [
152
+ { id: 'p1', name: 'Task version A' },
153
+ { id: 'c1', task: 'p1', label: 'Check item 1' },
154
+ ],
155
+ }),
156
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
157
+ ),
158
+ );
159
+ const events: MutationEvent[] = [];
160
+ ds.onMutation((e: MutationEvent) => events.push(e));
161
+
162
+ await ds.batchTransaction([
163
+ { object: 'ehr_task_version', action: 'create', data: { name: 'Task version A' } },
164
+ { object: 'ehr_task_check_item', action: 'create', data: { task: { $ref: 0 }, label: 'Check item 1' } },
165
+ ]);
166
+
167
+ expect(events).toEqual([
168
+ { type: 'create', resource: 'ehr_task_version', record: { id: 'p1', name: 'Task version A' } },
169
+ { type: 'create', resource: 'ehr_task_check_item', record: { id: 'c1', task: 'p1', label: 'Check item 1' } },
170
+ ]);
171
+ });
172
+
173
+ it('emits update/create/delete events after a committed master-detail EDIT batch', async () => {
174
+ const ds = makeBatchDS(async () =>
175
+ new Response(
176
+ JSON.stringify({
177
+ results: [{ id: 'p1' }, { id: 'c-new' }, { id: 'c1' }, { deleted: true }],
178
+ }),
179
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
180
+ ),
181
+ );
182
+ const events: MutationEvent[] = [];
183
+ ds.onMutation((e: MutationEvent) => events.push(e));
184
+
185
+ await ds.batchTransaction([
186
+ { object: 'ehr_task_version', action: 'update', id: 'p1', data: { name: 'Renamed' } },
187
+ { object: 'ehr_task_check_item', action: 'create', data: { task: 'p1' } },
188
+ { object: 'ehr_task_check_item', action: 'update', id: 'c1', data: { label: 'Edited' } },
189
+ { object: 'ehr_task_check_item', action: 'delete', id: 'c2' },
190
+ ]);
191
+
192
+ expect(events).toEqual([
193
+ { type: 'update', resource: 'ehr_task_version', id: 'p1', record: { id: 'p1' } },
194
+ { type: 'create', resource: 'ehr_task_check_item', record: { id: 'c-new' } },
195
+ { type: 'update', resource: 'ehr_task_check_item', id: 'c1', record: { id: 'c1' } },
196
+ { type: 'delete', resource: 'ehr_task_check_item', id: 'c2' },
197
+ ]);
198
+ });
199
+
200
+ it('treats an op without an explicit action as a create (the server default)', async () => {
201
+ const ds = makeBatchDS(async () =>
202
+ new Response(JSON.stringify({ results: [{ id: 'p1' }] }), {
203
+ status: 200,
204
+ headers: { 'Content-Type': 'application/json' },
205
+ }),
206
+ );
207
+ const events: MutationEvent[] = [];
208
+ ds.onMutation((e: MutationEvent) => events.push(e));
209
+
210
+ await ds.batchTransaction([{ object: 'ehr_task_version', data: { name: 'X' } }]);
211
+
212
+ expect(events).toEqual([
213
+ { type: 'create', resource: 'ehr_task_version', record: { id: 'p1' } },
214
+ ]);
215
+ });
216
+
217
+ it('emits NOTHING when the batch failed — the transaction rolled back', async () => {
218
+ const ds = makeBatchDS(async () =>
219
+ new Response(JSON.stringify({ error: 'validation failed' }), {
220
+ status: 422,
221
+ headers: { 'Content-Type': 'application/json' },
222
+ }),
223
+ );
224
+ const events: MutationEvent[] = [];
225
+ ds.onMutation((e: MutationEvent) => events.push(e));
226
+
227
+ await expect(
228
+ ds.batchTransaction([{ object: 'ehr_task_version', action: 'create', data: {} }]),
229
+ ).rejects.toThrow('validation failed');
230
+
231
+ expect(events).toEqual([]);
232
+ });
233
+ });
234
+
235
+ /**
236
+ * Regression for #2582 (fallback path): when the server lacks the transactional
237
+ * batch endpoint, MasterDetailForm persists child rows via `bulk(child,
238
+ * 'create', rows)` (applyDetail → createMany). `bulk` only emitted progress
239
+ * events, so those writes were equally invisible to the invalidation bus. Each
240
+ * branch now emits ONE resource-level event, matching bulkUpdate/bulkDelete.
241
+ */
242
+ describe('ObjectStackAdapter.bulk mutation events', () => {
243
+ it('emits a single create event per bulk create call', async () => {
244
+ const createMany = vi.fn().mockResolvedValue([{ id: 'c1' }, { id: 'c2' }]);
245
+ const ds = makeDS({ createMany });
246
+ const events: MutationEvent[] = [];
247
+ ds.onMutation((e: MutationEvent) => events.push(e));
248
+
249
+ await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }, { label: 'b' }]);
250
+
251
+ expect(events).toEqual([{ type: 'create', resource: 'ehr_task_check_item' }]);
252
+ });
253
+
254
+ it('does not emit when a bulk create returned zero records', async () => {
255
+ const createMany = vi.fn().mockResolvedValue([]);
256
+ const ds = makeDS({ createMany });
257
+ const events: MutationEvent[] = [];
258
+ ds.onMutation((e: MutationEvent) => events.push(e));
259
+
260
+ await ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }]);
261
+
262
+ expect(events).toEqual([]);
263
+ });
264
+
265
+ it('emits a single delete event per bulk delete call', async () => {
266
+ const deleteMany = vi.fn().mockResolvedValue(undefined);
267
+ const ds = makeDS({ deleteMany });
268
+ const events: MutationEvent[] = [];
269
+ ds.onMutation((e: MutationEvent) => events.push(e));
270
+
271
+ await ds.bulk('ehr_task_check_item', 'delete', [{ id: 'c1' }, { id: 'c2' }] as any);
272
+
273
+ expect(events).toEqual([{ type: 'delete', resource: 'ehr_task_check_item' }]);
274
+ });
275
+
276
+ it('emits a single update event per bulk update call', async () => {
277
+ const updateMany = vi.fn().mockResolvedValue([{ id: 'c1' }]);
278
+ const ds = makeDS({ updateMany });
279
+ const events: MutationEvent[] = [];
280
+ ds.onMutation((e: MutationEvent) => events.push(e));
281
+
282
+ await ds.bulk('ehr_task_check_item', 'update', [{ id: 'c1', label: 'x' }] as any);
283
+
284
+ expect(events).toEqual([{ type: 'update', resource: 'ehr_task_check_item' }]);
285
+ });
286
+
287
+ it('bulk create failure emits nothing', async () => {
288
+ const createMany = vi.fn().mockRejectedValue(new Error('boom'));
289
+ const ds = makeDS({ createMany });
290
+ const events: MutationEvent[] = [];
291
+ ds.onMutation((e: MutationEvent) => events.push(e));
292
+
293
+ await expect(ds.bulk('ehr_task_check_item', 'create', [{ label: 'a' }])).rejects.toThrow();
294
+
295
+ expect(events).toEqual([]);
296
+ });
297
+ });