@object-ui/data-objectstack 4.5.0 → 4.6.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,12 @@
1
1
  # @object-ui/data-objectstack
2
2
 
3
+ ## 4.6.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @object-ui/types@4.6.0
8
+ - @object-ui/core@4.6.0
9
+
3
10
  ## 4.5.0
4
11
 
5
12
  ### Patch Changes
package/README.md CHANGED
@@ -272,6 +272,46 @@ await dataSource.bulk('users', 'delete', [
272
272
  - Provides partial success reporting for resilient error handling
273
273
  - Atomic operations where supported by the backend
274
274
 
275
+ ## User-Scoped State Adapter
276
+
277
+ In addition to the main `DataSource` adapter, this package ships
278
+ `createObjectStackUserStateAdapter` — a small factory that lets Object UI
279
+ persist per-user UI state (favorites, recent items) into ObjectStack.
280
+
281
+ ```typescript
282
+ import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
283
+ import { useAttachUserStateAdapters } from '@object-ui/app-shell';
284
+
285
+ const favoritesAdapter = createObjectStackUserStateAdapter({
286
+ dataSource, // the ObjectStack DataSource
287
+ userId: user.id,
288
+ kind: 'favorites', // or 'recent'
289
+ // resource: 'user_app_state', // default
290
+ // onError: (op, err) => console.warn(`[user-state] ${op} failed`, err),
291
+ });
292
+
293
+ attach('favorites', favoritesAdapter);
294
+ ```
295
+
296
+ ### Backend contract
297
+
298
+ A single object stores one row per `(user_id, kind)` pair:
299
+
300
+ ```yaml
301
+ object: user_app_state
302
+ fields:
303
+ - { name: user_id, type: string, indexed: true }
304
+ - { name: kind, type: string, indexed: true }
305
+ - { name: payload, type: json }
306
+ - { name: updated_at, type: datetime }
307
+ unique: [user_id, kind]
308
+ ```
309
+
310
+ If the object doesn't exist, every call 404s and the UI silently degrades to
311
+ localStorage-only persistence. See
312
+ [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
313
+ for the full design.
314
+
275
315
  ## API Reference
276
316
 
277
317
  ### ObjectStackAdapter
package/dist/index.cjs CHANGED
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  createDefaultCanvasConfig: () => createDefaultCanvasConfig,
38
38
  createErrorFromResponse: () => createErrorFromResponse,
39
39
  createObjectStackAdapter: () => createObjectStackAdapter,
40
+ createObjectStackUserStateAdapter: () => createObjectStackUserStateAdapter,
40
41
  generateContractManifest: () => generateContractManifest,
41
42
  getSharedDiscovery: () => getSharedDiscovery,
42
43
  is404Error: () => is404Error,
@@ -568,6 +569,96 @@ function generateContractManifest(contract) {
568
569
  };
569
570
  }
570
571
 
572
+ // src/userState.ts
573
+ var DEFAULT_RESOURCE = "user_app_state";
574
+ function createObjectStackUserStateAdapter(options) {
575
+ const {
576
+ dataSource,
577
+ userId,
578
+ kind,
579
+ resource = DEFAULT_RESOURCE,
580
+ onError = () => {
581
+ }
582
+ } = options;
583
+ let cachedRowId = null;
584
+ const findExisting = async () => {
585
+ const params = {
586
+ filter: {
587
+ user_id: userId,
588
+ kind
589
+ },
590
+ limit: 1
591
+ };
592
+ const result = await dataSource.find(resource, params);
593
+ const rows = result?.data ?? [];
594
+ return rows.length > 0 ? rows[0] : null;
595
+ };
596
+ const decodePayload = (payload) => {
597
+ if (Array.isArray(payload)) return payload;
598
+ if (typeof payload === "string") {
599
+ try {
600
+ const parsed = JSON.parse(payload);
601
+ return Array.isArray(parsed) ? parsed : [];
602
+ } catch {
603
+ return [];
604
+ }
605
+ }
606
+ return [];
607
+ };
608
+ return {
609
+ async load() {
610
+ try {
611
+ const row = await findExisting();
612
+ if (!row) {
613
+ cachedRowId = null;
614
+ return [];
615
+ }
616
+ if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
617
+ return decodePayload(row.payload);
618
+ } catch (error) {
619
+ onError("load", error);
620
+ return [];
621
+ }
622
+ },
623
+ async save(items) {
624
+ try {
625
+ const now = (/* @__PURE__ */ new Date()).toISOString();
626
+ if (cachedRowId !== null) {
627
+ try {
628
+ await dataSource.update(resource, cachedRowId, {
629
+ payload: items,
630
+ updated_at: now
631
+ });
632
+ return;
633
+ } catch (updateError) {
634
+ cachedRowId = null;
635
+ onError("save", updateError);
636
+ }
637
+ }
638
+ const existing = await findExisting();
639
+ if (existing && existing.id !== void 0 && existing.id !== null) {
640
+ cachedRowId = existing.id;
641
+ await dataSource.update(resource, existing.id, {
642
+ payload: items,
643
+ updated_at: now
644
+ });
645
+ return;
646
+ }
647
+ const created = await dataSource.create(resource, {
648
+ user_id: userId,
649
+ kind,
650
+ payload: items,
651
+ updated_at: now
652
+ });
653
+ const newId = created?.id;
654
+ if (newId !== void 0 && newId !== null) cachedRowId = newId;
655
+ } catch (error) {
656
+ onError("save", error);
657
+ }
658
+ }
659
+ };
660
+ }
661
+
571
662
  // src/integration.ts
572
663
  var IntegrationManager = class {
573
664
  constructor() {
@@ -2066,6 +2157,7 @@ function createObjectStackAdapter(config) {
2066
2157
  createDefaultCanvasConfig,
2067
2158
  createErrorFromResponse,
2068
2159
  createObjectStackAdapter,
2160
+ createObjectStackUserStateAdapter,
2069
2161
  generateContractManifest,
2070
2162
  getSharedDiscovery,
2071
2163
  is404Error,
package/dist/index.d.cts CHANGED
@@ -315,6 +315,72 @@ declare function validatePluginContract(contract: PluginContract): ContractValid
315
315
  */
316
316
  declare function generateContractManifest(contract: PluginContract): Record<string, unknown>;
317
317
 
318
+ /**
319
+ * ObjectStack-backed `UserDataAdapter` factory.
320
+ *
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`.
326
+ *
327
+ * ## Required object schema
328
+ *
329
+ * Configure an object in ObjectStack with at least the following fields:
330
+ *
331
+ * ```yaml
332
+ * object: user_app_state
333
+ * 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]
339
+ * ```
340
+ *
341
+ * ## Failure modes
342
+ *
343
+ * The adapter is designed to **degrade silently**. Any error — missing schema,
344
+ * 4xx/5xx, network — is caught:
345
+ *
346
+ * - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
347
+ * - `save()` resolves without throwing, so UI mutations never surface a toast.
348
+ *
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
352
+ * "lights up" with no code change.
353
+ *
354
+ * @module
355
+ */
356
+
357
+ interface ObjectStackUserStateAdapterOptions {
358
+ /** Connected data source (usually the one provided by `<AdapterProvider>`). */
359
+ dataSource: DataSource<any>;
360
+ /** Authenticated user id. */
361
+ 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"`. */
365
+ resource?: string;
366
+ /**
367
+ * Optional console logger for development diagnostics. Defaults to noop so
368
+ * production builds stay quiet.
369
+ */
370
+ onError?: (where: 'load' | 'save', error: unknown) => void;
371
+ }
372
+ interface UserDataAdapter<T> {
373
+ load(): Promise<T[]>;
374
+ save(items: T[]): Promise<void>;
375
+ }
376
+ /**
377
+ * Build a `UserDataAdapter<T>` backed by ObjectStack.
378
+ *
379
+ * Each adapter instance is bound to a single `(user, kind)` pair; create one
380
+ * per slot you want to persist.
381
+ */
382
+ declare function createObjectStackUserStateAdapter<T = unknown>(options: ObjectStackUserStateAdapterOptions): UserDataAdapter<T>;
383
+
318
384
  /**
319
385
  * ObjectUI
320
386
  * Copyright (c) 2024-present ObjectStack Inc.
@@ -1057,4 +1123,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
1057
1123
  reconnectDelay?: number;
1058
1124
  }): DataSource<T>;
1059
1125
 
1060
- export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, generateContractManifest, getSharedDiscovery, is404Error, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
1126
+ export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
package/dist/index.d.ts CHANGED
@@ -315,6 +315,72 @@ declare function validatePluginContract(contract: PluginContract): ContractValid
315
315
  */
316
316
  declare function generateContractManifest(contract: PluginContract): Record<string, unknown>;
317
317
 
318
+ /**
319
+ * ObjectStack-backed `UserDataAdapter` factory.
320
+ *
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`.
326
+ *
327
+ * ## Required object schema
328
+ *
329
+ * Configure an object in ObjectStack with at least the following fields:
330
+ *
331
+ * ```yaml
332
+ * object: user_app_state
333
+ * 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]
339
+ * ```
340
+ *
341
+ * ## Failure modes
342
+ *
343
+ * The adapter is designed to **degrade silently**. Any error — missing schema,
344
+ * 4xx/5xx, network — is caught:
345
+ *
346
+ * - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
347
+ * - `save()` resolves without throwing, so UI mutations never surface a toast.
348
+ *
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
352
+ * "lights up" with no code change.
353
+ *
354
+ * @module
355
+ */
356
+
357
+ interface ObjectStackUserStateAdapterOptions {
358
+ /** Connected data source (usually the one provided by `<AdapterProvider>`). */
359
+ dataSource: DataSource<any>;
360
+ /** Authenticated user id. */
361
+ 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"`. */
365
+ resource?: string;
366
+ /**
367
+ * Optional console logger for development diagnostics. Defaults to noop so
368
+ * production builds stay quiet.
369
+ */
370
+ onError?: (where: 'load' | 'save', error: unknown) => void;
371
+ }
372
+ interface UserDataAdapter<T> {
373
+ load(): Promise<T[]>;
374
+ save(items: T[]): Promise<void>;
375
+ }
376
+ /**
377
+ * Build a `UserDataAdapter<T>` backed by ObjectStack.
378
+ *
379
+ * Each adapter instance is bound to a single `(user, kind)` pair; create one
380
+ * per slot you want to persist.
381
+ */
382
+ declare function createObjectStackUserStateAdapter<T = unknown>(options: ObjectStackUserStateAdapterOptions): UserDataAdapter<T>;
383
+
318
384
  /**
319
385
  * ObjectUI
320
386
  * Copyright (c) 2024-present ObjectStack Inc.
@@ -1057,4 +1123,4 @@ declare function createObjectStackAdapter<T = unknown>(config: {
1057
1123
  reconnectDelay?: number;
1058
1124
  }): DataSource<T>;
1059
1125
 
1060
- export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, generateContractManifest, getSharedDiscovery, is404Error, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
1126
+ export { type AuditEventType, type AuditLogConfig, type AuditLogEntry, AuthenticationError, type BatchProgressEvent, type BatchProgressListener, BulkOperationError, type CSPConfig, type CacheStats, type CloudDeploymentConfig, type CloudHostingConfig, type CloudMarketplaceEntry, CloudOperations, ConnectionError, type ConnectionState, type ConnectionStateEvent, type ConnectionStateListener, type ContractValidationError, type ContractValidationResult, type DataMaskingConfig, type DataMaskingRule, type EmailIntegrationConfig, type IntegrationConfig, IntegrationManager, type IntegrationProvider, type IntegrationTrigger, MetadataNotFoundError, ObjectStackAdapter, ObjectStackError, type ObjectStackUserStateAdapterOptions, type PluginAPIContract, type PluginContract, type PluginExport, SecurityManager, type SecurityPolicy, type SlackIntegrationConfig, type StudioCanvasConfig, type StudioColorPalette, type StudioPropertyEditor, type StudioShadowPreset, type StudioThemeBuilderConfig, type StudioTypographyPreset, type UserDataAdapter, ValidationError, type WebhookIntegrationConfig, calculateAutoLayout, clearSharedDiscoveryCache, createDefaultCanvasConfig, createErrorFromResponse, createObjectStackAdapter, createObjectStackUserStateAdapter, generateContractManifest, getSharedDiscovery, is404Error, isErrorType, isObjectStackError, snapToGrid, validatePluginContract };
package/dist/index.js CHANGED
@@ -525,6 +525,96 @@ function generateContractManifest(contract) {
525
525
  };
526
526
  }
527
527
 
528
+ // src/userState.ts
529
+ var DEFAULT_RESOURCE = "user_app_state";
530
+ function createObjectStackUserStateAdapter(options) {
531
+ const {
532
+ dataSource,
533
+ userId,
534
+ kind,
535
+ resource = DEFAULT_RESOURCE,
536
+ onError = () => {
537
+ }
538
+ } = options;
539
+ let cachedRowId = null;
540
+ const findExisting = async () => {
541
+ const params = {
542
+ filter: {
543
+ user_id: userId,
544
+ kind
545
+ },
546
+ limit: 1
547
+ };
548
+ const result = await dataSource.find(resource, params);
549
+ const rows = result?.data ?? [];
550
+ return rows.length > 0 ? rows[0] : null;
551
+ };
552
+ const decodePayload = (payload) => {
553
+ if (Array.isArray(payload)) return payload;
554
+ if (typeof payload === "string") {
555
+ try {
556
+ const parsed = JSON.parse(payload);
557
+ return Array.isArray(parsed) ? parsed : [];
558
+ } catch {
559
+ return [];
560
+ }
561
+ }
562
+ return [];
563
+ };
564
+ return {
565
+ async load() {
566
+ try {
567
+ const row = await findExisting();
568
+ if (!row) {
569
+ cachedRowId = null;
570
+ return [];
571
+ }
572
+ if (row.id !== void 0 && row.id !== null) cachedRowId = row.id;
573
+ return decodePayload(row.payload);
574
+ } catch (error) {
575
+ onError("load", error);
576
+ return [];
577
+ }
578
+ },
579
+ async save(items) {
580
+ try {
581
+ const now = (/* @__PURE__ */ new Date()).toISOString();
582
+ if (cachedRowId !== null) {
583
+ try {
584
+ await dataSource.update(resource, cachedRowId, {
585
+ payload: items,
586
+ updated_at: now
587
+ });
588
+ return;
589
+ } catch (updateError) {
590
+ cachedRowId = null;
591
+ onError("save", updateError);
592
+ }
593
+ }
594
+ const existing = await findExisting();
595
+ if (existing && existing.id !== void 0 && existing.id !== null) {
596
+ cachedRowId = existing.id;
597
+ await dataSource.update(resource, existing.id, {
598
+ payload: items,
599
+ updated_at: now
600
+ });
601
+ return;
602
+ }
603
+ const created = await dataSource.create(resource, {
604
+ user_id: userId,
605
+ kind,
606
+ payload: items,
607
+ updated_at: now
608
+ });
609
+ const newId = created?.id;
610
+ if (newId !== void 0 && newId !== null) cachedRowId = newId;
611
+ } catch (error) {
612
+ onError("save", error);
613
+ }
614
+ }
615
+ };
616
+ }
617
+
528
618
  // src/integration.ts
529
619
  var IntegrationManager = class {
530
620
  constructor() {
@@ -2022,6 +2112,7 @@ export {
2022
2112
  createDefaultCanvasConfig,
2023
2113
  createErrorFromResponse,
2024
2114
  createObjectStackAdapter,
2115
+ createObjectStackUserStateAdapter,
2025
2116
  generateContractManifest,
2026
2117
  getSharedDiscovery,
2027
2118
  is404Error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/data-objectstack",
3
- "version": "4.5.0",
3
+ "version": "4.6.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.5.0",
27
- "@object-ui/types": "4.5.0"
26
+ "@object-ui/core": "4.6.0",
27
+ "@object-ui/types": "4.6.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "tsup": "^8.5.1",
package/src/index.ts CHANGED
@@ -1790,6 +1790,13 @@ export { CloudOperations } from './cloud';
1790
1790
  export type { CloudDeploymentConfig, CloudHostingConfig, CloudMarketplaceEntry } from './cloud';
1791
1791
 
1792
1792
  export { validatePluginContract, generateContractManifest } from './contracts';
1793
+
1794
+ // User-scoped persistence adapter (favorites / recent items / …)
1795
+ export { createObjectStackUserStateAdapter } from './userState';
1796
+ export type {
1797
+ ObjectStackUserStateAdapterOptions,
1798
+ UserDataAdapter,
1799
+ } from './userState';
1793
1800
  export type { PluginContract, PluginExport, PluginAPIContract, ContractValidationResult, ContractValidationError } from './contracts';
1794
1801
 
1795
1802
  export { IntegrationManager } from './integration';
@@ -0,0 +1,241 @@
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, kind) pair', async () => {
31
+ const ds = mockDataSource();
32
+ const adapter = createObjectStackUserStateAdapter({
33
+ dataSource: ds,
34
+ userId: 'u1',
35
+ kind: 'favorites',
36
+ });
37
+
38
+ const items = await adapter.load();
39
+
40
+ expect(items).toEqual([]);
41
+ expect(ds.find).toHaveBeenCalledWith('user_app_state', {
42
+ filter: { user_id: 'u1', kind: 'favorites' },
43
+ limit: 1,
44
+ });
45
+ });
46
+
47
+ it('returns parsed payload when row exists (array payload)', async () => {
48
+ const payload = [{ id: 'object:contact', label: 'Contact' }];
49
+ const ds = mockDataSource({
50
+ find: vi.fn().mockResolvedValue({
51
+ data: [{ id: 'row-1', user_id: 'u1', kind: 'recent', payload }],
52
+ }) as any,
53
+ });
54
+
55
+ const adapter = createObjectStackUserStateAdapter({
56
+ dataSource: ds,
57
+ userId: 'u1',
58
+ kind: 'recent',
59
+ });
60
+
61
+ await expect(adapter.load()).resolves.toEqual(payload);
62
+ });
63
+
64
+ it('parses string-encoded JSON payloads', async () => {
65
+ const items = [{ id: 'a' }, { id: 'b' }];
66
+ const ds = mockDataSource({
67
+ find: vi.fn().mockResolvedValue({
68
+ data: [{ id: 1, payload: JSON.stringify(items) }],
69
+ }) as any,
70
+ });
71
+
72
+ const adapter = createObjectStackUserStateAdapter({
73
+ dataSource: ds,
74
+ userId: 'u',
75
+ kind: 'k',
76
+ });
77
+
78
+ await expect(adapter.load()).resolves.toEqual(items);
79
+ });
80
+
81
+ it('returns [] when payload is malformed JSON', async () => {
82
+ const ds = mockDataSource({
83
+ find: vi.fn().mockResolvedValue({
84
+ data: [{ id: 1, payload: '{not json' }],
85
+ }) as any,
86
+ });
87
+ const adapter = createObjectStackUserStateAdapter({
88
+ dataSource: ds,
89
+ userId: 'u',
90
+ kind: '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
+ kind: '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
+ kind: '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
+ kind: 'favorites',
139
+ });
140
+
141
+ await adapter.save([{ id: 'a' } as any]);
142
+
143
+ expect(ds.create).toHaveBeenCalledWith('user_app_state', {
144
+ user_id: 'u1',
145
+ kind: 'favorites',
146
+ payload: [{ id: 'a' }],
147
+ updated_at: expect.any(String),
148
+ });
149
+ });
150
+
151
+ it('updates the existing row when one exists', async () => {
152
+ const ds = mockDataSource({
153
+ find: vi.fn().mockResolvedValue({
154
+ data: [{ id: 'row-99', user_id: 'u', kind: 'k', payload: [] }],
155
+ }) as any,
156
+ });
157
+
158
+ const adapter = createObjectStackUserStateAdapter({
159
+ dataSource: ds,
160
+ userId: 'u',
161
+ kind: 'k',
162
+ });
163
+
164
+ await adapter.save([{ id: 'x' } as any]);
165
+
166
+ expect(ds.update).toHaveBeenCalledWith('user_app_state', 'row-99', {
167
+ payload: [{ id: 'x' }],
168
+ updated_at: expect.any(String),
169
+ });
170
+ expect(ds.create).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it('uses the cached row id from a previous load on subsequent saves', async () => {
174
+ const ds = mockDataSource({
175
+ find: vi.fn().mockResolvedValue({
176
+ data: [{ id: 'row-42', payload: [] }],
177
+ }) as any,
178
+ });
179
+
180
+ const adapter = createObjectStackUserStateAdapter({
181
+ dataSource: ds,
182
+ userId: 'u',
183
+ kind: 'k',
184
+ });
185
+
186
+ await adapter.load();
187
+ (ds.find as any).mockClear();
188
+
189
+ await adapter.save([{ id: 'b' } as any]);
190
+
191
+ // No second find() — went straight to update via cached id.
192
+ expect(ds.find).not.toHaveBeenCalled();
193
+ expect(ds.update).toHaveBeenCalledWith('user_app_state', 'row-42', expect.any(Object));
194
+ });
195
+
196
+ it('falls back to insert when cached row update fails (row deleted server-side)', async () => {
197
+ const ds = mockDataSource({
198
+ find: vi.fn()
199
+ .mockResolvedValueOnce({ data: [{ id: 'stale', payload: [] }] })
200
+ .mockResolvedValueOnce({ data: [] }) as any,
201
+ update: vi.fn().mockRejectedValue(new Error('not found')) as any,
202
+ create: vi.fn().mockResolvedValue({ id: 'new' }) as any,
203
+ });
204
+ const onError = vi.fn();
205
+
206
+ const adapter = createObjectStackUserStateAdapter({
207
+ dataSource: ds,
208
+ userId: 'u',
209
+ kind: 'k',
210
+ onError,
211
+ });
212
+
213
+ await adapter.load(); // primes cachedRowId = 'stale'
214
+ await adapter.save([{ id: 'z' } as any]);
215
+
216
+ expect(ds.update).toHaveBeenCalledTimes(1);
217
+ expect(ds.create).toHaveBeenCalledWith('user_app_state', expect.objectContaining({
218
+ user_id: 'u',
219
+ kind: 'k',
220
+ payload: [{ id: 'z' }],
221
+ }));
222
+ });
223
+
224
+ it('swallows errors when save() throws', async () => {
225
+ const onError = vi.fn();
226
+ const ds = mockDataSource({
227
+ find: vi.fn().mockRejectedValue(new Error('network down')) as any,
228
+ });
229
+
230
+ const adapter = createObjectStackUserStateAdapter({
231
+ dataSource: ds,
232
+ userId: 'u',
233
+ kind: 'k',
234
+ onError,
235
+ });
236
+
237
+ await expect(adapter.save([{ id: 'a' } as any])).resolves.toBeUndefined();
238
+ expect(onError).toHaveBeenCalledWith('save', expect.any(Error));
239
+ });
240
+ });
241
+ });
@@ -0,0 +1,179 @@
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, 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`.
9
+ *
10
+ * ## Required object schema
11
+ *
12
+ * Configure an object in ObjectStack with at least the following fields:
13
+ *
14
+ * ```yaml
15
+ * object: user_app_state
16
+ * 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]
22
+ * ```
23
+ *
24
+ * ## Failure modes
25
+ *
26
+ * The adapter is designed to **degrade silently**. Any error — missing schema,
27
+ * 4xx/5xx, network — is caught:
28
+ *
29
+ * - `load()` returns `[]`, so the hosting provider keeps its localStorage state.
30
+ * - `save()` resolves without throwing, so UI mutations never surface a toast.
31
+ *
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
35
+ * "lights up" with no code change.
36
+ *
37
+ * @module
38
+ */
39
+
40
+ import type { DataSource, QueryParams } from '@object-ui/types';
41
+
42
+ export interface ObjectStackUserStateAdapterOptions {
43
+ /** Connected data source (usually the one provided by `<AdapterProvider>`). */
44
+ dataSource: DataSource<any>;
45
+ /** Authenticated user id. */
46
+ 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"`. */
50
+ resource?: string;
51
+ /**
52
+ * Optional console logger for development diagnostics. Defaults to noop so
53
+ * production builds stay quiet.
54
+ */
55
+ onError?: (where: 'load' | 'save', error: unknown) => void;
56
+ }
57
+
58
+ export interface UserDataAdapter<T> {
59
+ load(): Promise<T[]>;
60
+ save(items: T[]): Promise<void>;
61
+ }
62
+
63
+ interface UserStateRecord {
64
+ id?: string | number;
65
+ user_id: string;
66
+ kind: string;
67
+ payload: unknown;
68
+ updated_at?: string;
69
+ }
70
+
71
+ const DEFAULT_RESOURCE = 'user_app_state';
72
+
73
+ /**
74
+ * Build a `UserDataAdapter<T>` backed by ObjectStack.
75
+ *
76
+ * Each adapter instance is bound to a single `(user, kind)` pair; create one
77
+ * per slot you want to persist.
78
+ */
79
+ export function createObjectStackUserStateAdapter<T = unknown>(
80
+ options: ObjectStackUserStateAdapterOptions,
81
+ ): UserDataAdapter<T> {
82
+ const {
83
+ dataSource,
84
+ userId,
85
+ kind,
86
+ resource = DEFAULT_RESOURCE,
87
+ onError = () => {},
88
+ } = options;
89
+
90
+ // Cache the row id between load() and save() so we can update in place
91
+ // without re-querying. Reset on every successful load.
92
+ let cachedRowId: string | number | null = null;
93
+
94
+ const findExisting = async (): Promise<UserStateRecord | null> => {
95
+ const params: QueryParams = {
96
+ filter: {
97
+ user_id: userId,
98
+ kind,
99
+ },
100
+ limit: 1,
101
+ };
102
+ const result = await dataSource.find(resource, params);
103
+ const rows = (result?.data ?? []) as UserStateRecord[];
104
+ return rows.length > 0 ? rows[0] : null;
105
+ };
106
+
107
+ const decodePayload = (payload: unknown): T[] => {
108
+ // 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') {
111
+ try {
112
+ const parsed = JSON.parse(payload);
113
+ return Array.isArray(parsed) ? (parsed as T[]) : [];
114
+ } catch {
115
+ return [];
116
+ }
117
+ }
118
+ return [];
119
+ };
120
+
121
+ return {
122
+ async load(): Promise<T[]> {
123
+ try {
124
+ const row = await findExisting();
125
+ if (!row) {
126
+ cachedRowId = null;
127
+ return [];
128
+ }
129
+ if (row.id !== undefined && row.id !== null) cachedRowId = row.id;
130
+ return decodePayload(row.payload);
131
+ } catch (error) {
132
+ onError('load', error);
133
+ return [];
134
+ }
135
+ },
136
+
137
+ async save(items: T[]): Promise<void> {
138
+ try {
139
+ const now = new Date().toISOString();
140
+ // Fast path: we already know the row id from a previous load/save.
141
+ if (cachedRowId !== null) {
142
+ try {
143
+ await dataSource.update(resource, cachedRowId, {
144
+ payload: items,
145
+ updated_at: now,
146
+ });
147
+ return;
148
+ } catch (updateError) {
149
+ // Row may have been deleted server-side — fall through to insert.
150
+ cachedRowId = null;
151
+ onError('save', updateError);
152
+ }
153
+ }
154
+
155
+ const existing = await findExisting();
156
+ if (existing && existing.id !== undefined && existing.id !== null) {
157
+ cachedRowId = existing.id;
158
+ await dataSource.update(resource, existing.id, {
159
+ payload: items,
160
+ updated_at: now,
161
+ });
162
+ return;
163
+ }
164
+
165
+ const created = await dataSource.create(resource, {
166
+ user_id: userId,
167
+ kind,
168
+ payload: items,
169
+ updated_at: now,
170
+ });
171
+ const newId = (created as UserStateRecord | undefined)?.id;
172
+ if (newId !== undefined && newId !== null) cachedRowId = newId;
173
+ } catch (error) {
174
+ onError('save', error);
175
+ // Swallow — provider falls back to localStorage as source of truth.
176
+ }
177
+ },
178
+ };
179
+ }