@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 +7 -0
- package/README.md +40 -0
- package/dist/index.cjs +92 -0
- package/dist/index.d.cts +67 -1
- package/dist/index.d.ts +67 -1
- package/dist/index.js +91 -0
- package/package.json +3 -3
- package/src/index.ts +7 -0
- package/src/userState.test.ts +241 -0
- package/src/userState.ts +179 -0
package/CHANGELOG.md
CHANGED
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.
|
|
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.
|
|
27
|
-
"@object-ui/types": "4.
|
|
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
|
+
});
|
package/src/userState.ts
ADDED
|
@@ -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
|
+
}
|