@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.
- package/package.json +44 -12
- package/src/admissions.ts +78 -12
- package/src/application-rows.ts +108 -0
- package/src/capability.ts +5 -0
- package/src/codegen-sqlite.ts +154 -0
- package/src/codegen.ts +59 -9
- package/src/database.ts +208 -56
- package/src/errors.ts +1 -0
- package/src/file-columns.ts +4 -1
- package/src/framework-tables.ts +28 -8
- package/src/framework.ts +10 -2
- package/src/guard.ts +8 -0
- package/src/index.ts +9 -1
- package/src/job-leases.ts +157 -37
- package/src/links.ts +8 -1
- package/src/migrations-sqlite.ts +132 -0
- package/src/nodes.ts +45 -9
- package/src/orm.ts +25 -15
- package/src/policy.ts +8 -2
- package/src/postgres.ts +26 -0
- package/src/registry.ts +8 -2
- package/src/sql.ts +15 -7
- package/src/sqlite-bun.ts +122 -0
- package/src/sqlite-migrate.ts +242 -0
- package/src/sqlite.ts +224 -0
- package/src/store.ts +336 -62
- package/src/stored-files.ts +149 -46
- package/src/tables.ts +16 -0
- package/src/temporal.ts +115 -9
- package/src/testing-shared.ts +691 -0
- package/src/testing-sqlite.ts +513 -0
- package/src/testing.ts +170 -482
- package/types/src/admissions.d.ts +21 -0
- package/types/src/application-rows.d.ts +58 -0
- package/types/src/builtins.d.ts +42 -0
- package/types/src/capability.d.ts +119 -0
- package/types/src/codegen-sqlite.d.ts +6 -0
- package/types/src/codegen.d.ts +82 -0
- package/types/src/database.d.ts +179 -0
- package/types/src/description.d.ts +109 -0
- package/types/src/errors.d.ts +98 -0
- package/types/src/file-columns.d.ts +37 -0
- package/types/src/file-references.d.ts +27 -0
- package/types/src/framework-tables.d.ts +59 -0
- package/types/src/framework.d.ts +37 -0
- package/types/src/guard.d.ts +23 -0
- package/types/src/identifier.d.ts +4 -0
- package/types/src/index.d.ts +10 -0
- package/types/src/job-leases.d.ts +93 -0
- package/types/src/links.d.ts +13 -0
- package/types/src/migrations-sqlite.d.ts +4 -0
- package/types/src/migrations.d.ts +25 -0
- package/types/src/nodes.d.ts +63 -0
- package/types/src/orm-types.d.ts +638 -0
- package/types/src/orm.d.ts +77 -0
- package/types/src/policy.d.ts +354 -0
- package/types/src/postgres.d.ts +12 -0
- package/types/src/production.d.ts +5 -0
- package/types/src/registry.d.ts +40 -0
- package/types/src/sql.d.ts +59 -0
- package/types/src/sqlite-bun.d.ts +13 -0
- package/types/src/sqlite-migrate.d.ts +28 -0
- package/types/src/sqlite.d.ts +24 -0
- package/types/src/store.d.ts +345 -0
- package/types/src/stored-files.d.ts +98 -0
- package/types/src/tables.d.ts +21 -0
- package/types/src/temporal.d.ts +33 -0
- package/types/src/testing-shared.d.ts +193 -0
- package/types/src/testing-sqlite.d.ts +22 -0
- package/types/src/testing.d.ts +35 -0
- 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.
|
|
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.
|
|
65
|
-
"@fabrika/platform
|
|
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
|
|
6
|
-
// counter and then write it, and each prunes a bounded batch
|
|
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
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
275
|
-
*
|
|
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:
|
|
278
|
-
const listed = (tables.get(table) ?? []).filter((column) => column
|
|
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'
|
|
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${
|