@supacloud/db 0.5.0 → 0.7.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/MIGRATION_BINDINGS.md +138 -0
- package/README.md +30 -0
- package/dist/command-adapter.d.ts +18 -0
- package/dist/command-bun.d.ts +4 -0
- package/dist/command-bun.js +19 -0
- package/dist/command-schema.d.ts +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +442 -0
- package/dist/migration-bindings.d.ts +57 -0
- package/package.json +15 -4
|
@@ -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,35 @@
|
|
|
1
1
|
# @supacloud/db
|
|
2
2
|
|
|
3
|
+
## Durable Commands
|
|
4
|
+
|
|
5
|
+
`createPostgresCommandStore(database)` implements the protocol's storage ports.
|
|
6
|
+
Use `createBunCommandDatabase` from `@supacloud/db/bun` for a native Bun SQL pool.
|
|
7
|
+
This package contains transaction/receipt SQL, Workflow binding, row validation
|
|
8
|
+
and input redaction. It does not own a recovery polling queue.
|
|
9
|
+
|
|
10
|
+
Execution factories moved to `@supacloud/commands`; pass `store` instead of
|
|
11
|
+
`database`. Errors moved to `CommandError` in `@supacloud/contracts`. Neither
|
|
12
|
+
remote sending nor command orchestration is re-exported here. The DB package
|
|
13
|
+
does not depend on the command runtime, including through its tests.
|
|
14
|
+
|
|
15
|
+
Install the existing PGMQ/Workflow runtime and updated commands-public SQL module,
|
|
16
|
+
then `COMMAND_PERSISTENCE_SQL` through a privileged application migration.
|
|
17
|
+
Existing unreleased v1 tables require `COMMAND_PERSISTENCE_UPGRADE_SQL`, with old
|
|
18
|
+
writers stopped; preserve operation identifiers during cutover.
|
|
19
|
+
The upgrade enqueues pending recovery using existing Workflow and removes prototype
|
|
20
|
+
lease/backoff columns. Use `createPostgresCommandStore(database, { submission })`
|
|
21
|
+
inside a submitted command's execute step to preserve its command ID and atomically
|
|
22
|
+
advance to reconciliation (external) or complete its Workflow (transactional).
|
|
23
|
+
`submission` carries commandId, stepId, messageId, attempt and workerId. The adapter
|
|
24
|
+
checks the current attempt, recorded tenant/actor/command, and original input.
|
|
25
|
+
Keep its schema private and supply domain authorization, JSON-stable input/result
|
|
26
|
+
decoders and a single-connection transaction adapter. Metadata cannot provide a
|
|
27
|
+
distributed transaction. See [migration, deployment and recovery](../../docs/command-migration.md).
|
|
28
|
+
|
|
29
|
+
For reviewed deployment-time public SQL parameters, see
|
|
30
|
+
[Controlled Migration Bindings](./MIGRATION_BINDINGS.md). This opt-in API preserves
|
|
31
|
+
Drizzle v1 source/snapshot ownership and the existing executor's migration ledger.
|
|
32
|
+
|
|
3
33
|
SupaCloud 的数据库治理层:把 RLS 策略、RPC 函数、触发器、授权(grant)作为**一等资源**做声明式管理,并与 PostgreSQL 真实 Catalog 对账。
|
|
4
34
|
|
|
5
35
|
定位:它是 Drizzle(schema/迁移)之上的治理层 —— Drizzle 负责表结构,本包负责表结构之外的安全与业务对象(策略、函数、权限)的声明、静态检查与漂移检测。**driver 无关**:所有 Catalog 读取都通过注入的 `QueryExecutor` 完成,不依赖任何数据库客户端,也不 import drizzle-orm(仅类型层兼容 drizzle Table 的内部形状)。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CommandStore, CommandRetentionStore } from "@supacloud/contracts";
|
|
2
|
+
export interface CommandTransaction {
|
|
3
|
+
query(sql: string, parameters?: readonly (string | number | boolean | null)[]): Promise<unknown>;
|
|
4
|
+
}
|
|
5
|
+
export interface CommandDatabase {
|
|
6
|
+
transaction<T>(run: (transaction: CommandTransaction) => Promise<T>): Promise<T>;
|
|
7
|
+
}
|
|
8
|
+
export interface CommandSubmissionBinding {
|
|
9
|
+
commandId: string;
|
|
10
|
+
stepId: string;
|
|
11
|
+
messageId: string;
|
|
12
|
+
attempt: number;
|
|
13
|
+
workerId: string;
|
|
14
|
+
}
|
|
15
|
+
/** PostgreSQL only: no remote sending, domain matching or recovery policy lives here. */
|
|
16
|
+
export declare function createPostgresCommandStore(database: CommandDatabase, options?: {
|
|
17
|
+
submission?: CommandSubmissionBinding;
|
|
18
|
+
}): CommandStore<CommandTransaction> & CommandRetentionStore;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/command-bun.ts
|
|
2
|
+
function createBunCommandDatabase(sql) {
|
|
3
|
+
return {
|
|
4
|
+
async transaction(run) {
|
|
5
|
+
const result = await sql.begin(async (connection) => ({
|
|
6
|
+
value: await run({
|
|
7
|
+
query: async (text, parameters = []) => {
|
|
8
|
+
const rows = await connection.unsafe(text, [...parameters]);
|
|
9
|
+
return rows;
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
}));
|
|
13
|
+
return result.value;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
createBunCommandDatabase
|
|
19
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Install through the application's normal migration flow, never during a request. */
|
|
2
|
+
export declare const COMMAND_PERSISTENCE_SQL = "\nDO $runtime$\nBEGIN\n IF to_regprocedure('supacloud_workflows.start_run(uuid,text,text,text,jsonb,integer)') IS NULL\n OR to_regprocedure('supacloud_commands.status(jsonb)') IS NULL THEN\n RAISE EXCEPTION 'Install the SupaCloud Commands/Workflow/PGMQ runtime before command persistence';\n END IF;\nEND\n$runtime$;\nCREATE SCHEMA IF NOT EXISTS supacloud_commands;\nREVOKE ALL ON SCHEMA supacloud_commands FROM PUBLIC;\nCREATE TABLE IF NOT EXISTS supacloud_commands.execution_receipts (\n tenant_id text NOT NULL,\n actor_id text NOT NULL,\n command text NOT NULL,\n operation_key text NOT NULL,\n kind text NOT NULL CHECK (kind IN ('transactional', 'external')),\n input_fingerprint text NOT NULL CHECK (input_fingerprint ~ '^[a-f0-9]{64}$'),\n input_payload text,\n dispatch_key uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,\n status text NOT NULL CHECK (status IN ('pending', 'unknown', 'confirmed')),\n audit_state text NOT NULL CHECK (audit_state IN ('pending', 'complete')),\n result jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, actor_id, command, operation_key),\n CHECK ((status = 'confirmed') = (result IS NOT NULL)),\n CHECK (audit_state <> 'complete' OR status = 'confirmed'),\n CHECK (kind <> 'transactional' OR (status = 'confirmed' AND audit_state = 'complete'))\n);\nCREATE TABLE IF NOT EXISTS supacloud_commands.execution_audit (\n tenant_id text NOT NULL,\n actor_id text NOT NULL,\n command text NOT NULL,\n operation_key text NOT NULL,\n event text NOT NULL,\n details jsonb NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, actor_id, command, operation_key),\n FOREIGN KEY (tenant_id, actor_id, command, operation_key)\n REFERENCES supacloud_commands.execution_receipts (tenant_id, actor_id, command, operation_key)\n);\nREVOKE ALL ON supacloud_commands.execution_receipts, supacloud_commands.execution_audit FROM PUBLIC;\nCREATE OR REPLACE FUNCTION supacloud_commands.enqueue_execution_recovery()\nRETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $recovery$\nDECLARE submission jsonb;\nBEGIN\n PERFORM pg_advisory_xact_lock(hashtextextended(NEW.dispatch_key::text,0));\n SELECT supacloud_commands.snapshot(NEW.dispatch_key,false) INTO submission;\n IF submission IS NOT NULL THEN\n IF submission->>'commandType' IS DISTINCT FROM NEW.command\n OR submission->>'actorId' IS DISTINCT FROM NEW.actor_id\n OR submission->>'tenantId' IS DISTINCT FROM NEW.tenant_id\n OR NEW.operation_key <> NEW.dispatch_key::text THEN\n RAISE EXCEPTION 'SUPACLOUD_COMMAND_IDENTITY_CONFLICT';\n END IF;\n RETURN NEW;\n END IF;\n IF NEW.kind = 'external' AND (NEW.status <> 'confirmed' OR NEW.audit_state <> 'complete') THEN\n PERFORM supacloud_workflows.start_run(\n NEW.dispatch_key, 'supacloud.command.reconcile', '1', 'reconcile',\n jsonb_build_object('commandId', NEW.dispatch_key, 'tenantId', NEW.tenant_id,\n 'actorId', NEW.actor_id, 'command', NEW.command, 'operationId', NEW.operation_key),\n 20\n );\n END IF;\n RETURN NEW;\nEND\n$recovery$;\nCREATE OR REPLACE TRIGGER execution_recovery_enqueue\nAFTER INSERT ON supacloud_commands.execution_receipts\nFOR EACH ROW EXECUTE FUNCTION supacloud_commands.enqueue_execution_recovery();\n\nCREATE OR REPLACE FUNCTION supacloud_commands.execution_status(request jsonb)\nRETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $status$\n SELECT jsonb_build_object('kind','execution','commandId',r.dispatch_key,\n 'execution',jsonb_build_object('tenantId',r.tenant_id,'actorId',r.actor_id,\n 'command',r.command,'operationId',r.operation_key,'dispatchKey',r.dispatch_key,\n 'status',r.status,'audit',r.audit_state)\n || CASE WHEN r.status='confirmed' THEN jsonb_build_object('result',r.result) ELSE '{}'::jsonb END,\n 'workflow',CASE WHEN w.id IS NULL THEN NULL ELSE jsonb_build_object('runId',w.id,'status',w.status) END)\n FROM supacloud_commands.execution_receipts r\n LEFT JOIN supacloud_workflows.runs w ON w.id=r.dispatch_key\n WHERE CASE WHEN request ? 'commandId' THEN r.dispatch_key::text=request->>'commandId'\n ELSE r.tenant_id=request->>'tenantId' AND r.actor_id=request->>'actorId'\n AND r.command=request->>'command' AND r.operation_key=request->>'operationId' END\n$status$;\nREVOKE ALL ON FUNCTION supacloud_commands.execution_status(jsonb) FROM PUBLIC;\n";
|
|
3
|
+
/** Explicit migration from the unreleased v1 prototype. Never discard operation keys. */
|
|
4
|
+
export declare const COMMAND_PERSISTENCE_UPGRADE_SQL = "\nALTER TABLE supacloud_commands.execution_receipts\n ADD COLUMN IF NOT EXISTS input_fingerprint text,\n ADD COLUMN IF NOT EXISTS input_payload text;\nDO $migration$\nBEGIN\n IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='supacloud_commands'\n AND table_name='execution_receipts' AND column_name='input_key') THEN\n EXECUTE 'UPDATE supacloud_commands.execution_receipts\n SET input_fingerprint=encode(sha256(convert_to(input_key,''UTF8'')),''hex''),input_payload=input_key\n WHERE input_fingerprint IS NULL';\n ALTER TABLE supacloud_commands.execution_receipts DROP COLUMN input_key;\n END IF;\nEND\n$migration$;\nALTER TABLE supacloud_commands.execution_receipts ALTER COLUMN input_fingerprint SET NOT NULL;\nDO $constraint$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint\n WHERE conrelid='supacloud_commands.execution_receipts'::regclass\n AND conname='execution_receipts_input_fingerprint_check') THEN\n ALTER TABLE supacloud_commands.execution_receipts\n ADD CONSTRAINT execution_receipts_input_fingerprint_check CHECK (input_fingerprint ~ '^[a-f0-9]{64}$');\n END IF;\nEND\n$constraint$;\n\nDO $runtime$\nBEGIN\n IF to_regprocedure('supacloud_workflows.start_run(uuid,text,text,text,jsonb,integer)') IS NULL\n OR to_regprocedure('supacloud_commands.status(jsonb)') IS NULL THEN\n RAISE EXCEPTION 'Install the SupaCloud Commands/Workflow/PGMQ runtime before command persistence';\n END IF;\nEND\n$runtime$;\nCREATE SCHEMA IF NOT EXISTS supacloud_commands;\nREVOKE ALL ON SCHEMA supacloud_commands FROM PUBLIC;\nCREATE TABLE IF NOT EXISTS supacloud_commands.execution_receipts (\n tenant_id text NOT NULL,\n actor_id text NOT NULL,\n command text NOT NULL,\n operation_key text NOT NULL,\n kind text NOT NULL CHECK (kind IN ('transactional', 'external')),\n input_fingerprint text NOT NULL CHECK (input_fingerprint ~ '^[a-f0-9]{64}$'),\n input_payload text,\n dispatch_key uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,\n status text NOT NULL CHECK (status IN ('pending', 'unknown', 'confirmed')),\n audit_state text NOT NULL CHECK (audit_state IN ('pending', 'complete')),\n result jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, actor_id, command, operation_key),\n CHECK ((status = 'confirmed') = (result IS NOT NULL)),\n CHECK (audit_state <> 'complete' OR status = 'confirmed'),\n CHECK (kind <> 'transactional' OR (status = 'confirmed' AND audit_state = 'complete'))\n);\nCREATE TABLE IF NOT EXISTS supacloud_commands.execution_audit (\n tenant_id text NOT NULL,\n actor_id text NOT NULL,\n command text NOT NULL,\n operation_key text NOT NULL,\n event text NOT NULL,\n details jsonb NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n PRIMARY KEY (tenant_id, actor_id, command, operation_key),\n FOREIGN KEY (tenant_id, actor_id, command, operation_key)\n REFERENCES supacloud_commands.execution_receipts (tenant_id, actor_id, command, operation_key)\n);\nREVOKE ALL ON supacloud_commands.execution_receipts, supacloud_commands.execution_audit FROM PUBLIC;\nCREATE OR REPLACE FUNCTION supacloud_commands.enqueue_execution_recovery()\nRETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $recovery$\nDECLARE submission jsonb;\nBEGIN\n PERFORM pg_advisory_xact_lock(hashtextextended(NEW.dispatch_key::text,0));\n SELECT supacloud_commands.snapshot(NEW.dispatch_key,false) INTO submission;\n IF submission IS NOT NULL THEN\n IF submission->>'commandType' IS DISTINCT FROM NEW.command\n OR submission->>'actorId' IS DISTINCT FROM NEW.actor_id\n OR submission->>'tenantId' IS DISTINCT FROM NEW.tenant_id\n OR NEW.operation_key <> NEW.dispatch_key::text THEN\n RAISE EXCEPTION 'SUPACLOUD_COMMAND_IDENTITY_CONFLICT';\n END IF;\n RETURN NEW;\n END IF;\n IF NEW.kind = 'external' AND (NEW.status <> 'confirmed' OR NEW.audit_state <> 'complete') THEN\n PERFORM supacloud_workflows.start_run(\n NEW.dispatch_key, 'supacloud.command.reconcile', '1', 'reconcile',\n jsonb_build_object('commandId', NEW.dispatch_key, 'tenantId', NEW.tenant_id,\n 'actorId', NEW.actor_id, 'command', NEW.command, 'operationId', NEW.operation_key),\n 20\n );\n END IF;\n RETURN NEW;\nEND\n$recovery$;\nCREATE OR REPLACE TRIGGER execution_recovery_enqueue\nAFTER INSERT ON supacloud_commands.execution_receipts\nFOR EACH ROW EXECUTE FUNCTION supacloud_commands.enqueue_execution_recovery();\n\nCREATE OR REPLACE FUNCTION supacloud_commands.execution_status(request jsonb)\nRETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $status$\n SELECT jsonb_build_object('kind','execution','commandId',r.dispatch_key,\n 'execution',jsonb_build_object('tenantId',r.tenant_id,'actorId',r.actor_id,\n 'command',r.command,'operationId',r.operation_key,'dispatchKey',r.dispatch_key,\n 'status',r.status,'audit',r.audit_state)\n || CASE WHEN r.status='confirmed' THEN jsonb_build_object('result',r.result) ELSE '{}'::jsonb END,\n 'workflow',CASE WHEN w.id IS NULL THEN NULL ELSE jsonb_build_object('runId',w.id,'status',w.status) END)\n FROM supacloud_commands.execution_receipts r\n LEFT JOIN supacloud_workflows.runs w ON w.id=r.dispatch_key\n WHERE CASE WHEN request ? 'commandId' THEN r.dispatch_key::text=request->>'commandId'\n ELSE r.tenant_id=request->>'tenantId' AND r.actor_id=request->>'actorId'\n AND r.command=request->>'command' AND r.operation_key=request->>'operationId' END\n$status$;\nREVOKE ALL ON FUNCTION supacloud_commands.execution_status(jsonb) FROM PUBLIC;\n\nDO $backfill$\nDECLARE r record;\nBEGIN\n FOR r IN SELECT * FROM supacloud_commands.execution_receipts\n WHERE kind='external' AND (status<>'confirmed' OR audit_state<>'complete')\n AND NOT EXISTS(SELECT FROM supacloud_workflows.runs w WHERE w.id=dispatch_key)\n LOOP\n PERFORM supacloud_workflows.start_run(\n r.dispatch_key,'supacloud.command.reconcile','1','reconcile',\n jsonb_build_object('commandId',r.dispatch_key,'tenantId',r.tenant_id,\n 'actorId',r.actor_id,'command',r.command,'operationId',r.operation_key),20\n );\n END LOOP;\nEND\n$backfill$;\nDROP INDEX IF EXISTS supacloud_commands.execution_pending_recovery;\nALTER TABLE supacloud_commands.execution_receipts\n DROP COLUMN IF EXISTS lease_id,\n DROP COLUMN IF EXISTS lease_until,\n DROP COLUMN IF EXISTS next_attempt_at,\n DROP COLUMN IF EXISTS recovery_attempts;\n";
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,9 @@ 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';
|
|
11
|
+
export { COMMAND_PERSISTENCE_SQL, COMMAND_PERSISTENCE_UPGRADE_SQL } from "./command-schema";
|
|
12
|
+
export { createPostgresCommandStore, type CommandDatabase, type CommandTransaction, type CommandSubmissionBinding, } from "./command-adapter";
|
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((value) => {
|
|
576
|
+
const template = record(value);
|
|
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((value) => {
|
|
584
|
+
const parameter = record(value);
|
|
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 {
|
|
@@ -733,12 +879,305 @@ function createRpcClient(transport, contracts) {
|
|
|
733
879
|
}
|
|
734
880
|
};
|
|
735
881
|
}
|
|
882
|
+
// src/command-schema.ts
|
|
883
|
+
var COMMAND_PERSISTENCE_SQL = `
|
|
884
|
+
DO $runtime$
|
|
885
|
+
BEGIN
|
|
886
|
+
IF to_regprocedure('supacloud_workflows.start_run(uuid,text,text,text,jsonb,integer)') IS NULL
|
|
887
|
+
OR to_regprocedure('supacloud_commands.status(jsonb)') IS NULL THEN
|
|
888
|
+
RAISE EXCEPTION 'Install the SupaCloud Commands/Workflow/PGMQ runtime before command persistence';
|
|
889
|
+
END IF;
|
|
890
|
+
END
|
|
891
|
+
$runtime$;
|
|
892
|
+
CREATE SCHEMA IF NOT EXISTS supacloud_commands;
|
|
893
|
+
REVOKE ALL ON SCHEMA supacloud_commands FROM PUBLIC;
|
|
894
|
+
CREATE TABLE IF NOT EXISTS supacloud_commands.execution_receipts (
|
|
895
|
+
tenant_id text NOT NULL,
|
|
896
|
+
actor_id text NOT NULL,
|
|
897
|
+
command text NOT NULL,
|
|
898
|
+
operation_key text NOT NULL,
|
|
899
|
+
kind text NOT NULL CHECK (kind IN ('transactional', 'external')),
|
|
900
|
+
input_fingerprint text NOT NULL CHECK (input_fingerprint ~ '^[a-f0-9]{64}$'),
|
|
901
|
+
input_payload text,
|
|
902
|
+
dispatch_key uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
|
|
903
|
+
status text NOT NULL CHECK (status IN ('pending', 'unknown', 'confirmed')),
|
|
904
|
+
audit_state text NOT NULL CHECK (audit_state IN ('pending', 'complete')),
|
|
905
|
+
result jsonb,
|
|
906
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
907
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
908
|
+
PRIMARY KEY (tenant_id, actor_id, command, operation_key),
|
|
909
|
+
CHECK ((status = 'confirmed') = (result IS NOT NULL)),
|
|
910
|
+
CHECK (audit_state <> 'complete' OR status = 'confirmed'),
|
|
911
|
+
CHECK (kind <> 'transactional' OR (status = 'confirmed' AND audit_state = 'complete'))
|
|
912
|
+
);
|
|
913
|
+
CREATE TABLE IF NOT EXISTS supacloud_commands.execution_audit (
|
|
914
|
+
tenant_id text NOT NULL,
|
|
915
|
+
actor_id text NOT NULL,
|
|
916
|
+
command text NOT NULL,
|
|
917
|
+
operation_key text NOT NULL,
|
|
918
|
+
event text NOT NULL,
|
|
919
|
+
details jsonb NOT NULL,
|
|
920
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
921
|
+
PRIMARY KEY (tenant_id, actor_id, command, operation_key),
|
|
922
|
+
FOREIGN KEY (tenant_id, actor_id, command, operation_key)
|
|
923
|
+
REFERENCES supacloud_commands.execution_receipts (tenant_id, actor_id, command, operation_key)
|
|
924
|
+
);
|
|
925
|
+
REVOKE ALL ON supacloud_commands.execution_receipts, supacloud_commands.execution_audit FROM PUBLIC;
|
|
926
|
+
CREATE OR REPLACE FUNCTION supacloud_commands.enqueue_execution_recovery()
|
|
927
|
+
RETURNS trigger LANGUAGE plpgsql SET search_path = '' AS $recovery$
|
|
928
|
+
DECLARE submission jsonb;
|
|
929
|
+
BEGIN
|
|
930
|
+
PERFORM pg_advisory_xact_lock(hashtextextended(NEW.dispatch_key::text,0));
|
|
931
|
+
SELECT supacloud_commands.snapshot(NEW.dispatch_key,false) INTO submission;
|
|
932
|
+
IF submission IS NOT NULL THEN
|
|
933
|
+
IF submission->>'commandType' IS DISTINCT FROM NEW.command
|
|
934
|
+
OR submission->>'actorId' IS DISTINCT FROM NEW.actor_id
|
|
935
|
+
OR submission->>'tenantId' IS DISTINCT FROM NEW.tenant_id
|
|
936
|
+
OR NEW.operation_key <> NEW.dispatch_key::text THEN
|
|
937
|
+
RAISE EXCEPTION 'SUPACLOUD_COMMAND_IDENTITY_CONFLICT';
|
|
938
|
+
END IF;
|
|
939
|
+
RETURN NEW;
|
|
940
|
+
END IF;
|
|
941
|
+
IF NEW.kind = 'external' AND (NEW.status <> 'confirmed' OR NEW.audit_state <> 'complete') THEN
|
|
942
|
+
PERFORM supacloud_workflows.start_run(
|
|
943
|
+
NEW.dispatch_key, 'supacloud.command.reconcile', '1', 'reconcile',
|
|
944
|
+
jsonb_build_object('commandId', NEW.dispatch_key, 'tenantId', NEW.tenant_id,
|
|
945
|
+
'actorId', NEW.actor_id, 'command', NEW.command, 'operationId', NEW.operation_key),
|
|
946
|
+
20
|
|
947
|
+
);
|
|
948
|
+
END IF;
|
|
949
|
+
RETURN NEW;
|
|
950
|
+
END
|
|
951
|
+
$recovery$;
|
|
952
|
+
CREATE OR REPLACE TRIGGER execution_recovery_enqueue
|
|
953
|
+
AFTER INSERT ON supacloud_commands.execution_receipts
|
|
954
|
+
FOR EACH ROW EXECUTE FUNCTION supacloud_commands.enqueue_execution_recovery();
|
|
955
|
+
|
|
956
|
+
CREATE OR REPLACE FUNCTION supacloud_commands.execution_status(request jsonb)
|
|
957
|
+
RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $status$
|
|
958
|
+
SELECT jsonb_build_object('kind','execution','commandId',r.dispatch_key,
|
|
959
|
+
'execution',jsonb_build_object('tenantId',r.tenant_id,'actorId',r.actor_id,
|
|
960
|
+
'command',r.command,'operationId',r.operation_key,'dispatchKey',r.dispatch_key,
|
|
961
|
+
'status',r.status,'audit',r.audit_state)
|
|
962
|
+
|| CASE WHEN r.status='confirmed' THEN jsonb_build_object('result',r.result) ELSE '{}'::jsonb END,
|
|
963
|
+
'workflow',CASE WHEN w.id IS NULL THEN NULL ELSE jsonb_build_object('runId',w.id,'status',w.status) END)
|
|
964
|
+
FROM supacloud_commands.execution_receipts r
|
|
965
|
+
LEFT JOIN supacloud_workflows.runs w ON w.id=r.dispatch_key
|
|
966
|
+
WHERE CASE WHEN request ? 'commandId' THEN r.dispatch_key::text=request->>'commandId'
|
|
967
|
+
ELSE r.tenant_id=request->>'tenantId' AND r.actor_id=request->>'actorId'
|
|
968
|
+
AND r.command=request->>'command' AND r.operation_key=request->>'operationId' END
|
|
969
|
+
$status$;
|
|
970
|
+
REVOKE ALL ON FUNCTION supacloud_commands.execution_status(jsonb) FROM PUBLIC;
|
|
971
|
+
`;
|
|
972
|
+
var COMMAND_PERSISTENCE_UPGRADE_SQL = `
|
|
973
|
+
ALTER TABLE supacloud_commands.execution_receipts
|
|
974
|
+
ADD COLUMN IF NOT EXISTS input_fingerprint text,
|
|
975
|
+
ADD COLUMN IF NOT EXISTS input_payload text;
|
|
976
|
+
DO $migration$
|
|
977
|
+
BEGIN
|
|
978
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='supacloud_commands'
|
|
979
|
+
AND table_name='execution_receipts' AND column_name='input_key') THEN
|
|
980
|
+
EXECUTE 'UPDATE supacloud_commands.execution_receipts
|
|
981
|
+
SET input_fingerprint=encode(sha256(convert_to(input_key,''UTF8'')),''hex''),input_payload=input_key
|
|
982
|
+
WHERE input_fingerprint IS NULL';
|
|
983
|
+
ALTER TABLE supacloud_commands.execution_receipts DROP COLUMN input_key;
|
|
984
|
+
END IF;
|
|
985
|
+
END
|
|
986
|
+
$migration$;
|
|
987
|
+
ALTER TABLE supacloud_commands.execution_receipts ALTER COLUMN input_fingerprint SET NOT NULL;
|
|
988
|
+
DO $constraint$
|
|
989
|
+
BEGIN
|
|
990
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint
|
|
991
|
+
WHERE conrelid='supacloud_commands.execution_receipts'::regclass
|
|
992
|
+
AND conname='execution_receipts_input_fingerprint_check') THEN
|
|
993
|
+
ALTER TABLE supacloud_commands.execution_receipts
|
|
994
|
+
ADD CONSTRAINT execution_receipts_input_fingerprint_check CHECK (input_fingerprint ~ '^[a-f0-9]{64}$');
|
|
995
|
+
END IF;
|
|
996
|
+
END
|
|
997
|
+
$constraint$;
|
|
998
|
+
${COMMAND_PERSISTENCE_SQL}
|
|
999
|
+
DO $backfill$
|
|
1000
|
+
DECLARE r record;
|
|
1001
|
+
BEGIN
|
|
1002
|
+
FOR r IN SELECT * FROM supacloud_commands.execution_receipts
|
|
1003
|
+
WHERE kind='external' AND (status<>'confirmed' OR audit_state<>'complete')
|
|
1004
|
+
AND NOT EXISTS(SELECT FROM supacloud_workflows.runs w WHERE w.id=dispatch_key)
|
|
1005
|
+
LOOP
|
|
1006
|
+
PERFORM supacloud_workflows.start_run(
|
|
1007
|
+
r.dispatch_key,'supacloud.command.reconcile','1','reconcile',
|
|
1008
|
+
jsonb_build_object('commandId',r.dispatch_key,'tenantId',r.tenant_id,
|
|
1009
|
+
'actorId',r.actor_id,'command',r.command,'operationId',r.operation_key),20
|
|
1010
|
+
);
|
|
1011
|
+
END LOOP;
|
|
1012
|
+
END
|
|
1013
|
+
$backfill$;
|
|
1014
|
+
DROP INDEX IF EXISTS supacloud_commands.execution_pending_recovery;
|
|
1015
|
+
ALTER TABLE supacloud_commands.execution_receipts
|
|
1016
|
+
DROP COLUMN IF EXISTS lease_id,
|
|
1017
|
+
DROP COLUMN IF EXISTS lease_until,
|
|
1018
|
+
DROP COLUMN IF EXISTS next_attempt_at,
|
|
1019
|
+
DROP COLUMN IF EXISTS recovery_attempts;
|
|
1020
|
+
`;
|
|
1021
|
+
// src/command-adapter.ts
|
|
1022
|
+
import {
|
|
1023
|
+
CommandError,
|
|
1024
|
+
canonicalCommandJson,
|
|
1025
|
+
commandIdentifier,
|
|
1026
|
+
decodeCommandJson,
|
|
1027
|
+
decodeDurableCommandReceipt
|
|
1028
|
+
} from "@supacloud/contracts";
|
|
1029
|
+
var where = "tenant_id=$1 AND actor_id=$2 AND command=$3 AND operation_key=$4";
|
|
1030
|
+
var keys2 = (ref) => [ref.tenantId, ref.actorId, ref.command, ref.operationId].map(commandIdentifier);
|
|
1031
|
+
function rows(value) {
|
|
1032
|
+
const isRow = (row) => row !== null && typeof row === "object" && !Array.isArray(row);
|
|
1033
|
+
if (!Array.isArray(value) || !Array.from(value).every(isRow))
|
|
1034
|
+
throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
1035
|
+
return value;
|
|
1036
|
+
}
|
|
1037
|
+
function decodeStored(row) {
|
|
1038
|
+
try {
|
|
1039
|
+
const fingerprint = row["input_fingerprint"], payload = row["input_payload"], kind = row["kind"];
|
|
1040
|
+
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint) || payload !== null && typeof payload !== "string" || kind !== "transactional" && kind !== "external")
|
|
1041
|
+
throw new Error("Invalid record");
|
|
1042
|
+
const serialized = row["result_json"];
|
|
1043
|
+
if (serialized !== null && typeof serialized !== "string")
|
|
1044
|
+
throw new Error("Invalid stored JSON");
|
|
1045
|
+
const raw = typeof serialized === "string" ? JSON.parse(serialized) : undefined;
|
|
1046
|
+
const receipt = decodeDurableCommandReceipt({ ...row, ...raw === undefined ? {} : { result: raw } }, decodeCommandJson);
|
|
1047
|
+
return { receipt, kind, inputFingerprint: fingerprint, inputPayload: payload };
|
|
1048
|
+
} catch {
|
|
1049
|
+
throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
var projection = `tenant_id AS "tenantId", actor_id AS "actorId", command,
|
|
1053
|
+
operation_key AS "operationId", dispatch_key::text AS "dispatchKey",
|
|
1054
|
+
status, audit_state AS audit, result::text AS result_json, input_fingerprint,input_payload,kind`;
|
|
1055
|
+
function createPostgresCommandStore(database, options = {}) {
|
|
1056
|
+
const submission = options.submission === undefined ? undefined : { ...options.submission };
|
|
1057
|
+
if (submission !== undefined && (!/^[0-9a-f-]{36}$/.test(submission.commandId) || !/^[0-9a-f-]{36}$/.test(submission.stepId) || !/^[1-9][0-9]*$/.test(submission.messageId) || !Number.isSafeInteger(submission.attempt) || submission.attempt < 1 || !commandIdentifier(submission.workerId)))
|
|
1058
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1059
|
+
const submittedPayload = async (tx, ref, inserting) => {
|
|
1060
|
+
if (submission === undefined)
|
|
1061
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1062
|
+
if (ref.operationId !== submission.commandId)
|
|
1063
|
+
throw new CommandError("COMMAND_IDEMPOTENCY_CONFLICT");
|
|
1064
|
+
const found = rows(await tx.query(`SELECT c.command_type,c.actor_id::text,c.tenant_id,c.payload::text AS payload_json,
|
|
1065
|
+
s.step_key,s.status FROM supacloud_commands.receipts c
|
|
1066
|
+
JOIN LATERAL supacloud_workflows.lock_step_attempt($2::uuid,$3::bigint,$4::integer,$5) s
|
|
1067
|
+
ON s.run_id=c.id WHERE c.id=$1::uuid`, [submission.commandId, submission.stepId, submission.messageId, submission.attempt, submission.workerId]));
|
|
1068
|
+
const row = found[0];
|
|
1069
|
+
if (found.length !== 1 || row === undefined || row["command_type"] !== ref.command || row["actor_id"] !== ref.actorId || row["tenant_id"] !== ref.tenantId || row["step_key"] !== "execute" || row["status"] !== "running" && (inserting || row["status"] !== "completed") || typeof row["payload_json"] !== "string")
|
|
1070
|
+
throw new CommandError("COMMAND_REJECTED");
|
|
1071
|
+
const payload = JSON.parse(row["payload_json"]);
|
|
1072
|
+
return payload;
|
|
1073
|
+
};
|
|
1074
|
+
const transaction = async (run) => {
|
|
1075
|
+
let started = false;
|
|
1076
|
+
try {
|
|
1077
|
+
return await database.transaction((tx) => {
|
|
1078
|
+
started = true;
|
|
1079
|
+
return run(tx);
|
|
1080
|
+
});
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
if (error instanceof CommandError)
|
|
1083
|
+
throw error;
|
|
1084
|
+
throw new CommandError(started ? "COMMAND_OUTCOME_UNKNOWN" : "COMMAND_UNAVAILABLE");
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
const session = (tx) => ({
|
|
1088
|
+
transaction: tx,
|
|
1089
|
+
async lock(ref) {
|
|
1090
|
+
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))", [JSON.stringify(keys2(ref))]);
|
|
1091
|
+
if (submission !== undefined)
|
|
1092
|
+
await submittedPayload(tx, ref, false);
|
|
1093
|
+
},
|
|
1094
|
+
async read(ref) {
|
|
1095
|
+
const found = rows(await tx.query(`SELECT ${projection} FROM supacloud_commands.execution_receipts WHERE ${where}`, keys2(ref)));
|
|
1096
|
+
if (found.length > 1)
|
|
1097
|
+
throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
1098
|
+
const row = found[0];
|
|
1099
|
+
return row === undefined ? null : decodeStored(row);
|
|
1100
|
+
},
|
|
1101
|
+
async insert(record) {
|
|
1102
|
+
const receipt = submission === undefined ? record.receipt : { ...record.receipt, dispatchKey: submission.commandId };
|
|
1103
|
+
if (submission !== undefined) {
|
|
1104
|
+
const payload = await submittedPayload(tx, receipt, true);
|
|
1105
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalCommandJson(payload)));
|
|
1106
|
+
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1107
|
+
if (fingerprint !== record.inputFingerprint)
|
|
1108
|
+
throw new CommandError("COMMAND_IDEMPOTENCY_CONFLICT");
|
|
1109
|
+
}
|
|
1110
|
+
const result = receipt.status === "confirmed" ? canonicalCommandJson(receipt.result) : null;
|
|
1111
|
+
await tx.query(`INSERT INTO supacloud_commands.execution_receipts
|
|
1112
|
+
(tenant_id,actor_id,command,operation_key,dispatch_key,kind,input_fingerprint,input_payload,status,audit_state,result)
|
|
1113
|
+
VALUES ($1,$2,$3,$4,$5::uuid,$6,$7,$8,$9,$10,$11::text::jsonb)`, [...keys2(receipt), receipt.dispatchKey, record.kind, record.inputFingerprint, record.inputPayload, receipt.status, receipt.audit, result]);
|
|
1114
|
+
if (submission !== undefined) {
|
|
1115
|
+
const attempt = { stepId: submission.stepId, messageId: submission.messageId, attempt: submission.attempt, workerId: submission.workerId };
|
|
1116
|
+
const commandId = submission.commandId;
|
|
1117
|
+
if (record.kind === "external") {
|
|
1118
|
+
await tx.query("SELECT public.supacloud_workflow_advance($1::text::jsonb)", [JSON.stringify({
|
|
1119
|
+
...attempt,
|
|
1120
|
+
output: { commandId, status: "intent-recorded" },
|
|
1121
|
+
nextStepKey: "reconcile",
|
|
1122
|
+
nextInput: { commandId, tenantId: receipt.tenantId, actorId: receipt.actorId, command: receipt.command, operationId: receipt.operationId },
|
|
1123
|
+
nextMaxAttempts: 20
|
|
1124
|
+
})]);
|
|
1125
|
+
} else {
|
|
1126
|
+
const output = { commandId, status: "confirmed", audit: "complete" };
|
|
1127
|
+
await tx.query("SELECT public.supacloud_workflow_complete($1::text::jsonb)", [JSON.stringify({ ...attempt, stepOutput: output, runOutput: output })]);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
},
|
|
1131
|
+
async confirm(ref, result) {
|
|
1132
|
+
await tx.query(`UPDATE supacloud_commands.execution_receipts SET status='confirmed',result=$5::text::jsonb,updated_at=now()
|
|
1133
|
+
WHERE ${where} AND status<>'confirmed'`, [...keys2(ref), canonicalCommandJson(result)]);
|
|
1134
|
+
},
|
|
1135
|
+
async markUnknown(ref) {
|
|
1136
|
+
await tx.query(`UPDATE supacloud_commands.execution_receipts SET status='unknown',updated_at=now()
|
|
1137
|
+
WHERE ${where} AND status<>'confirmed'`, keys2(ref));
|
|
1138
|
+
},
|
|
1139
|
+
async audit(ref, event, details) {
|
|
1140
|
+
await tx.query(`INSERT INTO supacloud_commands.execution_audit
|
|
1141
|
+
(tenant_id,actor_id,command,operation_key,event,details) VALUES ($1,$2,$3,$4,$5,$6::text::jsonb)`, [...keys2(ref), commandIdentifier(event), canonicalCommandJson(details)]);
|
|
1142
|
+
},
|
|
1143
|
+
async completeAudit(ref) {
|
|
1144
|
+
await tx.query(`UPDATE supacloud_commands.execution_receipts SET audit_state='complete',updated_at=now() WHERE ${where}`, keys2(ref));
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
const scopeKeys = (scope) => {
|
|
1148
|
+
if (scope.commands.length === 0)
|
|
1149
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1150
|
+
return [commandIdentifier(scope.tenantId), JSON.stringify(scope.commands.map(commandIdentifier))];
|
|
1151
|
+
};
|
|
1152
|
+
const integer = (value, min, max = Number.MAX_SAFE_INTEGER) => {
|
|
1153
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
1154
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1155
|
+
return value;
|
|
1156
|
+
};
|
|
1157
|
+
const selected = `tenant_id=$1 AND command IN (SELECT jsonb_array_elements_text($2::text::jsonb))`;
|
|
1158
|
+
const join = `r.tenant_id=c.tenant_id AND r.actor_id=c.actor_id AND r.command=c.command AND r.operation_key=c.operation_key`;
|
|
1159
|
+
return {
|
|
1160
|
+
transaction: (run) => transaction((tx) => run(session(tx))),
|
|
1161
|
+
async redactCompleted(options) {
|
|
1162
|
+
return transaction(async (tx) => rows(await tx.query(`WITH candidates AS (
|
|
1163
|
+
SELECT tenant_id,actor_id,command,operation_key FROM supacloud_commands.execution_receipts
|
|
1164
|
+
WHERE ${selected} AND status='confirmed' AND audit_state='complete' AND input_payload IS NOT NULL
|
|
1165
|
+
AND updated_at<to_timestamp($3::double precision/1000)
|
|
1166
|
+
ORDER BY updated_at LIMIT $4 FOR UPDATE SKIP LOCKED
|
|
1167
|
+
) UPDATE supacloud_commands.execution_receipts r SET input_payload=NULL FROM candidates c
|
|
1168
|
+
WHERE ${join} RETURNING r.operation_key`, [...scopeKeys(options), integer(options.before, 0), integer(options.limit, 1, 1000)])).length);
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
736
1172
|
export {
|
|
1173
|
+
COMMAND_PERSISTENCE_SQL,
|
|
1174
|
+
COMMAND_PERSISTENCE_UPGRADE_SQL,
|
|
737
1175
|
DatabaseAccessError,
|
|
738
1176
|
RpcContractError,
|
|
739
1177
|
applyModulePlan,
|
|
740
1178
|
buildDatabaseManifest,
|
|
741
1179
|
createDatabaseAccessBoundary,
|
|
1180
|
+
createPostgresCommandStore,
|
|
742
1181
|
createRpcClient,
|
|
743
1182
|
defineDatabaseModule,
|
|
744
1183
|
defineRpcContract,
|
|
@@ -746,8 +1185,11 @@ export {
|
|
|
746
1185
|
extractSearchPath,
|
|
747
1186
|
lintModule,
|
|
748
1187
|
lintSql,
|
|
1188
|
+
migrationBindingSha256,
|
|
1189
|
+
parseMigrationBindingManifest,
|
|
749
1190
|
planModule,
|
|
750
1191
|
readCatalog,
|
|
751
1192
|
reconcileModule,
|
|
1193
|
+
renderMigrationBindings,
|
|
752
1194
|
splitQualifiedName
|
|
753
1195
|
};
|
|
@@ -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,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/db",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
7
7
|
"module": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
9
|
"exports": {
|
|
10
|
+
"./bun": {
|
|
11
|
+
"types": "./dist/command-bun.d.ts",
|
|
12
|
+
"import": "./dist/command-bun.js"
|
|
13
|
+
},
|
|
10
14
|
".": {
|
|
11
15
|
"types": "./dist/index.d.ts",
|
|
12
16
|
"import": "./dist/index.js",
|
|
@@ -15,11 +19,12 @@
|
|
|
15
19
|
},
|
|
16
20
|
"files": [
|
|
17
21
|
"dist",
|
|
18
|
-
"README.md"
|
|
22
|
+
"README.md",
|
|
23
|
+
"MIGRATION_BINDINGS.md"
|
|
19
24
|
],
|
|
20
25
|
"scripts": {
|
|
21
26
|
"build": "bun run clean && bun run build:js && bun run build:types",
|
|
22
|
-
"build:js": "bun build src/index.ts --outdir dist --target node",
|
|
27
|
+
"build:js": "bun build src/index.ts src/command-bun.ts --outdir dist --target node --external bun --external @supacloud/contracts",
|
|
23
28
|
"build:types": "tsc -p tsconfig.json --emitDeclarationOnly",
|
|
24
29
|
"clean": "rm -rf dist",
|
|
25
30
|
"prepublishOnly": "bun run build",
|
|
@@ -41,7 +46,13 @@
|
|
|
41
46
|
"directory": "packages/db"
|
|
42
47
|
},
|
|
43
48
|
"devDependencies": {
|
|
44
|
-
"@types/bun": "^1.4.
|
|
49
|
+
"@types/bun": "^1.4.2",
|
|
45
50
|
"typescript": "^7.0.2"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@supacloud/contracts": "0.3.1"
|
|
54
|
+
},
|
|
55
|
+
"overrides": {
|
|
56
|
+
"@supacloud/contracts": "0.3.1"
|
|
46
57
|
}
|
|
47
58
|
}
|