@supacloud/db 0.5.0 → 0.6.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,138 @@
1
+ # Controlled Migration Bindings
2
+
3
+ This is a deployment-time API for **public environment bindings**, not a general
4
+ SQL template language or another migration engine. Use identical migrations for
5
+ schema, indexes, business functions and state machines. Explicitly register only
6
+ SQL that must bind to a target-specific application ID, HTTPS URL or resource
7
+ name. One-off account transfers and production data cleanup remain separately
8
+ reviewed operations.
9
+
10
+ ## Drizzle v1 Boundary
11
+
12
+ Reviewed against the Drizzle v1 PostgreSQL documentation on 2026-09-08:
13
+ official documentation repository commit
14
+ `dae09afd99baa6362ece434cfa7bab60ef4b8692`.
15
+
16
+ - [Upgrade to v1](https://orm.drizzle.team/docs/upgrade-v1):
17
+ v1 removes the journal file and groups SQL and snapshots in migration folders.
18
+ Do not run `drizzle-kit up` automatically on an application's existing history.
19
+ - [Custom migrations](https://orm.drizzle.team/docs/kit-custom-migrations):
20
+ `drizzle-kit generate --custom` is the extension point for custom SQL.
21
+ - [Migrate](https://orm.drizzle.team/docs/drizzle-kit-migrate):
22
+ migration execution reads applied history and supports separate configuration
23
+ files for different deployment targets.
24
+
25
+ Those documents do not define this parameter-rendering API. This is a SupaCloud
26
+ extension, not a claim that Drizzle automatically reconciles account changes.
27
+ The renderer accepts `20260908100000_binding/migration.sql` as well as legacy
28
+ flat SQL filenames. It neither discovers nor changes snapshots, migration
29
+ identities, order, or metadata. Call it on a deployment copy, never write its
30
+ output over generated source SQL. When SupaCloud is the executor, continue using
31
+ its canonical applied-migration ledger; do not additionally execute
32
+ `drizzle-kit migrate` against that same migration stream.
33
+
34
+ ## Usage
35
+
36
+ Commit a manifest containing explicit target pairs and reviewed source hashes.
37
+ `templateSha256` is the SHA-256 of the exact UTF-8 source, including whitespace.
38
+ An author obtains and reviews it once when adding a template; do not recompute
39
+ and accept a changed source hash automatically during deployment.
40
+
41
+ ```ts
42
+ import { renderMigrationBindings } from '@supacloud/db';
43
+
44
+ const result = renderMigrationBindings({
45
+ manifest: {
46
+ schema: 'supacloud.migration-bindings.v1',
47
+ targets: [
48
+ { environment: 'test', projectRef: 'test-project' },
49
+ { environment: 'production', projectRef: 'production-project' },
50
+ ],
51
+ templates: [{
52
+ file: '20260908100000_binding/migration.sql',
53
+ templateSha256: reviewedSourceSha256,
54
+ parameters: [{
55
+ placeholder: '__SC_BINDING_APPLICATION_ID__',
56
+ variable: 'APPLICATION_ID',
57
+ type: 'uuid',
58
+ occurrences: 1,
59
+ }],
60
+ }],
61
+ },
62
+ target: selectedTarget,
63
+ migrations: sourceFiles,
64
+ values: selectedEnvironment,
65
+ });
66
+ // Send result.migrations to the existing dry-run/apply pipeline.
67
+ // Persist result.attestation in the existing release artifacts.
68
+ ```
69
+
70
+ Example source:
71
+
72
+ ```sql
73
+ SELECT '__SC_BINDING_APPLICATION_ID__'::uuid;
74
+ ```
75
+
76
+ The caller must select and verify the environment before invoking this API.
77
+ Values are explicitly passed; the API does not read `process.env`, env files,
78
+ SSH configuration, or another target's fallback values. The target pair must
79
+ match the manifest. The full set of declared template sources must be supplied.
80
+ Plain SQL sources pass through byte-for-byte.
81
+
82
+ Only complete single-quoted placeholder literals are supported, including
83
+ literals in dollar-quoted PostgreSQL function bodies. `uuid`, `https-url`, and
84
+ `resource-name` have restricted alphabets excluding quotes, dollar delimiters,
85
+ backslashes and control characters. HTTPS URLs must not contain userinfo.
86
+ Do not put credentials, tokens, secrets or arbitrary text in these bindings.
87
+ Object identifiers, SQL fragments and arbitrary string interpolation are
88
+ intentionally unsupported.
89
+
90
+ `__SC_BINDING_*__` is reserved. Undeclared reserved tokens, stray declared
91
+ legacy tokens, duplicate definitions, missing inputs, changed source bytes,
92
+ wrong occurrence counts, unknown fields and unknown types fail closed.
93
+ Legacy tokens such as `__FA_SUPAUTH_CLIENT_ID__` can be explicitly declared
94
+ without editing historical SQL.
95
+
96
+ ## Evidence And Changes
97
+
98
+ The attestation includes the target pair, manifest digest, source digest,
99
+ rendered digest and parameter names/types. It excludes SQL and parameter values.
100
+ The returned `migrations` contain SQL and must not be logged as an attestation.
101
+ This is reproducibility evidence, not a signature or proof of the database's
102
+ current state. Store it in an existing access-controlled release receipt or
103
+ artifact, and separately read back the target's applied migration inventory.
104
+
105
+ The renderer never marks a migration as applied and has no database access.
106
+ Compare the rendered SQL with the existing executor's inventory. A changed
107
+ environment value is **not** permission to rewrite an applied migration or its
108
+ ledger row. Add a forward binding migration and retain the old release artifact;
109
+ historical reconciliation must use target-specific reviewed evidence.
110
+
111
+ The CLI does not automatically enable this API. An application deployment
112
+ adapter must supply the selected sources and target, then use its existing
113
+ executor. In particular, this API does not add recursive Drizzle-folder discovery
114
+ to the CLI's legacy flat-directory `push_migrations` command.
115
+
116
+ ## Acceptance
117
+
118
+ ```gherkin
119
+ Scenario: Same template, separate targets
120
+ Given a reviewed template and explicit test and production project bindings
121
+ When each target provides its own valid parameters
122
+ Then source digests match and rendered digests differ without changing source files
123
+
124
+ Scenario: Missing or unsafe parameter
125
+ Given a missing value, injected SQL fragment or secret-bearing URL
126
+ When rendering begins
127
+ Then it fails without echoing the supplied value or requesting any database mutation
128
+
129
+ Scenario: Undeclared or changed SQL
130
+ Given a changed template, unexpected token or unregistered target
131
+ When rendering begins
132
+ Then deployment preparation fails before SQL is returned
133
+
134
+ Scenario: Applied parameter changes
135
+ Given an already-applied binding migration and a changed parameter
136
+ When rendering again
137
+ Then a different digest is produced, not an automatic replay or ledger repair
138
+ ```
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @supacloud/db
2
2
 
3
+ For reviewed deployment-time public SQL parameters, see
4
+ [Controlled Migration Bindings](./MIGRATION_BINDINGS.md). This opt-in API preserves
5
+ Drizzle v1 source/snapshot ownership and the existing executor's migration ledger.
6
+
3
7
  SupaCloud 的数据库治理层:把 RLS 策略、RPC 函数、触发器、授权(grant)作为**一等资源**做声明式管理,并与 PostgreSQL 真实 Catalog 对账。
4
8
 
5
9
  定位:它是 Drizzle(schema/迁移)之上的治理层 —— Drizzle 负责表结构,本包负责表结构之外的安全与业务对象(策略、函数、权限)的声明、静态检查与漂移检测。**driver 无关**:所有 Catalog 读取都通过注入的 `QueryExecutor` 完成,不依赖任何数据库客户端,也不 import drizzle-orm(仅类型层兼容 drizzle Table 的内部形状)。
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { reconcileModule, splitQualifiedName, type ReconcileIssue, type Reconcil
4
4
  export { lintModule, lintSql, type LintIssue } from './lint.js';
5
5
  export { planModule, type ModulePlan, type PlanStep } from './plan.js';
6
6
  export { applyModulePlan, type ApplyResult } from './apply.js';
7
+ export { migrationBindingSha256, parseMigrationBindingManifest, renderMigrationBindings, type MigrationBindingManifest, type MigrationBindingParameter, type MigrationBindingSource, type MigrationBindingTarget, type MigrationBindingTemplate, type MigrationBindingType, } from './migration-bindings.js';
7
8
  export { buildDatabaseManifest, explainObject, type DatabaseManifest, type DatabaseManifestModule, } from './manifest.js';
8
9
  export { createDatabaseAccessBoundary, DatabaseAccessError, type AuthenticatedDatabaseIdentity, type DatabaseAccessBoundary, type DatabaseAccessBoundaryOptions, type DatabaseAccessErrorCode, } from './access.js';
9
10
  export { createRpcClient, defineRpcContract, RpcContractError, type RpcArgs, type RpcCallResult, type RpcContract, type RpcDecoder, type RpcResult, type RpcTransport, } from './rpc.js';
package/dist/index.js CHANGED
@@ -531,6 +531,152 @@ async function applyModulePlan(executor, plan) {
531
531
  }
532
532
  return result;
533
533
  }
534
+ // src/migration-bindings.ts
535
+ import { createHash } from "node:crypto";
536
+ var TOKEN = /__[A-Z][A-Z0-9_]*__/g;
537
+ var RESERVED_TOKEN = /__SC_BINDING_[A-Z0-9_]+__/;
538
+ var HASH = /^[a-f0-9]{64}$/;
539
+ function migrationBindingSha256(value) {
540
+ return createHash("sha256").update(value).digest("hex");
541
+ }
542
+ function record(value) {
543
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
544
+ throw new Error("Invalid migration binding object");
545
+ }
546
+ return value;
547
+ }
548
+ function keys(row, expected) {
549
+ if (Object.keys(row).sort().join(",") !== expected.sort().join(",")) {
550
+ throw new Error("Unexpected migration binding fields");
551
+ }
552
+ }
553
+ function validTarget(value) {
554
+ const target = record(value);
555
+ keys(target, ["environment", "projectRef"]);
556
+ if (typeof target.environment !== "string" || !/^[a-z][a-z0-9-]{0,62}$/.test(target.environment) || typeof target.projectRef !== "string" || !/^[a-z0-9][a-z0-9_-]{0,62}$/.test(target.projectRef)) {
557
+ throw new Error("Invalid migration binding target");
558
+ }
559
+ return target;
560
+ }
561
+ function validFile(file) {
562
+ return typeof file === "string" && /^[a-zA-Z0-9_./-]+\.sql$/.test(file) && !file.startsWith("/") && file.split("/").every((part) => part && part !== "." && part !== "..");
563
+ }
564
+ function parseMigrationBindingManifest(value) {
565
+ const manifest = record(value);
566
+ keys(manifest, ["schema", "targets", "templates"]);
567
+ if (manifest.schema !== "supacloud.migration-bindings.v1" || !Array.isArray(manifest.targets) || !manifest.targets.length || !Array.isArray(manifest.templates)) {
568
+ throw new Error("Invalid migration binding manifest");
569
+ }
570
+ const targets = manifest.targets.map(validTarget);
571
+ if (new Set(targets.map((target) => target.environment)).size !== targets.length) {
572
+ throw new Error("Duplicate migration binding environment");
573
+ }
574
+ const files = new Set;
575
+ const templates = manifest.templates.map((value2) => {
576
+ const template = record(value2);
577
+ keys(template, ["file", "templateSha256", "parameters"]);
578
+ if (!validFile(template.file) || files.has(template.file) || typeof template.templateSha256 !== "string" || !HASH.test(template.templateSha256) || !Array.isArray(template.parameters) || !template.parameters.length) {
579
+ throw new Error("Invalid or duplicate migration binding template");
580
+ }
581
+ files.add(template.file);
582
+ const placeholders = new Set;
583
+ const parameters = template.parameters.map((value3) => {
584
+ const parameter = record(value3);
585
+ keys(parameter, ["placeholder", "variable", "type", "occurrences"]);
586
+ if (typeof parameter.placeholder !== "string" || !/^__[A-Z][A-Z0-9_]*__$/.test(parameter.placeholder) || placeholders.has(parameter.placeholder) || typeof parameter.variable !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(parameter.variable) || !["uuid", "https-url", "resource-name"].includes(String(parameter.type)) || !Number.isSafeInteger(parameter.occurrences) || Number(parameter.occurrences) < 1) {
587
+ throw new Error("Invalid or duplicate migration binding parameter");
588
+ }
589
+ placeholders.add(parameter.placeholder);
590
+ return parameter;
591
+ });
592
+ return { file: template.file, templateSha256: template.templateSha256, parameters };
593
+ });
594
+ return { schema: "supacloud.migration-bindings.v1", targets, templates };
595
+ }
596
+ function bindingValue(parameter, values) {
597
+ const value = Object.hasOwn(values, parameter.variable) ? values[parameter.variable] : undefined;
598
+ if (typeof value !== "string" || !value || value.trim() !== value) {
599
+ throw new Error(`Missing or invalid migration binding variable: ${parameter.variable}`);
600
+ }
601
+ let valid = false;
602
+ if (parameter.type === "uuid") {
603
+ valid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
604
+ } else if (parameter.type === "resource-name") {
605
+ valid = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/.test(value);
606
+ } else if (/^https:\/\/[A-Za-z0-9._~:/?#[\]@!&()*+,;=%-]+$/.test(value)) {
607
+ try {
608
+ const url = new URL(value);
609
+ valid = url.protocol === "https:" && Boolean(url.hostname) && !url.username && !url.password;
610
+ } catch {}
611
+ }
612
+ if (!valid || value.includes("__"))
613
+ throw new Error(`Invalid ${parameter.type} migration binding: ${parameter.variable}`);
614
+ return value;
615
+ }
616
+ function renderMigrationBindings(options) {
617
+ const manifest = parseMigrationBindingManifest(options.manifest);
618
+ const target = validTarget(options.target);
619
+ if (!manifest.targets.some((candidate) => candidate.environment === target.environment && candidate.projectRef === target.projectRef)) {
620
+ throw new Error("Migration binding target is not registered in the manifest");
621
+ }
622
+ const sources = new Map;
623
+ for (const migration of options.migrations) {
624
+ if (!validFile(migration.file) || sources.has(migration.file) || typeof migration.sql !== "string" || !migration.sql.trim()) {
625
+ throw new Error("Invalid, empty, or duplicate migration binding source");
626
+ }
627
+ sources.set(migration.file, migration);
628
+ }
629
+ if (manifest.templates.some((template) => !sources.has(template.file))) {
630
+ throw new Error("Migration binding manifest references a missing source");
631
+ }
632
+ const templates = new Map(manifest.templates.map((template) => [template.file, template]));
633
+ const declaredTokens = new Set(manifest.templates.flatMap((template) => template.parameters.map((parameter) => parameter.placeholder)));
634
+ const files = [];
635
+ const migrations = options.migrations.map((migration) => {
636
+ const template = templates.get(migration.file);
637
+ const templateSha256 = migrationBindingSha256(migration.sql);
638
+ if (template && templateSha256 !== template.templateSha256) {
639
+ throw new Error(`Migration binding template checksum mismatch: ${migration.file}`);
640
+ }
641
+ const parameters = new Map(template?.parameters.map((parameter) => [parameter.placeholder, parameter]));
642
+ const counts = new Map;
643
+ const sql = migration.sql.replace(TOKEN, (token, offset) => {
644
+ const parameter = parameters.get(token);
645
+ if (!parameter) {
646
+ if (RESERVED_TOKEN.test(token) || declaredTokens.has(token)) {
647
+ throw new Error(`Undeclared migration binding placeholder: ${migration.file}`);
648
+ }
649
+ return token;
650
+ }
651
+ if (migration.sql[offset - 1] !== "'" || migration.sql[offset + token.length] !== "'") {
652
+ throw new Error(`Migration bindings must occupy a complete SQL string literal: ${migration.file}`);
653
+ }
654
+ counts.set(token, (counts.get(token) || 0) + 1);
655
+ return bindingValue(parameter, options.values);
656
+ });
657
+ for (const parameter of parameters.values()) {
658
+ if (counts.get(parameter.placeholder) !== parameter.occurrences) {
659
+ throw new Error(`Migration binding occurrence mismatch: ${migration.file}`);
660
+ }
661
+ }
662
+ files.push({
663
+ file: migration.file,
664
+ templateSha256,
665
+ renderedSqlSha256: migrationBindingSha256(sql),
666
+ parameters: [...parameters.values()].map(({ variable, type }) => ({ variable, type }))
667
+ });
668
+ return { file: migration.file, sql };
669
+ });
670
+ return {
671
+ migrations,
672
+ attestation: {
673
+ schema: "supacloud.migration-binding-attestation.v1",
674
+ ...target,
675
+ manifestSha256: migrationBindingSha256(JSON.stringify(manifest)),
676
+ files: files.sort((left, right) => left.file.localeCompare(right.file))
677
+ }
678
+ };
679
+ }
534
680
  // src/manifest.ts
535
681
  function buildDatabaseManifest(modules) {
536
682
  return {
@@ -746,8 +892,11 @@ export {
746
892
  extractSearchPath,
747
893
  lintModule,
748
894
  lintSql,
895
+ migrationBindingSha256,
896
+ parseMigrationBindingManifest,
749
897
  planModule,
750
898
  readCatalog,
751
899
  reconcileModule,
900
+ renderMigrationBindings,
752
901
  splitQualifiedName
753
902
  };
@@ -0,0 +1,57 @@
1
+ export type MigrationBindingType = 'uuid' | 'https-url' | 'resource-name';
2
+ export interface MigrationBindingTarget {
3
+ environment: string;
4
+ projectRef: string;
5
+ }
6
+ export interface MigrationBindingParameter {
7
+ placeholder: string;
8
+ variable: string;
9
+ type: MigrationBindingType;
10
+ occurrences: number;
11
+ }
12
+ export interface MigrationBindingTemplate {
13
+ file: string;
14
+ templateSha256: string;
15
+ parameters: MigrationBindingParameter[];
16
+ }
17
+ export interface MigrationBindingManifest {
18
+ schema: 'supacloud.migration-bindings.v1';
19
+ targets: MigrationBindingTarget[];
20
+ templates: MigrationBindingTemplate[];
21
+ }
22
+ export interface MigrationBindingSource {
23
+ file: string;
24
+ sql: string;
25
+ }
26
+ export declare function migrationBindingSha256(value: string): string;
27
+ export declare function parseMigrationBindingManifest(value: unknown): MigrationBindingManifest;
28
+ /**
29
+ * Pure rendering of explicitly declared SQL literals. The caller supplies an
30
+ * already-selected environment; this module never reads files or process.env.
31
+ */
32
+ export declare function renderMigrationBindings(options: {
33
+ manifest: unknown;
34
+ target: MigrationBindingTarget;
35
+ migrations: readonly MigrationBindingSource[];
36
+ values: Readonly<Record<string, string | undefined>>;
37
+ }): {
38
+ migrations: {
39
+ file: string;
40
+ sql: string;
41
+ }[];
42
+ attestation: {
43
+ environment: string;
44
+ projectRef: string;
45
+ schema: 'supacloud.migration-binding-attestation.v1';
46
+ manifestSha256: string;
47
+ files: {
48
+ file: string;
49
+ templateSha256: string;
50
+ renderedSqlSha256: string;
51
+ parameters: Array<{
52
+ variable: string;
53
+ type: MigrationBindingType;
54
+ }>;
55
+ }[];
56
+ };
57
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supacloud/db",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Database governance layer for SupaCloud: RLS policies, RPC functions and grants as first-class resources, with manifest/catalog reconciliation",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,8 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "README.md"
18
+ "README.md",
19
+ "MIGRATION_BINDINGS.md"
19
20
  ],
20
21
  "scripts": {
21
22
  "build": "bun run clean && bun run build:js && bun run build:types",