@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/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, replicationClient, schemaNotExposed, type Member, type MembersConfig, type SupabaseApi, type SupabaseOptions } 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, replicationClient, schemaNotExposed } 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
  */
package/dist/sql.js 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
  */
@@ -13,12 +15,13 @@ function inSchema(sql, schema) {
13
15
 
14
16
  create schema if not exists ${schema};
15
17
 
16
- -- The API roles need the same access Supabase grants on public; row level security on every
17
- -- table still decides what each user can read and write.
18
+ -- Signed-in users (authenticated) get table access, and row level security on every table decides
19
+ -- what each one can read and write. Signed-out callers (anon) get none, so a table added later
20
+ -- without RLS still isn't public; anon only reaches report_client_error, granted below.
18
21
  grant usage on schema ${schema} to anon, authenticated, service_role;
19
- alter default privileges in schema ${schema} grant all on tables to anon, authenticated, service_role;
20
- alter default privileges in schema ${schema} grant all on functions to anon, authenticated, service_role;
21
- alter default privileges in schema ${schema} grant all on sequences to anon, authenticated, service_role;
22
+ alter default privileges in schema ${schema} grant all on tables to authenticated, service_role;
23
+ alter default privileges in schema ${schema} grant all on functions to authenticated, service_role;
24
+ alter default privileges in schema ${schema} grant all on sequences to authenticated, service_role;
22
25
 
23
26
  `;
24
27
  return sql
@@ -88,12 +91,20 @@ begin
88
91
  end;
89
92
  $$;
90
93
 
94
+ -- The signed-in user's email, only once confirmed: a session whose email was never verified
95
+ -- (e.g. a password sign-up with confirmation off) matches no membership. Read from auth.users,
96
+ -- not the token, so a changed email applies right away.
91
97
  create or replace function public.current_email()
92
98
  returns text
93
99
  language sql
94
100
  stable
101
+ security definer
102
+ set search_path = ''
95
103
  as $$
96
- select lower(coalesce(auth.jwt() ->> 'email', ''));
104
+ select coalesce((
105
+ select lower(u.email) from auth.users u
106
+ where u.id = auth.uid() and u.email_confirmed_at is not null
107
+ ), '');
97
108
  $$;
98
109
 
99
110
  -- ---------------------------------------------------------------- membership
@@ -125,14 +136,31 @@ as $$
125
136
  );
126
137
  $$;
127
138
 
139
+ -- False once the ${noun} is deleted: nobody can add members or new rows to it. (Members still
140
+ -- read and update its rows, so every device syncs the deletion.)
141
+ create or replace function public.is_${noun}_live(p_${cf} text)
142
+ returns boolean
143
+ language sql
144
+ stable
145
+ security definer
146
+ set search_path = public
147
+ as $$
148
+ select not exists (select 1 from public.${container} c where c.id = p_${cf} and c._deleted);
149
+ $$;
150
+
128
151
  alter table public.${members} enable row level security;
129
152
  `);
130
153
  if (roles) {
131
154
  add(`
155
+ -- Editors and owners see every member; viewers see the owners and themselves.
132
156
  create policy "members read members" on public.${members}
133
- for select to authenticated using (public.${isMember}(${cf}));
157
+ for select to authenticated using (
158
+ email = public.current_email()
159
+ or public.${isMember}(${cf}, 'editor')
160
+ or (role = 'owner' and public.${isMember}(${cf}))
161
+ );
134
162
  create policy "owners invite" on public.${members}
135
- for insert to authenticated with check (public.${isMember}(${cf}, 'owner'));
163
+ for insert to authenticated with check (public.${isMember}(${cf}, 'owner') and public.is_${noun}_live(${cf}));
136
164
  create policy "owners change roles" on public.${members}
137
165
  for update to authenticated using (public.${isMember}(${cf}, 'owner'));
138
166
  -- Owners remove anyone; everyone can remove themselves (leave the ${noun}).
@@ -147,6 +175,9 @@ security definer
147
175
  set search_path = public
148
176
  as $$
149
177
  begin
178
+ -- Lock the ${noun} first, so two owners demoting each other at once can't both see "another
179
+ -- owner exists".
180
+ perform 1 from public.${container} where id = old.${cf} for update;
150
181
  if old.role = 'owner' and (tg_op = 'DELETE' or new.role <> 'owner') then
151
182
  if not exists (
152
183
  select 1 from public.${members}
@@ -171,7 +202,7 @@ create trigger ${members}_keep_owner before update or delete on public.${members
171
202
  create policy "members read memberships of their ${container}" on public.${members}
172
203
  for select to authenticated using (public.${isMember}(${cf}));
173
204
  create policy "members share their ${container}" on public.${members}
174
- for insert to authenticated with check (public.${isMember}(${cf}));
205
+ for insert to authenticated with check (public.${isMember}(${cf}) and public.is_${noun}_live(${cf}));
175
206
  create policy "members unshare their ${container}" on public.${members}
176
207
  for delete to authenticated using (public.${isMember}(${cf}));
177
208
  `);
@@ -206,13 +237,13 @@ ${children.length === 0
206
237
  ${writer
207
238
  ? ` execute format(
208
239
  'create policy "editors insert" on public.%1$s for insert to authenticated
209
- with check (public.${isMember}(${cf}, %2$L))', t, '${writer}');
240
+ with check (public.${isMember}(${cf}, %2$L) and public.is_${noun}_live(${cf}))', t, '${writer}');
210
241
  execute format(
211
242
  'create policy "editors update" on public.%1$s for update to authenticated
212
243
  using (public.${isMember}(${cf}, %2$L)) with check (public.${isMember}(${cf}, %2$L))', t, '${writer}');`
213
244
  : ` execute format(
214
245
  'create policy "members insert" on public.%1$s for insert to authenticated
215
- with check (public.${isMember}(${cf}))', t);
246
+ with check (public.${isMember}(${cf}) and public.is_${noun}_live(${cf}))', t);
216
247
  execute format(
217
248
  'create policy "members update" on public.%1$s for update to authenticated
218
249
  using (public.${isMember}(${cf})) with check (public.${isMember}(${cf}))', t);`}
@@ -220,11 +251,11 @@ ${writer
220
251
  `}end;
221
252
  $$;
222
253
 
223
- -- ${cap(container)}: any signed-in user creates one (and becomes ${roles ? 'its owner' : 'a member'}); ${roles ? 'only owners rename / delete' : 'members rename / delete'}.
254
+ -- ${cap(container)}: any signed-in user with a confirmed email creates one (and becomes ${roles ? 'its owner' : 'a member'}); ${roles ? 'only owners rename / delete' : 'members rename / delete'}.
224
255
  create policy "members read" on public.${container}
225
256
  for select to authenticated using (public.${isMember}(id));
226
257
  create policy "signed-in users create ${container}" on public.${container}
227
- for insert to authenticated with check (auth.uid() is not null);
258
+ for insert to authenticated with check (auth.uid() is not null and public.current_email() <> '');
228
259
  create policy "${roles ? 'owners' : 'members'} update" on public.${container}
229
260
  for update to authenticated using (public.${isMember}(id${roles ? ", 'owner'" : ''})) with check (public.${isMember}(id${roles ? ", 'owner'" : ''}));
230
261
 
@@ -268,8 +299,9 @@ $$;
268
299
  create trigger ${members}_touch after insert on public.${members}
269
300
  for each row execute function public.touch_${noun}_for_new_member();
270
301
 
271
- -- Members of a ${noun}${roles ? ' with their role' : ''}, flagging which emails belong to a registered user
272
- -- ("active") vs pending invites.
302
+ -- Members of a ${noun}${roles ? ' with their role' : ''}. "active" says whether an invited person has signed in
303
+ -- since being invited (false: the invite is still pending); it never reveals whether an email has
304
+ -- an account.${roles ? '\n-- Only owners see it (null for others). Viewers only see the owners and themselves.' : ''}
273
305
  create or replace function public.${rpc}(p_${cf} text)
274
306
  returns table (id uuid, email text, ${roles ? `role public.${roleType}, ` : ''}active boolean)
275
307
  language sql
@@ -277,14 +309,76 @@ stable
277
309
  security definer
278
310
  set search_path = public, auth
279
311
  as $$
280
- select m.id, m.email, ${roles ? 'm.role, ' : ''}exists (select 1 from auth.users u where lower(u.email) = m.email) as active
312
+ select m.id, m.email,${roles ? ' m.role,' : ''}
313
+ ${roles ? `case when public.${isMember}(p_${cf}, 'owner') or m.email = public.current_email() then ` : ''}m.email = public.current_email() or exists (
314
+ select 1 from auth.users u where lower(u.email) = m.email and u.last_sign_in_at >= m.created_at
315
+ )${roles ? ' end' : ''} as active
281
316
  from public.${members} m
282
- where m.${cf} = p_${cf} and public.${isMember}(p_${cf})
317
+ where m.${cf} = p_${cf} and public.${isMember}(p_${cf})${roles
318
+ ? `
319
+ and (public.${isMember}(p_${cf}, 'editor') or m.role = 'owner' or m.email = public.current_email())`
320
+ : ''}
283
321
  order by ${roles ? 'm.role desc, ' : ''}m.created_at;
284
322
  $$;
285
323
 
324
+ -- ---------------------------------------------------------------- client errors
325
+
326
+ -- Errors the app reports (failed local writes, sync errors, crashes), written only through
327
+ -- report_client_error. No policies: nobody reads them through the API; use the dashboard or the
328
+ -- service role.
329
+ create table public.client_errors (
330
+ id bigint generated always as identity primary key,
331
+ created_at timestamptz not null default now(),
332
+ app text not null,
333
+ version text not null,
334
+ kind text not null,
335
+ message text not null,
336
+ detail text not null,
337
+ path text not null,
338
+ user_id uuid
339
+ );
340
+ create index client_errors_created_idx on public.client_errors (created_at);
341
+ alter table public.client_errors enable row level security;
342
+ revoke all on public.client_errors from anon, authenticated;
343
+
344
+ create or replace function public.report_client_error(
345
+ p_app text, p_version text, p_kind text, p_message text, p_detail text, p_path text
346
+ )
347
+ returns void
348
+ language plpgsql
349
+ security definer
350
+ set search_path = public
351
+ as $$
352
+ begin
353
+ -- At most 300 reports an hour from everyone together, so a loop or abuse can't fill the
354
+ -- database; reports are kept for 30 days.
355
+ if (select count(*) from public.client_errors where created_at > now() - interval '1 hour') >= 300 then
356
+ return;
357
+ end if;
358
+ delete from public.client_errors where created_at < now() - interval '30 days';
359
+ insert into public.client_errors (app, version, kind, message, detail, path, user_id)
360
+ values (
361
+ left(coalesce(p_app, ''), 40), left(coalesce(p_version, ''), 40), left(coalesce(p_kind, ''), 40),
362
+ left(coalesce(p_message, ''), 500), left(coalesce(p_detail, ''), 4000), left(coalesce(p_path, ''), 200),
363
+ auth.uid()
364
+ );
365
+ end;
366
+ $$;
367
+
368
+ -- ---------------------------------------------------------------- function privileges
369
+
370
+ -- Postgres lets everyone (public) execute new functions. Only signed-in users may call these,
371
+ -- and signed-out callers only report errors.
372
+ revoke all on function public.current_email() from public, anon;
373
+ revoke all on function public.${isMember}(text${roles ? `, public.${roleType}` : ''}) from public, anon;
374
+ revoke all on function public.is_${noun}_live(text) from public, anon;
286
375
  revoke all on function public.${rpc}(text) from public, anon;
376
+ revoke all on function public.report_client_error(text, text, text, text, text, text) from public;
377
+ grant execute on function public.current_email() to authenticated;
378
+ grant execute on function public.${isMember}(text${roles ? `, public.${roleType}` : ''}) to authenticated;
379
+ grant execute on function public.is_${noun}_live(text) to authenticated;
287
380
  grant execute on function public.${rpc}(text) to authenticated;
381
+ grant execute on function public.report_client_error(text, text, text, text, text, text) to anon, authenticated;
288
382
 
289
383
  -- ---------------------------------------------------------------- realtime
290
384
 
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;
@@ -37,13 +37,26 @@ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D>
37
37
  remap?: RemapHook<D>;
38
38
  /** A `DevicePrefs` subclass with app-specific prefs. */
39
39
  prefs?: DevicePrefs;
40
+ /**
41
+ * localStorage key of the Supabase session (default `<appKey>-auth`), so apps sharing an
42
+ * origin don't share, or sign each other out of, a session.
43
+ */
44
+ authStorageKey?: string;
45
+ /**
46
+ * Send failed writes, sync errors, failed pref saves and uncaught errors to the
47
+ * `report_client_error` function of the app's Supabase schema (from the generated migration).
48
+ * Pass the deployed version (`version` from `$app/env`). Off when cloud sync isn't configured.
49
+ */
50
+ reportErrors?: {
51
+ version?: string;
52
+ };
40
53
  }
41
54
  /**
42
55
  * Wire the whole storage layer for an app from one config: app state (local + cloud data
43
56
  * sets), cloud sync, tombstones, device prefs, the Supabase members API and container
44
57
  * operations. Create it once in a module and import it where needed.
45
58
  */
46
- 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>): {
47
60
  config: SyncedStoreConfig<D, C>;
48
61
  roles: boolean;
49
62
  app: AppState<D>;
@@ -68,6 +81,11 @@ export declare function createSyncedStore<D extends DocMap, C extends Collection
68
81
  containerId: string;
69
82
  };
70
83
  copyContainer: (from: DataSet<D>, to: DataSet<D>, containerId: string, newIds: boolean) => Promise<string>;
84
+ exportContainers: (items: {
85
+ id: string;
86
+ location: import("./types.js").Location;
87
+ }[]) => Promise<import("./containers.js").ContainerExport>;
88
+ importContainers: (data: unknown) => Promise<string[]>;
71
89
  makeCloud: (containerId: string) => Promise<void>;
72
90
  makeLocal: (containerId: string) => Promise<string>;
73
91
  deleteContainer: (ds: DataSet<D>, containerId: string) => Promise<void>;
@@ -80,5 +98,6 @@ export declare function createSyncedStore<D extends DocMap, C extends Collection
80
98
  beforeReady?: (local: DataSet<D>) => Promise<void>;
81
99
  }) => Promise<void>;
82
100
  resetDevice: () => Promise<void>;
101
+ requestPersistence: () => Promise<boolean | null>;
83
102
  };
84
- 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/store.js CHANGED
@@ -4,6 +4,7 @@ import { CloudSync } from './cloud.svelte.js';
4
4
  import { createContainerOps } from './containers.js';
5
5
  import { openDatabase, setStorageFactory } from './database.js';
6
6
  import { createDataSet } from './dataset.js';
7
+ import { onWriteError, remoteReporter, reportError, setErrorReporter } from './errors.js';
7
8
  import { DevicePrefs, defaultPrefsKeys } from './prefs.svelte.js';
8
9
  import { createSupabaseApi } from './supabase.js';
9
10
  import { Tombstones } from './tombstones.svelte.js';
@@ -21,7 +22,7 @@ export function createSyncedStore(config) {
21
22
  ...config.members,
22
23
  containerField: config.containerField,
23
24
  roles
24
- }, { schema: config.supabaseSchema });
25
+ }, { schema: config.supabaseSchema, storageKey: config.authStorageKey ?? `${config.appKey}-auth` });
25
26
  const app = new AppState();
26
27
  const tombstones = new Tombstones(config.appKey);
27
28
  const prefs = config.prefs ?? new DevicePrefs(defaultPrefsKeys(config.appKey, config.nouns));
@@ -42,8 +43,39 @@ export function createSyncedStore(config) {
42
43
  supabase,
43
44
  roles,
44
45
  refs: config.refs,
45
- remap: config.remap
46
+ remap: config.remap,
47
+ appKey: config.appKey
46
48
  });
49
+ onWriteError((message) => (app.writeError = message));
50
+ /**
51
+ * Ask the browser to keep this site's data (it may decide on its own, or ask the user). The
52
+ * result is `app.persisted`. Called on start; call it again from a button to retry.
53
+ */
54
+ async function requestPersistence() {
55
+ const storage = typeof navigator === 'undefined' ? undefined : navigator.storage;
56
+ if (!storage?.persisted || !storage.persist)
57
+ return (app.persisted = null);
58
+ try {
59
+ app.persisted = (await storage.persisted()) || (await storage.persist());
60
+ }
61
+ catch {
62
+ app.persisted = null;
63
+ }
64
+ return app.persisted;
65
+ }
66
+ function startErrorReporting() {
67
+ if (!config.reportErrors || !config.env.cloudConfigured || typeof window === 'undefined')
68
+ return;
69
+ setErrorReporter(remoteReporter({
70
+ supabaseUrl: config.env.supabaseUrl,
71
+ supabaseAnonKey: config.env.supabaseAnonKey,
72
+ schema: supabase.schema,
73
+ app: config.appKey,
74
+ version: config.reportErrors.version
75
+ }));
76
+ window.addEventListener('error', (e) => reportError('unhandled', e.error ?? e.message));
77
+ window.addEventListener('unhandledrejection', (e) => reportError('unhandled', e.reason));
78
+ }
47
79
  let started;
48
80
  /**
49
81
  * Open the local database, then restore any Supabase session (which opens the cloud
@@ -51,15 +83,18 @@ export function createSyncedStore(config) {
51
83
  */
52
84
  function init(options = {}) {
53
85
  started ??= (async () => {
86
+ startErrorReporting();
54
87
  setStorageFactory(config.storage);
55
88
  const db = await openDatabase(`${config.appKey}_local`, config, { storage: config.storage });
56
89
  const local = createDataSet('local', db, config);
57
90
  await options.beforeReady?.(local).catch((err) => console.error('Startup hook failed', err));
58
91
  app.local = local;
59
92
  app.ready = true;
93
+ void requestPersistence();
60
94
  await cloud.init();
61
95
  })().catch((err) => {
62
96
  console.error(err);
97
+ reportError('fatal', err);
63
98
  app.fatal = err instanceof Error ? err.message : String(err);
64
99
  });
65
100
  return started;
@@ -82,5 +117,5 @@ export function createSyncedStore(config) {
82
117
  app.local = null;
83
118
  await local?.db.remove();
84
119
  }
85
- return { config, roles, app, cloud, tombstones, prefs, supabase, ops, init, resetDevice };
120
+ return { config, roles, app, cloud, tombstones, prefs, supabase, ops, init, resetDevice, requestPersistence };
86
121
  }
@@ -1,4 +1,4 @@
1
- import { type SupabaseClient } from '@supabase/supabase-js';
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
2
  import type { EnvConfig } from './env.js';
3
3
  import type { Role } from './types.js';
4
4
  export interface MembersConfig {
@@ -18,8 +18,11 @@ export interface MembersConfig {
18
18
  export interface Member {
19
19
  id: string;
20
20
  email: string;
21
- /** True when the email belongs to a registered user; false means the invite is pending. */
22
- active: boolean;
21
+ /**
22
+ * True once the invited person has signed in since being invited; false while the invite is
23
+ * pending. Null when the caller may not see it (only owners do, with roles).
24
+ */
25
+ active: boolean | null;
23
26
  /** Only when roles are on. */
24
27
  role?: Role | null;
25
28
  }
@@ -29,13 +32,32 @@ export interface SupabaseOptions {
29
32
  * must be listed under the Supabase project's exposed schemas (Data API settings).
30
33
  */
31
34
  schema?: string;
35
+ /**
36
+ * localStorage key of the signed-in session. Give every app on an origin its own key, so
37
+ * signing in or out of one doesn't affect another. Default: supabase-js's `sb-<project>-auth-token`,
38
+ * which a session found there is moved from once.
39
+ */
40
+ storageKey?: string;
32
41
  }
33
42
  export interface SupabaseApi {
34
43
  readonly configured: boolean;
35
44
  /** The Postgres schema the client reads and writes. */
36
45
  readonly schema: string;
37
- /** The shared client (PKCE, created on first use), or null in local-only mode. */
46
+ /** localStorage key of the session. */
47
+ readonly storageKey: string;
48
+ /**
49
+ * Load supabase-js and create the shared client (PKCE), once. Null in local-only mode. The
50
+ * library is only downloaded when something needs it: a stored session, a sign-in redirect,
51
+ * or a sign-in.
52
+ */
53
+ loadClient(): Promise<SupabaseClient | null>;
54
+ /** The client once `loadClient` has created it, otherwise null. */
38
55
  getClient(): SupabaseClient | null;
56
+ /**
57
+ * Whether a session may exist without loading supabase-js: one is stored, or the page is a
58
+ * sign-in redirect (PKCE `code` or an error in the URL).
59
+ */
60
+ mayHaveSession(): boolean;
39
61
  fetchMembers(containerId: string): Promise<Member[]>;
40
62
  inviteMember(containerId: string, email: string, role?: Role): Promise<void>;
41
63
  setMemberRole(memberId: string, role: Role): Promise<void>;
@@ -58,6 +80,14 @@ export declare function friendlyError(error: {
58
80
  message: string;
59
81
  code?: string;
60
82
  }, noun: string, schema?: string): Error;
83
+ /** supabase-js's own session key for a project URL. */
84
+ export declare function defaultStorageKey(supabaseUrl: string): string;
85
+ /**
86
+ * Move a session stored under supabase-js's default key to `storageKey` (once, when the new key
87
+ * is empty). The old key is removed: two apps holding the same refresh token would revoke each
88
+ * other's session, so only the first app to start keeps it and the other signs in again.
89
+ */
90
+ export declare function adoptSession(legacyKey: string, storageKey: string): void;
61
91
  /** Supabase client plus the members API, with table and RPC names from `members`. */
62
92
  export declare function createSupabaseApi(env: EnvConfig, members: MembersConfig, options?: SupabaseOptions): SupabaseApi;
63
93
  /**