@fabrika/db 0.0.0-stage → 0.0.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.
@@ -0,0 +1,413 @@
1
+ import { SQL } from 'bun'
2
+ import { CompiledQuery, createQueryId, type Dialect, Kysely, PostgresQueryCompiler, type RootOperationNode, type TransactionBuilder } from 'kysely'
3
+ import { PostgresJSDialect } from 'kysely-postgres-js'
4
+ import { databaseError, QueryRefusedError } from './errors'
5
+ import { assertAllowedTree, type GuardContext } from './guard'
6
+ import { isPlainIdentifier } from './identifier'
7
+
8
+ type LooseDB = Record<string, Record<string, unknown>>
9
+ type Executor = Pick<Kysely<LooseDB>, 'executeQuery'>
10
+
11
+ /**
12
+ * With `prepare: false`, Bun.SQL sends every parameter as text and a Date as `Date#toString()`, which Postgres
13
+ * refuses as a timestamp. Every guarded tree compiles here, so every Date parameter of the layer is bound as ISO text.
14
+ */
15
+ class ParameterCompiler extends PostgresQueryCompiler {
16
+ protected override addParameter(parameter: unknown): void {
17
+ super.addParameter(parameter instanceof Date ? timestampText(parameter) : parameter)
18
+ }
19
+ }
20
+
21
+ function timestampText(date: Date): string {
22
+ if (Number.isNaN(date.getTime())) {
23
+ throw new QueryRefusedError('invalid_value', 'an invalid Date cannot be bound')
24
+ }
25
+ return date.toISOString()
26
+ }
27
+
28
+ const compiler = new ParameterCompiler()
29
+ freezePrototypeChain(compiler)
30
+
31
+ /**
32
+ * The compiler turns the guarded tree into SQL, so a patch to it after the guard ran would bypass the guard.
33
+ * Freezing it when this module loads, before application modules, makes such a patch throw instead.
34
+ */
35
+ function freezePrototypeChain(object: object): void {
36
+ let current: object | null = object
37
+ while (current !== null && current !== Object.prototype) {
38
+ Object.freeze(current)
39
+ current = Object.getPrototypeOf(current)
40
+ }
41
+ }
42
+
43
+ /** IAM's application id alphabet; `-` maps to `_`, which the alphabet lacks, so two ids never share a schema. */
44
+ const APP_ID = /^[a-z0-9][a-z0-9-]*$/
45
+ /** Postgres truncates identifiers to 63 bytes; `app_` takes four of them. */
46
+ const MAX_APP_ID_LENGTH = 59
47
+
48
+ /**
49
+ * `app_<id>`, the schema an application's migrations create (ADR-0017). `@fabrika/platform-node`'s
50
+ * `applicationDatabaseSchema` derives the same name for the migration runner; the two must agree.
51
+ */
52
+ export function applicationSchema(appId: string): string {
53
+ if (!APP_ID.test(appId) || appId.length > MAX_APP_ID_LENGTH) {
54
+ throw new QueryRefusedError('invalid_name', 'the application id must be lowercase letters, digits and -, at most 59 characters')
55
+ }
56
+ return `app_${appId.replaceAll('-', '_')}`
57
+ }
58
+
59
+ export interface PostgresOptions {
60
+ readonly url: string
61
+ /** The application's schema (`app_<id>`), set as the connection's `search_path`. */
62
+ readonly schema: string
63
+ readonly max?: number
64
+ }
65
+
66
+ let openSession: (database: Database, readOnly: boolean) => Session
67
+ let transactionBuilder: (database: Database) => TransactionBuilder<LooseDB>
68
+
69
+ /** The connection the data layer runs on. It has no public query method: everything goes through a store. */
70
+ export class Database {
71
+ readonly #kysely: Kysely<LooseDB>
72
+ readonly #schema: string | undefined
73
+
74
+ private constructor(kysely: Kysely<LooseDB>, schema: string | undefined) {
75
+ this.#kysely = kysely
76
+ this.#schema = schema
77
+ Object.freeze(this)
78
+ }
79
+
80
+ static {
81
+ openSession = (database, readOnly) => new Session(database.#kysely, database.#kysely, readOnly)
82
+ transactionBuilder = (database) => database.#kysely.transaction()
83
+ }
84
+
85
+ /**
86
+ * Over `Bun.SQL` with `prepare: false`: Bun otherwise keeps one prepared statement per distinct SQL text per
87
+ * connection, and `in` lists of different lengths make that grow without bound.
88
+ */
89
+ static postgres(options: PostgresOptions): Database {
90
+ if (!isPlainIdentifier(options.schema)) {
91
+ throw new QueryRefusedError('invalid_name', 'the schema must match ^[a-z_][a-z0-9_]*$')
92
+ }
93
+ const pool = new SQL({ url: options.url, adapter: 'postgres', prepare: false, max: options.max ?? 10, connection: { search_path: options.schema } })
94
+ return new Database(new Kysely<LooseDB>({ dialect: new PostgresJSDialect({ postgres: pool }) }), options.schema)
95
+ }
96
+
97
+ /** Any Kysely Postgres dialect; tests use one that records statements instead of sending them. */
98
+ static fromDialect(dialect: Dialect): Database {
99
+ return new Database(new Kysely<LooseDB>({ dialect }), undefined)
100
+ }
101
+
102
+ /** The application's schema, the connection's `search_path`; `undefined` over a bare dialect. */
103
+ get schema(): string | undefined {
104
+ return this.#schema
105
+ }
106
+
107
+ async close(): Promise<void> {
108
+ await this.#kysely.destroy()
109
+ }
110
+ }
111
+ // Application modules load after this one: a replaced `postgres` would receive the database URL.
112
+ Object.freeze(Database)
113
+ Object.freeze(Database.prototype)
114
+
115
+ /** The connection a session runs on, and whether the request running now may only read. */
116
+ export interface SessionSource {
117
+ readonly database: Database
118
+ readonly readOnly?: () => boolean
119
+ }
120
+
121
+ /**
122
+ * The one way to a session: every statement of a request — a store's, a locator's, a framework table's — asks the
123
+ * runtime whether the request is read-only. A read-only session refuses every write and lock before it reaches the
124
+ * database, and reads in `READ ONLY` transactions.
125
+ */
126
+ export function sessionOf(source: SessionSource): Session {
127
+ return openSession(source.database, source.readOnly?.() === true)
128
+ }
129
+
130
+ let executeFixed: (session: Session, compiled: CompiledQuery) => Promise<readonly unknown[]>
131
+
132
+ /** What a session reports to, and asks, the transaction it runs in. */
133
+ interface SessionHooks {
134
+ /** Hears of every statement the database refused: in a transaction, Postgres aborts the rest of it. */
135
+ readonly failed?: () => void
136
+ /** Whether the transaction is still the work's: a session kept past it runs nothing on a connection given back. */
137
+ readonly open?: () => boolean
138
+ }
139
+
140
+ /** `work`, or the abort of `signal`, whichever comes first; a `work` left behind still has its rejection handled. */
141
+ function untilAborted<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
142
+ if (signal === undefined) {
143
+ return work
144
+ }
145
+ work.then(undefined, () => undefined)
146
+ const aborted = new Promise<never>((_resolve, reject) => {
147
+ if (signal.aborted) {
148
+ reject(signal.reason)
149
+ }
150
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true })
151
+ })
152
+ return Promise.race([work, aborted])
153
+ }
154
+
155
+ /**
156
+ * Runs guarded operation trees; the only path from the layer to the connection. A driver failure leaves it as a
157
+ * `DatabaseError`, which carries the SQLSTATE and nothing the driver said about the row.
158
+ */
159
+ export class Session {
160
+ readonly #executor: Executor
161
+ readonly #kysely: Kysely<LooseDB> | undefined
162
+ readonly #readOnly: boolean
163
+ readonly #hooks: SessionHooks
164
+
165
+ constructor(executor: Executor, kysely: Kysely<LooseDB> | undefined, readOnly: boolean, hooks: SessionHooks = {}) {
166
+ this.#executor = executor
167
+ this.#kysely = kysely
168
+ this.#readOnly = readOnly
169
+ this.#hooks = hooks
170
+ Object.freeze(this)
171
+ }
172
+
173
+ static {
174
+ executeFixed = async (session, compiled) => (await session.#execute(compiled)).rows
175
+ }
176
+
177
+ get readOnly(): boolean {
178
+ return this.#readOnly
179
+ }
180
+
181
+ /** Fails the transaction this session runs in, so it commits nothing: a statement refused before it was sent. */
182
+ fail(): void {
183
+ this.#hooks.failed?.()
184
+ }
185
+
186
+ async #execute<O>(compiled: CompiledQuery<O>): Promise<{ readonly rows: O[] }> {
187
+ if (this.#hooks.open?.() === false) {
188
+ throw new QueryRefusedError('ended', 'the transaction of this session has ended; it runs nothing more')
189
+ }
190
+ try {
191
+ return await this.#executor.executeQuery(compiled)
192
+ } catch (error) {
193
+ this.#hooks.failed?.()
194
+ throw databaseError(error)
195
+ }
196
+ }
197
+
198
+ async run<O>(node: RootOperationNode, context: GuardContext): Promise<O[]> {
199
+ assertAllowedTree(node, context)
200
+ if (this.#readOnly && (context.root !== 'SelectQueryNode' || context.lock !== undefined)) {
201
+ throw new QueryRefusedError('read_only', 'this request is read-only: it reads, and no operation may write or lock a row')
202
+ }
203
+ // CompiledQuery's row type is a phantom: this is the one place where generated row types meet the wire.
204
+ const compiled: CompiledQuery<O> = compiler.compileQuery(node, createQueryId())
205
+ const kysely = this.#kysely
206
+ if (this.#readOnly && kysely !== undefined) {
207
+ try {
208
+ const result = await kysely.transaction().setAccessMode('read only').execute((transaction) => transaction.executeQuery(compiled))
209
+ return result.rows
210
+ } catch (error) {
211
+ throw databaseError(error)
212
+ }
213
+ }
214
+ const result = await this.#execute(compiled)
215
+ return result.rows
216
+ }
217
+
218
+ /**
219
+ * Runs `work` in one transaction (`READ ONLY` in a read-only session); inside one, it runs in the transaction already
220
+ * open. When `signal` aborts, the transaction rolls back and its connection goes back to the pool at once, whether
221
+ * `work` returned or not; its session then runs nothing more.
222
+ */
223
+ async transaction<T>(work: (session: Session) => Promise<T>, signal?: AbortSignal): Promise<T> {
224
+ if (this.#kysely === undefined) {
225
+ return work(this)
226
+ }
227
+ const builder = this.#kysely.transaction()
228
+ let open = true
229
+ let failed = false
230
+ let failure: { readonly error: unknown } | undefined
231
+ try {
232
+ return await (this.#readOnly ? builder.setAccessMode('read only') : builder).execute(async (transaction) => {
233
+ try {
234
+ const hooks: SessionHooks = {
235
+ open: () => open,
236
+ failed: () => {
237
+ failed = true
238
+ },
239
+ }
240
+ const result = await untilAborted(work(new Session(transaction, undefined, this.#readOnly, hooks)), signal)
241
+ // A failure the work caught: Postgres would answer the COMMIT of an aborted transaction with a silent ROLLBACK.
242
+ if (failed) {
243
+ throw new QueryRefusedError('statement_failed', 'a statement of this transaction failed, so nothing it wrote commits')
244
+ }
245
+ return result
246
+ } catch (error) {
247
+ failure = { error }
248
+ throw error
249
+ } finally {
250
+ open = false
251
+ }
252
+ })
253
+ } catch (error) {
254
+ // The work's own error passes through; only BEGIN, COMMIT and ROLLBACK failures are the driver's.
255
+ throw failure !== undefined && failure.error === error ? error : databaseError(error)
256
+ }
257
+ }
258
+ }
259
+ Object.freeze(Session.prototype)
260
+
261
+ /** How long an invocation's transaction may sit idle before Postgres ends it, and its connection with it. */
262
+ export const INVOCATION_IDLE_TIMEOUT_MS = 30_000
263
+
264
+ const SQL_IDLE_TIMEOUT = "select set_config('idle_in_transaction_session_timeout', $1, true)"
265
+
266
+ /** What the invocation's transaction body throws to roll back; never seen outside this module. */
267
+ class RolledBack extends Error {}
268
+
269
+ /**
270
+ * The one transaction of an invocation (a request, a webhook delivery): opened by its first statement, then ended
271
+ * once by the framework — committed after the handler returned and its output passed validation, rolled back
272
+ * otherwise. Every handle of the invocation runs in `session()`. Ending it and giving its connection back are two
273
+ * states: once ended no statement runs in it, and the connection is back in the pool whether the COMMIT succeeded,
274
+ * failed, or the connection died.
275
+ */
276
+ export class InvocationTransaction {
277
+ readonly #database: Database
278
+ readonly #readOnly: boolean
279
+ readonly #decision = Promise.withResolvers<'commit' | 'rollback'>()
280
+ #opened: Promise<Session> | undefined
281
+ #run: Promise<void> | undefined
282
+ #released: Promise<void> = Promise.resolve()
283
+ #isReleased = true
284
+ #ended = false
285
+ #failed = false
286
+
287
+ constructor(database: Database, readOnly: boolean) {
288
+ this.#database = database
289
+ this.#readOnly = readOnly
290
+ }
291
+
292
+ /** A `READ ONLY` transaction: a query's or an export's. */
293
+ get readOnly(): boolean {
294
+ return this.#readOnly
295
+ }
296
+
297
+ /** Whether the transaction was opened: an invocation that reached no data commits nothing. */
298
+ get opened(): boolean {
299
+ return this.#opened !== undefined
300
+ }
301
+
302
+ /** Marks it failed, so it commits nothing: a statement refused before it was sent, even when the handler caught it. */
303
+ fail(): void {
304
+ this.#failed = true
305
+ }
306
+
307
+ /** No statement runs in it any more. */
308
+ get ended(): boolean {
309
+ return this.#ended
310
+ }
311
+
312
+ /** Its connection is back in the pool (or it never took one). */
313
+ get released(): boolean {
314
+ return this.#isReleased
315
+ }
316
+
317
+ /** The session the invocation's statements run in; the first call opens the transaction. */
318
+ session(): Promise<Session> {
319
+ if (this.#ended) {
320
+ return Promise.reject(new QueryRefusedError('ended', 'this invocation has ended; its data handles run only while its handler runs'))
321
+ }
322
+ this.#opened ??= this.#open()
323
+ return this.#opened
324
+ }
325
+
326
+ #open(): Promise<Session> {
327
+ const ready = Promise.withResolvers<Session>()
328
+ this.#isReleased = false
329
+ const builder = transactionBuilder(this.#database)
330
+ const run = (this.#readOnly ? builder.setAccessMode('read only') : builder).execute(async (transaction) => {
331
+ const session = new Session(transaction, undefined, this.#readOnly, {
332
+ failed: () => {
333
+ this.#failed = true
334
+ },
335
+ open: () => !this.#ended,
336
+ })
337
+ await executeFixed(session, CompiledQuery.raw(SQL_IDLE_TIMEOUT, [String(INVOCATION_IDLE_TIMEOUT_MS)]))
338
+ ready.resolve(session)
339
+ if ((await this.#decision.promise) === 'rollback') {
340
+ throw new RolledBack()
341
+ }
342
+ })
343
+ this.#run = run
344
+ this.#released = run.then(() => undefined, () => undefined).finally(() => {
345
+ this.#isReleased = true
346
+ })
347
+ run.catch((error: unknown) => ready.reject(databaseError(error)))
348
+ return ready.promise
349
+ }
350
+
351
+ /**
352
+ * Commits what the invocation wrote. A statement the database refused aborted the transaction, so it rolls back
353
+ * and throws instead: Postgres would answer the COMMIT with a silent ROLLBACK. A failed COMMIT throws a
354
+ * `DatabaseError`, after the connection is back in the pool.
355
+ */
356
+ async commit(): Promise<void> {
357
+ if (this.#ended) {
358
+ throw new QueryRefusedError('ended', 'this invocation has already ended')
359
+ }
360
+ this.#ended = true
361
+ const run = this.#run
362
+ if (this.#failed) {
363
+ this.#decision.resolve('rollback')
364
+ await this.#released
365
+ throw new QueryRefusedError('statement_failed', 'a statement of this invocation failed, so nothing it wrote commits')
366
+ }
367
+ if (run === undefined) {
368
+ return
369
+ }
370
+ this.#decision.resolve('commit')
371
+ try {
372
+ await run
373
+ } catch (error) {
374
+ throw databaseError(error)
375
+ } finally {
376
+ await this.#released
377
+ }
378
+ }
379
+
380
+ /** Discards what the invocation wrote; idempotent, never throws, and returns once the connection is back. */
381
+ async rollback(): Promise<void> {
382
+ this.#ended = true
383
+ this.#decision.resolve('rollback')
384
+ await this.#released
385
+ }
386
+ }
387
+ Object.freeze(InvocationTransaction.prototype)
388
+
389
+ /**
390
+ * A scalar parameter of a framework statement; Bun.SQL mis-binds arrays and JSON strings. No Date: these texts skip
391
+ * the compiler that binds one, and a lease time is the database's clock, never the process's.
392
+ */
393
+ export type FixedParameter = string | number | null
394
+
395
+ /**
396
+ * One of the framework's own fixed statements (`job-leases.ts`): a constant text with scalar parameters. They need
397
+ * what the guard refuses on purpose (`now()`, `set_config`, `pg_backend_pid()`, `on conflict`), so they bypass it;
398
+ * no subpath exports this, and no text is ever built from a caller's value.
399
+ */
400
+ export function runFixedStatement(session: Session, text: string, parameters: readonly FixedParameter[]): Promise<readonly unknown[]> {
401
+ return executeFixed(session, CompiledQuery.raw(text, [...parameters]))
402
+ }
403
+
404
+ /** A bound parameter of an application's own statement (`sql.ts`), after its value was encoded for the driver. */
405
+ export type SqlParameter = string | number | boolean | bigint | null
406
+
407
+ /**
408
+ * One `ctx.sql` statement, whose text `sql.ts` classified and whose values are bound parameters only. It runs through the
409
+ * session's one path to the connection, so a statement the database refuses fails the invocation it runs in.
410
+ */
411
+ export function runApplicationStatement(session: Session, text: string, parameters: readonly SqlParameter[]): Promise<readonly unknown[]> {
412
+ return executeFixed(session, CompiledQuery.raw(text, [...parameters]))
413
+ }