@ultimat3/db 1.0.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/src/sql.ts ADDED
@@ -0,0 +1,152 @@
1
+ // Single responsibility: build parameterised SQL. String interpolation is how every SQL
2
+ // injection ships, and an agent writing SQL cannot be trusted to remember the difference
3
+ // between a value and a fragment — so the tag binds scalars to `$1..$n` and refuses anything
4
+ // else outright. `raw()` is the one audited escape hatch, and it is visible in review.
5
+
6
+ import { identifierUnsafe, sqlUnsafe } from './errors';
7
+
8
+ export interface SqlFragment {
9
+ readonly text: string;
10
+ readonly values: readonly unknown[];
11
+ }
12
+
13
+ /** Only fragments carrying this brand may be spliced into another fragment. */
14
+ const SQL_BRAND: unique symbol = Symbol.for('ultimate.db.sql');
15
+
16
+ interface Compiled extends SqlFragment {
17
+ readonly [SQL_BRAND]: 'sql' | 'raw';
18
+ /**
19
+ * `chunks.length === values.length + 1`. Keeping the pre-split parts means nesting
20
+ * renumbers parameters by rebuilding, never by re-parsing `$n` out of `.text` — which
21
+ * would corrupt a `raw()` fragment that legitimately contains a `$` token.
22
+ */
23
+ readonly chunks: readonly string[];
24
+ }
25
+
26
+ export function isSqlFragment(value: unknown): value is SqlFragment {
27
+ return typeof value === 'object' && value !== null && SQL_BRAND in value;
28
+ }
29
+
30
+ const SCALAR_TYPES = new Set(['string', 'number', 'boolean', 'bigint', 'undefined']);
31
+
32
+ /** What Postgres can accept as a bound parameter. Everything else is a programming error. */
33
+ function isBoundValue(value: unknown): boolean {
34
+ if (value === null) return true;
35
+ if (SCALAR_TYPES.has(typeof value)) return true;
36
+ if (value instanceof Date) return true;
37
+ if (value instanceof Uint8Array) return true;
38
+ if (Array.isArray(value)) return value.every(isBoundValue);
39
+ return false;
40
+ }
41
+
42
+ function describe(value: unknown): string {
43
+ if (value === null) return 'null';
44
+ if (Array.isArray(value)) return 'an array containing a non-scalar';
45
+ if (typeof value === 'object' && 'text' in (value as Record<string, unknown>)) {
46
+ return 'an object shaped like a SqlFragment but not produced by sql`` or raw()';
47
+ }
48
+ return `a ${typeof value}`;
49
+ }
50
+
51
+ function render(chunks: readonly string[]): string {
52
+ let text = chunks[0] ?? '';
53
+ for (let index = 1; index < chunks.length; index += 1) {
54
+ text += `$${index}${chunks[index] ?? ''}`;
55
+ }
56
+ return text;
57
+ }
58
+
59
+ function compile(chunks: readonly string[], values: readonly unknown[]): Compiled {
60
+ return {
61
+ [SQL_BRAND]: 'sql',
62
+ chunks,
63
+ values,
64
+ text: render(chunks),
65
+ };
66
+ }
67
+
68
+ /** Accumulates chunks/values so appending a fragment is a splice, not a string rewrite. */
69
+ class Builder {
70
+ private readonly chunks: string[] = [''];
71
+ private readonly values: unknown[] = [];
72
+
73
+ text(part: string): void {
74
+ this.chunks[this.chunks.length - 1] = `${this.chunks[this.chunks.length - 1] ?? ''}${part}`;
75
+ }
76
+
77
+ value(value: unknown): void {
78
+ this.values.push(value === undefined ? null : value);
79
+ this.chunks.push('');
80
+ }
81
+
82
+ fragment(nested: Compiled): void {
83
+ this.text(nested.chunks[0] ?? '');
84
+ for (let index = 1; index < nested.chunks.length; index += 1) {
85
+ this.value(nested.values[index - 1]);
86
+ this.text(nested.chunks[index] ?? '');
87
+ }
88
+ }
89
+
90
+ done(): Compiled {
91
+ return compile([...this.chunks], [...this.values]);
92
+ }
93
+ }
94
+
95
+ export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]): SqlFragment {
96
+ const builder = new Builder();
97
+ builder.text(strings[0] ?? '');
98
+ for (let index = 0; index < values.length; index += 1) {
99
+ const value = values[index];
100
+ if (isSqlFragment(value)) builder.fragment(value as Compiled);
101
+ else if (isBoundValue(value)) builder.value(value);
102
+ else throw sqlUnsafe(describe(value), index + 1);
103
+ builder.text(strings[index + 1] ?? '');
104
+ }
105
+ return builder.done();
106
+ }
107
+
108
+ /**
109
+ * Mark a string as trusted SQL. Every call is an audit point: the argument must never be
110
+ * derived from a request, a row, or an LLM completion.
111
+ */
112
+ export function raw(trusted: string): SqlFragment {
113
+ const fragment: Compiled = {
114
+ [SQL_BRAND]: 'raw',
115
+ chunks: [trusted],
116
+ values: [],
117
+ text: trusted,
118
+ };
119
+ return fragment;
120
+ }
121
+
122
+ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
123
+
124
+ /**
125
+ * A quoted identifier — the safe answer to the `raw()` temptation. Table and column names
126
+ * cannot be bound as parameters, so they are validated and double-quoted instead.
127
+ */
128
+ export function identifier(name: string): SqlFragment {
129
+ if (SAFE_IDENTIFIER.test(name)) return raw(`"${name}"`);
130
+ if (name.length === 0 || /[\s"\\]/.test(name)) throw identifierUnsafe(name);
131
+ return raw(`"${name}"`);
132
+ }
133
+
134
+ /**
135
+ * A quoted string literal. Postgres utility statements (`CREATE DATABASE`, `COMMENT ON`) reject
136
+ * bound parameters, so this is the only place a value may be inlined — and it escapes quotes.
137
+ * Never reach for it in a query: `sql` binds parameters there.
138
+ */
139
+ export function literal(value: string): SqlFragment {
140
+ return raw(`'${value.replaceAll("'", "''")}'`);
141
+ }
142
+
143
+ /** `a, b, c` — the one blessed way to build an IN list or a column list. */
144
+ export function join(fragments: readonly SqlFragment[], separator = ', '): SqlFragment {
145
+ const builder = new Builder();
146
+ fragments.forEach((fragment, index) => {
147
+ if (!isSqlFragment(fragment)) throw sqlUnsafe(describe(fragment), index + 1);
148
+ if (index > 0) builder.text(separator);
149
+ builder.fragment(fragment as Compiled);
150
+ });
151
+ return builder.done();
152
+ }
@@ -0,0 +1,124 @@
1
+ // Single responsibility: transaction scope. The open `DbTx` rides an AsyncLocalStorage rather
2
+ // than a parameter so `ctx.jobs.enqueue()` can write its outbox row on the caller's connection —
3
+ // the transactional outbox is only atomic because `currentTx()` finds this store. Nesting maps
4
+ // to SAVEPOINTs, so an inner failure never silently aborts the outer unit of work.
5
+
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
7
+ import { nanoid } from '@ultimat3/core';
8
+ import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
9
+ import { raw, type SqlFragment } from './sql';
10
+
11
+ export interface DbTx extends DbClient {
12
+ readonly id: string;
13
+ /** Fired in reverse registration order when this scope rolls back. Never on commit. */
14
+ onRollback(undo: () => void): void;
15
+ }
16
+
17
+ export type IsolationLevel = 'read committed' | 'repeatable read' | 'serializable';
18
+
19
+ export interface TransactionOptions {
20
+ readonly isolation?: IsolationLevel | undefined;
21
+ readonly readOnly?: boolean | undefined;
22
+ /** Only meaningful with `serializable` + `readOnly`; lets Postgres wait instead of retrying. */
23
+ readonly deferrable?: boolean | undefined;
24
+ /** Override the ambient pool — tests and `x db branch` run against a specific client. */
25
+ readonly client?: DbClient | undefined;
26
+ }
27
+
28
+ interface TxState {
29
+ readonly tx: DbTx;
30
+ readonly connection: DbClient;
31
+ readonly undos: (() => void)[];
32
+ /** Shared by reference across nesting levels so savepoint names never collide. */
33
+ readonly savepoints: { value: number };
34
+ }
35
+
36
+ const storage = new AsyncLocalStorage<TxState>();
37
+
38
+ /** The open transaction, or `undefined` outside one. `@ultimat3/jobs` calls this per enqueue. */
39
+ export function currentTx(): DbTx | undefined {
40
+ return storage.getStore()?.tx;
41
+ }
42
+
43
+ export function beginStatement(options: TransactionOptions): string {
44
+ const modes: string[] = [];
45
+ if (options.isolation !== undefined) {
46
+ modes.push(`ISOLATION LEVEL ${options.isolation.toUpperCase()}`);
47
+ }
48
+ if (options.readOnly === true) modes.push('READ ONLY');
49
+ if (options.deferrable === true) modes.push('DEFERRABLE');
50
+ return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;
51
+ }
52
+
53
+ function makeTx(id: string, connection: DbClient, undos: (() => void)[]): DbTx {
54
+ return {
55
+ id,
56
+ query: <T>(fragment: SqlFragment) => connection.query<T>(fragment),
57
+ one: <T>(fragment: SqlFragment) => connection.one<T>(fragment),
58
+ execute: (fragment: SqlFragment) => connection.execute(fragment),
59
+ onRollback: (undo: () => void) => {
60
+ undos.push(undo);
61
+ },
62
+ };
63
+ }
64
+
65
+ /** Undo hooks are best-effort: one throwing must not mask the error that caused the rollback. */
66
+ function runUndos(undos: readonly (() => void)[]): void {
67
+ for (let index = undos.length - 1; index >= 0; index -= 1) {
68
+ try {
69
+ undos[index]?.();
70
+ } catch {
71
+ // swallowed deliberately — see above
72
+ }
73
+ }
74
+ }
75
+
76
+ async function runNested<T>(outer: TxState, fn: (tx: DbTx) => Promise<T>): Promise<T> {
77
+ outer.savepoints.value += 1;
78
+ const name = `x_sp_${outer.savepoints.value}`;
79
+ const undos: (() => void)[] = [];
80
+ const tx = makeTx(`${outer.tx.id}/${name}`, outer.connection, undos);
81
+ await outer.connection.execute(raw(`SAVEPOINT ${name}`));
82
+ try {
83
+ const result = await storage.run({ ...outer, tx, undos }, () => fn(tx));
84
+ await outer.connection.execute(raw(`RELEASE SAVEPOINT ${name}`));
85
+ // The nested scope committed into an outer one that can still roll back, so its undos
86
+ // must survive: hand them to the parent rather than dropping them.
87
+ outer.undos.push(...undos);
88
+ return result;
89
+ } catch (error) {
90
+ await outer.connection.execute(raw(`ROLLBACK TO SAVEPOINT ${name}`));
91
+ runUndos(undos);
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ export async function withTransaction<T>(
97
+ fn: (tx: DbTx) => Promise<T>,
98
+ options: TransactionOptions = {},
99
+ ): Promise<T> {
100
+ const outer = storage.getStore();
101
+ if (outer !== undefined) return runNested(outer, fn);
102
+
103
+ const client = options.client ?? baseClient();
104
+ const reserved: DbConnection | undefined = isReservable(client)
105
+ ? await client.reserve()
106
+ : undefined;
107
+ const connection: DbClient = reserved ?? client;
108
+ const undos: (() => void)[] = [];
109
+ const tx = makeTx(`tx_${nanoid(12)}`, connection, undos);
110
+
111
+ await connection.execute(raw(beginStatement(options)));
112
+ try {
113
+ const state: TxState = { tx, connection, undos, savepoints: { value: 0 } };
114
+ const result = await storage.run(state, () => fn(tx));
115
+ await connection.execute(raw('COMMIT'));
116
+ return result;
117
+ } catch (error) {
118
+ await connection.execute(raw('ROLLBACK')).catch(() => undefined);
119
+ runUndos(undos);
120
+ throw error;
121
+ } finally {
122
+ reserved?.release();
123
+ }
124
+ }