@wells-nelson/data-storage 1.0.2 → 1.1.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/README.md CHANGED
@@ -14,6 +14,7 @@ Peer dependencies: `svelte`, `rxdb`, `rxjs`, `@tanstack/db`, `@tanstack/rxdb-db-
14
14
  import { createSyncedStore, envConfig, field, flatSchema } from '@wells-nelson/data-storage';
15
15
  import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
16
16
 
17
+ // Docs maps collection names to document types, as a `type` or an `interface`.
17
18
  // Pass the container collection as the second type argument so its rows are typed precisely.
18
19
  export const store = createSyncedStore<Docs, 'projects'>({
19
20
  appKey: 'demo', // demo_local, demo_cloud_<user>, demo:hidden, demo:sync:<user>, demo:defaultProject…
@@ -51,7 +52,9 @@ onMount(() => void store.init({ beforeReady: importDevSeed }));
51
52
  show it until dismissed) and `persisted` (whether the browser keeps the data for good).
52
53
  - `cloud`: `CloudSync`: session (Google, email link), replication, `pendingIds`, `hasPending`,
53
54
  `lastSync`, `online`, `active`, `error`, `rejectedWrite`, `roleOf`, `revokedIds`,
54
- `forceResync`, `signOut`.
55
+ `forceResync`, `signOut`. Each collection replicates on its own; a container created in or moved to
56
+ the cloud (`createContainer`, `makeCloud`) holds its other rows back until its own row has reached
57
+ Supabase (`expectNewContainer`, at most 20 s), so row level security doesn't refuse them.
55
58
  - `ops`: container operations (`createContainer`, `renameContainer`, `makeCloud`, `makeLocal`,
56
59
  `deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`,
57
60
  `exportContainers`, `importContainers`) and the row
@@ -4,7 +4,7 @@ import type { DocMap, Location } from './types.js';
4
4
  * Holds the two data sets: `local` (this device only, never synced) is always open;
5
5
  * `cloud` exists only while signed in and replicates to Supabase.
6
6
  */
7
- export declare class AppState<D extends DocMap> {
7
+ export declare class AppState<D extends DocMap<D>> {
8
8
  local: DataSet<D> | null;
9
9
  cloud: DataSet<D> | null;
10
10
  ready: boolean;
@@ -10,7 +10,7 @@ export declare function isRejection(err: unknown): boolean;
10
10
  * doesn't expose is reported as the setting to change.
11
11
  */
12
12
  export declare function errorMessage(err: unknown, schema?: string): string;
13
- export interface CloudSyncOptions<D extends DocMap> {
13
+ export interface CloudSyncOptions<D extends DocMap<D>> {
14
14
  /** Prefix for database names and localStorage keys, e.g. 'demo'. */
15
15
  appKey: string;
16
16
  schema: StoreSchema<D>;
@@ -25,7 +25,7 @@ export interface CloudSyncOptions<D extends DocMap> {
25
25
  * pending changes per container (cleared once replication settles), last sync time, blocked
26
26
  * containers, membership / revocation checks, cached roles and rejected writes.
27
27
  */
28
- export declare class CloudSync<D extends DocMap> {
28
+ export declare class CloudSync<D extends DocMap<D>> {
29
29
  #private;
30
30
  readonly configured: boolean;
31
31
  user: User | null;
@@ -56,6 +56,14 @@ export declare class CloudSync<D extends DocMap> {
56
56
  /** Record our role right away (e.g. after creating a container or changing roles). */
57
57
  setRole(containerId: string, role: Role): void;
58
58
  init(): Promise<void>;
59
+ /**
60
+ * Hold back a new cloud container's other rows until the container's own row has reached
61
+ * Supabase. Each collection replicates on its own, and the server only accepts a child row once
62
+ * its container exists (row level security checks the membership the container insert
63
+ * creates); without this, a container copied to the cloud with its rows has some of them
64
+ * rejected until the next retry.
65
+ */
66
+ expectNewContainer(containerId: string): void;
59
67
  /** Record that a container has local changes that still need to reach Supabase. */
60
68
  markPending(containerId: string): void;
61
69
  clearPending(containerId: string): void;
@@ -7,6 +7,8 @@ import { replicationClient, schemaNotExposed } from './supabase.js';
7
7
  import { containerIdOf } from './types.js';
8
8
  const emptyState = () => ({ pending: {}, blocked: [], revoked: [], roles: {}, lastSync: null });
9
9
  const MEMBERSHIP_CHECK_INTERVAL = 30_000;
10
+ /** Longest a new container's other rows wait for the container row to be pushed. */
11
+ const CONTAINER_WAIT_MS = 20_000;
10
12
  /** Push errors caused by the server refusing the write (RLS, triggers), not by connectivity. */
11
13
  export function isRejection(err) {
12
14
  const e = err;
@@ -65,6 +67,9 @@ export class CloudSync {
65
67
  #signingOut = false;
66
68
  #queue = Promise.resolve();
67
69
  #attached = null;
70
+ /** New cloud containers whose own row hasn't reached Supabase yet (see `expectNewContainer`). */
71
+ #unsent = new Map();
72
+ #unsentWaits = new Map();
68
73
  constructor(options) {
69
74
  this.#appKey = options.appKey;
70
75
  this.#schema = options.schema;
@@ -185,10 +190,40 @@ export class CloudSync {
185
190
  await this.#startReplication(db);
186
191
  this.#scheduleSettle();
187
192
  }
193
+ /**
194
+ * Hold back a new cloud container's other rows until the container's own row has reached
195
+ * Supabase. Each collection replicates on its own, and the server only accepts a child row once
196
+ * its container exists (row level security checks the membership the container insert
197
+ * creates); without this, a container copied to the cloud with its rows has some of them
198
+ * rejected until the next retry.
199
+ */
200
+ expectNewContainer(containerId) {
201
+ if (this.#unsent.has(containerId))
202
+ return;
203
+ let release;
204
+ const sent = new Promise((resolve) => (release = resolve));
205
+ this.#unsent.set(containerId, release);
206
+ this.#unsentWaits.set(containerId, sent);
207
+ }
208
+ #containerSent(containerId) {
209
+ this.#unsent.get(containerId)?.();
210
+ this.#unsent.delete(containerId);
211
+ this.#unsentWaits.delete(containerId);
212
+ }
213
+ /** Waits while the container's own row is still on its way (bounded: if it's refused, the server decides). */
214
+ async #afterContainer(containerId) {
215
+ const sent = this.#unsentWaits.get(containerId);
216
+ if (!sent)
217
+ return;
218
+ let timer;
219
+ await Promise.race([sent, new Promise((r) => (timer = setTimeout(r, CONTAINER_WAIT_MS)))]);
220
+ clearTimeout(timer);
221
+ }
188
222
  async #startReplication(db) {
189
223
  const { replicateSupabase } = await import('rxdb/plugins/replication-supabase');
190
224
  const sb = replicationClient(this.#sb.getClient(), this.#sb.schema);
191
225
  this.#schema.collections.forEach((name, index) => {
226
+ const isContainer = name === this.#schema.container;
192
227
  const rep = replicateSupabase({
193
228
  replicationIdentifier: `supabase-${name}`,
194
229
  client: sb,
@@ -201,7 +236,14 @@ export class CloudSync {
201
236
  pull: { batchSize: 200 },
202
237
  push: {
203
238
  batchSize: 50,
204
- modifier: (doc) => this.#state.value.blocked.includes(containerIdOf(this.#schema, name, doc)) ? null : doc
239
+ modifier: async (doc) => {
240
+ const containerId = containerIdOf(this.#schema, name, doc);
241
+ if (this.#state.value.blocked.includes(containerId))
242
+ return null;
243
+ if (!isContainer)
244
+ await this.#afterContainer(containerId);
245
+ return doc;
246
+ }
205
247
  }
206
248
  });
207
249
  this.#replications.push(rep);
@@ -218,7 +260,11 @@ export class CloudSync {
218
260
  else
219
261
  this.#activeReps.delete(index);
220
262
  this.active = this.#activeReps.size > 0;
221
- }), rep.received$.subscribe(() => this.#scheduleSettle()), rep.sent$.subscribe(() => this.#scheduleSettle()));
263
+ }), rep.received$.subscribe(() => this.#scheduleSettle()), rep.sent$.subscribe((doc) => {
264
+ if (isContainer)
265
+ this.#containerSent(doc.id);
266
+ this.#scheduleSettle();
267
+ }));
222
268
  });
223
269
  }
224
270
  async #teardown(removeData) {
@@ -230,6 +276,8 @@ export class CloudSync {
230
276
  this.#replications = [];
231
277
  this.#activeReps.clear();
232
278
  this.active = false;
279
+ for (const id of [...this.#unsent.keys()])
280
+ this.#containerSent(id);
233
281
  await Promise.allSettled(reps.map((r) => r.cancel()));
234
282
  const ds = this.#app.cloud;
235
283
  this.#app.cloud = null;
@@ -7,7 +7,7 @@ import type { SupabaseApi } from './supabase.js';
7
7
  import type { Tombstones } from './tombstones.svelte.js';
8
8
  import type { CollectionName, DocMap, Location, Role, StoreSchema } from './types.js';
9
9
  /** Every row of one container, by collection. */
10
- export type ContainerDocs<D extends DocMap> = {
10
+ export type ContainerDocs<D extends DocMap<D>> = {
11
11
  [N in CollectionName<D>]: D[N][];
12
12
  };
13
13
  /**
@@ -15,8 +15,8 @@ export type ContainerDocs<D extends DocMap> = {
15
15
  * `refs` fields: composite ids and ids inside text fields. `map` turns an old id into the copy's
16
16
  * id (the identity when ids are kept).
17
17
  */
18
- export type RemapHook<D extends DocMap> = <N extends CollectionName<D>>(collection: N, row: D[N], map: (id: string) => string) => D[N];
19
- export interface ContainerOpsOptions<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> {
18
+ export type RemapHook<D extends DocMap<D>> = <N extends CollectionName<D>>(collection: N, row: D[N], map: (id: string) => string) => D[N];
19
+ export interface ContainerOpsOptions<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> {
20
20
  schema: StoreSchema<D, C>;
21
21
  app: AppState<D>;
22
22
  cloud: CloudSync<D>;
@@ -57,12 +57,12 @@ export interface ContainerExport {
57
57
  }
58
58
  export declare const now: () => number;
59
59
  export declare const uid: () => `${string}-${string}-${string}-${string}-${string}`;
60
- export type ContainerOps<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createContainerOps<D, C>>;
60
+ export type ContainerOps<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createContainerOps<D, C>>;
61
61
  /**
62
62
  * Operations on whole containers and the low-level row helpers they're built from. Every write
63
63
  * to a cloud data set marks the container pending so the profile can show unsynced changes.
64
64
  */
65
- export declare function createContainerOps<D extends DocMap, C extends CollectionName<D> = CollectionName<D>>(options: ContainerOpsOptions<D, C>): {
65
+ export declare function createContainerOps<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>>(options: ContainerOpsOptions<D, C>): {
66
66
  rx: <N extends CollectionName<D>>(ds: DataSet<D>, name: N) => RxCollection<D[N]>;
67
67
  touch: (ds: DataSet<D>, containerId: string) => void;
68
68
  removeDocs: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, ids: string[]) => Promise<void>;
@@ -67,9 +67,11 @@ export function createContainerOps(options) {
67
67
  const t = now();
68
68
  const hasCreatedAt = 'created_at' in (schema.schemas[container].properties ?? {});
69
69
  const doc = { ...fields, id, updated_at: t, ...(hasCreatedAt ? { created_at: t } : {}) };
70
- insertDoc(ds, container, doc, id);
71
- if (ds.location === 'cloud')
70
+ if (ds.location === 'cloud') {
72
71
  cloud.setRole(id, 'owner');
72
+ cloud.expectNewContainer(id);
73
+ }
74
+ insertDoc(ds, container, doc, id);
73
75
  return id;
74
76
  }
75
77
  function renameContainer(ds, containerId, name) {
@@ -139,6 +141,7 @@ export function createContainerOps(options) {
139
141
  if (to.location === 'cloud') {
140
142
  cloud.markPending(cid);
141
143
  cloud.setRole(cid, 'owner');
144
+ cloud.expectNewContainer(cid);
142
145
  }
143
146
  // Rows deliberately written again (e.g. a container moved back here) must not stay hidden.
144
147
  for (const name of collections)
@@ -1,9 +1,9 @@
1
1
  import { type RxCollection, type RxConflictHandler, type RxDatabase, type RxStorage } from 'rxdb/plugins/core';
2
2
  import type { CollectionName, DocMap, StoreSchema } from './types.js';
3
- export type Collections<D extends DocMap> = {
3
+ export type Collections<D extends DocMap<D>> = {
4
4
  [N in CollectionName<D>]: RxCollection<D[N]>;
5
5
  };
6
- export type Database<D extends DocMap> = RxDatabase<Collections<D>>;
6
+ export type Database<D extends DocMap<D>> = RxDatabase<Collections<D>>;
7
7
  export declare function docsEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean;
8
8
  /**
9
9
  * Last write wins: whichever side carries the newer `updated_at` (client clock) is kept.
@@ -21,4 +21,4 @@ export interface OpenDatabaseOptions {
21
21
  multiInstance?: boolean;
22
22
  }
23
23
  /** Create an RxDB database with one collection per schema, all using last-write-wins. */
24
- export declare function openDatabase<D extends DocMap>(name: string, schema: Pick<StoreSchema<D>, 'collections' | 'schemas'>, { storage, multiInstance }?: OpenDatabaseOptions): Promise<Database<D>>;
24
+ export declare function openDatabase<D extends DocMap<D>>(name: string, schema: Pick<StoreSchema<D>, 'collections' | 'schemas'>, { storage, multiInstance }?: OpenDatabaseOptions): Promise<Database<D>>;
package/dist/dataset.d.ts CHANGED
@@ -7,12 +7,12 @@ export type TSCollection<T extends object> = ReturnType<typeof makeCollection<T>
7
7
  * One RxDB database plus a TanStack DB collection on top of each RxDB collection.
8
8
  * Components query the TanStack collections; bulk operations go straight to RxDB.
9
9
  */
10
- export type DataSet<D extends DocMap> = {
10
+ export type DataSet<D extends DocMap<D>> = {
11
11
  location: Location;
12
12
  db: Database<D>;
13
13
  } & {
14
14
  [N in CollectionName<D>]: TSCollection<D[N]>;
15
15
  };
16
- export declare function createDataSet<D extends DocMap>(location: Location, db: Database<D>, schema: Pick<StoreSchema<D>, 'collections' | 'container' | 'containerField' | 'indexContainerField'>): DataSet<D>;
17
- export declare function disposeDataSet<D extends DocMap>(ds: DataSet<D>, collections: readonly CollectionName<D>[]): Promise<void>;
16
+ export declare function createDataSet<D extends DocMap<D>>(location: Location, db: Database<D>, schema: Pick<StoreSchema<D>, 'collections' | 'container' | 'containerField' | 'indexContainerField'>): DataSet<D>;
17
+ export declare function disposeDataSet<D extends DocMap<D>>(ds: DataSet<D>, collections: readonly CollectionName<D>[]): Promise<void>;
18
18
  export {};
package/dist/store.d.ts CHANGED
@@ -12,7 +12,7 @@ import type { CollectionName, DocMap, Nouns, StoreSchema } from './types.js';
12
12
  * `C` is the container collection; pass it (`createSyncedStore<Docs, 'projects'>`) so container
13
13
  * rows are typed as that document rather than a union of every document.
14
14
  */
15
- export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> extends StoreSchema<D, C> {
15
+ export interface SyncedStoreConfig<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> extends StoreSchema<D, C> {
16
16
  /** Prefix for database names and localStorage keys, e.g. 'demo' → `demo_local`, `demo:hidden`. */
17
17
  appKey: string;
18
18
  nouns: Nouns;
@@ -56,7 +56,7 @@ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D>
56
56
  * sets), cloud sync, tombstones, device prefs, the Supabase members API and container
57
57
  * operations. Create it once in a module and import it where needed.
58
58
  */
59
- export declare function createSyncedStore<D extends DocMap, C extends CollectionName<D> = CollectionName<D>>(config: SyncedStoreConfig<D, C>): {
59
+ export declare function createSyncedStore<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>>(config: SyncedStoreConfig<D, C>): {
60
60
  config: SyncedStoreConfig<D, C>;
61
61
  roles: boolean;
62
62
  app: AppState<D>;
@@ -100,4 +100,4 @@ export declare function createSyncedStore<D extends DocMap, C extends Collection
100
100
  resetDevice: () => Promise<void>;
101
101
  requestPersistence: () => Promise<boolean | null>;
102
102
  };
103
- export type SyncedStore<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createSyncedStore<D, C>>;
103
+ export type SyncedStore<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createSyncedStore<D, C>>;
package/dist/types.d.ts CHANGED
@@ -8,11 +8,15 @@ export interface DocBase {
8
8
  id: string;
9
9
  updated_at: number;
10
10
  }
11
- /** Collection name → document type, e.g. `{ projects: ProjectDoc; tasks: TaskDoc }`. */
12
- export type DocMap = {
13
- [name: string]: DocBase;
11
+ /**
12
+ * Collection name → document type, e.g. `{ projects: ProjectDoc; tasks: TaskDoc }`, as a `type`
13
+ * alias or an `interface`. Generics constrain with `D extends DocMap<D>` (every key of `D` is a
14
+ * document), which an interface satisfies; a plain `DocMap` is any such map.
15
+ */
16
+ export type DocMap<D = Record<string, DocBase>> = {
17
+ [K in keyof D]: DocBase;
14
18
  };
15
- export type CollectionName<D extends DocMap> = keyof D & string;
19
+ export type CollectionName<D extends DocMap<D>> = keyof D & string;
16
20
  /** Where a container lives: only on this device, or replicated to Supabase. */
17
21
  export type Location = 'local' | 'cloud';
18
22
  export type Role = 'viewer' | 'editor' | 'owner';
@@ -25,7 +29,7 @@ export interface Nouns {
25
29
  * The shape of an app's data. A **container** (a project, a list…) owns every other row through
26
30
  * `containerField` and is the unit that gets shared, synced and moved between data sets.
27
31
  */
28
- export interface StoreSchema<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> {
32
+ export interface StoreSchema<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> {
29
33
  /** Every collection, parents before children (the container first). */
30
34
  collections: readonly CollectionName<D>[];
31
35
  schemas: {
@@ -39,4 +43,4 @@ export interface StoreSchema<D extends DocMap, C extends CollectionName<D> = Col
39
43
  indexContainerField?: boolean;
40
44
  }
41
45
  /** The container a document belongs to (containers are their own container). */
42
- export declare function containerIdOf<D extends DocMap>(schema: Pick<StoreSchema<D>, 'container' | 'containerField'>, collection: CollectionName<D>, doc: object): string;
46
+ export declare function containerIdOf<D extends DocMap<D>>(schema: Pick<StoreSchema<D>, 'container' | 'containerField'>, collection: CollectionName<D>, doc: object): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wells-nelson/data-storage",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Offline-first storage for Wells Nelson apps: RxDB + TanStack DB data sets, Supabase sync of shared containers, tombstones, device prefs and a SQL migration generator.",
5
5
  "license": "MIT",
6
6
  "repository": {