@supacloud/db 0.6.0 → 0.8.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 +74 -0
- package/dist/command-adapter.d.ts +18 -0
- package/dist/command-bun.d.ts +8 -0
- package/dist/command-bun.js +350 -0
- package/dist/command-schema.d.ts +4 -0
- package/dist/drizzle-bun.d.ts +10 -0
- package/dist/drizzle-bun.js +93 -0
- package/dist/drizzle.d.ts +26 -0
- package/dist/drizzle.js +61 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +378 -4
- package/dist/migration-bindings.d.ts +3 -3
- package/dist/query.d.ts +36 -0
- package/dist/sql-analysis.d.ts +47 -0
- package/dist/sql-analysis.js +291 -0
- package/dist/sql-impact-cli.d.ts +14 -0
- package/dist/sql-impact-cli.js +371 -0
- package/package.json +42 -4
package/dist/drizzle.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/drizzle.ts
|
|
2
|
+
import { is } from "drizzle-orm";
|
|
3
|
+
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
|
4
|
+
|
|
5
|
+
// src/module.ts
|
|
6
|
+
function normalizeTable(ref) {
|
|
7
|
+
if (typeof ref === "string")
|
|
8
|
+
return ref;
|
|
9
|
+
const schema = ref._.schema ?? "public";
|
|
10
|
+
return `${schema}.${ref._.name}`;
|
|
11
|
+
}
|
|
12
|
+
function defineDatabaseModule(options) {
|
|
13
|
+
return {
|
|
14
|
+
name: options.name,
|
|
15
|
+
tables: (options.tables ?? []).map(normalizeTable),
|
|
16
|
+
policies: options.policies ?? [],
|
|
17
|
+
functions: options.functions ?? [],
|
|
18
|
+
triggers: options.triggers ?? [],
|
|
19
|
+
grants: options.grants ?? []
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/drizzle.ts
|
|
24
|
+
function defineDrizzleDatabaseModule(tables, options) {
|
|
25
|
+
const names = new Set;
|
|
26
|
+
for (const table of Object.values(tables)) {
|
|
27
|
+
if (!is(table, PgTable))
|
|
28
|
+
throw new TypeError("Expected a PostgreSQL Drizzle table");
|
|
29
|
+
const config = getTableConfig(table);
|
|
30
|
+
names.add(`${config.schema ?? "public"}.${config.name}`);
|
|
31
|
+
}
|
|
32
|
+
return defineDatabaseModule({ ...options, tables: [...names].sort() });
|
|
33
|
+
}
|
|
34
|
+
function createDrizzleCommandDatabase(options) {
|
|
35
|
+
return {
|
|
36
|
+
transaction: (work) => options.transaction(async (connection) => {
|
|
37
|
+
const db = options.bind(connection);
|
|
38
|
+
let active = true;
|
|
39
|
+
try {
|
|
40
|
+
return await work({
|
|
41
|
+
db,
|
|
42
|
+
query: async (text, parameters = []) => {
|
|
43
|
+
if (!active)
|
|
44
|
+
throw new Error("Command transaction is closed");
|
|
45
|
+
return options.query(connection, text, parameters);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
} finally {
|
|
49
|
+
active = false;
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function executeDecodedSql(executor, query, decode) {
|
|
55
|
+
return decode(await executor.execute(query));
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
createDrizzleCommandDatabase,
|
|
59
|
+
defineDrizzleDatabaseModule,
|
|
60
|
+
executeDecodedSql
|
|
61
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -8,3 +8,6 @@ export { migrationBindingSha256, parseMigrationBindingManifest, renderMigrationB
|
|
|
8
8
|
export { buildDatabaseManifest, explainObject, type DatabaseManifest, type DatabaseManifestModule, } from './manifest.js';
|
|
9
9
|
export { createDatabaseAccessBoundary, DatabaseAccessError, type AuthenticatedDatabaseIdentity, type DatabaseAccessBoundary, type DatabaseAccessBoundaryOptions, type DatabaseAccessErrorCode, } from './access.js';
|
|
10
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 { defineReadQuery, decodePostgrestQuery, QueryBoundaryError, type QueryEvent, type QueryObserver, type ReadQueryDefinition } from "./query";
|
|
13
|
+
export { createPostgresCommandStore, type CommandDatabase, type CommandTransaction, type CommandSubmissionBinding, } from "./command-adapter";
|
package/dist/index.js
CHANGED
|
@@ -572,16 +572,16 @@ function parseMigrationBindingManifest(value) {
|
|
|
572
572
|
throw new Error("Duplicate migration binding environment");
|
|
573
573
|
}
|
|
574
574
|
const files = new Set;
|
|
575
|
-
const templates = manifest.templates.map((
|
|
576
|
-
const template = record(
|
|
575
|
+
const templates = manifest.templates.map((value) => {
|
|
576
|
+
const template = record(value);
|
|
577
577
|
keys(template, ["file", "templateSha256", "parameters"]);
|
|
578
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
579
|
throw new Error("Invalid or duplicate migration binding template");
|
|
580
580
|
}
|
|
581
581
|
files.add(template.file);
|
|
582
582
|
const placeholders = new Set;
|
|
583
|
-
const parameters = template.parameters.map((
|
|
584
|
-
const parameter = record(
|
|
583
|
+
const parameters = template.parameters.map((value) => {
|
|
584
|
+
const parameter = record(value);
|
|
585
585
|
keys(parameter, ["placeholder", "variable", "type", "occurrences"]);
|
|
586
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
587
|
throw new Error("Invalid or duplicate migration binding parameter");
|
|
@@ -879,14 +879,388 @@ function createRpcClient(transport, contracts) {
|
|
|
879
879
|
}
|
|
880
880
|
};
|
|
881
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/query.ts
|
|
1022
|
+
class QueryBoundaryError extends Error {
|
|
1023
|
+
code;
|
|
1024
|
+
constructor(code) {
|
|
1025
|
+
super(code);
|
|
1026
|
+
this.code = code;
|
|
1027
|
+
this.name = "QueryBoundaryError";
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
function defineReadQuery(definition) {
|
|
1031
|
+
const contract = { ...definition };
|
|
1032
|
+
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(contract.name) || contract.fingerprint !== undefined && !/^[a-f0-9]{16,64}$/.test(contract.fingerprint) || contract.slowMs !== undefined && (!Number.isFinite(contract.slowMs) || contract.slowMs < 0)) {
|
|
1033
|
+
throw new TypeError("Invalid query observation metadata");
|
|
1034
|
+
}
|
|
1035
|
+
const emit = (event) => {
|
|
1036
|
+
try {
|
|
1037
|
+
Promise.resolve(contract.observe?.(Object.freeze(event))).catch(() => {});
|
|
1038
|
+
} catch {}
|
|
1039
|
+
};
|
|
1040
|
+
const executeUnknown = async (identity, raw, signal) => {
|
|
1041
|
+
if (signal?.aborted)
|
|
1042
|
+
throw new QueryBoundaryError("QUERY_ABORTED");
|
|
1043
|
+
let input;
|
|
1044
|
+
try {
|
|
1045
|
+
input = contract.input(raw);
|
|
1046
|
+
} catch {
|
|
1047
|
+
throw new QueryBoundaryError("QUERY_INPUT_INVALID");
|
|
1048
|
+
}
|
|
1049
|
+
let decision;
|
|
1050
|
+
try {
|
|
1051
|
+
decision = await contract.authorize(identity, input);
|
|
1052
|
+
} catch {
|
|
1053
|
+
throw new QueryBoundaryError("QUERY_AUTHORIZATION_FAILED");
|
|
1054
|
+
}
|
|
1055
|
+
if (decision === "deny")
|
|
1056
|
+
throw new QueryBoundaryError("QUERY_REJECTED");
|
|
1057
|
+
if (decision !== "allow")
|
|
1058
|
+
throw new QueryBoundaryError("QUERY_AUTHORIZATION_FAILED");
|
|
1059
|
+
if (signal?.aborted)
|
|
1060
|
+
throw new QueryBoundaryError("QUERY_ABORTED");
|
|
1061
|
+
const started = performance.now();
|
|
1062
|
+
const metadata = { operation: contract.name, ...contract.fingerprint ? { fingerprint: contract.fingerprint } : {} };
|
|
1063
|
+
emit({ ...metadata, phase: "started" });
|
|
1064
|
+
try {
|
|
1065
|
+
const value = await contract.execute(input, identity, signal);
|
|
1066
|
+
if (signal?.aborted)
|
|
1067
|
+
throw new QueryBoundaryError("QUERY_ABORTED");
|
|
1068
|
+
let result;
|
|
1069
|
+
try {
|
|
1070
|
+
result = contract.result(value);
|
|
1071
|
+
} catch {
|
|
1072
|
+
throw new QueryBoundaryError("QUERY_RESULT_INVALID");
|
|
1073
|
+
}
|
|
1074
|
+
const durationMs = performance.now() - started;
|
|
1075
|
+
emit({ ...metadata, phase: "succeeded", durationMs, slow: durationMs >= (contract.slowMs ?? 500) });
|
|
1076
|
+
return result;
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
const durationMs = performance.now() - started;
|
|
1079
|
+
emit({ ...metadata, phase: "failed", durationMs, slow: durationMs >= (contract.slowMs ?? 500) });
|
|
1080
|
+
throw error;
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
const execute = (identity, input, signal) => contract.policy ? contract.policy.execute((signal) => executeUnknown(identity, input, signal), signal) : executeUnknown(identity, input, signal);
|
|
1084
|
+
return Object.freeze({
|
|
1085
|
+
execute: (identity, input, signal) => execute(identity, input, signal),
|
|
1086
|
+
executeUnknown: execute
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
async function decodePostgrestQuery(query, decode) {
|
|
1090
|
+
const response = await query;
|
|
1091
|
+
if (response.error !== null)
|
|
1092
|
+
throw new QueryBoundaryError("QUERY_TRANSPORT_FAILED");
|
|
1093
|
+
try {
|
|
1094
|
+
return decode(response.data);
|
|
1095
|
+
} catch {
|
|
1096
|
+
throw new QueryBoundaryError("QUERY_RESULT_INVALID");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// src/command-adapter.ts
|
|
1100
|
+
import {
|
|
1101
|
+
CommandError,
|
|
1102
|
+
canonicalCommandJson,
|
|
1103
|
+
commandIdentifier,
|
|
1104
|
+
decodeCommandJson,
|
|
1105
|
+
decodeDurableCommandReceipt
|
|
1106
|
+
} from "@supacloud/contracts";
|
|
1107
|
+
var where = "tenant_id=$1 AND actor_id=$2 AND command=$3 AND operation_key=$4";
|
|
1108
|
+
var keys2 = (ref) => [ref.tenantId, ref.actorId, ref.command, ref.operationId].map(commandIdentifier);
|
|
1109
|
+
function rows(value) {
|
|
1110
|
+
const isRow = (row) => row !== null && typeof row === "object" && !Array.isArray(row);
|
|
1111
|
+
if (!Array.isArray(value) || !Array.from(value).every(isRow))
|
|
1112
|
+
throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
1113
|
+
return value;
|
|
1114
|
+
}
|
|
1115
|
+
function decodeStored(row) {
|
|
1116
|
+
try {
|
|
1117
|
+
const fingerprint = row["input_fingerprint"], payload = row["input_payload"], kind = row["kind"];
|
|
1118
|
+
if (typeof fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(fingerprint) || payload !== null && typeof payload !== "string" || kind !== "transactional" && kind !== "external")
|
|
1119
|
+
throw new Error("Invalid record");
|
|
1120
|
+
const serialized = row["result_json"];
|
|
1121
|
+
if (serialized !== null && typeof serialized !== "string")
|
|
1122
|
+
throw new Error("Invalid stored JSON");
|
|
1123
|
+
const raw = typeof serialized === "string" ? JSON.parse(serialized) : undefined;
|
|
1124
|
+
const receipt = decodeDurableCommandReceipt({ ...row, ...raw === undefined ? {} : { result: raw } }, decodeCommandJson);
|
|
1125
|
+
return { receipt, kind, inputFingerprint: fingerprint, inputPayload: payload };
|
|
1126
|
+
} catch {
|
|
1127
|
+
throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
var projection = `tenant_id AS "tenantId", actor_id AS "actorId", command,
|
|
1131
|
+
operation_key AS "operationId", dispatch_key::text AS "dispatchKey",
|
|
1132
|
+
status, audit_state AS audit, result::text AS result_json, input_fingerprint,input_payload,kind`;
|
|
1133
|
+
function createPostgresCommandStore(database, options = {}) {
|
|
1134
|
+
const submission = options.submission === undefined ? undefined : { ...options.submission };
|
|
1135
|
+
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)))
|
|
1136
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1137
|
+
const submittedPayload = async (tx, ref, inserting) => {
|
|
1138
|
+
if (submission === undefined)
|
|
1139
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1140
|
+
if (ref.operationId !== submission.commandId)
|
|
1141
|
+
throw new CommandError("COMMAND_IDEMPOTENCY_CONFLICT");
|
|
1142
|
+
const found = rows(await tx.query(`SELECT c.command_type,c.actor_id::text,c.tenant_id,c.payload::text AS payload_json,
|
|
1143
|
+
s.step_key,s.status FROM supacloud_commands.receipts c
|
|
1144
|
+
JOIN LATERAL supacloud_workflows.lock_step_attempt($2::uuid,$3::bigint,$4::integer,$5) s
|
|
1145
|
+
ON s.run_id=c.id WHERE c.id=$1::uuid`, [submission.commandId, submission.stepId, submission.messageId, submission.attempt, submission.workerId]));
|
|
1146
|
+
const row = found[0];
|
|
1147
|
+
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")
|
|
1148
|
+
throw new CommandError("COMMAND_REJECTED");
|
|
1149
|
+
const payload = JSON.parse(row["payload_json"]);
|
|
1150
|
+
return payload;
|
|
1151
|
+
};
|
|
1152
|
+
const transaction = async (run) => {
|
|
1153
|
+
let started = false;
|
|
1154
|
+
try {
|
|
1155
|
+
return await database.transaction((tx) => {
|
|
1156
|
+
started = true;
|
|
1157
|
+
return run(tx);
|
|
1158
|
+
});
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
if (error instanceof CommandError)
|
|
1161
|
+
throw error;
|
|
1162
|
+
throw new CommandError(started ? "COMMAND_OUTCOME_UNKNOWN" : "COMMAND_UNAVAILABLE");
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
const session = (tx) => ({
|
|
1166
|
+
transaction: tx,
|
|
1167
|
+
async lock(ref) {
|
|
1168
|
+
await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))", [JSON.stringify(keys2(ref))]);
|
|
1169
|
+
if (submission !== undefined)
|
|
1170
|
+
await submittedPayload(tx, ref, false);
|
|
1171
|
+
},
|
|
1172
|
+
async read(ref) {
|
|
1173
|
+
const found = rows(await tx.query(`SELECT ${projection} FROM supacloud_commands.execution_receipts WHERE ${where}`, keys2(ref)));
|
|
1174
|
+
if (found.length > 1)
|
|
1175
|
+
throw new CommandError("COMMAND_RECEIPT_INVALID");
|
|
1176
|
+
const row = found[0];
|
|
1177
|
+
return row === undefined ? null : decodeStored(row);
|
|
1178
|
+
},
|
|
1179
|
+
async insert(record) {
|
|
1180
|
+
const receipt = submission === undefined ? record.receipt : { ...record.receipt, dispatchKey: submission.commandId };
|
|
1181
|
+
if (submission !== undefined) {
|
|
1182
|
+
const payload = await submittedPayload(tx, receipt, true);
|
|
1183
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonicalCommandJson(payload)));
|
|
1184
|
+
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1185
|
+
if (fingerprint !== record.inputFingerprint)
|
|
1186
|
+
throw new CommandError("COMMAND_IDEMPOTENCY_CONFLICT");
|
|
1187
|
+
}
|
|
1188
|
+
const result = receipt.status === "confirmed" ? canonicalCommandJson(receipt.result) : null;
|
|
1189
|
+
await tx.query(`INSERT INTO supacloud_commands.execution_receipts
|
|
1190
|
+
(tenant_id,actor_id,command,operation_key,dispatch_key,kind,input_fingerprint,input_payload,status,audit_state,result)
|
|
1191
|
+
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]);
|
|
1192
|
+
if (submission !== undefined) {
|
|
1193
|
+
const attempt = { stepId: submission.stepId, messageId: submission.messageId, attempt: submission.attempt, workerId: submission.workerId };
|
|
1194
|
+
const commandId = submission.commandId;
|
|
1195
|
+
if (record.kind === "external") {
|
|
1196
|
+
await tx.query("SELECT public.supacloud_workflow_advance($1::text::jsonb)", [JSON.stringify({
|
|
1197
|
+
...attempt,
|
|
1198
|
+
output: { commandId, status: "intent-recorded" },
|
|
1199
|
+
nextStepKey: "reconcile",
|
|
1200
|
+
nextInput: { commandId, tenantId: receipt.tenantId, actorId: receipt.actorId, command: receipt.command, operationId: receipt.operationId },
|
|
1201
|
+
nextMaxAttempts: 20
|
|
1202
|
+
})]);
|
|
1203
|
+
} else {
|
|
1204
|
+
const output = { commandId, status: "confirmed", audit: "complete" };
|
|
1205
|
+
await tx.query("SELECT public.supacloud_workflow_complete($1::text::jsonb)", [JSON.stringify({ ...attempt, stepOutput: output, runOutput: output })]);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
async confirm(ref, result) {
|
|
1210
|
+
await tx.query(`UPDATE supacloud_commands.execution_receipts SET status='confirmed',result=$5::text::jsonb,updated_at=now()
|
|
1211
|
+
WHERE ${where} AND status<>'confirmed'`, [...keys2(ref), canonicalCommandJson(result)]);
|
|
1212
|
+
},
|
|
1213
|
+
async markUnknown(ref) {
|
|
1214
|
+
await tx.query(`UPDATE supacloud_commands.execution_receipts SET status='unknown',updated_at=now()
|
|
1215
|
+
WHERE ${where} AND status<>'confirmed'`, keys2(ref));
|
|
1216
|
+
},
|
|
1217
|
+
async audit(ref, event, details) {
|
|
1218
|
+
await tx.query(`INSERT INTO supacloud_commands.execution_audit
|
|
1219
|
+
(tenant_id,actor_id,command,operation_key,event,details) VALUES ($1,$2,$3,$4,$5,$6::text::jsonb)`, [...keys2(ref), commandIdentifier(event), canonicalCommandJson(details)]);
|
|
1220
|
+
},
|
|
1221
|
+
async completeAudit(ref) {
|
|
1222
|
+
await tx.query(`UPDATE supacloud_commands.execution_receipts SET audit_state='complete',updated_at=now() WHERE ${where}`, keys2(ref));
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
const scopeKeys = (scope) => {
|
|
1226
|
+
if (scope.commands.length === 0)
|
|
1227
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1228
|
+
return [commandIdentifier(scope.tenantId), JSON.stringify(scope.commands.map(commandIdentifier))];
|
|
1229
|
+
};
|
|
1230
|
+
const integer = (value, min, max = Number.MAX_SAFE_INTEGER) => {
|
|
1231
|
+
if (!Number.isSafeInteger(value) || value < min || value > max)
|
|
1232
|
+
throw new CommandError("COMMAND_INPUT_INVALID");
|
|
1233
|
+
return value;
|
|
1234
|
+
};
|
|
1235
|
+
const selected = `tenant_id=$1 AND command IN (SELECT jsonb_array_elements_text($2::text::jsonb))`;
|
|
1236
|
+
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`;
|
|
1237
|
+
return {
|
|
1238
|
+
transaction: (run) => transaction((tx) => run(session(tx))),
|
|
1239
|
+
async redactCompleted(options) {
|
|
1240
|
+
return transaction(async (tx) => rows(await tx.query(`WITH candidates AS (
|
|
1241
|
+
SELECT tenant_id,actor_id,command,operation_key FROM supacloud_commands.execution_receipts
|
|
1242
|
+
WHERE ${selected} AND status='confirmed' AND audit_state='complete' AND input_payload IS NOT NULL
|
|
1243
|
+
AND updated_at<to_timestamp($3::double precision/1000)
|
|
1244
|
+
ORDER BY updated_at LIMIT $4 FOR UPDATE SKIP LOCKED
|
|
1245
|
+
) UPDATE supacloud_commands.execution_receipts r SET input_payload=NULL FROM candidates c
|
|
1246
|
+
WHERE ${join} RETURNING r.operation_key`, [...scopeKeys(options), integer(options.before, 0), integer(options.limit, 1, 1000)])).length);
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
882
1250
|
export {
|
|
1251
|
+
COMMAND_PERSISTENCE_SQL,
|
|
1252
|
+
COMMAND_PERSISTENCE_UPGRADE_SQL,
|
|
883
1253
|
DatabaseAccessError,
|
|
1254
|
+
QueryBoundaryError,
|
|
884
1255
|
RpcContractError,
|
|
885
1256
|
applyModulePlan,
|
|
886
1257
|
buildDatabaseManifest,
|
|
887
1258
|
createDatabaseAccessBoundary,
|
|
1259
|
+
createPostgresCommandStore,
|
|
888
1260
|
createRpcClient,
|
|
1261
|
+
decodePostgrestQuery,
|
|
889
1262
|
defineDatabaseModule,
|
|
1263
|
+
defineReadQuery,
|
|
890
1264
|
defineRpcContract,
|
|
891
1265
|
explainObject,
|
|
892
1266
|
extractSearchPath,
|
|
@@ -40,9 +40,6 @@ export declare function renderMigrationBindings(options: {
|
|
|
40
40
|
sql: string;
|
|
41
41
|
}[];
|
|
42
42
|
attestation: {
|
|
43
|
-
environment: string;
|
|
44
|
-
projectRef: string;
|
|
45
|
-
schema: 'supacloud.migration-binding-attestation.v1';
|
|
46
43
|
manifestSha256: string;
|
|
47
44
|
files: {
|
|
48
45
|
file: string;
|
|
@@ -53,5 +50,8 @@ export declare function renderMigrationBindings(options: {
|
|
|
53
50
|
type: MigrationBindingType;
|
|
54
51
|
}>;
|
|
55
52
|
}[];
|
|
53
|
+
environment: string;
|
|
54
|
+
projectRef: string;
|
|
55
|
+
schema: "supacloud.migration-binding-attestation.v1";
|
|
56
56
|
};
|
|
57
57
|
};
|
package/dist/query.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface QueryEvent {
|
|
2
|
+
readonly operation: string;
|
|
3
|
+
readonly fingerprint?: string;
|
|
4
|
+
readonly phase: "started" | "succeeded" | "failed";
|
|
5
|
+
readonly durationMs?: number;
|
|
6
|
+
readonly slow?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type QueryObserver = (event: Readonly<QueryEvent>) => void | Promise<void>;
|
|
9
|
+
export interface ReadQueryDefinition<Input, Result, Identity> {
|
|
10
|
+
name: string;
|
|
11
|
+
/** Parser-derived SQL fingerprint or a generated operation hash; never raw SQL. */
|
|
12
|
+
fingerprint?: string;
|
|
13
|
+
input(value: unknown): Input;
|
|
14
|
+
result(value: unknown): Result;
|
|
15
|
+
authorize(identity: Identity, input: NoInfer<Input>): "allow" | "deny" | Promise<"allow" | "deny">;
|
|
16
|
+
execute(input: NoInfer<Input>, identity: Identity, signal?: AbortSignal): Promise<unknown>;
|
|
17
|
+
observe?: QueryObserver;
|
|
18
|
+
slowMs?: number;
|
|
19
|
+
policy?: {
|
|
20
|
+
execute<Value>(run: (signal: AbortSignal) => Promise<Value>, parent?: AbortSignal): Promise<Value>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare class QueryBoundaryError extends Error {
|
|
24
|
+
readonly code: "QUERY_INPUT_INVALID" | "QUERY_REJECTED" | "QUERY_AUTHORIZATION_FAILED" | "QUERY_RESULT_INVALID" | "QUERY_ABORTED" | "QUERY_TRANSPORT_FAILED";
|
|
25
|
+
constructor(code: "QUERY_INPUT_INVALID" | "QUERY_REJECTED" | "QUERY_AUTHORIZATION_FAILED" | "QUERY_RESULT_INVALID" | "QUERY_ABORTED" | "QUERY_TRANSPORT_FAILED");
|
|
26
|
+
}
|
|
27
|
+
/** Explicit composition shared by SQL, pg_graphql and PostgREST. No DI, caching or SQL rewriting. */
|
|
28
|
+
export declare function defineReadQuery<Input, Result, Identity>(definition: ReadQueryDefinition<Input, Result, Identity>): Readonly<{
|
|
29
|
+
execute: (identity: Identity, input: Input, signal?: AbortSignal) => Promise<Result>;
|
|
30
|
+
executeUnknown: (identity: Identity, input: unknown, signal?: AbortSignal) => Promise<Result>;
|
|
31
|
+
}>;
|
|
32
|
+
/** Preserve the official client's selected/embedded result type; validate before returning it. */
|
|
33
|
+
export declare function decodePostgrestQuery<Query extends PromiseLike<{
|
|
34
|
+
data: unknown;
|
|
35
|
+
error: unknown;
|
|
36
|
+
}>>(query: Query, decode: (value: unknown) => NoInfer<Awaited<Query>["data"]>): Promise<Awaited<Query>["data"]>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface SqlAnalysis {
|
|
2
|
+
fingerprint: string;
|
|
3
|
+
defines: string[];
|
|
4
|
+
touches: string[];
|
|
5
|
+
references: string[];
|
|
6
|
+
review: string[];
|
|
7
|
+
}
|
|
8
|
+
/** PostgreSQL 18 grammar, not regex-based SQL rewriting. Names group overloads conservatively. */
|
|
9
|
+
export declare function analyzeSql(sql: string): Promise<SqlAnalysis>;
|
|
10
|
+
/** Read-only admission check; PostgreSQL READ ONLY and least-privilege grants remain mandatory. */
|
|
11
|
+
export declare function assertReadSql(sql: string): Promise<string>;
|
|
12
|
+
export interface SqlMigrationSource {
|
|
13
|
+
id: string;
|
|
14
|
+
owner: string;
|
|
15
|
+
sql: string;
|
|
16
|
+
/** Object IDs from a reviewed catalog graph; never silently inferred for dynamic SQL. */
|
|
17
|
+
dependencies?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
export interface SqlDependencyEdge {
|
|
20
|
+
dependent: string;
|
|
21
|
+
dependency: string;
|
|
22
|
+
}
|
|
23
|
+
export interface SqlCatalogDependencies {
|
|
24
|
+
edges: SqlDependencyEdge[];
|
|
25
|
+
review: string[];
|
|
26
|
+
}
|
|
27
|
+
/** Collapse columns/overloads to their owning object for conservative impact propagation. */
|
|
28
|
+
export declare function readSqlDependencyGraph(executor: {
|
|
29
|
+
query(sql: string, parameters: unknown[]): Promise<unknown>;
|
|
30
|
+
}, schemas: readonly string[]): Promise<SqlCatalogDependencies>;
|
|
31
|
+
export interface SqlMigrationSnapshot {
|
|
32
|
+
id: string;
|
|
33
|
+
owner: string;
|
|
34
|
+
sha256: string;
|
|
35
|
+
analysis: SqlAnalysis;
|
|
36
|
+
}
|
|
37
|
+
export interface SqlImpactPlan {
|
|
38
|
+
migrations: SqlMigrationSnapshot[];
|
|
39
|
+
edges: SqlDependencyEdge[];
|
|
40
|
+
changed: string[];
|
|
41
|
+
affectedObjects: string[];
|
|
42
|
+
affectedMigrations: string[];
|
|
43
|
+
errors: string[];
|
|
44
|
+
review: string[];
|
|
45
|
+
}
|
|
46
|
+
/** Offline, append-only migration review. Does not execute SQL or approve a deployment. */
|
|
47
|
+
export declare function planSqlImpact(sources: readonly SqlMigrationSource[], baseline?: readonly Pick<SqlMigrationSnapshot, "id" | "sha256">[], catalogEdges?: readonly SqlDependencyEdge[]): Promise<SqlImpactPlan>;
|