@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.
- package/README.md +65 -9
- package/dist/cli/sync-migrations.d.ts +3 -0
- package/dist/cli/sync-migrations.d.ts.map +1 -0
- package/dist/cli/sync-migrations.js +108 -0
- package/dist/cli/sync-migrations.js.map +1 -0
- package/dist/data/SupabaseDefinitionStore.d.ts +2 -1
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
- package/dist/data/SupabaseDefinitionStore.js +16 -1
- package/dist/data/SupabaseDefinitionStore.js.map +1 -1
- package/dist/data/__tests__/test-helpers.d.ts +2 -3
- package/dist/data/__tests__/test-helpers.d.ts.map +1 -1
- package/dist/data/__tests__/test-helpers.js +1 -0
- package/dist/data/__tests__/test-helpers.js.map +1 -1
- package/dist/data/client.d.ts +10 -2
- package/dist/data/client.d.ts.map +1 -1
- package/dist/data/client.js +3 -0
- package/dist/data/client.js.map +1 -1
- package/package.json +5 -2
- package/src/cli/sync-migrations.ts +133 -0
- package/src/data/SupabaseDefinitionStore.ts +18 -2
- package/src/data/__tests__/test-helpers.ts +5 -4
- package/src/data/client.ts +16 -4
- package/supabase/config.toml +7 -2
- package/supabase/migrations/{0001_initial.sql → 20260425155514_selva_initial.sql} +314 -233
- package/supabase/migrations/20260529202220_selva_definition_version_schema.sql +16 -0
- package/supabase/.branches/_current_branch +0 -1
- package/supabase/.temp/cli-latest +0 -1
|
@@ -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
|
|
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:
|
|
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:
|
|
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) {
|
package/src/data/client.ts
CHANGED
|
@@ -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:
|
|
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):
|
|
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,
|
|
68
|
+
const perRequestCache = new WeakMap<RequestContext, SelvaSchemaClient>();
|
|
58
69
|
|
|
59
70
|
return {
|
|
60
71
|
serviceClient,
|
|
61
|
-
forRequest(ctx: RequestContext):
|
|
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}` }
|
package/supabase/config.toml
CHANGED
|
@@ -8,8 +8,13 @@ project_id = "selva-supabase-provider"
|
|
|
8
8
|
[api]
|
|
9
9
|
enabled = true
|
|
10
10
|
port = 54321
|
|
11
|
-
# Exposed REST schemas. `
|
|
12
|
-
#
|
|
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
|