@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/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Peer dependencies: `svelte`, `rxdb`, `rxjs`, `@tanstack/db`, `@tanstack/rxdb-db-
|
|
|
14
14
|
import { createSyncedStore, envConfig, field, flatSchema } from '@wells-nelson/data-storage';
|
|
15
15
|
import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
|
|
16
16
|
|
|
17
|
+
// Docs maps collection names to document types, as a `type` or an `interface`.
|
|
17
18
|
// Pass the container collection as the second type argument so its rows are typed precisely.
|
|
18
19
|
export const store = createSyncedStore<Docs, 'projects'>({
|
|
19
20
|
appKey: 'demo', // demo_local, demo_cloud_<user>, demo:hidden, demo:sync:<user>, demo:defaultProject…
|
|
@@ -33,7 +34,9 @@ export const store = createSyncedStore<Docs, 'projects'>({
|
|
|
33
34
|
if (collection === 'task_links') return { ...row, id: `${row.from_id}:${row.to_id}` };
|
|
34
35
|
if (collection === 'notes') return { ...row, mentions: row.mentions.split(',').map(map).join(',') };
|
|
35
36
|
return row;
|
|
36
|
-
}
|
|
37
|
+
},
|
|
38
|
+
// Optional: report failed writes, sync errors and crashes (needs report_client_error, below).
|
|
39
|
+
reportErrors: { version } // from '$app/env'
|
|
37
40
|
});
|
|
38
41
|
```
|
|
39
42
|
|
|
@@ -45,22 +48,50 @@ onMount(() => void store.init({ beforeReady: importDevSeed }));
|
|
|
45
48
|
`store` holds:
|
|
46
49
|
|
|
47
50
|
- `app`: `local` / `cloud` data sets (`DataSet`: the RxDB database plus a TanStack DB
|
|
48
|
-
collection per RxDB collection), `ready`, `fatal
|
|
51
|
+
collection per RxDB collection), `ready`, `fatal`, `writeError` (a local write that failed;
|
|
52
|
+
show it until dismissed) and `persisted` (whether the browser keeps the data for good).
|
|
49
53
|
- `cloud`: `CloudSync`: session (Google, email link), replication, `pendingIds`, `hasPending`,
|
|
50
54
|
`lastSync`, `online`, `active`, `error`, `rejectedWrite`, `roleOf`, `revokedIds`,
|
|
51
|
-
`forceResync`, `signOut`.
|
|
55
|
+
`forceResync`, `signOut`. Each collection replicates on its own; a container created in or moved to
|
|
56
|
+
the cloud (`createContainer`, `makeCloud`) holds its other rows back until its own row has reached
|
|
57
|
+
Supabase (`expectNewContainer`, at most 20 s), so row level security doesn't refuse them.
|
|
52
58
|
- `ops`: container operations (`createContainer`, `renameContainer`, `makeCloud`, `makeLocal`,
|
|
53
|
-
`deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries
|
|
59
|
+
`deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`,
|
|
60
|
+
`exportContainers`, `importContainers`) and the row
|
|
54
61
|
helpers they're built from (`insertDoc`, `patch`, `removeDocs`, `docsWhere`, `touch`…).
|
|
55
|
-
- `supabase`:
|
|
62
|
+
- `supabase`: PKCE client (`loadClient()`; supabase-js is only downloaded when a session may exist or
|
|
63
|
+
the user signs in) and members API (`fetchMembers`, `inviteMember`, `setMemberRole`,
|
|
56
64
|
`removeMember`, `leave`, `fetchMyMemberships`) with friendly error messages.
|
|
57
65
|
- `tombstones`: hides deleted rows at once and keeps hiding them if a sync briefly restores
|
|
58
66
|
them; pruned after a week.
|
|
59
67
|
- `prefs`: `DevicePrefs` (default container, "already prompted", collapsed sections). Pass a
|
|
60
68
|
subclass as `prefs` for app-specific ones and override `replaceContainer` to keep them in step.
|
|
69
|
+
- `requestPersistence()`: asks the browser to keep the data (called on start; call it again from
|
|
70
|
+
a button). Sets `app.persisted`.
|
|
61
71
|
- `resetDevice()`: teardown for a dev wipe.
|
|
62
72
|
|
|
63
|
-
Without roles everyone is an owner.
|
|
73
|
+
Without roles everyone is an owner.
|
|
74
|
+
|
|
75
|
+
**Session key.** The session is stored under `<appKey>-auth` (`authStorageKey` to change it), so
|
|
76
|
+
apps sharing an origin, e.g. two GitHub Pages project sites, don't share or end each other's
|
|
77
|
+
sessions. A session stored under supabase-js's default key is moved to the new key once; the old key
|
|
78
|
+
is removed, so a second app on the origin asks to sign in again.
|
|
79
|
+
|
|
80
|
+
**Lazy loading.** `@supabase/supabase-js` and RxDB's Supabase replication are loaded with dynamic
|
|
81
|
+
`import()`: signed-out users never download them. They load on start when a session is stored or
|
|
82
|
+
the page is a sign-in redirect, and otherwise on sign-in.
|
|
83
|
+
|
|
84
|
+
**Backups.** `ops.exportContainers([{ id, location }])` returns every row of those containers as a
|
|
85
|
+
JSON-ready object (`format: 'wells-nelson-containers'`, the `appKey`, a version).
|
|
86
|
+
`ops.importContainers(data)` checks a parsed file (format, app, every row against the schema)
|
|
87
|
+
before writing anything, then adds each container as a new local-only container with fresh ids.
|
|
88
|
+
`DeviceDataCard` in `@wells-nelson/interactions/account` is the UI.
|
|
89
|
+
|
|
90
|
+
**Errors.** `persist()` (TanStack writes) and other quiet failures go through `reportError(kind,
|
|
91
|
+
err)`; failed writes also set `app.writeError`. With `reportErrors`, the store sends each report
|
|
92
|
+
(once per kind and message, at most 20 per page load) to the schema's `report_client_error`
|
|
93
|
+
function with a plain `fetch`, so signed-out devices report too, and also catches uncaught errors
|
|
94
|
+
and rejections. `setErrorReporter` swaps in another destination (e.g. Sentry). Table and RPC names default to `<noun>_members` and
|
|
64
95
|
`get_<noun>_members`; override with e.g. `members: { membersRpc: 'get_list_collaborators' }`.
|
|
65
96
|
|
|
66
97
|
## A schema other than `public`
|
|
@@ -73,7 +104,8 @@ createSyncedStore({ ..., supabaseSchema: 'my_app' });
|
|
|
73
104
|
generateMigration({ ..., schema: 'my_app' });
|
|
74
105
|
```
|
|
75
106
|
|
|
76
|
-
The migration creates the schema with the grants Supabase's API roles need
|
|
107
|
+
The migration creates the schema with the grants Supabase's API roles need (tables only for
|
|
108
|
+
`authenticated`; `anon` only gets `report_client_error`). In the Supabase
|
|
77
109
|
dashboard, add the schema under **Project Settings → Data API → Exposed schemas**; until then,
|
|
78
110
|
the API can't see it. The store's client reads and writes that schema. It also fixes RxDB's
|
|
79
111
|
realtime subscription, which otherwise always listens on `public`.
|
|
@@ -92,4 +124,19 @@ const sql = generateMigration({
|
|
|
92
124
|
});
|
|
93
125
|
```
|
|
94
126
|
|
|
127
|
+
What it sets up besides the tables:
|
|
128
|
+
|
|
129
|
+
- `current_email()` returns the signed-in user's email only once it's confirmed (read from
|
|
130
|
+
`auth.users`), so an unverified sign-up can't claim invites made to that email.
|
|
131
|
+
- RLS on every table; nothing for `anon` except `report_client_error`. Functions are revoked from
|
|
132
|
+
`public` and `anon` and granted to `authenticated`.
|
|
133
|
+
- Nothing new (rows, members) goes into a deleted container (`is_<noun>_live`); containers can only
|
|
134
|
+
be created by users with an email. With roles, the last-owner trigger locks the container row, so
|
|
135
|
+
two owners demoting each other at once can't both succeed; viewers see only the owners and
|
|
136
|
+
themselves.
|
|
137
|
+
- The members RPC's `active` means "signed in since being invited" (only owners see it, with roles),
|
|
138
|
+
so it never reveals whether an email has an account.
|
|
139
|
+
- `client_errors` and `report_client_error(...)`: write-only error reports, capped at 300 an hour and
|
|
140
|
+
kept 30 days.
|
|
141
|
+
|
|
95
142
|
The sandbox's Data storage page runs this store in the browser and shows the migration.
|
package/dist/app.svelte.d.ts
CHANGED
|
@@ -4,10 +4,18 @@ import type { DocMap, Location } from './types.js';
|
|
|
4
4
|
* Holds the two data sets: `local` (this device only, never synced) is always open;
|
|
5
5
|
* `cloud` exists only while signed in and replicates to Supabase.
|
|
6
6
|
*/
|
|
7
|
-
export declare class AppState<D extends DocMap
|
|
7
|
+
export declare class AppState<D extends DocMap<D>> {
|
|
8
8
|
local: DataSet<D> | null;
|
|
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
|
@@ -10,7 +10,7 @@ export declare function isRejection(err: unknown): boolean;
|
|
|
10
10
|
* doesn't expose is reported as the setting to change.
|
|
11
11
|
*/
|
|
12
12
|
export declare function errorMessage(err: unknown, schema?: string): string;
|
|
13
|
-
export interface CloudSyncOptions<D extends DocMap
|
|
13
|
+
export interface CloudSyncOptions<D extends DocMap<D>> {
|
|
14
14
|
/** Prefix for database names and localStorage keys, e.g. 'demo'. */
|
|
15
15
|
appKey: string;
|
|
16
16
|
schema: StoreSchema<D>;
|
|
@@ -25,7 +25,7 @@ export interface CloudSyncOptions<D extends DocMap> {
|
|
|
25
25
|
* pending changes per container (cleared once replication settles), last sync time, blocked
|
|
26
26
|
* containers, membership / revocation checks, cached roles and rejected writes.
|
|
27
27
|
*/
|
|
28
|
-
export declare class CloudSync<D extends DocMap
|
|
28
|
+
export declare class CloudSync<D extends DocMap<D>> {
|
|
29
29
|
#private;
|
|
30
30
|
readonly configured: boolean;
|
|
31
31
|
user: User | null;
|
|
@@ -56,6 +56,14 @@ export declare class CloudSync<D extends DocMap> {
|
|
|
56
56
|
/** Record our role right away (e.g. after creating a container or changing roles). */
|
|
57
57
|
setRole(containerId: string, role: Role): void;
|
|
58
58
|
init(): Promise<void>;
|
|
59
|
+
/**
|
|
60
|
+
* Hold back a new cloud container's other rows until the container's own row has reached
|
|
61
|
+
* Supabase. Each collection replicates on its own, and the server only accepts a child row once
|
|
62
|
+
* its container exists (row level security checks the membership the container insert
|
|
63
|
+
* creates); without this, a container copied to the cloud with its rows has some of them
|
|
64
|
+
* rejected until the next retry.
|
|
65
|
+
*/
|
|
66
|
+
expectNewContainer(containerId: string): void;
|
|
59
67
|
/** Record that a container has local changes that still need to reach Supabase. */
|
|
60
68
|
markPending(containerId: string): void;
|
|
61
69
|
clearPending(containerId: string): void;
|
package/dist/cloud.svelte.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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
6
|
import { replicationClient, schemaNotExposed } from './supabase.js';
|
|
7
7
|
import { containerIdOf } from './types.js';
|
|
8
8
|
const emptyState = () => ({ pending: {}, blocked: [], revoked: [], roles: {}, lastSync: null });
|
|
9
9
|
const MEMBERSHIP_CHECK_INTERVAL = 30_000;
|
|
10
|
+
/** Longest a new container's other rows wait for the container row to be pushed. */
|
|
11
|
+
const CONTAINER_WAIT_MS = 20_000;
|
|
10
12
|
/** Push errors caused by the server refusing the write (RLS, triggers), not by connectivity. */
|
|
11
13
|
export function isRejection(err) {
|
|
12
14
|
const e = err;
|
|
@@ -64,6 +66,10 @@ export class CloudSync {
|
|
|
64
66
|
#lastMembershipCheck = 0;
|
|
65
67
|
#signingOut = false;
|
|
66
68
|
#queue = Promise.resolve();
|
|
69
|
+
#attached = null;
|
|
70
|
+
/** New cloud containers whose own row hasn't reached Supabase yet (see `expectNewContainer`). */
|
|
71
|
+
#unsent = new Map();
|
|
72
|
+
#unsentWaits = new Map();
|
|
67
73
|
constructor(options) {
|
|
68
74
|
this.#appKey = options.appKey;
|
|
69
75
|
this.#schema = options.schema;
|
|
@@ -126,7 +132,21 @@ export class CloudSync {
|
|
|
126
132
|
});
|
|
127
133
|
window.addEventListener('offline', () => (this.online = false));
|
|
128
134
|
}
|
|
129
|
-
|
|
135
|
+
// supabase-js (and the replication) only load when there may be a session: a stored one or
|
|
136
|
+
// a sign-in redirect. Otherwise they load when the user signs in.
|
|
137
|
+
if (this.#sb.mayHaveSession())
|
|
138
|
+
await this.#attach();
|
|
139
|
+
}
|
|
140
|
+
/** Load the client, restore its session and follow auth changes (once). */
|
|
141
|
+
#attach() {
|
|
142
|
+
this.#attached ??= this.#doAttach().catch((err) => {
|
|
143
|
+
this.#attached = null;
|
|
144
|
+
throw err;
|
|
145
|
+
});
|
|
146
|
+
return this.#attached;
|
|
147
|
+
}
|
|
148
|
+
async #doAttach() {
|
|
149
|
+
const sb = await this.#sb.loadClient();
|
|
130
150
|
if (!sb)
|
|
131
151
|
return;
|
|
132
152
|
const { data } = await sb.auth.getSession();
|
|
@@ -167,12 +187,43 @@ export class CloudSync {
|
|
|
167
187
|
this.user = user;
|
|
168
188
|
this.sessionExpired = false;
|
|
169
189
|
this.#app.cloud = createDataSet('cloud', db, this.#schema);
|
|
170
|
-
this.#startReplication(db);
|
|
190
|
+
await this.#startReplication(db);
|
|
171
191
|
this.#scheduleSettle();
|
|
172
192
|
}
|
|
173
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Hold back a new cloud container's other rows until the container's own row has reached
|
|
195
|
+
* Supabase. Each collection replicates on its own, and the server only accepts a child row once
|
|
196
|
+
* its container exists (row level security checks the membership the container insert
|
|
197
|
+
* creates); without this, a container copied to the cloud with its rows has some of them
|
|
198
|
+
* rejected until the next retry.
|
|
199
|
+
*/
|
|
200
|
+
expectNewContainer(containerId) {
|
|
201
|
+
if (this.#unsent.has(containerId))
|
|
202
|
+
return;
|
|
203
|
+
let release;
|
|
204
|
+
const sent = new Promise((resolve) => (release = resolve));
|
|
205
|
+
this.#unsent.set(containerId, release);
|
|
206
|
+
this.#unsentWaits.set(containerId, sent);
|
|
207
|
+
}
|
|
208
|
+
#containerSent(containerId) {
|
|
209
|
+
this.#unsent.get(containerId)?.();
|
|
210
|
+
this.#unsent.delete(containerId);
|
|
211
|
+
this.#unsentWaits.delete(containerId);
|
|
212
|
+
}
|
|
213
|
+
/** Waits while the container's own row is still on its way (bounded: if it's refused, the server decides). */
|
|
214
|
+
async #afterContainer(containerId) {
|
|
215
|
+
const sent = this.#unsentWaits.get(containerId);
|
|
216
|
+
if (!sent)
|
|
217
|
+
return;
|
|
218
|
+
let timer;
|
|
219
|
+
await Promise.race([sent, new Promise((r) => (timer = setTimeout(r, CONTAINER_WAIT_MS)))]);
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
}
|
|
222
|
+
async #startReplication(db) {
|
|
223
|
+
const { replicateSupabase } = await import('rxdb/plugins/replication-supabase');
|
|
174
224
|
const sb = replicationClient(this.#sb.getClient(), this.#sb.schema);
|
|
175
225
|
this.#schema.collections.forEach((name, index) => {
|
|
226
|
+
const isContainer = name === this.#schema.container;
|
|
176
227
|
const rep = replicateSupabase({
|
|
177
228
|
replicationIdentifier: `supabase-${name}`,
|
|
178
229
|
client: sb,
|
|
@@ -185,7 +236,14 @@ export class CloudSync {
|
|
|
185
236
|
pull: { batchSize: 200 },
|
|
186
237
|
push: {
|
|
187
238
|
batchSize: 50,
|
|
188
|
-
modifier: (doc) =>
|
|
239
|
+
modifier: async (doc) => {
|
|
240
|
+
const containerId = containerIdOf(this.#schema, name, doc);
|
|
241
|
+
if (this.#state.value.blocked.includes(containerId))
|
|
242
|
+
return null;
|
|
243
|
+
if (!isContainer)
|
|
244
|
+
await this.#afterContainer(containerId);
|
|
245
|
+
return doc;
|
|
246
|
+
}
|
|
189
247
|
}
|
|
190
248
|
});
|
|
191
249
|
this.#replications.push(rep);
|
|
@@ -193,13 +251,20 @@ export class CloudSync {
|
|
|
193
251
|
this.error = errorMessage(err, this.#sb.schema);
|
|
194
252
|
if (isRejection(err))
|
|
195
253
|
this.rejectedWrite = this.error;
|
|
254
|
+
// Offline failures are expected; anything else is worth knowing about.
|
|
255
|
+
if (isRejection(err) || this.online)
|
|
256
|
+
reportError('sync', this.error, `${name} ${err.parameters?.direction ?? ''}`);
|
|
196
257
|
}), rep.active$.subscribe((isActive) => {
|
|
197
258
|
if (isActive)
|
|
198
259
|
this.#activeReps.add(index);
|
|
199
260
|
else
|
|
200
261
|
this.#activeReps.delete(index);
|
|
201
262
|
this.active = this.#activeReps.size > 0;
|
|
202
|
-
}), rep.received$.subscribe(() => this.#scheduleSettle()), rep.sent$.subscribe(() =>
|
|
263
|
+
}), rep.received$.subscribe(() => this.#scheduleSettle()), rep.sent$.subscribe((doc) => {
|
|
264
|
+
if (isContainer)
|
|
265
|
+
this.#containerSent(doc.id);
|
|
266
|
+
this.#scheduleSettle();
|
|
267
|
+
}));
|
|
203
268
|
});
|
|
204
269
|
}
|
|
205
270
|
async #teardown(removeData) {
|
|
@@ -211,6 +276,8 @@ export class CloudSync {
|
|
|
211
276
|
this.#replications = [];
|
|
212
277
|
this.#activeReps.clear();
|
|
213
278
|
this.active = false;
|
|
279
|
+
for (const id of [...this.#unsent.keys()])
|
|
280
|
+
this.#containerSent(id);
|
|
214
281
|
await Promise.allSettled(reps.map((r) => r.cancel()));
|
|
215
282
|
const ds = this.#app.cloud;
|
|
216
283
|
this.#app.cloud = null;
|
|
@@ -324,6 +391,7 @@ export class CloudSync {
|
|
|
324
391
|
await this.checkMemberships(true);
|
|
325
392
|
}
|
|
326
393
|
async signInWithGoogle(redirectTo) {
|
|
394
|
+
await this.#attach();
|
|
327
395
|
const sb = this.#sb.getClient();
|
|
328
396
|
if (!sb)
|
|
329
397
|
throw new Error('Cloud sync is not configured.');
|
|
@@ -333,6 +401,7 @@ export class CloudSync {
|
|
|
333
401
|
}
|
|
334
402
|
/** Email a magic sign-in link. The link has to be opened in this browser (PKCE). */
|
|
335
403
|
async signInWithEmail(email, redirectTo) {
|
|
404
|
+
await this.#attach();
|
|
336
405
|
const sb = this.#sb.getClient();
|
|
337
406
|
if (!sb)
|
|
338
407
|
throw new Error('Cloud sync is not configured.');
|
package/dist/containers.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { SupabaseApi } from './supabase.js';
|
|
|
7
7
|
import type { Tombstones } from './tombstones.svelte.js';
|
|
8
8
|
import type { CollectionName, DocMap, Location, Role, StoreSchema } from './types.js';
|
|
9
9
|
/** Every row of one container, by collection. */
|
|
10
|
-
export type ContainerDocs<D extends DocMap
|
|
10
|
+
export type ContainerDocs<D extends DocMap<D>> = {
|
|
11
11
|
[N in CollectionName<D>]: D[N][];
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
@@ -15,8 +15,8 @@ export type ContainerDocs<D extends DocMap> = {
|
|
|
15
15
|
* `refs` fields: composite ids and ids inside text fields. `map` turns an old id into the copy's
|
|
16
16
|
* id (the identity when ids are kept).
|
|
17
17
|
*/
|
|
18
|
-
export type RemapHook<D extends DocMap
|
|
19
|
-
export interface ContainerOpsOptions<D extends DocMap
|
|
18
|
+
export type RemapHook<D extends DocMap<D>> = <N extends CollectionName<D>>(collection: N, row: D[N], map: (id: string) => string) => D[N];
|
|
19
|
+
export interface ContainerOpsOptions<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> {
|
|
20
20
|
schema: StoreSchema<D, C>;
|
|
21
21
|
app: AppState<D>;
|
|
22
22
|
cloud: CloudSync<D>;
|
|
@@ -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,18 +38,31 @@ 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
|
-
export type ContainerOps<D extends DocMap
|
|
60
|
+
export type ContainerOps<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createContainerOps<D, C>>;
|
|
46
61
|
/**
|
|
47
62
|
* Operations on whole containers and the low-level row helpers they're built from. Every write
|
|
48
63
|
* to a cloud data set marks the container pending so the profile can show unsynced changes.
|
|
49
64
|
*/
|
|
50
|
-
export declare function createContainerOps<D extends DocMap
|
|
65
|
+
export declare function createContainerOps<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>>(options: ContainerOpsOptions<D, C>): {
|
|
51
66
|
rx: <N extends CollectionName<D>>(ds: DataSet<D>, name: N) => RxCollection<D[N]>;
|
|
52
67
|
touch: (ds: DataSet<D>, containerId: string) => void;
|
|
53
68
|
removeDocs: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, ids: string[]) => Promise<void>;
|
|
@@ -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();
|
|
@@ -63,9 +67,11 @@ export function createContainerOps(options) {
|
|
|
63
67
|
const t = now();
|
|
64
68
|
const hasCreatedAt = 'created_at' in (schema.schemas[container].properties ?? {});
|
|
65
69
|
const doc = { ...fields, id, updated_at: t, ...(hasCreatedAt ? { created_at: t } : {}) };
|
|
66
|
-
|
|
67
|
-
if (ds.location === 'cloud')
|
|
70
|
+
if (ds.location === 'cloud') {
|
|
68
71
|
cloud.setRole(id, 'owner');
|
|
72
|
+
cloud.expectNewContainer(id);
|
|
73
|
+
}
|
|
74
|
+
insertDoc(ds, container, doc, id);
|
|
69
75
|
return id;
|
|
70
76
|
}
|
|
71
77
|
function renameContainer(ds, containerId, name) {
|
|
@@ -135,6 +141,7 @@ export function createContainerOps(options) {
|
|
|
135
141
|
if (to.location === 'cloud') {
|
|
136
142
|
cloud.markPending(cid);
|
|
137
143
|
cloud.setRole(cid, 'owner');
|
|
144
|
+
cloud.expectNewContainer(cid);
|
|
138
145
|
}
|
|
139
146
|
// Rows deliberately written again (e.g. a container moved back here) must not stay hidden.
|
|
140
147
|
for (const name of collections)
|
|
@@ -142,6 +149,80 @@ export function createContainerOps(options) {
|
|
|
142
149
|
await insertContainerDocs(to, rows);
|
|
143
150
|
return cid;
|
|
144
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Every row of the given containers as one JSON-ready object (a backup the user keeps). Each
|
|
154
|
+
* container is read from the data set it lives in.
|
|
155
|
+
*/
|
|
156
|
+
async function exportContainers(items) {
|
|
157
|
+
const containers = [];
|
|
158
|
+
for (const { id, location } of items) {
|
|
159
|
+
const docs = await readContainer(app.dataSet(location), id);
|
|
160
|
+
if (docs[container].length === 0)
|
|
161
|
+
continue;
|
|
162
|
+
containers.push(docs);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
format: 'wells-nelson-containers',
|
|
166
|
+
version: 1,
|
|
167
|
+
app: options.appKey ?? '',
|
|
168
|
+
exportedAt: new Date().toISOString(),
|
|
169
|
+
containers
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** Keep only the schema's fields, and only with the schema's types; anything else is not a backup. */
|
|
173
|
+
function cleanRow(name, row) {
|
|
174
|
+
const props = schema.schemas[name].properties;
|
|
175
|
+
if (!row || typeof row !== 'object')
|
|
176
|
+
throw new Error('This file is not a backup of this app.');
|
|
177
|
+
const out = {};
|
|
178
|
+
for (const [key, prop] of Object.entries(props)) {
|
|
179
|
+
const value = row[key];
|
|
180
|
+
const type = prop.type === 'number' ? 'number' : prop.type === 'boolean' ? 'boolean' : 'string';
|
|
181
|
+
if (typeof value !== type || (type === 'number' && !Number.isFinite(value))) {
|
|
182
|
+
throw new Error(`This backup has an invalid ${name} row (${key}).`);
|
|
183
|
+
}
|
|
184
|
+
out[key] = value;
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Add every container of an `exportContainers` file to this device as new local-only
|
|
190
|
+
* containers (fresh ids, so importing twice makes two copies and nothing is overwritten).
|
|
191
|
+
* Returns the new container ids.
|
|
192
|
+
*/
|
|
193
|
+
async function importContainers(data) {
|
|
194
|
+
const file = data;
|
|
195
|
+
if (!file || file.format !== 'wells-nelson-containers' || !Array.isArray(file.containers)) {
|
|
196
|
+
throw new Error('This file is not a backup of this app.');
|
|
197
|
+
}
|
|
198
|
+
if (file.version !== 1)
|
|
199
|
+
throw new Error('This backup was made by a newer version of the app. Update the app first.');
|
|
200
|
+
if (options.appKey && file.app !== options.appKey)
|
|
201
|
+
throw new Error('This backup is from a different app.');
|
|
202
|
+
// Check everything before writing anything.
|
|
203
|
+
const parsed = file.containers.map((entry) => {
|
|
204
|
+
const docs = {};
|
|
205
|
+
for (const name of collections) {
|
|
206
|
+
const rows = entry[name] ?? [];
|
|
207
|
+
if (!Array.isArray(rows))
|
|
208
|
+
throw new Error('This file is not a backup of this app.');
|
|
209
|
+
docs[name] = rows.map((r) => cleanRow(name, r));
|
|
210
|
+
}
|
|
211
|
+
const head = docs[container][0];
|
|
212
|
+
if (docs[container].length !== 1 || !head)
|
|
213
|
+
throw new Error('This file is not a backup of this app.');
|
|
214
|
+
return { docs, id: head.id };
|
|
215
|
+
});
|
|
216
|
+
const local = app.dataSet('local');
|
|
217
|
+
const ids = [];
|
|
218
|
+
for (const { docs, id } of parsed) {
|
|
219
|
+
const { rows, containerId } = remapContainer(docs, id, true);
|
|
220
|
+
await insertContainerDocs(local, rows);
|
|
221
|
+
prefs.markPrompted([containerId]);
|
|
222
|
+
ids.push(containerId);
|
|
223
|
+
}
|
|
224
|
+
return ids;
|
|
225
|
+
}
|
|
145
226
|
/** Remove a cloud container from this device only; nothing is deleted on Supabase. */
|
|
146
227
|
async function purgeCloudContainer(containerId) {
|
|
147
228
|
const ds = app.dataSet('cloud');
|
|
@@ -272,6 +353,8 @@ export function createContainerOps(options) {
|
|
|
272
353
|
insertContainerDocs,
|
|
273
354
|
remapContainer,
|
|
274
355
|
copyContainer,
|
|
356
|
+
exportContainers,
|
|
357
|
+
importContainers,
|
|
275
358
|
makeCloud,
|
|
276
359
|
makeLocal,
|
|
277
360
|
deleteContainer,
|
package/dist/database.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { type RxCollection, type RxConflictHandler, type RxDatabase, type RxStorage } from 'rxdb/plugins/core';
|
|
2
2
|
import type { CollectionName, DocMap, StoreSchema } from './types.js';
|
|
3
|
-
export type Collections<D extends DocMap
|
|
3
|
+
export type Collections<D extends DocMap<D>> = {
|
|
4
4
|
[N in CollectionName<D>]: RxCollection<D[N]>;
|
|
5
5
|
};
|
|
6
|
-
export type Database<D extends DocMap
|
|
6
|
+
export type Database<D extends DocMap<D>> = RxDatabase<Collections<D>>;
|
|
7
7
|
export declare function docsEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean;
|
|
8
8
|
/**
|
|
9
9
|
* Last write wins: whichever side carries the newer `updated_at` (client clock) is kept.
|
|
@@ -21,4 +21,4 @@ export interface OpenDatabaseOptions {
|
|
|
21
21
|
multiInstance?: boolean;
|
|
22
22
|
}
|
|
23
23
|
/** Create an RxDB database with one collection per schema, all using last-write-wins. */
|
|
24
|
-
export declare function openDatabase<D extends DocMap
|
|
24
|
+
export declare function openDatabase<D extends DocMap<D>>(name: string, schema: Pick<StoreSchema<D>, 'collections' | 'schemas'>, { storage, multiInstance }?: OpenDatabaseOptions): Promise<Database<D>>;
|
package/dist/dataset.d.ts
CHANGED
|
@@ -7,12 +7,12 @@ export type TSCollection<T extends object> = ReturnType<typeof makeCollection<T>
|
|
|
7
7
|
* One RxDB database plus a TanStack DB collection on top of each RxDB collection.
|
|
8
8
|
* Components query the TanStack collections; bulk operations go straight to RxDB.
|
|
9
9
|
*/
|
|
10
|
-
export type DataSet<D extends DocMap
|
|
10
|
+
export type DataSet<D extends DocMap<D>> = {
|
|
11
11
|
location: Location;
|
|
12
12
|
db: Database<D>;
|
|
13
13
|
} & {
|
|
14
14
|
[N in CollectionName<D>]: TSCollection<D[N]>;
|
|
15
15
|
};
|
|
16
|
-
export declare function createDataSet<D extends DocMap
|
|
17
|
-
export declare function disposeDataSet<D extends DocMap
|
|
16
|
+
export declare function createDataSet<D extends DocMap<D>>(location: Location, db: Database<D>, schema: Pick<StoreSchema<D>, 'collections' | 'container' | 'containerField' | 'indexContainerField'>): DataSet<D>;
|
|
17
|
+
export declare function disposeDataSet<D extends DocMap<D>>(ds: DataSet<D>, collections: readonly CollectionName<D>[]): Promise<void>;
|
|
18
18
|
export {};
|
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;
|