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

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/dist/sql.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Generates the Supabase migration for an app's tables: `_deleted` / `_modified` replication
3
+ * columns with their trigger and checkpoint index, `current_email()`, a membership table with
4
+ * an `is_<noun>_member` helper and RLS on every table, the creator-becomes-member trigger, the
5
+ * "touch rows on new share" trigger, the members RPC (active / pending) and the realtime
6
+ * publication. With `roles`, members are viewer / editor / owner and the last owner is protected.
7
+ *
8
+ * Pure string building: no database or Svelte dependency, so it runs from a plain Node script.
9
+ */
10
+ export type ColumnType = 'text' | 'number' | 'boolean';
11
+ export interface ColumnSpec {
12
+ type: ColumnType;
13
+ /** SQL default expression. Defaults: '' / 0 / false; none for id references. */
14
+ default?: string | null;
15
+ }
16
+ export interface MigrationTable {
17
+ name: string;
18
+ /** Every column except `id`, `_deleted` and `_modified`, in order. */
19
+ columns: Record<string, ColumnType | ColumnSpec>;
20
+ }
21
+ export interface MigrationConfig {
22
+ /** First line of the header comment, e.g. "My App: schema for RxDB <-> Supabase replication." */
23
+ title?: string;
24
+ /** The container table, e.g. 'projects'. Must be one of `tables`. */
25
+ container: string;
26
+ /** Column on child tables pointing at the container, e.g. 'project_id'. */
27
+ containerField: string;
28
+ /** Singular noun, e.g. 'project' (names `project_members`, `is_project_member`, `get_project_members`…). */
29
+ noun: string;
30
+ /** All replicated tables, parents first. */
31
+ tables: MigrationTable[];
32
+ roles?: boolean;
33
+ membersTable?: string;
34
+ membersRpc?: string;
35
+ }
36
+ /**
37
+ * Turn RxDB flat schemas into migration tables. `refs` fields (and the container field) become
38
+ * `text not null` with no default; `defaults` overrides per column, e.g. `{ tasks: { open: 'true' } }`.
39
+ */
40
+ export declare function tablesFromSchemas(collections: readonly string[], schemas: Record<string, {
41
+ properties: Record<string, {
42
+ type?: unknown;
43
+ }>;
44
+ }>, options: {
45
+ containerField: string;
46
+ refs?: Record<string, readonly string[] | undefined>;
47
+ defaults?: Record<string, Record<string, string>>;
48
+ }): MigrationTable[];
49
+ export declare function generateMigration(config: MigrationConfig): string;
package/dist/sql.js ADDED
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Generates the Supabase migration for an app's tables: `_deleted` / `_modified` replication
3
+ * columns with their trigger and checkpoint index, `current_email()`, a membership table with
4
+ * an `is_<noun>_member` helper and RLS on every table, the creator-becomes-member trigger, the
5
+ * "touch rows on new share" trigger, the members RPC (active / pending) and the realtime
6
+ * publication. With `roles`, members are viewer / editor / owner and the last owner is protected.
7
+ *
8
+ * Pure string building: no database or Svelte dependency, so it runs from a plain Node script.
9
+ */
10
+ const SQL_TYPES = { text: 'text', number: 'double precision', boolean: 'boolean' };
11
+ const DEFAULTS = { text: "''", number: '0', boolean: 'false' };
12
+ function columnSql(name, spec) {
13
+ const { type, default: def } = typeof spec === 'string' ? { type: spec, default: undefined } : spec;
14
+ const value = def === undefined ? DEFAULTS[type] : def;
15
+ return ` ${name} ${SQL_TYPES[type]} not null${value === null ? '' : ` default ${value}`}`;
16
+ }
17
+ /**
18
+ * Turn RxDB flat schemas into migration tables. `refs` fields (and the container field) become
19
+ * `text not null` with no default; `defaults` overrides per column, e.g. `{ tasks: { open: 'true' } }`.
20
+ */
21
+ export function tablesFromSchemas(collections, schemas, options) {
22
+ return collections.map((name) => {
23
+ const columns = {};
24
+ const refs = new Set([options.containerField, ...(options.refs?.[name] ?? [])]);
25
+ for (const [col, prop] of Object.entries(schemas[name].properties)) {
26
+ if (col === 'id')
27
+ continue;
28
+ const type = prop.type === 'number' ? 'number' : prop.type === 'boolean' ? 'boolean' : 'text';
29
+ const override = options.defaults?.[name]?.[col];
30
+ columns[col] = { type, default: override ?? (refs.has(col) ? null : undefined) };
31
+ }
32
+ return { name, columns };
33
+ });
34
+ }
35
+ export function generateMigration(config) {
36
+ const { container, containerField: cf, noun, roles = false } = config;
37
+ const members = config.membersTable ?? `${noun}_members`;
38
+ const rpc = config.membersRpc ?? `get_${noun}_members`;
39
+ const isMember = `is_${noun}_member`;
40
+ const roleType = `${noun}_role`;
41
+ const children = config.tables.filter((t) => t.name !== container);
42
+ const all = config.tables.map((t) => t.name);
43
+ if (!all.includes(container))
44
+ throw new Error(`The container table "${container}" must be in tables.`);
45
+ const list = (names) => names.map((n) => `'${n}'`).join(',');
46
+ const out = [];
47
+ const add = (s) => out.push(out.length === 0 ? s.replace(/^\n/, '') : s);
48
+ const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
49
+ add(`
50
+ -- ${config.title ?? 'Schema for RxDB <-> Supabase replication.'}
51
+ --
52
+ -- Every replicated table mirrors an RxDB collection 1:1 (same snake_case column names) plus:
53
+ -- _deleted soft-delete flag (rows are never hard-deleted by the app)
54
+ -- _modified server timestamp used as the replication checkpoint (set by trigger)
55
+ -- Access is granted per ${noun} through ${members}, matched on the signed-in user's email so a
56
+ -- ${noun} can be shared with someone who hasn't signed up yet (a "pending" member).${roles ? '\n-- Roles: viewer < editor < owner.' : ''}
57
+
58
+ -- ---------------------------------------------------------------- helpers
59
+
60
+ create or replace function public.set_modified()
61
+ returns trigger
62
+ language plpgsql
63
+ as $$
64
+ begin
65
+ new._modified := clock_timestamp();
66
+ return new;
67
+ end;
68
+ $$;
69
+
70
+ create or replace function public.current_email()
71
+ returns text
72
+ language sql
73
+ stable
74
+ as $$
75
+ select lower(coalesce(auth.jwt() ->> 'email', ''));
76
+ $$;
77
+
78
+ -- ---------------------------------------------------------------- membership
79
+ `);
80
+ if (roles)
81
+ add(`\ncreate type public.${roleType} as enum ('viewer', 'editor', 'owner');\n`);
82
+ add(`
83
+ create table public.${members} (
84
+ id uuid primary key default gen_random_uuid(),
85
+ ${cf} text not null,
86
+ email text not null check (email = lower(email)),${roles ? `\n role public.${roleType} not null,` : ''}
87
+ invited_by uuid default auth.uid(),
88
+ created_at timestamptz not null default now(),
89
+ unique (${cf}, email)
90
+ );
91
+ create index ${members}_email_idx on public.${members} (email);
92
+
93
+ -- security definer so policies can call it without recursing into ${members}' own RLS.
94
+ create or replace function public.${isMember}(p_${cf} text${roles ? `, p_min_role public.${roleType} default 'viewer'` : ''})
95
+ returns boolean
96
+ language sql
97
+ stable
98
+ security definer
99
+ set search_path = public
100
+ as $$
101
+ select exists (
102
+ select 1 from public.${members} m
103
+ where m.${cf} = p_${cf} and m.email = public.current_email()${roles ? ' and m.role >= p_min_role' : ''}
104
+ );
105
+ $$;
106
+
107
+ alter table public.${members} enable row level security;
108
+ `);
109
+ if (roles) {
110
+ add(`
111
+ create policy "members read members" on public.${members}
112
+ for select to authenticated using (public.${isMember}(${cf}));
113
+ create policy "owners invite" on public.${members}
114
+ for insert to authenticated with check (public.${isMember}(${cf}, 'owner'));
115
+ create policy "owners change roles" on public.${members}
116
+ for update to authenticated using (public.${isMember}(${cf}, 'owner'));
117
+ -- Owners remove anyone; everyone can remove themselves (leave the ${noun}).
118
+ create policy "owners remove, members leave" on public.${members}
119
+ for delete to authenticated using (public.${isMember}(${cf}, 'owner') or email = public.current_email());
120
+
121
+ -- A ${noun} can never end up without an owner.
122
+ create or replace function public.prevent_last_owner_removal()
123
+ returns trigger
124
+ language plpgsql
125
+ security definer
126
+ set search_path = public
127
+ as $$
128
+ begin
129
+ if old.role = 'owner' and (tg_op = 'DELETE' or new.role <> 'owner') then
130
+ if not exists (
131
+ select 1 from public.${members}
132
+ where ${cf} = old.${cf} and role = 'owner' and id <> old.id
133
+ ) then
134
+ -- A ${noun} that is itself deleted may drop its last owner.
135
+ if exists (select 1 from public.${container} t where t.id = old.${cf} and not t._deleted) then
136
+ raise exception 'cannot remove the last owner of a ${noun}';
137
+ end if;
138
+ end if;
139
+ end if;
140
+ return coalesce(new, old);
141
+ end;
142
+ $$;
143
+
144
+ create trigger ${members}_keep_owner before update or delete on public.${members}
145
+ for each row execute function public.prevent_last_owner_removal();
146
+ `);
147
+ }
148
+ else {
149
+ add(`
150
+ create policy "members read memberships of their ${container}" on public.${members}
151
+ for select to authenticated using (public.${isMember}(${cf}));
152
+ create policy "members share their ${container}" on public.${members}
153
+ for insert to authenticated with check (public.${isMember}(${cf}));
154
+ create policy "members unshare their ${container}" on public.${members}
155
+ for delete to authenticated using (public.${isMember}(${cf}));
156
+ `);
157
+ }
158
+ add(`\n-- ---------------------------------------------------------------- replicated tables\n`);
159
+ for (const table of config.tables) {
160
+ const cols = [' id text primary key', ...Object.entries(table.columns).map(([n, s]) => columnSql(n, s))];
161
+ cols.push(' _deleted boolean not null default false', ' _modified timestamptz not null default now()');
162
+ add(`\ncreate table public.${table.name} (\n${cols.join(',\n')}\n);\n`);
163
+ }
164
+ const writer = roles ? 'editor' : null;
165
+ add(`
166
+ do $$
167
+ declare t text;
168
+ begin
169
+ foreach t in array array[${list(all)}] loop
170
+ execute format('create index %1$s_modified_idx on public.%1$s (_modified, id)', t);
171
+ execute format(
172
+ 'create trigger %1$s_set_modified before insert or update on public.%1$s
173
+ for each row execute function public.set_modified()', t);
174
+ execute format('alter table public.%1$s enable row level security', t);
175
+ end loop;
176
+ ${children.length === 0
177
+ ? ''
178
+ : `
179
+ -- ${cap(noun)} data: ${roles ? 'viewers read, editors write' : 'members read and write'}.
180
+ foreach t in array array[${list(children.map((c) => c.name))}] loop
181
+ execute format('create index %1$s_${noun}_idx on public.%1$s (${cf})', t);
182
+ execute format(
183
+ 'create policy "members read" on public.%1$s for select to authenticated
184
+ using (public.${isMember}(${cf}))', t);
185
+ ${writer
186
+ ? ` execute format(
187
+ 'create policy "editors insert" on public.%1$s for insert to authenticated
188
+ with check (public.${isMember}(${cf}, %2$L))', t, '${writer}');
189
+ execute format(
190
+ 'create policy "editors update" on public.%1$s for update to authenticated
191
+ using (public.${isMember}(${cf}, %2$L)) with check (public.${isMember}(${cf}, %2$L))', t, '${writer}');`
192
+ : ` execute format(
193
+ 'create policy "members insert" on public.%1$s for insert to authenticated
194
+ with check (public.${isMember}(${cf}))', t);
195
+ execute format(
196
+ 'create policy "members update" on public.%1$s for update to authenticated
197
+ using (public.${isMember}(${cf})) with check (public.${isMember}(${cf}))', t);`}
198
+ end loop;
199
+ `}end;
200
+ $$;
201
+
202
+ -- ${cap(container)}: any signed-in user creates one (and becomes ${roles ? 'its owner' : 'a member'}); ${roles ? 'only owners rename / delete' : 'members rename / delete'}.
203
+ create policy "members read" on public.${container}
204
+ for select to authenticated using (public.${isMember}(id));
205
+ create policy "signed-in users create ${container}" on public.${container}
206
+ for insert to authenticated with check (auth.uid() is not null);
207
+ create policy "${roles ? 'owners' : 'members'} update" on public.${container}
208
+ for update to authenticated using (public.${isMember}(id${roles ? ", 'owner'" : ''})) with check (public.${isMember}(id${roles ? ", 'owner'" : ''}));
209
+
210
+ -- The creator of a ${noun} becomes its first ${roles ? 'owner' : 'member'}.
211
+ create or replace function public.add_creator_membership()
212
+ returns trigger
213
+ language plpgsql
214
+ security definer
215
+ set search_path = public
216
+ as $$
217
+ begin
218
+ if public.current_email() <> '' then
219
+ insert into public.${members} (${cf}, email${roles ? ', role' : ''})
220
+ values (new.id, public.current_email()${roles ? ", 'owner'" : ''})
221
+ on conflict (${cf}, email) do nothing;
222
+ end if;
223
+ return new;
224
+ end;
225
+ $$;
226
+
227
+ create trigger ${container}_add_creator after insert on public.${container}
228
+ for each row execute function public.add_creator_membership();
229
+
230
+ -- When a ${noun} is shared with someone, bump _modified on all of its rows so their replication
231
+ -- (which only pulls rows newer than its checkpoint) picks the whole ${noun} up.
232
+ create or replace function public.touch_${noun}_for_new_member()
233
+ returns trigger
234
+ language plpgsql
235
+ security definer
236
+ set search_path = public
237
+ as $$
238
+ begin
239
+ if new.email is distinct from public.current_email() then
240
+ update public.${container} set _modified = clock_timestamp() where id = new.${cf};
241
+ ${children.map((c) => ` update public.${c.name} set _modified = clock_timestamp() where ${cf} = new.${cf};`).join('\n')}
242
+ end if;
243
+ return new;
244
+ end;
245
+ $$;
246
+
247
+ create trigger ${members}_touch after insert on public.${members}
248
+ for each row execute function public.touch_${noun}_for_new_member();
249
+
250
+ -- Members of a ${noun}${roles ? ' with their role' : ''}, flagging which emails belong to a registered user
251
+ -- ("active") vs pending invites.
252
+ create or replace function public.${rpc}(p_${cf} text)
253
+ returns table (id uuid, email text, ${roles ? `role public.${roleType}, ` : ''}active boolean)
254
+ language sql
255
+ stable
256
+ security definer
257
+ set search_path = public, auth
258
+ as $$
259
+ select m.id, m.email, ${roles ? 'm.role, ' : ''}exists (select 1 from auth.users u where lower(u.email) = m.email) as active
260
+ from public.${members} m
261
+ where m.${cf} = p_${cf} and public.${isMember}(p_${cf})
262
+ order by ${roles ? 'm.role desc, ' : ''}m.created_at;
263
+ $$;
264
+
265
+ revoke all on function public.${rpc}(text) from public, anon;
266
+ grant execute on function public.${rpc}(text) to authenticated;
267
+
268
+ -- ---------------------------------------------------------------- realtime
269
+
270
+ alter publication supabase_realtime
271
+ add table ${all.map((n) => `public.${n}`).join(', ')};
272
+ `);
273
+ return out.join('');
274
+ }
@@ -0,0 +1,79 @@
1
+ import { AppState } from './app.svelte.js';
2
+ import { CloudSync } from './cloud.svelte.js';
3
+ import { type RemapHook } from './containers.js';
4
+ import { type StorageFactory } from './database.js';
5
+ import { type DataSet } from './dataset.js';
6
+ import type { EnvConfig } from './env.js';
7
+ import { DevicePrefs } from './prefs.svelte.js';
8
+ import { type MembersConfig } from './supabase.js';
9
+ import { Tombstones } from './tombstones.svelte.js';
10
+ import type { CollectionName, DocMap, Nouns, StoreSchema } from './types.js';
11
+ /**
12
+ * `C` is the container collection; pass it (`createSyncedStore<Docs, 'projects'>`) so container
13
+ * rows are typed as that document rather than a union of every document.
14
+ */
15
+ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> extends StoreSchema<D, C> {
16
+ /** Prefix for database names and localStorage keys, e.g. 'demo' → `demo_local`, `demo:hidden`. */
17
+ appKey: string;
18
+ nouns: Nouns;
19
+ /** From `envConfig(import.meta.env)`. */
20
+ env: EnvConfig;
21
+ /** RxDB storage, e.g. `getRxStorageDexie` (browser) or `getRxStorageMemory` (tests). */
22
+ storage: StorageFactory;
23
+ /** viewer / editor / owner roles (needs the roles variant of the SQL migration). */
24
+ roles?: boolean;
25
+ /** Override membership table / RPC names (defaults: `<noun>_members`, `get_<noun>_members`). */
26
+ members?: Partial<Omit<MembersConfig, 'roles' | 'containerField'>>;
27
+ /** Fields per collection holding ids of other rows in the container (rewritten when copying). */
28
+ refs?: {
29
+ [N in CollectionName<D>]?: (keyof D[N] & string)[];
30
+ };
31
+ /** Rewrite composite ids / ids inside text fields when copying (see `RemapHook`). */
32
+ remap?: RemapHook<D>;
33
+ /** A `DevicePrefs` subclass with app-specific prefs. */
34
+ prefs?: DevicePrefs;
35
+ }
36
+ /**
37
+ * Wire the whole storage layer for an app from one config: app state (local + cloud data
38
+ * sets), cloud sync, tombstones, device prefs, the Supabase members API and container
39
+ * operations. Create it once in a module and import it where needed.
40
+ */
41
+ export declare function createSyncedStore<D extends DocMap, C extends CollectionName<D> = CollectionName<D>>(config: SyncedStoreConfig<D, C>): {
42
+ config: SyncedStoreConfig<D, C>;
43
+ roles: boolean;
44
+ app: AppState<D>;
45
+ cloud: CloudSync<D>;
46
+ tombstones: Tombstones;
47
+ prefs: DevicePrefs;
48
+ supabase: import("./supabase.js").SupabaseApi;
49
+ ops: {
50
+ rx: <N extends CollectionName<D>>(ds: DataSet<D>, name: N) => import("rxdb").RxCollection<D[N]>;
51
+ touch: (ds: DataSet<D>, containerId: string) => void;
52
+ removeDocs: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, ids: string[]) => Promise<void>;
53
+ docsWhere: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, selector: Partial<Record<keyof D[N] & string, string | number | boolean>>) => Promise<D[N][]>;
54
+ insertDoc: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, doc: D[N], containerId: string) => void;
55
+ patch: <N extends CollectionName<D>>(ds: DataSet<D>, name: N, id: string, containerId: string, fields: Partial<D[N]>) => void;
56
+ createContainer: (ds: DataSet<D>, fields: Omit<D[C], "id" | "updated_at" | "created_at">) => string;
57
+ renameContainer: (ds: DataSet<D>, containerId: string, name: string) => void;
58
+ readContainer: (ds: DataSet<D>, containerId: string) => Promise<import("./containers.js").ContainerDocs<D>>;
59
+ removeContainerDocs: (ds: DataSet<D>, containerId: string) => Promise<void>;
60
+ insertContainerDocs: (to: DataSet<D>, docs: Partial<import("./containers.js").ContainerDocs<D>>) => Promise<void>;
61
+ remapContainer: (docs: import("./containers.js").ContainerDocs<D>, containerId: string, newIds: boolean) => {
62
+ rows: import("./containers.js").ContainerDocs<D>;
63
+ containerId: string;
64
+ };
65
+ copyContainer: (from: DataSet<D>, to: DataSet<D>, containerId: string, newIds: boolean) => Promise<string>;
66
+ makeCloud: (containerId: string) => Promise<void>;
67
+ makeLocal: (containerId: string) => Promise<string>;
68
+ deleteContainer: (ds: DataSet<D>, containerId: string) => Promise<void>;
69
+ leaveContainer: (containerId: string) => Promise<void>;
70
+ keepRevokedCopy: (containerId: string) => Promise<string>;
71
+ removeRevoked: (containerId: string) => Promise<void>;
72
+ entries: (localRows: D[C][], cloudRows: D[C][]) => import("./containers.js").ContainerEntry<D[C]>[];
73
+ };
74
+ init: (options?: {
75
+ beforeReady?: (local: DataSet<D>) => Promise<void>;
76
+ }) => Promise<void>;
77
+ resetDevice: () => Promise<void>;
78
+ };
79
+ export type SyncedStore<D extends DocMap, C extends CollectionName<D> = CollectionName<D>> = ReturnType<typeof createSyncedStore<D, C>>;
package/dist/store.js ADDED
@@ -0,0 +1,86 @@
1
+ import { tick } from 'svelte';
2
+ import { AppState } from './app.svelte.js';
3
+ import { CloudSync } from './cloud.svelte.js';
4
+ import { createContainerOps } from './containers.js';
5
+ import { openDatabase, setStorageFactory } from './database.js';
6
+ import { createDataSet } from './dataset.js';
7
+ import { DevicePrefs, defaultPrefsKeys } from './prefs.svelte.js';
8
+ import { createSupabaseApi } from './supabase.js';
9
+ import { Tombstones } from './tombstones.svelte.js';
10
+ /**
11
+ * Wire the whole storage layer for an app from one config: app state (local + cloud data
12
+ * sets), cloud sync, tombstones, device prefs, the Supabase members API and container
13
+ * operations. Create it once in a module and import it where needed.
14
+ */
15
+ export function createSyncedStore(config) {
16
+ const roles = config.roles ?? false;
17
+ const supabase = createSupabaseApi(config.env, {
18
+ table: `${config.nouns.singular}_members`,
19
+ membersRpc: `get_${config.nouns.singular}_members`,
20
+ noun: config.nouns.singular,
21
+ ...config.members,
22
+ containerField: config.containerField,
23
+ roles
24
+ });
25
+ const app = new AppState();
26
+ const tombstones = new Tombstones(config.appKey);
27
+ const prefs = config.prefs ?? new DevicePrefs(defaultPrefsKeys(config.appKey, config.nouns));
28
+ const cloud = new CloudSync({
29
+ appKey: config.appKey,
30
+ schema: config,
31
+ app,
32
+ supabase,
33
+ roles,
34
+ storage: config.storage
35
+ });
36
+ const ops = createContainerOps({
37
+ schema: config,
38
+ app,
39
+ cloud,
40
+ tombstones,
41
+ prefs,
42
+ supabase,
43
+ roles,
44
+ refs: config.refs,
45
+ remap: config.remap
46
+ });
47
+ let started;
48
+ /**
49
+ * Open the local database, then restore any Supabase session (which opens the cloud
50
+ * database). Safe to call more than once. A failure ends up in `app.fatal`.
51
+ */
52
+ function init(options = {}) {
53
+ started ??= (async () => {
54
+ setStorageFactory(config.storage);
55
+ const db = await openDatabase(`${config.appKey}_local`, config, { storage: config.storage });
56
+ const local = createDataSet('local', db, config);
57
+ await options.beforeReady?.(local).catch((err) => console.error('Startup hook failed', err));
58
+ app.local = local;
59
+ app.ready = true;
60
+ await cloud.init();
61
+ })().catch((err) => {
62
+ console.error(err);
63
+ app.fatal = err instanceof Error ? err.message : String(err);
64
+ });
65
+ return started;
66
+ }
67
+ /**
68
+ * Unmount the app, sign out locally and remove the local database. Pass to
69
+ * `wipeAllData({ beforeWipe })` from `@wells-nelson/interactions` for a dev "start over".
70
+ */
71
+ async function resetDevice() {
72
+ // Unmount first so no live query is still reading the databases being removed.
73
+ app.ready = false;
74
+ await tick();
75
+ try {
76
+ await cloud.signOut();
77
+ }
78
+ catch (err) {
79
+ console.warn('Sign-out during reset failed', err);
80
+ }
81
+ const local = app.local;
82
+ app.local = null;
83
+ await local?.db.remove();
84
+ }
85
+ return { config, roles, app, cloud, tombstones, prefs, supabase, ops, init, resetDevice };
86
+ }
@@ -0,0 +1,45 @@
1
+ import { type SupabaseClient } from '@supabase/supabase-js';
2
+ import type { EnvConfig } from './env.js';
3
+ import type { Role } from './types.js';
4
+ export interface MembersConfig {
5
+ /** Membership table, e.g. 'project_members'. */
6
+ table: string;
7
+ /** Column holding the container id, e.g. 'project_id'. */
8
+ containerField: string;
9
+ /** RPC returning a container's members with their active/pending state, e.g. 'get_project_members'. */
10
+ membersRpc: string;
11
+ /** The RPC's container id parameter (default `p_<containerField>`). */
12
+ rpcParam?: string;
13
+ /** Members carry a viewer / editor / owner role. */
14
+ roles: boolean;
15
+ /** Singular container noun for error messages, e.g. 'project'. */
16
+ noun: string;
17
+ }
18
+ export interface Member {
19
+ id: string;
20
+ email: string;
21
+ /** True when the email belongs to a registered user; false means the invite is pending. */
22
+ active: boolean;
23
+ /** Only when roles are on. */
24
+ role?: Role | null;
25
+ }
26
+ export interface SupabaseApi {
27
+ readonly configured: boolean;
28
+ /** The shared client (PKCE, created on first use), or null in local-only mode. */
29
+ getClient(): SupabaseClient | null;
30
+ fetchMembers(containerId: string): Promise<Member[]>;
31
+ inviteMember(containerId: string, email: string, role?: Role): Promise<void>;
32
+ setMemberRole(memberId: string, role: Role): Promise<void>;
33
+ removeMember(memberId: string): Promise<void>;
34
+ /** Remove the signed-in user's own membership. */
35
+ leave(containerId: string, email: string): Promise<void>;
36
+ /** Every container the user belongs to, with their role (null without roles). */
37
+ fetchMyMemberships(email: string): Promise<Map<string, Role | null>>;
38
+ }
39
+ /** Turn Postgres errors from triggers / RLS into messages a person can act on. */
40
+ export declare function friendlyError(error: {
41
+ message: string;
42
+ code?: string;
43
+ }, noun: string): Error;
44
+ /** Supabase client plus the members API, with table and RPC names from `members`. */
45
+ export declare function createSupabaseApi(env: EnvConfig, members: MembersConfig): SupabaseApi;
@@ -0,0 +1,77 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ /** Turn Postgres errors from triggers / RLS into messages a person can act on. */
3
+ export function friendlyError(error, noun) {
4
+ if (error.message.includes('last owner')) {
5
+ return new Error(`A ${noun} must always have at least one owner. Make someone else an owner first.`);
6
+ }
7
+ if (error.code === '42501')
8
+ return new Error(`You don't have permission to do that on this ${noun}.`);
9
+ if (error.code === '23505')
10
+ return new Error(`That email already has access to this ${noun}.`);
11
+ return new Error(error.message);
12
+ }
13
+ /** Supabase client plus the members API, with table and RPC names from `members`. */
14
+ export function createSupabaseApi(env, members) {
15
+ let client = null;
16
+ const { table, containerField, membersRpc, roles, noun } = members;
17
+ const rpcParam = members.rpcParam ?? `p_${containerField}`;
18
+ function getClient() {
19
+ if (!env.cloudConfigured)
20
+ return null;
21
+ client ??= createClient(env.supabaseUrl, env.supabaseAnonKey, {
22
+ auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
23
+ });
24
+ return client;
25
+ }
26
+ function requireClient() {
27
+ const sb = getClient();
28
+ if (!sb)
29
+ throw new Error('Cloud sync is not configured.');
30
+ return sb;
31
+ }
32
+ const fail = (error) => {
33
+ if (error)
34
+ throw friendlyError(error, noun);
35
+ };
36
+ return {
37
+ configured: env.cloudConfigured,
38
+ getClient,
39
+ async fetchMembers(containerId) {
40
+ const { data, error } = await requireClient().rpc(membersRpc, { [rpcParam]: containerId });
41
+ fail(error);
42
+ return (data ?? []);
43
+ },
44
+ async inviteMember(containerId, email, role) {
45
+ const row = { [containerField]: containerId, email: email.trim().toLowerCase() };
46
+ if (roles)
47
+ row.role = role ?? 'viewer';
48
+ const { error } = await requireClient().from(table).insert(row);
49
+ fail(error);
50
+ },
51
+ async setMemberRole(memberId, role) {
52
+ if (!roles)
53
+ throw new Error('Roles are not enabled.');
54
+ const { error } = await requireClient().from(table).update({ role }).eq('id', memberId);
55
+ fail(error);
56
+ },
57
+ async removeMember(memberId) {
58
+ const { error } = await requireClient().from(table).delete().eq('id', memberId);
59
+ fail(error);
60
+ },
61
+ async leave(containerId, email) {
62
+ const { error } = await requireClient()
63
+ .from(table)
64
+ .delete()
65
+ .eq(containerField, containerId)
66
+ .eq('email', email.toLowerCase());
67
+ fail(error);
68
+ },
69
+ async fetchMyMemberships(email) {
70
+ const columns = roles ? `${containerField}, role` : containerField;
71
+ const { data, error } = await requireClient().from(table).select(columns).eq('email', email.toLowerCase());
72
+ fail(error);
73
+ const rows = (data ?? []);
74
+ return new Map(rows.map((r) => [r[containerField], roles ? r.role : null]));
75
+ }
76
+ };
77
+ }
@@ -0,0 +1,16 @@
1
+ import type { Location } from './types.js';
2
+ /**
3
+ * Tombstones for deleted rows. A deletion hides its rows from the UI immediately (before the
4
+ * async delete finishes), and keeps hiding them if a sync briefly brings an older copy back.
5
+ * Keys include the data set, because moving a container between local and cloud keeps its ids.
6
+ * Entries are pruned after a week.
7
+ */
8
+ export declare class Tombstones {
9
+ #private;
10
+ /** @param prefix localStorage key prefix, e.g. 'demo' (stored as `demo:hidden`). */
11
+ constructor(prefix: string);
12
+ has(location: Location, id: string): boolean;
13
+ hide(location: Location, ids: string[]): void;
14
+ /** Rows written again on purpose (e.g. a container moved back to this data set) become visible. */
15
+ unhide(location: Location, ids: string[]): void;
16
+ }
@@ -0,0 +1,45 @@
1
+ import { Persisted } from './persisted.svelte.js';
2
+ const KEEP_MS = 7 * 24 * 60 * 60 * 1000;
3
+ /**
4
+ * Tombstones for deleted rows. A deletion hides its rows from the UI immediately (before the
5
+ * async delete finishes), and keeps hiding them if a sync briefly brings an older copy back.
6
+ * Keys include the data set, because moving a container between local and cloud keeps its ids.
7
+ * Entries are pruned after a week.
8
+ */
9
+ export class Tombstones {
10
+ #ids;
11
+ /** @param prefix localStorage key prefix, e.g. 'demo' (stored as `demo:hidden`). */
12
+ constructor(prefix) {
13
+ this.#ids = new Persisted(`${prefix}:hidden`, {});
14
+ const cutoff = Date.now() - KEEP_MS;
15
+ const kept = Object.fromEntries(Object.entries(this.#ids.value).filter(([, t]) => t > cutoff));
16
+ if (Object.keys(kept).length !== Object.keys(this.#ids.value).length)
17
+ this.#ids.value = kept;
18
+ }
19
+ has(location, id) {
20
+ return `${location}:${id}` in this.#ids.value;
21
+ }
22
+ hide(location, ids) {
23
+ if (ids.length === 0)
24
+ return;
25
+ const next = { ...this.#ids.value };
26
+ const t = Date.now();
27
+ for (const id of ids)
28
+ next[`${location}:${id}`] = t;
29
+ this.#ids.value = next;
30
+ }
31
+ /** Rows written again on purpose (e.g. a container moved back to this data set) become visible. */
32
+ unhide(location, ids) {
33
+ const next = { ...this.#ids.value };
34
+ let changed = false;
35
+ for (const id of ids) {
36
+ const key = `${location}:${id}`;
37
+ if (key in next) {
38
+ delete next[key];
39
+ changed = true;
40
+ }
41
+ }
42
+ if (changed)
43
+ this.#ids.value = next;
44
+ }
45
+ }