@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/job-leases.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// containers whose clocks disagree still agree on who holds a lease and when it expires.
|
|
6
6
|
|
|
7
7
|
import type { LeaseFence } from './capability'
|
|
8
|
-
import { runFixedStatement, type Session, sessionOf } from './database'
|
|
8
|
+
import { runFixedStatement, type Session, sessionOf, type SqlDialect } from './database'
|
|
9
9
|
import { DatabaseError, LeaseLostError, QueryRefusedError } from './errors'
|
|
10
10
|
import { JOB_KICKS_TABLE, JOB_LEASES_TABLE } from './migrations'
|
|
11
|
+
import { SQLITE_NOW_FORMAT } from './migrations-sqlite'
|
|
11
12
|
import type { DataLayerRuntime } from './store'
|
|
12
13
|
|
|
13
14
|
export type JobOutcome = 'succeeded' | 'failed'
|
|
@@ -82,69 +83,166 @@ export interface JobLeases {
|
|
|
82
83
|
state(job: string): Promise<JobLeaseState | null>
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/** One framework statement in each dialect's text; a session runs the text of its own. */
|
|
87
|
+
export type DialectTexts = Readonly<Record<SqlDialect, string>>
|
|
88
|
+
|
|
89
|
+
/** SQLite's clock, as a stored timestamp: ISO text, so a comparison of two is a text comparison. */
|
|
90
|
+
export const SQLITE_NOW = `strftime('${SQLITE_NOW_FORMAT}', 'now')`
|
|
91
|
+
|
|
92
|
+
/** SQLite's clock moved by `ms`, an SQL expression of milliseconds (negative is earlier). */
|
|
93
|
+
export function sqliteNowPlus(ms: string): string {
|
|
94
|
+
return `strftime('${SQLITE_NOW_FORMAT}', 'now', printf('%+.3f seconds', (${ms}) / 1000.0))`
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
const T = JOB_LEASES_TABLE
|
|
86
98
|
const MS = `* interval '1 millisecond'`
|
|
99
|
+
const N = SQLITE_NOW
|
|
100
|
+
const SQLITE_RUN_MS = `cast(round((julianday('now') - julianday(last_started_at)) * 86400000) as integer)`
|
|
87
101
|
const QUERY_CANCELED = '57014'
|
|
88
102
|
const IDLE_TIMEOUT = '25P03'
|
|
89
103
|
|
|
90
104
|
const STATE_COLUMNS =
|
|
91
105
|
'generation, holder, expires_at, attempt_deadline, first_seen_at, last_started_at, last_finished_at, last_status, last_succeeded_at, last_duration_ms, last_missed_reported_at'
|
|
92
106
|
|
|
93
|
-
const SQL_NOW =
|
|
107
|
+
const SQL_NOW: DialectTexts = {
|
|
108
|
+
postgres: 'select now() as now',
|
|
109
|
+
sqlite: `select ${N} as now`,
|
|
110
|
+
}
|
|
94
111
|
|
|
95
|
-
const SQL_REGISTER =
|
|
96
|
-
|
|
112
|
+
const SQL_REGISTER: DialectTexts = {
|
|
113
|
+
postgres: `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at)
|
|
114
|
+
values ($1, 0, null, now(), now(), now()) on conflict (job) do nothing`,
|
|
115
|
+
sqlite: `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at)
|
|
116
|
+
values (?1, 0, null, ${N}, ${N}, ${N}) on conflict (job) do nothing`,
|
|
117
|
+
}
|
|
97
118
|
|
|
98
|
-
const SQL_REAP =
|
|
119
|
+
const SQL_REAP: DialectTexts = {
|
|
120
|
+
postgres: `update ${T} set holder = null, last_status = 'failed', last_finished_at = now(),
|
|
99
121
|
last_duration_ms = (extract(epoch from now() - last_started_at) * 1000)::int
|
|
100
|
-
where job = $1 and holder is not null and expires_at <= now() returning job
|
|
122
|
+
where job = $1 and holder is not null and expires_at <= now() returning job`,
|
|
123
|
+
sqlite: `update ${T} set holder = null, last_status = 'failed', last_finished_at = ${N},
|
|
124
|
+
last_duration_ms = ${SQLITE_RUN_MS}
|
|
125
|
+
where job = ?1 and holder is not null and expires_at <= ${N} returning job`,
|
|
126
|
+
}
|
|
101
127
|
|
|
102
|
-
const SQL_ACQUIRE =
|
|
128
|
+
const SQL_ACQUIRE: DialectTexts = {
|
|
129
|
+
postgres: `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at, last_started_at)
|
|
103
130
|
values ($1, 1, $2, now() + $3::bigint ${MS}, now() + $4::bigint ${MS}, now(), now())
|
|
104
131
|
on conflict (job) do update set generation = ${T}.generation + 1, holder = excluded.holder, expires_at = excluded.expires_at,
|
|
105
132
|
attempt_deadline = excluded.attempt_deadline, last_started_at = excluded.last_started_at
|
|
106
|
-
where ${T}.holder is null returning generation
|
|
133
|
+
where ${T}.holder is null returning generation`,
|
|
134
|
+
sqlite: `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at, last_started_at)
|
|
135
|
+
values (?1, 1, ?2, ${sqliteNowPlus('?3')}, ${sqliteNowPlus('?4')}, ${N}, ${N})
|
|
136
|
+
on conflict (job) do update set generation = ${T}.generation + 1, holder = excluded.holder, expires_at = excluded.expires_at,
|
|
137
|
+
attempt_deadline = excluded.attempt_deadline, last_started_at = excluded.last_started_at
|
|
138
|
+
where ${T}.holder is null returning generation`,
|
|
139
|
+
}
|
|
107
140
|
|
|
108
|
-
const SQL_RENEW
|
|
141
|
+
const SQL_RENEW: DialectTexts = {
|
|
142
|
+
postgres: `update ${T} set generation = generation + 1, holder = $4, expires_at = now() + $5::bigint ${MS},
|
|
109
143
|
attempt_deadline = now() + $6::bigint ${MS}
|
|
110
|
-
where job = $1 and generation = $2 and holder = $3 and expires_at > now() returning generation
|
|
144
|
+
where job = $1 and generation = $2 and holder = $3 and expires_at > now() returning generation`,
|
|
145
|
+
sqlite: `update ${T} set generation = generation + 1, holder = ?4, expires_at = ${sqliteNowPlus('?5')},
|
|
146
|
+
attempt_deadline = ${sqliteNowPlus('?6')}
|
|
147
|
+
where job = ?1 and generation = ?2 and holder = ?3 and expires_at > ${N} returning generation`,
|
|
148
|
+
}
|
|
111
149
|
|
|
112
|
-
const SQL_FINISH =
|
|
150
|
+
const SQL_FINISH: DialectTexts = {
|
|
151
|
+
postgres: `update ${T} set holder = null, last_finished_at = now(), last_status = $4,
|
|
113
152
|
last_duration_ms = (extract(epoch from now() - last_started_at) * 1000)::int,
|
|
114
153
|
last_succeeded_at = case when $4 = 'succeeded' then now() else last_succeeded_at end
|
|
115
|
-
where job = $1 and generation = $2 and holder = $3 returning job
|
|
154
|
+
where job = $1 and generation = $2 and holder = $3 returning job`,
|
|
155
|
+
sqlite: `update ${T} set holder = null, last_finished_at = ${N}, last_status = ?4,
|
|
156
|
+
last_duration_ms = ${SQLITE_RUN_MS},
|
|
157
|
+
last_succeeded_at = case when ?4 = 'succeeded' then ${N} else last_succeeded_at end
|
|
158
|
+
where job = ?1 and generation = ?2 and holder = ?3 returning job`,
|
|
159
|
+
}
|
|
116
160
|
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
const SQL_TRY_ACQUIRE: DialectTexts = {
|
|
162
|
+
// A free lease whose row no transaction holds (`skip locked`): an attempt's fence holds it `for share` while it writes.
|
|
163
|
+
postgres: `with free as (select job from ${T} where job = $1 and holder is null for update skip locked)
|
|
119
164
|
update ${T} set generation = ${T}.generation + 1, holder = $2, expires_at = now() + $3::bigint ${MS},
|
|
120
165
|
attempt_deadline = now() + $4::bigint ${MS}, last_started_at = now()
|
|
121
|
-
from free where ${T}.job = free.job returning ${T}.generation
|
|
166
|
+
from free where ${T}.job = free.job returning ${T}.generation`,
|
|
167
|
+
// No lock to skip: while an attempt's fenced transaction is open, the Durable Object serves no kick.
|
|
168
|
+
sqlite: `update ${T} set generation = generation + 1, holder = ?2, expires_at = ${sqliteNowPlus('?3')},
|
|
169
|
+
attempt_deadline = ${sqliteNowPlus('?4')}, last_started_at = ${N}
|
|
170
|
+
where job = ?1 and holder is null returning generation`,
|
|
171
|
+
}
|
|
122
172
|
|
|
123
173
|
const K = JOB_KICKS_TABLE
|
|
124
174
|
|
|
125
|
-
const SQL_MARK_PENDING
|
|
175
|
+
const SQL_MARK_PENDING: DialectTexts = {
|
|
176
|
+
postgres: `insert into ${K} (job, pending) values ($1, true) on conflict (job) do update set pending = true where ${K}.pending = false`,
|
|
177
|
+
sqlite: `insert into ${K} (job, pending) values (?1, 1) on conflict (job) do update set pending = 1 where ${K}.pending = 0`,
|
|
178
|
+
}
|
|
126
179
|
|
|
127
|
-
const SQL_TAKE_PENDING =
|
|
180
|
+
const SQL_TAKE_PENDING: DialectTexts = {
|
|
181
|
+
postgres: `update ${K} set pending = false where job = $1 and pending returning job`,
|
|
182
|
+
sqlite: `update ${K} set pending = 0 where job = ?1 and pending = 1 returning job`,
|
|
183
|
+
}
|
|
128
184
|
|
|
129
|
-
const SQL_CLAIM_OVERDUE =
|
|
185
|
+
const SQL_CLAIM_OVERDUE: DialectTexts = {
|
|
186
|
+
postgres: `update ${T} set last_missed_reported_at = now()
|
|
130
187
|
where job = $1 and coalesce(last_succeeded_at, first_seen_at) < now() - 2 * $2::bigint ${MS}
|
|
131
188
|
and (last_missed_reported_at is null or last_missed_reported_at <= now() - $2::bigint ${MS})
|
|
132
|
-
returning last_succeeded_at, first_seen_at
|
|
189
|
+
returning last_succeeded_at, first_seen_at`,
|
|
190
|
+
sqlite: `update ${T} set last_missed_reported_at = ${N}
|
|
191
|
+
where job = ?1 and coalesce(last_succeeded_at, first_seen_at) < ${sqliteNowPlus('-2 * ?2')}
|
|
192
|
+
and (last_missed_reported_at is null or last_missed_reported_at <= ${sqliteNowPlus('-?2')})
|
|
193
|
+
returning last_succeeded_at, first_seen_at`,
|
|
194
|
+
}
|
|
133
195
|
|
|
134
|
-
|
|
135
|
-
|
|
196
|
+
const SQL_STILL_HELD: DialectTexts = {
|
|
197
|
+
postgres: `select job from ${T} where job = $1 and generation = $2 and holder = $3 and attempt_deadline > clock_timestamp()`,
|
|
198
|
+
sqlite: `select job from ${T} where job = ?1 and generation = ?2 and holder = ?3 and attempt_deadline > ${N}`,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const SQL_FENCE: DialectTexts = {
|
|
202
|
+
// Inside a mutation's transaction, so `clock_timestamp()`: `now()` is when the transaction began.
|
|
203
|
+
postgres: `select pg_backend_pid() as pid, transaction_timestamp()::text as began,
|
|
136
204
|
greatest(1, ceil(extract(epoch from attempt_deadline - clock_timestamp()) * 1000))::int as remaining_ms
|
|
137
|
-
from ${T} where job = $1 and generation = $2 and holder = $3 and attempt_deadline > clock_timestamp() for share
|
|
205
|
+
from ${T} where job = $1 and generation = $2 and holder = $3 and attempt_deadline > clock_timestamp() for share`,
|
|
206
|
+
// No lock: while the transaction is open the Durable Object serves no other event, so no takeover runs before it ends.
|
|
207
|
+
sqlite: SQL_STILL_HELD.sqlite,
|
|
208
|
+
}
|
|
138
209
|
|
|
139
|
-
// The attempt's remaining time bounds a statement and an idle wait alike: either past the deadline ends
|
|
210
|
+
// Postgres only. The attempt's remaining time bounds a statement and an idle wait alike: either past the deadline ends it.
|
|
140
211
|
const SQL_TIMEOUTS = `select set_config('statement_timeout', $1, true), set_config('idle_in_transaction_session_timeout', $1, true)`
|
|
141
212
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// Only the backend still in the transaction the fence opened: a pid the pool reused for other work is left alone.
|
|
213
|
+
// Postgres only. Only the backend still in the transaction the fence opened: a pid the pool reused is left alone.
|
|
145
214
|
const SQL_CANCEL = `select pg_cancel_backend(pid) from pg_stat_activity where pid = $1 and xact_start = $2::timestamptz`
|
|
146
215
|
|
|
147
|
-
const SQL_STATE =
|
|
216
|
+
const SQL_STATE: DialectTexts = {
|
|
217
|
+
postgres: `select now() as now, ${STATE_COLUMNS} from ${T} where job = $1`,
|
|
218
|
+
sqlite: `select ${N} as now, ${STATE_COLUMNS} from ${T} where job = ?1`,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The columns that hold a time; SQLite answers each as ISO text, which the row's entry reads as a `Date`. */
|
|
222
|
+
const TIMESTAMP_COLUMNS: ReadonlySet<string> = new Set([
|
|
223
|
+
'now',
|
|
224
|
+
'expires_at',
|
|
225
|
+
'attempt_deadline',
|
|
226
|
+
'first_seen_at',
|
|
227
|
+
'last_started_at',
|
|
228
|
+
'last_finished_at',
|
|
229
|
+
'last_succeeded_at',
|
|
230
|
+
'last_missed_reported_at',
|
|
231
|
+
])
|
|
232
|
+
|
|
233
|
+
const ISO_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
234
|
+
|
|
235
|
+
/** A stored SQLite timestamp (`SQLITE_NOW_FORMAT`) as a `Date`, or `null`; anything else is refused. */
|
|
236
|
+
export function sqliteTimestamp(value: unknown, what: string): Date | null {
|
|
237
|
+
if (value === null) {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
const parsed = typeof value === 'string' && ISO_TIMESTAMP.test(value) ? new Date(value) : undefined
|
|
241
|
+
if (parsed === undefined || Number.isNaN(parsed.getTime())) {
|
|
242
|
+
throw new QueryRefusedError('unexpected_row', `${what} is not a stored timestamp`)
|
|
243
|
+
}
|
|
244
|
+
return parsed
|
|
245
|
+
}
|
|
148
246
|
|
|
149
247
|
function record(row: unknown): Readonly<Record<string, unknown>> {
|
|
150
248
|
if (typeof row !== 'object' || row === null) {
|
|
@@ -153,8 +251,19 @@ function record(row: unknown): Readonly<Record<string, unknown>> {
|
|
|
153
251
|
return Object.fromEntries(Object.entries(row))
|
|
154
252
|
}
|
|
155
253
|
|
|
156
|
-
async function rows(
|
|
157
|
-
|
|
254
|
+
async function rows(
|
|
255
|
+
session: Session,
|
|
256
|
+
texts: DialectTexts,
|
|
257
|
+
parameters: readonly (string | number | null)[],
|
|
258
|
+
): Promise<Readonly<Record<string, unknown>>[]> {
|
|
259
|
+
const found = (await runFixedStatement(session, texts[session.dialect], parameters)).map(record)
|
|
260
|
+
return session.dialect === 'sqlite' ? found.map(sqliteRow) : found
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function sqliteRow(row: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
|
264
|
+
return Object.fromEntries(
|
|
265
|
+
Object.entries(row).map(([name, value]) => [name, TIMESTAMP_COLUMNS.has(name) ? sqliteTimestamp(value, `${T}.${name}`) : value]),
|
|
266
|
+
)
|
|
158
267
|
}
|
|
159
268
|
|
|
160
269
|
function integer(row: Readonly<Record<string, unknown>>, name: string): number {
|
|
@@ -254,7 +363,10 @@ function refuseEnded(fence: LeaseFence): void {
|
|
|
254
363
|
}
|
|
255
364
|
}
|
|
256
365
|
|
|
257
|
-
const SQL_CLOCK =
|
|
366
|
+
const SQL_CLOCK: DialectTexts = {
|
|
367
|
+
postgres: 'select clock_timestamp() as now',
|
|
368
|
+
sqlite: `select ${N} as now`,
|
|
369
|
+
}
|
|
258
370
|
|
|
259
371
|
/**
|
|
260
372
|
* The database's clock inside `session`'s transaction: a queue claim compares and moves `due` by it, so no process
|
|
@@ -275,6 +387,7 @@ const params = (fence: LeaseFence): (string | number)[] => [fence.job, fence.gen
|
|
|
275
387
|
* stays locked `for share` until the commit (a takeover or a renewal waits), and every statement is bounded by the
|
|
276
388
|
* attempt's remaining time. After it, just before the commit: the attempt still holds the lease within its deadline.
|
|
277
389
|
* Otherwise the transaction rolls back with `LeaseLostError`; so does a statement the deadline or the runner cancelled.
|
|
390
|
+
* On SQLite only the two checks run: no other event reaches the Durable Object while the transaction is open.
|
|
278
391
|
*/
|
|
279
392
|
export async function fencedTransaction<T>(session: Session, fence: LeaseFence, work: (transaction: Session) => Promise<T>): Promise<T> {
|
|
280
393
|
refuseEnded(fence)
|
|
@@ -285,12 +398,19 @@ export async function fencedTransaction<T>(session: Session, fence: LeaseFence,
|
|
|
285
398
|
if (held === undefined) {
|
|
286
399
|
throw lost(fence)
|
|
287
400
|
}
|
|
288
|
-
backend
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
401
|
+
// SQLite has no backend to cancel and no statement timeout: the check before the commit refuses a late attempt,
|
|
402
|
+
// and a Durable Object resets a transaction that runs past its own limit.
|
|
403
|
+
const postgres = transaction.dialect === 'postgres'
|
|
404
|
+
if (postgres) {
|
|
405
|
+
backend = { pid: integer(held, 'pid'), began: text(held, 'began') }
|
|
406
|
+
const running = inFlight.get(fence) ?? new Set<Backend>()
|
|
407
|
+
running.add(backend)
|
|
408
|
+
inFlight.set(fence, running)
|
|
409
|
+
}
|
|
292
410
|
refuseEnded(fence)
|
|
293
|
-
|
|
411
|
+
if (postgres) {
|
|
412
|
+
await runFixedStatement(transaction, SQL_TIMEOUTS, [String(integer(held, 'remaining_ms'))])
|
|
413
|
+
}
|
|
294
414
|
const result = await work(transaction)
|
|
295
415
|
refuseEnded(fence)
|
|
296
416
|
if ((await rows(transaction, SQL_STILL_HELD, params(fence))).length === 0) {
|
|
@@ -368,11 +488,11 @@ export function jobLeases(runtime: () => DataLayerRuntime): JobLeases {
|
|
|
368
488
|
async cancelInFlight(fence: LeaseFence): Promise<void> {
|
|
369
489
|
ended.add(fence)
|
|
370
490
|
// The transaction rolls back and gives its connection back now, even while its work waits on something else;
|
|
371
|
-
// a statement still running is cancelled, so the ROLLBACK queued behind it runs.
|
|
491
|
+
// a statement still running is cancelled, so the ROLLBACK queued behind it runs. SQLite records no backend.
|
|
372
492
|
stopOf(fence).abort(lost(fence))
|
|
373
493
|
const running = [...(inFlight.get(fence) ?? [])]
|
|
374
494
|
for (const backend of running) {
|
|
375
|
-
await
|
|
495
|
+
await runFixedStatement(session(), SQL_CANCEL, [backend.pid, backend.began])
|
|
376
496
|
}
|
|
377
497
|
},
|
|
378
498
|
|
package/src/links.ts
CHANGED
|
@@ -57,5 +57,12 @@ export function linkValues(table: string, links: readonly Container[], grant: Re
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function linksPredicate(table: string, values: readonly LinkValue[]): OperationNode {
|
|
60
|
-
|
|
60
|
+
const terms: OperationNode[] = []
|
|
61
|
+
for (let index = 0; index < values.length; index++) {
|
|
62
|
+
const entry = values[index]
|
|
63
|
+
if (entry !== undefined) {
|
|
64
|
+
terms[terms.length] = equals(table, entry.link.column, entry.value)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return allOf(terms)
|
|
61
68
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @fabrika/db/migrations-sqlite — the framework's own tables in an application's SQLite database, a Cloudflare
|
|
2
|
+
// Durable Object's (ADR-0065). The same tables and columns as the Postgres bundle in `migrations.ts`, without the
|
|
3
|
+
// history a Postgres schema carries: no form files and no adoption of collection tables, which no SQLite database
|
|
4
|
+
// ever had. Plain data, applied as the bundle `fabrika` before the application's `app` bundle.
|
|
5
|
+
//
|
|
6
|
+
// The SQLite forms of the column types, which every framework statement and the row reads rely on:
|
|
7
|
+
// - a timestamp is TEXT in UTC, `YYYY-MM-DDTHH:MM:SS.sssZ`: what `strftime(SQLITE_NOW_FORMAT, 'now')` and
|
|
8
|
+
// `Date#toISOString()` both write, so text order is time order;
|
|
9
|
+
// - a boolean is INTEGER 0 or 1; a UUID is TEXT; a BIGINT is INTEGER.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
FILE_DELETIONS_TABLE,
|
|
13
|
+
FILES_TABLE,
|
|
14
|
+
FORM_ADMISSIONS_TABLE,
|
|
15
|
+
FORM_BYTES_TABLE,
|
|
16
|
+
FORM_CHALLENGES_TABLE,
|
|
17
|
+
type FrameworkMigration,
|
|
18
|
+
JOB_KICKS_TABLE,
|
|
19
|
+
JOB_LEASES_TABLE,
|
|
20
|
+
WEBHOOK_BINDINGS_TABLE,
|
|
21
|
+
WEBHOOK_DELIVERIES_TABLE,
|
|
22
|
+
} from './migrations'
|
|
23
|
+
|
|
24
|
+
/** The `strftime` format of a stored timestamp; `strftime(SQLITE_NOW_FORMAT, 'now')` is the database's clock. */
|
|
25
|
+
export const SQLITE_NOW_FORMAT = '%Y-%m-%dT%H:%M:%fZ'
|
|
26
|
+
|
|
27
|
+
export const SQLITE_FRAMEWORK_MIGRATIONS: readonly FrameworkMigration[] = Object.freeze([
|
|
28
|
+
Object.freeze({
|
|
29
|
+
name: '0001_framework.sql',
|
|
30
|
+
sql: `-- A binding admits deliveries for one scope value from one provider account, signed with its own secret.
|
|
31
|
+
CREATE TABLE ${WEBHOOK_BINDINGS_TABLE} (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
webhook TEXT NOT NULL,
|
|
34
|
+
scope_type TEXT NOT NULL,
|
|
35
|
+
scope_value TEXT NOT NULL,
|
|
36
|
+
account TEXT NOT NULL,
|
|
37
|
+
secret TEXT NOT NULL,
|
|
38
|
+
created_by TEXT NOT NULL,
|
|
39
|
+
created_at INTEGER NOT NULL
|
|
40
|
+
) STRICT;
|
|
41
|
+
|
|
42
|
+
-- GitHub signs no timestamp, so a recorded delivery id is the replay defence. A delivery is claimed with a lease and
|
|
43
|
+
-- completed in its handler's transaction, which clears the lease.
|
|
44
|
+
CREATE TABLE ${WEBHOOK_DELIVERIES_TABLE} (
|
|
45
|
+
binding_id TEXT NOT NULL REFERENCES ${WEBHOOK_BINDINGS_TABLE} (id) ON DELETE CASCADE,
|
|
46
|
+
delivery_id TEXT NOT NULL,
|
|
47
|
+
received_at INTEGER NOT NULL,
|
|
48
|
+
lease_until INTEGER,
|
|
49
|
+
PRIMARY KEY (binding_id, delivery_id)
|
|
50
|
+
) STRICT;
|
|
51
|
+
|
|
52
|
+
-- One row per job: the lease a run takes, fenced by its generation, and the outcome of the last run.
|
|
53
|
+
CREATE TABLE ${JOB_LEASES_TABLE} (
|
|
54
|
+
job TEXT PRIMARY KEY,
|
|
55
|
+
generation INTEGER NOT NULL,
|
|
56
|
+
holder TEXT,
|
|
57
|
+
expires_at TEXT NOT NULL,
|
|
58
|
+
attempt_deadline TEXT NOT NULL,
|
|
59
|
+
first_seen_at TEXT NOT NULL,
|
|
60
|
+
last_started_at TEXT,
|
|
61
|
+
last_finished_at TEXT,
|
|
62
|
+
last_status TEXT,
|
|
63
|
+
last_succeeded_at TEXT,
|
|
64
|
+
last_duration_ms INTEGER,
|
|
65
|
+
last_missed_reported_at TEXT
|
|
66
|
+
) STRICT;
|
|
67
|
+
|
|
68
|
+
-- Whether a kick arrived after the job's current run started: that run runs the job once more after it succeeded.
|
|
69
|
+
CREATE TABLE ${JOB_KICKS_TABLE} (
|
|
70
|
+
job TEXT PRIMARY KEY,
|
|
71
|
+
pending INTEGER NOT NULL CHECK (pending IN (0, 1))
|
|
72
|
+
) STRICT;
|
|
73
|
+
|
|
74
|
+
-- A form's admission counters, one row per form, bucket and minute; a row of a past minute is pruned by the next
|
|
75
|
+
-- admission.
|
|
76
|
+
CREATE TABLE ${FORM_ADMISSIONS_TABLE} (
|
|
77
|
+
form TEXT NOT NULL,
|
|
78
|
+
bucket TEXT NOT NULL,
|
|
79
|
+
window_start TEXT NOT NULL,
|
|
80
|
+
count INTEGER NOT NULL,
|
|
81
|
+
PRIMARY KEY (form, bucket, window_start)
|
|
82
|
+
) STRICT;
|
|
83
|
+
CREATE INDEX ${FORM_ADMISSIONS_TABLE}_window_start ON ${FORM_ADMISSIONS_TABLE} (window_start);
|
|
84
|
+
|
|
85
|
+
-- The digest of each challenge token a form accepted, until the token could no longer verify: a replay is refused.
|
|
86
|
+
CREATE TABLE ${FORM_CHALLENGES_TABLE} (
|
|
87
|
+
form TEXT NOT NULL,
|
|
88
|
+
token_digest TEXT NOT NULL,
|
|
89
|
+
expires_at TEXT NOT NULL,
|
|
90
|
+
PRIMARY KEY (form, token_digest)
|
|
91
|
+
) STRICT;
|
|
92
|
+
CREATE INDEX ${FORM_CHALLENGES_TABLE}_expires_at ON ${FORM_CHALLENGES_TABLE} (expires_at);
|
|
93
|
+
|
|
94
|
+
-- The bytes a form's files may write per hour: one row per form and hour, which the next reservation of a later hour
|
|
95
|
+
-- prunes.
|
|
96
|
+
CREATE TABLE ${FORM_BYTES_TABLE} (
|
|
97
|
+
form TEXT NOT NULL,
|
|
98
|
+
window_start TEXT NOT NULL,
|
|
99
|
+
bytes INTEGER NOT NULL,
|
|
100
|
+
PRIMARY KEY (form, window_start)
|
|
101
|
+
) STRICT;
|
|
102
|
+
|
|
103
|
+
-- Every stored file: a value a table's file column references by id. A row is 'pending' from before its object
|
|
104
|
+
-- (<kind>/<id>) is written until the first reference claims it, then 'quarantined' (a scanning kind) or 'live'.
|
|
105
|
+
CREATE TABLE ${FILES_TABLE} (
|
|
106
|
+
id TEXT PRIMARY KEY,
|
|
107
|
+
kind TEXT NOT NULL,
|
|
108
|
+
status TEXT NOT NULL CONSTRAINT ${FILES_TABLE}_status CHECK (status IN ('pending', 'quarantined', 'live', 'rejected')),
|
|
109
|
+
object_key TEXT NOT NULL UNIQUE,
|
|
110
|
+
size INTEGER,
|
|
111
|
+
sha256 TEXT,
|
|
112
|
+
content_type TEXT NOT NULL,
|
|
113
|
+
name TEXT,
|
|
114
|
+
scope TEXT,
|
|
115
|
+
request TEXT,
|
|
116
|
+
created_at TEXT NOT NULL,
|
|
117
|
+
object_version TEXT,
|
|
118
|
+
scan_attempts INTEGER NOT NULL DEFAULT 0,
|
|
119
|
+
scan_after TEXT,
|
|
120
|
+
legacy TEXT
|
|
121
|
+
) STRICT;
|
|
122
|
+
CREATE INDEX ${FILES_TABLE}_created_at ON ${FILES_TABLE} (created_at) WHERE legacy IS NULL;
|
|
123
|
+
CREATE INDEX ${FILES_TABLE}_scan ON ${FILES_TABLE} (scan_after) WHERE status = 'quarantined';
|
|
124
|
+
|
|
125
|
+
-- An object whose file row is deleted, until the object is: the sweep finishes a deletion a crash interrupted.
|
|
126
|
+
CREATE TABLE ${FILE_DELETIONS_TABLE} (
|
|
127
|
+
object_key TEXT PRIMARY KEY,
|
|
128
|
+
recorded_at TEXT NOT NULL
|
|
129
|
+
) STRICT;
|
|
130
|
+
`,
|
|
131
|
+
}),
|
|
132
|
+
])
|
package/src/nodes.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
WhereNode,
|
|
39
39
|
} from 'kysely'
|
|
40
40
|
import { inSet, type NameSet, nameSet, namesOf } from './builtins'
|
|
41
|
+
import type { SqlDialect } from './database'
|
|
41
42
|
import type { Compare, Condition, Contains, JoinState, Primitive, SelectState } from './description'
|
|
42
43
|
import { QueryRefusedError } from './errors'
|
|
43
44
|
|
|
@@ -49,31 +50,55 @@ export function equals(table: string, name: string, value: Primitive): Operation
|
|
|
49
50
|
return BinaryOperationNode.create(column(table, name), OperatorNode.create('='), ValueNode.create(value))
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// The scope predicates are built through these, so they read their nodes by index, never through an iterator or a method.
|
|
52
54
|
export function allOf(nodes: readonly OperationNode[]): OperationNode {
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
+
let joined = nodes[0]
|
|
56
|
+
if (joined === undefined) {
|
|
55
57
|
throw new QueryRefusedError('statement_shape', 'an empty conjunction')
|
|
56
58
|
}
|
|
57
|
-
|
|
59
|
+
for (let index = 1; index < nodes.length; index++) {
|
|
60
|
+
const node = nodes[index]
|
|
61
|
+
if (node !== undefined) {
|
|
62
|
+
joined = AndNode.create(joined, node)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return joined
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
/** One node, or the OR of several in a parenthesis: inside an AND, `a and b or c` would read as `(a and b) or c`. */
|
|
61
69
|
export function anyOf(nodes: readonly OperationNode[]): OperationNode {
|
|
62
|
-
|
|
63
|
-
if (
|
|
70
|
+
let joined = nodes[0]
|
|
71
|
+
if (joined === undefined) {
|
|
64
72
|
throw new QueryRefusedError('statement_shape', 'an empty disjunction')
|
|
65
73
|
}
|
|
66
|
-
|
|
74
|
+
for (let index = 1; index < nodes.length; index++) {
|
|
75
|
+
const node = nodes[index]
|
|
76
|
+
if (node !== undefined) {
|
|
77
|
+
joined = OrNode.create(joined, node)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return nodes.length === 1 ? joined : ParensNode.create(joined)
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
export function inList(table: string, name: string, values: readonly string[]): OperationNode {
|
|
70
84
|
return BinaryOperationNode.create(
|
|
71
85
|
column(table, name),
|
|
72
86
|
OperatorNode.create('in'),
|
|
73
|
-
ValueListNode.create(values
|
|
87
|
+
ValueListNode.create(valueNodes(values)),
|
|
74
88
|
)
|
|
75
89
|
}
|
|
76
90
|
|
|
91
|
+
function valueNodes(values: readonly string[]): ValueNode[] {
|
|
92
|
+
const nodes: ValueNode[] = []
|
|
93
|
+
for (let index = 0; index < values.length; index++) {
|
|
94
|
+
const value = values[index]
|
|
95
|
+
if (value !== undefined) {
|
|
96
|
+
nodes[nodes.length] = ValueNode.create(value)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return nodes
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
/** The scope of a `Within` that is not limited to scope values. */
|
|
78
103
|
export function everything(): OperationNode {
|
|
79
104
|
return ValueNode.createImmediate(true)
|
|
@@ -167,6 +192,15 @@ function joinedSelections(join: JoinState): SelectionNode[] {
|
|
|
167
192
|
return columns.map((name) => SelectionNode.create(AliasNode.create(column(child, name), IdentifierNode.create(`${child}.${name}`))))
|
|
168
193
|
}
|
|
169
194
|
|
|
195
|
+
/**
|
|
196
|
+
* One order term. Postgres sorts nulls last ascending and first descending; SQLite the other way, so on SQLite the term
|
|
197
|
+
* states Postgres's order and a keyset page (`orm.ts`) reads the same rows after its cursor on both.
|
|
198
|
+
*/
|
|
199
|
+
function orderItem(table: string, order: SelectState['orderBy'][number], dialect: SqlDialect): OrderByItemNode {
|
|
200
|
+
const item = OrderByItemNode.create(column(table, order.column), RawNode.createWithSql(order.direction))
|
|
201
|
+
return dialect === 'postgres' ? item : Object.freeze({ ...item, nulls: order.direction === 'asc' ? 'last' : 'first' })
|
|
202
|
+
}
|
|
203
|
+
|
|
170
204
|
/** `after` is a keyset page's position, ANDed to the caller's conditions inside their parenthesis. */
|
|
171
205
|
export function selectNode(
|
|
172
206
|
state: SelectState,
|
|
@@ -174,12 +208,13 @@ export function selectNode(
|
|
|
174
208
|
scope: OperationNode,
|
|
175
209
|
after?: OperationNode,
|
|
176
210
|
days: NameSet = NO_DAYS,
|
|
211
|
+
dialect: SqlDialect = 'postgres',
|
|
177
212
|
): SelectQueryNode {
|
|
178
213
|
const table = state.table
|
|
179
214
|
const columns = state.columns ?? visible
|
|
180
215
|
const conditions = conditionsNode(table, state.conditions)
|
|
181
216
|
const filter = after === undefined ? conditions : conditions === undefined ? after : AndNode.create(conditions, ParensNode.create(after))
|
|
182
|
-
const orderBy = state.orderBy.map((order) =>
|
|
217
|
+
const orderBy = state.orderBy.map((order) => orderItem(table, order, dialect))
|
|
183
218
|
const node: SelectQueryNode = {
|
|
184
219
|
kind: 'SelectQueryNode',
|
|
185
220
|
from: FromNode.create([TableNode.create(table)]),
|
|
@@ -352,6 +387,7 @@ export function claimNode(
|
|
|
352
387
|
due: { readonly due: string; readonly attempts: string; readonly now: Date; readonly maxAttempts: number },
|
|
353
388
|
scope: OperationNode,
|
|
354
389
|
days: NameSet,
|
|
390
|
+
dialect: SqlDialect = 'postgres',
|
|
355
391
|
): SelectQueryNode {
|
|
356
392
|
const table = state.table
|
|
357
393
|
const waiting = allOf([
|
|
@@ -359,7 +395,7 @@ export function claimNode(
|
|
|
359
395
|
BinaryOperationNode.create(column(table, due.due), OperatorNode.create('<='), ValueNode.create(due.now)),
|
|
360
396
|
BinaryOperationNode.create(column(table, due.attempts), OperatorNode.create('<'), ValueNode.create(due.maxAttempts)),
|
|
361
397
|
])
|
|
362
|
-
const node = selectNode(state, columns, scope, waiting, days)
|
|
398
|
+
const node = selectNode(state, columns, scope, waiting, days, dialect)
|
|
363
399
|
return Object.freeze({ ...node, endModifiers: [SelectModifierNode.create('ForUpdate'), SelectModifierNode.create('SkipLocked')] })
|
|
364
400
|
}
|
|
365
401
|
|