@wells-nelson/data-storage 0.0.0-stage → 0.0.1

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 CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @wells-nelson/data-storage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline-first storage for apps whose data lives in **containers** (a project, a list) that hold
4
+ every other row and are what gets shared. Local data is always on the device; signed-in users
5
+ also get a per-user cloud database replicated to Supabase.
6
+
7
+ Peer dependencies: `svelte`, `rxdb`, `rxjs`, `@tanstack/db`, `@tanstack/rxdb-db-collection`,
8
+ `@supabase/supabase-js`. Keep `"overrides": { "rxdb": "$rxdb" }` in the app.
9
+
10
+ ## Set up
11
+
12
+ ```ts
13
+ // src/lib/store.ts
14
+ import { createSyncedStore, envConfig, field, flatSchema } from '@wells-nelson/data-storage';
15
+ import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie';
16
+
17
+ // Pass the container collection as the second type argument so its rows are typed precisely.
18
+ export const store = createSyncedStore<Docs, 'projects'>({
19
+ appKey: 'demo', // demo_local, demo_cloud_<user>, demo:hidden, demo:sync:<user>, demo:defaultProject…
20
+ nouns: { singular: 'project', plural: 'projects' },
21
+ env: envConfig(import.meta.env),
22
+ storage: getRxStorageDexie,
23
+ roles: true,
24
+ collections: ['projects', 'tasks', 'task_links', 'notes'], // parents first
25
+ schemas, // flatSchema<ProjectDoc>({ id: field.id, name: field.string, … }) per collection
26
+ container: 'projects',
27
+ containerField: 'project_id',
28
+ indexContainerField: true,
29
+ // Ids of other rows in the container, rewritten when a copy gets new ids.
30
+ refs: { task_links: ['from_id', 'to_id'] },
31
+ // Composite ids and ids inside text fields (runs after `refs` were mapped).
32
+ remap: (collection, row, map) => {
33
+ if (collection === 'task_links') return { ...row, id: `${row.from_id}:${row.to_id}` };
34
+ if (collection === 'notes') return { ...row, mentions: row.mentions.split(',').map(map).join(',') };
35
+ return row;
36
+ }
37
+ });
38
+ ```
39
+
40
+ ```ts
41
+ // root layout
42
+ onMount(() => void store.init({ beforeReady: importDevSeed }));
43
+ ```
44
+
45
+ `store` holds:
46
+
47
+ - `app`: `local` / `cloud` data sets (`DataSet`: the RxDB database plus a TanStack DB
48
+ collection per RxDB collection), `ready`, `fatal`.
49
+ - `cloud`: `CloudSync`: session (Google, email link), replication, `pendingIds`, `hasPending`,
50
+ `lastSync`, `online`, `active`, `error`, `rejectedWrite`, `roleOf`, `revokedIds`,
51
+ `forceResync`, `signOut`.
52
+ - `ops`: container operations (`createContainer`, `renameContainer`, `makeCloud`, `makeLocal`,
53
+ `deleteContainer`, `leaveContainer`, `keepRevokedCopy`, `removeRevoked`, `entries`) and the row
54
+ helpers they're built from (`insertDoc`, `patch`, `removeDocs`, `docsWhere`, `touch`…).
55
+ - `supabase`: lazy PKCE client and members API (`fetchMembers`, `inviteMember`, `setMemberRole`,
56
+ `removeMember`, `leave`, `fetchMyMemberships`) with friendly error messages.
57
+ - `tombstones`: hides deleted rows at once and keeps hiding them if a sync briefly restores
58
+ them; pruned after a week.
59
+ - `prefs`: `DevicePrefs` (default container, "already prompted", collapsed sections). Pass a
60
+ subclass as `prefs` for app-specific ones and override `replaceContainer` to keep them in step.
61
+ - `resetDevice()`: teardown for a dev wipe.
62
+
63
+ Without roles everyone is an owner. Table and RPC names default to `<noun>_members` and
64
+ `get_<noun>_members`; override with e.g. `members: { membersRpc: 'get_list_collaborators' }`.
65
+
66
+ ## SQL migration
67
+
68
+ ```ts
69
+ import { generateMigration, tablesFromSchemas } from '@wells-nelson/data-storage/sql';
70
+
71
+ const sql = generateMigration({
72
+ container: 'projects',
73
+ containerField: 'project_id',
74
+ noun: 'project',
75
+ roles: true,
76
+ tables: tablesFromSchemas(collections, schemas, { containerField: 'project_id', refs, defaults: { tasks: { done: 'false' } } })
77
+ });
78
+ ```
79
+
80
+ The sandbox's Data storage page runs this store in the browser and shows the migration.
@@ -0,0 +1,13 @@
1
+ import type { DataSet } from './dataset.js';
2
+ import type { DocMap, Location } from './types.js';
3
+ /**
4
+ * Holds the two data sets: `local` (this device only, never synced) is always open;
5
+ * `cloud` exists only while signed in and replicates to Supabase.
6
+ */
7
+ export declare class AppState<D extends DocMap> {
8
+ local: DataSet<D> | null;
9
+ cloud: DataSet<D> | null;
10
+ ready: boolean;
11
+ fatal: string | null;
12
+ dataSet(location: Location): DataSet<D>;
13
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Holds the two data sets: `local` (this device only, never synced) is always open;
3
+ * `cloud` exists only while signed in and replicates to Supabase.
4
+ */
5
+ export class AppState {
6
+ local = $state.raw(null);
7
+ cloud = $state.raw(null);
8
+ ready = $state(false);
9
+ fatal = $state(null);
10
+ dataSet(location) {
11
+ const ds = location === 'local' ? this.local : this.cloud;
12
+ if (!ds)
13
+ throw new Error(`The ${location} data set is not available`);
14
+ return ds;
15
+ }
16
+ }
@@ -0,0 +1,68 @@
1
+ import type { User } from '@supabase/supabase-js';
2
+ import type { AppState } from './app.svelte.js';
3
+ import { type StorageFactory } from './database.js';
4
+ import type { SupabaseApi } from './supabase.js';
5
+ import { type DocMap, type Role, type StoreSchema } from './types.js';
6
+ /** Push errors caused by the server refusing the write (RLS, triggers), not by connectivity. */
7
+ export declare function isRejection(err: unknown): boolean;
8
+ export declare function errorMessage(err: unknown): string;
9
+ export interface CloudSyncOptions<D extends DocMap> {
10
+ /** Prefix for database names and localStorage keys, e.g. 'demo'. */
11
+ appKey: string;
12
+ schema: StoreSchema<D>;
13
+ app: AppState<D>;
14
+ supabase: SupabaseApi;
15
+ /** Containers have viewer / editor / owner roles. Without roles everyone is an owner. */
16
+ roles?: boolean;
17
+ storage?: StorageFactory;
18
+ }
19
+ /**
20
+ * Supabase session, the per-user cloud database and its live replication, and sync status:
21
+ * pending changes per container (cleared once replication settles), last sync time, blocked
22
+ * containers, membership / revocation checks, cached roles and rejected writes.
23
+ */
24
+ export declare class CloudSync<D extends DocMap> {
25
+ #private;
26
+ readonly configured: boolean;
27
+ user: User | null;
28
+ /** Supabase dropped the session on its own (e.g. refresh token revoked); data is kept until sign-in. */
29
+ sessionExpired: boolean;
30
+ online: boolean;
31
+ active: boolean;
32
+ error: string | null;
33
+ /**
34
+ * A write the server rejected (e.g. a permission or last-owner rule). Unlike transient
35
+ * network errors this needs the user's attention; show it until dismissed.
36
+ */
37
+ rejectedWrite: string | null;
38
+ constructor(options: CloudSyncOptions<D>);
39
+ get email(): string;
40
+ get lastSync(): number | null;
41
+ /** Containers with local changes that haven't reached Supabase yet. */
42
+ get pendingIds(): string[];
43
+ get hasAnyPending(): boolean;
44
+ hasPending(containerId: string): boolean;
45
+ isRevoked(containerId: string): boolean;
46
+ get revokedIds(): string[];
47
+ /**
48
+ * This user's role on a cloud container. A container created here that hasn't synced yet is
49
+ * ours. Without roles everyone is an owner.
50
+ */
51
+ roleOf(containerId: string): Role;
52
+ /** Record our role right away (e.g. after creating a container or changing roles). */
53
+ setRole(containerId: string, role: Role): void;
54
+ init(): Promise<void>;
55
+ /** Record that a container has local changes that still need to reach Supabase. */
56
+ markPending(containerId: string): void;
57
+ clearPending(containerId: string): void;
58
+ /** Stop pushing anything for this container (used right before removing it from this device). */
59
+ block(containerId: string): void;
60
+ /** Detect cloud containers this user no longer has access to (someone unshared them). */
61
+ checkMemberships(force?: boolean): Promise<void>;
62
+ forceResync(): Promise<void>;
63
+ signInWithGoogle(redirectTo: string): Promise<void>;
64
+ /** Email a magic sign-in link. The link has to be opened in this browser (PKCE). */
65
+ signInWithEmail(email: string, redirectTo: string): Promise<void>;
66
+ /** Removes every cloud-enabled container from this device. Local-only ones stay. */
67
+ signOut(): Promise<void>;
68
+ }
@@ -0,0 +1,349 @@
1
+ import { replicateSupabase } from 'rxdb/plugins/replication-supabase';
2
+ import { tick } from 'svelte';
3
+ import { openDatabase } from './database.js';
4
+ import { createDataSet, disposeDataSet } from './dataset.js';
5
+ import { Persisted } from './persisted.svelte.js';
6
+ import { containerIdOf } from './types.js';
7
+ const emptyState = () => ({ pending: {}, blocked: [], revoked: [], roles: {}, lastSync: null });
8
+ const MEMBERSHIP_CHECK_INTERVAL = 30_000;
9
+ /** Push errors caused by the server refusing the write (RLS, triggers), not by connectivity. */
10
+ export function isRejection(err) {
11
+ const e = err;
12
+ if (e?.parameters?.direction !== 'push')
13
+ return false;
14
+ const code = e.parameters.errors?.[0]?.code;
15
+ // 42501: RLS violation; P0001: raised by a trigger (e.g. last owner); 23xxx: constraint violations.
16
+ return !!code && (code === '42501' || code === 'P0001' || code.startsWith('23'));
17
+ }
18
+ export function errorMessage(err) {
19
+ if (err && typeof err === 'object') {
20
+ const e = err;
21
+ return e.parameters?.errors?.[0]?.message ?? e.message ?? String(err);
22
+ }
23
+ return String(err);
24
+ }
25
+ /**
26
+ * Supabase session, the per-user cloud database and its live replication, and sync status:
27
+ * pending changes per container (cleared once replication settles), last sync time, blocked
28
+ * containers, membership / revocation checks, cached roles and rejected writes.
29
+ */
30
+ export class CloudSync {
31
+ configured;
32
+ user = $state.raw(null);
33
+ /** Supabase dropped the session on its own (e.g. refresh token revoked); data is kept until sign-in. */
34
+ sessionExpired = $state(false);
35
+ online = $state(typeof navigator === 'undefined' ? true : navigator.onLine);
36
+ active = $state(false);
37
+ error = $state(null);
38
+ /**
39
+ * A write the server rejected (e.g. a permission or last-owner rule). Unlike transient
40
+ * network errors this needs the user's attention; show it until dismissed.
41
+ */
42
+ rejectedWrite = $state(null);
43
+ #appKey;
44
+ #schema;
45
+ #app;
46
+ #sb;
47
+ #roles;
48
+ #storage;
49
+ #state;
50
+ #replications = [];
51
+ #subscriptions = [];
52
+ #activeReps = new Set();
53
+ #writeVersion = 0;
54
+ #settling = false;
55
+ #resettle = false;
56
+ #settleTimer;
57
+ #lastMembershipCheck = 0;
58
+ #signingOut = false;
59
+ #queue = Promise.resolve();
60
+ constructor(options) {
61
+ this.#appKey = options.appKey;
62
+ this.#schema = options.schema;
63
+ this.#app = options.app;
64
+ this.#sb = options.supabase;
65
+ this.#roles = options.roles ?? false;
66
+ this.#storage = options.storage;
67
+ this.configured = options.supabase.configured;
68
+ this.#state = new Persisted(this.#stateKey('anonymous'), emptyState());
69
+ }
70
+ #stateKey(userId) {
71
+ return `${this.#appKey}:sync:${userId}`;
72
+ }
73
+ get email() {
74
+ return this.user?.email?.toLowerCase() ?? '';
75
+ }
76
+ get lastSync() {
77
+ return this.#state.value.lastSync;
78
+ }
79
+ /** Containers with local changes that haven't reached Supabase yet. */
80
+ get pendingIds() {
81
+ return Object.keys(this.#state.value.pending);
82
+ }
83
+ get hasAnyPending() {
84
+ return this.pendingIds.length > 0;
85
+ }
86
+ hasPending(containerId) {
87
+ return containerId in this.#state.value.pending;
88
+ }
89
+ isRevoked(containerId) {
90
+ return this.#state.value.revoked.includes(containerId);
91
+ }
92
+ get revokedIds() {
93
+ return this.#state.value.revoked;
94
+ }
95
+ /**
96
+ * This user's role on a cloud container. A container created here that hasn't synced yet is
97
+ * ours. Without roles everyone is an owner.
98
+ */
99
+ roleOf(containerId) {
100
+ if (!this.#roles)
101
+ return 'owner';
102
+ const role = this.#state.value.roles[containerId];
103
+ if (role)
104
+ return role;
105
+ return this.hasPending(containerId) ? 'owner' : 'viewer';
106
+ }
107
+ /** Record our role right away (e.g. after creating a container or changing roles). */
108
+ setRole(containerId, role) {
109
+ if (this.#roles)
110
+ this.#update((s) => (s.roles[containerId] = role));
111
+ }
112
+ async init() {
113
+ if (typeof window !== 'undefined') {
114
+ window.addEventListener('online', () => {
115
+ this.online = true;
116
+ for (const rep of this.#replications)
117
+ rep.reSync();
118
+ this.#scheduleSettle();
119
+ });
120
+ window.addEventListener('offline', () => (this.online = false));
121
+ }
122
+ const sb = this.#sb.getClient();
123
+ if (!sb)
124
+ return;
125
+ const { data } = await sb.auth.getSession();
126
+ if (data.session)
127
+ await this.#serial(() => this.#startFor(data.session.user));
128
+ sb.auth.onAuthStateChange((event, session) => {
129
+ // Supabase recommends not awaiting other calls inside this callback.
130
+ setTimeout(() => {
131
+ if (session?.user && session.user.id !== this.user?.id) {
132
+ void this.#serial(() => this.#startFor(session.user));
133
+ }
134
+ else if (session?.user) {
135
+ this.user = session.user;
136
+ this.sessionExpired = false;
137
+ }
138
+ else if (event === 'SIGNED_OUT' && this.user && !this.#signingOut) {
139
+ this.sessionExpired = true;
140
+ }
141
+ });
142
+ });
143
+ }
144
+ #serial(fn) {
145
+ const next = this.#queue.then(fn, fn);
146
+ this.#queue = next.catch(() => undefined);
147
+ return next;
148
+ }
149
+ async #startFor(user) {
150
+ if (this.user?.id === user.id && this.#app.cloud) {
151
+ this.user = user;
152
+ this.sessionExpired = false;
153
+ return;
154
+ }
155
+ if (this.user)
156
+ await this.#teardown(false);
157
+ this.#state.rekey(this.#stateKey(user.id), emptyState());
158
+ this.#writeVersion = Math.max(0, ...Object.values(this.#state.value.pending));
159
+ const db = await openDatabase(`${this.#appKey}_cloud_${user.id.replace(/[^a-z0-9]/gi, '').toLowerCase()}`, this.#schema, { storage: this.#storage });
160
+ this.user = user;
161
+ this.sessionExpired = false;
162
+ this.#app.cloud = createDataSet('cloud', db, this.#schema);
163
+ this.#startReplication(db);
164
+ this.#scheduleSettle();
165
+ }
166
+ #startReplication(db) {
167
+ const sb = this.#sb.getClient();
168
+ this.#schema.collections.forEach((name, index) => {
169
+ const rep = replicateSupabase({
170
+ replicationIdentifier: `supabase-${name}`,
171
+ client: sb,
172
+ tableName: name,
173
+ collection: db.collections[name],
174
+ live: true,
175
+ waitForLeadership: false,
176
+ retryTime: 10_000,
177
+ autoStart: true,
178
+ pull: { batchSize: 200 },
179
+ push: {
180
+ batchSize: 50,
181
+ modifier: (doc) => this.#state.value.blocked.includes(containerIdOf(this.#schema, name, doc)) ? null : doc
182
+ }
183
+ });
184
+ this.#replications.push(rep);
185
+ this.#subscriptions.push(rep.error$.subscribe((err) => {
186
+ this.error = errorMessage(err);
187
+ if (isRejection(err))
188
+ this.rejectedWrite = this.error;
189
+ }), rep.active$.subscribe((isActive) => {
190
+ if (isActive)
191
+ this.#activeReps.add(index);
192
+ else
193
+ this.#activeReps.delete(index);
194
+ this.active = this.#activeReps.size > 0;
195
+ }), rep.received$.subscribe(() => this.#scheduleSettle()), rep.sent$.subscribe(() => this.#scheduleSettle()));
196
+ });
197
+ }
198
+ async #teardown(removeData) {
199
+ clearTimeout(this.#settleTimer);
200
+ for (const sub of this.#subscriptions)
201
+ sub.unsubscribe();
202
+ this.#subscriptions = [];
203
+ const reps = this.#replications;
204
+ this.#replications = [];
205
+ this.#activeReps.clear();
206
+ this.active = false;
207
+ await Promise.allSettled(reps.map((r) => r.cancel()));
208
+ const ds = this.#app.cloud;
209
+ this.#app.cloud = null;
210
+ if (ds) {
211
+ // Let the UI drop its live queries on these collections before cleaning them up;
212
+ // cleaning up a collection a live query still depends on is TanStack DB error 254.
213
+ await tick();
214
+ await new Promise((r) => setTimeout(r, 0));
215
+ await disposeDataSet(ds, this.#schema.collections).catch((err) => console.warn('Collection cleanup failed', err));
216
+ if (removeData)
217
+ await ds.db.remove();
218
+ else
219
+ await ds.db.close();
220
+ }
221
+ if (removeData && this.user)
222
+ Persisted.remove(this.#stateKey(this.user.id));
223
+ this.#state.rekey(this.#stateKey('anonymous'), emptyState());
224
+ this.user = null;
225
+ this.error = null;
226
+ }
227
+ #update(fn) {
228
+ const next = $state.snapshot(this.#state.value);
229
+ fn(next);
230
+ this.#state.value = next;
231
+ }
232
+ /** Record that a container has local changes that still need to reach Supabase. */
233
+ markPending(containerId) {
234
+ const version = ++this.#writeVersion;
235
+ this.#update((s) => (s.pending[containerId] = version));
236
+ this.#scheduleSettle();
237
+ }
238
+ clearPending(containerId) {
239
+ this.#update((s) => delete s.pending[containerId]);
240
+ }
241
+ /** Stop pushing anything for this container (used right before removing it from this device). */
242
+ block(containerId) {
243
+ this.#update((s) => {
244
+ if (!s.blocked.includes(containerId))
245
+ s.blocked.push(containerId);
246
+ delete s.pending[containerId];
247
+ s.revoked = s.revoked.filter((id) => id !== containerId);
248
+ });
249
+ }
250
+ #scheduleSettle() {
251
+ clearTimeout(this.#settleTimer);
252
+ this.#settleTimer = setTimeout(() => void this.#settle(), 300);
253
+ }
254
+ /** Waits until every replication is idle, then clears pending changes made before the wait began. */
255
+ async #settle() {
256
+ if (this.#settling) {
257
+ this.#resettle = true;
258
+ return;
259
+ }
260
+ this.#settling = true;
261
+ try {
262
+ do {
263
+ this.#resettle = false;
264
+ const reps = this.#replications;
265
+ if (reps.length === 0)
266
+ return;
267
+ const version = this.#writeVersion;
268
+ await Promise.all(reps.map((r) => r.awaitInSync()));
269
+ if (reps !== this.#replications)
270
+ return;
271
+ this.#update((s) => {
272
+ for (const [id, v] of Object.entries(s.pending))
273
+ if (v <= version)
274
+ delete s.pending[id];
275
+ s.lastSync = Date.now();
276
+ });
277
+ this.error = null;
278
+ void this.checkMemberships();
279
+ } while (this.#resettle);
280
+ }
281
+ finally {
282
+ this.#settling = false;
283
+ }
284
+ }
285
+ /** Detect cloud containers this user no longer has access to (someone unshared them). */
286
+ async checkMemberships(force = false) {
287
+ const ds = this.#app.cloud;
288
+ if (!ds || !this.online || !this.email)
289
+ return;
290
+ if (!force && Date.now() - this.#lastMembershipCheck < MEMBERSHIP_CHECK_INTERVAL)
291
+ return;
292
+ this.#lastMembershipCheck = Date.now();
293
+ try {
294
+ const memberOf = await this.#sb.fetchMyMemberships(this.email);
295
+ const containers = await ds.db.collections[this.#schema.container].find().exec();
296
+ if (ds !== this.#app.cloud)
297
+ return;
298
+ this.#update((s) => {
299
+ if (this.#roles) {
300
+ s.roles = Object.fromEntries([...memberOf].filter((e) => e[1] !== null));
301
+ }
302
+ s.revoked = containers
303
+ .map((c) => c.primary)
304
+ .filter((id) => !memberOf.has(id) && !(id in s.pending) && !s.blocked.includes(id));
305
+ // A container that was re-shared becomes pushable again.
306
+ s.blocked = s.blocked.filter((id) => !memberOf.has(id));
307
+ });
308
+ }
309
+ catch (err) {
310
+ this.error = errorMessage(err);
311
+ }
312
+ }
313
+ async forceResync() {
314
+ for (const rep of this.#replications)
315
+ rep.reSync();
316
+ this.#scheduleSettle();
317
+ await this.checkMemberships(true);
318
+ }
319
+ async signInWithGoogle(redirectTo) {
320
+ const sb = this.#sb.getClient();
321
+ if (!sb)
322
+ throw new Error('Cloud sync is not configured.');
323
+ const { error } = await sb.auth.signInWithOAuth({ provider: 'google', options: { redirectTo } });
324
+ if (error)
325
+ throw error;
326
+ }
327
+ /** Email a magic sign-in link. The link has to be opened in this browser (PKCE). */
328
+ async signInWithEmail(email, redirectTo) {
329
+ const sb = this.#sb.getClient();
330
+ if (!sb)
331
+ throw new Error('Cloud sync is not configured.');
332
+ const { error } = await sb.auth.signInWithOtp({ email: email.trim(), options: { emailRedirectTo: redirectTo } });
333
+ if (error)
334
+ throw error;
335
+ }
336
+ /** Removes every cloud-enabled container from this device. Local-only ones stay. */
337
+ async signOut() {
338
+ const sb = this.#sb.getClient();
339
+ this.#signingOut = true;
340
+ try {
341
+ await this.#serial(() => this.#teardown(true));
342
+ this.sessionExpired = false;
343
+ await sb?.auth.signOut({ scope: 'local' });
344
+ }
345
+ finally {
346
+ this.#signingOut = false;
347
+ }
348
+ }
349
+ }
@@ -0,0 +1,74 @@
1
+ import type { RxCollection } from 'rxdb/plugins/core';
2
+ import type { AppState } from './app.svelte.js';
3
+ import type { CloudSync } from './cloud.svelte.js';
4
+ import type { DataSet } from './dataset.js';
5
+ import type { DevicePrefs } from './prefs.svelte.js';
6
+ import type { SupabaseApi } from './supabase.js';
7
+ import type { Tombstones } from './tombstones.svelte.js';
8
+ import type { CollectionName, DocMap, Location, Role, StoreSchema } from './types.js';
9
+ /** Every row of one container, by collection. */
10
+ export type ContainerDocs<D extends DocMap> = {
11
+ [N in CollectionName<D>]: D[N][];
12
+ };
13
+ /**
14
+ * App-specific id references a copy has to rewrite, beyond `id`, the container field and the
15
+ * `refs` fields: composite ids and ids inside text fields. `map` turns an old id into the copy's
16
+ * id (the identity when ids are kept).
17
+ */
18
+ export type RemapHook<D extends DocMap> = <N extends CollectionName<D>>(collection: N, row: D[N], map: (id: string) => string) => D[N];
19
+ export interface ContainerOpsOptions<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> {
20
+ schema: StoreSchema<D, C>;
21
+ app: AppState<D>;
22
+ cloud: CloudSync<D>;
23
+ tombstones: Tombstones;
24
+ prefs: DevicePrefs;
25
+ supabase: SupabaseApi;
26
+ roles?: boolean;
27
+ /** Fields per collection holding ids of other rows in the same container, e.g. `{ items: ['category_id'] }`. */
28
+ refs?: {
29
+ [N in CollectionName<D>]?: (keyof D[N] & string)[];
30
+ };
31
+ remap?: RemapHook<D>;
32
+ }
33
+ /** A container as listed by the app (one entry even while it moves between data sets). */
34
+ export interface ContainerEntry<T> {
35
+ container: T;
36
+ location: Location;
37
+ role: Role;
38
+ }
39
+ /** Persisting a TanStack DB transaction in the background, logging failures. */
40
+ export declare function persist(tx: {
41
+ when(state: 'settled'): Promise<unknown>;
42
+ }): void;
43
+ export declare const now: () => number;
44
+ export declare const uid: () => `${string}-${string}-${string}-${string}-${string}`;
45
+ export type ContainerOps<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createContainerOps<D, C>>;
46
+ /**
47
+ * Operations on whole containers and the low-level row helpers they're built from. Every write
48
+ * to a cloud data set marks the container pending so the profile can show unsynced changes.
49
+ */
50
+ export declare function createContainerOps<D extends DocMap, C extends CollectionName<D> = CollectionName<D>>(options: ContainerOpsOptions<D, C>): {
51
+ rx: <N extends CollectionName<D>>(ds: DataSet<D>, name: N) => RxCollection<D[N]>;
52
+ touch: (ds: DataSet<D>, containerId: string) => void;
53
+ removeDocs: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, ids: string[]) => Promise<void>;
54
+ docsWhere: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, selector: Partial<Record<keyof D[N] & string, string | number | boolean>>) => Promise<D[N][]>;
55
+ insertDoc: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, doc: D[N], containerId: string) => void;
56
+ patch: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, id: string, containerId: string, fields: Partial<D[N]>) => void;
57
+ createContainer: (ds: DataSet<D>, fields: Omit<D[C], "id" | "updated_at" | "created_at">) => string;
58
+ renameContainer: (ds: DataSet<D>, containerId: string, name: string) => void;
59
+ readContainer: (ds: DataSet<D>, containerId: string) => Promise<ContainerDocs<D>>;
60
+ removeContainerDocs: (ds: DataSet<D>, containerId: string) => Promise<void>;
61
+ insertContainerDocs: (to: DataSet<D>, docs: Partial<ContainerDocs<D>>) => Promise<void>;
62
+ remapContainer: (docs: ContainerDocs<D>, containerId: string, newIds: boolean) => {
63
+ rows: ContainerDocs<D>;
64
+ containerId: string;
65
+ };
66
+ copyContainer: (from: DataSet<D>, to: DataSet<D>, containerId: string, newIds: boolean) => Promise<string>;
67
+ makeCloud: (containerId: string) => Promise<void>;
68
+ makeLocal: (containerId: string) => Promise<string>;
69
+ deleteContainer: (ds: DataSet<D>, containerId: string) => Promise<void>;
70
+ leaveContainer: (containerId: string) => Promise<void>;
71
+ keepRevokedCopy: (containerId: string) => Promise<string>;
72
+ removeRevoked: (containerId: string) => Promise<void>;
73
+ entries: (localRows: D[C][], cloudRows: D[C][]) => ContainerEntry<D[C]>[];
74
+ };