@object-ui/data-objectstack 17.5.0 → 17.7.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 +2311 -0
- package/README.md +557 -11
- package/dist/index.cjs +1663 -463
- package/dist/index.d.cts +1780 -518
- package/dist/index.d.ts +1780 -518
- package/dist/index.js +1648 -457
- package/package.json +9 -9
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/aggregate-capability.test.ts +0 -177
- package/src/apiAccessDenied.test.ts +0 -233
- package/src/appAccessProbe.test.ts +0 -147
- package/src/bulkDelete.test.ts +0 -75
- package/src/bulkUpdate.test.ts +0 -93
- package/src/cache/MetadataCache.test.ts +0 -477
- package/src/cache/MetadataCache.ts +0 -289
- package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
- package/src/connection.test.ts +0 -188
- package/src/contracts.ts +0 -115
- package/src/deleteView.homes.test.ts +0 -389
- package/src/deleteViewContract.types.test.ts +0 -246
- package/src/errors.test.ts +0 -426
- package/src/errors.ts +0 -293
- package/src/exportDownload.test.ts +0 -141
- package/src/filter-entry-translation.test.ts +0 -360
- package/src/filter-operator-ast-parity.test.ts +0 -135
- package/src/getObjectSchema.test.ts +0 -145
- package/src/httpFailureLogging.test.ts +0 -109
- package/src/importJob.test.ts +0 -71
- package/src/index.ts +0 -4442
- package/src/integration.ts +0 -200
- package/src/listViewOverrides.test.ts +0 -241
- package/src/listViews.test.ts +0 -175
- package/src/metadata-client.get-envelope.test.ts +0 -171
- package/src/metadata-client.saveAdvisories.test.ts +0 -226
- package/src/metadata-client.test.ts +0 -303
- package/src/metadata-client.ts +0 -1046
- package/src/occ.test.ts +0 -101
- package/src/onMutation.test.ts +0 -589
- package/src/onSaveAdvisory.test.ts +0 -311
- package/src/onWriteWarning.test.ts +0 -273
- package/src/orderby-serialization.test.ts +0 -115
- package/src/queryDataset.test.ts +0 -543
- package/src/searchAll.test.ts +0 -120
- package/src/security.ts +0 -243
- package/src/spec-symbol-batch6.test.ts +0 -326
- package/src/studio.ts +0 -152
- package/src/updateView.draft.test.ts +0 -242
- package/src/userState.test.ts +0 -315
- package/src/userState.ts +0 -238
- package/src/v3-compat.test.ts +0 -229
- package/src/validation-error.test.ts +0 -94
- package/src/viewCacheInvalidation.pin.test.ts +0 -260
- package/src/viewInvalidationSeam.pin.test.ts +0 -162
package/src/userState.test.ts
DELETED
|
@@ -1,315 +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
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
-
import { createObjectStackUserStateAdapter } from './userState';
|
|
11
|
-
import type { DataSource } from '@object-ui/types';
|
|
12
|
-
|
|
13
|
-
function mockDataSource(overrides: Partial<DataSource<any>> = {}): DataSource<any> {
|
|
14
|
-
return {
|
|
15
|
-
find: vi.fn().mockResolvedValue({ data: [] }),
|
|
16
|
-
findOne: vi.fn().mockResolvedValue(null),
|
|
17
|
-
create: vi.fn().mockResolvedValue({}),
|
|
18
|
-
update: vi.fn().mockResolvedValue({}),
|
|
19
|
-
delete: vi.fn().mockResolvedValue(true),
|
|
20
|
-
...overrides,
|
|
21
|
-
} as unknown as DataSource<any>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe('createObjectStackUserStateAdapter', () => {
|
|
25
|
-
beforeEach(() => {
|
|
26
|
-
vi.restoreAllMocks();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe('load', () => {
|
|
30
|
-
it('returns [] when no row exists for the (user, key) pair', async () => {
|
|
31
|
-
const ds = mockDataSource();
|
|
32
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
33
|
-
dataSource: ds,
|
|
34
|
-
userId: 'u1',
|
|
35
|
-
key: 'ui.favorites',
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const items = await adapter.load();
|
|
39
|
-
|
|
40
|
-
expect(items).toEqual([]);
|
|
41
|
-
expect(ds.find).toHaveBeenCalledWith('sys_user_preference', {
|
|
42
|
-
$filter: { user_id: 'u1', key: 'ui.favorites' },
|
|
43
|
-
$top: 1,
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('returns parsed value when row exists (array value)', async () => {
|
|
48
|
-
const value = [{ id: 'object:contact', label: 'Contact' }];
|
|
49
|
-
const ds = mockDataSource({
|
|
50
|
-
find: vi.fn().mockResolvedValue({
|
|
51
|
-
data: [{ id: 'row-1', user_id: 'u1', key: 'ui.recent', value }],
|
|
52
|
-
}) as any,
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
56
|
-
dataSource: ds,
|
|
57
|
-
userId: 'u1',
|
|
58
|
-
key: 'ui.recent',
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
await expect(adapter.load()).resolves.toEqual(value);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('parses string-encoded JSON values', async () => {
|
|
65
|
-
const items = [{ id: 'a' }, { id: 'b' }];
|
|
66
|
-
const ds = mockDataSource({
|
|
67
|
-
find: vi.fn().mockResolvedValue({
|
|
68
|
-
data: [{ id: 1, value: JSON.stringify(items) }],
|
|
69
|
-
}) as any,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
73
|
-
dataSource: ds,
|
|
74
|
-
userId: 'u',
|
|
75
|
-
key: 'k',
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
await expect(adapter.load()).resolves.toEqual(items);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('returns [] when value is malformed JSON', async () => {
|
|
82
|
-
const ds = mockDataSource({
|
|
83
|
-
find: vi.fn().mockResolvedValue({
|
|
84
|
-
data: [{ id: 1, value: '{not json' }],
|
|
85
|
-
}) as any,
|
|
86
|
-
});
|
|
87
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
88
|
-
dataSource: ds,
|
|
89
|
-
userId: 'u',
|
|
90
|
-
key: 'k',
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
await expect(adapter.load()).resolves.toEqual([]);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('swallows errors and returns [] when find() throws', async () => {
|
|
97
|
-
const onError = vi.fn();
|
|
98
|
-
const ds = mockDataSource({
|
|
99
|
-
find: vi.fn().mockRejectedValue(new Error('boom')) as any,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
103
|
-
dataSource: ds,
|
|
104
|
-
userId: 'u',
|
|
105
|
-
key: 'k',
|
|
106
|
-
onError,
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
await expect(adapter.load()).resolves.toEqual([]);
|
|
110
|
-
expect(onError).toHaveBeenCalledWith('load', expect.any(Error));
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('uses a custom resource name when provided', async () => {
|
|
114
|
-
const ds = mockDataSource();
|
|
115
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
116
|
-
dataSource: ds,
|
|
117
|
-
userId: 'u',
|
|
118
|
-
key: 'k',
|
|
119
|
-
resource: 'my_prefs',
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
await adapter.load();
|
|
123
|
-
|
|
124
|
-
expect(ds.find).toHaveBeenCalledWith('my_prefs', expect.any(Object));
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
describe('save', () => {
|
|
129
|
-
it('creates a new row when none exists', async () => {
|
|
130
|
-
const created = { id: 'new-row' };
|
|
131
|
-
const ds = mockDataSource({
|
|
132
|
-
create: vi.fn().mockResolvedValue(created) as any,
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
136
|
-
dataSource: ds,
|
|
137
|
-
userId: 'u1',
|
|
138
|
-
key: 'ui.favorites',
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
await adapter.save([{ id: 'a' } as any]);
|
|
142
|
-
|
|
143
|
-
expect(ds.create).toHaveBeenCalledWith('sys_user_preference', {
|
|
144
|
-
user_id: 'u1',
|
|
145
|
-
key: 'ui.favorites',
|
|
146
|
-
value: [{ id: 'a' }],
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('updates the existing row when one exists', async () => {
|
|
151
|
-
const ds = mockDataSource({
|
|
152
|
-
find: vi.fn().mockResolvedValue({
|
|
153
|
-
data: [{ id: 'row-99', user_id: 'u', key: 'k', value: [] }],
|
|
154
|
-
}) as any,
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
158
|
-
dataSource: ds,
|
|
159
|
-
userId: 'u',
|
|
160
|
-
key: 'k',
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
await adapter.save([{ id: 'x' } as any]);
|
|
164
|
-
|
|
165
|
-
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-99', {
|
|
166
|
-
value: [{ id: 'x' }],
|
|
167
|
-
});
|
|
168
|
-
expect(ds.create).not.toHaveBeenCalled();
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
// #3794 — `updated_at` is server-managed. A caller-supplied value is
|
|
172
|
-
// stripped (framework #2948) and reported back as a dropped field, which
|
|
173
|
-
// the console turns into a "Some fields were not saved" toast. Stamping it
|
|
174
|
-
// here fired that warning on every recents/favorites write, about a field
|
|
175
|
-
// no user ever touched — noise on the exact surface that is supposed to
|
|
176
|
-
// tell a user their edit did not land.
|
|
177
|
-
it('never sends the server-managed updated_at column', async () => {
|
|
178
|
-
const ds = mockDataSource({
|
|
179
|
-
find: vi.fn().mockResolvedValue({ data: [] }) as any,
|
|
180
|
-
create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
|
|
181
|
-
});
|
|
182
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
183
|
-
dataSource: ds,
|
|
184
|
-
userId: 'u',
|
|
185
|
-
key: 'ui.recent',
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
await adapter.save([{ id: 'a' } as any]);
|
|
189
|
-
await adapter.save([{ id: 'b' } as any]);
|
|
190
|
-
|
|
191
|
-
for (const call of [...(ds.create as any).mock.calls, ...(ds.update as any).mock.calls]) {
|
|
192
|
-
expect(call[call.length - 1]).not.toHaveProperty('updated_at');
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('uses the cached row id from a previous load on subsequent saves', async () => {
|
|
197
|
-
const ds = mockDataSource({
|
|
198
|
-
find: vi.fn().mockResolvedValue({
|
|
199
|
-
data: [{ id: 'row-42', value: [] }],
|
|
200
|
-
}) as any,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
204
|
-
dataSource: ds,
|
|
205
|
-
userId: 'u',
|
|
206
|
-
key: 'k',
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
await adapter.load();
|
|
210
|
-
(ds.find as any).mockClear();
|
|
211
|
-
|
|
212
|
-
await adapter.save([{ id: 'b' } as any]);
|
|
213
|
-
|
|
214
|
-
// No second find() — went straight to update via cached id.
|
|
215
|
-
expect(ds.find).not.toHaveBeenCalled();
|
|
216
|
-
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-42', expect.any(Object));
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('falls back to insert when cached row update fails (row deleted server-side)', async () => {
|
|
220
|
-
const ds = mockDataSource({
|
|
221
|
-
find: vi.fn()
|
|
222
|
-
.mockResolvedValueOnce({ data: [{ id: 'stale', value: [] }] })
|
|
223
|
-
.mockResolvedValueOnce({ data: [] }) as any,
|
|
224
|
-
update: vi.fn().mockRejectedValue(new Error('not found')) as any,
|
|
225
|
-
create: vi.fn().mockResolvedValue({ id: 'new' }) as any,
|
|
226
|
-
});
|
|
227
|
-
const onError = vi.fn();
|
|
228
|
-
|
|
229
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
230
|
-
dataSource: ds,
|
|
231
|
-
userId: 'u',
|
|
232
|
-
key: 'k',
|
|
233
|
-
onError,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
await adapter.load(); // primes cachedRowId = 'stale'
|
|
237
|
-
await adapter.save([{ id: 'z' } as any]);
|
|
238
|
-
|
|
239
|
-
expect(ds.update).toHaveBeenCalledTimes(1);
|
|
240
|
-
expect(ds.create).toHaveBeenCalledWith('sys_user_preference', expect.objectContaining({
|
|
241
|
-
user_id: 'u',
|
|
242
|
-
key: 'k',
|
|
243
|
-
value: [{ id: 'z' }],
|
|
244
|
-
}));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('recovers from a UNIQUE(user_id, key) insert failure by updating in place', async () => {
|
|
248
|
-
// First findExisting() (no cached id) misses; create() loses the race and
|
|
249
|
-
// throws; the recovery findExisting() now sees the row → update.
|
|
250
|
-
const ds = mockDataSource({
|
|
251
|
-
find: vi.fn()
|
|
252
|
-
.mockResolvedValueOnce({ data: [] })
|
|
253
|
-
.mockResolvedValueOnce({ data: [{ id: 'row-7', user_id: 'u', key: 'k', value: [] }] }) as any,
|
|
254
|
-
create: vi.fn().mockRejectedValue(new Error('UNIQUE constraint failed')) as any,
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
258
|
-
dataSource: ds,
|
|
259
|
-
userId: 'u',
|
|
260
|
-
key: 'k',
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
await adapter.save([{ id: 'q' } as any]);
|
|
264
|
-
|
|
265
|
-
expect(ds.create).toHaveBeenCalledTimes(1);
|
|
266
|
-
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
|
|
267
|
-
value: [{ id: 'q' }],
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
it('serializes concurrent saves so only one insert happens', async () => {
|
|
272
|
-
// Both saves start before either resolves. Without serialization both
|
|
273
|
-
// would findExisting()→[]→create() and the second would trip the
|
|
274
|
-
// UNIQUE constraint. With the save chain, the second waits, sees the
|
|
275
|
-
// cached row id from the first, and updates.
|
|
276
|
-
const ds = mockDataSource({
|
|
277
|
-
find: vi.fn().mockResolvedValue({ data: [] }) as any,
|
|
278
|
-
create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
282
|
-
dataSource: ds,
|
|
283
|
-
userId: 'u',
|
|
284
|
-
key: 'ui.recent',
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
await Promise.all([
|
|
288
|
-
adapter.save([{ id: 'a' } as any]),
|
|
289
|
-
adapter.save([{ id: 'a' }, { id: 'b' }] as any),
|
|
290
|
-
]);
|
|
291
|
-
|
|
292
|
-
expect(ds.create).toHaveBeenCalledTimes(1);
|
|
293
|
-
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
|
|
294
|
-
value: [{ id: 'a' }, { id: 'b' }],
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it('swallows errors when save() throws', async () => {
|
|
299
|
-
const onError = vi.fn();
|
|
300
|
-
const ds = mockDataSource({
|
|
301
|
-
find: vi.fn().mockRejectedValue(new Error('network down')) as any,
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const adapter = createObjectStackUserStateAdapter({
|
|
305
|
-
dataSource: ds,
|
|
306
|
-
userId: 'u',
|
|
307
|
-
key: 'k',
|
|
308
|
-
onError,
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
await expect(adapter.save([{ id: 'a' } as any])).resolves.toBeUndefined();
|
|
312
|
-
expect(onError).toHaveBeenCalledWith('save', expect.any(Error));
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
});
|
package/src/userState.ts
DELETED
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectStack-backed `UserDataAdapter` factory.
|
|
3
|
-
*
|
|
4
|
-
* Persists arbitrary per-user UI state (favorites, recently-accessed items, …)
|
|
5
|
-
* as a single JSON blob per `(user_id, key)` row in the **unified per-user
|
|
6
|
-
* KV store** — the `sys_user_preference` object shipped by every
|
|
7
|
-
* `@objectstack/plugin-auth`-enabled environment.
|
|
8
|
-
*
|
|
9
|
-
* Using the existing `sys_user_preference` table (rather than a parallel
|
|
10
|
-
* `user_app_state` table) keeps things consistent with the platform's
|
|
11
|
-
* "one KV store per scope" pattern:
|
|
12
|
-
*
|
|
13
|
-
* - `sys_setting` ← tenant / env scope
|
|
14
|
-
* - `sys_user_preference` ← per-user scope ← we live here
|
|
15
|
-
*
|
|
16
|
-
* Callers are encouraged to namespace their keys (e.g. `ui.favorites`,
|
|
17
|
-
* `ui.recent`, `ui.grid.account.state`) so explicit settings (`theme`,
|
|
18
|
-
* `locale`) and machine-written UI traces stay easy to tell apart.
|
|
19
|
-
*
|
|
20
|
-
* ## Schema this adapter expects
|
|
21
|
-
*
|
|
22
|
-
* The canonical `sys_user_preference` schema (from
|
|
23
|
-
* `@objectstack/platform-objects`):
|
|
24
|
-
*
|
|
25
|
-
* ```yaml
|
|
26
|
-
* object: sys_user_preference
|
|
27
|
-
* fields:
|
|
28
|
-
* user_id: lookup(sys_user) # indexed
|
|
29
|
-
* key: string # indexed (e.g. "ui.favorites")
|
|
30
|
-
* value: json # the serialised list
|
|
31
|
-
* updated_at: datetime # auto-managed
|
|
32
|
-
* unique: [user_id, key]
|
|
33
|
-
* ```
|
|
34
|
-
*
|
|
35
|
-
* ## Failure modes
|
|
36
|
-
*
|
|
37
|
-
* The adapter is designed to **degrade silently**. Any error — missing
|
|
38
|
-
* schema, 4xx/5xx, network — is caught:
|
|
39
|
-
*
|
|
40
|
-
* - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
|
|
41
|
-
* - `save()` resolves without throwing, so UI mutations never surface a toast.
|
|
42
|
-
*
|
|
43
|
-
* As soon as the backend supports `sys_user_preference`, persistence
|
|
44
|
-
* "lights up" with no code change.
|
|
45
|
-
*
|
|
46
|
-
* @module
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
import type { DataSource, QueryParams } from '@object-ui/types';
|
|
50
|
-
|
|
51
|
-
export interface ObjectStackUserStateAdapterOptions {
|
|
52
|
-
/** Connected data source (usually the one provided by `<AdapterProvider>`). */
|
|
53
|
-
dataSource: DataSource<any>;
|
|
54
|
-
/** Authenticated user id. */
|
|
55
|
-
userId: string;
|
|
56
|
-
/**
|
|
57
|
-
* Storage key. Should be a dotted, namespaced string so UI traces
|
|
58
|
-
* don't collide with user-facing preferences. Examples:
|
|
59
|
-
* `ui.favorites`, `ui.recent`, `ui.grid.account.state`.
|
|
60
|
-
*/
|
|
61
|
-
key: string;
|
|
62
|
-
/** Override the storage object name. Defaults to `"sys_user_preference"`. */
|
|
63
|
-
resource?: string;
|
|
64
|
-
/**
|
|
65
|
-
* Optional console logger for development diagnostics. Defaults to noop so
|
|
66
|
-
* production builds stay quiet.
|
|
67
|
-
*/
|
|
68
|
-
onError?: (where: 'load' | 'save', error: unknown) => void;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface UserDataAdapter<T> {
|
|
72
|
-
load(): Promise<T[]>;
|
|
73
|
-
save(items: T[]): Promise<void>;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface UserPreferenceRecord {
|
|
77
|
-
id?: string | number;
|
|
78
|
-
user_id: string;
|
|
79
|
-
key: string;
|
|
80
|
-
value: unknown;
|
|
81
|
-
updated_at?: string;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const DEFAULT_RESOURCE = 'sys_user_preference';
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Build a `UserDataAdapter<T>` backed by ObjectStack.
|
|
88
|
-
*
|
|
89
|
-
* Each adapter instance is bound to a single `(user, key)` pair; create
|
|
90
|
-
* one per slot you want to persist.
|
|
91
|
-
*/
|
|
92
|
-
export function createObjectStackUserStateAdapter<T = unknown>(
|
|
93
|
-
options: ObjectStackUserStateAdapterOptions,
|
|
94
|
-
): UserDataAdapter<T> {
|
|
95
|
-
const {
|
|
96
|
-
dataSource,
|
|
97
|
-
userId,
|
|
98
|
-
key,
|
|
99
|
-
resource = DEFAULT_RESOURCE,
|
|
100
|
-
onError = () => {},
|
|
101
|
-
} = options;
|
|
102
|
-
|
|
103
|
-
// Cache the row id between load() and save() so we can update in place
|
|
104
|
-
// without re-querying. Reset on every successful load.
|
|
105
|
-
let cachedRowId: string | number | null = null;
|
|
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
|
-
|
|
115
|
-
const findExisting = async (): Promise<UserPreferenceRecord | null> => {
|
|
116
|
-
// NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare
|
|
117
|
-
// names (`filter`, `limit`) silently drops the predicate at the
|
|
118
|
-
// `convertQueryParams` layer, which then returns *any* row in the
|
|
119
|
-
// `sys_user_preference` table — that caused the Favorites adapter to load
|
|
120
|
-
// the Recents row (and vice-versa), so clicked items leaked into Starred.
|
|
121
|
-
const params: QueryParams = {
|
|
122
|
-
$filter: {
|
|
123
|
-
user_id: userId,
|
|
124
|
-
key,
|
|
125
|
-
},
|
|
126
|
-
$top: 1,
|
|
127
|
-
};
|
|
128
|
-
const result = await dataSource.find(resource, params);
|
|
129
|
-
const rows = (result?.data ?? []) as UserPreferenceRecord[];
|
|
130
|
-
// Defensive: if the backend ignored our predicate and returned mixed
|
|
131
|
-
// rows, only accept ones tagged for this (user, key) pair. Rows that
|
|
132
|
-
// omit those metadata fields entirely are trusted (covers mocks and
|
|
133
|
-
// backends that strip metadata from the projection).
|
|
134
|
-
const match = rows.find(r => {
|
|
135
|
-
if (!r) return false;
|
|
136
|
-
const userMismatch = r.user_id !== undefined && r.user_id !== userId;
|
|
137
|
-
const keyMismatch = r.key !== undefined && r.key !== key;
|
|
138
|
-
return !userMismatch && !keyMismatch;
|
|
139
|
-
});
|
|
140
|
-
return match ?? null;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const decodeValue = (value: unknown): T[] => {
|
|
144
|
-
// The server may return the JSON column already parsed, or as a string.
|
|
145
|
-
if (Array.isArray(value)) return value as T[];
|
|
146
|
-
if (typeof value === 'string') {
|
|
147
|
-
try {
|
|
148
|
-
const parsed = JSON.parse(value);
|
|
149
|
-
return Array.isArray(parsed) ? (parsed as T[]) : [];
|
|
150
|
-
} catch {
|
|
151
|
-
return [];
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return [];
|
|
155
|
-
};
|
|
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
|
-
//
|
|
163
|
-
// Deliberately does NOT stamp `updated_at`: the column is server-managed, and
|
|
164
|
-
// a non-system caller's write to it is stripped (framework #2948) and reported
|
|
165
|
-
// back as a dropped field — which the console surfaces as a "Some fields were
|
|
166
|
-
// not saved" toast (#3431). Sending it made every recents/favorites write pop
|
|
167
|
-
// a scary warning about a field the user never touched, drowning the real
|
|
168
|
-
// signal the toast exists for (#3794). The server stamps it either way.
|
|
169
|
-
const upsert = async (items: T[]): Promise<void> => {
|
|
170
|
-
// Fast path: we already know the row id from a previous load/save.
|
|
171
|
-
if (cachedRowId !== null) {
|
|
172
|
-
try {
|
|
173
|
-
await dataSource.update(resource, cachedRowId, { value: items });
|
|
174
|
-
return;
|
|
175
|
-
} catch (updateError) {
|
|
176
|
-
// Row may have been deleted server-side — fall through to find/insert.
|
|
177
|
-
cachedRowId = null;
|
|
178
|
-
onError('save', updateError);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const existing = await findExisting();
|
|
183
|
-
if (existing && existing.id !== undefined && existing.id !== null) {
|
|
184
|
-
cachedRowId = existing.id;
|
|
185
|
-
await dataSource.update(resource, existing.id, { value: items });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
const created = await dataSource.create(resource, {
|
|
191
|
-
user_id: userId,
|
|
192
|
-
key,
|
|
193
|
-
value: items,
|
|
194
|
-
});
|
|
195
|
-
const newId = (created as UserPreferenceRecord | undefined)?.id;
|
|
196
|
-
if (newId !== undefined && newId !== null) cachedRowId = newId;
|
|
197
|
-
} catch (createError) {
|
|
198
|
-
// The row already exists (UNIQUE(user_id, key) violation) — re-find and
|
|
199
|
-
// update in place. This turns the insert into a real upsert.
|
|
200
|
-
const recovered = await findExisting();
|
|
201
|
-
if (recovered && recovered.id !== undefined && recovered.id !== null) {
|
|
202
|
-
cachedRowId = recovered.id;
|
|
203
|
-
await dataSource.update(resource, recovered.id, { value: items });
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
throw createError;
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
return {
|
|
211
|
-
async load(): Promise<T[]> {
|
|
212
|
-
try {
|
|
213
|
-
const row = await findExisting();
|
|
214
|
-
if (!row) {
|
|
215
|
-
cachedRowId = null;
|
|
216
|
-
return [];
|
|
217
|
-
}
|
|
218
|
-
if (row.id !== undefined && row.id !== null) cachedRowId = row.id;
|
|
219
|
-
return decodeValue(row.value);
|
|
220
|
-
} catch (error) {
|
|
221
|
-
onError('load', error);
|
|
222
|
-
return [];
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
|
|
226
|
-
async save(items: T[]): Promise<void> {
|
|
227
|
-
// Chain onto any in-flight save so concurrent flushes upsert serially
|
|
228
|
-
// rather than racing into two inserts (see `saveChain` above). Each link
|
|
229
|
-
// catches its own errors so one failure never poisons the chain.
|
|
230
|
-
const run = saveChain.then(() => upsert(items)).catch(error => {
|
|
231
|
-
onError('save', error);
|
|
232
|
-
// Swallow — provider falls back to localStorage as source of truth.
|
|
233
|
-
});
|
|
234
|
-
saveChain = run;
|
|
235
|
-
return run;
|
|
236
|
-
},
|
|
237
|
-
};
|
|
238
|
-
}
|