@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 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
@@ -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 { SupabaseApi } from './supabase.js';
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
- export declare function errorMessage(err: unknown): string;
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;
@@ -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
- export function errorMessage(err) {
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
- return e.parameters?.errors?.[0]?.message ?? e.message ?? String(err);
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
- return out.join('');
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));
@@ -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": "0.0.1",
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": {