@object-ui/data-objectstack 4.8.0 → 5.0.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 CHANGED
@@ -1,5 +1,40 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 5.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c7561a7: **Unify per-user UI state storage onto `sys_user_preference`.**
8
+
9
+ `createObjectStackUserStateAdapter` previously wrote to a bespoke
10
+ `user_app_state` object using `(user_id, kind, payload)` columns. That
11
+ parallel KV table duplicated the canonical per-user preference store
12
+ shipped by `@objectstack/plugin-auth`, and pulled UI traces (favorites,
13
+ recent items, grid widths) out of the place users actually look for
14
+ their settings.
15
+
16
+ The adapter now defaults to:
17
+ - `resource`: `sys_user_preference`
18
+ - field shape: `(user_id, key, value)` instead of `(user_id, kind, payload)`
19
+ - option name: **`key`** instead of `kind`
20
+
21
+ `ConsoleShell` is updated to attach favorites/recent under the namespaced
22
+ keys `ui.favorites` and `ui.recent`. Recommended convention for new
23
+ adapters: keep machine-written UI traces under `ui.*` so they stay
24
+ distinguishable from user-facing preferences (`theme`, `locale`, ...).
25
+
26
+ **Migration**: callers passing `kind:` need to switch to `key:`. Callers
27
+ relying on the old `user_app_state` table can pin
28
+ `resource: 'user_app_state'` to keep the legacy behaviour, but no
29
+ backend ships that schema and the new default works against any
30
+ plugin-auth-enabled environment with zero extra setup.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [7213027]
35
+ - @object-ui/types@5.0.0
36
+ - @object-ui/core@5.0.0
37
+
3
38
  ## 4.8.0
4
39
 
5
40
  ### Patch Changes
package/README.md CHANGED
@@ -283,10 +283,10 @@ import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
283
283
  import { useAttachUserStateAdapters } from '@object-ui/app-shell';
284
284
 
285
285
  const favoritesAdapter = createObjectStackUserStateAdapter({
286
- dataSource, // the ObjectStack DataSource
286
+ dataSource, // the ObjectStack DataSource
287
287
  userId: user.id,
288
- kind: 'favorites', // or 'recent'
289
- // resource: 'user_app_state', // default
288
+ key: 'ui.favorites', // or 'ui.recent', 'ui.grid.account.state', ...
289
+ // resource: 'sys_user_preference', // default — the unified per-user KV store
290
290
  // onError: (op, err) => console.warn(`[user-state] ${op} failed`, err),
291
291
  });
292
292
 
@@ -295,20 +295,26 @@ attach('favorites', favoritesAdapter);
295
295
 
296
296
  ### Backend contract
297
297
 
298
- A single object stores one row per `(user_id, kind)` pair:
298
+ The adapter writes to the platform's unified per-user KV store —
299
+ `sys_user_preference` — shipped by every `@objectstack/plugin-auth`
300
+ environment. One row per `(user_id, key)` pair:
299
301
 
300
302
  ```yaml
301
- object: user_app_state
303
+ object: sys_user_preference
302
304
  fields:
303
- - { name: user_id, type: string, indexed: true }
304
- - { name: kind, type: string, indexed: true }
305
- - { name: payload, type: json }
305
+ - { name: user_id, type: lookup(sys_user), indexed: true }
306
+ - { name: key, type: string, indexed: true }
307
+ - { name: value, type: json }
306
308
  - { name: updated_at, type: datetime }
307
- unique: [user_id, kind]
309
+ unique: [user_id, key]
308
310
  ```
309
311
 
310
- If the object doesn't exist, every call 404s and the UI silently degrades to
311
- localStorage-only persistence. See
312
+ By convention, namespace UI-trace keys under `ui.*` (e.g. `ui.favorites`,
313
+ `ui.recent`, `ui.grid.<object>.state`, `ui.sidebar.collapsed`) so they
314
+ stay easy to tell apart from user-facing preferences (`theme`, `locale`).
315
+
316
+ If the backend doesn't yet expose `sys_user_preference`, every call
317
+ 404s and the UI silently degrades to localStorage-only persistence. See
312
318
  [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
313
319
  for the full design.
314
320
 
package/dist/index.cjs CHANGED
@@ -570,12 +570,12 @@ function generateContractManifest(contract) {
570
570
  }
571
571
 
572
572
  // src/userState.ts
573
- var DEFAULT_RESOURCE = "user_app_state";
573
+ var DEFAULT_RESOURCE = "sys_user_preference";
574
574
  function createObjectStackUserStateAdapter(options) {
575
575
  const {
576
576
  dataSource,
577
577
  userId,
578
- kind,
578
+ key,
579
579
  resource = DEFAULT_RESOURCE,
580
580
  onError = () => {
581
581
  }
@@ -585,7 +585,7 @@ function createObjectStackUserStateAdapter(options) {
585
585
  const params = {
586
586
  filter: {
587
587
  user_id: userId,
588
- kind
588
+ key
589
589
  },
590
590
  limit: 1
591
591
  };
@@ -593,11 +593,11 @@ function createObjectStackUserStateAdapter(options) {
593
593
  const rows = result?.data ?? [];
594
594
  return rows.length > 0 ? rows[0] : null;
595
595
  };
596
- const decodePayload = (payload) => {
597
- if (Array.isArray(payload)) return payload;
598
- if (typeof payload === "string") {
596
+ const decodeValue = (value) => {
597
+ if (Array.isArray(value)) return value;
598
+ if (typeof value === "string") {
599
599
  try {
600
- const parsed = JSON.parse(payload);
600
+ const parsed = JSON.parse(value);
601
601
  return Array.isArray(parsed) ? parsed : [];
602
602
  } catch {
603
603
  return [];
@@ -614,7 +614,7 @@ function createObjectStackUserStateAdapter(options) {
614
614
  return [];
615
615
  }
616
616
  if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
617
- return decodePayload(row.payload);
617
+ return decodeValue(row.value);
618
618
  } catch (error) {
619
619
  onError("load", error);
620
620
  return [];
@@ -626,7 +626,7 @@ function createObjectStackUserStateAdapter(options) {
626
626
  if (cachedRowId !== null) {
627
627
  try {
628
628
  await dataSource.update(resource, cachedRowId, {
629
- payload: items,
629
+ value: items,
630
630
  updated_at: now
631
631
  });
632
632
  return;
@@ -639,15 +639,15 @@ function createObjectStackUserStateAdapter(options) {
639
639
  if (existing && existing.id !== void 0 && existing.id !== null) {
640
640
  cachedRowId = existing.id;
641
641
  await dataSource.update(resource, existing.id, {
642
- payload: items,
642
+ value: items,
643
643
  updated_at: now
644
644
  });
645
645
  return;
646
646
  }
647
647
  const created = await dataSource.create(resource, {
648
648
  user_id: userId,
649
- kind,
650
- payload: items,
649
+ key,
650
+ value: items,
651
651
  updated_at: now
652
652
  });
653
653
  const newId = created?.id;
package/dist/index.d.cts CHANGED
@@ -319,36 +319,45 @@ declare function generateContractManifest(contract: PluginContract): Record<stri
319
319
  * ObjectStack-backed `UserDataAdapter` factory.
320
320
  *
321
321
  * Persists arbitrary per-user UI state (favorites, recently-accessed items, …)
322
- * as a single JSON blob per `(user_id, kind)` row in an object named
323
- * `user_app_state`. The single-blob approach keeps round-trips down (one
324
- * `find` + one `update`/`create` per change) and matches the
325
- * "save the full list" semantics of `FavoritesProvider` / `RecentItemsProvider`.
322
+ * as a single JSON blob per `(user_id, key)` row in the **unified per-user
323
+ * KV store** — the `sys_user_preference` object shipped by every
324
+ * `@objectstack/plugin-auth`-enabled environment.
326
325
  *
327
- * ## Required object schema
326
+ * Using the existing `sys_user_preference` table (rather than a parallel
327
+ * `user_app_state` table) keeps things consistent with the platform's
328
+ * "one KV store per scope" pattern:
328
329
  *
329
- * Configure an object in ObjectStack with at least the following fields:
330
+ * - `sys_setting` ← tenant / env scope
331
+ * - `sys_user_preference` ← per-user scope ← we live here
332
+ *
333
+ * Callers are encouraged to namespace their keys (e.g. `ui.favorites`,
334
+ * `ui.recent`, `ui.grid.account.state`) so explicit settings (`theme`,
335
+ * `locale`) and machine-written UI traces stay easy to tell apart.
336
+ *
337
+ * ## Schema this adapter expects
338
+ *
339
+ * The canonical `sys_user_preference` schema (from
340
+ * `@objectstack/platform-objects`):
330
341
  *
331
342
  * ```yaml
332
- * object: user_app_state
343
+ * object: sys_user_preference
333
344
  * fields:
334
- * user_id: string # indexed
335
- * kind: string # indexed (e.g. "favorites" | "recent")
336
- * payload: json # the serialised list
337
- * updated_at: datetime # (optional) auto-managed
338
- * unique: [user_id, kind]
345
+ * user_id: lookup(sys_user) # indexed
346
+ * key: string # indexed (e.g. "ui.favorites")
347
+ * value: json # the serialised list
348
+ * updated_at: datetime # auto-managed
349
+ * unique: [user_id, key]
339
350
  * ```
340
351
  *
341
352
  * ## Failure modes
342
353
  *
343
- * The adapter is designed to **degrade silently**. Any error — missing schema,
344
- * 4xx/5xx, network — is caught:
354
+ * The adapter is designed to **degrade silently**. Any error — missing
355
+ * schema, 4xx/5xx, network — is caught:
345
356
  *
346
357
  * - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
347
358
  * - `save()` resolves without throwing, so UI mutations never surface a toast.
348
359
  *
349
- * This means an OSS user who hasn't yet provisioned the `user_app_state`
350
- * object still gets a perfectly working (localStorage-only) favorites /
351
- * recent-items experience. As soon as the object exists, persistence
360
+ * As soon as the backend supports `sys_user_preference`, persistence
352
361
  * "lights up" with no code change.
353
362
  *
354
363
  * @module
@@ -359,9 +368,13 @@ interface ObjectStackUserStateAdapterOptions {
359
368
  dataSource: DataSource<any>;
360
369
  /** Authenticated user id. */
361
370
  userId: string;
362
- /** Logical bucket key, e.g. `"favorites"` or `"recent"`. */
363
- kind: string;
364
- /** Override the storage object name. Defaults to `"user_app_state"`. */
371
+ /**
372
+ * Storage key. Should be a dotted, namespaced string so UI traces
373
+ * don't collide with user-facing preferences. Examples:
374
+ * `ui.favorites`, `ui.recent`, `ui.grid.account.state`.
375
+ */
376
+ key: string;
377
+ /** Override the storage object name. Defaults to `"sys_user_preference"`. */
365
378
  resource?: string;
366
379
  /**
367
380
  * Optional console logger for development diagnostics. Defaults to noop so
@@ -376,8 +389,8 @@ interface UserDataAdapter<T> {
376
389
  /**
377
390
  * Build a `UserDataAdapter<T>` backed by ObjectStack.
378
391
  *
379
- * Each adapter instance is bound to a single `(user, kind)` pair; create one
380
- * per slot you want to persist.
392
+ * Each adapter instance is bound to a single `(user, key)` pair; create
393
+ * one per slot you want to persist.
381
394
  */
382
395
  declare function createObjectStackUserStateAdapter<T = unknown>(options: ObjectStackUserStateAdapterOptions): UserDataAdapter<T>;
383
396
 
package/dist/index.d.ts CHANGED
@@ -319,36 +319,45 @@ declare function generateContractManifest(contract: PluginContract): Record<stri
319
319
  * ObjectStack-backed `UserDataAdapter` factory.
320
320
  *
321
321
  * Persists arbitrary per-user UI state (favorites, recently-accessed items, …)
322
- * as a single JSON blob per `(user_id, kind)` row in an object named
323
- * `user_app_state`. The single-blob approach keeps round-trips down (one
324
- * `find` + one `update`/`create` per change) and matches the
325
- * "save the full list" semantics of `FavoritesProvider` / `RecentItemsProvider`.
322
+ * as a single JSON blob per `(user_id, key)` row in the **unified per-user
323
+ * KV store** — the `sys_user_preference` object shipped by every
324
+ * `@objectstack/plugin-auth`-enabled environment.
326
325
  *
327
- * ## Required object schema
326
+ * Using the existing `sys_user_preference` table (rather than a parallel
327
+ * `user_app_state` table) keeps things consistent with the platform's
328
+ * "one KV store per scope" pattern:
328
329
  *
329
- * Configure an object in ObjectStack with at least the following fields:
330
+ * - `sys_setting` ← tenant / env scope
331
+ * - `sys_user_preference` ← per-user scope ← we live here
332
+ *
333
+ * Callers are encouraged to namespace their keys (e.g. `ui.favorites`,
334
+ * `ui.recent`, `ui.grid.account.state`) so explicit settings (`theme`,
335
+ * `locale`) and machine-written UI traces stay easy to tell apart.
336
+ *
337
+ * ## Schema this adapter expects
338
+ *
339
+ * The canonical `sys_user_preference` schema (from
340
+ * `@objectstack/platform-objects`):
330
341
  *
331
342
  * ```yaml
332
- * object: user_app_state
343
+ * object: sys_user_preference
333
344
  * fields:
334
- * user_id: string # indexed
335
- * kind: string # indexed (e.g. "favorites" | "recent")
336
- * payload: json # the serialised list
337
- * updated_at: datetime # (optional) auto-managed
338
- * unique: [user_id, kind]
345
+ * user_id: lookup(sys_user) # indexed
346
+ * key: string # indexed (e.g. "ui.favorites")
347
+ * value: json # the serialised list
348
+ * updated_at: datetime # auto-managed
349
+ * unique: [user_id, key]
339
350
  * ```
340
351
  *
341
352
  * ## Failure modes
342
353
  *
343
- * The adapter is designed to **degrade silently**. Any error — missing schema,
344
- * 4xx/5xx, network — is caught:
354
+ * The adapter is designed to **degrade silently**. Any error — missing
355
+ * schema, 4xx/5xx, network — is caught:
345
356
  *
346
357
  * - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
347
358
  * - `save()` resolves without throwing, so UI mutations never surface a toast.
348
359
  *
349
- * This means an OSS user who hasn't yet provisioned the `user_app_state`
350
- * object still gets a perfectly working (localStorage-only) favorites /
351
- * recent-items experience. As soon as the object exists, persistence
360
+ * As soon as the backend supports `sys_user_preference`, persistence
352
361
  * "lights up" with no code change.
353
362
  *
354
363
  * @module
@@ -359,9 +368,13 @@ interface ObjectStackUserStateAdapterOptions {
359
368
  dataSource: DataSource<any>;
360
369
  /** Authenticated user id. */
361
370
  userId: string;
362
- /** Logical bucket key, e.g. `"favorites"` or `"recent"`. */
363
- kind: string;
364
- /** Override the storage object name. Defaults to `"user_app_state"`. */
371
+ /**
372
+ * Storage key. Should be a dotted, namespaced string so UI traces
373
+ * don't collide with user-facing preferences. Examples:
374
+ * `ui.favorites`, `ui.recent`, `ui.grid.account.state`.
375
+ */
376
+ key: string;
377
+ /** Override the storage object name. Defaults to `"sys_user_preference"`. */
365
378
  resource?: string;
366
379
  /**
367
380
  * Optional console logger for development diagnostics. Defaults to noop so
@@ -376,8 +389,8 @@ interface UserDataAdapter<T> {
376
389
  /**
377
390
  * Build a `UserDataAdapter<T>` backed by ObjectStack.
378
391
  *
379
- * Each adapter instance is bound to a single `(user, kind)` pair; create one
380
- * per slot you want to persist.
392
+ * Each adapter instance is bound to a single `(user, key)` pair; create
393
+ * one per slot you want to persist.
381
394
  */
382
395
  declare function createObjectStackUserStateAdapter<T = unknown>(options: ObjectStackUserStateAdapterOptions): UserDataAdapter<T>;
383
396
 
package/dist/index.js CHANGED
@@ -526,12 +526,12 @@ function generateContractManifest(contract) {
526
526
  }
527
527
 
528
528
  // src/userState.ts
529
- var DEFAULT_RESOURCE = "user_app_state";
529
+ var DEFAULT_RESOURCE = "sys_user_preference";
530
530
  function createObjectStackUserStateAdapter(options) {
531
531
  const {
532
532
  dataSource,
533
533
  userId,
534
- kind,
534
+ key,
535
535
  resource = DEFAULT_RESOURCE,
536
536
  onError = () => {
537
537
  }
@@ -541,7 +541,7 @@ function createObjectStackUserStateAdapter(options) {
541
541
  const params = {
542
542
  filter: {
543
543
  user_id: userId,
544
- kind
544
+ key
545
545
  },
546
546
  limit: 1
547
547
  };
@@ -549,11 +549,11 @@ function createObjectStackUserStateAdapter(options) {
549
549
  const rows = result?.data ?? [];
550
550
  return rows.length > 0 ? rows[0] : null;
551
551
  };
552
- const decodePayload = (payload) => {
553
- if (Array.isArray(payload)) return payload;
554
- if (typeof payload === "string") {
552
+ const decodeValue = (value) => {
553
+ if (Array.isArray(value)) return value;
554
+ if (typeof value === "string") {
555
555
  try {
556
- const parsed = JSON.parse(payload);
556
+ const parsed = JSON.parse(value);
557
557
  return Array.isArray(parsed) ? parsed : [];
558
558
  } catch {
559
559
  return [];
@@ -570,7 +570,7 @@ function createObjectStackUserStateAdapter(options) {
570
570
  return [];
571
571
  }
572
572
  if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
573
- return decodePayload(row.payload);
573
+ return decodeValue(row.value);
574
574
  } catch (error) {
575
575
  onError("load", error);
576
576
  return [];
@@ -582,7 +582,7 @@ function createObjectStackUserStateAdapter(options) {
582
582
  if (cachedRowId !== null) {
583
583
  try {
584
584
  await dataSource.update(resource, cachedRowId, {
585
- payload: items,
585
+ value: items,
586
586
  updated_at: now
587
587
  });
588
588
  return;
@@ -595,15 +595,15 @@ function createObjectStackUserStateAdapter(options) {
595
595
  if (existing && existing.id !== void 0 && existing.id !== null) {
596
596
  cachedRowId = existing.id;
597
597
  await dataSource.update(resource, existing.id, {
598
- payload: items,
598
+ value: items,
599
599
  updated_at: now
600
600
  });
601
601
  return;
602
602
  }
603
603
  const created = await dataSource.create(resource, {
604
604
  user_id: userId,
605
- kind,
606
- payload: items,
605
+ key,
606
+ value: items,
607
607
  updated_at: now
608
608
  });
609
609
  const newId = created?.id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "4.8.0",
3
+ "version": "5.0.0",
4
4
  "description": "ObjectStack Data Adapter for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,8 +23,8 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "@objectstack/client": "^4.0.5",
26
- "@object-ui/core": "4.8.0",
27
- "@object-ui/types": "4.8.0"
26
+ "@object-ui/core": "5.0.0",
27
+ "@object-ui/types": "5.0.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.1",
@@ -27,67 +27,67 @@ describe('createObjectStackUserStateAdapter', () => {
27
27
  });
28
28
 
29
29
  describe('load', () => {
30
- it('returns [] when no row exists for the (user, kind) pair', async () => {
30
+ it('returns [] when no row exists for the (user, key) pair', async () => {
31
31
  const ds = mockDataSource();
32
32
  const adapter = createObjectStackUserStateAdapter({
33
33
  dataSource: ds,
34
34
  userId: 'u1',
35
- kind: 'favorites',
35
+ key: 'ui.favorites',
36
36
  });
37
37
 
38
38
  const items = await adapter.load();
39
39
 
40
40
  expect(items).toEqual([]);
41
- expect(ds.find).toHaveBeenCalledWith('user_app_state', {
42
- filter: { user_id: 'u1', kind: 'favorites' },
41
+ expect(ds.find).toHaveBeenCalledWith('sys_user_preference', {
42
+ filter: { user_id: 'u1', key: 'ui.favorites' },
43
43
  limit: 1,
44
44
  });
45
45
  });
46
46
 
47
- it('returns parsed payload when row exists (array payload)', async () => {
48
- const payload = [{ id: 'object:contact', label: 'Contact' }];
47
+ it('returns parsed value when row exists (array value)', async () => {
48
+ const value = [{ id: 'object:contact', label: 'Contact' }];
49
49
  const ds = mockDataSource({
50
50
  find: vi.fn().mockResolvedValue({
51
- data: [{ id: 'row-1', user_id: 'u1', kind: 'recent', payload }],
51
+ data: [{ id: 'row-1', user_id: 'u1', key: 'ui.recent', value }],
52
52
  }) as any,
53
53
  });
54
54
 
55
55
  const adapter = createObjectStackUserStateAdapter({
56
56
  dataSource: ds,
57
57
  userId: 'u1',
58
- kind: 'recent',
58
+ key: 'ui.recent',
59
59
  });
60
60
 
61
- await expect(adapter.load()).resolves.toEqual(payload);
61
+ await expect(adapter.load()).resolves.toEqual(value);
62
62
  });
63
63
 
64
- it('parses string-encoded JSON payloads', async () => {
64
+ it('parses string-encoded JSON values', async () => {
65
65
  const items = [{ id: 'a' }, { id: 'b' }];
66
66
  const ds = mockDataSource({
67
67
  find: vi.fn().mockResolvedValue({
68
- data: [{ id: 1, payload: JSON.stringify(items) }],
68
+ data: [{ id: 1, value: JSON.stringify(items) }],
69
69
  }) as any,
70
70
  });
71
71
 
72
72
  const adapter = createObjectStackUserStateAdapter({
73
73
  dataSource: ds,
74
74
  userId: 'u',
75
- kind: 'k',
75
+ key: 'k',
76
76
  });
77
77
 
78
78
  await expect(adapter.load()).resolves.toEqual(items);
79
79
  });
80
80
 
81
- it('returns [] when payload is malformed JSON', async () => {
81
+ it('returns [] when value is malformed JSON', async () => {
82
82
  const ds = mockDataSource({
83
83
  find: vi.fn().mockResolvedValue({
84
- data: [{ id: 1, payload: '{not json' }],
84
+ data: [{ id: 1, value: '{not json' }],
85
85
  }) as any,
86
86
  });
87
87
  const adapter = createObjectStackUserStateAdapter({
88
88
  dataSource: ds,
89
89
  userId: 'u',
90
- kind: 'k',
90
+ key: 'k',
91
91
  });
92
92
 
93
93
  await expect(adapter.load()).resolves.toEqual([]);
@@ -102,7 +102,7 @@ describe('createObjectStackUserStateAdapter', () => {
102
102
  const adapter = createObjectStackUserStateAdapter({
103
103
  dataSource: ds,
104
104
  userId: 'u',
105
- kind: 'k',
105
+ key: 'k',
106
106
  onError,
107
107
  });
108
108
 
@@ -115,7 +115,7 @@ describe('createObjectStackUserStateAdapter', () => {
115
115
  const adapter = createObjectStackUserStateAdapter({
116
116
  dataSource: ds,
117
117
  userId: 'u',
118
- kind: 'k',
118
+ key: 'k',
119
119
  resource: 'my_prefs',
120
120
  });
121
121
 
@@ -135,15 +135,15 @@ describe('createObjectStackUserStateAdapter', () => {
135
135
  const adapter = createObjectStackUserStateAdapter({
136
136
  dataSource: ds,
137
137
  userId: 'u1',
138
- kind: 'favorites',
138
+ key: 'ui.favorites',
139
139
  });
140
140
 
141
141
  await adapter.save([{ id: 'a' } as any]);
142
142
 
143
- expect(ds.create).toHaveBeenCalledWith('user_app_state', {
143
+ expect(ds.create).toHaveBeenCalledWith('sys_user_preference', {
144
144
  user_id: 'u1',
145
- kind: 'favorites',
146
- payload: [{ id: 'a' }],
145
+ key: 'ui.favorites',
146
+ value: [{ id: 'a' }],
147
147
  updated_at: expect.any(String),
148
148
  });
149
149
  });
@@ -151,20 +151,20 @@ describe('createObjectStackUserStateAdapter', () => {
151
151
  it('updates the existing row when one exists', async () => {
152
152
  const ds = mockDataSource({
153
153
  find: vi.fn().mockResolvedValue({
154
- data: [{ id: 'row-99', user_id: 'u', kind: 'k', payload: [] }],
154
+ data: [{ id: 'row-99', user_id: 'u', key: 'k', value: [] }],
155
155
  }) as any,
156
156
  });
157
157
 
158
158
  const adapter = createObjectStackUserStateAdapter({
159
159
  dataSource: ds,
160
160
  userId: 'u',
161
- kind: 'k',
161
+ key: 'k',
162
162
  });
163
163
 
164
164
  await adapter.save([{ id: 'x' } as any]);
165
165
 
166
- expect(ds.update).toHaveBeenCalledWith('user_app_state', 'row-99', {
167
- payload: [{ id: 'x' }],
166
+ expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-99', {
167
+ value: [{ id: 'x' }],
168
168
  updated_at: expect.any(String),
169
169
  });
170
170
  expect(ds.create).not.toHaveBeenCalled();
@@ -173,14 +173,14 @@ describe('createObjectStackUserStateAdapter', () => {
173
173
  it('uses the cached row id from a previous load on subsequent saves', async () => {
174
174
  const ds = mockDataSource({
175
175
  find: vi.fn().mockResolvedValue({
176
- data: [{ id: 'row-42', payload: [] }],
176
+ data: [{ id: 'row-42', value: [] }],
177
177
  }) as any,
178
178
  });
179
179
 
180
180
  const adapter = createObjectStackUserStateAdapter({
181
181
  dataSource: ds,
182
182
  userId: 'u',
183
- kind: 'k',
183
+ key: 'k',
184
184
  });
185
185
 
186
186
  await adapter.load();
@@ -190,13 +190,13 @@ describe('createObjectStackUserStateAdapter', () => {
190
190
 
191
191
  // No second find() — went straight to update via cached id.
192
192
  expect(ds.find).not.toHaveBeenCalled();
193
- expect(ds.update).toHaveBeenCalledWith('user_app_state', 'row-42', expect.any(Object));
193
+ expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-42', expect.any(Object));
194
194
  });
195
195
 
196
196
  it('falls back to insert when cached row update fails (row deleted server-side)', async () => {
197
197
  const ds = mockDataSource({
198
198
  find: vi.fn()
199
- .mockResolvedValueOnce({ data: [{ id: 'stale', payload: [] }] })
199
+ .mockResolvedValueOnce({ data: [{ id: 'stale', value: [] }] })
200
200
  .mockResolvedValueOnce({ data: [] }) as any,
201
201
  update: vi.fn().mockRejectedValue(new Error('not found')) as any,
202
202
  create: vi.fn().mockResolvedValue({ id: 'new' }) as any,
@@ -206,7 +206,7 @@ describe('createObjectStackUserStateAdapter', () => {
206
206
  const adapter = createObjectStackUserStateAdapter({
207
207
  dataSource: ds,
208
208
  userId: 'u',
209
- kind: 'k',
209
+ key: 'k',
210
210
  onError,
211
211
  });
212
212
 
@@ -214,10 +214,10 @@ describe('createObjectStackUserStateAdapter', () => {
214
214
  await adapter.save([{ id: 'z' } as any]);
215
215
 
216
216
  expect(ds.update).toHaveBeenCalledTimes(1);
217
- expect(ds.create).toHaveBeenCalledWith('user_app_state', expect.objectContaining({
217
+ expect(ds.create).toHaveBeenCalledWith('sys_user_preference', expect.objectContaining({
218
218
  user_id: 'u',
219
- kind: 'k',
220
- payload: [{ id: 'z' }],
219
+ key: 'k',
220
+ value: [{ id: 'z' }],
221
221
  }));
222
222
  });
223
223
 
@@ -230,7 +230,7 @@ describe('createObjectStackUserStateAdapter', () => {
230
230
  const adapter = createObjectStackUserStateAdapter({
231
231
  dataSource: ds,
232
232
  userId: 'u',
233
- kind: 'k',
233
+ key: 'k',
234
234
  onError,
235
235
  });
236
236
 
package/src/userState.ts CHANGED
@@ -2,36 +2,45 @@
2
2
  * ObjectStack-backed `UserDataAdapter` factory.
3
3
  *
4
4
  * Persists arbitrary per-user UI state (favorites, recently-accessed items, …)
5
- * as a single JSON blob per `(user_id, kind)` row in an object named
6
- * `user_app_state`. The single-blob approach keeps round-trips down (one
7
- * `find` + one `update`/`create` per change) and matches the
8
- * "save the full list" semantics of `FavoritesProvider` / `RecentItemsProvider`.
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.
9
8
  *
10
- * ## Required object schema
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:
11
12
  *
12
- * Configure an object in ObjectStack with at least the following fields:
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`):
13
24
  *
14
25
  * ```yaml
15
- * object: user_app_state
26
+ * object: sys_user_preference
16
27
  * fields:
17
- * user_id: string # indexed
18
- * kind: string # indexed (e.g. "favorites" | "recent")
19
- * payload: json # the serialised list
20
- * updated_at: datetime # (optional) auto-managed
21
- * unique: [user_id, kind]
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]
22
33
  * ```
23
34
  *
24
35
  * ## Failure modes
25
36
  *
26
- * The adapter is designed to **degrade silently**. Any error — missing schema,
27
- * 4xx/5xx, network — is caught:
37
+ * The adapter is designed to **degrade silently**. Any error — missing
38
+ * schema, 4xx/5xx, network — is caught:
28
39
  *
29
40
  * - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
30
41
  * - `save()` resolves without throwing, so UI mutations never surface a toast.
31
42
  *
32
- * This means an OSS user who hasn't yet provisioned the `user_app_state`
33
- * object still gets a perfectly working (localStorage-only) favorites /
34
- * recent-items experience. As soon as the object exists, persistence
43
+ * As soon as the backend supports `sys_user_preference`, persistence
35
44
  * "lights up" with no code change.
36
45
  *
37
46
  * @module
@@ -44,9 +53,13 @@ export interface ObjectStackUserStateAdapterOptions {
44
53
  dataSource: DataSource<any>;
45
54
  /** Authenticated user id. */
46
55
  userId: string;
47
- /** Logical bucket key, e.g. `"favorites"` or `"recent"`. */
48
- kind: string;
49
- /** Override the storage object name. Defaults to `"user_app_state"`. */
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"`. */
50
63
  resource?: string;
51
64
  /**
52
65
  * Optional console logger for development diagnostics. Defaults to noop so
@@ -60,21 +73,21 @@ export interface UserDataAdapter<T> {
60
73
  save(items: T[]): Promise<void>;
61
74
  }
62
75
 
63
- interface UserStateRecord {
76
+ interface UserPreferenceRecord {
64
77
  id?: string | number;
65
78
  user_id: string;
66
- kind: string;
67
- payload: unknown;
79
+ key: string;
80
+ value: unknown;
68
81
  updated_at?: string;
69
82
  }
70
83
 
71
- const DEFAULT_RESOURCE = 'user_app_state';
84
+ const DEFAULT_RESOURCE = 'sys_user_preference';
72
85
 
73
86
  /**
74
87
  * Build a `UserDataAdapter<T>` backed by ObjectStack.
75
88
  *
76
- * Each adapter instance is bound to a single `(user, kind)` pair; create one
77
- * per slot you want to persist.
89
+ * Each adapter instance is bound to a single `(user, key)` pair; create
90
+ * one per slot you want to persist.
78
91
  */
79
92
  export function createObjectStackUserStateAdapter<T = unknown>(
80
93
  options: ObjectStackUserStateAdapterOptions,
@@ -82,7 +95,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
82
95
  const {
83
96
  dataSource,
84
97
  userId,
85
- kind,
98
+ key,
86
99
  resource = DEFAULT_RESOURCE,
87
100
  onError = () => {},
88
101
  } = options;
@@ -91,25 +104,25 @@ export function createObjectStackUserStateAdapter<T = unknown>(
91
104
  // without re-querying. Reset on every successful load.
92
105
  let cachedRowId: string | number | null = null;
93
106
 
94
- const findExisting = async (): Promise<UserStateRecord | null> => {
107
+ const findExisting = async (): Promise<UserPreferenceRecord | null> => {
95
108
  const params: QueryParams = {
96
109
  filter: {
97
110
  user_id: userId,
98
- kind,
111
+ key,
99
112
  },
100
113
  limit: 1,
101
114
  };
102
115
  const result = await dataSource.find(resource, params);
103
- const rows = (result?.data ?? []) as UserStateRecord[];
116
+ const rows = (result?.data ?? []) as UserPreferenceRecord[];
104
117
  return rows.length > 0 ? rows[0] : null;
105
118
  };
106
119
 
107
- const decodePayload = (payload: unknown): T[] => {
120
+ const decodeValue = (value: unknown): T[] => {
108
121
  // The server may return the JSON column already parsed, or as a string.
109
- if (Array.isArray(payload)) return payload as T[];
110
- if (typeof payload === 'string') {
122
+ if (Array.isArray(value)) return value as T[];
123
+ if (typeof value === 'string') {
111
124
  try {
112
- const parsed = JSON.parse(payload);
125
+ const parsed = JSON.parse(value);
113
126
  return Array.isArray(parsed) ? (parsed as T[]) : [];
114
127
  } catch {
115
128
  return [];
@@ -127,7 +140,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
127
140
  return [];
128
141
  }
129
142
  if (row.id !== undefined && row.id !== null) cachedRowId = row.id;
130
- return decodePayload(row.payload);
143
+ return decodeValue(row.value);
131
144
  } catch (error) {
132
145
  onError('load', error);
133
146
  return [];
@@ -141,7 +154,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
141
154
  if (cachedRowId !== null) {
142
155
  try {
143
156
  await dataSource.update(resource, cachedRowId, {
144
- payload: items,
157
+ value: items,
145
158
  updated_at: now,
146
159
  });
147
160
  return;
@@ -156,7 +169,7 @@ export function createObjectStackUserStateAdapter<T = unknown>(
156
169
  if (existing && existing.id !== undefined && existing.id !== null) {
157
170
  cachedRowId = existing.id;
158
171
  await dataSource.update(resource, existing.id, {
159
- payload: items,
172
+ value: items,
160
173
  updated_at: now,
161
174
  });
162
175
  return;
@@ -164,11 +177,11 @@ export function createObjectStackUserStateAdapter<T = unknown>(
164
177
 
165
178
  const created = await dataSource.create(resource, {
166
179
  user_id: userId,
167
- kind,
168
- payload: items,
180
+ key,
181
+ value: items,
169
182
  updated_at: now,
170
183
  });
171
- const newId = (created as UserStateRecord | undefined)?.id;
184
+ const newId = (created as UserPreferenceRecord | undefined)?.id;
172
185
  if (newId !== undefined && newId !== null) cachedRowId = newId;
173
186
  } catch (error) {
174
187
  onError('save', error);