@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/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 = 'select now() as 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 = `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at)
96
- values ($1, 0, null, now(), now(), now()) on conflict (job) do nothing`
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 = `update ${T} set holder = null, last_status = 'failed', last_finished_at = now(),
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 = `insert into ${T} (job, generation, holder, expires_at, attempt_deadline, first_seen_at, last_started_at)
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 = `update ${T} set generation = generation + 1, holder = $4, expires_at = now() + $5::bigint ${MS},
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 = `update ${T} set holder = null, last_finished_at = now(), last_status = $4,
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
- // A free lease whose row no transaction holds (`skip locked`): an attempt's fence holds it `for share` while it writes.
118
- const SQL_TRY_ACQUIRE = `with free as (select job from ${T} where job = $1 and holder is null for update skip locked)
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 = `insert into ${K} (job, pending) values ($1, true) on conflict (job) do update set pending = true where ${K}.pending = false`
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 = `update ${K} set pending = false where job = $1 and pending returning job`
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 = `update ${T} set last_missed_reported_at = now()
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
- // Inside a mutation's transaction, so `clock_timestamp()`: `now()` is when the transaction began.
135
- const SQL_FENCE = `select pg_backend_pid() as pid, transaction_timestamp()::text as began,
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 the transaction.
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
- const SQL_STILL_HELD = `select job from ${T} where job = $1 and generation = $2 and holder = $3 and attempt_deadline > clock_timestamp()`
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 = `select now() as now, ${STATE_COLUMNS} from ${T} where job = $1`
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(session: Session, text: string, parameters: readonly (string | number | null)[]): Promise<Readonly<Record<string, unknown>>[]> {
157
- return (await runFixedStatement(session, text, parameters)).map(record)
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 = 'select clock_timestamp() as now'
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 = { pid: integer(held, 'pid'), began: text(held, 'began') }
289
- const running = inFlight.get(fence) ?? new Set<Backend>()
290
- running.add(backend)
291
- inFlight.set(fence, running)
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
- await rows(transaction, SQL_TIMEOUTS, [String(integer(held, 'remaining_ms'))])
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 rows(session(), SQL_CANCEL, [backend.pid, backend.began])
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
- return allOf(values.map(({ link, value }) => equals(table, link.column, value)))
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
- const [first, ...rest] = nodes
54
- if (first === undefined) {
55
+ let joined = nodes[0]
56
+ if (joined === undefined) {
55
57
  throw new QueryRefusedError('statement_shape', 'an empty conjunction')
56
58
  }
57
- return rest.reduce<OperationNode>((left, right) => AndNode.create(left, right), first)
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
- const [first, ...rest] = nodes
63
- if (first === undefined) {
70
+ let joined = nodes[0]
71
+ if (joined === undefined) {
64
72
  throw new QueryRefusedError('statement_shape', 'an empty disjunction')
65
73
  }
66
- return rest.length === 0 ? first : ParensNode.create(rest.reduce<OperationNode>((left, right) => OrNode.create(left, right), first))
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.map((value) => ValueNode.create(value))),
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) => OrderByItemNode.create(column(table, order.column), RawNode.createWithSql(order.direction)))
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