@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/README.md
CHANGED
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
# @supacloud/db
|
|
2
2
|
|
|
3
|
+
## Drizzle, Read Queries and SQL Impact
|
|
4
|
+
|
|
5
|
+
Use `defineDrizzleDatabaseModule` and `createDrizzleCommandDatabase` from
|
|
6
|
+
`@supacloud/db/drizzle`, or `createBunDrizzleCommandDatabase` from
|
|
7
|
+
`@supacloud/db/drizzle-bun`. Authorization, business writes, receipts and audit
|
|
8
|
+
share one transaction connection. Do not use the outer pool for command writes
|
|
9
|
+
or retain `transaction.db` after the transaction. The adapter currently targets
|
|
10
|
+
`drizzle-orm@1.0.0-rc.4`, not a stable 1.0 release.
|
|
11
|
+
|
|
12
|
+
`executeDecodedSql` keeps SQL results unknown until an explicit decoder validates
|
|
13
|
+
them. Use parameterized templates; `sql<T>` alone does not validate returned data.
|
|
14
|
+
The optional `@supacloud/commands/typebox` contract can use schemas derived through
|
|
15
|
+
`drizzle-orm/typebox-legacy`, selecting public fields rather than accepting
|
|
16
|
+
internal tenant/permission fields.
|
|
17
|
+
|
|
18
|
+
`defineReadQuery` combines input decoding, authorization, result decoding and
|
|
19
|
+
payload-free observation. `createBunReadDatabase` admits one parsed SELECT and
|
|
20
|
+
runs a PostgreSQL READ ONLY transaction with a statement timeout.
|
|
21
|
+
`createBunDrizzleReadDatabase` provides the read transaction boundary for Drizzle.
|
|
22
|
+
Keep least-privilege roles and RLS; AOP does not replace them.
|
|
23
|
+
|
|
24
|
+
`supacloud-sql-impact impact.json --check` analyzes ordered, owned migrations:
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"migrations": [
|
|
29
|
+
{ "id": "001", "owner": "orders", "path": "migrations/001.sql" }
|
|
30
|
+
],
|
|
31
|
+
"baseline": []
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Paths are relative to this config. Reports include fingerprints, dependencies,
|
|
36
|
+
transitive impact, review reasons and a digest. Persist applied IDs and SHA-256
|
|
37
|
+
hashes as `baseline`; deleted, reordered or rewritten history fails validation.
|
|
38
|
+
Supply `readSqlDependencyGraph(executor, ["app"])` results as `catalogEdges` and
|
|
39
|
+
`catalogReview`. Import this catalog reader from `@supacloud/db/sql-analysis`.
|
|
40
|
+
|
|
41
|
+
Dynamic SQL, opaque functions, unqualified names and unsupported semantics require
|
|
42
|
+
review. This is conservative analysis, not proof of arbitrary SQL safety. Approve
|
|
43
|
+
the exact reviewed report via `approvedDigest`; changes invalidate approval, and
|
|
44
|
+
approval never overrides history errors. The tool does not execute migrations.
|
|
45
|
+
|
|
46
|
+
The focused `tests/drizzle-foundation.test.ts` test needs Docker and starts a
|
|
47
|
+
disposable local PostgreSQL/PGMQ instance. Prepare Commands and Compiler package
|
|
48
|
+
dependencies first using `scripts/build-command-dependencies.ts db`.
|
|
49
|
+
|
|
50
|
+
## Durable Commands
|
|
51
|
+
|
|
52
|
+
`createPostgresCommandStore(database)` implements the protocol's storage ports.
|
|
53
|
+
Use `createBunCommandDatabase` from `@supacloud/db/bun` for a native Bun SQL pool.
|
|
54
|
+
This package contains transaction/receipt SQL, Workflow binding, row validation
|
|
55
|
+
and input redaction. It does not own a recovery polling queue.
|
|
56
|
+
|
|
57
|
+
Execution factories moved to `@supacloud/commands`; pass `store` instead of
|
|
58
|
+
`database`. Errors moved to `CommandError` in `@supacloud/contracts`. Neither
|
|
59
|
+
remote sending nor command orchestration is re-exported here. The DB package
|
|
60
|
+
does not depend on the command runtime in production. The Drizzle integration
|
|
61
|
+
test uses the command runtime to verify the shared transaction boundary.
|
|
62
|
+
|
|
63
|
+
Install the existing PGMQ/Workflow runtime and updated commands-public SQL module,
|
|
64
|
+
then `COMMAND_PERSISTENCE_SQL` through a privileged application migration.
|
|
65
|
+
Existing unreleased v1 tables require `COMMAND_PERSISTENCE_UPGRADE_SQL`, with old
|
|
66
|
+
writers stopped; preserve operation identifiers during cutover.
|
|
67
|
+
The upgrade enqueues pending recovery using existing Workflow and removes prototype
|
|
68
|
+
lease/backoff columns. Use `createPostgresCommandStore(database, { submission })`
|
|
69
|
+
inside a submitted command's execute step to preserve its command ID and atomically
|
|
70
|
+
advance to reconciliation (external) or complete its Workflow (transactional).
|
|
71
|
+
`submission` carries commandId, stepId, messageId, attempt and workerId. The adapter
|
|
72
|
+
checks the current attempt, recorded tenant/actor/command, and original input.
|
|
73
|
+
Keep its schema private and supply domain authorization, JSON-stable input/result
|
|
74
|
+
decoders and a single-connection transaction adapter. Metadata cannot provide a
|
|
75
|
+
distributed transaction. See [migration, deployment and recovery](../../docs/command-migration.md).
|
|
76
|
+
|
|
3
77
|
For reviewed deployment-time public SQL parameters, see
|
|
4
78
|
[Controlled Migration Bindings](./MIGRATION_BINDINGS.md). This opt-in API preserves
|
|
5
79
|
Drizzle v1 source/snapshot ownership and the existing executor's migration ledger.
|
|
@@ -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<Transaction extends CommandTransaction = CommandTransaction> {
|
|
6
|
+
transaction<T>(run: (transaction: Transaction) => 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<Transaction extends CommandTransaction>(database: CommandDatabase<Transaction>, options?: {
|
|
17
|
+
submission?: CommandSubmissionBinding;
|
|
18
|
+
}): CommandStore<Transaction> & CommandRetentionStore;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { CommandDatabase } from "./command-adapter";
|
|
3
|
+
/** The SQL pool owns one connection from BEGIN through COMMIT/ROLLBACK. */
|
|
4
|
+
export declare function createBunCommandDatabase(sql: SQL): CommandDatabase;
|
|
5
|
+
/** The driver owns a read-only transaction and enforces its statement timeout. */
|
|
6
|
+
export declare function createBunReadDatabase(pool: SQL, timeoutMs?: number): Readonly<{
|
|
7
|
+
query(text: string, parameters?: readonly (string | number | boolean | null)[], signal?: AbortSignal): Promise<unknown>;
|
|
8
|
+
}>;
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
var __esm = (fn, res, err) => () => {
|
|
2
|
+
if (fn)
|
|
3
|
+
try {
|
|
4
|
+
res = fn(fn = 0);
|
|
5
|
+
} catch (e) {
|
|
6
|
+
err = [e];
|
|
7
|
+
}
|
|
8
|
+
if (err)
|
|
9
|
+
throw err[0];
|
|
10
|
+
return res;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/sql-analysis.ts
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { fingerprint, parse } from "libpg-query";
|
|
16
|
+
function record(value) {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function walk(value, visit) {
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
for (const item of value)
|
|
22
|
+
walk(item, visit);
|
|
23
|
+
} else if (record(value)) {
|
|
24
|
+
visit(value);
|
|
25
|
+
for (const item of Object.values(value))
|
|
26
|
+
walk(item, visit);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function names(value) {
|
|
30
|
+
if (!Array.isArray(value))
|
|
31
|
+
return [];
|
|
32
|
+
return value.flatMap((part) => record(part) && record(part["String"]) && typeof part["String"]["sval"] === "string" ? [part["String"]["sval"]] : []);
|
|
33
|
+
}
|
|
34
|
+
async function analyzeSql(sql) {
|
|
35
|
+
const result = await parse(sql);
|
|
36
|
+
const defines = new Set, touches = new Set, references = new Set, review = new Set;
|
|
37
|
+
const qualified = (kind, parts) => {
|
|
38
|
+
if (parts.length !== 2) {
|
|
39
|
+
review.add(`Resolve unqualified or unsupported ${kind} name: ${parts.join(".")}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
return objectId(kind, parts);
|
|
43
|
+
};
|
|
44
|
+
const relation = (value) => {
|
|
45
|
+
if (!record(value) || typeof value["relname"] !== "string")
|
|
46
|
+
return;
|
|
47
|
+
return qualified("relation", typeof value["schemaname"] === "string" ? [value["schemaname"], value["relname"]] : [value["relname"]]);
|
|
48
|
+
};
|
|
49
|
+
walk(result, (node) => {
|
|
50
|
+
if (record(node["RangeVar"])) {
|
|
51
|
+
const name = relation(node["RangeVar"]);
|
|
52
|
+
if (name)
|
|
53
|
+
references.add(name);
|
|
54
|
+
}
|
|
55
|
+
if (record(node["FuncCall"])) {
|
|
56
|
+
const name = qualified("function", names(node["FuncCall"]["funcname"]));
|
|
57
|
+
if (name)
|
|
58
|
+
references.add(name);
|
|
59
|
+
}
|
|
60
|
+
if (record(node["Constraint"])) {
|
|
61
|
+
const name = relation(node["Constraint"]["pktable"]);
|
|
62
|
+
if (name)
|
|
63
|
+
references.add(name);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const statements = record(result) && Array.isArray(result["stmts"]) ? result["stmts"] : [];
|
|
67
|
+
for (const raw of statements) {
|
|
68
|
+
if (!record(raw) || !record(raw["stmt"]))
|
|
69
|
+
throw new Error("Invalid PostgreSQL parse tree");
|
|
70
|
+
const [kind, node] = Object.entries(raw["stmt"])[0] ?? [];
|
|
71
|
+
if (!kind || !record(node))
|
|
72
|
+
throw new Error("Invalid SQL statement");
|
|
73
|
+
const target = relation(node["relation"] ?? node["view"] ?? node["table"]);
|
|
74
|
+
if (target) {
|
|
75
|
+
touches.add(target);
|
|
76
|
+
if (kind === "CreateStmt" || kind === "ViewStmt")
|
|
77
|
+
defines.add(target);
|
|
78
|
+
}
|
|
79
|
+
if (kind === "DropStmt") {
|
|
80
|
+
for (const item of Array.isArray(node["objects"]) ? node["objects"] : []) {
|
|
81
|
+
const name = record(item) && record(item["List"]) ? qualified(node["removeType"] === "OBJECT_TABLE" || node["removeType"] === "OBJECT_VIEW" ? "relation" : "object", names(item["List"]["items"])) : undefined;
|
|
82
|
+
if (name)
|
|
83
|
+
touches.add(name);
|
|
84
|
+
else
|
|
85
|
+
review.add("Review dropped object identity and dependencies");
|
|
86
|
+
}
|
|
87
|
+
review.add("Destructive DROP requires review");
|
|
88
|
+
} else if (kind === "CreateFunctionStmt") {
|
|
89
|
+
const name = qualified("function", names(node["funcname"]));
|
|
90
|
+
if (name) {
|
|
91
|
+
defines.add(name);
|
|
92
|
+
touches.add(name);
|
|
93
|
+
}
|
|
94
|
+
let language, body;
|
|
95
|
+
for (const item of Array.isArray(node["options"]) ? node["options"] : []) {
|
|
96
|
+
if (!record(item) || !record(item["DefElem"]))
|
|
97
|
+
continue;
|
|
98
|
+
const option = item["DefElem"];
|
|
99
|
+
if (option["defname"] === "language")
|
|
100
|
+
language = names([option["arg"]])[0];
|
|
101
|
+
if (option["defname"] === "as" && record(option["arg"]) && record(option["arg"]["List"]))
|
|
102
|
+
body = names(option["arg"]["List"]["items"])[0];
|
|
103
|
+
}
|
|
104
|
+
if (language === "sql" && body !== undefined) {
|
|
105
|
+
const nested = await analyzeSql(body);
|
|
106
|
+
for (const name of [...nested.references, ...nested.touches])
|
|
107
|
+
references.add(name);
|
|
108
|
+
for (const item of nested.review)
|
|
109
|
+
review.add(item);
|
|
110
|
+
} else {
|
|
111
|
+
review.add("Function body dependencies require catalog verification or explicit reviewed dependencies");
|
|
112
|
+
}
|
|
113
|
+
review.add("Review function security, grants and overloads");
|
|
114
|
+
} else if (![
|
|
115
|
+
"SelectStmt",
|
|
116
|
+
"CreateStmt",
|
|
117
|
+
"ViewStmt",
|
|
118
|
+
"AlterTableStmt",
|
|
119
|
+
"IndexStmt",
|
|
120
|
+
"InsertStmt",
|
|
121
|
+
"UpdateStmt",
|
|
122
|
+
"DeleteStmt",
|
|
123
|
+
"CreatePolicyStmt",
|
|
124
|
+
"AlterPolicyStmt",
|
|
125
|
+
"CreateTrigStmt"
|
|
126
|
+
].includes(kind)) {
|
|
127
|
+
review.add(`Review unsupported dependency semantics: ${kind}`);
|
|
128
|
+
}
|
|
129
|
+
if (["AlterTableStmt", "CreatePolicyStmt", "AlterPolicyStmt", "CreateTrigStmt"].includes(kind)) {
|
|
130
|
+
review.add(`Review schema/access impact: ${kind}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
fingerprint: await fingerprint(sql),
|
|
135
|
+
defines: [...defines].sort(),
|
|
136
|
+
touches: [...touches].sort(),
|
|
137
|
+
references: [...references].sort(),
|
|
138
|
+
review: [...review].sort()
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function assertReadSql(sql) {
|
|
142
|
+
const tree = await parse(sql);
|
|
143
|
+
if (!record(tree) || !Array.isArray(tree["stmts"]) || tree["stmts"].length !== 1)
|
|
144
|
+
throw new Error("Expected one read statement");
|
|
145
|
+
const entry = tree["stmts"][0];
|
|
146
|
+
if (!record(entry) || !record(entry["stmt"]) || !record(entry["stmt"]["SelectStmt"]))
|
|
147
|
+
throw new Error("Expected SELECT");
|
|
148
|
+
walk(tree, (node) => {
|
|
149
|
+
if (["InsertStmt", "UpdateStmt", "DeleteStmt", "MergeStmt", "IntoClause", "intoClause", "LockingClause"].some((name) => (name in node))) {
|
|
150
|
+
throw new Error("Mutating or locking SQL is not a read query");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
return fingerprint(sql);
|
|
154
|
+
}
|
|
155
|
+
async function readSqlDependencyGraph(executor, schemas) {
|
|
156
|
+
if (!schemas.length || schemas.some((schema) => !schema.trim()))
|
|
157
|
+
throw new TypeError("Select explicit application schemas");
|
|
158
|
+
const schemaParameters = schemas.map((_, index) => `$${index + 1}::text`).join(", ");
|
|
159
|
+
const rows = await executor.query(`
|
|
160
|
+
WITH objects AS (
|
|
161
|
+
SELECT 'pg_class'::regclass::oid AS classid, c.oid AS objid, n.nspname,
|
|
162
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text AS identity
|
|
163
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
164
|
+
UNION ALL
|
|
165
|
+
SELECT 'pg_proc'::regclass::oid, p.oid, n.nspname,
|
|
166
|
+
'function:' || to_json(n.nspname)::text || '.' || to_json(p.proname)::text
|
|
167
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
|
|
168
|
+
UNION ALL
|
|
169
|
+
SELECT 'pg_rewrite'::regclass::oid, r.oid, n.nspname,
|
|
170
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
|
|
171
|
+
FROM pg_rewrite r JOIN pg_class c ON c.oid=r.ev_class JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
172
|
+
UNION ALL
|
|
173
|
+
SELECT 'pg_policy'::regclass::oid, p.oid, n.nspname,
|
|
174
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
|
|
175
|
+
FROM pg_policy p JOIN pg_class c ON c.oid=p.polrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
176
|
+
UNION ALL
|
|
177
|
+
SELECT 'pg_trigger'::regclass::oid, t.oid, n.nspname,
|
|
178
|
+
'relation:' || to_json(n.nspname)::text || '.' || to_json(c.relname)::text
|
|
179
|
+
FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid JOIN pg_namespace n ON n.oid=c.relnamespace
|
|
180
|
+
)
|
|
181
|
+
SELECT DISTINCT a.identity AS dependent, b.identity AS dependency
|
|
182
|
+
FROM pg_depend d
|
|
183
|
+
JOIN objects a ON a.classid=d.classid AND a.objid=d.objid
|
|
184
|
+
JOIN objects b ON b.classid=d.refclassid AND b.objid=d.refobjid
|
|
185
|
+
WHERE a.nspname IN (${schemaParameters})
|
|
186
|
+
AND b.nspname IN (${schemaParameters}) AND a.identity<>b.identity
|
|
187
|
+
ORDER BY dependent, dependency`, [...schemas]);
|
|
188
|
+
if (!Array.isArray(rows))
|
|
189
|
+
throw new TypeError("Invalid PostgreSQL dependency rows");
|
|
190
|
+
const edges = rows.map((row) => {
|
|
191
|
+
if (!record(row) || typeof row["dependent"] !== "string" || typeof row["dependency"] !== "string")
|
|
192
|
+
throw new TypeError("Invalid PostgreSQL dependency edge");
|
|
193
|
+
return { dependent: row["dependent"], dependency: row["dependency"] };
|
|
194
|
+
});
|
|
195
|
+
const functions = await executor.query(`
|
|
196
|
+
SELECT DISTINCT n.nspname AS schema, p.proname AS name
|
|
197
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace
|
|
198
|
+
JOIN pg_language l ON l.oid=p.prolang
|
|
199
|
+
WHERE n.nspname IN (${schemaParameters})
|
|
200
|
+
AND (l.lanname='plpgsql' OR (l.lanname='sql' AND p.prosqlbody IS NULL))
|
|
201
|
+
ORDER BY schema, name`, [...schemas]);
|
|
202
|
+
if (!Array.isArray(functions))
|
|
203
|
+
throw new TypeError("Invalid PostgreSQL function rows");
|
|
204
|
+
return {
|
|
205
|
+
edges,
|
|
206
|
+
review: functions.map((row) => {
|
|
207
|
+
if (!record(row) || typeof row["schema"] !== "string" || typeof row["name"] !== "string")
|
|
208
|
+
throw new TypeError("Invalid function identity");
|
|
209
|
+
return `Catalog cannot prove function-body dependencies: ${objectId("function", [row["schema"], row["name"]])}`;
|
|
210
|
+
})
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
async function planSqlImpact(sources, baseline = [], catalogEdges = []) {
|
|
214
|
+
const ids = new Set;
|
|
215
|
+
const migrations = [];
|
|
216
|
+
const edges = new Map;
|
|
217
|
+
const addEdge = (dependent, dependency) => {
|
|
218
|
+
if (dependent !== dependency)
|
|
219
|
+
edges.set(JSON.stringify([dependent, dependency]), { dependent, dependency });
|
|
220
|
+
};
|
|
221
|
+
for (const edge of catalogEdges)
|
|
222
|
+
addEdge(edge["dependent"], edge["dependency"]);
|
|
223
|
+
for (const source of sources) {
|
|
224
|
+
if (!source.id.trim() || !source.owner.trim() || ids.has(source.id))
|
|
225
|
+
throw new Error("Migration IDs must be unique and each migration must have an owner");
|
|
226
|
+
ids.add(source.id);
|
|
227
|
+
const analysis = await analyzeSql(source.sql);
|
|
228
|
+
const references = [...new Set([...analysis.references, ...source.dependencies ?? []])].sort();
|
|
229
|
+
for (const target of analysis.touches)
|
|
230
|
+
for (const reference of references)
|
|
231
|
+
addEdge(target, reference);
|
|
232
|
+
migrations.push({
|
|
233
|
+
id: source.id,
|
|
234
|
+
owner: source.owner,
|
|
235
|
+
sha256: createHash("sha256").update(source.sql).digest("hex"),
|
|
236
|
+
analysis: { ...analysis, references }
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
const current = new Map(migrations.map((entry) => [entry.id, entry]));
|
|
240
|
+
const previous = new Map(baseline.map((entry) => [entry.id, entry]));
|
|
241
|
+
const errors = [];
|
|
242
|
+
if (previous.size !== baseline.length)
|
|
243
|
+
errors.push("Duplicate migration in baseline");
|
|
244
|
+
for (const [index, old] of baseline.entries()) {
|
|
245
|
+
if (current.get(old.id)?.sha256 !== old.sha256 || migrations[index]?.id !== old.id) {
|
|
246
|
+
errors.push(`Applied migration removed, reordered or rewritten: ${old.id}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const changed = migrations.filter((entry) => previous.get(entry.id)?.sha256 !== entry.sha256);
|
|
250
|
+
const affected = new Set(changed.flatMap((entry) => entry.analysis.touches));
|
|
251
|
+
const reverse = new Map;
|
|
252
|
+
for (const edge of edges.values()) {
|
|
253
|
+
const values = reverse.get(edge["dependency"]) ?? [];
|
|
254
|
+
values.push(edge["dependent"]);
|
|
255
|
+
reverse.set(edge["dependency"], values);
|
|
256
|
+
}
|
|
257
|
+
for (const object of affected)
|
|
258
|
+
for (const dependent of reverse.get(object) ?? [])
|
|
259
|
+
affected.add(dependent);
|
|
260
|
+
const definitions = new Map;
|
|
261
|
+
migrations.forEach((entry, index) => {
|
|
262
|
+
for (const object of entry.analysis.defines)
|
|
263
|
+
if (!definitions.has(object))
|
|
264
|
+
definitions.set(object, index);
|
|
265
|
+
});
|
|
266
|
+
migrations.forEach((entry, index) => {
|
|
267
|
+
for (const reference of entry.analysis.references) {
|
|
268
|
+
if ((definitions.get(reference) ?? -1) > index)
|
|
269
|
+
errors.push(`${entry.id} references an object defined by a later migration: ${reference}`);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
return {
|
|
273
|
+
migrations,
|
|
274
|
+
edges: [...edges.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
275
|
+
changed: changed.map((entry) => entry.id),
|
|
276
|
+
affectedObjects: [...affected].sort(),
|
|
277
|
+
affectedMigrations: migrations.filter((entry) => [...entry.analysis.touches, ...entry.analysis.references].some((object) => affected.has(object))).map((entry) => entry.id),
|
|
278
|
+
errors,
|
|
279
|
+
review: changed.flatMap((entry) => entry.analysis.review.map((note) => `${entry.id}: ${note}`))
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
var objectId = (kind, parts) => `${kind}:${parts.map((part) => JSON.stringify(part)).join(".")}`;
|
|
283
|
+
var init_sql_analysis = () => {};
|
|
284
|
+
|
|
285
|
+
// src/command-bun.ts
|
|
286
|
+
function createBunCommandDatabase(sql) {
|
|
287
|
+
return {
|
|
288
|
+
async transaction(run) {
|
|
289
|
+
const result = await sql.begin(async (connection) => ({
|
|
290
|
+
value: await run({
|
|
291
|
+
query: async (text, parameters = []) => {
|
|
292
|
+
const rows = await connection.unsafe(text, [...parameters]);
|
|
293
|
+
return rows;
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
}));
|
|
297
|
+
return result.value;
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function createBunReadDatabase(pool, timeoutMs = 5000) {
|
|
302
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
|
|
303
|
+
throw new TypeError("Invalid read statement timeout");
|
|
304
|
+
}
|
|
305
|
+
const admitted = new Map;
|
|
306
|
+
return Object.freeze({
|
|
307
|
+
async query(text, parameters = [], signal) {
|
|
308
|
+
if (signal?.aborted)
|
|
309
|
+
throw new Error("Read aborted");
|
|
310
|
+
await Promise.resolve().then(() => init_sql_analysis());
|
|
311
|
+
let admission = admitted.get(text);
|
|
312
|
+
if (!admission) {
|
|
313
|
+
admission = assertReadSql(text);
|
|
314
|
+
if (admitted.size >= 256) {
|
|
315
|
+
const oldest = admitted.keys().next().value;
|
|
316
|
+
if (oldest !== undefined)
|
|
317
|
+
admitted.delete(oldest);
|
|
318
|
+
}
|
|
319
|
+
admitted.set(text, admission);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
await admission;
|
|
323
|
+
} catch (error) {
|
|
324
|
+
admitted.delete(text);
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
return pool.begin(async (connection) => {
|
|
328
|
+
await connection.unsafe("SET TRANSACTION READ ONLY");
|
|
329
|
+
await connection`SELECT set_config('statement_timeout', ${String(timeoutMs)}, true)`;
|
|
330
|
+
if (signal?.aborted)
|
|
331
|
+
throw new Error("Read aborted");
|
|
332
|
+
const pending = connection.unsafe(text, [...parameters]);
|
|
333
|
+
const abort = () => pending.cancel();
|
|
334
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
335
|
+
try {
|
|
336
|
+
const rows = await pending;
|
|
337
|
+
if (signal?.aborted)
|
|
338
|
+
throw new Error("Read aborted");
|
|
339
|
+
return rows;
|
|
340
|
+
} finally {
|
|
341
|
+
signal?.removeEventListener("abort", abort);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
export {
|
|
348
|
+
createBunCommandDatabase,
|
|
349
|
+
createBunReadDatabase
|
|
350
|
+
};
|
|
@@ -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";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { type BunSQLDatabase } from "drizzle-orm/bun-sql/postgres";
|
|
3
|
+
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations";
|
|
4
|
+
import type { DrizzlePgConfig } from "drizzle-orm/pg-core";
|
|
5
|
+
/** Drizzle is bound to Bun's pinned transaction client, not the application pool. */
|
|
6
|
+
export declare function createBunDrizzleCommandDatabase<Relations extends AnyRelations = EmptyRelations>(pool: SQL, config?: DrizzlePgConfig<Relations>): import("./command-adapter").CommandDatabase<import("./drizzle").DrizzleCommandTransaction<BunSQLDatabase<Relations>>>;
|
|
7
|
+
/** Explicit read-only unit of work; use a least-privilege pool with the intended RLS identity. */
|
|
8
|
+
export declare function createBunDrizzleReadDatabase<Relations extends AnyRelations = EmptyRelations>(pool: SQL, config?: DrizzlePgConfig<Relations>, timeoutMs?: number): Readonly<{
|
|
9
|
+
read<Result>(work: (db: BunSQLDatabase<Relations>) => Promise<Result>): Promise<Result>;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/drizzle-bun.ts
|
|
2
|
+
import { drizzle } from "drizzle-orm/bun-sql/postgres";
|
|
3
|
+
|
|
4
|
+
// src/drizzle.ts
|
|
5
|
+
import { is } from "drizzle-orm";
|
|
6
|
+
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
|
7
|
+
|
|
8
|
+
// src/module.ts
|
|
9
|
+
function normalizeTable(ref) {
|
|
10
|
+
if (typeof ref === "string")
|
|
11
|
+
return ref;
|
|
12
|
+
const schema = ref._.schema ?? "public";
|
|
13
|
+
return `${schema}.${ref._.name}`;
|
|
14
|
+
}
|
|
15
|
+
function defineDatabaseModule(options) {
|
|
16
|
+
return {
|
|
17
|
+
name: options.name,
|
|
18
|
+
tables: (options.tables ?? []).map(normalizeTable),
|
|
19
|
+
policies: options.policies ?? [],
|
|
20
|
+
functions: options.functions ?? [],
|
|
21
|
+
triggers: options.triggers ?? [],
|
|
22
|
+
grants: options.grants ?? []
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/drizzle.ts
|
|
27
|
+
function defineDrizzleDatabaseModule(tables, options) {
|
|
28
|
+
const names = new Set;
|
|
29
|
+
for (const table of Object.values(tables)) {
|
|
30
|
+
if (!is(table, PgTable))
|
|
31
|
+
throw new TypeError("Expected a PostgreSQL Drizzle table");
|
|
32
|
+
const config = getTableConfig(table);
|
|
33
|
+
names.add(`${config.schema ?? "public"}.${config.name}`);
|
|
34
|
+
}
|
|
35
|
+
return defineDatabaseModule({ ...options, tables: [...names].sort() });
|
|
36
|
+
}
|
|
37
|
+
function createDrizzleCommandDatabase(options) {
|
|
38
|
+
return {
|
|
39
|
+
transaction: (work) => options.transaction(async (connection) => {
|
|
40
|
+
const db = options.bind(connection);
|
|
41
|
+
let active = true;
|
|
42
|
+
try {
|
|
43
|
+
return await work({
|
|
44
|
+
db,
|
|
45
|
+
query: async (text, parameters = []) => {
|
|
46
|
+
if (!active)
|
|
47
|
+
throw new Error("Command transaction is closed");
|
|
48
|
+
return options.query(connection, text, parameters);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} finally {
|
|
52
|
+
active = false;
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function executeDecodedSql(executor, query, decode) {
|
|
58
|
+
return decode(await executor.execute(query));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/drizzle-bun.ts
|
|
62
|
+
function createBunDrizzleCommandDatabase(pool, config = {}) {
|
|
63
|
+
return createDrizzleCommandDatabase({
|
|
64
|
+
transaction: async (work) => {
|
|
65
|
+
const result = await pool.begin(async (connection) => ({ value: await work(connection) }));
|
|
66
|
+
return result.value;
|
|
67
|
+
},
|
|
68
|
+
bind: (connection) => drizzle({ ...config, client: connection }),
|
|
69
|
+
query: async (connection, text, parameters) => {
|
|
70
|
+
const rows = await connection.unsafe(text, [...parameters]);
|
|
71
|
+
return rows;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function createBunDrizzleReadDatabase(pool, config = {}, timeoutMs = 5000) {
|
|
76
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647) {
|
|
77
|
+
throw new TypeError("Invalid read statement timeout");
|
|
78
|
+
}
|
|
79
|
+
return Object.freeze({
|
|
80
|
+
async read(work) {
|
|
81
|
+
const result = await pool.begin(async (connection) => {
|
|
82
|
+
await connection.unsafe("SET TRANSACTION READ ONLY");
|
|
83
|
+
await connection`SELECT set_config('statement_timeout', ${String(timeoutMs)}, true)`;
|
|
84
|
+
return { value: await work(drizzle({ ...config, client: connection })) };
|
|
85
|
+
});
|
|
86
|
+
return result.value;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export {
|
|
91
|
+
createBunDrizzleCommandDatabase,
|
|
92
|
+
createBunDrizzleReadDatabase
|
|
93
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type SQL } from "drizzle-orm";
|
|
2
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import type { DatabaseModule, DatabaseModuleOptions } from "./module.js";
|
|
4
|
+
import type { CommandDatabase, CommandTransaction } from "./command-adapter";
|
|
5
|
+
/** Accept explicit table exports, not arbitrary objects or private type-only metadata. */
|
|
6
|
+
export declare function defineDrizzleDatabaseModule(tables: Readonly<Record<string, PgTable>>, options: Omit<DatabaseModuleOptions, "tables">): DatabaseModule;
|
|
7
|
+
export interface DrizzleCommandTransaction<Database> extends CommandTransaction {
|
|
8
|
+
readonly db: Database;
|
|
9
|
+
}
|
|
10
|
+
export interface DrizzleCommandDatabaseOptions<Connection, Database> {
|
|
11
|
+
/** Owns BEGIN/COMMIT/ROLLBACK and pins one connection for the callback. */
|
|
12
|
+
transaction<T>(work: (connection: Connection) => Promise<T>): Promise<T>;
|
|
13
|
+
/** Construct Drizzle on this connection, never on the outer connection pool. */
|
|
14
|
+
bind(connection: NoInfer<Connection>): Database;
|
|
15
|
+
/** Execute parameterized protocol SQL on the same connection; return rows only. */
|
|
16
|
+
query(connection: NoInfer<Connection>, text: string, parameters: readonly (string | number | boolean | null)[]): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* One native transaction serves Drizzle business writes and SQL receipts.
|
|
20
|
+
* No SQL placeholder rewriting, hidden second transaction or driver internals.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createDrizzleCommandDatabase<Connection, Database>(options: DrizzleCommandDatabaseOptions<Connection, Database>): CommandDatabase<DrizzleCommandTransaction<Database>>;
|
|
23
|
+
/** Raw SQL results stay unknown until an application-owned decoder validates them. */
|
|
24
|
+
export declare function executeDecodedSql<Result>(executor: {
|
|
25
|
+
execute(query: SQL): PromiseLike<unknown>;
|
|
26
|
+
}, query: SQL<unknown>, decode: (value: unknown) => Result): Promise<Result>;
|