@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +2311 -0
  2. package/README.md +557 -11
  3. package/dist/index.cjs +1663 -463
  4. package/dist/index.d.cts +1780 -518
  5. package/dist/index.d.ts +1780 -518
  6. package/dist/index.js +1648 -457
  7. package/package.json +9 -9
  8. package/dist/tsconfig.tsbuildinfo +0 -1
  9. package/src/aggregate-capability.test.ts +0 -177
  10. package/src/apiAccessDenied.test.ts +0 -233
  11. package/src/appAccessProbe.test.ts +0 -147
  12. package/src/bulkDelete.test.ts +0 -75
  13. package/src/bulkUpdate.test.ts +0 -93
  14. package/src/cache/MetadataCache.test.ts +0 -477
  15. package/src/cache/MetadataCache.ts +0 -289
  16. package/src/cloud-surface-retired-4152.pin.test.ts +0 -171
  17. package/src/connection.test.ts +0 -188
  18. package/src/contracts.ts +0 -115
  19. package/src/deleteView.homes.test.ts +0 -389
  20. package/src/deleteViewContract.types.test.ts +0 -246
  21. package/src/errors.test.ts +0 -426
  22. package/src/errors.ts +0 -293
  23. package/src/exportDownload.test.ts +0 -141
  24. package/src/filter-entry-translation.test.ts +0 -360
  25. package/src/filter-operator-ast-parity.test.ts +0 -135
  26. package/src/getObjectSchema.test.ts +0 -145
  27. package/src/httpFailureLogging.test.ts +0 -109
  28. package/src/importJob.test.ts +0 -71
  29. package/src/index.ts +0 -4442
  30. package/src/integration.ts +0 -200
  31. package/src/listViewOverrides.test.ts +0 -241
  32. package/src/listViews.test.ts +0 -175
  33. package/src/metadata-client.get-envelope.test.ts +0 -171
  34. package/src/metadata-client.saveAdvisories.test.ts +0 -226
  35. package/src/metadata-client.test.ts +0 -303
  36. package/src/metadata-client.ts +0 -1046
  37. package/src/occ.test.ts +0 -101
  38. package/src/onMutation.test.ts +0 -589
  39. package/src/onSaveAdvisory.test.ts +0 -311
  40. package/src/onWriteWarning.test.ts +0 -273
  41. package/src/orderby-serialization.test.ts +0 -115
  42. package/src/queryDataset.test.ts +0 -543
  43. package/src/searchAll.test.ts +0 -120
  44. package/src/security.ts +0 -243
  45. package/src/spec-symbol-batch6.test.ts +0 -326
  46. package/src/studio.ts +0 -152
  47. package/src/updateView.draft.test.ts +0 -242
  48. package/src/userState.test.ts +0 -315
  49. package/src/userState.ts +0 -238
  50. package/src/v3-compat.test.ts +0 -229
  51. package/src/validation-error.test.ts +0 -94
  52. package/src/viewCacheInvalidation.pin.test.ts +0 -260
  53. package/src/viewInvalidationSeam.pin.test.ts +0 -162
@@ -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
- }