@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.
- package/CHANGELOG.md +46 -0
- package/dist/index.cjs +299 -46
- package/dist/index.d.cts +76 -2
- package/dist/index.d.ts +76 -2
- package/dist/index.js +299 -46
- package/package.json +4 -4
- package/src/exportDownload.test.ts +93 -0
- package/src/getObjectSchema.test.ts +36 -0
- package/src/importJob.test.ts +71 -0
- package/src/index.ts +312 -9
- package/src/metadata-client.test.ts +30 -0
- package/src/metadata-client.ts +37 -7
- package/src/onMutation.test.ts +125 -0
- package/src/userState.test.ts +53 -0
- package/src/userState.ts +64 -37
package/src/metadata-client.ts
CHANGED
|
@@ -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
|
-
(
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
+
});
|
package/src/userState.test.ts
CHANGED
|
@@ -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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
}
|