@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/orm.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // is that invocation, then refuses what the site may not do with the message its types give, then runs the store's
4
4
  // own checks of kind, dimension and action against the capability.
5
5
 
6
+ import { uuidv7 } from '@fabrika/platform'
6
7
  import type { OperationNode } from 'kysely'
7
8
  import { BinaryOperationNode, OperatorNode, ValueNode } from 'kysely'
8
9
  import { freeze, hasOwn, inSet, isSafeInteger, nameSet, namesOf, ownDescriptor, ownKeys, ownNames, parseList, setOwn } from './builtins'
@@ -41,11 +42,14 @@ import {
41
42
  appListPredicate,
42
43
  appRecordPredicate,
43
44
  appRowPredicate,
45
+ boundTerms,
46
+ boundValues,
44
47
  childObjectPredicate,
45
48
  childScope,
46
49
  claimedRowPredicate,
47
50
  claimScope,
48
51
  type DataLayerRuntime,
52
+ dialectOf,
49
53
  heldAction,
50
54
  insertPrepared,
51
55
  insertRow,
@@ -60,6 +64,7 @@ import {
60
64
  reachPredicate,
61
65
  readValues,
62
66
  removeRow,
67
+ rowColumnsOf,
63
68
  type Runner,
64
69
  runtimeOf,
65
70
  scopedApplicationRead,
@@ -72,7 +77,7 @@ import {
72
77
  updatingRef,
73
78
  versionArgument,
74
79
  } from './store'
75
- import { answerDays, timestampInput } from './temporal'
80
+ import { answerRows, timestampInput } from './temporal'
76
81
 
77
82
  /** A list page holds this many rows unless the caller asks for fewer, and never more than `MAX_LIST_LIMIT`. */
78
83
  export const DEFAULT_LIST_LIMIT = 50
@@ -331,7 +336,7 @@ function targetRow(
331
336
  const node = lockedSelectNode(context.model.name, visibleFor(context.model, grant.action), scope, context.model.dates)
332
337
  const session = await run.read()
333
338
  const rows = await session.run(node, { root: 'SelectQueryNode', table: context.model.name, joins: {}, scope, lock: 'update' })
334
- const [row] = answerDays(context.model.name, context.model.dates, rows)
339
+ const [row] = answerRows(dialectOf(context), context.model.name, rowColumnsOf(context.model), rows)
335
340
  if (row === undefined) {
336
341
  throw new NotFoundError(`${context.model.name} not found`)
337
342
  }
@@ -590,7 +595,7 @@ function applicationRows(
590
595
  const node = lockedSelectNode(table, visibleFor(model, action), scope, model.dates)
591
596
  const session = await run.read()
592
597
  const rows = await session.run(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'update' })
593
- const [row] = answerDays(table, model.dates, rows)
598
+ const [row] = answerRows(dialectOf(context), table, rowColumnsOf(model), rows)
594
599
  if (row === undefined) {
595
600
  throw new NotFoundError(`${table} not found`)
596
601
  }
@@ -752,10 +757,11 @@ async function claimRows(
752
757
  }
753
758
  const limit = limitOf(options['limit'])
754
759
  const visible = visibleFor(model, grant.action)
755
- const conditions = conditionsOf(actionShape(model, visible), options['where'])
760
+ const conditions = conditionsOf(context, actionShape(model, visible), options['where'])
756
761
  if (scope === undefined) {
757
762
  return []
758
763
  }
764
+ const dialect = dialectOf(context)
759
765
  const key = keyOf(model)
760
766
  const chain = model.chain.map((link) => link.column)
761
767
  const selected = namesOf(nameSet([...visible, key, version, queue.attempts, ...chain]))
@@ -769,6 +775,7 @@ async function claimRows(
769
775
  { due: queue.due, attempts: queue.attempts, now, maxAttempts: queue.maxAttempts },
770
776
  scope,
771
777
  model.dates,
778
+ dialect,
772
779
  )
773
780
  const rows = await session.run<Readonly<Record<string, unknown>>>(node, { root: 'SelectQueryNode', table, joins: {}, scope, lock: 'claim' })
774
781
  const byAttempts = new Map<number, string[]>()
@@ -785,7 +792,7 @@ async function claimRows(
785
792
  const due = new Date(now.getTime() + backoffMs(tickMs, attempts + 1))
786
793
  const changes = new Map<string, Primitive>([[queue.attempts, attempts + 1], [queue.due, due]])
787
794
  const updated = await session.run<Readonly<Record<string, unknown>>>(
788
- updateNode(table, changes, version, scopedWhere(inList(table, key, ids), scope), answer(context)),
795
+ updateNode(table, boundValues(context, changes), version, scopedWhere(inList(table, key, ids), scope), answer(context)),
789
796
  { root: 'UpdateQueryNode', table, joins: {}, scope },
790
797
  )
791
798
  for (const row of updated) {
@@ -797,7 +804,7 @@ async function claimRows(
797
804
  claimed.set(id, { version: next, attempts: attempts + 1, due })
798
805
  }
799
806
  }
800
- return { rows: await answerFiles(model, site.files, session, answerDays(table, model.dates, rows), grant.action), claimed }
807
+ return { rows: await answerFiles(model, site.files, session, answerRows(dialect, table, rowColumnsOf(model), rows), grant.action), claimed }
801
808
  })
802
809
  return leased.rows.map((row) => {
803
810
  const id = String(row[key])
@@ -957,10 +964,11 @@ function unscopedRows(context: TableContext, site: DataSite, run: Runner, mode:
957
964
  aggregate: bound(site, async <Row>(spec: AggregateSpec<Row>) => aggregateRows(context, run, everything(), context.shape, spec)),
958
965
  get: bound(site, async (id: unknown) => {
959
966
  const scope = everything()
960
- const node = selectNode(rowState(table, [], [], 1), visible, scope, byKey(id), model.dates)
967
+ const dialect = dialectOf(context)
968
+ const node = selectNode(rowState(table, [], [], 1), visible, scope, byKey(id), model.dates, dialect)
961
969
  const session = await run.read()
962
970
  const rows = await session.run(node, { root: 'SelectQueryNode', table, joins: {}, scope })
963
- const [row] = answerDays(table, model.dates, rows)
971
+ const [row] = answerRows(dialect, table, rowColumnsOf(model), rows)
964
972
  if (row === undefined) {
965
973
  throw new NotFoundError(`${table} not found`)
966
974
  }
@@ -1003,7 +1011,7 @@ async function insertUnscoped(context: TableContext, run: Runner, principal: str
1003
1011
  )
1004
1012
  }
1005
1013
  }
1006
- row.set(keyOf(model), Bun.randomUUIDv7())
1014
+ row.set(keyOf(model), uuidv7())
1007
1015
  if (model.version !== undefined) {
1008
1016
  row.set(model.version, 1)
1009
1017
  }
@@ -1045,7 +1053,8 @@ function readOptions(options: unknown, allowed: readonly string[], what: string)
1045
1053
  return read
1046
1054
  }
1047
1055
 
1048
- function conditionsOf(shape: TableShape, where: unknown): (Compare | Contains)[] {
1056
+ /** A list's `where`, parsed, with each value as its dialect binds it (`boundTerms`). */
1057
+ function conditionsOf(context: TableContext, shape: TableShape, where: unknown): readonly (Compare | Contains)[] {
1049
1058
  if (where === undefined) {
1050
1059
  return []
1051
1060
  }
@@ -1059,7 +1068,7 @@ function conditionsOf(shape: TableShape, where: unknown): (Compare | Contains)[]
1059
1068
  if (conditions === undefined) {
1060
1069
  throw new QueryRefusedError('invalid_argument', 'where takes a plain array of [column, operator, value] conditions')
1061
1070
  }
1062
- return conditions
1071
+ return boundTerms(context, context.model, conditions)
1063
1072
  }
1064
1073
 
1065
1074
  function orderOf(shape: TableShape, orderBy: unknown, tiebreak: readonly string[]): OrderColumn[] {
@@ -1202,15 +1211,16 @@ async function listRows(
1202
1211
  const table = context.model.name
1203
1212
  const options = readOptions(input, ['where', 'orderBy', 'limit', 'after'], 'list')
1204
1213
  const shape = actionShape(context.model, visible)
1205
- const conditions = conditionsOf(shape, options['where'])
1214
+ const conditions = conditionsOf(context, shape, options['where'])
1206
1215
  const order = orderOf(shape, options['orderBy'], tiebreak)
1207
1216
  const limit = limitOf(options['limit'])
1208
1217
  const position = options['after'] === undefined ? undefined : keysetAfter(table, order, decodeCursor(options['after'], order))
1209
1218
  if (scope === undefined) {
1210
1219
  return { items: [], next: null }
1211
1220
  }
1221
+ const dialect = dialectOf(context)
1212
1222
  const node = withCursorColumns(
1213
- selectNode(rowState(table, conditions, order, limit + 1), visible, scope, position, context.model.dates),
1223
+ selectNode(rowState(table, conditions, order, limit + 1), visible, scope, position, context.model.dates, dialect),
1214
1224
  table,
1215
1225
  order.map((term) => term.column),
1216
1226
  )
@@ -1221,7 +1231,7 @@ async function listRows(
1221
1231
  joins: {},
1222
1232
  scope,
1223
1233
  })
1224
- const page = answerDays(table, context.model.dates, rows.slice(0, limit))
1234
+ const page = answerRows(dialect, table, rowColumnsOf(context.model), rows.slice(0, limit))
1225
1235
  const last = page[page.length - 1]
1226
1236
  const items = page.map((row) => withoutCursor(row, order))
1227
1237
  return {
@@ -1239,7 +1249,7 @@ async function countRows(
1239
1249
  ): Promise<number> {
1240
1250
  const table = context.model.name
1241
1251
  const options = readOptions(input, ['where'], 'count')
1242
- const conditions = conditionsOf(actionShape(context.model, visible), options['where'])
1252
+ const conditions = conditionsOf(context, actionShape(context.model, visible), options['where'])
1243
1253
  if (scope === undefined) {
1244
1254
  return 0
1245
1255
  }
package/src/policy.ts CHANGED
@@ -160,6 +160,8 @@ export interface TableColumns<Col extends string = string, Table extends string
160
160
  readonly dates?: readonly Col[]
161
161
  /** The `timestamp` and `timestamptz` columns, which a write takes as a `Date` or strict ISO 8601 text; absent when none. */
162
162
  readonly timestamps?: readonly Col[]
163
+ /** On SQLite, the columns declared `BOOLEAN`, stored as 0 or 1 and read as a boolean; absent when none. */
164
+ readonly booleans?: readonly Col[]
163
165
  }
164
166
 
165
167
  /** The columns and foreign keys of every table, generated next to the schema types. */
@@ -221,6 +223,8 @@ export interface TableModel {
221
223
  readonly dates: NameSet
222
224
  /** The `timestamp` and `timestamptz` columns: a write's text must be strict ISO 8601 with an offset. */
223
225
  readonly timestamps: NameSet
226
+ /** On SQLite, the `BOOLEAN` columns, stored as 0 or 1. */
227
+ readonly booleans: NameSet
224
228
  /** The table's foreign keys, from the column list; a containment column must be one to its container's key. */
225
229
  readonly foreignKeys: readonly ForeignKey[]
226
230
  /**
@@ -342,7 +346,7 @@ function parseVocabulary(input: unknown): VocabularyModel {
342
346
  return freeze({ dimensions: freeze(containment), actions: nameSet(actions) })
343
347
  }
344
348
 
345
- const COLUMN_LIST_FIELDS = nameSet(['columns', 'foreignKeys', 'dates', 'timestamps'])
349
+ const COLUMN_LIST_FIELDS = nameSet(['columns', 'foreignKeys', 'dates', 'timestamps', 'booleans'])
346
350
  const FOREIGN_KEY_FIELDS = nameSet(['columns', 'table', 'references'])
347
351
  const POLICY_FIELDS = nameSet(['scope', 'actions', 'insert', 'update', 'version', 'createdBy', 'columns', 'queue', 'files'])
348
352
  const QUEUE_FIELDS = nameSet(['attempts', 'due', 'maxAttempts'])
@@ -524,6 +528,7 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
524
528
  }
525
529
  const dates = parseColumnSet(table, ownEntry(entry, 'dates'), column, 'dates')
526
530
  const timestamps = parseColumnSet(table, ownEntry(entry, 'timestamps'), column, 'timestamps')
531
+ const booleans = parseColumnSet(table, ownEntry(entry, 'booleans'), column, 'booleans')
527
532
  const queue = queueInput === undefined
528
533
  ? undefined
529
534
  : parseQueue(table, queueInput, column, { columns, timestamps, version, actions, update, columnWrites })
@@ -551,6 +556,7 @@ function parseTable(table: string, columnList: unknown, policyInput: unknown, vo
551
556
  columnWrites: freeze(columnWrites),
552
557
  dates,
553
558
  timestamps,
559
+ booleans,
554
560
  foreignKeys,
555
561
  }
556
562
  }
@@ -673,7 +679,7 @@ function parseQueue(
673
679
  return freeze({ attempts, due, maxAttempts: maxInput === undefined ? DEFAULT_MAX_ATTEMPTS : maxInput })
674
680
  }
675
681
 
676
- /** The column list's `date` (`dates`) or timestamp (`timestamps`) columns, each one of the table's. */
682
+ /** The column list's `date` (`dates`), timestamp (`timestamps`) or boolean (`booleans`) columns, each one of the table's. */
677
683
  function parseColumnSet(table: string, input: unknown, column: (name: unknown, field: string) => string, field: string): NameSet {
678
684
  if (input === undefined) {
679
685
  return nameSet([])
@@ -0,0 +1,26 @@
1
+ import { SQL } from 'bun'
2
+ import { PostgresJSDialect } from 'kysely-postgres-js'
3
+ import { Database } from './database'
4
+ import { QueryRefusedError } from './errors'
5
+ import { isPlainIdentifier } from './identifier'
6
+
7
+ export interface PostgresOptions {
8
+ readonly url: string
9
+ /** The application's schema (`app_<id>`), set as the connection's `search_path`. */
10
+ readonly schema: string
11
+ readonly max?: number
12
+ }
13
+
14
+ /**
15
+ * Over `Bun.SQL` with `prepare: false`: Bun otherwise keeps one prepared statement per distinct SQL text per
16
+ * connection, and `in` lists of different lengths make that grow without bound.
17
+ */
18
+ export function openPostgres(options: PostgresOptions): Database {
19
+ if (!isPlainIdentifier(options.schema)) {
20
+ throw new QueryRefusedError('invalid_name', 'the schema must match ^[a-z_][a-z0-9_]*$')
21
+ }
22
+ const pool = new SQL({ url: options.url, adapter: 'postgres', prepare: false, max: options.max ?? 10, connection: { search_path: options.schema } })
23
+ return Database.fromDialect(new PostgresJSDialect({ postgres: pool }), { schema: options.schema })
24
+ }
25
+ // Application modules load after this one: a replaced opener would receive the database URL.
26
+ Object.freeze(openPostgres)
package/src/registry.ts CHANGED
@@ -26,9 +26,15 @@ export interface FrameworkTables {
26
26
  readonly files: StoredFiles
27
27
  }
28
28
 
29
+ /** A connection of the layer and its locator. Only its holder can `release` it, which lets the layer be bound again. */
30
+ export interface ProvisionalBinding {
31
+ readonly locate: Locator
32
+ release(): void
33
+ }
34
+
29
35
  export interface FrameworkSide {
30
- /** Connects the layer once and answers its locator; a second call throws. */
31
- bind(runtime: DataLayerRuntime): Locator
36
+ /** Connects the layer once and answers its binding; a call while one is connected throws. */
37
+ bind(runtime: DataLayerRuntime): ProvisionalBinding
32
38
  readonly framework: FrameworkTables
33
39
  /** The untyped handle over one table that `@fabrika/db/testing` hands the isolation tests. */
34
40
  probe(table: string): TableProbe
package/src/sql.ts CHANGED
@@ -373,17 +373,21 @@ function scopeFragment(terms: ScopeTerms, alias: string): { readonly parts: read
373
373
  const parts: string[] = []
374
374
  const values: SqlValue[] = []
375
375
  let text = '('
376
- terms.forEach((group, groupIndex) => {
376
+ // Index loops, never a method of the terms or of Array.prototype: this is the scope of the statement.
377
+ for (let groupIndex = 0; groupIndex < terms.length; groupIndex++) {
378
+ const group = terms[groupIndex] ?? []
377
379
  text += `${groupIndex === 0 ? '' : ' or '}(`
378
- group.forEach((test, testIndex) => {
380
+ for (let testIndex = 0; testIndex < group.length; testIndex++) {
381
+ const test = group[testIndex]
382
+ if (test === undefined) continue
379
383
  text += `${testIndex === 0 ? '' : ' and '}${alias}.${test.column} = ${typeof test.value === 'string' ? '' : 'any('}`
380
- parts.push(text)
381
- values.push(test.value)
384
+ parts[parts.length] = text
385
+ values[values.length] = test.value
382
386
  text = typeof test.value === 'string' ? '' : ')'
383
- })
387
+ }
384
388
  text += ')'
385
- })
386
- parts.push(`${text})`)
389
+ }
390
+ parts[parts.length] = `${text})`
387
391
  return { parts, values }
388
392
  }
389
393
 
@@ -830,6 +834,10 @@ export function sqlTag(site: SqlSite, schema: string | undefined, termsOf: Scope
830
834
  const statement = classifySql(expanded.parts, site.mode, schema ?? '')
831
835
  const parameters = expanded.values.map((value, index) => parameterOf(value, index + 1))
832
836
  const session = await site.session()
837
+ // ADR-0065: the classifier reads Postgres's grammar, so nothing of it is sent to SQLite.
838
+ if (session.dialect !== 'postgres') {
839
+ refuse('sql_dialect', 'raw SQL runs only on Postgres; an application on Cloudflare reads and writes through ctx.db')
840
+ }
833
841
  if (session.readOnly && site.mode === 'write') {
834
842
  refuse('read_only', 'this invocation is read-only')
835
843
  }
@@ -0,0 +1,122 @@
1
+ // A `bun:sqlite` database shaped like a Durable Object's storage, for the tests and the SQLite test kit. Not a subpath:
2
+ // a Worker never loads it.
3
+
4
+ import { Database, type SQLQueryBindings } from 'bun:sqlite'
5
+ import { AsyncLocalStorage } from 'node:async_hooks'
6
+ import type { SqliteCursor, SqliteStorage, SqliteValue } from './sqlite'
7
+
8
+ export interface BunSqliteStorage extends SqliteStorage {
9
+ /** Runs `fn` in one transaction of its own, as a Durable Object's `storage.transactionSync` does. */
10
+ transactionSync<T>(fn: () => T): T
11
+ }
12
+
13
+ /** workerd's authorizer refuses these in `sql.exec`: a transaction runs through `transaction` or `transactionSync`. */
14
+ const TRANSACTION_CONTROL = /(?:^|;)(?:\s|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*(?:begin|commit|end|rollback|savepoint|release)\b/i
15
+
16
+ function bunBinding(value: SqliteValue): SQLQueryBindings {
17
+ return value instanceof ArrayBuffer ? new Uint8Array(value) : value
18
+ }
19
+
20
+ /** A column value as workerd answers it: a BLOB is an `ArrayBuffer`. */
21
+ function storageValue(value: unknown): SqliteValue {
22
+ if (value === null || typeof value === 'string' || typeof value === 'number') {
23
+ return value
24
+ }
25
+ if (value instanceof Uint8Array) {
26
+ return value.slice().buffer
27
+ }
28
+ throw new Error('bun:sqlite answered a value a Durable Object never does')
29
+ }
30
+
31
+ function storageRow(row: Record<string, unknown>): Record<string, SqliteValue> {
32
+ return Object.fromEntries(Object.entries(row).map(([name, value]) => [name, storageValue(value)]))
33
+ }
34
+
35
+ /** The transaction a `transaction` callback runs in: its savepoint depth, and the queue its own nested ones wait in. */
36
+ interface OpenTransaction {
37
+ readonly depth: number
38
+ queue: Promise<void>
39
+ }
40
+
41
+ /**
42
+ * `database` (a fresh `:memory:` one by default) with foreign keys enforced, as a Durable Object enforces them. One
43
+ * `transaction` runs at a time; one started inside a callback nests in a savepoint, as workerd nests it, and its
44
+ * siblings wait for each other. A statement outside one runs at once. A text of several statements runs whole when its
45
+ * first statement answers no rows, as a migration's does; otherwise only its first statement runs.
46
+ */
47
+ export function bunSqliteStorage(database: Database = new Database(':memory:')): BunSqliteStorage {
48
+ database.run('PRAGMA foreign_keys = ON')
49
+ const totalChanges = (): number => database.query<{ readonly changes: number }, []>('select total_changes() as changes').get()?.changes ?? 0
50
+ const root: OpenTransaction = { depth: 0, queue: Promise.resolve() }
51
+ const open = new AsyncLocalStorage<OpenTransaction>()
52
+
53
+ function exec(query: string, ...bindings: SqliteValue[]): SqliteCursor {
54
+ if (TRANSACTION_CONTROL.test(query)) {
55
+ throw new Error('sql.exec runs no transaction statement: use storage.transaction() or storage.transactionSync()')
56
+ }
57
+ const values = bindings.map(bunBinding)
58
+ const before = totalChanges()
59
+ const statement = database.prepare<Record<string, unknown>, SQLQueryBindings[]>(query)
60
+ let rows: Record<string, SqliteValue>[] = []
61
+ try {
62
+ if (statement.columnNames.length > 0) {
63
+ rows = statement.all(...values).map(storageRow)
64
+ } else {
65
+ database.run(query, values)
66
+ }
67
+ } finally {
68
+ statement.finalize()
69
+ }
70
+ const rowsWritten = totalChanges() - before
71
+ return { toArray: () => rows, rowsWritten }
72
+ }
73
+
74
+ return {
75
+ sql: { exec },
76
+ async transaction<T>(closure: () => Promise<T>): Promise<T> {
77
+ const parent = open.getStore() ?? root
78
+ const previous = parent.queue
79
+ const done = Promise.withResolvers<void>()
80
+ parent.queue = previous.then(() => done.promise)
81
+ await previous
82
+ const depth = parent.depth + 1
83
+ const savepoint = `_test_savepoint_${depth}`
84
+ try {
85
+ database.run(depth === 1 ? 'BEGIN' : `SAVEPOINT ${savepoint}`)
86
+ try {
87
+ const result = await open.run({ depth, queue: Promise.resolve() }, closure)
88
+ database.run(depth === 1 ? 'COMMIT' : `RELEASE ${savepoint}`)
89
+ return result
90
+ } catch (error) {
91
+ if (depth === 1 && database.inTransaction) {
92
+ database.run('ROLLBACK')
93
+ } else if (depth > 1) {
94
+ database.run(`ROLLBACK TO ${savepoint}`)
95
+ database.run(`RELEASE ${savepoint}`)
96
+ }
97
+ throw error
98
+ }
99
+ } finally {
100
+ done.resolve()
101
+ }
102
+ },
103
+ transactionSync<T>(fn: () => T): T {
104
+ // Inside an open transaction workerd nests it in a savepoint too.
105
+ const nested = database.inTransaction
106
+ database.run(nested ? 'SAVEPOINT _test_sync_savepoint' : 'BEGIN')
107
+ try {
108
+ const result = fn()
109
+ database.run(nested ? 'RELEASE _test_sync_savepoint' : 'COMMIT')
110
+ return result
111
+ } catch (error) {
112
+ if (nested) {
113
+ database.run('ROLLBACK TO _test_sync_savepoint')
114
+ database.run('RELEASE _test_sync_savepoint')
115
+ } else if (database.inTransaction) {
116
+ database.run('ROLLBACK')
117
+ }
118
+ throw error
119
+ }
120
+ },
121
+ }
122
+ }
@@ -0,0 +1,242 @@
1
+ // @fabrika/db/sqlite-migrate — applies the framework's and the application's migrations to an application's SQLite
2
+ // database when its Durable Object boots (ADR-0065), in a ledger in the same database. The identity and the
3
+ // checksum rule are the Postgres runner's (ADR-0017): `(bundle, name)`, and the SHA-256 of an applied file's text.
4
+ // Every new object instance runs it, so a bundle whose files match the digest recorded for it is not read further.
5
+ // Runs inside a Worker: no Bun import.
6
+
7
+ /** What the runner needs of the database; a Durable Object's `storage.sql.exec` and `storage.transactionSync` fit it. */
8
+ export interface SqliteMigrationTarget {
9
+ /** Runs `query`, which may hold several statements, and answers the rows of the last one. */
10
+ exec(query: string, ...bindings: (string | number | null)[]): { toArray(): Record<string, unknown>[] }
11
+ /** Runs `fn` in one transaction: commits when it returns, rolls back when it throws. */
12
+ transactionSync<T>(fn: () => T): T
13
+ }
14
+
15
+ export interface SqliteMigrationBundle {
16
+ readonly bundle: string
17
+ readonly migrations: readonly { readonly name: string; readonly sql: string }[]
18
+ }
19
+
20
+ export class SqliteMigrationError extends Error {
21
+ override readonly name = 'SqliteMigrationError'
22
+ }
23
+
24
+ const LEDGER = 'fabrika_migrations'
25
+ const DIGESTS = 'fabrika_migration_bundles'
26
+ const BUNDLE_NAME = /^[a-z][a-z0-9-]*$/
27
+ const MIGRATION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.sql$/
28
+
29
+ const LEDGER_DDL = `CREATE TABLE IF NOT EXISTS ${LEDGER} (
30
+ bundle TEXT NOT NULL,
31
+ name TEXT NOT NULL,
32
+ checksum TEXT NOT NULL,
33
+ applied_at TEXT NOT NULL,
34
+ PRIMARY KEY (bundle, name)
35
+ ) STRICT`
36
+
37
+ /** A bundle's digest: the SHA-256 of its ordered file names and checksums, written with the last of its files applied. */
38
+ const DIGESTS_DDL = `CREATE TABLE IF NOT EXISTS ${DIGESTS} (
39
+ bundle TEXT PRIMARY KEY,
40
+ digest TEXT NOT NULL
41
+ ) STRICT`
42
+
43
+ interface HashedMigration {
44
+ readonly name: string
45
+ readonly sql: string
46
+ readonly checksum: string
47
+ }
48
+
49
+ interface PlannedMigration extends HashedMigration {
50
+ readonly bundle: string
51
+ }
52
+
53
+ interface PlannedBundle {
54
+ readonly bundle: string
55
+ readonly digest: string
56
+ readonly migrations: readonly PlannedMigration[]
57
+ }
58
+
59
+ interface HashedFiles {
60
+ readonly digest: string
61
+ readonly migrations: readonly HashedMigration[]
62
+ }
63
+
64
+ /** The files an array held when it was hashed, and their hashes: reused while the array still holds the same files. */
65
+ interface HashedEntry {
66
+ readonly snapshot: readonly { readonly name: string; readonly sql: string }[]
67
+ readonly files: Promise<HashedFiles>
68
+ }
69
+
70
+ /** The migration arrays are module values, hashed once per isolate; an array mutated since is hashed again. */
71
+ const hashedFiles = new WeakMap<SqliteMigrationBundle['migrations'], HashedEntry>()
72
+
73
+ /** The SHA-256 hex of the file's UTF-8 text: the Postgres runner's `migrationChecksum`, so both ledgers agree. */
74
+ async function checksumOf(sql: string): Promise<string> {
75
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sql))
76
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
77
+ }
78
+
79
+ function compareNames(a: string, b: string): number {
80
+ return a < b ? -1 : a > b ? 1 : 0
81
+ }
82
+
83
+ /** Refuses a malformed bundle before anything is hashed or run. */
84
+ function check(bundles: readonly SqliteMigrationBundle[]): void {
85
+ const bundleNames = new Set<string>()
86
+ for (const bundle of bundles) {
87
+ if (!BUNDLE_NAME.test(bundle.bundle)) throw new SqliteMigrationError(`Invalid SQLite migration bundle name: ${bundle.bundle}`)
88
+ if (bundleNames.has(bundle.bundle)) throw new SqliteMigrationError(`Duplicate SQLite migration bundle ${bundle.bundle}`)
89
+ bundleNames.add(bundle.bundle)
90
+ const names = new Set<string>()
91
+ for (const migration of bundle.migrations) {
92
+ const identity = `${bundle.bundle}/${migration.name}`
93
+ if (!MIGRATION_NAME.test(migration.name)) throw new SqliteMigrationError(`Invalid SQLite migration filename: ${identity}`)
94
+ if (names.has(migration.name)) throw new SqliteMigrationError(`Duplicate SQLite migration ${identity}`)
95
+ if (migration.sql.trim() === '') throw new SqliteMigrationError(`SQLite migration ${identity} is empty`)
96
+ names.add(migration.name)
97
+ }
98
+ }
99
+ }
100
+
101
+ /** A bundle's files in name order with their checksums, and its digest: the SHA-256 of the ordered names and checksums. */
102
+ async function hash(files: SqliteMigrationBundle['migrations']): Promise<HashedFiles> {
103
+ const migrations: HashedMigration[] = []
104
+ for (const { name, sql } of [...files].sort((a, b) => compareNames(a.name, b.name))) {
105
+ migrations.push({ name, sql, checksum: await checksumOf(sql) })
106
+ }
107
+ // A filename holds no space or newline, so each line is unambiguous.
108
+ const digest = await checksumOf(migrations.map((migration) => `${migration.name} ${migration.checksum}\n`).join(''))
109
+ return { digest, migrations }
110
+ }
111
+
112
+ function holdsSnapshot(files: SqliteMigrationBundle['migrations'], snapshot: HashedEntry['snapshot']): boolean {
113
+ return files.length === snapshot.length && files.every((file, index) => file.name === snapshot[index]?.name && file.sql === snapshot[index]?.sql)
114
+ }
115
+
116
+ /** Each bundle's migrations in name order, the bundles in the order given; refuses a malformed bundle before any SQL runs. */
117
+ async function plan(bundles: readonly SqliteMigrationBundle[]): Promise<readonly PlannedBundle[]> {
118
+ check(bundles)
119
+ const planned: PlannedBundle[] = []
120
+ for (const bundle of bundles) {
121
+ let entry = hashedFiles.get(bundle.migrations)
122
+ if (entry === undefined || !holdsSnapshot(bundle.migrations, entry.snapshot)) {
123
+ entry = { snapshot: bundle.migrations.map(({ name, sql }) => ({ name, sql })), files: hash(bundle.migrations) }
124
+ hashedFiles.set(bundle.migrations, entry)
125
+ }
126
+ const { digest, migrations } = await entry.files
127
+ planned.push({ bundle: bundle.bundle, digest, migrations: migrations.map((migration) => ({ ...migration, bundle: bundle.bundle })) })
128
+ }
129
+ return planned
130
+ }
131
+
132
+ /** The digest recorded for each bundle that has one; empty while the table does not exist, as before its first run. */
133
+ function readDigests(target: SqliteMigrationTarget, planned: readonly PlannedBundle[]): Map<string, string> {
134
+ const recorded = new Map<string, string>()
135
+ if (target.exec(`SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = ?`, DIGESTS).toArray().length === 0) return recorded
136
+ for (const { bundle } of planned) {
137
+ const [row] = target.exec(`SELECT digest FROM ${DIGESTS} WHERE bundle = ?`, bundle).toArray()
138
+ if (row === undefined) continue
139
+ const { digest } = row
140
+ if (typeof digest !== 'string') throw new SqliteMigrationError(`SQLite migration table ${DIGESTS} holds a malformed row`)
141
+ recorded.set(bundle, digest)
142
+ }
143
+ return recorded
144
+ }
145
+
146
+ function recordDigest(target: SqliteMigrationTarget, bundle: PlannedBundle): void {
147
+ target.exec(DIGESTS_DDL)
148
+ target.exec(
149
+ `INSERT INTO ${DIGESTS} (bundle, digest) VALUES (?, ?) ON CONFLICT (bundle) DO UPDATE SET digest = excluded.digest`,
150
+ bundle.bundle,
151
+ bundle.digest,
152
+ )
153
+ }
154
+
155
+ /** Recorded checksums by `bundle/name`; empty while the ledger does not exist, so a fresh database is checked first. */
156
+ function readLedger(target: SqliteMigrationTarget): Map<string, string> {
157
+ const present = target.exec(`SELECT 1 AS present FROM sqlite_schema WHERE type = 'table' AND name = ?`, LEDGER).toArray()
158
+ const applied = new Map<string, string>()
159
+ if (present.length === 0) return applied
160
+ for (const row of target.exec(`SELECT bundle, name, checksum FROM ${LEDGER}`).toArray()) {
161
+ const { bundle, name, checksum } = row
162
+ if (typeof bundle !== 'string' || typeof name !== 'string' || typeof checksum !== 'string') {
163
+ throw new SqliteMigrationError(`SQLite migration ledger ${LEDGER} holds a malformed row`)
164
+ }
165
+ applied.set(`${bundle}/${name}`, checksum)
166
+ }
167
+ return applied
168
+ }
169
+
170
+ /**
171
+ * Refuses, before anything is applied: an applied file whose text changed, and a new file that sorts before the last
172
+ * applied one of its bundle, which would run after the files it precedes. A ledger row without a file is the database
173
+ * ahead of the code after a rollback, which is tolerated, as on Postgres.
174
+ */
175
+ function verify(planned: readonly PlannedBundle[], applied: ReadonlyMap<string, string>): void {
176
+ for (const { migrations } of planned) {
177
+ let lastApplied: string | undefined
178
+ for (const migration of migrations) {
179
+ const identity = `${migration.bundle}/${migration.name}`
180
+ const recorded = applied.get(identity)
181
+ if (recorded === undefined) continue
182
+ if (recorded !== migration.checksum) {
183
+ throw new SqliteMigrationError(
184
+ `SQLite migration ${identity} changed after it was applied; an applied migration is immutable, so write the change as a new migration`,
185
+ )
186
+ }
187
+ lastApplied = migration.name
188
+ }
189
+ for (const migration of migrations) {
190
+ if (lastApplied === undefined || compareNames(migration.name, lastApplied) >= 0) break
191
+ if (!applied.has(`${migration.bundle}/${migration.name}`)) {
192
+ throw new SqliteMigrationError(
193
+ `SQLite migration ${migration.bundle}/${migration.name} sorts before ${migration.bundle}/${lastApplied}, which is already applied; rename it to sort after it`,
194
+ )
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Applies every migration the ledger does not record, each in its own transaction together with its ledger row, so a
202
+ * failing one leaves nothing of itself and keeps the ones before it. A bundle's digest is recorded in the transaction
203
+ * of its last file, or alone when nothing of it was pending; while every bundle matches its digest, the ledger is not
204
+ * read. Answers the identities it applied.
205
+ */
206
+ export async function migrateSqlite(
207
+ target: SqliteMigrationTarget,
208
+ bundles: readonly SqliteMigrationBundle[],
209
+ ): Promise<{ readonly applied: readonly string[] }> {
210
+ const planned = await plan(bundles)
211
+ const digests = readDigests(target, planned)
212
+ if (planned.every((bundle) => digests.get(bundle.bundle) === bundle.digest)) return { applied: [] }
213
+ const recorded = readLedger(target)
214
+ verify(planned, recorded)
215
+ target.exec(LEDGER_DDL)
216
+ const applied: string[] = []
217
+ for (const bundle of planned) {
218
+ if (digests.get(bundle.bundle) === bundle.digest) continue
219
+ const pending = bundle.migrations.filter((migration) => !recorded.has(`${migration.bundle}/${migration.name}`))
220
+ if (pending.length === 0) target.transactionSync(() => recordDigest(target, bundle))
221
+ for (const [index, migration] of pending.entries()) {
222
+ const identity = `${migration.bundle}/${migration.name}`
223
+ try {
224
+ target.transactionSync(() => {
225
+ target.exec(migration.sql)
226
+ target.exec(
227
+ `INSERT INTO ${LEDGER} (bundle, name, checksum, applied_at) VALUES (?, ?, ?, ?)`,
228
+ migration.bundle,
229
+ migration.name,
230
+ migration.checksum,
231
+ new Date().toISOString(),
232
+ )
233
+ if (index === pending.length - 1) recordDigest(target, bundle)
234
+ })
235
+ } catch (error) {
236
+ throw new SqliteMigrationError(`SQLite migration ${identity} failed`, { cause: error })
237
+ }
238
+ applied.push(identity)
239
+ }
240
+ }
241
+ return { applied }
242
+ }