@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.
- package/package.json +44 -12
- package/src/admissions.ts +78 -12
- package/src/application-rows.ts +108 -0
- package/src/capability.ts +5 -0
- package/src/codegen-sqlite.ts +154 -0
- package/src/codegen.ts +59 -9
- package/src/database.ts +208 -56
- package/src/errors.ts +1 -0
- package/src/file-columns.ts +4 -1
- package/src/framework-tables.ts +28 -8
- package/src/framework.ts +10 -2
- package/src/guard.ts +8 -0
- package/src/index.ts +9 -1
- package/src/job-leases.ts +157 -37
- package/src/links.ts +8 -1
- package/src/migrations-sqlite.ts +132 -0
- package/src/nodes.ts +45 -9
- package/src/orm.ts +25 -15
- package/src/policy.ts +8 -2
- package/src/postgres.ts +26 -0
- package/src/registry.ts +8 -2
- package/src/sql.ts +15 -7
- package/src/sqlite-bun.ts +122 -0
- package/src/sqlite-migrate.ts +242 -0
- package/src/sqlite.ts +224 -0
- package/src/store.ts +336 -62
- package/src/stored-files.ts +149 -46
- package/src/tables.ts +16 -0
- package/src/temporal.ts +115 -9
- package/src/testing-shared.ts +733 -0
- package/src/testing-sqlite.ts +513 -0
- package/src/testing.ts +170 -482
- package/types/src/admissions.d.ts +21 -0
- package/types/src/application-rows.d.ts +58 -0
- package/types/src/builtins.d.ts +42 -0
- package/types/src/capability.d.ts +119 -0
- package/types/src/codegen-sqlite.d.ts +6 -0
- package/types/src/codegen.d.ts +82 -0
- package/types/src/database.d.ts +179 -0
- package/types/src/description.d.ts +109 -0
- package/types/src/errors.d.ts +98 -0
- package/types/src/file-columns.d.ts +37 -0
- package/types/src/file-references.d.ts +27 -0
- package/types/src/framework-tables.d.ts +59 -0
- package/types/src/framework.d.ts +37 -0
- package/types/src/guard.d.ts +23 -0
- package/types/src/identifier.d.ts +4 -0
- package/types/src/index.d.ts +10 -0
- package/types/src/job-leases.d.ts +93 -0
- package/types/src/links.d.ts +13 -0
- package/types/src/migrations-sqlite.d.ts +4 -0
- package/types/src/migrations.d.ts +25 -0
- package/types/src/nodes.d.ts +63 -0
- package/types/src/orm-types.d.ts +638 -0
- package/types/src/orm.d.ts +77 -0
- package/types/src/policy.d.ts +354 -0
- package/types/src/postgres.d.ts +12 -0
- package/types/src/production.d.ts +5 -0
- package/types/src/registry.d.ts +40 -0
- package/types/src/sql.d.ts +59 -0
- package/types/src/sqlite-bun.d.ts +13 -0
- package/types/src/sqlite-migrate.d.ts +28 -0
- package/types/src/sqlite.d.ts +24 -0
- package/types/src/store.d.ts +345 -0
- package/types/src/stored-files.d.ts +98 -0
- package/types/src/tables.d.ts +21 -0
- package/types/src/temporal.d.ts +33 -0
- package/types/src/testing-shared.d.ts +201 -0
- package/types/src/testing-sqlite.d.ts +22 -0
- package/types/src/testing.d.ts +35 -0
- package/types/src/vocabulary-tables.d.ts +225 -0
package/src/database.ts
CHANGED
|
@@ -1,12 +1,41 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
29
|
-
freezePrototypeChain(
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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 #
|
|
73
|
-
readonly #schema: string | undefined
|
|
109
|
+
readonly #engine: DatabaseEngine
|
|
74
110
|
|
|
75
|
-
private constructor(
|
|
76
|
-
|
|
77
|
-
|
|
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.#
|
|
83
|
-
|
|
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
|
-
*
|
|
91
|
-
*
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
102
|
-
static
|
|
103
|
-
return new Database(
|
|
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
|
-
|
|
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
|
|
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 #
|
|
213
|
+
readonly #engine: DatabaseEngine
|
|
214
|
+
readonly #inTransaction: boolean
|
|
175
215
|
readonly #readOnly: boolean
|
|
176
216
|
readonly #hooks: SessionHooks
|
|
177
217
|
|
|
178
|
-
constructor(executor: Executor,
|
|
218
|
+
constructor(executor: Executor, engine: DatabaseEngine, inTransaction: boolean, readOnly: boolean, hooks: SessionHooks = {}) {
|
|
179
219
|
this.#executor = executor
|
|
180
|
-
this.#
|
|
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
|
-
|
|
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
|
|
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.#
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
343
|
-
const
|
|
344
|
-
|
|
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",
|
package/src/file-columns.ts
CHANGED
|
@@ -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
|
|
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`)
|
package/src/framework-tables.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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,
|