@fabrika/db 0.8.2 → 0.9.0-cloudflare.2

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.
Files changed (71) hide show
  1. package/package.json +44 -12
  2. package/src/admissions.ts +78 -12
  3. package/src/application-rows.ts +108 -0
  4. package/src/capability.ts +5 -0
  5. package/src/codegen-sqlite.ts +154 -0
  6. package/src/codegen.ts +59 -9
  7. package/src/database.ts +208 -56
  8. package/src/errors.ts +1 -0
  9. package/src/file-columns.ts +4 -1
  10. package/src/framework-tables.ts +28 -8
  11. package/src/framework.ts +10 -2
  12. package/src/guard.ts +8 -0
  13. package/src/index.ts +9 -1
  14. package/src/job-leases.ts +157 -37
  15. package/src/links.ts +8 -1
  16. package/src/migrations-sqlite.ts +132 -0
  17. package/src/nodes.ts +45 -9
  18. package/src/orm.ts +25 -15
  19. package/src/policy.ts +8 -2
  20. package/src/postgres.ts +26 -0
  21. package/src/registry.ts +8 -2
  22. package/src/sql.ts +15 -7
  23. package/src/sqlite-bun.ts +122 -0
  24. package/src/sqlite-migrate.ts +242 -0
  25. package/src/sqlite.ts +224 -0
  26. package/src/store.ts +336 -62
  27. package/src/stored-files.ts +149 -46
  28. package/src/tables.ts +16 -0
  29. package/src/temporal.ts +115 -9
  30. package/src/testing-shared.ts +733 -0
  31. package/src/testing-sqlite.ts +513 -0
  32. package/src/testing.ts +170 -482
  33. package/types/src/admissions.d.ts +21 -0
  34. package/types/src/application-rows.d.ts +58 -0
  35. package/types/src/builtins.d.ts +42 -0
  36. package/types/src/capability.d.ts +119 -0
  37. package/types/src/codegen-sqlite.d.ts +6 -0
  38. package/types/src/codegen.d.ts +82 -0
  39. package/types/src/database.d.ts +179 -0
  40. package/types/src/description.d.ts +109 -0
  41. package/types/src/errors.d.ts +98 -0
  42. package/types/src/file-columns.d.ts +37 -0
  43. package/types/src/file-references.d.ts +27 -0
  44. package/types/src/framework-tables.d.ts +59 -0
  45. package/types/src/framework.d.ts +37 -0
  46. package/types/src/guard.d.ts +23 -0
  47. package/types/src/identifier.d.ts +4 -0
  48. package/types/src/index.d.ts +10 -0
  49. package/types/src/job-leases.d.ts +93 -0
  50. package/types/src/links.d.ts +13 -0
  51. package/types/src/migrations-sqlite.d.ts +4 -0
  52. package/types/src/migrations.d.ts +25 -0
  53. package/types/src/nodes.d.ts +63 -0
  54. package/types/src/orm-types.d.ts +638 -0
  55. package/types/src/orm.d.ts +77 -0
  56. package/types/src/policy.d.ts +354 -0
  57. package/types/src/postgres.d.ts +12 -0
  58. package/types/src/production.d.ts +5 -0
  59. package/types/src/registry.d.ts +40 -0
  60. package/types/src/sql.d.ts +59 -0
  61. package/types/src/sqlite-bun.d.ts +13 -0
  62. package/types/src/sqlite-migrate.d.ts +28 -0
  63. package/types/src/sqlite.d.ts +24 -0
  64. package/types/src/store.d.ts +345 -0
  65. package/types/src/stored-files.d.ts +98 -0
  66. package/types/src/tables.d.ts +21 -0
  67. package/types/src/temporal.d.ts +33 -0
  68. package/types/src/testing-shared.d.ts +201 -0
  69. package/types/src/testing-sqlite.d.ts +22 -0
  70. package/types/src/testing.d.ts +35 -0
  71. package/types/src/vocabulary-tables.d.ts +225 -0
package/src/database.ts CHANGED
@@ -1,12 +1,41 @@
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'
1
+ import { CompiledQuery, createQueryId, type Dialect, Kysely, PostgresQueryCompiler, type QueryCompiler, type RootOperationNode } from 'kysely'
4
2
  import { databaseError, QueryRefusedError } from './errors'
5
3
  import { assertAllowedTree, type GuardContext } from './guard'
6
4
  import { isPlainIdentifier } from './identifier'
7
5
 
8
- type LooseDB = Record<string, Record<string, unknown>>
9
- type Executor = Pick<Kysely<LooseDB>, 'executeQuery'>
6
+ export type LooseDB = Record<string, Record<string, unknown>>
7
+ /** What a statement runs on: the database's own executor, or the one of the transaction it runs in. */
8
+ export type Executor = Pick<Kysely<LooseDB>, 'executeQuery'>
9
+
10
+ /** The SQL a database speaks: Postgres on Bun, SQLite in a Cloudflare Durable Object (ADR-0065). */
11
+ export type SqlDialect = 'postgres' | 'sqlite'
12
+
13
+ export interface TransactionOptions {
14
+ /**
15
+ * A `READ ONLY` transaction on Postgres. SQLite opens none: each statement runs in a transaction of its own, which
16
+ * rolls back a statement that wrote. A read-only session refuses writes either way.
17
+ */
18
+ readonly readOnly: boolean
19
+ /** How long the transaction may sit idle before the database ends it; a database without the setting ignores it. */
20
+ readonly idleTimeoutMs?: number
21
+ }
22
+
23
+ /**
24
+ * Runs `body` in one transaction, on the executor it hands it: commits when `body` resolves, rolls back when it
25
+ * rejects, and rejects with `body`'s error. Its BEGIN, COMMIT and ROLLBACK failures reject with the driver's error.
26
+ */
27
+ export type TransactionRunner = <T>(options: TransactionOptions, body: (executor: Executor) => Promise<T>) => Promise<T>
28
+
29
+ /** What a `Database` runs on: the driver, the compiler of its dialect, and how it opens a transaction. */
30
+ export interface DatabaseEngine {
31
+ readonly dialect: SqlDialect
32
+ readonly kysely: Kysely<LooseDB>
33
+ /** Compiles every guarded tree; frozen, since a patch to it after the guard ran would bypass the guard. */
34
+ readonly compiler: QueryCompiler
35
+ readonly transaction: TransactionRunner
36
+ /** The application's schema, the connection's `search_path`; `undefined` where the database has none. */
37
+ readonly schema?: string
38
+ }
10
39
 
11
40
  /**
12
41
  * With `prepare: false`, Bun.SQL sends every parameter as text and a Date as `Date#toString()`, which Postgres
@@ -25,14 +54,14 @@ function timestampText(date: Date): string {
25
54
  return date.toISOString()
26
55
  }
27
56
 
28
- const compiler = new ParameterCompiler()
29
- freezePrototypeChain(compiler)
57
+ const postgresCompiler = new ParameterCompiler()
58
+ freezePrototypeChain(postgresCompiler)
30
59
 
31
60
  /**
32
61
  * The compiler turns the guarded tree into SQL, so a patch to it after the guard ran would bypass the guard.
33
62
  * Freezing it when this module loads, before application modules, makes such a patch throw instead.
34
63
  */
35
- function freezePrototypeChain(object: object): void {
64
+ export function freezePrototypeChain(object: object): void {
36
65
  let current: object | null = object
37
66
  while (current !== null && current !== Object.prototype) {
38
67
  Object.freeze(current)
@@ -56,63 +85,73 @@ export function applicationSchema(appId: string): string {
56
85
  return `app_${appId.replaceAll('-', '_')}`
57
86
  }
58
87
 
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
88
+ const SQL_IDLE_TIMEOUT = "select set_config('idle_in_transaction_session_timeout', $1, true)"
89
+
90
+ /** Postgres over Kysely's own transaction builder. */
91
+ function postgresTransaction(kysely: Kysely<LooseDB>): TransactionRunner {
92
+ return (options, body) => {
93
+ const builder = kysely.transaction()
94
+ return (options.readOnly ? builder.setAccessMode('read only') : builder).execute(async (transaction) => {
95
+ if (options.idleTimeoutMs !== undefined) {
96
+ await transaction.executeQuery(CompiledQuery.raw(SQL_IDLE_TIMEOUT, [String(options.idleTimeoutMs)]))
97
+ }
98
+ return body(transaction)
99
+ })
100
+ }
64
101
  }
65
102
 
66
103
  let openSession: (database: Database, readOnly: boolean) => Session
67
- let transactionBuilder: (database: Database) => TransactionBuilder<LooseDB>
104
+ let engineOf: (database: Database) => DatabaseEngine
68
105
  let firstConnection: (database: Database) => Promise<void>
69
106
 
70
107
  /** The connection the data layer runs on. It has no public query method: everything goes through a store. */
71
108
  export class Database {
72
- readonly #kysely: Kysely<LooseDB>
73
- readonly #schema: string | undefined
109
+ readonly #engine: DatabaseEngine
74
110
 
75
- private constructor(kysely: Kysely<LooseDB>, schema: string | undefined) {
76
- this.#kysely = kysely
77
- this.#schema = schema
111
+ private constructor(engine: DatabaseEngine) {
112
+ if (engine.schema !== undefined && !isPlainIdentifier(engine.schema)) {
113
+ throw new QueryRefusedError('invalid_name', 'the schema must match ^[a-z_][a-z0-9_]*$')
114
+ }
115
+ this.#engine = Object.freeze({ ...engine })
78
116
  Object.freeze(this)
79
117
  }
80
118
 
81
119
  static {
82
- openSession = (database, readOnly) => new Session(database.#kysely, database.#kysely, readOnly)
83
- transactionBuilder = (database) => database.#kysely.transaction()
120
+ openSession = (database, readOnly) => new Session(database.#engine.kysely, database.#engine, false, readOnly)
121
+ engineOf = (database) => database.#engine
84
122
  firstConnection = async (database) => {
85
- await database.#kysely.executeQuery(CompiledQuery.raw('SELECT 1'))
123
+ await database.#engine.kysely.executeQuery(CompiledQuery.raw('SELECT 1'))
86
124
  }
87
125
  }
88
126
 
89
127
  /**
90
- * Over `Bun.SQL` with `prepare: false`: Bun otherwise keeps one prepared statement per distinct SQL text per
91
- * connection, and `in` lists of different lengths make that grow without bound.
128
+ * Any Kysely Postgres dialect: `@fabrika/db/postgres` opens Bun.SQL through it, tests use one that records statements
129
+ * instead of sending them.
92
130
  */
93
- static postgres(options: PostgresOptions): Database {
94
- if (!isPlainIdentifier(options.schema)) {
95
- throw new QueryRefusedError('invalid_name', 'the schema must match ^[a-z_][a-z0-9_]*$')
96
- }
97
- const pool = new SQL({ url: options.url, adapter: 'postgres', prepare: false, max: options.max ?? 10, connection: { search_path: options.schema } })
98
- return new Database(new Kysely<LooseDB>({ dialect: new PostgresJSDialect({ postgres: pool }) }), options.schema)
131
+ static fromDialect(dialect: Dialect, options: { readonly schema?: string } = {}): Database {
132
+ const kysely = new Kysely<LooseDB>({ dialect })
133
+ return new Database({ dialect: 'postgres', kysely, compiler: postgresCompiler, transaction: postgresTransaction(kysely), schema: options.schema })
99
134
  }
100
135
 
101
- /** Any Kysely Postgres dialect; tests use one that records statements instead of sending them. */
102
- static fromDialect(dialect: Dialect): Database {
103
- return new Database(new Kysely<LooseDB>({ dialect }), undefined)
136
+ /** A database of another dialect (`@fabrika/db/sqlite`), with its own frozen compiler and transaction runner. */
137
+ static fromEngine(engine: DatabaseEngine): Database {
138
+ return new Database(engine)
104
139
  }
105
140
 
106
- /** The application's schema, the connection's `search_path`; `undefined` over a bare dialect. */
141
+ get dialect(): SqlDialect {
142
+ return this.#engine.dialect
143
+ }
144
+
145
+ /** The application's schema, the connection's `search_path`; `undefined` over a bare dialect and on SQLite. */
107
146
  get schema(): string | undefined {
108
- return this.#schema
147
+ return this.#engine.schema
109
148
  }
110
149
 
111
150
  async close(): Promise<void> {
112
- await this.#kysely.destroy()
151
+ await this.#engine.kysely.destroy()
113
152
  }
114
153
  }
115
- // Application modules load after this one: a replaced `postgres` would receive the database URL.
154
+ // Application modules load after this one: a replaced opener would receive the database's connection.
116
155
  Object.freeze(Database)
117
156
  Object.freeze(Database.prototype)
118
157
 
@@ -171,13 +210,15 @@ function untilAborted<T>(work: Promise<T>, signal: AbortSignal | undefined): Pro
171
210
  */
172
211
  export class Session {
173
212
  readonly #executor: Executor
174
- readonly #kysely: Kysely<LooseDB> | undefined
213
+ readonly #engine: DatabaseEngine
214
+ readonly #inTransaction: boolean
175
215
  readonly #readOnly: boolean
176
216
  readonly #hooks: SessionHooks
177
217
 
178
- constructor(executor: Executor, kysely: Kysely<LooseDB> | undefined, readOnly: boolean, hooks: SessionHooks = {}) {
218
+ constructor(executor: Executor, engine: DatabaseEngine, inTransaction: boolean, readOnly: boolean, hooks: SessionHooks = {}) {
179
219
  this.#executor = executor
180
- this.#kysely = kysely
220
+ this.#engine = engine
221
+ this.#inTransaction = inTransaction
181
222
  this.#readOnly = readOnly
182
223
  this.#hooks = hooks
183
224
  Object.freeze(this)
@@ -191,6 +232,11 @@ export class Session {
191
232
  return this.#readOnly
192
233
  }
193
234
 
235
+ /** The dialect of the database this session runs on: a framework statement picks its text by it. */
236
+ get dialect(): SqlDialect {
237
+ return this.#engine.dialect
238
+ }
239
+
194
240
  /** Fails the transaction this session runs in, so it commits nothing: a statement refused before it was sent. */
195
241
  fail(): void {
196
242
  this.#hooks.failed?.()
@@ -214,11 +260,10 @@ export class Session {
214
260
  throw new QueryRefusedError('read_only', 'this request is read-only: it reads, and no operation may write or lock a row')
215
261
  }
216
262
  // CompiledQuery's row type is a phantom: this is the one place where generated row types meet the wire.
217
- const compiled: CompiledQuery<O> = compiler.compileQuery(node, createQueryId())
218
- const kysely = this.#kysely
219
- if (this.#readOnly && kysely !== undefined) {
263
+ const compiled: CompiledQuery<O> = this.#engine.compiler.compileQuery(node, createQueryId())
264
+ if (this.#readOnly && !this.#inTransaction) {
220
265
  try {
221
- const result = await kysely.transaction().setAccessMode('read only').execute((transaction) => transaction.executeQuery(compiled))
266
+ const result = await this.#engine.transaction({ readOnly: true }, (executor) => executor.executeQuery(compiled))
222
267
  return result.rows
223
268
  } catch (error) {
224
269
  throw databaseError(error)
@@ -234,15 +279,14 @@ export class Session {
234
279
  * `work` returned or not; its session then runs nothing more.
235
280
  */
236
281
  async transaction<T>(work: (session: Session) => Promise<T>, signal?: AbortSignal): Promise<T> {
237
- if (this.#kysely === undefined) {
282
+ if (this.#inTransaction) {
238
283
  return work(this)
239
284
  }
240
- const builder = this.#kysely.transaction()
241
285
  let open = true
242
286
  let failed = false
243
287
  let failure: { readonly error: unknown } | undefined
244
288
  try {
245
- return await (this.#readOnly ? builder.setAccessMode('read only') : builder).execute(async (transaction) => {
289
+ return await this.#engine.transaction({ readOnly: this.#readOnly }, async (executor) => {
246
290
  try {
247
291
  const hooks: SessionHooks = {
248
292
  open: () => open,
@@ -250,7 +294,7 @@ export class Session {
250
294
  failed = true
251
295
  },
252
296
  }
253
- const result = await untilAborted(work(new Session(transaction, undefined, this.#readOnly, hooks)), signal)
297
+ const result = await untilAborted(work(new Session(executor, this.#engine, true, this.#readOnly, hooks)), signal)
254
298
  // A failure the work caught: Postgres would answer the COMMIT of an aborted transaction with a silent ROLLBACK.
255
299
  if (failed) {
256
300
  throw new QueryRefusedError('statement_failed', 'a statement of this transaction failed, so nothing it wrote commits')
@@ -274,11 +318,43 @@ Object.freeze(Session.prototype)
274
318
  /** How long an invocation's transaction may sit idle before Postgres ends it, and its connection with it. */
275
319
  export const INVOCATION_IDLE_TIMEOUT_MS = 30_000
276
320
 
277
- const SQL_IDLE_TIMEOUT = "select set_config('idle_in_transaction_session_timeout', $1, true)"
278
-
279
321
  /** What the invocation's transaction body throws to roll back; never seen outside this module. */
280
322
  class RolledBack extends Error {}
281
323
 
324
+ /** A writing invocation that holds its object's storage transaction longer than this logs one line. */
325
+ export const TRANSACTION_HELD_LOG_MS = 1_000
326
+ /** A writing invocation that holds its object's storage transaction longer than this is also reported. */
327
+ export const TRANSACTION_HELD_REPORT_MS = 5_000
328
+
329
+ /** Where an enclosure tells how long it held its object: every other event of the object waited that long. */
330
+ export interface HoldWatch {
331
+ /** The site the invocation serves, which the log line names. */
332
+ readonly site: string
333
+ /** Hears a hold longer than `TRANSACTION_HELD_REPORT_MS`, in whole ms. */
334
+ readonly held: (heldMs: number) => void
335
+ }
336
+
337
+ /**
338
+ * Runs once the transaction settled, so it never throws: a failed log or report must not turn a committed invocation
339
+ * into a failed one. Each has its own guard; the line names the site and the ms only, never a value or a statement.
340
+ */
341
+ function reportHold(watch: HoldWatch, heldMs: number): void {
342
+ try {
343
+ if (heldMs > TRANSACTION_HELD_LOG_MS) {
344
+ console.warn(`fabrika: ${watch.site}: held its object's transaction for ${heldMs} ms`)
345
+ }
346
+ } catch {
347
+ // The report below still goes out.
348
+ }
349
+ try {
350
+ if (heldMs > TRANSACTION_HELD_REPORT_MS) {
351
+ watch.held(heldMs)
352
+ }
353
+ } catch {
354
+ // The hold was logged; the invocation's own outcome stands.
355
+ }
356
+ }
357
+
282
358
  /**
283
359
  * The one transaction of an invocation (a request, a webhook delivery): opened by its first statement, then ended
284
360
  * once by the framework — committed after the handler returned and its output passed validation, rolled back
@@ -289,6 +365,7 @@ class RolledBack extends Error {}
289
365
  export class InvocationTransaction {
290
366
  readonly #database: Database
291
367
  readonly #readOnly: boolean
368
+ readonly #now: () => number
292
369
  readonly #decision = Promise.withResolvers<'commit' | 'rollback'>()
293
370
  #opened: Promise<Session> | undefined
294
371
  #run: Promise<void> | undefined
@@ -296,10 +373,15 @@ export class InvocationTransaction {
296
373
  #isReleased = true
297
374
  #ended = false
298
375
  #failed = false
376
+ /** Inside `enclose` on SQLite: `commit()` and `rollback()` only decide, and the enclosure ends the transaction. */
377
+ #enclosed = false
378
+ #decided: 'commit' | 'rollback' | undefined
299
379
 
300
- constructor(database: Database, readOnly: boolean) {
380
+ /** `now` is the clock an enclosure's hold is measured by. */
381
+ constructor(database: Database, readOnly: boolean, now: () => number = Date.now) {
301
382
  this.#database = database
302
383
  this.#readOnly = readOnly
384
+ this.#now = now
303
385
  }
304
386
 
305
387
  /** A `READ ONLY` transaction: a query's or an export's. */
@@ -327,6 +409,65 @@ export class InvocationTransaction {
327
409
  return this.#isReleased
328
410
  }
329
411
 
412
+ /**
413
+ * Runs `work`: the invocation from its handler to its `commit()`. On SQLite a writing invocation's transaction
414
+ * encloses `work`, opened before it: a Durable Object's transaction holds back every other event, the answers to the
415
+ * object's own I/O included, until its callback ends, so the handler runs inside it. There `commit()` and `rollback()`
416
+ * decide, the COMMIT runs once `work` returned, and a failed COMMIT rejects with a `DatabaseError`. A read-only
417
+ * invocation, and every invocation elsewhere, runs `work` as is: a read-only one on SQLite holds no storage
418
+ * transaction, so the object serves other events while it awaits.
419
+ *
420
+ * Only the SQLite enclosure is measured for `watch`, from entering the transaction until it settled: it holds the
421
+ * whole object, where a Postgres transaction holds one pooled connection and other requests go on.
422
+ */
423
+ async enclose<T>(work: () => Promise<T>, watch: HoldWatch): Promise<T> {
424
+ const engine = engineOf(this.#database)
425
+ if (engine.dialect !== 'sqlite' || this.#readOnly) {
426
+ return work()
427
+ }
428
+ if (this.#opened !== undefined || this.#ended) {
429
+ throw new QueryRefusedError('ended', 'an invocation encloses its work before its first statement')
430
+ }
431
+ this.#enclosed = true
432
+ this.#isReleased = false
433
+ let result: { readonly value: T } | undefined
434
+ let entered: number | undefined
435
+ try {
436
+ await engine.transaction({ readOnly: this.#readOnly }, async (executor) => {
437
+ entered = this.#now()
438
+ const session = new Session(executor, engine, true, this.#readOnly, {
439
+ failed: () => {
440
+ this.#failed = true
441
+ },
442
+ open: () => !this.#ended,
443
+ })
444
+ this.#opened = Promise.resolve(session)
445
+ result = { value: await work() }
446
+ this.#ended = true
447
+ if (this.#decided !== 'commit') {
448
+ throw new RolledBack()
449
+ }
450
+ })
451
+ } catch (error) {
452
+ if (result === undefined) {
453
+ throw error
454
+ }
455
+ if (!(error instanceof RolledBack)) {
456
+ throw databaseError(error)
457
+ }
458
+ } finally {
459
+ this.#ended = true
460
+ this.#isReleased = true
461
+ if (entered !== undefined) {
462
+ reportHold(watch, Math.round(this.#now() - entered))
463
+ }
464
+ }
465
+ if (result === undefined) {
466
+ throw new QueryRefusedError('ended', 'the transaction ended before the invocation answered')
467
+ }
468
+ return result.value
469
+ }
470
+
330
471
  /** The session the invocation's statements run in; the first call opens the transaction. */
331
472
  session(): Promise<Session> {
332
473
  if (this.#ended) {
@@ -339,15 +480,15 @@ export class InvocationTransaction {
339
480
  #open(): Promise<Session> {
340
481
  const ready = Promise.withResolvers<Session>()
341
482
  this.#isReleased = false
342
- const builder = transactionBuilder(this.#database)
343
- const run = (this.#readOnly ? builder.setAccessMode('read only') : builder).execute(async (transaction) => {
344
- const session = new Session(transaction, undefined, this.#readOnly, {
483
+ const engine = engineOf(this.#database)
484
+ const options: TransactionOptions = { readOnly: this.#readOnly, idleTimeoutMs: INVOCATION_IDLE_TIMEOUT_MS }
485
+ const run = engine.transaction(options, async (executor) => {
486
+ const session = new Session(executor, engine, true, this.#readOnly, {
345
487
  failed: () => {
346
488
  this.#failed = true
347
489
  },
348
490
  open: () => !this.#ended,
349
491
  })
350
- await executeFixed(session, CompiledQuery.raw(SQL_IDLE_TIMEOUT, [String(INVOCATION_IDLE_TIMEOUT_MS)]))
351
492
  ready.resolve(session)
352
493
  if ((await this.#decision.promise) === 'rollback') {
353
494
  throw new RolledBack()
@@ -371,6 +512,13 @@ export class InvocationTransaction {
371
512
  throw new QueryRefusedError('ended', 'this invocation has already ended')
372
513
  }
373
514
  this.#ended = true
515
+ if (this.#enclosed) {
516
+ this.#decided = this.#failed ? 'rollback' : 'commit'
517
+ if (this.#failed) {
518
+ throw new QueryRefusedError('statement_failed', 'a statement of this invocation failed, so nothing it wrote commits')
519
+ }
520
+ return
521
+ }
374
522
  const run = this.#run
375
523
  if (this.#failed) {
376
524
  this.#decision.resolve('rollback')
@@ -390,9 +538,13 @@ export class InvocationTransaction {
390
538
  }
391
539
  }
392
540
 
393
- /** Discards what the invocation wrote; idempotent, never throws, and returns once the connection is back. */
541
+ /** Discards what the invocation wrote; idempotent, never throws, and returns once the connection is back (inside `enclose`, it only decides). */
394
542
  async rollback(): Promise<void> {
395
543
  this.#ended = true
544
+ if (this.#enclosed) {
545
+ this.#decided ??= 'rollback'
546
+ return
547
+ }
396
548
  this.#decision.resolve('rollback')
397
549
  await this.#released
398
550
  }
package/src/errors.ts CHANGED
@@ -55,6 +55,7 @@ export const QUERY_REFUSALS = {
55
55
  sql_function: 'ctx.sql calls a function that is not a reviewed built-in',
56
56
  sql_outside_transaction: 'a job runs ctx.sql only inside ctx.transaction((tx) => …), as tx.sql',
57
57
  sql_scope: 'ctx.sql.scope(…) stands alone, joined by AND, in a WHERE or JOIN … ON clause of its own invocation',
58
+ sql_dialect: 'ctx.sql runs only on Postgres; an application on SQLite (Cloudflare) reads and writes through ctx.db',
58
59
  file_scan: "a scan verdict outside the scan job's lease",
59
60
  claim_site: 'claim leases rows in a transaction of its own: a job calls it on ctx.db, never inside ctx.transaction or a request',
60
61
  on_conflict: "an insert's onConflict names no column of the table, a column the insert leaves empty, or another action",
@@ -125,7 +125,10 @@ export async function storedRowScope(model: TableModel, session: Session, key: s
125
125
  if (!isPlainIdentifier(model.name) || !isPlainIdentifier(column) || !isPlainIdentifier(key)) {
126
126
  throw new QueryRefusedError('invalid_name', "a file column's table is named by plain identifiers")
127
127
  }
128
- const [found] = await runFixedStatement(session, `select ${column}::text as scope from ${model.name} where ${key}::text = $1`, [keyValue])
128
+ const statement = session.dialect === 'sqlite'
129
+ ? `select cast(${column} as text) as scope from ${model.name} where cast(${key} as text) = ?1`
130
+ : `select ${column}::text as scope from ${model.name} where ${key}::text = $1`
131
+ const [found] = await runFixedStatement(session, statement, [keyValue])
129
132
  const value: unknown = typeof found === 'object' && found !== null ? ownDescriptor(found, 'scope')?.value : undefined
130
133
  if (typeof value !== 'string') {
131
134
  throw new NotFoundError(`${model.name} not found`)
@@ -8,6 +8,7 @@ import { BinaryOperationNode, type OperationNode, OperatorNode, ValueNode } from
8
8
  import { runFixedStatement, type Session, sessionOf } from './database'
9
9
  import { QueryRefusedError } from './errors'
10
10
  import type { GuardContext } from './guard'
11
+ import type { DialectTexts } from './job-leases'
11
12
  import { WEBHOOK_BINDINGS_TABLE, WEBHOOK_DELIVERIES_TABLE } from './migrations'
12
13
  import { acknowledgement, allOf, column, deleteNode, equals, insertNode, locateNode, scopedWhere, updateNode } from './nodes'
13
14
  import type { DataLayerRuntime } from './store'
@@ -72,16 +73,35 @@ export interface DeliveryClaim {
72
73
  const D = WEBHOOK_DELIVERIES_TABLE
73
74
 
74
75
  /** A new key, or one whose claim expired without completion; a completed one (no lease) is never taken. */
75
- const SQL_CLAIM = `insert into ${D} (binding_id, delivery_id, received_at, lease_until) values ($1, $2, $3::bigint, $4::bigint)
76
+ const SQL_CLAIM: DialectTexts = {
77
+ postgres: `insert into ${D} (binding_id, delivery_id, received_at, lease_until) values ($1, $2, $3::bigint, $4::bigint)
76
78
  on conflict (binding_id, delivery_id) do update set received_at = excluded.received_at, lease_until = excluded.lease_until
77
79
  where ${D}.lease_until is not null and ${D}.lease_until < excluded.received_at
78
- returning delivery_id`
80
+ returning delivery_id`,
81
+ sqlite: `insert into ${D} (binding_id, delivery_id, received_at, lease_until) values (?1, ?2, ?3, ?4)
82
+ on conflict (binding_id, delivery_id) do update set received_at = excluded.received_at, lease_until = excluded.lease_until
83
+ where ${D}.lease_until is not null and ${D}.lease_until < excluded.received_at
84
+ returning delivery_id`,
85
+ }
79
86
 
80
- const SQL_COMPLETE = `update ${D} set lease_until = null
87
+ const SQL_COMPLETE: DialectTexts = {
88
+ postgres: `update ${D} set lease_until = null
81
89
  where binding_id = $1 and delivery_id = $2 and lease_until = $3::bigint
82
- returning delivery_id`
90
+ returning delivery_id`,
91
+ sqlite: `update ${D} set lease_until = null
92
+ where binding_id = ?1 and delivery_id = ?2 and lease_until = ?3
93
+ returning delivery_id`,
94
+ }
83
95
 
84
- const SQL_RELEASE = `delete from ${D} where binding_id = $1 and delivery_id = $2 and lease_until = $3::bigint returning delivery_id`
96
+ const SQL_RELEASE: DialectTexts = {
97
+ postgres: `delete from ${D} where binding_id = $1 and delivery_id = $2 and lease_until = $3::bigint returning delivery_id`,
98
+ sqlite: `delete from ${D} where binding_id = ?1 and delivery_id = ?2 and lease_until = ?3 returning delivery_id`,
99
+ }
100
+
101
+ /** Runs the text of `session`'s dialect. */
102
+ function run(session: Session, texts: DialectTexts, parameters: readonly (string | number)[]): Promise<readonly unknown[]> {
103
+ return runFixedStatement(session, texts[session.dialect], parameters)
104
+ }
85
105
 
86
106
  const BINDING_COLUMNS = ['id', 'webhook', 'scope_type', 'scope_value', 'account', 'secret']
87
107
 
@@ -186,17 +206,17 @@ export function webhookTables(runtime: () => DataLayerRuntime): WebhookTables {
186
206
  )
187
207
  const leaseUntil = now + leaseMs
188
208
  // The primary key decides between concurrent copies of one delivery: exactly one inserts or takes over.
189
- const claimed = await runFixedStatement(session(), SQL_CLAIM, [bindingId, deliveryKey, now, leaseUntil])
209
+ const claimed = await run(session(), SQL_CLAIM, [bindingId, deliveryKey, now, leaseUntil])
190
210
  return claimed.length === 0 ? null : Object.freeze({ bindingId, key: deliveryKey, leaseUntil })
191
211
  },
192
212
 
193
213
  async completeDelivery(claim: DeliveryClaim, using?: Session): Promise<boolean> {
194
- const completed = await runFixedStatement(using ?? session(), SQL_COMPLETE, [claim.bindingId, claim.key, claim.leaseUntil])
214
+ const completed = await run(using ?? session(), SQL_COMPLETE, [claim.bindingId, claim.key, claim.leaseUntil])
195
215
  return completed.length > 0
196
216
  },
197
217
 
198
218
  async releaseDelivery(claim: DeliveryClaim): Promise<void> {
199
- await runFixedStatement(session(), SQL_RELEASE, [claim.bindingId, claim.key, claim.leaseUntil])
219
+ await run(session(), SQL_RELEASE, [claim.bindingId, claim.key, claim.leaseUntil])
200
220
  },
201
221
  })
202
222
  }
package/src/framework.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // hands the framework the locator its access check uses. Browser- and Worker-safe: the Worker adapter imports `boot`.
4
4
 
5
5
  import type { DataAccess } from './orm'
6
- import { frameworkSideOf, type FrameworkTables, type Locator } from './registry'
6
+ import { frameworkSideOf, type FrameworkTables, type Locator, type ProvisionalBinding } from './registry'
7
7
  import type { DataLayerRuntime } from './store'
8
8
 
9
9
  export type { Admissions } from './admissions'
@@ -23,7 +23,7 @@ export { type FileHolding, fileHoldingOf, type FileReference, fileReference, hol
23
23
  export type { DeliveryClaim, NewWebhookBinding, WebhookBinding, WebhookTables } from './framework-tables'
24
24
  export type { JobLeases, JobLeaseState, JobOutcome, OverdueClaim } from './job-leases'
25
25
  export type { DataAccess, DataHandles, DataSite } from './orm'
26
- export type { FrameworkTables, Locator } from './registry'
26
+ export type { FrameworkTables, Locator, ProvisionalBinding } from './registry'
27
27
  export type { SqlMode, SqlSite, SqlTag, SqlValue } from './sql'
28
28
  export type { FileColumnRef, QuarantinedStoredFile, ServedFile, StagedFile, StoredFileMeta, StoredFiles, StoredFileStatus } from './stored-files'
29
29
 
@@ -37,6 +37,14 @@ export function isScopedDatabase(value: unknown): boolean {
37
37
  * reach. Throws for any other value and for a layer that is already connected.
38
38
  */
39
39
  export function bindScopedDatabase(value: unknown, runtime: DataLayerRuntime): Locator {
40
+ return bindScopedDatabaseProvisionally(value, runtime).locate
41
+ }
42
+
43
+ /**
44
+ * Connects the layer like `bindScopedDatabase`, and answers with its locator the `release` that disconnects it again:
45
+ * a boot that fails releases the layer, so a later boot of the same application binds it. Throws as that does.
46
+ */
47
+ export function bindScopedDatabaseProvisionally(value: unknown, runtime: DataLayerRuntime): ProvisionalBinding {
40
48
  const side = frameworkSideOf(value)
41
49
  if (side === undefined) {
42
50
  throw new TypeError('not a data layer scoped(tables) created')
package/src/guard.ts CHANGED
@@ -262,6 +262,14 @@ function checkNode(node: OperationNode, parent: Visit | undefined, context: Guar
262
262
  return
263
263
  case 'TableNode':
264
264
  return checkTable(node, parent, context)
265
+ case 'OrderByItemNode': {
266
+ // Kysely prints `nulls` as raw text; the layer only ever states the order Postgres has by default.
267
+ const nulls = read(node, 'nulls')
268
+ if (nulls !== undefined && nulls !== 'first' && nulls !== 'last') {
269
+ throw new QueryRefusedError('statement_shape', 'an order term states nulls first or nulls last, nothing else')
270
+ }
271
+ return
272
+ }
265
273
  case 'JoinNode':
266
274
  return checkJoin(node, context)
267
275
  case 'ReferenceNode':
package/src/index.ts CHANGED
@@ -13,7 +13,15 @@ export type {
13
13
  WithinGrant,
14
14
  } from './capability'
15
15
  export { MAX_WITHIN_VALUES } from './capability'
16
- export { applicationSchema, Database, type PostgresOptions } from './database'
16
+ export {
17
+ applicationSchema,
18
+ Database,
19
+ type DatabaseEngine,
20
+ type Executor,
21
+ type SqlDialect,
22
+ type TransactionOptions,
23
+ type TransactionRunner,
24
+ } from './database'
17
25
  export type {
18
26
  AllowedOperator,
19
27
  ComparisonOperator,