@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 +64 -5
- package/dist/app.svelte.d.ts +8 -0
- package/dist/app.svelte.js +8 -0
- package/dist/cloud.svelte.d.ts +6 -2
- package/dist/cloud.svelte.js +37 -9
- package/dist/containers.d.ts +21 -1
- package/dist/containers.js +82 -2
- package/dist/errors.d.ts +44 -0
- package/dist/errors.js +101 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/persisted.svelte.d.ts +4 -1
- package/dist/persisted.svelte.js +7 -3
- package/dist/sql.d.ts +12 -4
- package/dist/sql.js +134 -18
- package/dist/store.d.ts +24 -0
- package/dist/store.js +38 -3
- package/dist/supabase.d.ts +60 -6
- package/dist/supabase.js +119 -19
- package/package.json +1 -1
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()
|
|
4
|
-
* an `is_<noun>_member` helper and RLS on every table, the
|
|
5
|
-
* "touch rows on new share" trigger, the members RPC
|
|
6
|
-
*
|
|
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
|
|
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 (
|
|
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' : ''}
|
|
251
|
-
-- (
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/supabase.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
70
|
+
function loadClient() {
|
|
19
71
|
if (!env.cloudConfigured)
|
|
20
|
-
return null;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
27
|
-
|
|
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
|
-
|
|
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
|
+
}
|