@wells-nelson/data-storage 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -7
- package/dist/app.svelte.d.ts +9 -1
- package/dist/app.svelte.js +8 -0
- package/dist/cloud.svelte.d.ts +10 -2
- package/dist/cloud.svelte.js +75 -6
- package/dist/containers.d.ts +26 -6
- package/dist/containers.js +87 -4
- package/dist/database.d.ts +3 -3
- package/dist/dataset.d.ts +3 -3
- 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 +6 -4
- package/dist/sql.js +115 -21
- package/dist/store.d.ts +22 -3
- package/dist/store.js +38 -3
- package/dist/supabase.d.ts +34 -4
- package/dist/supabase.js +79 -17
- package/dist/types.d.ts +10 -6
- package/package.json +1 -1
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
|
-
/**
|
|
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);
|
package/dist/persisted.svelte.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
*/
|
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()
|
|
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
|
*/
|
|
@@ -13,12 +15,13 @@ function inSchema(sql, schema) {
|
|
|
13
15
|
|
|
14
16
|
create schema if not exists ${schema};
|
|
15
17
|
|
|
16
|
-
--
|
|
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.
|
|
18
21
|
grant usage on schema ${schema} to anon, authenticated, service_role;
|
|
19
|
-
alter default privileges in schema ${schema} grant all on tables to
|
|
20
|
-
alter default privileges in schema ${schema} grant all on functions to
|
|
21
|
-
alter default privileges in schema ${schema} grant all on sequences to
|
|
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
|
|
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 (
|
|
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' : ''}
|
|
272
|
-
-- (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
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,8 +18,11 @@ 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,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
|
-
/**
|
|
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
|
/**
|