@object-ui/data-objectstack 11.3.0 → 11.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -7,7 +7,22 @@
7
7
  */
8
8
 
9
9
  import { ObjectStackClient, type QueryOptions as ObjectStackQueryOptions } from '@objectstack/client';
10
- import type { DataSource, QueryParams, QueryResult, FileUploadResult, ExportDownloadRequest } from '@object-ui/types';
10
+ import type {
11
+ DataSource,
12
+ MutationEvent,
13
+ QueryParams,
14
+ QueryResult,
15
+ FileUploadResult,
16
+ ExportDownloadRequest,
17
+ ImportRequestOptions,
18
+ ImportRecordsResult,
19
+ CreateImportJobResult,
20
+ ImportJobProgressInfo,
21
+ ImportJobResultsInfo,
22
+ ImportJobSummaryInfo,
23
+ ImportJobUndoResult,
24
+ ListImportJobsOptions,
25
+ } from '@object-ui/types';
11
26
  import { convertFiltersToAST } from '@object-ui/core';
12
27
  import { MetadataCache } from './cache/MetadataCache';
13
28
  import {
@@ -389,6 +404,11 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
389
404
  // so optional collections like sys_presence don't hammer the server with
390
405
  // failing requests on every record open / panel render.
391
406
  private missingResources = new Set<string>();
407
+ // Subscribers registered via onMutation(). Emitted after each successful
408
+ // create/update/delete so data-bound views (ListView, ObjectView, kanban,
409
+ // calendar) auto-refresh — the interface ListView relies on to reflect
410
+ // inline-edit "Save All" writes without a manual reload.
411
+ private mutationListeners = new Set<(event: MutationEvent<T>) => void>();
392
412
 
393
413
  constructor(config: {
394
414
  baseUrl: string;
@@ -692,9 +712,37 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
692
712
  /**
693
713
  * Create a new record.
694
714
  */
715
+ /**
716
+ * Notify all mutation subscribers. A throwing listener must not break the
717
+ * mutation or starve the other subscribers, so each is isolated.
718
+ */
719
+ private emitMutation(event: MutationEvent<T>): void {
720
+ for (const listener of this.mutationListeners) {
721
+ try {
722
+ listener(event);
723
+ } catch (err) {
724
+ console.warn('ObjectStackAdapter: mutation listener error', err);
725
+ }
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Subscribe to create/update/delete events on any resource. Returns an
731
+ * unsubscribe function. Data-bound views use this to auto-refresh after a
732
+ * mutation (e.g. inline-edit "Save All", which writes through `update` and
733
+ * must repaint the list without a manual reload).
734
+ */
735
+ onMutation(callback: (event: MutationEvent<T>) => void): () => void {
736
+ this.mutationListeners.add(callback);
737
+ return () => {
738
+ this.mutationListeners.delete(callback);
739
+ };
740
+ }
741
+
695
742
  async create(resource: string, data: Partial<T>): Promise<T> {
696
743
  await this.connect();
697
744
  const result = await this.client.data.create<T>(resource, data);
745
+ this.emitMutation({ type: 'create', resource, record: { ...result.record } });
698
746
  return result.record;
699
747
  }
700
748
 
@@ -723,6 +771,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
723
771
  data,
724
772
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
725
773
  );
774
+ this.emitMutation({ type: 'update', resource, id, record: { ...result.record } });
726
775
  return result.record;
727
776
  } catch (err) {
728
777
  throw normaliseClientError(err);
@@ -748,6 +797,9 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
748
797
  String(id),
749
798
  opts?.ifMatch ? { ifMatch: opts.ifMatch } : undefined,
750
799
  );
800
+ if (result.deleted) {
801
+ this.emitMutation({ type: 'delete', resource, id });
802
+ }
751
803
  return result.deleted;
752
804
  } catch (err) {
753
805
  throw normaliseClientError(err);
@@ -778,6 +830,13 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
778
830
  if (!ids || ids.length === 0) return 0;
779
831
  const records = ids.map((id) => ({ id: String(id), data: patch as any }));
780
832
 
833
+ // Notify subscribers once for the whole batch (not per-id) so a single
834
+ // "mark all read"/"archive selected" refreshes bound views exactly once.
835
+ const emitBulk = (count: number): number => {
836
+ if (count > 0) this.emitMutation({ type: 'update', resource });
837
+ return count;
838
+ };
839
+
781
840
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
782
841
  const updateMany = (this.client.data as any).updateMany;
783
842
  if (typeof updateMany === 'function') {
@@ -786,10 +845,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
786
845
  // The server returns BatchUpdateResponse { succeeded, failed, ... };
787
846
  // fall back to ids.length on adapters that return a bare array.
788
847
  if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
789
- return (res as any).succeeded as number;
848
+ return emitBulk((res as any).succeeded as number);
790
849
  }
791
- if (Array.isArray(res)) return (res as any[]).length;
792
- return ids.length;
850
+ if (Array.isArray(res)) return emitBulk((res as any[]).length);
851
+ return emitBulk(ids.length);
793
852
  } catch (err) {
794
853
  throw normaliseClientError(err);
795
854
  }
@@ -805,7 +864,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
805
864
  // continueOnError semantics — swallow per-row errors
806
865
  }
807
866
  }
808
- return succeeded;
867
+ return emitBulk(succeeded);
809
868
  }
810
869
 
811
870
  /**
@@ -823,17 +882,23 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
823
882
  if (!ids || ids.length === 0) return 0;
824
883
  const strIds = ids.map((id) => String(id));
825
884
 
885
+ // Notify subscribers once for the whole batch (see bulkUpdate).
886
+ const emitBulk = (count: number): number => {
887
+ if (count > 0) this.emitMutation({ type: 'delete', resource });
888
+ return count;
889
+ };
890
+
826
891
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
827
892
  const deleteMany = (this.client.data as any).deleteMany;
828
893
  if (typeof deleteMany === 'function') {
829
894
  try {
830
895
  const res = await deleteMany(resource, strIds, { continueOnError: true });
831
896
  if (res && typeof res === 'object' && typeof (res as any).succeeded === 'number') {
832
- return (res as any).succeeded as number;
897
+ return emitBulk((res as any).succeeded as number);
833
898
  }
834
- if (Array.isArray(res)) return (res as any[]).length;
899
+ if (Array.isArray(res)) return emitBulk((res as any[]).length);
835
900
  // deleteMany historically returns void on success — assume all hit.
836
- return strIds.length;
901
+ return emitBulk(strIds.length);
837
902
  } catch (err) {
838
903
  throw normaliseClientError(err);
839
904
  }
@@ -849,7 +914,7 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
849
914
  // continueOnError semantics — swallow per-row errors
850
915
  }
851
916
  }
852
- return succeeded;
917
+ return emitBulk(succeeded);
853
918
  }
854
919
 
855
920
  /**
@@ -1047,6 +1112,183 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1047
1112
  }
1048
1113
  }
1049
1114
 
1115
+ /**
1116
+ * Bulk-import raw spreadsheet rows in a single server round-trip via
1117
+ * `POST /api/v1/data/:object/import`. The server performs all value coercion
1118
+ * (booleans, numbers, dates→ISO, select label→code, lookup name→id) from the
1119
+ * object's field metadata, so this method forwards the request verbatim and
1120
+ * returns the aggregate + per-row result untouched.
1121
+ *
1122
+ * Requires `@objectstack/client` with `data.import` (server `/import` route).
1123
+ * Callers should feature-detect (`typeof dataSource.importRecords`) and fall
1124
+ * back to a per-row `create` loop when unavailable.
1125
+ */
1126
+ async importRecords(
1127
+ resource: string,
1128
+ request: ImportRequestOptions,
1129
+ ): Promise<ImportRecordsResult> {
1130
+ await this.connect();
1131
+ const importFn = (this.client.data as { import?: unknown }).import;
1132
+ if (typeof importFn !== 'function') {
1133
+ throw new ObjectStackError(
1134
+ 'The connected @objectstack/client does not support data.import(). ' +
1135
+ 'Upgrade the client, or import via a per-row create fallback.',
1136
+ 'UNSUPPORTED_OPERATION',
1137
+ 400,
1138
+ );
1139
+ }
1140
+ try {
1141
+ const result = await (importFn as (
1142
+ object: string,
1143
+ req: ImportRequestOptions,
1144
+ ) => Promise<ImportRecordsResult>).call(this.client.data, resource, request);
1145
+ return result;
1146
+ } catch (err) {
1147
+ throw normaliseClientError(err);
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Feature-detect the async import-job API on the connected client. Older
1153
+ * clients/servers lack these routes; callers fall back to {@link importRecords}.
1154
+ */
1155
+ private importJobApi(): {
1156
+ createImportJob: (object: string, req: ImportRequestOptions) => Promise<CreateImportJobResult>;
1157
+ getImportJobProgress: (jobId: string) => Promise<ImportJobProgressInfo>;
1158
+ getImportJobResults: (jobId: string) => Promise<ImportJobResultsInfo>;
1159
+ listImportJobs: (query: ListImportJobsOptions) => Promise<ImportJobSummaryInfo[]>;
1160
+ cancelImportJob: (jobId: string) => Promise<{ success: boolean }>;
1161
+ undoImportJob: (jobId: string) => Promise<ImportJobUndoResult>;
1162
+ } | undefined {
1163
+ const d = this.client.data as Record<string, unknown>;
1164
+ if (typeof d.createImportJob !== 'function') return undefined;
1165
+ return d as any;
1166
+ }
1167
+
1168
+ /**
1169
+ * Start an asynchronous import job — the large-file counterpart to
1170
+ * {@link importRecords}. Posts the whole payload once; the server processes
1171
+ * rows in the background. Requires an `@objectstack/client` new enough to
1172
+ * expose `data.createImportJob` (server `/import/jobs` route). Callers should
1173
+ * feature-detect (`typeof dataSource.createImportJob`) and fall back to the
1174
+ * synchronous path when unavailable.
1175
+ */
1176
+ async createImportJob(
1177
+ resource: string,
1178
+ request: ImportRequestOptions,
1179
+ ): Promise<CreateImportJobResult> {
1180
+ await this.connect();
1181
+ const api = this.importJobApi();
1182
+ if (!api) {
1183
+ throw new ObjectStackError(
1184
+ 'The connected @objectstack/client does not support async import jobs (data.createImportJob). ' +
1185
+ 'Upgrade the client, or use the synchronous importRecords() path.',
1186
+ 'UNSUPPORTED_OPERATION',
1187
+ 400,
1188
+ );
1189
+ }
1190
+ try {
1191
+ return await api.createImportJob.call(this.client.data, resource, request);
1192
+ } catch (err) {
1193
+ throw normaliseClientError(err);
1194
+ }
1195
+ }
1196
+
1197
+ /** Poll an import job's progress. Requires {@link createImportJob} support. */
1198
+ async getImportJobProgress(jobId: string): Promise<ImportJobProgressInfo> {
1199
+ await this.connect();
1200
+ const api = this.importJobApi();
1201
+ if (!api) {
1202
+ throw new ObjectStackError(
1203
+ 'The connected @objectstack/client does not support async import jobs.',
1204
+ 'UNSUPPORTED_OPERATION',
1205
+ 400,
1206
+ );
1207
+ }
1208
+ try {
1209
+ return await api.getImportJobProgress.call(this.client.data, jobId);
1210
+ } catch (err) {
1211
+ throw normaliseClientError(err);
1212
+ }
1213
+ }
1214
+
1215
+ /** Fetch an import job's capped per-row results. */
1216
+ async getImportJobResults(jobId: string): Promise<ImportJobResultsInfo> {
1217
+ await this.connect();
1218
+ const api = this.importJobApi();
1219
+ if (!api) {
1220
+ throw new ObjectStackError(
1221
+ 'The connected @objectstack/client does not support async import jobs.',
1222
+ 'UNSUPPORTED_OPERATION',
1223
+ 400,
1224
+ );
1225
+ }
1226
+ try {
1227
+ return await api.getImportJobResults.call(this.client.data, jobId);
1228
+ } catch (err) {
1229
+ throw normaliseClientError(err);
1230
+ }
1231
+ }
1232
+
1233
+ /** List recent import jobs (history), newest first. */
1234
+ async listImportJobs(options: ListImportJobsOptions = {}): Promise<ImportJobSummaryInfo[]> {
1235
+ await this.connect();
1236
+ const api = this.importJobApi();
1237
+ if (!api) {
1238
+ throw new ObjectStackError(
1239
+ 'The connected @objectstack/client does not support async import jobs.',
1240
+ 'UNSUPPORTED_OPERATION',
1241
+ 400,
1242
+ );
1243
+ }
1244
+ try {
1245
+ return await api.listImportJobs.call(this.client.data, options);
1246
+ } catch (err) {
1247
+ throw normaliseClientError(err);
1248
+ }
1249
+ }
1250
+
1251
+ /** Cancel a pending/running import job (cooperative). */
1252
+ async cancelImportJob(jobId: string): Promise<void> {
1253
+ await this.connect();
1254
+ const api = this.importJobApi();
1255
+ if (!api) {
1256
+ throw new ObjectStackError(
1257
+ 'The connected @objectstack/client does not support async import jobs.',
1258
+ 'UNSUPPORTED_OPERATION',
1259
+ 400,
1260
+ );
1261
+ }
1262
+ try {
1263
+ await api.cancelImportJob.call(this.client.data, jobId);
1264
+ } catch (err) {
1265
+ throw normaliseClientError(err);
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * Logically roll back a finished import job — delete the records it created
1271
+ * and restore the records it updated to their pre-import values. Requires an
1272
+ * `@objectstack/client` new enough to expose `data.undoImportJob`, and a job
1273
+ * the server captured an undo log for (see {@link ImportJobProgressInfo.undoable}).
1274
+ */
1275
+ async undoImportJob(jobId: string): Promise<ImportJobUndoResult> {
1276
+ await this.connect();
1277
+ const api = this.importJobApi();
1278
+ if (!api || typeof (api as { undoImportJob?: unknown }).undoImportJob !== 'function') {
1279
+ throw new ObjectStackError(
1280
+ 'The connected @objectstack/client does not support undoing import jobs (data.undoImportJob).',
1281
+ 'UNSUPPORTED_OPERATION',
1282
+ 400,
1283
+ );
1284
+ }
1285
+ try {
1286
+ return await api.undoImportJob.call(this.client.data, jobId);
1287
+ } catch (err) {
1288
+ throw normaliseClientError(err);
1289
+ }
1290
+ }
1291
+
1050
1292
  /**
1051
1293
  * Normalize the result from data.find() or data.query() into a consistent QueryResult.
1052
1294
  */
@@ -1647,6 +1889,28 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
1647
1889
  }
1648
1890
  }
1649
1891
 
1892
+ /**
1893
+ * List registered import `mapping` artifacts targeting a given object
1894
+ * (framework #2611). Reads the `mapping` metadata kind via the overlay API
1895
+ * and filters by `targetObject` client-side (the metadata index is
1896
+ * name-only). Feeds the import wizard's "saved mapping" selector; a failure
1897
+ * (older server without the `mapping` kind) degrades to an empty list, so
1898
+ * the selector simply doesn't appear.
1899
+ */
1900
+ async listImportMappings(objectName: string): Promise<any[]> {
1901
+ await this.connect();
1902
+ try {
1903
+ const result: any = await this.client.meta.getItems('mapping');
1904
+ const items: any[] = Array.isArray(result?.items)
1905
+ ? result.items
1906
+ : Array.isArray(result) ? result : [];
1907
+ return items.filter((m: any) => m && m.targetObject === objectName);
1908
+ } catch (err) {
1909
+ console.warn('[OBJECTSTACKDataSource] listImportMappings failed:', err);
1910
+ return [];
1911
+ }
1912
+ }
1913
+
1650
1914
  /**
1651
1915
  * Create a new overlay view for an object. The view's `name` is the
1652
1916
  * stable identifier — must be unique within the project scope. Returns
@@ -2394,6 +2658,7 @@ export type {
2394
2658
  MetadataDeleteOptions,
2395
2659
  MetadataHistoryOptions,
2396
2660
  MetadataError,
2661
+ MetadataValidationIssue,
2397
2662
  MetadataLayered,
2398
2663
  MetadataReference,
2399
2664
  MetadataDiagnostics,
@@ -122,6 +122,36 @@ describe('MetadataClient', () => {
122
122
  });
123
123
  });
124
124
 
125
+ it('extracts message, code + field-anchored issues from the dispatcher error shape', async () => {
126
+ // The dispatcher sends `error` as an OBJECT { message, code, details:{ code, issues } }.
127
+ // Reading `.message` (not String(object) → "[object Object]") and surfacing
128
+ // `details.issues` is what lets the Studio point at the offending field.
129
+ const c = new MetadataClient({
130
+ baseUrl: 'http://localhost:3000',
131
+ fetch: mockFetch(async () =>
132
+ new Response(
133
+ JSON.stringify({
134
+ success: false,
135
+ error: {
136
+ message: '[invalid_metadata] object/bad failed spec validation: label: Required',
137
+ code: 422,
138
+ details: {
139
+ code: 'invalid_metadata',
140
+ issues: [{ path: 'label', message: 'Required', code: 'invalid_type' }],
141
+ },
142
+ },
143
+ }),
144
+ { status: 422, headers: { 'content-type': 'application/json' } },
145
+ )),
146
+ });
147
+ await expect(c.save('object', 'bad', {})).rejects.toMatchObject({
148
+ status: 422,
149
+ code: 'invalid_metadata',
150
+ message: '[invalid_metadata] object/bad failed spec validation: label: Required',
151
+ issues: [{ path: 'label', message: 'Required', code: 'invalid_type' }],
152
+ });
153
+ });
154
+
125
155
  it('listDrafts requests /meta/_drafts with packageId + type and parses {drafts}', async () => {
126
156
  const seen: string[] = [];
127
157
  const c = new MetadataClient({
@@ -278,10 +278,20 @@ export interface MetadataHistoryOptions {
278
278
  limit?: number;
279
279
  }
280
280
 
281
+ /** A single field-anchored spec-validation issue (server `error.details.issues`). */
282
+ export interface MetadataValidationIssue {
283
+ /** Dot-path to the offending field, e.g. `fields.amount.type` (`''` = root). */
284
+ path: string;
285
+ message: string;
286
+ code?: string;
287
+ }
288
+
281
289
  export interface MetadataError extends Error {
282
290
  status: number;
283
291
  code?: string;
284
292
  body?: unknown;
293
+ /** Structured spec-validation issues, when the failure was a validation error. */
294
+ issues?: MetadataValidationIssue[];
285
295
  }
286
296
 
287
297
  const META_PREFIX = '/meta';
@@ -310,16 +320,36 @@ async function parseError(res: Response): Promise<MetadataError> {
310
320
  } catch {
311
321
  body = await res.text().catch(() => undefined);
312
322
  }
323
+ const bodyObj = body && typeof body === 'object' ? (body as Record<string, any>) : undefined;
324
+ // The dispatcher error shape is `{ success:false, error:{ message, code,
325
+ // details:{ code, issues } } }` — `error` is an OBJECT. Older/other surfaces
326
+ // send `error` as a plain string or a top-level `message`. Accept all three
327
+ // (reading `.message` off the object case, not `String(object)` which yields
328
+ // "[object Object]").
329
+ const errNode = bodyObj?.error;
313
330
  const message =
314
- (body && typeof body === 'object' && 'error' in (body as Record<string, unknown>)
315
- ? String((body as Record<string, unknown>).error)
316
- : undefined) ?? `Metadata request failed: ${res.status} ${res.statusText}`;
317
- const err = new Error(message) as MetadataError;
331
+ (errNode && typeof errNode === 'object' ? errNode.message : errNode) ??
332
+ bodyObj?.message ??
333
+ `Metadata request failed: ${res.status} ${res.statusText}`;
334
+ const err = new Error(String(message)) as MetadataError;
318
335
  err.status = res.status;
319
336
  err.body = body;
320
- if (body && typeof body === 'object' && 'code' in (body as Record<string, unknown>)) {
321
- err.code = String((body as Record<string, unknown>).code);
322
- }
337
+ // Field-anchored spec-validation issues. Two live server shapes carry them:
338
+ // the REST server puts them at top-level `body.issues` (with `error` a string),
339
+ // the HTTP dispatcher nests them under `error.details.issues` (with `error` an
340
+ // object). Accept both (plus a defensive `error.issues`) so the caller can
341
+ // point at the offending field regardless of which transport served the error.
342
+ const details = errNode && typeof errNode === 'object' ? errNode.details : undefined;
343
+ const issues =
344
+ details?.issues ??
345
+ (errNode && typeof errNode === 'object' ? errNode.issues : undefined) ??
346
+ bodyObj?.issues;
347
+ if (Array.isArray(issues)) err.issues = issues as MetadataValidationIssue[];
348
+ const code =
349
+ details?.code ??
350
+ (errNode && typeof errNode === 'object' ? errNode.code : undefined) ??
351
+ bodyObj?.code;
352
+ if (code != null) err.code = String(code);
323
353
  return err;
324
354
  }
325
355
 
@@ -0,0 +1,125 @@
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
+ * Regression: ObjectStackAdapter must implement `onMutation` and emit an event
11
+ * after every successful create/update/delete (and bulk variant).
12
+ *
13
+ * Real-world symptom (framework app-showcase): inline-edit a grid cell, click
14
+ * "全部保存" (Save All) — the write persisted but the list did NOT auto-refresh.
15
+ * ListView auto-refreshes by subscribing to `dataSource.onMutation`; the
16
+ * ObjectStack adapter never implemented it, so the per-row `update` writes
17
+ * issued by ObjectGrid's default batch-save were silent and the grid kept
18
+ * showing stale rows until a manual reload.
19
+ */
20
+ import { describe, it, expect, vi } from 'vitest';
21
+ import { ObjectStackAdapter } from './index';
22
+ import type { MutationEvent } from '@object-ui/types';
23
+
24
+ function makeDS(stub: Record<string, any>) {
25
+ const ds: any = new ObjectStackAdapter({
26
+ baseUrl: 'http://test.local',
27
+ fetch: vi.fn(async () => {
28
+ const body = { success: true, data: { capabilities: {}, routes: {} } };
29
+ return new Response(JSON.stringify(body), {
30
+ status: 200,
31
+ headers: { 'Content-Type': 'application/json' },
32
+ });
33
+ }),
34
+ });
35
+ ds.connected = true;
36
+ ds.connectionState = 'connected';
37
+ ds.client = { data: stub };
38
+ return ds;
39
+ }
40
+
41
+ describe('ObjectStackAdapter.onMutation', () => {
42
+ it('emits an update event after a successful update (the inline-edit save path)', async () => {
43
+ const update = vi
44
+ .fn()
45
+ .mockResolvedValue({ record: { id: 'r1', account: 'acc-1' } });
46
+ const ds = makeDS({ update });
47
+
48
+ const events: MutationEvent[] = [];
49
+ const unsub = ds.onMutation((e: MutationEvent) => events.push(e));
50
+
51
+ await ds.update('showcase_project', 'r1', { account: 'acc-1' });
52
+
53
+ expect(events).toEqual([
54
+ { type: 'update', resource: 'showcase_project', id: 'r1', record: { id: 'r1', account: 'acc-1' } },
55
+ ]);
56
+
57
+ // Unsubscribe stops delivery.
58
+ unsub();
59
+ await ds.update('showcase_project', 'r1', { account: 'acc-2' });
60
+ expect(events).toHaveLength(1);
61
+ });
62
+
63
+ it('emits a create event with the new record', async () => {
64
+ const create = vi.fn().mockResolvedValue({ record: { id: 'new', name: 'X' } });
65
+ const ds = makeDS({ create });
66
+ const events: MutationEvent[] = [];
67
+ ds.onMutation((e: MutationEvent) => events.push(e));
68
+
69
+ await ds.create('showcase_project', { name: 'X' });
70
+
71
+ expect(events).toEqual([
72
+ { type: 'create', resource: 'showcase_project', record: { id: 'new', name: 'X' } },
73
+ ]);
74
+ });
75
+
76
+ it('emits a delete event only when the server confirms deletion', async () => {
77
+ const del = vi
78
+ .fn()
79
+ .mockResolvedValueOnce({ deleted: true })
80
+ .mockResolvedValueOnce({ deleted: false });
81
+ const ds = makeDS({ delete: del });
82
+ const events: MutationEvent[] = [];
83
+ ds.onMutation((e: MutationEvent) => events.push(e));
84
+
85
+ await ds.delete('showcase_project', 'r1');
86
+ await ds.delete('showcase_project', 'r2');
87
+
88
+ expect(events).toEqual([
89
+ { type: 'delete', resource: 'showcase_project', id: 'r1' },
90
+ ]);
91
+ });
92
+
93
+ it('emits a single bulk event per bulkUpdate call (not per id)', async () => {
94
+ const updateMany = vi.fn().mockResolvedValue({ succeeded: 3, failed: 0, results: [] });
95
+ const ds = makeDS({ updateMany });
96
+ const events: MutationEvent[] = [];
97
+ ds.onMutation((e: MutationEvent) => events.push(e));
98
+
99
+ await ds.bulkUpdate('showcase_project', ['a', 'b', 'c'], { archived: true });
100
+
101
+ expect(events).toEqual([{ type: 'update', resource: 'showcase_project' }]);
102
+ });
103
+
104
+ it('does not emit when a bulk operation affected zero rows', async () => {
105
+ const updateMany = vi.fn().mockResolvedValue({ succeeded: 0, failed: 0, results: [] });
106
+ const ds = makeDS({ updateMany });
107
+ const events: MutationEvent[] = [];
108
+ ds.onMutation((e: MutationEvent) => events.push(e));
109
+
110
+ await ds.bulkUpdate('showcase_project', ['a'], { archived: true });
111
+
112
+ expect(events).toEqual([]);
113
+ });
114
+
115
+ it('isolates a throwing listener so the mutation still resolves and others fire', async () => {
116
+ const update = vi.fn().mockResolvedValue({ record: { id: 'r1' } });
117
+ const ds = makeDS({ update });
118
+ const good = vi.fn();
119
+ ds.onMutation(() => { throw new Error('boom'); });
120
+ ds.onMutation(good);
121
+
122
+ await expect(ds.update('showcase_project', 'r1', { x: 1 })).resolves.toBeTruthy();
123
+ expect(good).toHaveBeenCalledTimes(1);
124
+ });
125
+ });