@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/sqlite.ts ADDED
@@ -0,0 +1,224 @@
1
+ // @fabrika/db/sqlite — the data layer's engine over a Cloudflare Durable Object's SQLite (ADR-0065). Worker-safe: no
2
+ // Bun import. `sqlite-bun.ts` gives the tests a `bun:sqlite` database of the same shape.
3
+
4
+ import {
5
+ type BinaryOperationNode,
6
+ type CompiledQuery,
7
+ type DatabaseConnection,
8
+ type DatabaseIntrospector,
9
+ type Dialect,
10
+ type DialectAdapter,
11
+ type Driver,
12
+ Kysely,
13
+ OperatorNode,
14
+ type QueryResult,
15
+ type SelectModifierNode,
16
+ type SelectQueryNode,
17
+ SqliteAdapter,
18
+ SqliteIntrospector,
19
+ SqliteQueryCompiler,
20
+ } from 'kysely'
21
+ import { Database, freezePrototypeChain, type LooseDB, type TransactionRunner } from './database'
22
+ import { DatabaseError, QueryRefusedError } from './errors'
23
+
24
+ /** A value `sql.exec` binds and answers; a Durable Object's `SqlStorageValue`. */
25
+ export type SqliteValue = ArrayBuffer | string | number | null
26
+
27
+ export interface SqliteCursor {
28
+ toArray(): Record<string, SqliteValue>[]
29
+ readonly rowsWritten: number
30
+ }
31
+
32
+ /** What the engine needs of a Durable Object's `ctx.storage`; `DurableObjectStorage` is assignable to it. */
33
+ export interface SqliteStorage {
34
+ readonly sql: {
35
+ exec(query: string, ...bindings: SqliteValue[]): SqliteCursor
36
+ }
37
+ /** Commits when `closure` resolves, rolls back when it rejects; the object serves no other event meanwhile. */
38
+ transaction<T>(closure: () => Promise<T>): Promise<T>
39
+ /** Runs `closure` in one transaction of its own, nested in a savepoint inside an open one; rolls back when it throws. */
40
+ transactionSync<T>(closure: () => T): T
41
+ }
42
+
43
+ const LIKE_OPERATORS: ReadonlyMap<string, string> = new Map([
44
+ ['like', 'like'],
45
+ ['ilike', 'like'],
46
+ ['not like', 'not like'],
47
+ ['not ilike', 'not like'],
48
+ ])
49
+
50
+ /** The modifiers a Durable Object needs none of: while a transaction is open it serves no other event. */
51
+ const LOCK_MODIFIERS: ReadonlySet<string> = new Set(['ForUpdate', 'ForShare', 'ForKeyShare', 'ForNoKeyUpdate', 'NoWait', 'SkipLocked'])
52
+
53
+ function isLockModifier(modifier: SelectModifierNode): boolean {
54
+ return modifier.modifier !== undefined && LOCK_MODIFIERS.has(modifier.modifier)
55
+ }
56
+
57
+ /**
58
+ * SQLite's forms of what the layer binds and builds. LIKE takes `\` as its escape character only when told, so every
59
+ * pattern gets `escape '\'`, as Postgres has by default; `ilike` is `like`, case-insensitive for ASCII only.
60
+ */
61
+ class SqliteCompiler extends SqliteQueryCompiler {
62
+ protected override addParameter(parameter: unknown): void {
63
+ super.addParameter(sqliteParameter(parameter))
64
+ }
65
+
66
+ protected override visitBinaryOperation(node: BinaryOperationNode): void {
67
+ const pattern = OperatorNode.is(node.operator) ? LIKE_OPERATORS.get(node.operator.operator) : undefined
68
+ if (pattern === undefined) {
69
+ super.visitBinaryOperation(node)
70
+ return
71
+ }
72
+ this.visitNode(node.leftOperand)
73
+ this.append(` ${pattern} `)
74
+ this.visitNode(node.rightOperand)
75
+ this.append(" escape '\\'")
76
+ }
77
+
78
+ protected override visitSelectQuery(node: SelectQueryNode): void {
79
+ const modifiers = node.endModifiers?.filter((modifier) => !isLockModifier(modifier))
80
+ super.visitSelectQuery({ ...node, endModifiers: modifiers === undefined || modifiers.length === 0 ? undefined : modifiers })
81
+ }
82
+ }
83
+
84
+ function sqliteParameter(parameter: unknown): unknown {
85
+ if (parameter instanceof Date) {
86
+ if (Number.isNaN(parameter.getTime())) {
87
+ throw new QueryRefusedError('invalid_value', 'an invalid Date cannot be bound')
88
+ }
89
+ return parameter.toISOString()
90
+ }
91
+ if (typeof parameter === 'boolean') {
92
+ return parameter ? 1 : 0
93
+ }
94
+ if (typeof parameter === 'bigint') {
95
+ const value = Number(parameter)
96
+ if (!Number.isSafeInteger(value)) {
97
+ throw new QueryRefusedError('invalid_value', 'a bigint outside the safe integer range cannot be bound on SQLite')
98
+ }
99
+ return value
100
+ }
101
+ return parameter
102
+ }
103
+
104
+ const sqliteCompiler = new SqliteCompiler()
105
+ freezePrototypeChain(sqliteCompiler)
106
+
107
+ function binding(value: unknown): SqliteValue {
108
+ if (value === null || typeof value === 'string' || typeof value === 'number' || value instanceof ArrayBuffer) {
109
+ return value
110
+ }
111
+ throw new QueryRefusedError('invalid_value', 'a value SQLite cannot bind: string, number, ArrayBuffer or null')
112
+ }
113
+
114
+ /** The SQLSTATE of each constraint failure, by the start of the message workerd and bun:sqlite both give. */
115
+ const CONSTRAINT_STATES: readonly (readonly [string, string])[] = Object.freeze([
116
+ ['UNIQUE constraint failed', '23505'],
117
+ ['FOREIGN KEY constraint failed', '23503'],
118
+ ['CHECK constraint failed', '23514'],
119
+ ['NOT NULL constraint failed', '23502'],
120
+ ])
121
+
122
+ /** The driver's failure as a `DatabaseError`; its message can quote row values, so none of it is kept. */
123
+ function sqliteError(error: unknown): DatabaseError {
124
+ if (error instanceof DatabaseError) {
125
+ return error
126
+ }
127
+ const message = error instanceof Error ? error.message : ''
128
+ const match = CONSTRAINT_STATES.find(([prefix]) => message.startsWith(prefix))
129
+ return new DatabaseError(match === undefined ? null : match[1])
130
+ }
131
+
132
+ /** Postgres's SQLSTATE for a write in a `READ ONLY` transaction, which the backstop answers too. */
133
+ const READ_ONLY_TRANSACTION = '25006'
134
+
135
+ /**
136
+ * A read-only statement's rows, run in a `transactionSync` of its own that holds nothing across an await. A statement
137
+ * that wrote throws, which rolls back what it wrote: the backstop below the layer's refusal of a write node.
138
+ */
139
+ function readRows(storage: SqliteStorage, query: string, bindings: SqliteValue[]): Record<string, SqliteValue>[] {
140
+ return storage.transactionSync(() => {
141
+ const cursor = storage.sql.exec(query, ...bindings)
142
+ // workerd counts `rowsWritten` as the cursor is read: only a consumed cursor reports the whole statement.
143
+ const rows = cursor.toArray()
144
+ if (cursor.rowsWritten > 0) {
145
+ throw new DatabaseError(READ_ONLY_TRANSACTION)
146
+ }
147
+ return rows
148
+ })
149
+ }
150
+
151
+ function connectionOf(storage: SqliteStorage, readOnly: boolean): DatabaseConnection {
152
+ return {
153
+ async executeQuery<R>(compiled: CompiledQuery): Promise<QueryResult<R>> {
154
+ const bindings = compiled.parameters.map(binding)
155
+ let rows: Record<string, SqliteValue>[]
156
+ try {
157
+ rows = readOnly ? readRows(storage, compiled.sql, bindings) : storage.sql.exec(compiled.sql, ...bindings).toArray()
158
+ } catch (error) {
159
+ throw sqliteError(error)
160
+ }
161
+ // Untyped rows become the caller's row type, as Bun.SQL's `any` rows do on Postgres.
162
+ return { rows: rows as R[] }
163
+ },
164
+ streamQuery<R>(): AsyncIterableIterator<QueryResult<R>> {
165
+ throw new QueryRefusedError('statement_shape', 'a SQLite database does not stream a query')
166
+ },
167
+ }
168
+ }
169
+
170
+ function refuseKyselyTransaction(): Promise<never> {
171
+ return Promise.reject(new QueryRefusedError('statement_shape', "a SQLite transaction runs through the storage's transaction, never Kysely's"))
172
+ }
173
+
174
+ class SqliteDialect implements Dialect {
175
+ readonly #storage: SqliteStorage
176
+ readonly #readOnly: boolean
177
+
178
+ constructor(storage: SqliteStorage, readOnly: boolean) {
179
+ this.#storage = storage
180
+ this.#readOnly = readOnly
181
+ }
182
+
183
+ createAdapter(): DialectAdapter {
184
+ return new SqliteAdapter()
185
+ }
186
+
187
+ createDriver(): Driver {
188
+ const connection = connectionOf(this.#storage, this.#readOnly)
189
+ return {
190
+ async init() {},
191
+ async acquireConnection() {
192
+ return connection
193
+ },
194
+ beginTransaction: refuseKyselyTransaction,
195
+ commitTransaction: refuseKyselyTransaction,
196
+ rollbackTransaction: refuseKyselyTransaction,
197
+ async releaseConnection() {},
198
+ async destroy() {},
199
+ }
200
+ }
201
+
202
+ createQueryCompiler(): SqliteQueryCompiler {
203
+ return sqliteCompiler
204
+ }
205
+
206
+ createIntrospector(db: Kysely<LooseDB>): DatabaseIntrospector {
207
+ return new SqliteIntrospector(db)
208
+ }
209
+ }
210
+
211
+ /**
212
+ * The data layer's database over a Durable Object's storage. A read-only transaction opens no storage transaction,
213
+ * which would hold back every other event of the object across the work's awaits: each of its statements runs in one
214
+ * of its own, so two of them may see different committed states, as under Postgres's READ COMMITTED. The idle timeout
215
+ * has no SQLite form.
216
+ */
217
+ export function openSqlite(storage: SqliteStorage): Database {
218
+ const kysely = new Kysely<LooseDB>({ dialect: new SqliteDialect(storage, false) })
219
+ const reads = new Kysely<LooseDB>({ dialect: new SqliteDialect(storage, true) })
220
+ const transaction: TransactionRunner = (options, body) => options.readOnly ? body(reads) : storage.transaction(() => body(kysely))
221
+ return Database.fromEngine({ dialect: 'sqlite', kysely, compiler: sqliteCompiler, transaction })
222
+ }
223
+ // Application modules load after this one: a replaced opener would receive the object's storage.
224
+ Object.freeze(openSqlite)