@wells-nelson/data-storage 1.0.0 → 1.0.2

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
@@ -33,7 +33,9 @@ export const store = createSyncedStore<Docs, 'projects'>({
33
33
  if (collection === 'task_links') return { ...row, id: `${row.from_id}:${row.to_id}` };
34
34
  if (collection === 'notes') return { ...row, mentions: row.mentions.split(',').map(map).join(',') };
35
35
  return row;
36
- }
36
+ },
37
+ // Optional: report failed writes, sync errors and crashes (needs report_client_error, below).
38
+ reportErrors: { version } // from '$app/env'
37
39
  });
38
40
  ```
39
41
 
@@ -45,24 +47,66 @@ onMount(() => void store.init({ beforeReady: importDevSeed }));
45
47
  `store` holds:
46
48
 
47
49
  - `app`: `local` / `cloud` data sets (`DataSet`: the RxDB database plus a TanStack DB
48
- collection per RxDB collection), `ready`, `fatal`.
50
+ collection per RxDB collection), `ready`, `fatal`, `writeError` (a local write that failed;
51
+ show it until dismissed) and `persisted` (whether the browser keeps the data for good).
49
52
  - `cloud`: `CloudSync`: session (Google, email link), replication, `pendingIds`, `hasPending`,
50
53
  `lastSync`, `online`, `active`, `error`, `rejectedWrite`, `roleOf`, `revokedIds`,
51
54
  `forceResync`, `signOut`.
52
55
  - `ops`: container operations (`createContainer`, `renameContainer`, `makeCloud`, `makeLocal`,
53
- `deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`) and the row
56
+ `deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`,
57
+ `exportContainers`, `importContainers`) and the row
54
58
  helpers they're built from (`insertDoc`, `patch`, `removeDocs`, `docsWhere`, `touch`…).
55
- - `supabase`: lazy PKCE client and members API (`fetchMembers`, `inviteMember`, `setMemberRole`,
59
+ - `supabase`: PKCE client (`loadClient()`; supabase-js is only downloaded when a session may exist or
60
+ the user signs in) and members API (`fetchMembers`, `inviteMember`, `setMemberRole`,
56
61
  `removeMember`, `leave`, `fetchMyMemberships`) with friendly error messages.
57
62
  - `tombstones`: hides deleted rows at once and keeps hiding them if a sync briefly restores
58
63
  them; pruned after a week.
59
64
  - `prefs`: `DevicePrefs` (default container, "already prompted", collapsed sections). Pass a
60
65
  subclass as `prefs` for app-specific ones and override `replaceContainer` to keep them in step.
66
+ - `requestPersistence()`: asks the browser to keep the data (called on start; call it again from
67
+ a button). Sets `app.persisted`.
61
68
  - `resetDevice()`: teardown for a dev wipe.
62
69
 
63
- Without roles everyone is an owner. Table and RPC names default to `<noun>_members` and
70
+ Without roles everyone is an owner.
71
+
72
+ **Session key.** The session is stored under `<appKey>-auth` (`authStorageKey` to change it), so
73
+ apps sharing an origin, e.g. two GitHub Pages project sites, don't share or end each other's
74
+ sessions. A session stored under supabase-js's default key is moved to the new key once; the old key
75
+ is removed, so a second app on the origin asks to sign in again.
76
+
77
+ **Lazy loading.** `@supabase/supabase-js` and RxDB's Supabase replication are loaded with dynamic
78
+ `import()`: signed-out users never download them. They load on start when a session is stored or
79
+ the page is a sign-in redirect, and otherwise on sign-in.
80
+
81
+ **Backups.** `ops.exportContainers([{ id, location }])` returns every row of those containers as a
82
+ JSON-ready object (`format: 'wells-nelson-containers'`, the `appKey`, a version).
83
+ `ops.importContainers(data)` checks a parsed file (format, app, every row against the schema)
84
+ before writing anything, then adds each container as a new local-only container with fresh ids.
85
+ `DeviceDataCard` in `@wells-nelson/interactions/account` is the UI.
86
+
87
+ **Errors.** `persist()` (TanStack writes) and other quiet failures go through `reportError(kind,
88
+ err)`; failed writes also set `app.writeError`. With `reportErrors`, the store sends each report
89
+ (once per kind and message, at most 20 per page load) to the schema's `report_client_error`
90
+ function with a plain `fetch`, so signed-out devices report too, and also catches uncaught errors
91
+ and rejections. `setErrorReporter` swaps in another destination (e.g. Sentry). Table and RPC names default to `<noun>_members` and
64
92
  `get_<noun>_members`; override with e.g. `members: { membersRpc: 'get_list_collaborators' }`.
65
93
 
94
+ ## A schema other than `public`
95
+
96
+ To keep an app's tables in their own Postgres schema (handy when several apps share one Supabase
97
+ project), set `supabaseSchema` on the store and the same `schema` on the migration:
98
+
99
+ ```ts
100
+ createSyncedStore({ ..., supabaseSchema: 'my_app' });
101
+ generateMigration({ ..., schema: 'my_app' });
102
+ ```
103
+
104
+ The migration creates the schema with the grants Supabase's API roles need (tables only for
105
+ `authenticated`; `anon` only gets `report_client_error`). In the Supabase
106
+ dashboard, add the schema under **Project Settings → Data API → Exposed schemas**; until then,
107
+ the API can't see it. The store's client reads and writes that schema. It also fixes RxDB's
108
+ realtime subscription, which otherwise always listens on `public`.
109
+
66
110
  ## SQL migration
67
111
 
68
112
  ```ts
@@ -77,4 +121,19 @@ const sql = generateMigration({
77
121
  });
78
122
  ```
79
123
 
124
+ What it sets up besides the tables:
125
+
126
+ - `current_email()` returns the signed-in user's email only once it's confirmed (read from
127
+ `auth.users`), so an unverified sign-up can't claim invites made to that email.
128
+ - RLS on every table; nothing for `anon` except `report_client_error`. Functions are revoked from
129
+ `public` and `anon` and granted to `authenticated`.
130
+ - Nothing new (rows, members) goes into a deleted container (`is_<noun>_live`); containers can only
131
+ be created by users with an email. With roles, the last-owner trigger locks the container row, so
132
+ two owners demoting each other at once can't both succeed; viewers see only the owners and
133
+ themselves.
134
+ - The members RPC's `active` means "signed in since being invited" (only owners see it, with roles),
135
+ so it never reveals whether an email has an account.
136
+ - `client_errors` and `report_client_error(...)`: write-only error reports, capped at 300 an hour and
137
+ kept 30 days.
138
+
80
139
  The sandbox's Data storage page runs this store in the browser and shows the migration.
@@ -9,5 +9,13 @@ export declare class AppState<D extends DocMap> {
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)
@@ -1,11 +1,15 @@
1
1
  import type { User } from '@supabase/supabase-js';
2
2
  import type { AppState } from './app.svelte.js';
3
3
  import { type StorageFactory } from './database.js';
4
- import type { SupabaseApi } from './supabase.js';
4
+ import { type SupabaseApi } from './supabase.js';
5
5
  import { type DocMap, type Role, type StoreSchema } from './types.js';
6
6
  /** Push errors caused by the server refusing the write (RLS, triggers), not by connectivity. */
7
7
  export declare function isRejection(err: unknown): boolean;
8
- export declare function errorMessage(err: unknown): string;
8
+ /**
9
+ * A replication or API error as one line for the UI. With `schema`, a schema the Supabase API
10
+ * doesn't expose is reported as the setting to change.
11
+ */
12
+ export declare function errorMessage(err: unknown, schema?: string): string;
9
13
  export interface CloudSyncOptions<D extends DocMap> {
10
14
  /** Prefix for database names and localStorage keys, e.g. 'demo'. */
11
15
  appKey: string;
@@ -1,8 +1,9 @@
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
+ import { replicationClient, schemaNotExposed } from './supabase.js';
6
7
  import { containerIdOf } from './types.js';
7
8
  const emptyState = () => ({ pending: {}, blocked: [], revoked: [], roles: {}, lastSync: null });
8
9
  const MEMBERSHIP_CHECK_INTERVAL = 30_000;
@@ -15,10 +16,16 @@ export function isRejection(err) {
15
16
  // 42501: RLS violation; P0001: raised by a trigger (e.g. last owner); 23xxx: constraint violations.
16
17
  return !!code && (code === '42501' || code === 'P0001' || code.startsWith('23'));
17
18
  }
18
- export function errorMessage(err) {
19
+ /**
20
+ * A replication or API error as one line for the UI. With `schema`, a schema the Supabase API
21
+ * doesn't expose is reported as the setting to change.
22
+ */
23
+ export function errorMessage(err, schema) {
19
24
  if (err && typeof err === 'object') {
20
25
  const e = err;
21
- return e.parameters?.errors?.[0]?.message ?? e.message ?? String(err);
26
+ const inner = e.parameters?.errors?.[0];
27
+ const unexposed = schema ? (schemaNotExposed(inner, schema) ?? schemaNotExposed(e, schema)) : null;
28
+ return unexposed ?? inner?.message ?? e.message ?? String(err);
22
29
  }
23
30
  return String(err);
24
31
  }
@@ -57,6 +64,7 @@ export class CloudSync {
57
64
  #lastMembershipCheck = 0;
58
65
  #signingOut = false;
59
66
  #queue = Promise.resolve();
67
+ #attached = null;
60
68
  constructor(options) {
61
69
  this.#appKey = options.appKey;
62
70
  this.#schema = options.schema;
@@ -119,7 +127,21 @@ export class CloudSync {
119
127
  });
120
128
  window.addEventListener('offline', () => (this.online = false));
121
129
  }
122
- const sb = this.#sb.getClient();
130
+ // supabase-js (and the replication) only load when there may be a session: a stored one or
131
+ // a sign-in redirect. Otherwise they load when the user signs in.
132
+ if (this.#sb.mayHaveSession())
133
+ await this.#attach();
134
+ }
135
+ /** Load the client, restore its session and follow auth changes (once). */
136
+ #attach() {
137
+ this.#attached ??= this.#doAttach().catch((err) => {
138
+ this.#attached = null;
139
+ throw err;
140
+ });
141
+ return this.#attached;
142
+ }
143
+ async #doAttach() {
144
+ const sb = await this.#sb.loadClient();
123
145
  if (!sb)
124
146
  return;
125
147
  const { data } = await sb.auth.getSession();
@@ -160,11 +182,12 @@ export class CloudSync {
160
182
  this.user = user;
161
183
  this.sessionExpired = false;
162
184
  this.#app.cloud = createDataSet('cloud', db, this.#schema);
163
- this.#startReplication(db);
185
+ await this.#startReplication(db);
164
186
  this.#scheduleSettle();
165
187
  }
166
- #startReplication(db) {
167
- const sb = this.#sb.getClient();
188
+ async #startReplication(db) {
189
+ const { replicateSupabase } = await import('rxdb/plugins/replication-supabase');
190
+ const sb = replicationClient(this.#sb.getClient(), this.#sb.schema);
168
191
  this.#schema.collections.forEach((name, index) => {
169
192
  const rep = replicateSupabase({
170
193
  replicationIdentifier: `supabase-${name}`,
@@ -183,9 +206,12 @@ export class CloudSync {
183
206
  });
184
207
  this.#replications.push(rep);
185
208
  this.#subscriptions.push(rep.error$.subscribe((err) => {
186
- this.error = errorMessage(err);
209
+ this.error = errorMessage(err, this.#sb.schema);
187
210
  if (isRejection(err))
188
211
  this.rejectedWrite = this.error;
212
+ // Offline failures are expected; anything else is worth knowing about.
213
+ if (isRejection(err) || this.online)
214
+ reportError('sync', this.error, `${name} ${err.parameters?.direction ?? ''}`);
189
215
  }), rep.active$.subscribe((isActive) => {
190
216
  if (isActive)
191
217
  this.#activeReps.add(index);
@@ -307,7 +333,7 @@ export class CloudSync {
307
333
  });
308
334
  }
309
335
  catch (err) {
310
- this.error = errorMessage(err);
336
+ this.error = errorMessage(err, this.#sb.schema);
311
337
  }
312
338
  }
313
339
  async forceResync() {
@@ -317,6 +343,7 @@ export class CloudSync {
317
343
  await this.checkMemberships(true);
318
344
  }
319
345
  async signInWithGoogle(redirectTo) {
346
+ await this.#attach();
320
347
  const sb = this.#sb.getClient();
321
348
  if (!sb)
322
349
  throw new Error('Cloud sync is not configured.');
@@ -326,6 +353,7 @@ export class CloudSync {
326
353
  }
327
354
  /** Email a magic sign-in link. The link has to be opened in this browser (PKCE). */
328
355
  async signInWithEmail(email, redirectTo) {
356
+ await this.#attach();
329
357
  const sb = this.#sb.getClient();
330
358
  if (!sb)
331
359
  throw new Error('Cloud sync is not configured.');
@@ -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,10 +38,23 @@ 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
60
  export type ContainerOps<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createContainerOps<D, C>>;
@@ -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();
@@ -142,6 +146,80 @@ export function createContainerOps(options) {
142
146
  await insertContainerDocs(to, rows);
143
147
  return cid;
144
148
  }
149
+ /**
150
+ * Every row of the given containers as one JSON-ready object (a backup the user keeps). Each
151
+ * container is read from the data set it lives in.
152
+ */
153
+ async function exportContainers(items) {
154
+ const containers = [];
155
+ for (const { id, location } of items) {
156
+ const docs = await readContainer(app.dataSet(location), id);
157
+ if (docs[container].length === 0)
158
+ continue;
159
+ containers.push(docs);
160
+ }
161
+ return {
162
+ format: 'wells-nelson-containers',
163
+ version: 1,
164
+ app: options.appKey ?? '',
165
+ exportedAt: new Date().toISOString(),
166
+ containers
167
+ };
168
+ }
169
+ /** Keep only the schema's fields, and only with the schema's types; anything else is not a backup. */
170
+ function cleanRow(name, row) {
171
+ const props = schema.schemas[name].properties;
172
+ if (!row || typeof row !== 'object')
173
+ throw new Error('This file is not a backup of this app.');
174
+ const out = {};
175
+ for (const [key, prop] of Object.entries(props)) {
176
+ const value = row[key];
177
+ const type = prop.type === 'number' ? 'number' : prop.type === 'boolean' ? 'boolean' : 'string';
178
+ if (typeof value !== type || (type === 'number' && !Number.isFinite(value))) {
179
+ throw new Error(`This backup has an invalid ${name} row (${key}).`);
180
+ }
181
+ out[key] = value;
182
+ }
183
+ return out;
184
+ }
185
+ /**
186
+ * Add every container of an `exportContainers` file to this device as new local-only
187
+ * containers (fresh ids, so importing twice makes two copies and nothing is overwritten).
188
+ * Returns the new container ids.
189
+ */
190
+ async function importContainers(data) {
191
+ const file = data;
192
+ if (!file || file.format !== 'wells-nelson-containers' || !Array.isArray(file.containers)) {
193
+ throw new Error('This file is not a backup of this app.');
194
+ }
195
+ if (file.version !== 1)
196
+ throw new Error('This backup was made by a newer version of the app. Update the app first.');
197
+ if (options.appKey && file.app !== options.appKey)
198
+ throw new Error('This backup is from a different app.');
199
+ // Check everything before writing anything.
200
+ const parsed = file.containers.map((entry) => {
201
+ const docs = {};
202
+ for (const name of collections) {
203
+ const rows = entry[name] ?? [];
204
+ if (!Array.isArray(rows))
205
+ throw new Error('This file is not a backup of this app.');
206
+ docs[name] = rows.map((r) => cleanRow(name, r));
207
+ }
208
+ const head = docs[container][0];
209
+ if (docs[container].length !== 1 || !head)
210
+ throw new Error('This file is not a backup of this app.');
211
+ return { docs, id: head.id };
212
+ });
213
+ const local = app.dataSet('local');
214
+ const ids = [];
215
+ for (const { docs, id } of parsed) {
216
+ const { rows, containerId } = remapContainer(docs, id, true);
217
+ await insertContainerDocs(local, rows);
218
+ prefs.markPrompted([containerId]);
219
+ ids.push(containerId);
220
+ }
221
+ return ids;
222
+ }
145
223
  /** Remove a cloud container from this device only; nothing is deleted on Supabase. */
146
224
  async function purgeCloudContainer(containerId) {
147
225
  const ds = app.dataSet('cloud');
@@ -272,6 +350,8 @@ export function createContainerOps(options) {
272
350
  insertContainerDocs,
273
351
  remapContainer,
274
352
  copyContainer,
353
+ exportContainers,
354
+ importContainers,
275
355
  makeCloud,
276
356
  makeLocal,
277
357
  deleteContainer,
@@ -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;
package/dist/errors.js ADDED
@@ -0,0 +1,101 @@
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
+ const MAX_REPORTS = 20;
8
+ let reporter = null;
9
+ let sent = 0;
10
+ const seen = new Set();
11
+ const writeListeners = new Set();
12
+ /** Where reports go (null to stop reporting). */
13
+ export function setErrorReporter(next) {
14
+ reporter = next;
15
+ }
16
+ /** Forget what was reported (tests). */
17
+ export function resetErrorReports() {
18
+ sent = 0;
19
+ seen.clear();
20
+ }
21
+ export function describeError(err) {
22
+ if (err instanceof Error)
23
+ return { message: err.message || err.name, detail: err.stack };
24
+ if (err && typeof err === 'object') {
25
+ const e = err;
26
+ if (typeof e.message === 'string')
27
+ return { message: e.message, detail: safeJson(err) };
28
+ return { message: safeJson(err) ?? String(err) };
29
+ }
30
+ return { message: String(err) };
31
+ }
32
+ function safeJson(value) {
33
+ try {
34
+ return JSON.stringify(value);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /** Report an error once per kind and message (and at most 20 per page load). Never throws. */
41
+ export function reportError(kind, err, detail) {
42
+ const described = describeError(err);
43
+ const key = `${kind}:${described.message}`;
44
+ if (seen.has(key) || sent >= MAX_REPORTS)
45
+ return;
46
+ seen.add(key);
47
+ if (!reporter)
48
+ return;
49
+ sent++;
50
+ try {
51
+ reporter({ kind, message: described.message, detail: detail ?? described.detail });
52
+ }
53
+ catch {
54
+ /* reporting must never break the app */
55
+ }
56
+ }
57
+ /** Called with a message whenever a local write fails (see `persist`). Returns an unsubscribe. */
58
+ export function onWriteError(listener) {
59
+ writeListeners.add(listener);
60
+ return () => void writeListeners.delete(listener);
61
+ }
62
+ /** A local write failed: tell listeners (the store shows it) and report it. */
63
+ export function writeFailed(err) {
64
+ console.error('Failed to save change', err);
65
+ reportError('write', err);
66
+ const { message } = describeError(err);
67
+ for (const listener of writeListeners)
68
+ listener(message);
69
+ }
70
+ /**
71
+ * Sends reports to the `report_client_error` function of the app's Supabase schema with a plain
72
+ * `fetch` (no supabase-js needed, so signed-out devices report too). Only the path of the page
73
+ * is sent, never its query or any user data beyond the error itself.
74
+ */
75
+ export function remoteReporter(options) {
76
+ const url = `${options.supabaseUrl.replace(/\/$/, '')}/rest/v1/rpc/report_client_error`;
77
+ return ({ kind, message, detail }) => {
78
+ if (typeof fetch === 'undefined')
79
+ return;
80
+ void fetch(url, {
81
+ method: 'POST',
82
+ keepalive: true,
83
+ headers: {
84
+ apikey: options.supabaseAnonKey,
85
+ Authorization: `Bearer ${options.supabaseAnonKey}`,
86
+ 'Content-Type': 'application/json',
87
+ 'Content-Profile': options.schema
88
+ },
89
+ body: JSON.stringify({
90
+ p_app: options.app,
91
+ p_version: options.version ?? '',
92
+ p_kind: kind,
93
+ p_message: message.slice(0, 500),
94
+ p_detail: (detail ?? '').slice(0, 4000),
95
+ p_path: typeof location === 'undefined' ? '' : location.pathname
96
+ })
97
+ }).catch(() => {
98
+ /* offline or not set up: drop it */
99
+ });
100
+ };
101
+ }
package/dist/index.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  export { AppState } from './app.svelte.js';
2
2
  export { CloudSync, errorMessage, isRejection, type CloudSyncOptions } from './cloud.svelte.js';
3
- export { createContainerOps, now, persist, uid, type ContainerDocs, type ContainerEntry, type ContainerOps, type ContainerOpsOptions, type RemapHook } from './containers.js';
3
+ export { createContainerOps, now, type ContainerExport, persist, uid, type ContainerDocs, type ContainerEntry, type ContainerOps, type ContainerOpsOptions, type RemapHook } from './containers.js';
4
4
  export { docsEqual, lastWriteWins, openDatabase, setStorageFactory, type Collections, type Database, type OpenDatabaseOptions, type StorageFactory } from './database.js';
5
5
  export { createDataSet, disposeDataSet, type DataSet, type TSCollection } from './dataset.js';
6
6
  export { envConfig, type EnvConfig } from './env.js';
7
+ export { describeError, onWriteError, remoteReporter, reportError, resetErrorReports, setErrorReporter, writeFailed, type ErrorKind, type ErrorReport, type ErrorReporter, type RemoteReporterOptions } from './errors.js';
7
8
  export { Persisted } from './persisted.svelte.js';
8
9
  export { DevicePrefs, defaultPrefsKeys, type DevicePrefsKeys } from './prefs.svelte.js';
9
10
  export { field, flatSchema, type FieldSpec } from './schema.js';
10
11
  export { createSyncedStore, type SyncedStore, type SyncedStoreConfig } from './store.js';
11
- export { createSupabaseApi, friendlyError, type Member, type MembersConfig, type SupabaseApi } from './supabase.js';
12
+ export { adoptSession, createSupabaseApi, defaultStorageKey, friendlyError, replicationClient, schemaNotExposed, type Member, type MembersConfig, type SupabaseApi, type SupabaseOptions } from './supabase.js';
12
13
  export { Tombstones } from './tombstones.svelte.js';
13
14
  export { containerIdOf, type CollectionName, type DocBase, type DocMap, type Location, type Nouns, type Role, type StoreSchema } from './types.js';
package/dist/index.js CHANGED
@@ -4,10 +4,11 @@ export { createContainerOps, now, persist, uid } from './containers.js';
4
4
  export { docsEqual, lastWriteWins, openDatabase, setStorageFactory } from './database.js';
5
5
  export { createDataSet, disposeDataSet } from './dataset.js';
6
6
  export { envConfig } from './env.js';
7
+ export { describeError, onWriteError, remoteReporter, reportError, resetErrorReports, setErrorReporter, writeFailed } from './errors.js';
7
8
  export { Persisted } from './persisted.svelte.js';
8
9
  export { DevicePrefs, defaultPrefsKeys } from './prefs.svelte.js';
9
10
  export { field, flatSchema } from './schema.js';
10
11
  export { createSyncedStore } from './store.js';
11
- export { createSupabaseApi, friendlyError } from './supabase.js';
12
+ export { adoptSession, createSupabaseApi, defaultStorageKey, friendlyError, replicationClient, schemaNotExposed } from './supabase.js';
12
13
  export { Tombstones } from './tombstones.svelte.js';
13
14
  export { containerIdOf } from './types.js';
@@ -1,4 +1,7 @@
1
- /** Reactive value mirrored to localStorage. Storage failures (private mode, quota) are ignored. */
1
+ /**
2
+ * Reactive value mirrored to localStorage. Storage failures (private mode, quota) don't break the
3
+ * app; a failed write is reported once.
4
+ */
2
5
  export declare class Persisted<T> {
3
6
  #private;
4
7
  constructor(key: string, initial: T);
@@ -1,4 +1,8 @@
1
- /** Reactive value mirrored to localStorage. Storage failures (private mode, quota) are ignored. */
1
+ import { reportError } from './errors.js';
2
+ /**
3
+ * Reactive value mirrored to localStorage. Storage failures (private mode, quota) don't break the
4
+ * app; a failed write is reported once.
5
+ */
2
6
  export class Persisted {
3
7
  #key;
4
8
  #value = $state();
@@ -31,8 +35,8 @@ export class Persisted {
31
35
  try {
32
36
  localStorage.setItem(key, JSON.stringify(value));
33
37
  }
34
- catch {
35
- /* ignore */
38
+ catch (err) {
39
+ reportError('prefs', err);
36
40
  }
37
41
  }
38
42
  static remove(key) {
package/dist/sql.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Generates the Supabase migration for an app's tables: `_deleted` / `_modified` replication
3
- * columns with their trigger and checkpoint index, `current_email()`, a membership table with
4
- * an `is_<noun>_member` helper and RLS on every table, the creator-becomes-member trigger, the
5
- * "touch rows on new share" trigger, the members RPC (active / pending) and the realtime
6
- * publication. With `roles`, members are viewer / editor / owner and the last owner is protected.
3
+ * columns with their trigger and checkpoint index, `current_email()` (confirmed emails only), a
4
+ * membership table with an `is_<noun>_member` helper and RLS on every table, the
5
+ * creator-becomes-member trigger, the "touch rows on new share" trigger, the members RPC
6
+ * (active / pending), a write-only `client_errors` table with its `report_client_error`
7
+ * function, and the realtime publication. With `roles`, members are viewer / editor / owner and
8
+ * the last owner is protected. Signed-out callers (`anon`) get nothing but `report_client_error`.
7
9
  *
8
10
  * Pure string building: no database or Svelte dependency, so it runs from a plain Node script.
9
11
  */
@@ -32,6 +34,12 @@ export interface MigrationConfig {
32
34
  roles?: boolean;
33
35
  membersTable?: string;
34
36
  membersRpc?: string;
37
+ /**
38
+ * Postgres schema for every table, type and function (default 'public'). Any other schema is
39
+ * created with the grants Supabase needs; also expose it in the project's Data API settings and
40
+ * pass the same name as `supabaseSchema` to the store.
41
+ */
42
+ schema?: string;
35
43
  }
36
44
  /**
37
45
  * Turn RxDB flat schemas into migration tables. `refs` fields (and the container field) become