@wells-nelson/data-storage 1.0.1 → 1.0.3

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…
@@ -33,7 +34,9 @@ export const store = createSyncedStore<Docs, 'projects'>({
33
34
  if (collection === 'task_links') return { ...row, id: `${row.from_id}:${row.to_id}` };
34
35
  if (collection === 'notes') return { ...row, mentions: row.mentions.split(',').map(map).join(',') };
35
36
  return row;
36
- }
37
+ },
38
+ // Optional: report failed writes, sync errors and crashes (needs report_client_error, below).
39
+ reportErrors: { version } // from '$app/env'
37
40
  });
38
41
  ```
39
42
 
@@ -45,22 +48,50 @@ onMount(() => void store.init({ beforeReady: importDevSeed }));
45
48
  `store` holds:
46
49
 
47
50
  - `app`: `local` / `cloud` data sets (`DataSet`: the RxDB database plus a TanStack DB
48
- collection per RxDB collection), `ready`, `fatal`.
51
+ collection per RxDB collection), `ready`, `fatal`, `writeError` (a local write that failed;
52
+ show it until dismissed) and `persisted` (whether the browser keeps the data for good).
49
53
  - `cloud`: `CloudSync`: session (Google, email link), replication, `pendingIds`, `hasPending`,
50
54
  `lastSync`, `online`, `active`, `error`, `rejectedWrite`, `roleOf`, `revokedIds`,
51
- `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.
52
58
  - `ops`: container operations (`createContainer`, `renameContainer`, `makeCloud`, `makeLocal`,
53
- `deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`) and the row
59
+ `deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`,
60
+ `exportContainers`, `importContainers`) and the row
54
61
  helpers they're built from (`insertDoc`, `patch`, `removeDocs`, `docsWhere`, `touch`…).
55
- - `supabase`: lazy PKCE client and members API (`fetchMembers`, `inviteMember`, `setMemberRole`,
62
+ - `supabase`: PKCE client (`loadClient()`; supabase-js is only downloaded when a session may exist or
63
+ the user signs in) and members API (`fetchMembers`, `inviteMember`, `setMemberRole`,
56
64
  `removeMember`, `leave`, `fetchMyMemberships`) with friendly error messages.
57
65
  - `tombstones`: hides deleted rows at once and keeps hiding them if a sync briefly restores
58
66
  them; pruned after a week.
59
67
  - `prefs`: `DevicePrefs` (default container, "already prompted", collapsed sections). Pass a
60
68
  subclass as `prefs` for app-specific ones and override `replaceContainer` to keep them in step.
69
+ - `requestPersistence()`: asks the browser to keep the data (called on start; call it again from
70
+ a button). Sets `app.persisted`.
61
71
  - `resetDevice()`: teardown for a dev wipe.
62
72
 
63
- Without roles everyone is an owner. Table and RPC names default to `<noun>_members` and
73
+ Without roles everyone is an owner.
74
+
75
+ **Session key.** The session is stored under `<appKey>-auth` (`authStorageKey` to change it), so
76
+ apps sharing an origin, e.g. two GitHub Pages project sites, don't share or end each other's
77
+ sessions. A session stored under supabase-js's default key is moved to the new key once; the old key
78
+ is removed, so a second app on the origin asks to sign in again.
79
+
80
+ **Lazy loading.** `@supabase/supabase-js` and RxDB's Supabase replication are loaded with dynamic
81
+ `import()`: signed-out users never download them. They load on start when a session is stored or
82
+ the page is a sign-in redirect, and otherwise on sign-in.
83
+
84
+ **Backups.** `ops.exportContainers([{ id, location }])` returns every row of those containers as a
85
+ JSON-ready object (`format: 'wells-nelson-containers'`, the `appKey`, a version).
86
+ `ops.importContainers(data)` checks a parsed file (format, app, every row against the schema)
87
+ before writing anything, then adds each container as a new local-only container with fresh ids.
88
+ `DeviceDataCard` in `@wells-nelson/interactions/account` is the UI.
89
+
90
+ **Errors.** `persist()` (TanStack writes) and other quiet failures go through `reportError(kind,
91
+ err)`; failed writes also set `app.writeError`. With `reportErrors`, the store sends each report
92
+ (once per kind and message, at most 20 per page load) to the schema's `report_client_error`
93
+ function with a plain `fetch`, so signed-out devices report too, and also catches uncaught errors
94
+ and rejections. `setErrorReporter` swaps in another destination (e.g. Sentry). Table and RPC names default to `<noun>_members` and
64
95
  `get_<noun>_members`; override with e.g. `members: { membersRpc: 'get_list_collaborators' }`.
65
96
 
66
97
  ## A schema other than `public`
@@ -73,7 +104,8 @@ createSyncedStore({ ..., supabaseSchema: 'my_app' });
73
104
  generateMigration({ ..., schema: 'my_app' });
74
105
  ```
75
106
 
76
- The migration creates the schema with the grants Supabase's API roles need. In the Supabase
107
+ The migration creates the schema with the grants Supabase's API roles need (tables only for
108
+ `authenticated`; `anon` only gets `report_client_error`). In the Supabase
77
109
  dashboard, add the schema under **Project Settings → Data API → Exposed schemas**; until then,
78
110
  the API can't see it. The store's client reads and writes that schema. It also fixes RxDB's
79
111
  realtime subscription, which otherwise always listens on `public`.
@@ -92,4 +124,19 @@ const sql = generateMigration({
92
124
  });
93
125
  ```
94
126
 
127
+ What it sets up besides the tables:
128
+
129
+ - `current_email()` returns the signed-in user's email only once it's confirmed (read from
130
+ `auth.users`), so an unverified sign-up can't claim invites made to that email.
131
+ - RLS on every table; nothing for `anon` except `report_client_error`. Functions are revoked from
132
+ `public` and `anon` and granted to `authenticated`.
133
+ - Nothing new (rows, members) goes into a deleted container (`is_<noun>_live`); containers can only
134
+ be created by users with an email. With roles, the last-owner trigger locks the container row, so
135
+ two owners demoting each other at once can't both succeed; viewers see only the owners and
136
+ themselves.
137
+ - The members RPC's `active` means "signed in since being invited" (only owners see it, with roles),
138
+ so it never reveals whether an email has an account.
139
+ - `client_errors` and `report_client_error(...)`: write-only error reports, capped at 300 an hour and
140
+ kept 30 days.
141
+
95
142
  The sandbox's Data storage page runs this store in the browser and shows the migration.
@@ -4,10 +4,18 @@ 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;
11
11
  fatal: string | null;
12
+ /** A local write that failed (e.g. storage full). Show it until dismissed. */
13
+ writeError: string | null;
14
+ /**
15
+ * Whether the browser keeps this site's data until the user clears it (`true`), or may evict
16
+ * it under storage pressure or, in Safari, after 7 days without a visit (`false`). Null until
17
+ * known, or where the browser can't say.
18
+ */
19
+ persisted: boolean | null;
12
20
  dataSet(location: Location): DataSet<D>;
13
21
  }
@@ -7,6 +7,14 @@ export class AppState {
7
7
  cloud = $state.raw(null);
8
8
  ready = $state(false);
9
9
  fatal = $state(null);
10
+ /** A local write that failed (e.g. storage full). Show it until dismissed. */
11
+ writeError = $state(null);
12
+ /**
13
+ * Whether the browser keeps this site's data until the user clears it (`true`), or may evict
14
+ * it under storage pressure or, in Safari, after 7 days without a visit (`false`). Null until
15
+ * known, or where the browser can't say.
16
+ */
17
+ persisted = $state(null);
10
18
  dataSet(location) {
11
19
  const ds = location === 'local' ? this.local : this.cloud;
12
20
  if (!ds)
@@ -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;
@@ -1,12 +1,14 @@
1
- import { replicateSupabase } from 'rxdb/plugins/replication-supabase';
2
1
  import { tick } from 'svelte';
3
2
  import { openDatabase } from './database.js';
4
3
  import { createDataSet, disposeDataSet } from './dataset.js';
4
+ import { reportError } from './errors.js';
5
5
  import { Persisted } from './persisted.svelte.js';
6
6
  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;
@@ -64,6 +66,10 @@ export class CloudSync {
64
66
  #lastMembershipCheck = 0;
65
67
  #signingOut = false;
66
68
  #queue = Promise.resolve();
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();
67
73
  constructor(options) {
68
74
  this.#appKey = options.appKey;
69
75
  this.#schema = options.schema;
@@ -126,7 +132,21 @@ export class CloudSync {
126
132
  });
127
133
  window.addEventListener('offline', () => (this.online = false));
128
134
  }
129
- const sb = this.#sb.getClient();
135
+ // supabase-js (and the replication) only load when there may be a session: a stored one or
136
+ // a sign-in redirect. Otherwise they load when the user signs in.
137
+ if (this.#sb.mayHaveSession())
138
+ await this.#attach();
139
+ }
140
+ /** Load the client, restore its session and follow auth changes (once). */
141
+ #attach() {
142
+ this.#attached ??= this.#doAttach().catch((err) => {
143
+ this.#attached = null;
144
+ throw err;
145
+ });
146
+ return this.#attached;
147
+ }
148
+ async #doAttach() {
149
+ const sb = await this.#sb.loadClient();
130
150
  if (!sb)
131
151
  return;
132
152
  const { data } = await sb.auth.getSession();
@@ -167,12 +187,43 @@ export class CloudSync {
167
187
  this.user = user;
168
188
  this.sessionExpired = false;
169
189
  this.#app.cloud = createDataSet('cloud', db, this.#schema);
170
- this.#startReplication(db);
190
+ await this.#startReplication(db);
171
191
  this.#scheduleSettle();
172
192
  }
173
- #startReplication(db) {
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
+ }
222
+ async #startReplication(db) {
223
+ const { replicateSupabase } = await import('rxdb/plugins/replication-supabase');
174
224
  const sb = replicationClient(this.#sb.getClient(), this.#sb.schema);
175
225
  this.#schema.collections.forEach((name, index) => {
226
+ const isContainer = name === this.#schema.container;
176
227
  const rep = replicateSupabase({
177
228
  replicationIdentifier: `supabase-${name}`,
178
229
  client: sb,
@@ -185,7 +236,14 @@ export class CloudSync {
185
236
  pull: { batchSize: 200 },
186
237
  push: {
187
238
  batchSize: 50,
188
- 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
+ }
189
247
  }
190
248
  });
191
249
  this.#replications.push(rep);
@@ -193,13 +251,20 @@ export class CloudSync {
193
251
  this.error = errorMessage(err, this.#sb.schema);
194
252
  if (isRejection(err))
195
253
  this.rejectedWrite = this.error;
254
+ // Offline failures are expected; anything else is worth knowing about.
255
+ if (isRejection(err) || this.online)
256
+ reportError('sync', this.error, `${name} ${err.parameters?.direction ?? ''}`);
196
257
  }), rep.active$.subscribe((isActive) => {
197
258
  if (isActive)
198
259
  this.#activeReps.add(index);
199
260
  else
200
261
  this.#activeReps.delete(index);
201
262
  this.active = this.#activeReps.size > 0;
202
- }), 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
+ }));
203
268
  });
204
269
  }
205
270
  async #teardown(removeData) {
@@ -211,6 +276,8 @@ export class CloudSync {
211
276
  this.#replications = [];
212
277
  this.#activeReps.clear();
213
278
  this.active = false;
279
+ for (const id of [...this.#unsent.keys()])
280
+ this.#containerSent(id);
214
281
  await Promise.allSettled(reps.map((r) => r.cancel()));
215
282
  const ds = this.#app.cloud;
216
283
  this.#app.cloud = null;
@@ -324,6 +391,7 @@ export class CloudSync {
324
391
  await this.checkMemberships(true);
325
392
  }
326
393
  async signInWithGoogle(redirectTo) {
394
+ await this.#attach();
327
395
  const sb = this.#sb.getClient();
328
396
  if (!sb)
329
397
  throw new Error('Cloud sync is not configured.');
@@ -333,6 +401,7 @@ export class CloudSync {
333
401
  }
334
402
  /** Email a magic sign-in link. The link has to be opened in this browser (PKCE). */
335
403
  async signInWithEmail(email, redirectTo) {
404
+ await this.#attach();
336
405
  const sb = this.#sb.getClient();
337
406
  if (!sb)
338
407
  throw new Error('Cloud sync is not configured.');
@@ -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>;
@@ -29,6 +29,8 @@ export interface ContainerOpsOptions<D extends DocMap, C extends CollectionName<
29
29
  [N in CollectionName<D>]?: (keyof D[N] & string)[];
30
30
  };
31
31
  remap?: RemapHook<D>;
32
+ /** The store's appKey, written into exports and checked on import. */
33
+ appKey?: string;
32
34
  }
33
35
  /** A container as listed by the app (one entry even while it moves between data sets). */
34
36
  export interface ContainerEntry<T> {
@@ -36,18 +38,31 @@ export interface ContainerEntry<T> {
36
38
  location: Location;
37
39
  role: Role;
38
40
  }
39
- /** Persisting a TanStack DB transaction in the background, logging failures. */
41
+ /**
42
+ * Persisting a TanStack DB transaction in the background. A failure is reported and shown
43
+ * (`app.writeError` through the store), not just logged.
44
+ */
40
45
  export declare function persist(tx: {
41
46
  when(state: 'settled'): Promise<unknown>;
42
47
  }): void;
48
+ /** The file format of `exportContainers`. */
49
+ export interface ContainerExport {
50
+ format: 'wells-nelson-containers';
51
+ version: 1;
52
+ /** The store's appKey: a file only imports into the app that wrote it. */
53
+ app: string;
54
+ exportedAt: string;
55
+ /** One entry per container: every row of it, by collection. */
56
+ containers: Record<string, Record<string, unknown>[]>[];
57
+ }
43
58
  export declare const now: () => number;
44
59
  export declare const uid: () => `${string}-${string}-${string}-${string}-${string}`;
45
- 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>>;
46
61
  /**
47
62
  * Operations on whole containers and the low-level row helpers they're built from. Every write
48
63
  * to a cloud data set marks the container pending so the profile can show unsynced changes.
49
64
  */
50
- 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>): {
51
66
  rx: <N extends CollectionName<D>>(ds: DataSet<D>, name: N) => RxCollection<D[N]>;
52
67
  touch: (ds: DataSet<D>, containerId: string) => void;
53
68
  removeDocs: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, ids: string[]) => Promise<void>;
@@ -64,6 +79,11 @@ export declare function createContainerOps<D extends DocMap, C extends Collectio
64
79
  containerId: string;
65
80
  };
66
81
  copyContainer: (from: DataSet<D>, to: DataSet<D>, containerId: string, newIds: boolean) => Promise<string>;
82
+ exportContainers: (items: {
83
+ id: string;
84
+ location: Location;
85
+ }[]) => Promise<ContainerExport>;
86
+ importContainers: (data: unknown) => Promise<string[]>;
67
87
  makeCloud: (containerId: string) => Promise<void>;
68
88
  makeLocal: (containerId: string) => Promise<string>;
69
89
  deleteContainer: (ds: DataSet<D>, containerId: string) => Promise<void>;
@@ -1,6 +1,10 @@
1
- /** Persisting a TanStack DB transaction in the background, logging failures. */
1
+ import { writeFailed } from './errors.js';
2
+ /**
3
+ * Persisting a TanStack DB transaction in the background. A failure is reported and shown
4
+ * (`app.writeError` through the store), not just logged.
5
+ */
2
6
  export function persist(tx) {
3
- tx.when('settled').catch((err) => console.error('Failed to save change', err));
7
+ tx.when('settled').catch(writeFailed);
4
8
  }
5
9
  export const now = () => Date.now();
6
10
  export const uid = () => crypto.randomUUID();
@@ -63,9 +67,11 @@ export function createContainerOps(options) {
63
67
  const t = now();
64
68
  const hasCreatedAt = 'created_at' in (schema.schemas[container].properties ?? {});
65
69
  const doc = { ...fields, id, updated_at: t, ...(hasCreatedAt ? { created_at: t } : {}) };
66
- insertDoc(ds, container, doc, id);
67
- if (ds.location === 'cloud')
70
+ if (ds.location === 'cloud') {
68
71
  cloud.setRole(id, 'owner');
72
+ cloud.expectNewContainer(id);
73
+ }
74
+ insertDoc(ds, container, doc, id);
69
75
  return id;
70
76
  }
71
77
  function renameContainer(ds, containerId, name) {
@@ -135,6 +141,7 @@ export function createContainerOps(options) {
135
141
  if (to.location === 'cloud') {
136
142
  cloud.markPending(cid);
137
143
  cloud.setRole(cid, 'owner');
144
+ cloud.expectNewContainer(cid);
138
145
  }
139
146
  // Rows deliberately written again (e.g. a container moved back here) must not stay hidden.
140
147
  for (const name of collections)
@@ -142,6 +149,80 @@ export function createContainerOps(options) {
142
149
  await insertContainerDocs(to, rows);
143
150
  return cid;
144
151
  }
152
+ /**
153
+ * Every row of the given containers as one JSON-ready object (a backup the user keeps). Each
154
+ * container is read from the data set it lives in.
155
+ */
156
+ async function exportContainers(items) {
157
+ const containers = [];
158
+ for (const { id, location } of items) {
159
+ const docs = await readContainer(app.dataSet(location), id);
160
+ if (docs[container].length === 0)
161
+ continue;
162
+ containers.push(docs);
163
+ }
164
+ return {
165
+ format: 'wells-nelson-containers',
166
+ version: 1,
167
+ app: options.appKey ?? '',
168
+ exportedAt: new Date().toISOString(),
169
+ containers
170
+ };
171
+ }
172
+ /** Keep only the schema's fields, and only with the schema's types; anything else is not a backup. */
173
+ function cleanRow(name, row) {
174
+ const props = schema.schemas[name].properties;
175
+ if (!row || typeof row !== 'object')
176
+ throw new Error('This file is not a backup of this app.');
177
+ const out = {};
178
+ for (const [key, prop] of Object.entries(props)) {
179
+ const value = row[key];
180
+ const type = prop.type === 'number' ? 'number' : prop.type === 'boolean' ? 'boolean' : 'string';
181
+ if (typeof value !== type || (type === 'number' && !Number.isFinite(value))) {
182
+ throw new Error(`This backup has an invalid ${name} row (${key}).`);
183
+ }
184
+ out[key] = value;
185
+ }
186
+ return out;
187
+ }
188
+ /**
189
+ * Add every container of an `exportContainers` file to this device as new local-only
190
+ * containers (fresh ids, so importing twice makes two copies and nothing is overwritten).
191
+ * Returns the new container ids.
192
+ */
193
+ async function importContainers(data) {
194
+ const file = data;
195
+ if (!file || file.format !== 'wells-nelson-containers' || !Array.isArray(file.containers)) {
196
+ throw new Error('This file is not a backup of this app.');
197
+ }
198
+ if (file.version !== 1)
199
+ throw new Error('This backup was made by a newer version of the app. Update the app first.');
200
+ if (options.appKey && file.app !== options.appKey)
201
+ throw new Error('This backup is from a different app.');
202
+ // Check everything before writing anything.
203
+ const parsed = file.containers.map((entry) => {
204
+ const docs = {};
205
+ for (const name of collections) {
206
+ const rows = entry[name] ?? [];
207
+ if (!Array.isArray(rows))
208
+ throw new Error('This file is not a backup of this app.');
209
+ docs[name] = rows.map((r) => cleanRow(name, r));
210
+ }
211
+ const head = docs[container][0];
212
+ if (docs[container].length !== 1 || !head)
213
+ throw new Error('This file is not a backup of this app.');
214
+ return { docs, id: head.id };
215
+ });
216
+ const local = app.dataSet('local');
217
+ const ids = [];
218
+ for (const { docs, id } of parsed) {
219
+ const { rows, containerId } = remapContainer(docs, id, true);
220
+ await insertContainerDocs(local, rows);
221
+ prefs.markPrompted([containerId]);
222
+ ids.push(containerId);
223
+ }
224
+ return ids;
225
+ }
145
226
  /** Remove a cloud container from this device only; nothing is deleted on Supabase. */
146
227
  async function purgeCloudContainer(containerId) {
147
228
  const ds = app.dataSet('cloud');
@@ -272,6 +353,8 @@ export function createContainerOps(options) {
272
353
  insertContainerDocs,
273
354
  remapContainer,
274
355
  copyContainer,
356
+ exportContainers,
357
+ importContainers,
275
358
  makeCloud,
276
359
  makeLocal,
277
360
  deleteContainer,
@@ -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 {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Client error reporting. Library code calls `reportError` where something fails without the
3
+ * user necessarily noticing (a local write, sync, device prefs); the store installs a reporter
4
+ * that sends each report to the app's Supabase project (see `remoteReporter`). Reports are
5
+ * de-duplicated by kind and message and capped per page load.
6
+ */
7
+ export type ErrorKind = 'write' | 'sync' | 'prefs' | 'unhandled' | (string & {});
8
+ export interface ErrorReport {
9
+ kind: ErrorKind;
10
+ message: string;
11
+ /** Extra context, e.g. a stack trace. */
12
+ detail?: string;
13
+ }
14
+ export type ErrorReporter = (report: ErrorReport) => void;
15
+ /** Where reports go (null to stop reporting). */
16
+ export declare function setErrorReporter(next: ErrorReporter | null): void;
17
+ /** Forget what was reported (tests). */
18
+ export declare function resetErrorReports(): void;
19
+ export declare function describeError(err: unknown): {
20
+ message: string;
21
+ detail?: string;
22
+ };
23
+ /** Report an error once per kind and message (and at most 20 per page load). Never throws. */
24
+ export declare function reportError(kind: ErrorKind, err: unknown, detail?: string): void;
25
+ /** Called with a message whenever a local write fails (see `persist`). Returns an unsubscribe. */
26
+ export declare function onWriteError(listener: (message: string) => void): () => undefined;
27
+ /** A local write failed: tell listeners (the store shows it) and report it. */
28
+ export declare function writeFailed(err: unknown): void;
29
+ export interface RemoteReporterOptions {
30
+ supabaseUrl: string;
31
+ supabaseAnonKey: string;
32
+ /** Postgres schema holding `report_client_error` (see the generated migration). */
33
+ schema: string;
34
+ /** App name stored with each report, e.g. the store's appKey. */
35
+ app: string;
36
+ /** The deployed version, stored with each report. */
37
+ version?: string;
38
+ }
39
+ /**
40
+ * Sends reports to the `report_client_error` function of the app's Supabase schema with a plain
41
+ * `fetch` (no supabase-js needed, so signed-out devices report too). Only the path of the page
42
+ * is sent, never its query or any user data beyond the error itself.
43
+ */
44
+ export declare function remoteReporter(options: RemoteReporterOptions): ErrorReporter;