@wells-nelson/data-storage 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/cloud.svelte.d.ts +6 -2
- package/dist/cloud.svelte.js +12 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/sql.d.ts +6 -0
- package/dist/sql.js +24 -2
- package/dist/store.d.ts +5 -0
- package/dist/store.js +1 -1
- package/dist/supabase.d.ts +26 -2
- package/dist/supabase.js +41 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,21 @@ onMount(() => void store.init({ beforeReady: importDevSeed }));
|
|
|
63
63
|
Without roles everyone is an owner. Table and RPC names default to `<noun>_members` and
|
|
64
64
|
`get_<noun>_members`; override with e.g. `members: { membersRpc: 'get_list_collaborators' }`.
|
|
65
65
|
|
|
66
|
+
## A schema other than `public`
|
|
67
|
+
|
|
68
|
+
To keep an app's tables in their own Postgres schema (handy when several apps share one Supabase
|
|
69
|
+
project), set `supabaseSchema` on the store and the same `schema` on the migration:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
createSyncedStore({ ..., supabaseSchema: 'my_app' });
|
|
73
|
+
generateMigration({ ..., schema: 'my_app' });
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The migration creates the schema with the grants Supabase's API roles need. In the Supabase
|
|
77
|
+
dashboard, add the schema under **Project Settings → Data API → Exposed schemas**; until then,
|
|
78
|
+
the API can't see it. The store's client reads and writes that schema. It also fixes RxDB's
|
|
79
|
+
realtime subscription, which otherwise always listens on `public`.
|
|
80
|
+
|
|
66
81
|
## SQL migration
|
|
67
82
|
|
|
68
83
|
```ts
|
package/dist/cloud.svelte.d.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { User } from '@supabase/supabase-js';
|
|
2
2
|
import type { AppState } from './app.svelte.js';
|
|
3
3
|
import { type StorageFactory } from './database.js';
|
|
4
|
-
import type
|
|
4
|
+
import { type SupabaseApi } from './supabase.js';
|
|
5
5
|
import { type DocMap, type Role, type StoreSchema } from './types.js';
|
|
6
6
|
/** Push errors caused by the server refusing the write (RLS, triggers), not by connectivity. */
|
|
7
7
|
export declare function isRejection(err: unknown): boolean;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* A replication or API error as one line for the UI. With `schema`, a schema the Supabase API
|
|
10
|
+
* doesn't expose is reported as the setting to change.
|
|
11
|
+
*/
|
|
12
|
+
export declare function errorMessage(err: unknown, schema?: string): string;
|
|
9
13
|
export interface CloudSyncOptions<D extends DocMap> {
|
|
10
14
|
/** Prefix for database names and localStorage keys, e.g. 'demo'. */
|
|
11
15
|
appKey: string;
|
package/dist/cloud.svelte.js
CHANGED
|
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
|
|
|
3
3
|
import { openDatabase } from './database.js';
|
|
4
4
|
import { createDataSet, disposeDataSet } from './dataset.js';
|
|
5
5
|
import { Persisted } from './persisted.svelte.js';
|
|
6
|
+
import { replicationClient, schemaNotExposed } from './supabase.js';
|
|
6
7
|
import { containerIdOf } from './types.js';
|
|
7
8
|
const emptyState = () => ({ pending: {}, blocked: [], revoked: [], roles: {}, lastSync: null });
|
|
8
9
|
const MEMBERSHIP_CHECK_INTERVAL = 30_000;
|
|
@@ -15,10 +16,16 @@ export function isRejection(err) {
|
|
|
15
16
|
// 42501: RLS violation; P0001: raised by a trigger (e.g. last owner); 23xxx: constraint violations.
|
|
16
17
|
return !!code && (code === '42501' || code === 'P0001' || code.startsWith('23'));
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* A replication or API error as one line for the UI. With `schema`, a schema the Supabase API
|
|
21
|
+
* doesn't expose is reported as the setting to change.
|
|
22
|
+
*/
|
|
23
|
+
export function errorMessage(err, schema) {
|
|
19
24
|
if (err && typeof err === 'object') {
|
|
20
25
|
const e = err;
|
|
21
|
-
|
|
26
|
+
const inner = e.parameters?.errors?.[0];
|
|
27
|
+
const unexposed = schema ? (schemaNotExposed(inner, schema) ?? schemaNotExposed(e, schema)) : null;
|
|
28
|
+
return unexposed ?? inner?.message ?? e.message ?? String(err);
|
|
22
29
|
}
|
|
23
30
|
return String(err);
|
|
24
31
|
}
|
|
@@ -164,7 +171,7 @@ export class CloudSync {
|
|
|
164
171
|
this.#scheduleSettle();
|
|
165
172
|
}
|
|
166
173
|
#startReplication(db) {
|
|
167
|
-
const sb = this.#sb.getClient();
|
|
174
|
+
const sb = replicationClient(this.#sb.getClient(), this.#sb.schema);
|
|
168
175
|
this.#schema.collections.forEach((name, index) => {
|
|
169
176
|
const rep = replicateSupabase({
|
|
170
177
|
replicationIdentifier: `supabase-${name}`,
|
|
@@ -183,7 +190,7 @@ export class CloudSync {
|
|
|
183
190
|
});
|
|
184
191
|
this.#replications.push(rep);
|
|
185
192
|
this.#subscriptions.push(rep.error$.subscribe((err) => {
|
|
186
|
-
this.error = errorMessage(err);
|
|
193
|
+
this.error = errorMessage(err, this.#sb.schema);
|
|
187
194
|
if (isRejection(err))
|
|
188
195
|
this.rejectedWrite = this.error;
|
|
189
196
|
}), rep.active$.subscribe((isActive) => {
|
|
@@ -307,7 +314,7 @@ export class CloudSync {
|
|
|
307
314
|
});
|
|
308
315
|
}
|
|
309
316
|
catch (err) {
|
|
310
|
-
this.error = errorMessage(err);
|
|
317
|
+
this.error = errorMessage(err, this.#sb.schema);
|
|
311
318
|
}
|
|
312
319
|
}
|
|
313
320
|
async forceResync() {
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export { Persisted } from './persisted.svelte.js';
|
|
|
8
8
|
export { DevicePrefs, defaultPrefsKeys, type DevicePrefsKeys } from './prefs.svelte.js';
|
|
9
9
|
export { field, flatSchema, type FieldSpec } from './schema.js';
|
|
10
10
|
export { createSyncedStore, type SyncedStore, type SyncedStoreConfig } from './store.js';
|
|
11
|
-
export { createSupabaseApi, friendlyError, type Member, type MembersConfig, type SupabaseApi } from './supabase.js';
|
|
11
|
+
export { createSupabaseApi, friendlyError, replicationClient, schemaNotExposed, type Member, type MembersConfig, type SupabaseApi, type SupabaseOptions } from './supabase.js';
|
|
12
12
|
export { Tombstones } from './tombstones.svelte.js';
|
|
13
13
|
export { containerIdOf, type CollectionName, type DocBase, type DocMap, type Location, type Nouns, type Role, type StoreSchema } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,6 @@ export { Persisted } from './persisted.svelte.js';
|
|
|
8
8
|
export { DevicePrefs, defaultPrefsKeys } from './prefs.svelte.js';
|
|
9
9
|
export { field, flatSchema } from './schema.js';
|
|
10
10
|
export { createSyncedStore } from './store.js';
|
|
11
|
-
export { createSupabaseApi, friendlyError } from './supabase.js';
|
|
11
|
+
export { createSupabaseApi, friendlyError, replicationClient, schemaNotExposed } from './supabase.js';
|
|
12
12
|
export { Tombstones } from './tombstones.svelte.js';
|
|
13
13
|
export { containerIdOf } from './types.js';
|
package/dist/sql.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface MigrationConfig {
|
|
|
32
32
|
roles?: boolean;
|
|
33
33
|
membersTable?: string;
|
|
34
34
|
membersRpc?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Postgres schema for every table, type and function (default 'public'). Any other schema is
|
|
37
|
+
* created with the grants Supabase needs; also expose it in the project's Data API settings and
|
|
38
|
+
* pass the same name as `supabaseSchema` to the store.
|
|
39
|
+
*/
|
|
40
|
+
schema?: string;
|
|
35
41
|
}
|
|
36
42
|
/**
|
|
37
43
|
* Turn RxDB flat schemas into migration tables. `refs` fields (and the container field) become
|
package/dist/sql.js
CHANGED
|
@@ -7,6 +7,25 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Pure string building: no database or Svelte dependency, so it runs from a plain Node script.
|
|
9
9
|
*/
|
|
10
|
+
/** Move everything the migration creates from `public` into `schema` (`auth.*` is untouched). */
|
|
11
|
+
function inSchema(sql, schema) {
|
|
12
|
+
const setup = `-- ---------------------------------------------------------------- schema
|
|
13
|
+
|
|
14
|
+
create schema if not exists ${schema};
|
|
15
|
+
|
|
16
|
+
-- The API roles need the same access Supabase grants on public; row level security on every
|
|
17
|
+
-- table still decides what each user can read and write.
|
|
18
|
+
grant usage on schema ${schema} to anon, authenticated, service_role;
|
|
19
|
+
alter default privileges in schema ${schema} grant all on tables to anon, authenticated, service_role;
|
|
20
|
+
alter default privileges in schema ${schema} grant all on functions to anon, authenticated, service_role;
|
|
21
|
+
alter default privileges in schema ${schema} grant all on sequences to anon, authenticated, service_role;
|
|
22
|
+
|
|
23
|
+
`;
|
|
24
|
+
return sql
|
|
25
|
+
.replaceAll('public.', `${schema}.`)
|
|
26
|
+
.replaceAll('set search_path = public', `set search_path = ${schema}`)
|
|
27
|
+
.replace('-- ---------------------------------------------------------------- helpers', `${setup}-- ---------------------------------------------------------------- helpers`);
|
|
28
|
+
}
|
|
10
29
|
const SQL_TYPES = { text: 'text', number: 'double precision', boolean: 'boolean' };
|
|
11
30
|
const DEFAULTS = { text: "''", number: '0', boolean: 'false' };
|
|
12
31
|
function columnSql(name, spec) {
|
|
@@ -33,7 +52,9 @@ export function tablesFromSchemas(collections, schemas, options) {
|
|
|
33
52
|
});
|
|
34
53
|
}
|
|
35
54
|
export function generateMigration(config) {
|
|
36
|
-
const { container, containerField: cf, noun, roles = false } = config;
|
|
55
|
+
const { container, containerField: cf, noun, roles = false, schema = 'public' } = config;
|
|
56
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(schema))
|
|
57
|
+
throw new Error(`Not a valid schema name: "${schema}".`);
|
|
37
58
|
const members = config.membersTable ?? `${noun}_members`;
|
|
38
59
|
const rpc = config.membersRpc ?? `get_${noun}_members`;
|
|
39
60
|
const isMember = `is_${noun}_member`;
|
|
@@ -270,5 +291,6 @@ grant execute on function public.${rpc}(text) to authenticated;
|
|
|
270
291
|
alter publication supabase_realtime
|
|
271
292
|
add table ${all.map((n) => `public.${n}`).join(', ')};
|
|
272
293
|
`);
|
|
273
|
-
|
|
294
|
+
const sql = out.join('');
|
|
295
|
+
return schema === 'public' ? sql : inSchema(sql, schema);
|
|
274
296
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface SyncedStoreConfig<D extends DocMap, C extends CollectionName<D>
|
|
|
22
22
|
storage: StorageFactory;
|
|
23
23
|
/** viewer / editor / owner roles (needs the roles variant of the SQL migration). */
|
|
24
24
|
roles?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Postgres schema for the app's tables and functions (default 'public'). A custom schema must be
|
|
27
|
+
* exposed in the Supabase project's Data API settings; generate its SQL with the same `schema`.
|
|
28
|
+
*/
|
|
29
|
+
supabaseSchema?: string;
|
|
25
30
|
/** Override membership table / RPC names (defaults: `<noun>_members`, `get_<noun>_members`). */
|
|
26
31
|
members?: Partial<Omit<MembersConfig, 'roles' | 'containerField'>>;
|
|
27
32
|
/** Fields per collection holding ids of other rows in the container (rewritten when copying). */
|
package/dist/store.js
CHANGED
|
@@ -21,7 +21,7 @@ export function createSyncedStore(config) {
|
|
|
21
21
|
...config.members,
|
|
22
22
|
containerField: config.containerField,
|
|
23
23
|
roles
|
|
24
|
-
});
|
|
24
|
+
}, { schema: config.supabaseSchema });
|
|
25
25
|
const app = new AppState();
|
|
26
26
|
const tombstones = new Tombstones(config.appKey);
|
|
27
27
|
const prefs = config.prefs ?? new DevicePrefs(defaultPrefsKeys(config.appKey, config.nouns));
|
package/dist/supabase.d.ts
CHANGED
|
@@ -23,8 +23,17 @@ export interface Member {
|
|
|
23
23
|
/** Only when roles are on. */
|
|
24
24
|
role?: Role | null;
|
|
25
25
|
}
|
|
26
|
+
export interface SupabaseOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Postgres schema holding the app's tables and functions (default 'public'). A custom schema
|
|
29
|
+
* must be listed under the Supabase project's exposed schemas (Data API settings).
|
|
30
|
+
*/
|
|
31
|
+
schema?: string;
|
|
32
|
+
}
|
|
26
33
|
export interface SupabaseApi {
|
|
27
34
|
readonly configured: boolean;
|
|
35
|
+
/** The Postgres schema the client reads and writes. */
|
|
36
|
+
readonly schema: string;
|
|
28
37
|
/** The shared client (PKCE, created on first use), or null in local-only mode. */
|
|
29
38
|
getClient(): SupabaseClient | null;
|
|
30
39
|
fetchMembers(containerId: string): Promise<Member[]>;
|
|
@@ -36,10 +45,25 @@ export interface SupabaseApi {
|
|
|
36
45
|
/** Every container the user belongs to, with their role (null without roles). */
|
|
37
46
|
fetchMyMemberships(email: string): Promise<Map<string, Role | null>>;
|
|
38
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* PostgREST refuses a schema that isn't exposed in the project's Data API settings (PGRST106,
|
|
50
|
+
* "Invalid schema: …"). Returns the fix to show instead, or null for other errors.
|
|
51
|
+
*/
|
|
52
|
+
export declare function schemaNotExposed(error: {
|
|
53
|
+
message?: string;
|
|
54
|
+
code?: string;
|
|
55
|
+
} | undefined, schema: string): string | null;
|
|
39
56
|
/** Turn Postgres errors from triggers / RLS into messages a person can act on. */
|
|
40
57
|
export declare function friendlyError(error: {
|
|
41
58
|
message: string;
|
|
42
59
|
code?: string;
|
|
43
|
-
}, noun: string): Error;
|
|
60
|
+
}, noun: string, schema?: string): Error;
|
|
44
61
|
/** Supabase client plus the members API, with table and RPC names from `members`. */
|
|
45
|
-
export declare function createSupabaseApi(env: EnvConfig, members: MembersConfig): SupabaseApi;
|
|
62
|
+
export declare function createSupabaseApi(env: EnvConfig, members: MembersConfig, options?: SupabaseOptions): SupabaseApi;
|
|
63
|
+
/**
|
|
64
|
+
* The client RxDB's Supabase replication should use. Its pulls and pushes go through
|
|
65
|
+
* `client.from()`, which follows the client's schema, but its realtime subscription always
|
|
66
|
+
* asks for schema 'public'. For any other schema this wraps the three methods the replication
|
|
67
|
+
* calls and points that subscription at the right schema.
|
|
68
|
+
*/
|
|
69
|
+
export declare function replicationClient(client: SupabaseClient, schema: string): SupabaseClient;
|
package/dist/supabase.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
/**
|
|
3
|
+
* PostgREST refuses a schema that isn't exposed in the project's Data API settings (PGRST106,
|
|
4
|
+
* "Invalid schema: …"). Returns the fix to show instead, or null for other errors.
|
|
5
|
+
*/
|
|
6
|
+
export function schemaNotExposed(error, schema) {
|
|
7
|
+
if (!error)
|
|
8
|
+
return null;
|
|
9
|
+
if (error.code !== 'PGRST106' && !/invalid schema|schema must be one of/i.test(error.message ?? ''))
|
|
10
|
+
return null;
|
|
11
|
+
return `Expose the ${schema} schema in Supabase (Project Settings → Data API).`;
|
|
12
|
+
}
|
|
2
13
|
/** Turn Postgres errors from triggers / RLS into messages a person can act on. */
|
|
3
|
-
export function friendlyError(error, noun) {
|
|
14
|
+
export function friendlyError(error, noun, schema = 'public') {
|
|
15
|
+
const unexposed = schemaNotExposed(error, schema);
|
|
16
|
+
if (unexposed)
|
|
17
|
+
return new Error(unexposed);
|
|
4
18
|
if (error.message.includes('last owner')) {
|
|
5
19
|
return new Error(`A ${noun} must always have at least one owner. Make someone else an owner first.`);
|
|
6
20
|
}
|
|
@@ -11,14 +25,16 @@ export function friendlyError(error, noun) {
|
|
|
11
25
|
return new Error(error.message);
|
|
12
26
|
}
|
|
13
27
|
/** Supabase client plus the members API, with table and RPC names from `members`. */
|
|
14
|
-
export function createSupabaseApi(env, members) {
|
|
28
|
+
export function createSupabaseApi(env, members, options = {}) {
|
|
15
29
|
let client = null;
|
|
30
|
+
const schema = options.schema ?? 'public';
|
|
16
31
|
const { table, containerField, membersRpc, roles, noun } = members;
|
|
17
32
|
const rpcParam = members.rpcParam ?? `p_${containerField}`;
|
|
18
33
|
function getClient() {
|
|
19
34
|
if (!env.cloudConfigured)
|
|
20
35
|
return null;
|
|
21
36
|
client ??= createClient(env.supabaseUrl, env.supabaseAnonKey, {
|
|
37
|
+
db: { schema },
|
|
22
38
|
auth: { flowType: 'pkce', persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }
|
|
23
39
|
});
|
|
24
40
|
return client;
|
|
@@ -31,10 +47,11 @@ export function createSupabaseApi(env, members) {
|
|
|
31
47
|
}
|
|
32
48
|
const fail = (error) => {
|
|
33
49
|
if (error)
|
|
34
|
-
throw friendlyError(error, noun);
|
|
50
|
+
throw friendlyError(error, noun, schema);
|
|
35
51
|
};
|
|
36
52
|
return {
|
|
37
53
|
configured: env.cloudConfigured,
|
|
54
|
+
schema,
|
|
38
55
|
getClient,
|
|
39
56
|
async fetchMembers(containerId) {
|
|
40
57
|
const { data, error } = await requireClient().rpc(membersRpc, { [rpcParam]: containerId });
|
|
@@ -75,3 +92,24 @@ export function createSupabaseApi(env, members) {
|
|
|
75
92
|
}
|
|
76
93
|
};
|
|
77
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The client RxDB's Supabase replication should use. Its pulls and pushes go through
|
|
97
|
+
* `client.from()`, which follows the client's schema, but its realtime subscription always
|
|
98
|
+
* asks for schema 'public'. For any other schema this wraps the three methods the replication
|
|
99
|
+
* calls and points that subscription at the right schema.
|
|
100
|
+
*/
|
|
101
|
+
export function replicationClient(client, schema) {
|
|
102
|
+
if (schema === 'public')
|
|
103
|
+
return client;
|
|
104
|
+
const adapter = {
|
|
105
|
+
from: (table) => client.from(table),
|
|
106
|
+
removeChannel: (channel) => client.removeChannel(channel),
|
|
107
|
+
channel: (name, opts) => {
|
|
108
|
+
const channel = client.channel(name, opts);
|
|
109
|
+
const on = channel.on.bind(channel);
|
|
110
|
+
channel.on = (type, filter, cb) => on(type, type === 'postgres_changes' ? { ...filter, schema } : filter, cb);
|
|
111
|
+
return channel;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
return adapter;
|
|
115
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wells-nelson/data-storage",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Offline-first storage for Wells Nelson apps: RxDB + TanStack DB data sets, Supabase sync of shared containers, tombstones, device prefs and a SQL migration generator.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|