@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.
@@ -0,0 +1,283 @@
1
+ /** Persisting a TanStack DB transaction in the background, logging failures. */
2
+ export function persist(tx) {
3
+ tx.when('settled').catch((err) => console.error('Failed to save change', err));
4
+ }
5
+ export const now = () => Date.now();
6
+ export const uid = () => crypto.randomUUID();
7
+ /**
8
+ * Operations on whole containers and the low-level row helpers they're built from. Every write
9
+ * to a cloud data set marks the container pending so the profile can show unsynced changes.
10
+ */
11
+ export function createContainerOps(options) {
12
+ const { schema, app, cloud, tombstones, prefs, supabase, remap } = options;
13
+ const refs = (options.refs ?? {});
14
+ const roles = options.roles ?? false;
15
+ const { container, containerField, collections } = schema;
16
+ function rx(ds, name) {
17
+ return ds.db.collections[name];
18
+ }
19
+ /** Record that a container changed (only cloud data sets track pending changes). */
20
+ function touch(ds, containerId) {
21
+ if (ds.location === 'cloud')
22
+ cloud.markPending(containerId);
23
+ }
24
+ /** Delete documents. `updated_at` is bumped first so the deletion wins last-write-wins conflicts. */
25
+ async function removeDocs(ds, name, ids) {
26
+ if (ids.length === 0)
27
+ return;
28
+ // Gone from the UI right away, and stays gone if a sync briefly brings an old copy back.
29
+ tombstones.hide(ds.location, ids);
30
+ const coll = rx(ds, name);
31
+ const found = await coll.findByIds(ids).exec();
32
+ if (found.size === 0)
33
+ return;
34
+ const t = now();
35
+ await coll.bulkUpsert([...found.values()].map((d) => ({ ...d.toMutableJSON(), updated_at: t })));
36
+ await coll.bulkRemove([...found.keys()]);
37
+ }
38
+ async function docsWhere(ds, name, selector) {
39
+ const docs = await rx(ds, name)
40
+ .find({ selector: selector })
41
+ .exec();
42
+ return docs.map((d) => d.toMutableJSON());
43
+ }
44
+ /** Insert one row through TanStack DB (optimistic) and mark its container changed. */
45
+ function insertDoc(ds, name, doc, containerId) {
46
+ const coll = ds[name];
47
+ persist(coll.insert(doc));
48
+ touch(ds, containerId);
49
+ }
50
+ /** Update fields of one row through TanStack DB, bumping `updated_at`. */
51
+ function patch(ds, name, id, containerId, fields) {
52
+ const coll = ds[name];
53
+ persist(coll.update(id, (d) => {
54
+ Object.assign(d, fields);
55
+ d.updated_at = now();
56
+ }));
57
+ touch(ds, containerId);
58
+ }
59
+ // ---------------------------------------------------------------- containers
60
+ /** Create a container (`id`, `updated_at` and, when the schema has it, `created_at` are filled in). */
61
+ function createContainer(ds, fields) {
62
+ const id = uid();
63
+ const t = now();
64
+ const hasCreatedAt = 'created_at' in (schema.schemas[container].properties ?? {});
65
+ const doc = { ...fields, id, updated_at: t, ...(hasCreatedAt ? { created_at: t } : {}) };
66
+ insertDoc(ds, container, doc, id);
67
+ if (ds.location === 'cloud')
68
+ cloud.setRole(id, 'owner');
69
+ return id;
70
+ }
71
+ function renameContainer(ds, containerId, name) {
72
+ patch(ds, container, containerId, containerId, { name });
73
+ }
74
+ async function readContainer(ds, containerId) {
75
+ const out = {};
76
+ for (const name of collections) {
77
+ out[name] =
78
+ name === container
79
+ ? await docsWhere(ds, name, { id: containerId })
80
+ : await docsWhere(ds, name, { [containerField]: containerId });
81
+ }
82
+ return out;
83
+ }
84
+ async function removeContainerDocs(ds, containerId) {
85
+ const docs = await readContainer(ds, containerId);
86
+ // Children first, the container row last.
87
+ for (const name of [...collections].reverse()) {
88
+ await removeDocs(ds, name, docs[name].map((d) => d.id));
89
+ }
90
+ }
91
+ /** Insert a whole container (all collections) into a data set, parents first. Used for copies and seeds. */
92
+ async function insertContainerDocs(to, docs) {
93
+ for (const name of collections) {
94
+ const rows = docs[name] ?? [];
95
+ if (rows.length === 0)
96
+ continue;
97
+ const result = await rx(to, name).bulkUpsert(rows);
98
+ if (result.error.length)
99
+ throw new Error(`Saving ${name} failed`);
100
+ }
101
+ }
102
+ /**
103
+ * Copy rows to a new container id, regenerating every id when `newIds` (so the copy is
104
+ * independent), and giving all rows a fresh `updated_at` so they beat any older (e.g.
105
+ * soft-deleted) server copy under last-write-wins. Pure; exported for testing.
106
+ */
107
+ function remapContainer(docs, containerId, newIds) {
108
+ const ids = new Map();
109
+ const map = (id) => {
110
+ if (!newIds || !id)
111
+ return id;
112
+ let next = ids.get(id);
113
+ if (!next)
114
+ ids.set(id, (next = uid()));
115
+ return next;
116
+ };
117
+ const t = now();
118
+ const cid = map(containerId);
119
+ const rows = {};
120
+ for (const name of collections) {
121
+ const refFields = refs[name] ?? [];
122
+ rows[name] = docs[name].map((d) => {
123
+ const row = { ...d, id: name === container ? cid : map(d.id), updated_at: t };
124
+ if (name !== container)
125
+ row[containerField] = cid;
126
+ for (const f of refFields)
127
+ row[f] = map(row[f]);
128
+ return remap ? remap(name, row, map) : row;
129
+ });
130
+ }
131
+ return { rows, containerId: cid };
132
+ }
133
+ async function copyContainer(from, to, containerId, newIds) {
134
+ const { rows, containerId: cid } = remapContainer(await readContainer(from, containerId), containerId, newIds);
135
+ if (to.location === 'cloud') {
136
+ cloud.markPending(cid);
137
+ cloud.setRole(cid, 'owner');
138
+ }
139
+ // Rows deliberately written again (e.g. a container moved back here) must not stay hidden.
140
+ for (const name of collections)
141
+ tombstones.unhide(to.location, rows[name].map((r) => r.id));
142
+ await insertContainerDocs(to, rows);
143
+ return cid;
144
+ }
145
+ /** Remove a cloud container from this device only; nothing is deleted on Supabase. */
146
+ async function purgeCloudContainer(containerId) {
147
+ const ds = app.dataSet('cloud');
148
+ cloud.block(containerId);
149
+ await removeContainerDocs(ds, containerId);
150
+ }
151
+ function requireOnline() {
152
+ if (!cloud.online)
153
+ throw new Error('This needs an internet connection. Try again once you are back online.');
154
+ }
155
+ async function otherMembers(containerId) {
156
+ requireOnline();
157
+ const members = await supabase.fetchMembers(containerId);
158
+ return members.filter((m) => m.email !== cloud.email);
159
+ }
160
+ const isOwner = (m) => !roles || m.role === 'owner';
161
+ /** local-only -> cloud-enabled. Same ids; uploads (or queues the upload when offline). */
162
+ async function makeCloud(containerId) {
163
+ const local = app.dataSet('local');
164
+ await copyContainer(local, app.dataSet('cloud'), containerId, false);
165
+ await removeContainerDocs(local, containerId);
166
+ }
167
+ /**
168
+ * cloud-enabled -> local-only. Unshared containers are deleted from Supabase and kept here
169
+ * with the same ids. Shared ones stay on Supabase for the others; we keep a copy under new
170
+ * ids and leave. Returns the id of the local container.
171
+ */
172
+ async function makeLocal(containerId) {
173
+ const cloudDs = app.dataSet('cloud');
174
+ const local = app.dataSet('local');
175
+ const others = await otherMembers(containerId);
176
+ if (others.length === 0) {
177
+ await copyContainer(cloudDs, local, containerId, false);
178
+ await removeContainerDocs(cloudDs, containerId);
179
+ prefs.markPrompted([containerId]);
180
+ return containerId;
181
+ }
182
+ // Copy before leaving: if leaving fails the worst case is an extra local copy, not lost data.
183
+ const newId = await copyContainer(cloudDs, local, containerId, true);
184
+ await supabase.leave(containerId, cloud.email);
185
+ await purgeCloudContainer(containerId);
186
+ prefs.markPrompted([newId]);
187
+ prefs.replaceContainer(containerId, newId);
188
+ return newId;
189
+ }
190
+ /**
191
+ * Delete a container. For a cloud container we only leave it while other owners remain
192
+ * (without roles: while it's shared with anyone); otherwise it's deleted for everyone.
193
+ */
194
+ async function deleteContainer(ds, containerId) {
195
+ // Hide it immediately so it never flashes back while the delete runs.
196
+ tombstones.hide(ds.location, [containerId]);
197
+ try {
198
+ if (ds.location === 'local') {
199
+ await removeContainerDocs(ds, containerId);
200
+ }
201
+ else {
202
+ const others = await otherMembers(containerId);
203
+ if (others.some(isOwner)) {
204
+ await supabase.leave(containerId, cloud.email);
205
+ await purgeCloudContainer(containerId);
206
+ }
207
+ else {
208
+ await removeContainerDocs(ds, containerId);
209
+ }
210
+ }
211
+ }
212
+ catch (err) {
213
+ tombstones.unhide(ds.location, [containerId]);
214
+ throw err;
215
+ }
216
+ prefs.replaceContainer(containerId, null);
217
+ }
218
+ /** Leave a shared container (non-owners, or owners while other owners remain). */
219
+ async function leaveContainer(containerId) {
220
+ requireOnline();
221
+ tombstones.hide('cloud', [containerId]);
222
+ try {
223
+ await supabase.leave(containerId, cloud.email);
224
+ }
225
+ catch (err) {
226
+ tombstones.unhide('cloud', [containerId]);
227
+ throw err;
228
+ }
229
+ await purgeCloudContainer(containerId);
230
+ prefs.replaceContainer(containerId, null);
231
+ }
232
+ /** For a container we were removed from: keep a local-only copy (new ids), drop the cloud copy. */
233
+ async function keepRevokedCopy(containerId) {
234
+ const newId = await copyContainer(app.dataSet('cloud'), app.dataSet('local'), containerId, true);
235
+ await purgeCloudContainer(containerId);
236
+ prefs.markPrompted([newId]);
237
+ prefs.replaceContainer(containerId, newId);
238
+ return newId;
239
+ }
240
+ async function removeRevoked(containerId) {
241
+ await purgeCloudContainer(containerId);
242
+ prefs.replaceContainer(containerId, null);
243
+ }
244
+ /**
245
+ * Merge the local and cloud containers for display: hidden (deleted) ones dropped, and one
246
+ * entry per id while a container moves between data sets (the cloud copy wins).
247
+ */
248
+ function entries(localRows, cloudRows) {
249
+ const byId = new Map();
250
+ for (const row of localRows) {
251
+ if (!tombstones.has('local', row.id))
252
+ byId.set(row.id, { container: row, location: 'local', role: 'owner' });
253
+ }
254
+ for (const row of cloudRows) {
255
+ if (!tombstones.has('cloud', row.id)) {
256
+ byId.set(row.id, { container: row, location: 'cloud', role: cloud.roleOf(row.id) });
257
+ }
258
+ }
259
+ return [...byId.values()];
260
+ }
261
+ return {
262
+ rx,
263
+ touch,
264
+ removeDocs,
265
+ docsWhere,
266
+ insertDoc,
267
+ patch,
268
+ createContainer,
269
+ renameContainer,
270
+ readContainer,
271
+ removeContainerDocs,
272
+ insertContainerDocs,
273
+ remapContainer,
274
+ copyContainer,
275
+ makeCloud,
276
+ makeLocal,
277
+ deleteContainer,
278
+ leaveContainer,
279
+ keepRevokedCopy,
280
+ removeRevoked,
281
+ entries
282
+ };
283
+ }
@@ -0,0 +1,24 @@
1
+ import { type RxCollection, type RxConflictHandler, type RxDatabase, type RxStorage } from 'rxdb/plugins/core';
2
+ import type { CollectionName, DocMap, StoreSchema } from './types.js';
3
+ export type Collections<D extends DocMap> = {
4
+ [N in CollectionName<D>]: RxCollection<D[N]>;
5
+ };
6
+ export type Database<D extends DocMap> = RxDatabase<Collections<D>>;
7
+ export declare function docsEqual(a: Record<string, unknown>, b: Record<string, unknown>): boolean;
8
+ /**
9
+ * Last write wins: whichever side carries the newer `updated_at` (client clock) is kept.
10
+ * Ties go to the server so every device converges on the same state.
11
+ */
12
+ export declare const lastWriteWins: RxConflictHandler<{
13
+ updated_at: number;
14
+ }>;
15
+ export type StorageFactory = () => RxStorage<any, any>;
16
+ /** Browser code uses Dexie (IndexedDB); scripts and tests can swap in memory storage. */
17
+ export declare function setStorageFactory(factory: StorageFactory): void;
18
+ export interface OpenDatabaseOptions {
19
+ storage?: StorageFactory;
20
+ /** Share the database between tabs (default true). */
21
+ multiInstance?: boolean;
22
+ }
23
+ /** Create an RxDB database with one collection per schema, all using last-write-wins. */
24
+ export declare function openDatabase<D extends DocMap>(name: string, schema: Pick<StoreSchema<D>, 'collections' | 'schemas'>, { storage, multiInstance }?: OpenDatabaseOptions): Promise<Database<D>>;
@@ -0,0 +1,39 @@
1
+ import { createRxDatabase } from 'rxdb/plugins/core';
2
+ const IGNORED_FIELDS = new Set(['_meta', '_rev', '_attachments']);
3
+ export function docsEqual(a, b) {
4
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
5
+ for (const key of keys) {
6
+ if (IGNORED_FIELDS.has(key))
7
+ continue;
8
+ if (a[key] !== b[key])
9
+ return false;
10
+ }
11
+ return true;
12
+ }
13
+ /**
14
+ * Last write wins: whichever side carries the newer `updated_at` (client clock) is kept.
15
+ * Ties go to the server so every device converges on the same state.
16
+ */
17
+ export const lastWriteWins = {
18
+ isEqual: (a, b) => docsEqual(a, b),
19
+ resolve: async ({ newDocumentState, realMasterState }) => (newDocumentState.updated_at ?? 0) > (realMasterState.updated_at ?? 0) ? newDocumentState : realMasterState
20
+ };
21
+ let storageFactory = () => {
22
+ throw new Error('No RxDB storage configured. Call setStorageFactory() or pass `storage`.');
23
+ };
24
+ /** Browser code uses Dexie (IndexedDB); scripts and tests can swap in memory storage. */
25
+ export function setStorageFactory(factory) {
26
+ storageFactory = factory;
27
+ }
28
+ /** Create an RxDB database with one collection per schema, all using last-write-wins. */
29
+ export async function openDatabase(name, schema, { storage, multiInstance = true } = {}) {
30
+ const db = await createRxDatabase({
31
+ name,
32
+ storage: (storage ?? storageFactory)(),
33
+ multiInstance,
34
+ eventReduce: true,
35
+ closeDuplicates: true
36
+ });
37
+ await db.addCollections(Object.fromEntries(schema.collections.map((n) => [n, { schema: schema.schemas[n], conflictHandler: lastWriteWins }])));
38
+ return db;
39
+ }
@@ -0,0 +1,18 @@
1
+ import type { RxCollection } from 'rxdb/plugins/core';
2
+ import type { Database } from './database.js';
3
+ import type { CollectionName, DocMap, Location, StoreSchema } from './types.js';
4
+ declare function makeCollection<T extends object>(id: string, rxCollection: RxCollection<T>): import("@tanstack/db").Collection<T, string, import("@tanstack/db").UtilsRecord, never, T> & import("@tanstack/db").NonSingleResult;
5
+ export type TSCollection<T extends object> = ReturnType<typeof makeCollection<T>>;
6
+ /**
7
+ * One RxDB database plus a TanStack DB collection on top of each RxDB collection.
8
+ * Components query the TanStack collections; bulk operations go straight to RxDB.
9
+ */
10
+ export type DataSet<D extends DocMap> = {
11
+ location: Location;
12
+ db: Database<D>;
13
+ } & {
14
+ [N in CollectionName<D>]: TSCollection<D[N]>;
15
+ };
16
+ export declare function createDataSet<D extends DocMap>(location: Location, db: Database<D>, schema: Pick<StoreSchema<D>, 'collections' | 'container' | 'containerField' | 'indexContainerField'>): DataSet<D>;
17
+ export declare function disposeDataSet<D extends DocMap>(ds: DataSet<D>, collections: readonly CollectionName<D>[]): Promise<void>;
18
+ export {};
@@ -0,0 +1,25 @@
1
+ import { BasicIndex, createCollection } from '@tanstack/db';
2
+ import { rxdbCollectionOptions } from '@tanstack/rxdb-db-collection';
3
+ function makeCollection(id, rxCollection) {
4
+ return createCollection(rxdbCollectionOptions({
5
+ id,
6
+ rxCollection: rxCollection,
7
+ startSync: true,
8
+ gcTime: 0
9
+ }));
10
+ }
11
+ export function createDataSet(location, db, schema) {
12
+ const ds = { location, db };
13
+ for (const name of schema.collections) {
14
+ const coll = makeCollection(`${db.name}-${name}`, db.collections[name]);
15
+ // Container pages filter by container; index it when collections get big.
16
+ if (schema.indexContainerField && name !== schema.container) {
17
+ coll.createIndex((row) => row[schema.containerField], { indexType: BasicIndex });
18
+ }
19
+ ds[name] = coll;
20
+ }
21
+ return ds;
22
+ }
23
+ export async function disposeDataSet(ds, collections) {
24
+ await Promise.all(collections.map((n) => ds[n].cleanup()));
25
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface EnvConfig {
2
+ supabaseUrl: string;
3
+ supabaseAnonKey: string;
4
+ /** False when either Supabase value is missing: the app runs fully local-only. */
5
+ cloudConfigured: boolean;
6
+ }
7
+ /**
8
+ * Read the Supabase settings from Vite env vars. Pass `import.meta.env` from the app, so the
9
+ * values are replaced at the app's build time:
10
+ *
11
+ * ```ts
12
+ * export const env = envConfig(import.meta.env);
13
+ * ```
14
+ *
15
+ * Values come from `.env.local` during development and from repository variables in the deploy
16
+ * workflow. When either is missing the app runs fully local-only.
17
+ */
18
+ export declare function envConfig(env: {
19
+ readonly [key: string]: unknown;
20
+ }): EnvConfig;
package/dist/env.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Read the Supabase settings from Vite env vars. Pass `import.meta.env` from the app, so the
3
+ * values are replaced at the app's build time:
4
+ *
5
+ * ```ts
6
+ * export const env = envConfig(import.meta.env);
7
+ * ```
8
+ *
9
+ * Values come from `.env.local` during development and from repository variables in the deploy
10
+ * workflow. When either is missing the app runs fully local-only.
11
+ */
12
+ export function envConfig(env) {
13
+ const read = (key) => (typeof env[key] === 'string' ? env[key] : '');
14
+ const supabaseUrl = read('VITE_SUPABASE_URL');
15
+ const supabaseAnonKey = read('VITE_SUPABASE_ANON_KEY');
16
+ return { supabaseUrl, supabaseAnonKey, cloudConfigured: Boolean(supabaseUrl && supabaseAnonKey) };
17
+ }
@@ -0,0 +1,13 @@
1
+ export { AppState } from './app.svelte.js';
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';
4
+ export { docsEqual, lastWriteWins, openDatabase, setStorageFactory, type Collections, type Database, type OpenDatabaseOptions, type StorageFactory } from './database.js';
5
+ export { createDataSet, disposeDataSet, type DataSet, type TSCollection } from './dataset.js';
6
+ export { envConfig, type EnvConfig } from './env.js';
7
+ export { Persisted } from './persisted.svelte.js';
8
+ export { DevicePrefs, defaultPrefsKeys, type DevicePrefsKeys } from './prefs.svelte.js';
9
+ export { field, flatSchema, type FieldSpec } from './schema.js';
10
+ export { createSyncedStore, type SyncedStore, type SyncedStoreConfig } from './store.js';
11
+ export { createSupabaseApi, friendlyError, type Member, type MembersConfig, type SupabaseApi } from './supabase.js';
12
+ export { Tombstones } from './tombstones.svelte.js';
13
+ export { containerIdOf, type CollectionName, type DocBase, type DocMap, type Location, type Nouns, type Role, type StoreSchema } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ export { AppState } from './app.svelte.js';
2
+ export { CloudSync, errorMessage, isRejection } from './cloud.svelte.js';
3
+ export { createContainerOps, now, persist, uid } from './containers.js';
4
+ export { docsEqual, lastWriteWins, openDatabase, setStorageFactory } from './database.js';
5
+ export { createDataSet, disposeDataSet } from './dataset.js';
6
+ export { envConfig } from './env.js';
7
+ export { Persisted } from './persisted.svelte.js';
8
+ export { DevicePrefs, defaultPrefsKeys } from './prefs.svelte.js';
9
+ export { field, flatSchema } from './schema.js';
10
+ export { createSyncedStore } from './store.js';
11
+ export { createSupabaseApi, friendlyError } from './supabase.js';
12
+ export { Tombstones } from './tombstones.svelte.js';
13
+ export { containerIdOf } from './types.js';
@@ -0,0 +1,12 @@
1
+ /** Reactive value mirrored to localStorage. Storage failures (private mode, quota) are ignored. */
2
+ export declare class Persisted<T> {
3
+ #private;
4
+ constructor(key: string, initial: T);
5
+ get value(): T;
6
+ set value(v: T);
7
+ /** Re-point at another key (e.g. when the signed-in user changes). */
8
+ rekey(key: string, initial: T): void;
9
+ static read<T>(key: string, fallback: T): T;
10
+ static write(key: string, value: unknown): void;
11
+ static remove(key: string): void;
12
+ }
@@ -0,0 +1,46 @@
1
+ /** Reactive value mirrored to localStorage. Storage failures (private mode, quota) are ignored. */
2
+ export class Persisted {
3
+ #key;
4
+ #value = $state();
5
+ constructor(key, initial) {
6
+ this.#key = key;
7
+ this.#value = Persisted.read(key, initial);
8
+ }
9
+ get value() {
10
+ return this.#value;
11
+ }
12
+ set value(v) {
13
+ this.#value = v;
14
+ Persisted.write(this.#key, v);
15
+ }
16
+ /** Re-point at another key (e.g. when the signed-in user changes). */
17
+ rekey(key, initial) {
18
+ this.#key = key;
19
+ this.#value = Persisted.read(key, initial);
20
+ }
21
+ static read(key, fallback) {
22
+ try {
23
+ const raw = localStorage.getItem(key);
24
+ return raw === null ? fallback : JSON.parse(raw);
25
+ }
26
+ catch {
27
+ return fallback;
28
+ }
29
+ }
30
+ static write(key, value) {
31
+ try {
32
+ localStorage.setItem(key, JSON.stringify(value));
33
+ }
34
+ catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+ static remove(key) {
39
+ try {
40
+ localStorage.removeItem(key);
41
+ }
42
+ catch {
43
+ /* ignore */
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,33 @@
1
+ import type { Nouns } from './types.js';
2
+ export interface DevicePrefsKeys {
3
+ collapsed: string;
4
+ prompted: string;
5
+ defaultContainer: string;
6
+ }
7
+ /**
8
+ * Default localStorage keys, e.g. for prefix `demo` and noun project:
9
+ * `demo:collapsed`, `demo:promptedLocalProjects`, `demo:defaultProject`.
10
+ */
11
+ export declare function defaultPrefsKeys(prefix: string, nouns: Nouns): DevicePrefsKeys;
12
+ /**
13
+ * Per-device preferences that never sync: the default container (cold-start redirect), which
14
+ * local-only containers were already offered for cloud sync, and collapsed sections.
15
+ * Subclass it for app-specific prefs; override `replaceContainer` to keep them in step when a
16
+ * container changes id or is deleted.
17
+ */
18
+ export declare class DevicePrefs {
19
+ #private;
20
+ /** Set once the cold-start redirect to the default container was considered for this page load. */
21
+ defaultChecked: boolean;
22
+ constructor(keys: DevicePrefsKeys);
23
+ /** The container a cold start of the app opens straight into. */
24
+ get defaultContainerId(): string | null;
25
+ setDefaultContainer(id: string | null): void;
26
+ /** A container moved to a new id (`newId`) or was deleted (`null`). */
27
+ replaceContainer(oldId: string, newId: string | null): void;
28
+ isCollapsed(key: string, fallback?: boolean): boolean;
29
+ setCollapsed(key: string, collapsed: boolean): void;
30
+ /** Local-only containers already offered in the "make cloud-enabled?" prompt after sign-in. */
31
+ wasPrompted(id: string): boolean;
32
+ markPrompted(ids: string[]): void;
33
+ }
@@ -0,0 +1,56 @@
1
+ import { Persisted } from './persisted.svelte.js';
2
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
3
+ /**
4
+ * Default localStorage keys, e.g. for prefix `demo` and noun project:
5
+ * `demo:collapsed`, `demo:promptedLocalProjects`, `demo:defaultProject`.
6
+ */
7
+ export function defaultPrefsKeys(prefix, nouns) {
8
+ return {
9
+ collapsed: `${prefix}:collapsed`,
10
+ prompted: `${prefix}:promptedLocal${cap(nouns.plural)}`,
11
+ defaultContainer: `${prefix}:default${cap(nouns.singular)}`
12
+ };
13
+ }
14
+ /**
15
+ * Per-device preferences that never sync: the default container (cold-start redirect), which
16
+ * local-only containers were already offered for cloud sync, and collapsed sections.
17
+ * Subclass it for app-specific prefs; override `replaceContainer` to keep them in step when a
18
+ * container changes id or is deleted.
19
+ */
20
+ export class DevicePrefs {
21
+ #collapsed;
22
+ #prompted;
23
+ #defaultContainer;
24
+ /** Set once the cold-start redirect to the default container was considered for this page load. */
25
+ defaultChecked = false;
26
+ constructor(keys) {
27
+ this.#collapsed = new Persisted(keys.collapsed, {});
28
+ this.#prompted = new Persisted(keys.prompted, []);
29
+ this.#defaultContainer = new Persisted(keys.defaultContainer, null);
30
+ }
31
+ /** The container a cold start of the app opens straight into. */
32
+ get defaultContainerId() {
33
+ return this.#defaultContainer.value;
34
+ }
35
+ setDefaultContainer(id) {
36
+ this.#defaultContainer.value = id;
37
+ }
38
+ /** A container moved to a new id (`newId`) or was deleted (`null`). */
39
+ replaceContainer(oldId, newId) {
40
+ if (this.#defaultContainer.value === oldId)
41
+ this.#defaultContainer.value = newId;
42
+ }
43
+ isCollapsed(key, fallback = false) {
44
+ return this.#collapsed.value[key] ?? fallback;
45
+ }
46
+ setCollapsed(key, collapsed) {
47
+ this.#collapsed.value = { ...this.#collapsed.value, [key]: collapsed };
48
+ }
49
+ /** Local-only containers already offered in the "make cloud-enabled?" prompt after sign-in. */
50
+ wasPrompted(id) {
51
+ return this.#prompted.value.includes(id);
52
+ }
53
+ markPrompted(ids) {
54
+ this.#prompted.value = [...new Set([...this.#prompted.value, ...ids])];
55
+ }
56
+ }
@@ -0,0 +1,30 @@
1
+ import type { RxJsonSchema } from 'rxdb/plugins/core';
2
+ /** Field shorthands for `flatSchema`. */
3
+ export declare const field: {
4
+ /** The primary key (RxDB needs a maxLength on it). */
5
+ readonly id: {
6
+ readonly type: "string";
7
+ readonly maxLength: 100;
8
+ };
9
+ readonly string: {
10
+ readonly type: "string";
11
+ };
12
+ readonly number: {
13
+ readonly type: "number";
14
+ };
15
+ readonly boolean: {
16
+ readonly type: "boolean";
17
+ };
18
+ };
19
+ export type FieldSpec = {
20
+ type: 'string' | 'number' | 'boolean';
21
+ maxLength?: number;
22
+ };
23
+ /**
24
+ * RxDB JSON schema for a flat table: every field required, string `id` primary key, version 0.
25
+ *
26
+ * ```ts
27
+ * const projects = flatSchema<ProjectDoc>({ id: field.id, name: field.string, created_at: field.number, updated_at: field.number });
28
+ * ```
29
+ */
30
+ export declare function flatSchema<T>(properties: Record<keyof T & string, FieldSpec>): RxJsonSchema<T>;
package/dist/schema.js ADDED
@@ -0,0 +1,24 @@
1
+ /** Field shorthands for `flatSchema`. */
2
+ export const field = {
3
+ /** The primary key (RxDB needs a maxLength on it). */
4
+ id: { type: 'string', maxLength: 100 },
5
+ string: { type: 'string' },
6
+ number: { type: 'number' },
7
+ boolean: { type: 'boolean' }
8
+ };
9
+ /**
10
+ * RxDB JSON schema for a flat table: every field required, string `id` primary key, version 0.
11
+ *
12
+ * ```ts
13
+ * const projects = flatSchema<ProjectDoc>({ id: field.id, name: field.string, created_at: field.number, updated_at: field.number });
14
+ * ```
15
+ */
16
+ export function flatSchema(properties) {
17
+ return {
18
+ version: 0,
19
+ primaryKey: 'id',
20
+ type: 'object',
21
+ properties,
22
+ required: Object.keys(properties)
23
+ };
24
+ }