@object-ui/data-objectstack 11.2.0 → 11.4.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.
@@ -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
+ });
@@ -221,6 +221,59 @@ describe('createObjectStackUserStateAdapter', () => {
221
221
  }));
222
222
  });
223
223
 
224
+ it('recovers from a UNIQUE(user_id, key) insert failure by updating in place', async () => {
225
+ // First findExisting() (no cached id) misses; create() loses the race and
226
+ // throws; the recovery findExisting() now sees the row → update.
227
+ const ds = mockDataSource({
228
+ find: vi.fn()
229
+ .mockResolvedValueOnce({ data: [] })
230
+ .mockResolvedValueOnce({ data: [{ id: 'row-7', user_id: 'u', key: 'k', value: [] }] }) as any,
231
+ create: vi.fn().mockRejectedValue(new Error('UNIQUE constraint failed')) as any,
232
+ });
233
+
234
+ const adapter = createObjectStackUserStateAdapter({
235
+ dataSource: ds,
236
+ userId: 'u',
237
+ key: 'k',
238
+ });
239
+
240
+ await adapter.save([{ id: 'q' } as any]);
241
+
242
+ expect(ds.create).toHaveBeenCalledTimes(1);
243
+ expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
244
+ value: [{ id: 'q' }],
245
+ updated_at: expect.any(String),
246
+ });
247
+ });
248
+
249
+ it('serializes concurrent saves so only one insert happens', async () => {
250
+ // Both saves start before either resolves. Without serialization both
251
+ // would findExisting()→[]→create() and the second would trip the
252
+ // UNIQUE constraint. With the save chain, the second waits, sees the
253
+ // cached row id from the first, and updates.
254
+ const ds = mockDataSource({
255
+ find: vi.fn().mockResolvedValue({ data: [] }) as any,
256
+ create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
257
+ });
258
+
259
+ const adapter = createObjectStackUserStateAdapter({
260
+ dataSource: ds,
261
+ userId: 'u',
262
+ key: 'ui.recent',
263
+ });
264
+
265
+ await Promise.all([
266
+ adapter.save([{ id: 'a' } as any]),
267
+ adapter.save([{ id: 'a' }, { id: 'b' }] as any),
268
+ ]);
269
+
270
+ expect(ds.create).toHaveBeenCalledTimes(1);
271
+ expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
272
+ value: [{ id: 'a' }, { id: 'b' }],
273
+ updated_at: expect.any(String),
274
+ });
275
+ });
276
+
224
277
  it('swallows errors when save() throws', async () => {
225
278
  const onError = vi.fn();
226
279
  const ds = mockDataSource({
package/src/userState.ts CHANGED
@@ -104,6 +104,14 @@ export function createObjectStackUserStateAdapter<T = unknown>(
104
104
  // without re-querying. Reset on every successful load.
105
105
  let cachedRowId: string | number | null = null;
106
106
 
107
+ // Serializes overlapping save() calls. A fresh adapter is created whenever
108
+ // the data source / user changes (see UserStateBridge), so its cachedRowId
109
+ // starts null; rapid navigations then fire debounced flushes that can race —
110
+ // two saves each findExisting()→null→create() and the second trips the
111
+ // UNIQUE(user_id, key) constraint. Chaining writes means the second save
112
+ // sees the cachedRowId the first one set and updates instead of inserting.
113
+ let saveChain: Promise<void> = Promise.resolve();
114
+
107
115
  const findExisting = async (): Promise<UserPreferenceRecord | null> => {
108
116
  // NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare
109
117
  // names (`filter`, `limit`) silently drops the predicate at the
@@ -146,6 +154,55 @@ export function createObjectStackUserStateAdapter<T = unknown>(
146
154
  return [];
147
155
  };
148
156
 
157
+ // Upsert by (user_id, key): update in place when the row is known/found,
158
+ // otherwise insert — and if the insert loses a UNIQUE(user_id, key) race
159
+ // (a concurrent writer created the row, or the backend dropped our find
160
+ // predicate), recover by re-finding and updating rather than surfacing the
161
+ // failed insert.
162
+ const upsert = async (items: T[]): Promise<void> => {
163
+ const now = new Date().toISOString();
164
+
165
+ // Fast path: we already know the row id from a previous load/save.
166
+ if (cachedRowId !== null) {
167
+ try {
168
+ await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
169
+ return;
170
+ } catch (updateError) {
171
+ // Row may have been deleted server-side — fall through to find/insert.
172
+ cachedRowId = null;
173
+ onError('save', updateError);
174
+ }
175
+ }
176
+
177
+ const existing = await findExisting();
178
+ if (existing && existing.id !== undefined && existing.id !== null) {
179
+ cachedRowId = existing.id;
180
+ await dataSource.update(resource, existing.id, { value: items, updated_at: now });
181
+ return;
182
+ }
183
+
184
+ try {
185
+ const created = await dataSource.create(resource, {
186
+ user_id: userId,
187
+ key,
188
+ value: items,
189
+ updated_at: now,
190
+ });
191
+ const newId = (created as UserPreferenceRecord | undefined)?.id;
192
+ if (newId !== undefined && newId !== null) cachedRowId = newId;
193
+ } catch (createError) {
194
+ // The row already exists (UNIQUE(user_id, key) violation) — re-find and
195
+ // update in place. This turns the insert into a real upsert.
196
+ const recovered = await findExisting();
197
+ if (recovered && recovered.id !== undefined && recovered.id !== null) {
198
+ cachedRowId = recovered.id;
199
+ await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
200
+ return;
201
+ }
202
+ throw createError;
203
+ }
204
+ };
205
+
149
206
  return {
150
207
  async load(): Promise<T[]> {
151
208
  try {
@@ -163,45 +220,15 @@ export function createObjectStackUserStateAdapter<T = unknown>(
163
220
  },
164
221
 
165
222
  async save(items: T[]): Promise<void> {
166
- try {
167
- const now = new Date().toISOString();
168
- // Fast path: we already know the row id from a previous load/save.
169
- if (cachedRowId !== null) {
170
- try {
171
- await dataSource.update(resource, cachedRowId, {
172
- value: items,
173
- updated_at: now,
174
- });
175
- return;
176
- } catch (updateError) {
177
- // Row may have been deleted server-side — fall through to insert.
178
- cachedRowId = null;
179
- onError('save', updateError);
180
- }
181
- }
182
-
183
- const existing = await findExisting();
184
- if (existing && existing.id !== undefined && existing.id !== null) {
185
- cachedRowId = existing.id;
186
- await dataSource.update(resource, existing.id, {
187
- value: items,
188
- updated_at: now,
189
- });
190
- return;
191
- }
192
-
193
- const created = await dataSource.create(resource, {
194
- user_id: userId,
195
- key,
196
- value: items,
197
- updated_at: now,
198
- });
199
- const newId = (created as UserPreferenceRecord | undefined)?.id;
200
- if (newId !== undefined && newId !== null) cachedRowId = newId;
201
- } catch (error) {
223
+ // Chain onto any in-flight save so concurrent flushes upsert serially
224
+ // rather than racing into two inserts (see `saveChain` above). Each link
225
+ // catches its own errors so one failure never poisons the chain.
226
+ const run = saveChain.then(() => upsert(items)).catch(error => {
202
227
  onError('save', error);
203
228
  // Swallow — provider falls back to localStorage as source of truth.
204
- }
229
+ });
230
+ saveChain = run;
231
+ return run;
205
232
  },
206
233
  };
207
234
  }