@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
@@ -9,7 +9,7 @@ import { freeze } from './builtins'
9
9
  import { type FixedParameter, runFixedStatement, type Session, sessionOf } from './database'
10
10
  import { DatabaseError, QueryRefusedError } from './errors'
11
11
  import { isPlainIdentifier } from './identifier'
12
- import { fencedTransaction } from './job-leases'
12
+ import { type DialectTexts, fencedTransaction, SQLITE_NOW, sqliteNowPlus, sqliteTimestamp } from './job-leases'
13
13
  import { FILE_DELETIONS_TABLE, FILES_TABLE } from './migrations'
14
14
  import type { DataLayerRuntime } from './store'
15
15
 
@@ -112,55 +112,136 @@ export interface StoredFiles {
112
112
  }
113
113
 
114
114
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
115
- /** Postgres' code of a foreign key violation: the delete of a file some column still references. */
115
+ /** Postgres' code of a foreign key violation, which the SQLite engine maps its own to: the delete of a referenced file. */
116
116
  const FOREIGN_KEY_VIOLATION = '23503'
117
117
 
118
118
  const SERVED_COLUMNS = `id::text as id, kind, name, content_type, size, status, object_key, object_version`
119
+ const SQLITE_SERVED_COLUMNS = `id, kind, name, content_type, size, status, object_key, object_version`
120
+ const N = SQLITE_NOW
121
+
122
+ const SQL_STAGE: DialectTexts = {
123
+ postgres: `insert into ${F} (id, kind, status, object_key, content_type, name, request, created_at)
124
+ values ($1::uuid, $2, 'pending', $3, $4, $5, $6, now())`,
125
+ sqlite: `insert into ${F} (id, kind, status, object_key, content_type, name, request, created_at)
126
+ values (?1, ?2, 'pending', ?3, ?4, ?5, ?6, ${N})`,
127
+ }
119
128
 
120
- const SQL_STAGE = `insert into ${F} (id, kind, status, object_key, content_type, name, request, created_at)
121
- values ($1::uuid, $2, 'pending', $3, $4, $5, $6, now())`
122
-
123
- const SQL_WRITTEN = `update ${F} set size = $2::bigint, sha256 = $3 where id = $1::uuid and status = 'pending' returning id`
129
+ const SQL_WRITTEN: DialectTexts = {
130
+ postgres: `update ${F} set size = $2::bigint, sha256 = $3 where id = $1::uuid and status = 'pending' returning id`,
131
+ sqlite: `update ${F} set size = cast(?2 as integer), sha256 = ?3 where id = ?1 and status = 'pending' returning id`,
132
+ }
124
133
 
125
134
  const SQL_ABANDON = `with gone as (delete from ${F} where id = $1::uuid and status = 'pending' and request = $2 returning object_key)
126
135
  insert into ${D} (object_key, recorded_at) select object_key, now() from gone on conflict (object_key) do nothing returning object_key`
127
136
 
128
- const SQL_FIND = `select ${SERVED_COLUMNS} from ${F} where id = $1::uuid`
137
+ // SQLite has no data-modifying CTE: the delete and the record of its object are two statements in one transaction.
138
+ const SQLITE_ABANDON = `delete from ${F} where id = ?1 and status = 'pending' and request = ?2 returning object_key`
139
+ const SQLITE_RECORD_ABANDONED =
140
+ `insert into ${D} (object_key, recorded_at) values (?1, ${N}) on conflict (object_key) do nothing returning object_key`
129
141
 
130
- const SQL_REQUARANTINE = `update ${F} set status = 'quarantined', object_version = null, scan_attempts = 0, scan_after = now()
131
- where id = $1::uuid and status = 'live' and object_version is not distinct from $2`
142
+ const SQL_FIND: DialectTexts = {
143
+ postgres: `select ${SERVED_COLUMNS} from ${F} where id = $1::uuid`,
144
+ sqlite: `select ${SQLITE_SERVED_COLUMNS} from ${F} where id = ?1`,
145
+ }
132
146
 
133
- const SQL_METADATA = `select id::text as id, kind, name, content_type, size, status from ${F}
134
- where id = any(string_to_array($1, ',')::uuid[])`
147
+ const SQL_REQUARANTINE: DialectTexts = {
148
+ postgres: `update ${F} set status = 'quarantined', object_version = null, scan_attempts = 0, scan_after = now()
149
+ where id = $1::uuid and status = 'live' and object_version is not distinct from $2`,
150
+ sqlite: `update ${F} set status = 'quarantined', object_version = null, scan_attempts = 0, scan_after = ${N}
151
+ where id = ?1 and status = 'live' and object_version is ?2`,
152
+ }
153
+
154
+ // The ids are one parameter: a comma-separated list on Postgres, a JSON array on SQLite.
155
+ const SQL_METADATA: DialectTexts = {
156
+ postgres: `select id::text as id, kind, name, content_type, size, status from ${F}
157
+ where id = any(string_to_array($1, ',')::uuid[])`,
158
+ sqlite: `select id, kind, name, content_type, size, status from ${F}
159
+ where id in (select value from json_each(?1))`,
160
+ }
135
161
 
136
- const SQL_QUARANTINED = `select id::text as id, object_key, size, sha256, content_type, scan_attempts, coalesce(scan_after, created_at) as due_since
162
+ const SQL_QUARANTINED: DialectTexts = {
163
+ postgres: `select id::text as id, object_key, size, sha256, content_type, scan_attempts, coalesce(scan_after, created_at) as due_since
137
164
  from ${F} where status = 'quarantined' and kind = any(string_to_array($1, ',')) and (scan_after is null or scan_after < $2::timestamptz)
138
- order by scan_after nulls first, created_at limit $3::int`
165
+ order by scan_after nulls first, created_at limit $3::int`,
166
+ sqlite: `select id, object_key, size, sha256, content_type, scan_attempts, coalesce(scan_after, created_at) as due_since
167
+ from ${F} where status = 'quarantined' and kind in (select value from json_each(?1)) and (scan_after is null or scan_after < ?2)
168
+ order by scan_after nulls first, created_at limit ?3`,
169
+ }
139
170
 
140
- const SQL_PROMOTE = `update ${F} set status = 'live', object_version = $3 where id = $1::uuid and status = 'quarantined' and sha256 = $2 returning id`
171
+ const SQL_PROMOTE: DialectTexts = {
172
+ postgres: `update ${F} set status = 'live', object_version = $3 where id = $1::uuid and status = 'quarantined' and sha256 = $2 returning id`,
173
+ sqlite: `update ${F} set status = 'live', object_version = ?3 where id = ?1 and status = 'quarantined' and sha256 = ?2 returning id`,
174
+ }
141
175
 
142
- const SQL_DEFER = `update ${F} set scan_attempts = $2::int, scan_after = $3::timestamptz
143
- where id = $1::uuid and status = 'quarantined' and scan_attempts = $2::int - 1 returning id`
176
+ const SQL_DEFER: DialectTexts = {
177
+ postgres: `update ${F} set scan_attempts = $2::int, scan_after = $3::timestamptz
178
+ where id = $1::uuid and status = 'quarantined' and scan_attempts = $2::int - 1 returning id`,
179
+ sqlite: `update ${F} set scan_attempts = ?2, scan_after = ?3
180
+ where id = ?1 and status = 'quarantined' and scan_attempts = ?2 - 1 returning id`,
181
+ }
144
182
 
145
- const SQL_REJECT = `update ${F} set status = 'rejected' where id = $1::uuid and status = 'quarantined' returning id`
183
+ const SQL_REJECT: DialectTexts = {
184
+ postgres: `update ${F} set status = 'rejected' where id = $1::uuid and status = 'quarantined' returning id`,
185
+ sqlite: `update ${F} set status = 'rejected' where id = ?1 and status = 'quarantined' returning id`,
186
+ }
146
187
 
147
- const SQL_UNFINISHED = `select object_key from ${D} order by recorded_at limit $1::int`
188
+ const SQL_UNFINISHED: DialectTexts = {
189
+ postgres: `select object_key from ${D} order by recorded_at limit $1::int`,
190
+ sqlite: `select object_key from ${D} order by recorded_at limit ?1`,
191
+ }
148
192
 
149
- const SQL_FORGET = `delete from ${D} where object_key = $1`
193
+ const SQL_FORGET: DialectTexts = {
194
+ postgres: `delete from ${D} where object_key = $1`,
195
+ sqlite: `delete from ${D} where object_key = ?1`,
196
+ }
150
197
 
151
198
  const SQL_REMOVE = `with gone as (
152
199
  delete from ${F} where id = $1::uuid and legacy is null and created_at < now() - $2::bigint * interval '1 millisecond' returning object_key
153
200
  )
154
201
  insert into ${D} (object_key, recorded_at) select object_key, now() from gone returning object_key`
155
202
 
203
+ const SQLITE_REMOVE = `delete from ${F} where id = ?1 and legacy is null and created_at < ${sqliteNowPlus('-?2')} returning object_key`
204
+ const SQLITE_RECORD_REMOVED = `insert into ${D} (object_key, recorded_at) values (?1, ${N}) returning object_key`
205
+
156
206
  /** The lock a reference write takes: a file being deleted cannot gain a reference, and a referenced one is not deleted. */
157
- const SQL_LOCK = `select kind, status, request, scope, created_at > now() - $2::bigint * interval '1 millisecond' as fresh
158
- from ${F} where id = $1::uuid for share`
207
+ const SQL_LOCK: DialectTexts = {
208
+ postgres: `select kind, status, request, scope, created_at > now() - $2::bigint * interval '1 millisecond' as fresh
209
+ from ${F} where id = $1::uuid for share`,
210
+ // No lock: while the reference write's transaction is open, the Durable Object runs no sweep.
211
+ sqlite: `select kind, status, request, scope, created_at > ${sqliteNowPlus('-?2')} as fresh
212
+ from ${F} where id = ?1`,
213
+ }
159
214
 
160
- const SQL_CLAIM = `update ${F} set status = $2 where id = $1::uuid and status = 'pending' returning id`
215
+ const SQL_CLAIM: DialectTexts = {
216
+ postgres: `update ${F} set status = $2 where id = $1::uuid and status = 'pending' returning id`,
217
+ sqlite: `update ${F} set status = ?2 where id = ?1 and status = 'pending' returning id`,
218
+ }
161
219
 
162
220
  /** The first dimensional reference stamps the scope; one from another top-level scope matches nothing. */
163
- const SQL_STAMP = `update ${F} set scope = $2 where id = $1::uuid and (scope is null or scope = $2) returning id`
221
+ const SQL_STAMP: DialectTexts = {
222
+ postgres: `update ${F} set scope = $2 where id = $1::uuid and (scope is null or scope = $2) returning id`,
223
+ sqlite: `update ${F} set scope = ?2 where id = ?1 and (scope is null or scope = ?2) returning id`,
224
+ }
225
+
226
+ /** Runs the text of `session`'s dialect. */
227
+ function run(session: Session, texts: DialectTexts, parameters: readonly FixedParameter[]): Promise<readonly unknown[]> {
228
+ return runFixedStatement(session, texts[session.dialect], parameters)
229
+ }
230
+
231
+ /**
232
+ * SQLite's form of a delete that records its object for deletion: the delete, then the record, in one transaction.
233
+ * The record's object key, or `null` when nothing was deleted or recorded.
234
+ */
235
+ function deleteAndRecord(session: Session, deletion: string, parameters: readonly FixedParameter[], recording: string): Promise<string | null> {
236
+ return session.transaction(async (transaction) => {
237
+ const [gone] = await runFixedStatement(transaction, deletion, parameters)
238
+ if (gone === undefined) {
239
+ return null
240
+ }
241
+ const [recorded] = await runFixedStatement(transaction, recording, [text(record(gone), 'object_key')])
242
+ return recorded === undefined ? null : text(record(recorded), 'object_key')
243
+ })
244
+ }
164
245
 
165
246
  /** `exists (select 1 from <table> where <column> = f.id)`, one per column, over the policies' validated identifiers. */
166
247
  function referencedBy(columns: readonly FileColumnRef[]): string {
@@ -227,8 +308,9 @@ function status(row: Readonly<Record<string, unknown>>): StoredFileStatus {
227
308
  return found
228
309
  }
229
310
 
230
- function date(row: Readonly<Record<string, unknown>>, column: string): Date {
231
- const value = row[column]
311
+ /** A timestamp column: a `Date` from Postgres, a stored ISO text from SQLite. */
312
+ function date(session: Session, row: Readonly<Record<string, unknown>>, column: string): Date {
313
+ const value = session.dialect === 'sqlite' ? sqliteTimestamp(row[column], `${F}.${column}`) : row[column]
232
314
  if (!(value instanceof Date)) {
233
315
  throw new QueryRefusedError('unexpected_row', `${F}.${column} came back as ${typeof value}`)
234
316
  }
@@ -248,7 +330,7 @@ function metaOf(row: Readonly<Record<string, unknown>>): StoredFileMeta {
248
330
 
249
331
  /** Locks file `id` `for share` in `session`, the transaction that writes a reference to it; `null` when it is gone. */
250
332
  export async function lockStoredFile(session: Session, id: string, graceMs: number): Promise<LockedFile | null> {
251
- const [found] = await runFixedStatement(session, SQL_LOCK, [uuid(id), positive(graceMs, 'a pending grace')])
333
+ const [found] = await run(session, SQL_LOCK, [uuid(id), positive(graceMs, 'a pending grace')])
252
334
  if (found === undefined) {
253
335
  return null
254
336
  }
@@ -258,13 +340,14 @@ export async function lockStoredFile(session: Session, id: string, graceMs: numb
258
340
  status: status(row),
259
341
  request: nullableText(row, 'request'),
260
342
  scope: nullableText(row, 'scope'),
261
- fresh: row['fresh'] === true,
343
+ // SQLite answers a comparison as 1 or 0.
344
+ fresh: row['fresh'] === (session.dialect === 'sqlite' ? 1 : true),
262
345
  })
263
346
  }
264
347
 
265
348
  /** Ends the pending state of a file the reference write locked: `quarantined` for a scanning kind, else `live`. */
266
349
  export async function claimStoredFile(session: Session, id: string, ended: 'quarantined' | 'live'): Promise<void> {
267
- const rows = await runFixedStatement(session, SQL_CLAIM, [uuid(id), ended])
350
+ const rows = await run(session, SQL_CLAIM, [uuid(id), ended])
268
351
  if (rows.length !== 1) {
269
352
  throw new QueryRefusedError('unexpected_row', 'a pending file changed under its lock')
270
353
  }
@@ -272,7 +355,7 @@ export async function claimStoredFile(session: Session, id: string, ended: 'quar
272
355
 
273
356
  /** Sets the scope of a file's first dimensional reference; `false` when it already holds another. */
274
357
  export async function stampStoredFile(session: Session, id: string, scope: string): Promise<boolean> {
275
- return (await runFixedStatement(session, SQL_STAMP, [uuid(id), scope])).length === 1
358
+ return (await run(session, SQL_STAMP, [uuid(id), scope])).length === 1
276
359
  }
277
360
 
278
361
  /** Each file's metadata, by id, in `using`: one statement for every file column of a read. */
@@ -281,7 +364,8 @@ export async function storedFilesMetadata(using: Session, ids: readonly string[]
281
364
  if (unique.length === 0) {
282
365
  return new Map()
283
366
  }
284
- const rows = await runFixedStatement(using, SQL_METADATA, [unique.map(uuid).join(',')])
367
+ const checked = unique.map(uuid)
368
+ const rows = await run(using, SQL_METADATA, [using.dialect === 'sqlite' ? JSON.stringify(checked) : checked.join(',')])
285
369
  return new Map(rows.map((found) => {
286
370
  const meta = metaOf(record(found))
287
371
  return [meta.id, meta] as const
@@ -290,32 +374,36 @@ export async function storedFilesMetadata(using: Session, ids: readonly string[]
290
374
 
291
375
  export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
292
376
  const session = () => sessionOf(runtime())
293
- async function fenced(statement: string, parameters: readonly FixedParameter[]): Promise<boolean> {
377
+ async function fenced(statement: DialectTexts, parameters: readonly FixedParameter[]): Promise<boolean> {
294
378
  const current = runtime()
295
379
  const fence = current.capabilities.fence()
296
380
  if (fence === null) {
297
381
  throw new QueryRefusedError('file_scan', "a scan verdict is written only inside the scan job's lease")
298
382
  }
299
- const rows = await fencedTransaction(sessionOf(current), fence, (transaction) => runFixedStatement(transaction, statement, parameters))
383
+ const rows = await fencedTransaction(sessionOf(current), fence, (transaction) => run(transaction, statement, parameters))
300
384
  return rows.length > 0
301
385
  }
302
386
  return freeze({
303
387
  async stage(file: StagedFile): Promise<void> {
304
- await runFixedStatement(session(), SQL_STAGE, [uuid(file.id), file.kind, file.objectKey, file.contentType, file.name, file.request])
388
+ await run(session(), SQL_STAGE, [uuid(file.id), file.kind, file.objectKey, file.contentType, file.name, file.request])
305
389
  },
306
390
 
307
391
  async written(id: string, written: { readonly size: number; readonly sha256: string }): Promise<void> {
308
392
  if (!/^[0-9a-f]{64}$/.test(written.sha256) || !Number.isSafeInteger(written.size) || written.size < 0) {
309
393
  throw new QueryRefusedError('invalid_argument', 'a written file records its size and the hex SHA-256 of its bytes')
310
394
  }
311
- const rows = await runFixedStatement(session(), SQL_WRITTEN, [uuid(id), String(written.size), written.sha256])
395
+ const rows = await run(session(), SQL_WRITTEN, [uuid(id), String(written.size), written.sha256])
312
396
  if (rows.length !== 1) {
313
397
  throw new QueryRefusedError('unexpected_row', 'a staged file is no longer pending')
314
398
  }
315
399
  },
316
400
 
317
401
  async abandon(id: string, request: string): Promise<string | null> {
318
- const [found] = await runFixedStatement(session(), SQL_ABANDON, [uuid(id), request])
402
+ const current = session()
403
+ if (current.dialect === 'sqlite') {
404
+ return deleteAndRecord(current, SQLITE_ABANDON, [uuid(id), request], SQLITE_RECORD_ABANDONED)
405
+ }
406
+ const [found] = await runFixedStatement(current, SQL_ABANDON, [uuid(id), request])
319
407
  return found === undefined ? null : text(record(found), 'object_key')
320
408
  },
321
409
 
@@ -323,7 +411,7 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
323
411
  if (!UUID.test(id)) {
324
412
  return null
325
413
  }
326
- const [found] = await runFixedStatement(using ?? session(), SQL_FIND, [id])
414
+ const [found] = await run(using ?? session(), SQL_FIND, [id])
327
415
  if (found === undefined) {
328
416
  return null
329
417
  }
@@ -332,7 +420,7 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
332
420
  },
333
421
 
334
422
  async requarantine(id: string, version: string | null): Promise<void> {
335
- await runFixedStatement(session(), SQL_REQUARANTINE, [uuid(id), version])
423
+ await run(session(), SQL_REQUARANTINE, [uuid(id), version])
336
424
  },
337
425
 
338
426
  metadata: storedFilesMetadata,
@@ -341,7 +429,9 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
341
429
  if (kinds.length === 0) {
342
430
  return []
343
431
  }
344
- const rows = await runFixedStatement(session(), SQL_QUARANTINED, [kinds.join(','), now.toISOString(), positive(limit, 'a scan batch')])
432
+ const current = session()
433
+ const listed = current.dialect === 'sqlite' ? JSON.stringify(kinds) : kinds.join(',')
434
+ const rows = await run(current, SQL_QUARANTINED, [listed, now.toISOString(), positive(limit, 'a scan batch')])
345
435
  return rows.map((found) => {
346
436
  const row = record(found)
347
437
  return freeze({
@@ -351,7 +441,7 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
351
441
  sha256: text(row, 'sha256'),
352
442
  contentType: text(row, 'content_type'),
353
443
  scanAttempts: count(row, 'scan_attempts'),
354
- dueSince: date(row, 'due_since'),
444
+ dueSince: date(current, row, 'due_since'),
355
445
  })
356
446
  })
357
447
  },
@@ -363,33 +453,46 @@ export function storedFiles(runtime: () => DataLayerRuntime): StoredFiles {
363
453
  reject: (id: string) => fenced(SQL_REJECT, [uuid(id)]),
364
454
 
365
455
  async unfinishedDeletions(limit: number): Promise<readonly string[]> {
366
- const rows = await runFixedStatement(session(), SQL_UNFINISHED, [positive(limit, 'a sweep batch')])
456
+ const rows = await run(session(), SQL_UNFINISHED, [positive(limit, 'a sweep batch')])
367
457
  return rows.map((found) => text(record(found), 'object_key'))
368
458
  },
369
459
 
370
460
  async forgetDeletion(objectKey: string): Promise<void> {
371
- await runFixedStatement(session(), SQL_FORGET, [objectKey])
461
+ await run(session(), SQL_FORGET, [objectKey])
372
462
  },
373
463
 
374
464
  async adoptReferenced(columns: readonly FileColumnRef[]): Promise<number> {
375
465
  if (columns.length === 0) {
376
466
  return 0
377
467
  }
378
- const statement = `update ${F} f set legacy = null where f.legacy is not null and (${referencedBy(columns)}) returning id`
379
- return (await runFixedStatement(session(), statement, [])).length
468
+ const current = session()
469
+ const statement = current.dialect === 'sqlite'
470
+ ? `update ${F} as f set legacy = null where f.legacy is not null and (${referencedBy(columns)}) returning id`
471
+ : `update ${F} f set legacy = null where f.legacy is not null and (${referencedBy(columns)}) returning id`
472
+ return (await runFixedStatement(current, statement, [])).length
380
473
  },
381
474
 
382
475
  async unreferenced(columns: readonly FileColumnRef[], limit: number, graceMs: number): Promise<readonly string[]> {
383
- const statement = `select f.id::text as id from ${F} f
476
+ const current = session()
477
+ const statement = current.dialect === 'sqlite'
478
+ ? `select f.id as id from ${F} f
479
+ where f.legacy is null and f.created_at < ${sqliteNowPlus('-?1')} and not (${referencedBy(columns)})
480
+ order by f.created_at limit ?2`
481
+ : `select f.id::text as id from ${F} f
384
482
  where f.legacy is null and f.created_at < now() - $1::bigint * interval '1 millisecond' and not (${referencedBy(columns)})
385
483
  order by f.created_at limit $2::int`
386
- const rows = await runFixedStatement(session(), statement, [positive(graceMs, 'a pending grace'), positive(limit, 'a sweep batch')])
484
+ const rows = await runFixedStatement(current, statement, [positive(graceMs, 'a pending grace'), positive(limit, 'a sweep batch')])
387
485
  return rows.map((found) => text(record(found), 'id'))
388
486
  },
389
487
 
390
488
  async remove(id: string, graceMs: number): Promise<string | null> {
391
489
  try {
392
- const [found] = await runFixedStatement(session(), SQL_REMOVE, [uuid(id), positive(graceMs, 'a pending grace')])
490
+ const current = session()
491
+ const parameters = [uuid(id), positive(graceMs, 'a pending grace')]
492
+ if (current.dialect === 'sqlite') {
493
+ return await deleteAndRecord(current, SQLITE_REMOVE, parameters, SQLITE_RECORD_REMOVED)
494
+ }
495
+ const [found] = await runFixedStatement(current, SQL_REMOVE, parameters)
393
496
  return found === undefined ? null : text(record(found), 'object_key')
394
497
  } catch (error) {
395
498
  if (error instanceof DatabaseError && error.sqlState === FOREIGN_KEY_VIOLATION) {
package/src/tables.ts CHANGED
@@ -2,10 +2,13 @@
2
2
  // driver, so `@fabrika/app/contract` and the static contract reader in `@fabrika/check` import it.
3
3
 
4
4
  import type { ColumnPolicyArtifact, TableActionArtifact, TablePolicyArtifact, TableScope } from '@fabrika/auth-core'
5
+ import { type ApplicationRowsPlan, planApplicationRows } from './application-rows'
5
6
  import { freeze, namesOf, ownEntry, ownKeys, setOwn } from './builtins'
6
7
  import { PolicyError } from './errors'
7
8
  import { modelOf, type ScopeModel, type TableModel, tableModels, type Tables } from './policy'
8
9
 
10
+ export { applicationRowRefusalText } from './application-rows'
11
+ export type { ApplicationRowRefusal, ApplicationRowsPlan } from './application-rows'
9
12
  export { PolicyError } from './errors'
10
13
  export type { FileReference } from './file-references'
11
14
  export type {
@@ -96,6 +99,19 @@ export type {
96
99
  VocabularyTablePolicy,
97
100
  } from './vocabulary-tables'
98
101
 
102
+ /**
103
+ * The plan of seeding one row of each application table `rows` names (table → column → value), as an endpoint access
104
+ * case seeds its `appRows`: the order, the foreign keys that link one given row to another, and every refusal.
105
+ * `@fabrika/check` reports the refusals from source; `@fabrika/db/testing` throws the first, both in the words of
106
+ * `applicationRowRefusalText`.
107
+ */
108
+ export function applicationRowsPlan<DB, P>(
109
+ tables: Tables<DB, P>,
110
+ rows: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
111
+ ): ApplicationRowsPlan {
112
+ return planApplicationRows(tableModels(tables), rows)
113
+ }
114
+
99
115
  /** Every table's policy in the form the contract artifact carries, by table name. */
100
116
  export type TablePolicies = Readonly<Record<string, TablePolicyArtifact>>
101
117
 
package/src/temporal.ts CHANGED
@@ -3,11 +3,14 @@
3
3
  // the database's own text (`cast(<column> as text)`, `nodes.ts`), never through the `Date` `Bun.SQL` decodes: outside
4
4
  // 0001–9999 that one drifts by the process's offset and can land on a neighbouring valid day. Only an aggregate's
5
5
  // `min`/`max` and a store's joined child column still arrive as a `Date`. The generated column list names the `date`
6
- // columns (`TableColumns.dates`); every read of a table's rows passes through `answerDays`. A write's text for a day or a
6
+ // columns (`TableColumns.dates`); every read of a table's rows passes through `answerRows` (`answerDays` on Postgres). A write's text for a day or a
7
7
  // timestamp is checked here before anything is sent: Postgres would also take `now`, `today` or `10/12/2026`, and read
8
- // a timestamp without an offset in the session's time zone.
8
+ // a timestamp without an offset in the session's time zone. On SQLite (ADR-0065) a timestamp is stored as UTC ISO text
9
+ // and a boolean as 0 or 1: `storedTimestamp` is what a write binds and `answerRows` turns both back into what Postgres's
10
+ // driver answers.
9
11
 
10
12
  import { defineValue, inSet, type NameSet, ownDescriptor, ownKeys } from './builtins'
13
+ import type { SqlDialect } from './database'
11
14
  import { QueryRefusedError } from './errors'
12
15
 
13
16
  /** The text Postgres prints for a day in 0001–9999 under its default `DateStyle` (ISO). */
@@ -61,7 +64,7 @@ export function answerDays<R>(table: string, days: NameSet, rows: R[]): R[] {
61
64
  }
62
65
 
63
66
  /** An instant in strict ISO 8601: a day, `T`, the time to the minute or finer, and `Z` or an offset. */
64
- const TIMESTAMP_TEXT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.[0-9]{1,6})?)?(?:Z|[+-]([0-9]{2}):([0-9]{2}))$/
67
+ const TIMESTAMP_TEXT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]{1,6}))?)?(?:Z|([+-])([0-9]{2}):([0-9]{2}))$/
65
68
 
66
69
  /** Whether `year-month-day` is a day of the calendar: no 30 February, no month 13. */
67
70
  function isCalendarDay(year: number, month: number, day: number): boolean {
@@ -78,14 +81,117 @@ export function dayInput(value: string, where: string): string {
78
81
  return value
79
82
  }
80
83
 
81
- /** A timestamp a write binds as text: strict ISO 8601 with `Z` or an offset, every field in range. */
82
- export function timestampInput(value: string, where: string): string {
84
+ /** The instant of strict ISO 8601 text, in milliseconds since the epoch (digits past the millisecond dropped), or `undefined`. */
85
+ function instantOf(value: string): number | undefined {
83
86
  const match = TIMESTAMP_TEXT.exec(value)
84
- const [, year, month, day, hour, minute, second = '0', offsetHour = '0', offsetMinute = '0'] = match ?? []
85
- const valid = match !== null && isCalendarDay(Number(year), Number(month), Number(day)) && Number(hour) < 24 && Number(minute) < 60
86
- && Number(second) < 60 && Number(offsetHour) < 16 && Number(offsetMinute) < 60
87
+ if (match === null) {
88
+ return undefined
89
+ }
90
+ const [, year, month, day, hour, minute, second = '0', fraction = '', sign = '+', offsetHour = '0', offsetMinute = '0'] = match
91
+ const valid = isCalendarDay(Number(year), Number(month), Number(day)) && Number(hour) < 24 && Number(minute) < 60 && Number(second) < 60
92
+ && Number(offsetHour) < 16 && Number(offsetMinute) < 60
87
93
  if (!valid) {
88
- throw new QueryRefusedError('invalid_value', `${where} takes a Date or ISO 8601 text with Z or an offset, such as 2026-10-12T08:30:00.000Z`)
94
+ return undefined
95
+ }
96
+ // setUTCFullYear, unlike Date.UTC, keeps the years 0001–0099.
97
+ const date = new Date(0)
98
+ date.setUTCFullYear(Number(year), Number(month) - 1, Number(day))
99
+ date.setUTCHours(Number(hour), Number(minute), Number(second), Number(fraction.padEnd(3, '0').slice(0, 3)))
100
+ const offset = (Number(offsetHour) * 60 + Number(offsetMinute)) * 60_000
101
+ return date.getTime() - (sign === '-' ? -offset : offset)
102
+ }
103
+
104
+ function timestampRefused(where: string): QueryRefusedError {
105
+ return new QueryRefusedError('invalid_value', `${where} takes a Date or ISO 8601 text with Z or an offset, such as 2026-10-12T08:30:00.000Z`)
106
+ }
107
+
108
+ /** A timestamp a write binds as text: strict ISO 8601 with `Z` or an offset, every field in range. */
109
+ export function timestampInput(value: string, where: string): string {
110
+ if (instantOf(value) === undefined) {
111
+ throw timestampRefused(where)
89
112
  }
90
113
  return value
91
114
  }
115
+
116
+ /** A timestamp as SQLite stores it: UTC `Date#toISOString()` text, whose text order is time order. */
117
+ const STORED_TIMESTAMP = /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$/
118
+
119
+ /**
120
+ * What a write or a comparison binds for a timestamp on SQLite: the instant of a `Date`, or of the text `timestampInput`
121
+ * admits, as stored text. Digits past the millisecond are dropped; an instant outside the years 0000–9999 UTC is
122
+ * refused, since its text would not sort.
123
+ */
124
+ export function storedTimestamp(value: string | Date, where: string): string {
125
+ const time = typeof value === 'string' ? instantOf(value) : value.getTime()
126
+ if (time === undefined) {
127
+ throw timestampRefused(where)
128
+ }
129
+ const text = Number.isNaN(time) ? '' : new Date(time).toISOString()
130
+ if (!STORED_TIMESTAMP.test(text)) {
131
+ throw new QueryRefusedError(
132
+ 'invalid_value',
133
+ `${where} holds an instant outside the years 0000-9999 UTC, which SQLite cannot store as sortable text`,
134
+ )
135
+ }
136
+ return text
137
+ }
138
+
139
+ /** The columns of a read's rows the layer answers in a form of its own, by the name each arrives under. */
140
+ export interface RowColumns {
141
+ /** `date` columns: `YYYY-MM-DD` text. */
142
+ readonly days: NameSet
143
+ /** Timestamps: a `Date` (SQLite answers its stored text). */
144
+ readonly timestamps: NameSet
145
+ /** Booleans: `true` or `false` (SQLite answers 1 or 0). */
146
+ readonly booleans: NameSet
147
+ }
148
+
149
+ function unexpected(where: string, expected: string): QueryRefusedError {
150
+ return new QueryRefusedError('unexpected_row', `${where} came back as something other than ${expected}`)
151
+ }
152
+
153
+ /** One SQLite value of `key` as Postgres's driver answers it; a value the migrations' types never store is refused. */
154
+ function sqliteValue(columns: RowColumns, where: string, key: string, value: unknown): unknown {
155
+ if (value === null) {
156
+ return value
157
+ }
158
+ if (inSet(columns.timestamps, key)) {
159
+ if (typeof value !== 'string' || !STORED_TIMESTAMP.test(value)) {
160
+ throw unexpected(where, 'UTC ISO text (YYYY-MM-DDTHH:MM:SS.sssZ), the form a timestamp is stored in on SQLite')
161
+ }
162
+ return new Date(value)
163
+ }
164
+ if (inSet(columns.booleans, key)) {
165
+ if (value !== 0 && value !== 1) {
166
+ throw unexpected(where, '0 or 1, the form a boolean is stored in on SQLite')
167
+ }
168
+ return value === 1
169
+ }
170
+ return typeof value === 'string' && inSet(columns.days, key) ? dayText(value, where) : value
171
+ }
172
+
173
+ /**
174
+ * Every row of a read as the layer answers it, in place: each day as `YYYY-MM-DD` text, and on SQLite each timestamp as
175
+ * a `Date` and each boolean as `true` or `false`, so a handler reads the same values on both dialects.
176
+ */
177
+ export function answerRows<R>(dialect: SqlDialect, table: string, columns: RowColumns, rows: R[]): R[] {
178
+ if (dialect === 'postgres') {
179
+ return answerDays(table, columns.days, rows)
180
+ }
181
+ for (let index = 0; index < rows.length; index++) {
182
+ const row = rows[index]
183
+ if (typeof row !== 'object' || row === null) {
184
+ continue
185
+ }
186
+ const keys = ownKeys(row)
187
+ for (let position = 0; position < keys.length; position++) {
188
+ const key = keys[position] ?? ''
189
+ const value: unknown = ownDescriptor(row, key)?.value
190
+ const answered = sqliteValue(columns, `${table}.${key}`, key, value)
191
+ if (answered !== value) {
192
+ defineValue(row, key, answered)
193
+ }
194
+ }
195
+ }
196
+ return rows
197
+ }