@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/dist/sql.js CHANGED
@@ -1,12 +1,34 @@
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
  */
12
+ /** Move everything the migration creates from `public` into `schema` (`auth.*` is untouched). */
13
+ function inSchema(sql, schema) {
14
+ const setup = `-- ---------------------------------------------------------------- schema
15
+
16
+ create schema if not exists ${schema};
17
+
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.
21
+ grant usage on schema ${schema} 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;
25
+
26
+ `;
27
+ return sql
28
+ .replaceAll('public.', `${schema}.`)
29
+ .replaceAll('set search_path = public', `set search_path = ${schema}`)
30
+ .replace('-- ---------------------------------------------------------------- helpers', `${setup}-- ---------------------------------------------------------------- helpers`);
31
+ }
10
32
  const SQL_TYPES = { text: 'text', number: 'double precision', boolean: 'boolean' };
11
33
  const DEFAULTS = { text: "''", number: '0', boolean: 'false' };
12
34
  function columnSql(name, spec) {
@@ -33,7 +55,9 @@ export function tablesFromSchemas(collections, schemas, options) {
33
55
  });
34
56
  }
35
57
  export function generateMigration(config) {
36
- const { container, containerField: cf, noun, roles = false } = config;
58
+ const { container, containerField: cf, noun, roles = false, schema = 'public' } = config;
59
+ if (!/^[a-z_][a-z0-9_]*$/.test(schema))
60
+ throw new Error(`Not a valid schema name: "${schema}".`);
37
61
  const members = config.membersTable ?? `${noun}_members`;
38
62
  const rpc = config.membersRpc ?? `get_${noun}_members`;
39
63
  const isMember = `is_${noun}_member`;
@@ -67,12 +91,20 @@ begin
67
91
  end;
68
92
  $$;
69
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.
70
97
  create or replace function public.current_email()
71
98
  returns text
72
99
  language sql
73
100
  stable
101
+ security definer
102
+ set search_path = ''
74
103
  as $$
75
- 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
+ ), '');
76
108
  $$;
77
109
 
78
110
  -- ---------------------------------------------------------------- membership
@@ -104,14 +136,31 @@ as $$
104
136
  );
105
137
  $$;
106
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
+
107
151
  alter table public.${members} enable row level security;
108
152
  `);
109
153
  if (roles) {
110
154
  add(`
155
+ -- Editors and owners see every member; viewers see the owners and themselves.
111
156
  create policy "members read members" on public.${members}
112
- 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
+ );
113
162
  create policy "owners invite" on public.${members}
114
- 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}));
115
164
  create policy "owners change roles" on public.${members}
116
165
  for update to authenticated using (public.${isMember}(${cf}, 'owner'));
117
166
  -- Owners remove anyone; everyone can remove themselves (leave the ${noun}).
@@ -126,6 +175,9 @@ security definer
126
175
  set search_path = public
127
176
  as $$
128
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;
129
181
  if old.role = 'owner' and (tg_op = 'DELETE' or new.role <> 'owner') then
130
182
  if not exists (
131
183
  select 1 from public.${members}
@@ -150,7 +202,7 @@ create trigger ${members}_keep_owner before update or delete on public.${members
150
202
  create policy "members read memberships of their ${container}" on public.${members}
151
203
  for select to authenticated using (public.${isMember}(${cf}));
152
204
  create policy "members share their ${container}" on public.${members}
153
- 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}));
154
206
  create policy "members unshare their ${container}" on public.${members}
155
207
  for delete to authenticated using (public.${isMember}(${cf}));
156
208
  `);
@@ -185,13 +237,13 @@ ${children.length === 0
185
237
  ${writer
186
238
  ? ` execute format(
187
239
  'create policy "editors insert" on public.%1$s for insert to authenticated
188
- 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}');
189
241
  execute format(
190
242
  'create policy "editors update" on public.%1$s for update to authenticated
191
243
  using (public.${isMember}(${cf}, %2$L)) with check (public.${isMember}(${cf}, %2$L))', t, '${writer}');`
192
244
  : ` execute format(
193
245
  'create policy "members insert" on public.%1$s for insert to authenticated
194
- with check (public.${isMember}(${cf}))', t);
246
+ with check (public.${isMember}(${cf}) and public.is_${noun}_live(${cf}))', t);
195
247
  execute format(
196
248
  'create policy "members update" on public.%1$s for update to authenticated
197
249
  using (public.${isMember}(${cf})) with check (public.${isMember}(${cf}))', t);`}
@@ -199,11 +251,11 @@ ${writer
199
251
  `}end;
200
252
  $$;
201
253
 
202
- -- ${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'}.
203
255
  create policy "members read" on public.${container}
204
256
  for select to authenticated using (public.${isMember}(id));
205
257
  create policy "signed-in users create ${container}" on public.${container}
206
- 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() <> '');
207
259
  create policy "${roles ? 'owners' : 'members'} update" on public.${container}
208
260
  for update to authenticated using (public.${isMember}(id${roles ? ", 'owner'" : ''})) with check (public.${isMember}(id${roles ? ", 'owner'" : ''}));
209
261
 
@@ -247,8 +299,9 @@ $$;
247
299
  create trigger ${members}_touch after insert on public.${members}
248
300
  for each row execute function public.touch_${noun}_for_new_member();
249
301
 
250
- -- Members of a ${noun}${roles ? ' with their role' : ''}, flagging which emails belong to a registered user
251
- -- ("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.' : ''}
252
305
  create or replace function public.${rpc}(p_${cf} text)
253
306
  returns table (id uuid, email text, ${roles ? `role public.${roleType}, ` : ''}active boolean)
254
307
  language sql
@@ -256,19 +309,82 @@ stable
256
309
  security definer
257
310
  set search_path = public, auth
258
311
  as $$
259
- 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
260
316
  from public.${members} m
261
- 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
+ : ''}
262
321
  order by ${roles ? 'm.role desc, ' : ''}m.created_at;
263
322
  $$;
264
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;
265
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;
266
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;
267
382
 
268
383
  -- ---------------------------------------------------------------- realtime
269
384
 
270
385
  alter publication supabase_realtime
271
386
  add table ${all.map((n) => `public.${n}`).join(', ')};
272
387
  `);
273
- return out.join('');
388
+ const sql = out.join('');
389
+ return schema === 'public' ? sql : inSchema(sql, schema);
274
390
  }
package/dist/store.d.ts CHANGED
@@ -22,6 +22,11 @@ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D>
22
22
  storage: StorageFactory;
23
23
  /** viewer / editor / owner roles (needs the roles variant of the SQL migration). */
24
24
  roles?: boolean;
25
+ /**
26
+ * Postgres schema for the app's tables and functions (default 'public'). A custom schema must be
27
+ * exposed in the Supabase project's Data API settings; generate its SQL with the same `schema`.
28
+ */
29
+ supabaseSchema?: string;
25
30
  /** Override membership table / RPC names (defaults: `<noun>_members`, `get_<noun>_members`). */
26
31
  members?: Partial<Omit<MembersConfig, 'roles' | 'containerField'>>;
27
32
  /** Fields per collection holding ids of other rows in the container (rewritten when copying). */
@@ -32,6 +37,19 @@ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D>
32
37
  remap?: RemapHook<D>;
33
38
  /** A `DevicePrefs` subclass with app-specific prefs. */
34
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
+ };
35
53
  }
36
54
  /**
37
55
  * Wire the whole storage layer for an app from one config: app state (local + cloud data
@@ -63,6 +81,11 @@ export declare function createSyncedStore<D extends DocMap, C extends Collection
63
81
  containerId: string;
64
82
  };
65
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[]>;
66
89
  makeCloud: (containerId: string) => Promise<void>;
67
90
  makeLocal: (containerId: string) => Promise<string>;
68
91
  deleteContainer: (ds: DataSet<D>, containerId: string) => Promise<void>;
@@ -75,5 +98,6 @@ export declare function createSyncedStore<D extends DocMap, C extends Collection
75
98
  beforeReady?: (local: DataSet<D>) => Promise<void>;
76
99
  }) => Promise<void>;
77
100
  resetDevice: () => Promise<void>;
101
+ requestPersistence: () => Promise<boolean | null>;
78
102
  };
79
103
  export type SyncedStore<D extends DocMap, 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
- });
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,15 +18,46 @@ 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
+ export interface SupabaseOptions {
30
+ /**
31
+ * Postgres schema holding the app's tables and functions (default 'public'). A custom schema
32
+ * must be listed under the Supabase project's exposed schemas (Data API settings).
33
+ */
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;
41
+ }
26
42
  export interface SupabaseApi {
27
43
  readonly configured: boolean;
28
- /** The shared client (PKCE, created on first use), or null in local-only mode. */
44
+ /** The Postgres schema the client reads and writes. */
45
+ readonly schema: string;
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. */
29
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;
30
61
  fetchMembers(containerId: string): Promise<Member[]>;
31
62
  inviteMember(containerId: string, email: string, role?: Role): Promise<void>;
32
63
  setMemberRole(memberId: string, role: Role): Promise<void>;
@@ -36,10 +67,33 @@ export interface SupabaseApi {
36
67
  /** Every container the user belongs to, with their role (null without roles). */
37
68
  fetchMyMemberships(email: string): Promise<Map<string, Role | null>>;
38
69
  }
70
+ /**
71
+ * PostgREST refuses a schema that isn't exposed in the project's Data API settings (PGRST106,
72
+ * "Invalid schema: …"). Returns the fix to show instead, or null for other errors.
73
+ */
74
+ export declare function schemaNotExposed(error: {
75
+ message?: string;
76
+ code?: string;
77
+ } | undefined, schema: string): string | null;
39
78
  /** Turn Postgres errors from triggers / RLS into messages a person can act on. */
40
79
  export declare function friendlyError(error: {
41
80
  message: string;
42
81
  code?: string;
43
- }, noun: string): Error;
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;
44
91
  /** Supabase client plus the members API, with table and RPC names from `members`. */
45
- export declare function createSupabaseApi(env: EnvConfig, members: MembersConfig): SupabaseApi;
92
+ export declare function createSupabaseApi(env: EnvConfig, members: MembersConfig, options?: SupabaseOptions): SupabaseApi;
93
+ /**
94
+ * The client RxDB's Supabase replication should use. Its pulls and pushes go through
95
+ * `client.from()`, which follows the client's schema, but its realtime subscription always
96
+ * asks for schema 'public'. For any other schema this wraps the three methods the replication
97
+ * calls and points that subscription at the right schema.
98
+ */
99
+ export declare function replicationClient(client: SupabaseClient, schema: string): SupabaseClient;
package/dist/supabase.js CHANGED
@@ -1,6 +1,19 @@
1
- import { createClient } from '@supabase/supabase-js';
1
+ /**
2
+ * PostgREST refuses a schema that isn't exposed in the project's Data API settings (PGRST106,
3
+ * "Invalid schema: …"). Returns the fix to show instead, or null for other errors.
4
+ */
5
+ export function schemaNotExposed(error, schema) {
6
+ if (!error)
7
+ return null;
8
+ if (error.code !== 'PGRST106' && !/invalid schema|schema must be one of/i.test(error.message ?? ''))
9
+ return null;
10
+ return `Expose the ${schema} schema in Supabase (Project Settings → Data API).`;
11
+ }
2
12
  /** Turn Postgres errors from triggers / RLS into messages a person can act on. */
3
- export function friendlyError(error, noun) {
13
+ export function friendlyError(error, noun, schema = 'public') {
14
+ const unexposed = schemaNotExposed(error, schema);
15
+ if (unexposed)
16
+ return new Error(unexposed);
4
17
  if (error.message.includes('last owner')) {
5
18
  return new Error(`A ${noun} must always have at least one owner. Make someone else an owner first.`);
6
19
  }
@@ -10,34 +23,100 @@ export function friendlyError(error, noun) {
10
23
  return new Error(`That email already has access to this ${noun}.`);
11
24
  return new Error(error.message);
12
25
  }
26
+ /** supabase-js's own session key for a project URL. */
27
+ export function defaultStorageKey(supabaseUrl) {
28
+ try {
29
+ return `sb-${new URL(supabaseUrl).hostname.split('.')[0]}-auth-token`;
30
+ }
31
+ catch {
32
+ return 'sb-auth-token';
33
+ }
34
+ }
35
+ /**
36
+ * Move a session stored under supabase-js's default key to `storageKey` (once, when the new key
37
+ * is empty). The old key is removed: two apps holding the same refresh token would revoke each
38
+ * other's session, so only the first app to start keeps it and the other signs in again.
39
+ */
40
+ export function adoptSession(legacyKey, storageKey) {
41
+ if (legacyKey === storageKey)
42
+ return;
43
+ try {
44
+ if (localStorage.getItem(storageKey) !== null)
45
+ return;
46
+ const value = localStorage.getItem(legacyKey);
47
+ if (value === null)
48
+ return;
49
+ localStorage.setItem(storageKey, value);
50
+ localStorage.removeItem(legacyKey);
51
+ const verifier = localStorage.getItem(`${legacyKey}-code-verifier`);
52
+ if (verifier !== null) {
53
+ localStorage.setItem(`${storageKey}-code-verifier`, verifier);
54
+ localStorage.removeItem(`${legacyKey}-code-verifier`);
55
+ }
56
+ }
57
+ catch {
58
+ /* storage unavailable: nothing to move */
59
+ }
60
+ }
13
61
  /** Supabase client plus the members API, with table and RPC names from `members`. */
14
- export function createSupabaseApi(env, members) {
62
+ export function createSupabaseApi(env, members, options = {}) {
15
63
  let client = null;
64
+ let loading = null;
65
+ const schema = options.schema ?? 'public';
66
+ const legacyKey = defaultStorageKey(env.supabaseUrl);
67
+ const storageKey = options.storageKey ?? legacyKey;
16
68
  const { table, containerField, membersRpc, roles, noun } = members;
17
69
  const rpcParam = members.rpcParam ?? `p_${containerField}`;
18
- function getClient() {
70
+ function loadClient() {
19
71
  if (!env.cloudConfigured)
20
- return null;
21
- client ??= createClient(env.supabaseUrl, env.supabaseAnonKey, {
22
- auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
23
- });
24
- return client;
72
+ return Promise.resolve(null);
73
+ loading ??= (async () => {
74
+ const { createClient } = await import('@supabase/supabase-js');
75
+ adoptSession(legacyKey, storageKey);
76
+ client = createClient(env.supabaseUrl, env.supabaseAnonKey, {
77
+ db: { schema },
78
+ auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, storageKey }
79
+ });
80
+ return client;
81
+ })();
82
+ // A failed download (offline on first use) can be retried later.
83
+ loading.catch(() => (loading = null));
84
+ return loading;
25
85
  }
26
- function requireClient() {
27
- const sb = getClient();
86
+ function mayHaveSession() {
87
+ if (!env.cloudConfigured)
88
+ return false;
89
+ try {
90
+ if (localStorage.getItem(storageKey) !== null || localStorage.getItem(legacyKey) !== null)
91
+ return true;
92
+ }
93
+ catch {
94
+ /* no storage: only a redirect can carry a session */
95
+ }
96
+ if (typeof location === 'undefined')
97
+ return false;
98
+ const params = new URLSearchParams(location.search);
99
+ return params.has('code') || params.has('error_description') || /access_token|error_description/.test(location.hash);
100
+ }
101
+ async function requireClient() {
102
+ const sb = await loadClient();
28
103
  if (!sb)
29
104
  throw new Error('Cloud sync is not configured.');
30
105
  return sb;
31
106
  }
32
107
  const fail = (error) => {
33
108
  if (error)
34
- throw friendlyError(error, noun);
109
+ throw friendlyError(error, noun, schema);
35
110
  };
36
111
  return {
37
112
  configured: env.cloudConfigured,
38
- getClient,
113
+ schema,
114
+ storageKey,
115
+ loadClient,
116
+ getClient: () => client,
117
+ mayHaveSession,
39
118
  async fetchMembers(containerId) {
40
- const { data, error } = await requireClient().rpc(membersRpc, { [rpcParam]: containerId });
119
+ const { data, error } = await (await requireClient()).rpc(membersRpc, { [rpcParam]: containerId });
41
120
  fail(error);
42
121
  return (data ?? []);
43
122
  },
@@ -45,21 +124,21 @@ export function createSupabaseApi(env, members) {
45
124
  const row = { [containerField]: containerId, email: email.trim().toLowerCase() };
46
125
  if (roles)
47
126
  row.role = role ?? 'viewer';
48
- const { error } = await requireClient().from(table).insert(row);
127
+ const { error } = await (await requireClient()).from(table).insert(row);
49
128
  fail(error);
50
129
  },
51
130
  async setMemberRole(memberId, role) {
52
131
  if (!roles)
53
132
  throw new Error('Roles are not enabled.');
54
- const { error } = await requireClient().from(table).update({ role }).eq('id', memberId);
133
+ const { error } = await (await requireClient()).from(table).update({ role }).eq('id', memberId);
55
134
  fail(error);
56
135
  },
57
136
  async removeMember(memberId) {
58
- const { error } = await requireClient().from(table).delete().eq('id', memberId);
137
+ const { error } = await (await requireClient()).from(table).delete().eq('id', memberId);
59
138
  fail(error);
60
139
  },
61
140
  async leave(containerId, email) {
62
- const { error } = await requireClient()
141
+ const { error } = await (await requireClient())
63
142
  .from(table)
64
143
  .delete()
65
144
  .eq(containerField, containerId)
@@ -68,10 +147,31 @@ export function createSupabaseApi(env, members) {
68
147
  },
69
148
  async fetchMyMemberships(email) {
70
149
  const columns = roles ? `${containerField}, role` : containerField;
71
- const { data, error } = await requireClient().from(table).select(columns).eq('email', email.toLowerCase());
150
+ const { data, error } = await (await requireClient()).from(table).select(columns).eq('email', email.toLowerCase());
72
151
  fail(error);
73
152
  const rows = (data ?? []);
74
153
  return new Map(rows.map((r) => [r[containerField], roles ? r.role : null]));
75
154
  }
76
155
  };
77
156
  }
157
+ /**
158
+ * The client RxDB's Supabase replication should use. Its pulls and pushes go through
159
+ * `client.from()`, which follows the client's schema, but its realtime subscription always
160
+ * asks for schema 'public'. For any other schema this wraps the three methods the replication
161
+ * calls and points that subscription at the right schema.
162
+ */
163
+ export function replicationClient(client, schema) {
164
+ if (schema === 'public')
165
+ return client;
166
+ const adapter = {
167
+ from: (table) => client.from(table),
168
+ removeChannel: (channel) => client.removeChannel(channel),
169
+ channel: (name, opts) => {
170
+ const channel = client.channel(name, opts);
171
+ const on = channel.on.bind(channel);
172
+ channel.on = (type, filter, cb) => on(type, type === 'postgres_changes' ? { ...filter, schema } : filter, cb);
173
+ return channel;
174
+ }
175
+ };
176
+ return adapter;
177
+ }