@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/README.md +79 -2
- package/dist/app.svelte.d.ts +13 -0
- package/dist/app.svelte.js +16 -0
- package/dist/cloud.svelte.d.ts +68 -0
- package/dist/cloud.svelte.js +349 -0
- package/dist/containers.d.ts +74 -0
- package/dist/containers.js +283 -0
- package/dist/database.d.ts +24 -0
- package/dist/database.js +39 -0
- package/dist/dataset.d.ts +18 -0
- package/dist/dataset.js +25 -0
- package/dist/env.d.ts +20 -0
- package/dist/env.js +17 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/persisted.svelte.d.ts +12 -0
- package/dist/persisted.svelte.js +46 -0
- package/dist/prefs.svelte.d.ts +33 -0
- package/dist/prefs.svelte.js +56 -0
- package/dist/schema.d.ts +30 -0
- package/dist/schema.js +24 -0
- package/dist/sql.d.ts +49 -0
- package/dist/sql.js +274 -0
- package/dist/store.d.ts +79 -0
- package/dist/store.js +86 -0
- package/dist/supabase.d.ts +45 -0
- package/dist/supabase.js +77 -0
- package/dist/tombstones.svelte.d.ts +16 -0
- package/dist/tombstones.svelte.js +45 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +5 -0
- package/package.json +56 -4
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
|
+
}
|
package/dist/store.d.ts
ADDED
|
@@ -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;
|
package/dist/supabase.js
ADDED
|
@@ -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
|
+
}
|