@wells-nelson/data-storage 1.0.2 → 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 +4 -1
- package/dist/app.svelte.d.ts +1 -1
- package/dist/cloud.svelte.d.ts +10 -2
- package/dist/cloud.svelte.js +50 -2
- package/dist/containers.d.ts +5 -5
- package/dist/containers.js +5 -2
- package/dist/database.d.ts +3 -3
- package/dist/dataset.d.ts +3 -3
- package/dist/store.d.ts +3 -3
- 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…
|
|
@@ -51,7 +52,9 @@ onMount(() => void store.init({ beforeReady: importDevSeed }));
|
|
|
51
52
|
show it until dismissed) and `persisted` (whether the browser keeps the data for good).
|
|
52
53
|
- `cloud`: `CloudSync`: session (Google, email link), replication, `pendingIds`, `hasPending`,
|
|
53
54
|
`lastSync`, `online`, `active`, `error`, `rejectedWrite`, `roleOf`, `revokedIds`,
|
|
54
|
-
`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.
|
|
55
58
|
- `ops`: container operations (`createContainer`, `renameContainer`, `makeCloud`, `makeLocal`,
|
|
56
59
|
`deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`,
|
|
57
60
|
`exportContainers`, `importContainers`) and the row
|
package/dist/app.svelte.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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;
|
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
|
@@ -7,6 +7,8 @@ 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;
|
|
@@ -65,6 +67,9 @@ export class CloudSync {
|
|
|
65
67
|
#signingOut = false;
|
|
66
68
|
#queue = Promise.resolve();
|
|
67
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();
|
|
68
73
|
constructor(options) {
|
|
69
74
|
this.#appKey = options.appKey;
|
|
70
75
|
this.#schema = options.schema;
|
|
@@ -185,10 +190,40 @@ export class CloudSync {
|
|
|
185
190
|
await this.#startReplication(db);
|
|
186
191
|
this.#scheduleSettle();
|
|
187
192
|
}
|
|
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
|
+
}
|
|
188
222
|
async #startReplication(db) {
|
|
189
223
|
const { replicateSupabase } = await import('rxdb/plugins/replication-supabase');
|
|
190
224
|
const sb = replicationClient(this.#sb.getClient(), this.#sb.schema);
|
|
191
225
|
this.#schema.collections.forEach((name, index) => {
|
|
226
|
+
const isContainer = name === this.#schema.container;
|
|
192
227
|
const rep = replicateSupabase({
|
|
193
228
|
replicationIdentifier: `supabase-${name}`,
|
|
194
229
|
client: sb,
|
|
@@ -201,7 +236,14 @@ export class CloudSync {
|
|
|
201
236
|
pull: { batchSize: 200 },
|
|
202
237
|
push: {
|
|
203
238
|
batchSize: 50,
|
|
204
|
-
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
|
+
}
|
|
205
247
|
}
|
|
206
248
|
});
|
|
207
249
|
this.#replications.push(rep);
|
|
@@ -218,7 +260,11 @@ export class CloudSync {
|
|
|
218
260
|
else
|
|
219
261
|
this.#activeReps.delete(index);
|
|
220
262
|
this.active = this.#activeReps.size > 0;
|
|
221
|
-
}), 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
|
+
}));
|
|
222
268
|
});
|
|
223
269
|
}
|
|
224
270
|
async #teardown(removeData) {
|
|
@@ -230,6 +276,8 @@ export class CloudSync {
|
|
|
230
276
|
this.#replications = [];
|
|
231
277
|
this.#activeReps.clear();
|
|
232
278
|
this.active = false;
|
|
279
|
+
for (const id of [...this.#unsent.keys()])
|
|
280
|
+
this.#containerSent(id);
|
|
233
281
|
await Promise.allSettled(reps.map((r) => r.cancel()));
|
|
234
282
|
const ds = this.#app.cloud;
|
|
235
283
|
this.#app.cloud = null;
|
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>;
|
|
@@ -57,12 +57,12 @@ export interface ContainerExport {
|
|
|
57
57
|
}
|
|
58
58
|
export declare const now: () => number;
|
|
59
59
|
export declare const uid: () => `${string}-${string}-${string}-${string}-${string}`;
|
|
60
|
-
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>>;
|
|
61
61
|
/**
|
|
62
62
|
* Operations on whole containers and the low-level row helpers they're built from. Every write
|
|
63
63
|
* to a cloud data set marks the container pending so the profile can show unsynced changes.
|
|
64
64
|
*/
|
|
65
|
-
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>): {
|
|
66
66
|
rx: <N extends CollectionName<D>>(ds: DataSet<D>, name: N) => RxCollection<D[N]>;
|
|
67
67
|
touch: (ds: DataSet<D>, containerId: string) => void;
|
|
68
68
|
removeDocs: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, ids: string[]) => Promise<void>;
|
package/dist/containers.js
CHANGED
|
@@ -67,9 +67,11 @@ export function createContainerOps(options) {
|
|
|
67
67
|
const t = now();
|
|
68
68
|
const hasCreatedAt = 'created_at' in (schema.schemas[container].properties ?? {});
|
|
69
69
|
const doc = { ...fields, id, updated_at: t, ...(hasCreatedAt ? { created_at: t } : {}) };
|
|
70
|
-
|
|
71
|
-
if (ds.location === 'cloud')
|
|
70
|
+
if (ds.location === 'cloud') {
|
|
72
71
|
cloud.setRole(id, 'owner');
|
|
72
|
+
cloud.expectNewContainer(id);
|
|
73
|
+
}
|
|
74
|
+
insertDoc(ds, container, doc, id);
|
|
73
75
|
return id;
|
|
74
76
|
}
|
|
75
77
|
function renameContainer(ds, containerId, name) {
|
|
@@ -139,6 +141,7 @@ export function createContainerOps(options) {
|
|
|
139
141
|
if (to.location === 'cloud') {
|
|
140
142
|
cloud.markPending(cid);
|
|
141
143
|
cloud.setRole(cid, 'owner');
|
|
144
|
+
cloud.expectNewContainer(cid);
|
|
142
145
|
}
|
|
143
146
|
// Rows deliberately written again (e.g. a container moved back here) must not stay hidden.
|
|
144
147
|
for (const name of collections)
|
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/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;
|
|
@@ -56,7 +56,7 @@ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D>
|
|
|
56
56
|
* sets), cloud sync, tombstones, device prefs, the Supabase members API and container
|
|
57
57
|
* operations. Create it once in a module and import it where needed.
|
|
58
58
|
*/
|
|
59
|
-
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>): {
|
|
60
60
|
config: SyncedStoreConfig<D, C>;
|
|
61
61
|
roles: boolean;
|
|
62
62
|
app: AppState<D>;
|
|
@@ -100,4 +100,4 @@ export declare function createSyncedStore<D extends DocMap, C extends Collection
|
|
|
100
100
|
resetDevice: () => Promise<void>;
|
|
101
101
|
requestPersistence: () => Promise<boolean | null>;
|
|
102
102
|
};
|
|
103
|
-
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/types.d.ts
CHANGED
|
@@ -8,11 +8,15 @@ export interface DocBase {
|
|
|
8
8
|
id: string;
|
|
9
9
|
updated_at: number;
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Collection name → document type, e.g. `{ projects: ProjectDoc; tasks: TaskDoc }`, as a `type`
|
|
13
|
+
* alias or an `interface`. Generics constrain with `D extends DocMap<D>` (every key of `D` is a
|
|
14
|
+
* document), which an interface satisfies; a plain `DocMap` is any such map.
|
|
15
|
+
*/
|
|
16
|
+
export type DocMap<D = Record<string, DocBase>> = {
|
|
17
|
+
[K in keyof D]: DocBase;
|
|
14
18
|
};
|
|
15
|
-
export type CollectionName<D extends DocMap
|
|
19
|
+
export type CollectionName<D extends DocMap<D>> = keyof D & string;
|
|
16
20
|
/** Where a container lives: only on this device, or replicated to Supabase. */
|
|
17
21
|
export type Location = 'local' | 'cloud';
|
|
18
22
|
export type Role = 'viewer' | 'editor' | 'owner';
|
|
@@ -25,7 +29,7 @@ export interface Nouns {
|
|
|
25
29
|
* The shape of an app's data. A **container** (a project, a list…) owns every other row through
|
|
26
30
|
* `containerField` and is the unit that gets shared, synced and moved between data sets.
|
|
27
31
|
*/
|
|
28
|
-
export interface StoreSchema<D extends DocMap
|
|
32
|
+
export interface StoreSchema<D extends DocMap<D>, C extends CollectionName<D> = CollectionName<D>> {
|
|
29
33
|
/** Every collection, parents before children (the container first). */
|
|
30
34
|
collections: readonly CollectionName<D>[];
|
|
31
35
|
schemas: {
|
|
@@ -39,4 +43,4 @@ export interface StoreSchema<D extends DocMap, C extends CollectionName<D> = Col
|
|
|
39
43
|
indexContainerField?: boolean;
|
|
40
44
|
}
|
|
41
45
|
/** The container a document belongs to (containers are their own container). */
|
|
42
|
-
export declare function containerIdOf<D extends DocMap
|
|
46
|
+
export declare function containerIdOf<D extends DocMap<D>>(schema: Pick<StoreSchema<D>, 'container' | 'containerField'>, collection: CollectionName<D>, doc: object): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wells-nelson/data-storage",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Offline-first storage for Wells Nelson apps: RxDB + TanStack DB data sets, Supabase sync of shared containers, tombstones, device prefs and a SQL migration generator.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|