@fabrika/db 0.8.2 → 0.9.0-cloudflare.1

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 +691 -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 +193 -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/db",
3
- "version": "0.8.2",
3
+ "version": "0.9.0-cloudflare.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,47 +12,78 @@
12
12
  },
13
13
  "files": [
14
14
  "src",
15
- "!src/**/__tests__/**"
15
+ "!src/**/__tests__/**",
16
+ "types"
16
17
  ],
17
18
  "exports": {
18
19
  ".": {
19
20
  "bun": "./src/index.ts",
20
- "types": "./src/index.ts",
21
+ "types": "./types/src/index.d.ts",
21
22
  "default": "./src/index.ts"
22
23
  },
23
24
  "./capability": {
24
25
  "bun": "./src/capability.ts",
25
- "types": "./src/capability.ts",
26
+ "types": "./types/src/capability.d.ts",
26
27
  "default": "./src/capability.ts"
27
28
  },
28
29
  "./tables": {
29
30
  "bun": "./src/tables.ts",
30
- "types": "./src/tables.ts",
31
+ "types": "./types/src/tables.d.ts",
31
32
  "default": "./src/tables.ts"
32
33
  },
33
34
  "./framework": {
34
35
  "bun": "./src/framework.ts",
35
- "types": "./src/framework.ts",
36
+ "types": "./types/src/framework.d.ts",
36
37
  "default": "./src/framework.ts"
37
38
  },
38
39
  "./migrations": {
39
40
  "bun": "./src/migrations.ts",
40
- "types": "./src/migrations.ts",
41
+ "types": "./types/src/migrations.d.ts",
41
42
  "default": "./src/migrations.ts"
42
43
  },
44
+ "./migrations-sqlite": {
45
+ "bun": "./src/migrations-sqlite.ts",
46
+ "types": "./types/src/migrations-sqlite.d.ts",
47
+ "default": "./src/migrations-sqlite.ts"
48
+ },
43
49
  "./production": {
44
50
  "bun": "./src/production.ts",
45
- "types": "./src/production.ts",
51
+ "types": "./types/src/production.d.ts",
46
52
  "default": "./src/production.ts"
47
53
  },
54
+ "./postgres": {
55
+ "bun": "./src/postgres.ts",
56
+ "types": "./types/src/postgres.d.ts",
57
+ "default": "./src/postgres.ts"
58
+ },
59
+ "./sqlite": {
60
+ "bun": "./src/sqlite.ts",
61
+ "types": "./types/src/sqlite.d.ts",
62
+ "default": "./src/sqlite.ts"
63
+ },
64
+ "./sqlite-migrate": {
65
+ "bun": "./src/sqlite-migrate.ts",
66
+ "types": "./types/src/sqlite-migrate.d.ts",
67
+ "default": "./src/sqlite-migrate.ts"
68
+ },
69
+ "./codegen-sqlite": {
70
+ "bun": "./src/codegen-sqlite.ts",
71
+ "types": "./types/src/codegen-sqlite.d.ts",
72
+ "default": "./src/codegen-sqlite.ts"
73
+ },
74
+ "./testing-sqlite": {
75
+ "bun": "./src/testing-sqlite.ts",
76
+ "types": "./types/src/testing-sqlite.d.ts",
77
+ "default": "./src/testing-sqlite.ts"
78
+ },
48
79
  "./codegen": {
49
80
  "bun": "./src/codegen.ts",
50
- "types": "./src/codegen.ts",
81
+ "types": "./types/src/codegen.d.ts",
51
82
  "default": "./src/codegen.ts"
52
83
  },
53
84
  "./testing": {
54
85
  "bun": "./src/testing.ts",
55
- "types": "./src/testing.ts",
86
+ "types": "./types/src/testing.d.ts",
56
87
  "default": "./src/testing.ts"
57
88
  }
58
89
  },
@@ -61,8 +92,9 @@
61
92
  "test": "bun test"
62
93
  },
63
94
  "dependencies": {
64
- "@fabrika/auth-core": "0.8.2",
65
- "@fabrika/platform-node": "0.8.2",
95
+ "@fabrika/auth-core": "0.9.0-cloudflare.1",
96
+ "@fabrika/platform": "0.9.0-cloudflare.1",
97
+ "@fabrika/platform-node": "0.9.0-cloudflare.1",
66
98
  "kysely": "0.29.6",
67
99
  "kysely-postgres-js": "5.0.1",
68
100
  "zod": "^3.25.76"
package/src/admissions.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  // (`@fabrika/db/migrations`): the per-minute buckets a procedure's admission takes from, shared by every instance of the
3
3
  // application, the digests of the challenge tokens it accepted, and its hourly byte budget (`bytesPerHour`). Each is
4
4
  // keyed by the endpoint id; the tables keep the names and the `form` column they were created with. Only `@fabrika/app`'s
5
- // dispatcher reaches them, through `frameworkTablesOf`. Each operation is one statement, so two instances never read a
6
- // counter and then write it, and each prunes a bounded batch of expired rows of its own table, so no table grows past
7
- // what the last minutes or hours hold.
5
+ // dispatcher reaches them, through `frameworkTablesOf`. Each operation is one statement (on SQLite, a prune and the
6
+ // write in one transaction), so two instances never read a counter and then write it, and each prunes a bounded batch
7
+ // of expired rows of its own table, so no table grows past what the last minutes or hours hold.
8
8
 
9
- import { runFixedStatement, sessionOf } from './database'
9
+ import { runFixedStatement, type Session, sessionOf } from './database'
10
10
  import { QueryRefusedError } from './errors'
11
+ import { SQLITE_NOW, sqliteNowPlus } from './job-leases'
11
12
  import { FORM_ADMISSIONS_TABLE, FORM_BYTES_TABLE, FORM_CHALLENGES_TABLE } from './migrations'
12
13
  import type { DataLayerRuntime } from './store'
13
14
 
@@ -49,6 +50,18 @@ insert into ${A} (form, bucket, window_start, count) values ($1, $2, date_trunc(
49
50
  on conflict (form, bucket, window_start) do update set count = ${A}.count + 1 where ${A}.count < $3::int
50
51
  returning count`
51
52
 
53
+ // SQLite has no data-modifying CTE, so the prune is a statement of its own before the take, in one transaction; no row
54
+ // is locked, as no other event reaches the Durable Object while it is open.
55
+ const SQLITE_MINUTE = `strftime('%Y-%m-%dT%H:%M:00.000Z', 'now')`
56
+
57
+ const SQLITE_PRUNE_ADMISSIONS = `delete from ${A} where (form, bucket, window_start) in (
58
+ select form, bucket, window_start from ${A} where window_start < ${SQLITE_MINUTE} limit ${PRUNE_BATCH}
59
+ )`
60
+
61
+ const SQLITE_TAKE = `insert into ${A} (form, bucket, window_start, count) values (?1, ?2, ${SQLITE_MINUTE}, 1)
62
+ on conflict (form, bucket, window_start) do update set count = ${A}.count + 1 where ${A}.count < ?3
63
+ returning count`
64
+
52
65
  const B = FORM_BYTES_TABLE
53
66
 
54
67
  const SQL_RESERVE_BYTES = `with pruned as (
@@ -61,9 +74,22 @@ insert into ${B} (form, window_start, bytes) select $1, date_trunc('hour', now()
61
74
  on conflict (form, window_start) do update set bytes = ${B}.bytes + excluded.bytes where ${B}.bytes + excluded.bytes <= $3::bigint
62
75
  returning bytes`
63
76
 
77
+ const SQLITE_HOUR = `strftime('%Y-%m-%dT%H:00:00.000Z', 'now')`
78
+
79
+ const SQLITE_PRUNE_BYTES = `delete from ${B} where (form, window_start) in (
80
+ select form, window_start from ${B} where window_start < ${SQLITE_HOUR} limit ${PRUNE_BATCH}
81
+ )`
82
+
83
+ const SQLITE_RESERVE_BYTES = `insert into ${B} (form, window_start, bytes) select ?1, ${SQLITE_HOUR}, ?2 where ?2 <= ?3
84
+ on conflict (form, window_start) do update set bytes = ${B}.bytes + excluded.bytes where ${B}.bytes + excluded.bytes <= ?3
85
+ returning bytes`
86
+
64
87
  const SQL_RELEASE_BYTES = `update ${B} set bytes = greatest(bytes - $2::bigint, 0)
65
88
  where form = $1 and window_start = date_trunc('hour', now()) returning bytes`
66
89
 
90
+ const SQLITE_RELEASE_BYTES = `update ${B} set bytes = max(bytes - ?2, 0)
91
+ where form = ?1 and window_start = ${SQLITE_HOUR} returning bytes`
92
+
67
93
  const SQL_RECORD_CHALLENGE = `with pruned as (
68
94
  delete from ${C} where (form, token_digest) in (
69
95
  select form, token_digest from ${C} where expires_at <= now() and (form, token_digest) <> ($1, $2)
@@ -74,6 +100,33 @@ insert into ${C} (form, token_digest, expires_at) values ($1, $2, now() + $3::bi
74
100
  on conflict (form, token_digest) do update set expires_at = excluded.expires_at where ${C}.expires_at <= now()
75
101
  returning form`
76
102
 
103
+ const SQLITE_PRUNE_CHALLENGES = `delete from ${C} where (form, token_digest) in (
104
+ select form, token_digest from ${C} where expires_at <= ${SQLITE_NOW} and (form, token_digest) <> (?1, ?2) limit ${PRUNE_BATCH}
105
+ )`
106
+
107
+ const SQLITE_RECORD_CHALLENGE = `insert into ${C} (form, token_digest, expires_at) values (?1, ?2, ${sqliteNowPlus('?3')})
108
+ on conflict (form, token_digest) do update set expires_at = excluded.expires_at where ${C}.expires_at <= ${SQLITE_NOW}
109
+ returning form`
110
+
111
+ /**
112
+ * The rows `statement` answers. On Postgres it is one statement, its prune a CTE; on SQLite the prune (with
113
+ * `pruneParameters`) runs first, in the same transaction.
114
+ */
115
+ async function pruneAndWrite(
116
+ session: Session,
117
+ statement: { readonly postgres: string; readonly sqlite: string; readonly prune: string },
118
+ parameters: readonly (string | number)[],
119
+ pruneParameters: readonly (string | number)[],
120
+ ): Promise<readonly unknown[]> {
121
+ if (session.dialect === 'postgres') {
122
+ return runFixedStatement(session, statement.postgres, parameters)
123
+ }
124
+ return session.transaction(async (transaction) => {
125
+ await runFixedStatement(transaction, statement.prune, pruneParameters)
126
+ return runFixedStatement(transaction, statement.sqlite, parameters)
127
+ })
128
+ }
129
+
77
130
  function positiveInteger(value: number, what: string): number {
78
131
  if (!Number.isSafeInteger(value) || value < 1) {
79
132
  throw new QueryRefusedError('invalid_argument', `${what} must be a whole number of at least 1`)
@@ -85,27 +138,40 @@ export function admissions(runtime: () => DataLayerRuntime): Admissions {
85
138
  const session = () => sessionOf(runtime())
86
139
  return Object.freeze({
87
140
  async take(endpoint: string, bucket: string, limit: number): Promise<boolean> {
88
- const rows = await runFixedStatement(session(), SQL_TAKE, [endpoint, bucket, positiveInteger(limit, 'a bucket limit')])
141
+ const rows = await pruneAndWrite(
142
+ session(),
143
+ { postgres: SQL_TAKE, sqlite: SQLITE_TAKE, prune: SQLITE_PRUNE_ADMISSIONS },
144
+ [endpoint, bucket, positiveInteger(limit, 'a bucket limit')],
145
+ [],
146
+ )
89
147
  return rows.length > 0
90
148
  },
91
149
 
92
150
  async recordChallenge(endpoint: string, tokenDigest: string, retainMs: number): Promise<boolean> {
93
- const rows = await runFixedStatement(session(), SQL_RECORD_CHALLENGE, [endpoint, tokenDigest, positiveInteger(retainMs, 'a retention')])
151
+ const rows = await pruneAndWrite(
152
+ session(),
153
+ { postgres: SQL_RECORD_CHALLENGE, sqlite: SQLITE_RECORD_CHALLENGE, prune: SQLITE_PRUNE_CHALLENGES },
154
+ [endpoint, tokenDigest, positiveInteger(retainMs, 'a retention')],
155
+ [endpoint, tokenDigest],
156
+ )
94
157
  return rows.length > 0
95
158
  },
96
159
 
97
160
  async reserveBytes(endpoint: string, bytes: number, limit: number): Promise<boolean> {
98
- const rows = await runFixedStatement(session(), SQL_RESERVE_BYTES, [
99
- endpoint,
100
- positiveInteger(bytes, 'a reservation'),
101
- positiveInteger(limit, 'a byte budget'),
102
- ])
161
+ const rows = await pruneAndWrite(
162
+ session(),
163
+ { postgres: SQL_RESERVE_BYTES, sqlite: SQLITE_RESERVE_BYTES, prune: SQLITE_PRUNE_BYTES },
164
+ [endpoint, positiveInteger(bytes, 'a reservation'), positiveInteger(limit, 'a byte budget')],
165
+ [],
166
+ )
103
167
  return rows.length > 0
104
168
  },
105
169
 
106
170
  async releaseBytes(endpoint: string, bytes: number): Promise<void> {
107
171
  if (bytes > 0) {
108
- await runFixedStatement(session(), SQL_RELEASE_BYTES, [endpoint, positiveInteger(bytes, 'a release')])
172
+ const current = session()
173
+ const statement = current.dialect === 'sqlite' ? SQLITE_RELEASE_BYTES : SQL_RELEASE_BYTES
174
+ await runFixedStatement(current, statement, [endpoint, positiveInteger(bytes, 'a release')])
109
175
  }
110
176
  },
111
177
  })
@@ -0,0 +1,108 @@
1
+ // The rule of the application rows an endpoint access case seeds (`appRows`): which tables, columns and values they
2
+ // may name, which foreign keys link one given row to another, and the order they are seeded in. `@fabrika/check`
3
+ // reports each refusal from source, and `@fabrika/db/testing` throws it before it seeds anything, both in the words of
4
+ // `applicationRowRefusalText`. Browser-safe.
5
+
6
+ import { inSet } from './builtins'
7
+ import { type ForeignKey, modelOf, type TableModels } from './policy'
8
+
9
+ /** Why the values of a set of application rows cannot be seeded as given. */
10
+ export type ApplicationRowRefusal =
11
+ /** `table` is no application table: `mode` is its scope mode, or `null` when no policy names it. */
12
+ | { readonly kind: 'table'; readonly table: string; readonly mode: string | null }
13
+ /** `column` is no column of `table`'s column list. */
14
+ | { readonly kind: 'column'; readonly table: string; readonly column: string }
15
+ /** `column` is the row's key, version or creator, which the seeding stamps. */
16
+ | { readonly kind: 'stamped'; readonly table: string; readonly column: string }
17
+ /** `column` is a file column, which the seeding fills with a live, scanned file. */
18
+ | { readonly kind: 'file'; readonly table: string; readonly column: string }
19
+ /** `column` is given an array or an object, where a column takes one value. */
20
+ | { readonly kind: 'value'; readonly table: string; readonly column: string; readonly given: 'array' | 'object' }
21
+ /** The values give `columns`, some but not all columns of the foreign key `key`. */
22
+ | { readonly kind: 'partial-key'; readonly table: string; readonly columns: readonly string[]; readonly key: ForeignKey }
23
+ /** The foreign keys that link `tables` form a cycle, so no order seeds each after the rows it links to. */
24
+ | { readonly kind: 'cycle'; readonly tables: readonly string[] }
25
+
26
+ export interface ApplicationRowsPlan {
27
+ /** The application tables, each after every given table it links to. */
28
+ readonly order: readonly string[]
29
+ /** Each application table's foreign keys to another given table that the values leave to the link. */
30
+ readonly links: Readonly<Record<string, readonly ForeignKey[]>>
31
+ /** Every refusal, by table then column; a cycle last. Empty when the rows can be seeded. */
32
+ readonly refusals: readonly ApplicationRowRefusal[]
33
+ }
34
+
35
+ /** The plan of seeding one row of each table `rows` names (table → column → value). */
36
+ export function planApplicationRows(models: TableModels, rows: Readonly<Record<string, Readonly<Record<string, unknown>>>>): ApplicationRowsPlan {
37
+ const given = Object.keys(rows).sort()
38
+ const refusals: ApplicationRowRefusal[] = []
39
+ const links: Record<string, readonly ForeignKey[]> = {}
40
+ for (const table of given) {
41
+ const model = modelOf(models, table)
42
+ const scope = model?.scope
43
+ if (model === undefined || scope?.mode !== 'app') {
44
+ refusals.push({ kind: 'table', table, mode: scope?.mode ?? null })
45
+ continue
46
+ }
47
+ const values: Readonly<Record<string, unknown>> = (Object.hasOwn(rows, table) ? rows[table] : undefined) ?? {}
48
+ const named = Object.keys(values).sort()
49
+ const stamped = [scope.key, model.version, model.createdBy]
50
+ for (const column of named) {
51
+ const value = values[column]
52
+ if (!inSet(model.columns, column)) {
53
+ refusals.push({ kind: 'column', table, column })
54
+ } else if (stamped.includes(column)) {
55
+ refusals.push({ kind: 'stamped', table, column })
56
+ } else if (Object.hasOwn(model.files, column)) {
57
+ refusals.push({ kind: 'file', table, column })
58
+ } else if (value !== null && typeof value === 'object') {
59
+ refusals.push({ kind: 'value', table, column, given: Array.isArray(value) ? 'array' : 'object' })
60
+ }
61
+ }
62
+ // A foreign key is given whole or not at all: the seeding fills one it is not given (the seeded row of a given
63
+ // table, else null or a referenced row), never the rest of one.
64
+ for (const key of model.foreignKeys) {
65
+ const set = key.columns.filter((column) => named.includes(column))
66
+ if (set.length > 0 && set.length < key.columns.length) refusals.push({ kind: 'partial-key', table, columns: set, key })
67
+ }
68
+ links[table] = model.foreignKeys.filter((key) =>
69
+ key.table !== table && given.includes(key.table) && key.columns.every((column) => !named.includes(column))
70
+ )
71
+ }
72
+ // A table comes after every given table it links to; a cycle among them has no such order.
73
+ const order: string[] = []
74
+ const pending = given.filter((table) => Object.hasOwn(links, table))
75
+ while (pending.length > 0) {
76
+ const next = pending.findIndex((table) => (links[table] ?? []).every((key) => !pending.includes(key.table)))
77
+ if (next === -1) {
78
+ refusals.push({ kind: 'cycle', tables: [...pending] })
79
+ break
80
+ }
81
+ order.push(...pending.splice(next, 1))
82
+ }
83
+ return { order, links, refusals }
84
+ }
85
+
86
+ /** A refusal in the words both the checker and the test kit use, so the two never describe one rule differently. */
87
+ export function applicationRowRefusalText(refusal: ApplicationRowRefusal): string {
88
+ switch (refusal.kind) {
89
+ case 'table':
90
+ return `${refusal.table} is ${
91
+ refusal.mode === null ? 'no table of src/db/tables.ts' : `a ${refusal.mode} table`
92
+ }, and an application row is seeded only in an application table (scope: { app })`
93
+ case 'column':
94
+ return `${refusal.table}.${refusal.column} is no column of ${refusal.table}`
95
+ case 'stamped':
96
+ return `${refusal.table}.${refusal.column} is the row's key, version or creator, which the seeding stamps`
97
+ case 'file':
98
+ return `${refusal.table}.${refusal.column} is a file column, which the seeding fills with a live, scanned file`
99
+ case 'value':
100
+ return `${refusal.table}.${refusal.column} is given ${refusal.given === 'array' ? 'an array' : 'an object'}, and a seeded column takes one value`
101
+ case 'partial-key':
102
+ return `${refusal.table}.${refusal.columns.join(', ')} is part of the foreign key to ${refusal.key.table} (${
103
+ refusal.key.columns.join(', ')
104
+ }), which is given whole or not at all`
105
+ case 'cycle':
106
+ return `the foreign keys of ${refusal.tables.join(', ')} form a cycle, so the seeding cannot seed each row after the row it refers to`
107
+ }
108
+ }
package/src/capability.ts CHANGED
@@ -62,6 +62,11 @@ export interface WithinGrant {
62
62
  * every statement judges a row by the chain columns it carries now: a scope moved out of a container is out.
63
63
  */
64
64
  readonly containers: readonly ContainerGrant[]
65
+ /**
66
+ * The dimensions inside `dimension` (the listed object's own and the containers between) in which the caller holds
67
+ * the action: a row that lies in one of their values is listed, as a single-object read of it is allowed.
68
+ */
69
+ readonly contained: readonly ContainerGrant[]
65
70
  }
66
71
 
67
72
  /** An `app` endpoint's grant: the action everywhere in the application, which the proxy checked was held globally. */
@@ -0,0 +1,154 @@
1
+ // @fabrika/db/codegen-sqlite — `fabrika db codegen` for an application on Cloudflare (ADR-0065): applies its bundles to
2
+ // an in-memory SQLite database, reads its tables back and renders `SCHEMA_FILE` with the Postgres codegen's renderer.
3
+ // Runs in the CLI, on Bun.
4
+
5
+ import { Database } from 'bun:sqlite'
6
+ import { z } from 'zod/v4'
7
+ import { CodegenError, type DescribedColumn, type ForeignKeyColumns, type NamedColumn, renderTables, type ScalarType } from './codegen'
8
+ import { migrateSqlite, type SqliteMigrationBundle, type SqliteMigrationTarget } from './sqlite-migrate'
9
+
10
+ const STRING: ScalarType = { select: 'string' }
11
+ const NUMBER: ScalarType = { select: 'number' }
12
+
13
+ /** A column's TypeScript types and list field by its declared type, without a length (`VARCHAR(80)` → `VARCHAR`). */
14
+ const DECLARED_TYPES: ReadonlyMap<string, { readonly scalar: ScalarType; readonly list: DescribedColumn['list'] }> = new Map([
15
+ ['TEXT', { scalar: STRING, list: undefined }],
16
+ ['VARCHAR', { scalar: STRING, list: undefined }],
17
+ ['CHAR', { scalar: STRING, list: undefined }],
18
+ ['UUID', { scalar: STRING, list: undefined }],
19
+ ['INTEGER', { scalar: NUMBER, list: undefined }],
20
+ ['INT', { scalar: NUMBER, list: undefined }],
21
+ ['BIGINT', { scalar: NUMBER, list: undefined }],
22
+ ['SMALLINT', { scalar: NUMBER, list: undefined }],
23
+ ['REAL', { scalar: NUMBER, list: undefined }],
24
+ ['DOUBLE', { scalar: NUMBER, list: undefined }],
25
+ ['FLOAT', { scalar: NUMBER, list: undefined }],
26
+ ['BOOLEAN', { scalar: { select: 'boolean' }, list: 'booleans' }],
27
+ ['TIMESTAMP', { scalar: { select: 'Date', write: 'Date | string' }, list: 'timestamps' }],
28
+ ['TIMESTAMPTZ', { scalar: { select: 'Date', write: 'Date | string' }, list: 'timestamps' }],
29
+ ['DATETIME', { scalar: { select: 'Date', write: 'Date | string' }, list: 'timestamps' }],
30
+ ['DATE', { scalar: STRING, list: 'dates' }],
31
+ ['BLOB', { scalar: { select: 'Uint8Array' }, list: undefined }],
32
+ ])
33
+
34
+ interface SqliteColumn extends NamedColumn {
35
+ readonly declared: string
36
+ readonly nullable: boolean
37
+ readonly generated: DescribedColumn['generated']
38
+ }
39
+
40
+ const tableSchema = z.object({ name: z.string(), type: z.string(), wr: z.number() })
41
+
42
+ const columnSchema = z.object({
43
+ name: z.string(),
44
+ type: z.string(),
45
+ notnull: z.number(),
46
+ dflt_value: z.string().nullable(),
47
+ pk: z.number(),
48
+ /** 0 an ordinary column, 2 or 3 a generated one. */
49
+ hidden: z.number(),
50
+ })
51
+
52
+ const foreignKeySchema = z.object({ id: z.number(), seq: z.number(), table: z.string(), from: z.string(), to: z.string().nullable() })
53
+
54
+ /** Runs multi-statement text the way a Durable Object's `sql.exec` does; `prepare` alone compiles only its first statement. */
55
+ function bunTarget(db: Database): SqliteMigrationTarget {
56
+ return {
57
+ exec(query, ...bindings) {
58
+ const statement = db.prepare<Record<string, unknown>, (string | number | null)[]>(query)
59
+ try {
60
+ if (bindings.length > 0 || statement.columnNames.length > 0) {
61
+ const rows = statement.all(...bindings)
62
+ return { toArray: () => rows }
63
+ }
64
+ } finally {
65
+ statement.finalize()
66
+ }
67
+ db.run(query)
68
+ return { toArray: () => [] }
69
+ },
70
+ transactionSync: (fn) => db.transaction(fn)(),
71
+ }
72
+ }
73
+
74
+ function describeSqlite(column: SqliteColumn): DescribedColumn {
75
+ const base = column.declared.trim().toUpperCase().replace(/\s*\(.*\)$/s, '')
76
+ const mapped = DECLARED_TYPES.get(base)
77
+ if (mapped === undefined) {
78
+ throw new CodegenError(
79
+ `${column.table_name}.${column.column_name} has declared type '${column.declared}', which the data layer does not read or bind on SQLite (supported: ${
80
+ [...DECLARED_TYPES.keys()].join(', ')
81
+ })`,
82
+ )
83
+ }
84
+ return { scalar: mapped.scalar, nullable: column.nullable, generated: column.generated, list: mapped.list }
85
+ }
86
+
87
+ function primaryKeyOf(db: Database, table: string): string[] {
88
+ const columns = z.array(columnSchema).parse(db.query('SELECT * FROM pragma_table_xinfo(?) WHERE pk > 0 ORDER BY pk').all(table))
89
+ return columns.map((column) => column.name)
90
+ }
91
+
92
+ /** Every column of the application's ordinary tables, and their foreign keys in declaration order. */
93
+ function introspect(db: Database): { columns: SqliteColumn[]; foreignKeys: ForeignKeyColumns[] } {
94
+ const tables = z.array(tableSchema).parse(
95
+ db.query(`SELECT name, type, wr FROM pragma_table_list WHERE schema = 'main' AND type IN ('table', 'virtual') ORDER BY name`).all(),
96
+ ).filter((table) => !table.name.startsWith('sqlite_') && !table.name.startsWith('fabrika_'))
97
+ const columns: SqliteColumn[] = []
98
+ const foreignKeys: ForeignKeyColumns[] = []
99
+ for (const table of tables) {
100
+ if (table.type === 'virtual') throw new CodegenError(`table ${table.name} is a virtual table, which the data layer does not read`)
101
+ const declared = z.array(columnSchema).parse(db.query('SELECT * FROM pragma_table_xinfo(?) ORDER BY cid').all(table.name))
102
+ const keyColumns = declared.filter((column) => column.pk > 0)
103
+ for (const column of declared) {
104
+ // An INTEGER PRIMARY KEY of a rowid table is the rowid: never null, assigned when an insert omits it.
105
+ const rowid = table.wr === 0 && keyColumns.length === 1 && column.pk === 1 && column.type.trim().toUpperCase() === 'INTEGER'
106
+ columns.push({
107
+ table_name: table.name,
108
+ column_name: column.name,
109
+ declared: column.type,
110
+ nullable: !rowid && column.notnull === 0,
111
+ generated: column.hidden === 2 || column.hidden === 3 ? 'always' : rowid || column.dflt_value !== null ? 'default' : 'no',
112
+ })
113
+ }
114
+ const references = z.array(foreignKeySchema).parse(db.query('SELECT * FROM pragma_foreign_key_list(?) ORDER BY id DESC, seq').all(table.name))
115
+ for (const id of new Set(references.map((reference) => reference.id))) {
116
+ const parts = references.filter((reference) => reference.id === id)
117
+ const referenced = parts[0]?.table
118
+ if (referenced === undefined) continue
119
+ // `REFERENCES parent` without columns names the parent's primary key.
120
+ const implicit = parts.some((part) => part.to === null) ? primaryKeyOf(db, referenced) : []
121
+ const referencedColumns: string[] = []
122
+ for (const [index, part] of parts.entries()) {
123
+ const name = part.to ?? implicit[index]
124
+ if (name === undefined) {
125
+ throw new CodegenError(`${table.name}: a foreign key to ${referenced} names no columns, and ${referenced} has no primary key of that width`)
126
+ }
127
+ referencedColumns.push(name)
128
+ }
129
+ foreignKeys.push({
130
+ table_name: table.name,
131
+ columns: parts.map((part) => part.from),
132
+ referenced_table: referenced,
133
+ referenced_columns: referencedColumns,
134
+ })
135
+ }
136
+ }
137
+ return { columns, foreignKeys }
138
+ }
139
+
140
+ /**
141
+ * Applies `bundles` (the framework's `fabrika`, then the application's `app`) to a fresh in-memory database and renders
142
+ * the module for `SCHEMA_FILE` from its application tables. Deterministic for given migrations.
143
+ */
144
+ export async function generateSqliteSchema(bundles: readonly SqliteMigrationBundle[]): Promise<string> {
145
+ const db = new Database(':memory:')
146
+ try {
147
+ db.run('PRAGMA foreign_keys = ON')
148
+ await migrateSqlite(bunTarget(db), bundles)
149
+ const { columns, foreignKeys } = introspect(db)
150
+ return renderTables(columns, foreignKeys, describeSqlite)
151
+ } finally {
152
+ db.close()
153
+ }
154
+ }
package/src/codegen.ts CHANGED
@@ -12,7 +12,7 @@ const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
12
12
  const HEADER = '// @generated by fabrika db codegen from the migrated database. Do not edit: write a migration, then run fabrika db codegen.'
13
13
 
14
14
  /** A column's TypeScript types: what a read returns, and what an insert or update may bind when that differs. */
15
- interface ScalarType {
15
+ export interface ScalarType {
16
16
  readonly select: string
17
17
  readonly write?: string
18
18
  }
@@ -206,8 +206,43 @@ interface Rendered {
206
206
  readonly uses: 'ColumnType' | 'Generated' | 'GeneratedAlways' | undefined
207
207
  }
208
208
 
209
- function columnType(column: CatalogColumn): Rendered {
209
+ /** What the renderer needs of a column beyond its name, whichever database it was read from. */
210
+ export interface DescribedColumn {
211
+ readonly scalar: ScalarType
212
+ readonly nullable: boolean
213
+ readonly generated: 'no' | 'default' | 'always'
214
+ /** The column list field that names the column: a day, a timestamp, or (on SQLite) a boolean. */
215
+ readonly list: 'dates' | 'timestamps' | 'booleans' | undefined
216
+ }
217
+
218
+ /** A foreign key as the renderer reads it: the columns of `table_name` that reference `referenced_table`. */
219
+ export interface ForeignKeyColumns {
220
+ readonly table_name: string
221
+ readonly columns: readonly string[]
222
+ readonly referenced_table: string
223
+ readonly referenced_columns: readonly string[]
224
+ }
225
+
226
+ /** A column as the renderer groups it, before its type is described. */
227
+ export interface NamedColumn {
228
+ readonly table_name: string
229
+ readonly column_name: string
230
+ }
231
+
232
+ function describePostgres(column: CatalogColumn): DescribedColumn {
210
233
  const scalar = scalarType(column)
234
+ const list = column.type_type !== 'b'
235
+ ? undefined
236
+ : column.type_name === 'date'
237
+ ? 'dates'
238
+ : column.type_name === 'timestamp' || column.type_name === 'timestamptz'
239
+ ? 'timestamps'
240
+ : undefined
241
+ return { scalar, nullable: column.nullable, generated: column.generated, list }
242
+ }
243
+
244
+ function columnType(column: DescribedColumn): Rendered {
245
+ const { scalar } = column
211
246
  const nullable = column.nullable ? ' | null' : ''
212
247
  const select = `${scalar.select}${nullable}`
213
248
  const write = scalar.write === undefined ? select : `${scalar.write}${nullable}`
@@ -226,7 +261,19 @@ function columnType(column: CatalogColumn): Rendered {
226
261
  * `fabrika db codegen --verify` can compare text.
227
262
  */
228
263
  export function renderSchema(catalog: readonly CatalogColumn[], foreignKeys: readonly CatalogForeignKey[] = []): string {
229
- const tables = new Map<string, CatalogColumn[]>()
264
+ return renderTables(catalog, foreignKeys, describePostgres)
265
+ }
266
+
267
+ /**
268
+ * `renderSchema` over columns of any database: `describe` gives each column's types and list field, and refuses a
269
+ * type the data layer does not read. A foreign key's `constraint_name` is not rendered; keys keep the given order.
270
+ */
271
+ export function renderTables<C extends NamedColumn>(
272
+ catalog: readonly C[],
273
+ foreignKeys: readonly ForeignKeyColumns[],
274
+ describe: (column: C) => DescribedColumn,
275
+ ): string {
276
+ const tables = new Map<string, C[]>()
230
277
  for (const column of catalog) {
231
278
  if (column.table_name.startsWith(FRAMEWORK_TABLE_PREFIX)) continue
232
279
  for (const name of [column.table_name, column.column_name]) {
@@ -243,13 +290,16 @@ export function renderSchema(catalog: readonly CatalogColumn[], foreignKeys: rea
243
290
  const owners = new Map<string, string>()
244
291
  const used = new Set<string>()
245
292
  const interfaces: string[] = []
293
+ const described = new Map<C, DescribedColumn>()
246
294
  for (const table of names) {
247
295
  const name = interfaceName(table)
248
296
  const owner = owners.get(name)
249
297
  if (owner !== undefined) throw new CodegenError(`tables ${owner} and ${table} would both generate ${name}; rename one in a migration`)
250
298
  owners.set(name, table)
251
299
  const fields = (tables.get(table) ?? []).map((column) => {
252
- const rendered = columnType(column)
300
+ const description = describe(column)
301
+ described.set(column, description)
302
+ const rendered = columnType(description)
253
303
  if (rendered.uses !== undefined) used.add(rendered.uses)
254
304
  return `\t${column.column_name}: ${rendered.type}\n`
255
305
  })
@@ -271,14 +321,14 @@ export function renderSchema(catalog: readonly CatalogColumn[], foreignKeys: rea
271
321
  return `\t\tforeignKeys: [\n${rendered.join('')}\t\t],\n`
272
322
  }
273
323
  /**
274
- * A table's columns of `types` as the list field `field`: the `date` columns, which the layer answers as `YYYY-MM-DD`,
275
- * and the timestamps, whose written text it checks. A table without one has no such field.
324
+ * A table's columns listed in `field`: the days, which the layer answers as `YYYY-MM-DD`, the timestamps, whose
325
+ * written text it checks, and SQLite's booleans, which it reads from 0 or 1. A table without one has no such field.
276
326
  */
277
- const listOf = (table: string, field: string, types: readonly string[]): string => {
278
- const listed = (tables.get(table) ?? []).filter((column) => column.type_type === 'b' && types.includes(column.type_name))
327
+ const listOf = (table: string, field: DescribedColumn['list']): string => {
328
+ const listed = (tables.get(table) ?? []).filter((column) => described.get(column)?.list === field)
279
329
  return listed.length === 0 ? '' : `\t\t${field}: [${listed.map((column) => quote(column.column_name)).join(', ')}],\n`
280
330
  }
281
- const datesOf = (table: string): string => `${listOf(table, 'dates', ['date'])}${listOf(table, 'timestamps', ['timestamp', 'timestamptz'])}`
331
+ const datesOf = (table: string): string => `${listOf(table, 'dates')}${listOf(table, 'timestamps')}${listOf(table, 'booleans')}`
282
332
  const columns = names
283
333
  .map((table) =>
284
334
  `\t'${table}': {\n\t\tcolumns: [\n${(tables.get(table) ?? []).map((column) => `\t\t\t'${column.column_name}',\n`).join('')}\t\t],\n${