@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 +35 -0
- package/README.md +17 -11
- package/dist/index.cjs +12 -12
- package/dist/index.d.cts +35 -22
- package/dist/index.d.ts +35 -22
- package/dist/index.js +12 -12
- package/package.json +3 -3
- package/src/userState.test.ts +34 -34
- package/src/userState.ts +53 -40
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,
|
|
286
|
+
dataSource, // the ObjectStack DataSource
|
|
287
287
|
userId: user.id,
|
|
288
|
-
|
|
289
|
-
// resource: '
|
|
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
|
-
|
|
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:
|
|
303
|
+
object: sys_user_preference
|
|
302
304
|
fields:
|
|
303
|
-
- { name: user_id, type:
|
|
304
|
-
- { name:
|
|
305
|
-
- { name:
|
|
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,
|
|
309
|
+
unique: [user_id, key]
|
|
308
310
|
```
|
|
309
311
|
|
|
310
|
-
|
|
311
|
-
|
|
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 = "
|
|
573
|
+
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
574
574
|
function createObjectStackUserStateAdapter(options) {
|
|
575
575
|
const {
|
|
576
576
|
dataSource,
|
|
577
577
|
userId,
|
|
578
|
-
|
|
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
|
-
|
|
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
|
|
597
|
-
if (Array.isArray(
|
|
598
|
-
if (typeof
|
|
596
|
+
const decodeValue = (value) => {
|
|
597
|
+
if (Array.isArray(value)) return value;
|
|
598
|
+
if (typeof value === "string") {
|
|
599
599
|
try {
|
|
600
|
-
const parsed = JSON.parse(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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,
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
343
|
+
* object: sys_user_preference
|
|
333
344
|
* fields:
|
|
334
|
-
* user_id:
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* updated_at: datetime
|
|
338
|
-
* unique: [user_id,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
363
|
-
|
|
364
|
-
|
|
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,
|
|
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,
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
343
|
+
* object: sys_user_preference
|
|
333
344
|
* fields:
|
|
334
|
-
* user_id:
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* updated_at: datetime
|
|
338
|
-
* unique: [user_id,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
363
|
-
|
|
364
|
-
|
|
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,
|
|
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 = "
|
|
529
|
+
var DEFAULT_RESOURCE = "sys_user_preference";
|
|
530
530
|
function createObjectStackUserStateAdapter(options) {
|
|
531
531
|
const {
|
|
532
532
|
dataSource,
|
|
533
533
|
userId,
|
|
534
|
-
|
|
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
|
-
|
|
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
|
|
553
|
-
if (Array.isArray(
|
|
554
|
-
if (typeof
|
|
552
|
+
const decodeValue = (value) => {
|
|
553
|
+
if (Array.isArray(value)) return value;
|
|
554
|
+
if (typeof value === "string") {
|
|
555
555
|
try {
|
|
556
|
-
const parsed = JSON.parse(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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": "
|
|
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": "
|
|
27
|
-
"@object-ui/types": "
|
|
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",
|
package/src/userState.test.ts
CHANGED
|
@@ -27,67 +27,67 @@ describe('createObjectStackUserStateAdapter', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
describe('load', () => {
|
|
30
|
-
it('returns [] when no row exists for the (user,
|
|
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
|
-
|
|
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('
|
|
42
|
-
filter: { user_id: 'u1',
|
|
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
|
|
48
|
-
const
|
|
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',
|
|
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
|
-
|
|
58
|
+
key: 'ui.recent',
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
await expect(adapter.load()).resolves.toEqual(
|
|
61
|
+
await expect(adapter.load()).resolves.toEqual(value);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it('parses string-encoded JSON
|
|
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,
|
|
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
|
-
|
|
75
|
+
key: 'k',
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
await expect(adapter.load()).resolves.toEqual(items);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
it('returns [] when
|
|
81
|
+
it('returns [] when value is malformed JSON', async () => {
|
|
82
82
|
const ds = mockDataSource({
|
|
83
83
|
find: vi.fn().mockResolvedValue({
|
|
84
|
-
data: [{ id: 1,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
key: 'ui.favorites',
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
await adapter.save([{ id: 'a' } as any]);
|
|
142
142
|
|
|
143
|
-
expect(ds.create).toHaveBeenCalledWith('
|
|
143
|
+
expect(ds.create).toHaveBeenCalledWith('sys_user_preference', {
|
|
144
144
|
user_id: 'u1',
|
|
145
|
-
|
|
146
|
-
|
|
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',
|
|
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
|
-
|
|
161
|
+
key: 'k',
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
await adapter.save([{ id: 'x' } as any]);
|
|
165
165
|
|
|
166
|
-
expect(ds.update).toHaveBeenCalledWith('
|
|
167
|
-
|
|
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',
|
|
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
|
-
|
|
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('
|
|
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',
|
|
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
|
-
|
|
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('
|
|
217
|
+
expect(ds.create).toHaveBeenCalledWith('sys_user_preference', expect.objectContaining({
|
|
218
218
|
user_id: 'u',
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
26
|
+
* object: sys_user_preference
|
|
16
27
|
* fields:
|
|
17
|
-
* user_id:
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* updated_at: datetime
|
|
21
|
-
* unique: [user_id,
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
76
|
+
interface UserPreferenceRecord {
|
|
64
77
|
id?: string | number;
|
|
65
78
|
user_id: string;
|
|
66
|
-
|
|
67
|
-
|
|
79
|
+
key: string;
|
|
80
|
+
value: unknown;
|
|
68
81
|
updated_at?: string;
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
const DEFAULT_RESOURCE = '
|
|
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,
|
|
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
|
-
|
|
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<
|
|
107
|
+
const findExisting = async (): Promise<UserPreferenceRecord | null> => {
|
|
95
108
|
const params: QueryParams = {
|
|
96
109
|
filter: {
|
|
97
110
|
user_id: userId,
|
|
98
|
-
|
|
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
|
|
116
|
+
const rows = (result?.data ?? []) as UserPreferenceRecord[];
|
|
104
117
|
return rows.length > 0 ? rows[0] : null;
|
|
105
118
|
};
|
|
106
119
|
|
|
107
|
-
const
|
|
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(
|
|
110
|
-
if (typeof
|
|
122
|
+
if (Array.isArray(value)) return value as T[];
|
|
123
|
+
if (typeof value === 'string') {
|
|
111
124
|
try {
|
|
112
|
-
const parsed = JSON.parse(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
180
|
+
key,
|
|
181
|
+
value: items,
|
|
169
182
|
updated_at: now,
|
|
170
183
|
});
|
|
171
|
-
const newId = (created as
|
|
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);
|