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