@selvajs/supabase-provider 0.11.0 → 0.13.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.
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================================
3
+ // sync-migrations — copy this package's Supabase migrations into a consuming
4
+ // app's supabase/migrations/ directory.
5
+ //
6
+ // Migrations ship with deterministic timestamp-prefixed filenames
7
+ // (<UTCtimestamp>_selva_<name>.sql) so they slot into a consuming app's own
8
+ // migration timeline without colliding, and so every machine/CI produces a
9
+ // byte-identical migration history. We copy verbatim — no rename at copy time,
10
+ // which is what keeps the history stable.
11
+ //
12
+ // Usage:
13
+ // npx @selvajs/supabase-provider sync-migrations [--dir <path>] [--force]
14
+ //
15
+ // --dir <path> Target migrations dir (default: ./supabase/migrations)
16
+ // --force Overwrite files that exist with differing content
17
+ // ============================================================================
18
+
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+
25
+ // dist/cli/ -> package root is two levels up; migrations ship alongside src/dist.
26
+ const packageRoot = resolve(here, '..', '..');
27
+ const sourceDir = join(packageRoot, 'supabase', 'migrations');
28
+
29
+ interface Options {
30
+ targetDir: string;
31
+ force: boolean;
32
+ }
33
+
34
+ function parseArgs(argv: string[]): Options {
35
+ let targetDir = resolve(process.cwd(), 'supabase', 'migrations');
36
+ let force = false;
37
+
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const arg = argv[i];
40
+ if (arg === '--dir') {
41
+ const value = argv[++i];
42
+ if (!value) fail('--dir requires a path argument');
43
+ targetDir = resolve(process.cwd(), value);
44
+ } else if (arg === '--force') {
45
+ force = true;
46
+ } else if (arg === '--help' || arg === '-h') {
47
+ printHelp();
48
+ process.exit(0);
49
+ } else {
50
+ fail(`unknown argument: ${arg}`);
51
+ }
52
+ }
53
+
54
+ return { targetDir, force };
55
+ }
56
+
57
+ function printHelp(): void {
58
+ console.log(
59
+ [
60
+ 'sync-migrations — copy @selvajs/supabase-provider migrations into your app.',
61
+ '',
62
+ 'Usage:',
63
+ ' npx @selvajs/supabase-provider sync-migrations [--dir <path>] [--force]',
64
+ '',
65
+ ' --dir <path> Target migrations dir (default: ./supabase/migrations)',
66
+ ' --force Overwrite files that exist with differing content',
67
+ '',
68
+ 'After syncing, apply with: npx supabase db push'
69
+ ].join('\n')
70
+ );
71
+ }
72
+
73
+ function fail(message: string): never {
74
+ console.error(`error: ${message}`);
75
+ process.exit(1);
76
+ }
77
+
78
+ function main(): void {
79
+ const { targetDir, force } = parseArgs(process.argv.slice(2));
80
+
81
+ if (!existsSync(sourceDir)) {
82
+ fail(`could not locate packaged migrations at ${sourceDir}`);
83
+ }
84
+
85
+ const files = readdirSync(sourceDir)
86
+ .filter((name) => name.endsWith('.sql'))
87
+ .sort();
88
+
89
+ if (files.length === 0) {
90
+ fail(`no .sql migrations found in ${sourceDir}`);
91
+ }
92
+
93
+ mkdirSync(targetDir, { recursive: true });
94
+
95
+ let copied = 0;
96
+ let skipped = 0;
97
+ let conflicts = 0;
98
+
99
+ for (const name of files) {
100
+ const from = join(sourceDir, name);
101
+ const to = join(targetDir, name);
102
+ const contents = readFileSync(from);
103
+
104
+ if (existsSync(to)) {
105
+ const existing = readFileSync(to);
106
+ if (existing.equals(contents)) {
107
+ skipped++;
108
+ continue;
109
+ }
110
+ if (!force) {
111
+ console.warn(
112
+ ` conflict ${name} exists with different content (use --force to overwrite)`
113
+ );
114
+ conflicts++;
115
+ continue;
116
+ }
117
+ }
118
+
119
+ writeFileSync(to, contents);
120
+ console.log(` copied ${name}`);
121
+ copied++;
122
+ }
123
+
124
+ console.log(`\n${copied} copied, ${skipped} unchanged, ${conflicts} conflict(s) -> ${targetDir}`);
125
+
126
+ if (conflicts > 0) {
127
+ process.exit(1);
128
+ }
129
+
130
+ console.log('\nNext: review the files, then run npx supabase db push');
131
+ }
132
+
133
+ main();
@@ -9,7 +9,8 @@ import type {
9
9
  RequestContext,
10
10
  DefinitionListOptions,
11
11
  ListOptions,
12
- Page
12
+ Page,
13
+ UISchema
13
14
  } from '@selvajs/platform';
14
15
  import { ProviderError, auditSoftDelete, actorFrom, NoopEventSink } from '@selvajs/platform';
15
16
  import type { ClientBundle } from './client.js';
@@ -228,6 +229,15 @@ export class SupabaseDefinitionStore implements IDefinitionStore {
228
229
  return data ? rowToVersion(data) : null;
229
230
  }
230
231
 
232
+ async setVersionSchema(ctx: RequestContext, versionId: string, schema: UISchema): Promise<void> {
233
+ const { error } = await this.clients
234
+ .forRequest(ctx)
235
+ .from('definition_versions')
236
+ .update({ schema, schema_extracted_at: new Date().toISOString() })
237
+ .eq('id', versionId);
238
+ if (error) throw mapError(error);
239
+ }
240
+
231
241
  async deleteVersion(ctx: RequestContext, versionId: string): Promise<void> {
232
242
  // FK enforcement at the DB layer: live_version_id / draft_version_id
233
243
  // are ON DELETE RESTRICT. If the version is referenced by either
@@ -377,6 +387,8 @@ interface DefinitionVersionRow {
377
387
  uploaded_by: string;
378
388
  uploaded_at: string;
379
389
  change_note?: string | null;
390
+ schema?: UISchema | null;
391
+ schema_extracted_at?: string | null;
380
392
  }
381
393
 
382
394
  function rowToRecord(row: DefinitionRow): DefinitionRecord {
@@ -450,7 +462,9 @@ function rowToVersion(row: DefinitionVersionRow): DefinitionVersion {
450
462
  originalFilename: row.original_filename ?? undefined,
451
463
  uploadedBy: row.uploaded_by,
452
464
  uploadedAt: row.uploaded_at,
453
- changeNote: row.change_note ?? undefined
465
+ changeNote: row.change_note ?? undefined,
466
+ schema: row.schema ?? undefined,
467
+ schemaExtractedAt: row.schema_extracted_at ?? undefined
454
468
  };
455
469
  }
456
470
 
@@ -466,6 +480,8 @@ function versionToRow(v: DefinitionVersion): Record<string, unknown> {
466
480
  uploaded_at: v.uploadedAt
467
481
  };
468
482
  if (v.changeNote !== undefined) row.change_note = v.changeNote;
483
+ if (v.schema !== undefined) row.schema = v.schema;
484
+ if (v.schemaExtractedAt !== undefined) row.schema_extracted_at = v.schemaExtractedAt;
469
485
  return row;
470
486
  }
471
487
 
@@ -6,14 +6,14 @@
6
6
  * every relevant table.
7
7
  */
8
8
 
9
- import { createClient, type SupabaseClient } from '@supabase/supabase-js';
10
- import { buildClientBundle, type ClientBundle } from '../client.js';
9
+ import { createClient } from '@supabase/supabase-js';
10
+ import { buildClientBundle, type ClientBundle, type SelvaSchemaClient } from '../client.js';
11
11
 
12
12
  export interface TestContext {
13
13
  url: string;
14
14
  anonKey: string;
15
15
  serviceRoleKey: string;
16
- adminClient: SupabaseClient;
16
+ adminClient: SelvaSchemaClient;
17
17
  bundle: ClientBundle;
18
18
  }
19
19
 
@@ -27,9 +27,10 @@ export function readEnv(): TestContext | null {
27
27
  // loads `@supabase/realtime-js`, which on Node < 22 throws at construction
28
28
  // without a native WebSocket. Swallow that so the suite cleanly skips
29
29
  // instead of crashing test collection.
30
- let adminClient: SupabaseClient;
30
+ let adminClient: SelvaSchemaClient;
31
31
  try {
32
32
  adminClient = createClient(url, serviceRoleKey, {
33
+ db: { schema: 'selva' },
33
34
  auth: { persistSession: false, autoRefreshToken: false }
34
35
  });
35
36
  } catch (err) {
@@ -1,6 +1,15 @@
1
1
  import { createClient, type SupabaseClient } from '@supabase/supabase-js';
2
2
  import type { RequestContext } from '@selvajs/platform';
3
3
 
4
+ /**
5
+ * All engine tables live in the `selva` schema (not `public`) so a consuming
6
+ * app keeps `public` for its own tables. Every data client targets `selva` via
7
+ * `db: { schema: 'selva' }`, which the supabase-js type system surfaces as the
8
+ * client's schema generic — hence this alias rather than the bare default
9
+ * `SupabaseClient` (which is pinned to `'public'`).
10
+ */
11
+ export type SelvaSchemaClient = SupabaseClient<any, 'public', 'selva'>;
12
+
4
13
  /**
5
14
  * Build a Supabase client for a given `RequestContext`.
6
15
  *
@@ -27,14 +36,14 @@ import type { RequestContext } from '@selvajs/platform';
27
36
  */
28
37
  export interface ClientBundle {
29
38
  /** Long-lived service-role client. Bypasses RLS. */
30
- serviceClient: SupabaseClient;
39
+ serviceClient: SelvaSchemaClient;
31
40
  /**
32
41
  * Produce (or reuse) a client scoped to this request's identity.
33
42
  * - `ctx.system === true` → service-role (RLS bypassed).
34
43
  * - `ctx.adapterContext.sessionToken` set → user-scoped (RLS active).
35
44
  * - neither → anon client (RLS active, no `auth.uid()`).
36
45
  */
37
- forRequest(ctx: RequestContext): SupabaseClient;
46
+ forRequest(ctx: RequestContext): SelvaSchemaClient;
38
47
  }
39
48
 
40
49
  export interface BuildClientOptions {
@@ -45,20 +54,22 @@ export interface BuildClientOptions {
45
54
 
46
55
  export function buildClientBundle(opts: BuildClientOptions): ClientBundle {
47
56
  const serviceClient = createClient(opts.supabaseUrl, opts.serviceRoleKey, {
57
+ db: { schema: 'selva' },
48
58
  auth: { persistSession: false, autoRefreshToken: false }
49
59
  });
50
60
 
51
61
  // Long-lived anon client — the fail-closed default for ctxes that aren't
52
62
  // explicitly system and don't carry a session token.
53
63
  const anonClient = createClient(opts.supabaseUrl, opts.anonKey, {
64
+ db: { schema: 'selva' },
54
65
  auth: { persistSession: false, autoRefreshToken: false }
55
66
  });
56
67
 
57
- const perRequestCache = new WeakMap<RequestContext, SupabaseClient>();
68
+ const perRequestCache = new WeakMap<RequestContext, SelvaSchemaClient>();
58
69
 
59
70
  return {
60
71
  serviceClient,
61
- forRequest(ctx: RequestContext): SupabaseClient {
72
+ forRequest(ctx: RequestContext): SelvaSchemaClient {
62
73
  if (ctx.system) return serviceClient;
63
74
 
64
75
  const token = extractSessionToken(ctx.adapterContext);
@@ -68,6 +79,7 @@ export function buildClientBundle(opts: BuildClientOptions): ClientBundle {
68
79
  if (cached) return cached;
69
80
 
70
81
  const client = createClient(opts.supabaseUrl, opts.anonKey, {
82
+ db: { schema: 'selva' },
71
83
  auth: { persistSession: false, autoRefreshToken: false },
72
84
  global: {
73
85
  headers: { Authorization: `Bearer ${token}` }
@@ -8,8 +8,13 @@ project_id = "selva-supabase-provider"
8
8
  [api]
9
9
  enabled = true
10
10
  port = 54321
11
- # Exposed REST schemas. `public` is where all Selva tables live; `storage`
12
- # stays private (only the storage-js client reaches it via the dedicated path).
11
+ # Exposed REST schemas. `selva` (where all Selva tables live, isolated from a
12
+ # consuming app's `public`) is intentionally NOT listed here: PostgREST loads
13
+ # this config at stack boot, *before* migrations run, so naming a not-yet-created
14
+ # schema fails the health check and aborts `supabase start`. Instead the initial
15
+ # migration exposes `selva` via `alter role authenticator set pgrst.db_schemas`
16
+ # once the schema exists. A hosted project that boots from a snapshot already
17
+ # containing `selva` may also add it here directly.
13
18
  schemas = ["public", "graphql_public"]
14
19
  extra_search_path = ["public", "extensions"]
15
20
  max_rows = 1000