@fabrika/db 0.0.0-stage → 0.4.0

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 CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
- "name": "@fabrika/db",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@fabrika/db",
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/db"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ },
23
+ "./capability": {
24
+ "bun": "./src/capability.ts",
25
+ "types": "./src/capability.ts",
26
+ "default": "./src/capability.ts"
27
+ },
28
+ "./tables": {
29
+ "bun": "./src/tables.ts",
30
+ "types": "./src/tables.ts",
31
+ "default": "./src/tables.ts"
32
+ },
33
+ "./framework": {
34
+ "bun": "./src/framework.ts",
35
+ "types": "./src/framework.ts",
36
+ "default": "./src/framework.ts"
37
+ },
38
+ "./migrations": {
39
+ "bun": "./src/migrations.ts",
40
+ "types": "./src/migrations.ts",
41
+ "default": "./src/migrations.ts"
42
+ },
43
+ "./production": {
44
+ "bun": "./src/production.ts",
45
+ "types": "./src/production.ts",
46
+ "default": "./src/production.ts"
47
+ },
48
+ "./codegen": {
49
+ "bun": "./src/codegen.ts",
50
+ "types": "./src/codegen.ts",
51
+ "default": "./src/codegen.ts"
52
+ },
53
+ "./testing": {
54
+ "bun": "./src/testing.ts",
55
+ "types": "./src/testing.ts",
56
+ "default": "./src/testing.ts"
57
+ }
58
+ },
59
+ "scripts": {
60
+ "typecheck": "tsc --noEmit",
61
+ "test": "bun test"
62
+ },
63
+ "dependencies": {
64
+ "@fabrika/auth-core": "^0.4.0",
65
+ "@fabrika/platform-node": "^0.4.0",
66
+ "kysely": "0.29.6",
67
+ "kysely-postgres-js": "5.0.1",
68
+ "zod": "^3.25.76"
69
+ }
70
+ }
@@ -0,0 +1,94 @@
1
+ // Captured when the module loads, before application code: a later patch to Object, Array, Map or their prototypes
2
+ // does not change what these references do. Every policy and allowlist check goes through them and index loops,
3
+ // never through an iterator, a spread or a method the checked value (or a mutable prototype) supplies.
4
+
5
+ export const ownKeys = Object.keys
6
+ export const ownDescriptor = Object.getOwnPropertyDescriptor
7
+ export const hasOwn = Object.hasOwn
8
+ export const isArray = Array.isArray
9
+ export const freeze = Object.freeze
10
+ export const prototypeOf = Object.getPrototypeOf
11
+ export const isSafeInteger = Number.isSafeInteger
12
+ export const OBJECT_PROTOTYPE: unknown = Object.prototype
13
+ const define = Object.defineProperty
14
+ export const ownNames = Object.getOwnPropertyNames
15
+ export const ownSymbols = Object.getOwnPropertySymbols
16
+ const apply = Reflect.apply
17
+ const ARRAY_PROTOTYPE: unknown = Array.prototype
18
+ const mapForEach: (this: ReadonlyMap<string, string>, callback: (value: string, key: string) => void) => void = Map.prototype.forEach
19
+
20
+ /**
21
+ * A frozen set of names, read through the captured `Object.hasOwn` only. Entries are defined, not assigned, so
22
+ * `__proto__` or an inherited setter cannot drop one.
23
+ */
24
+ export type NameSet = Readonly<Record<string, true>>
25
+
26
+ export function nameSet(names: readonly string[]): NameSet {
27
+ const set: Record<string, true> = {}
28
+ for (let index = 0; index < names.length; index++) {
29
+ const name = names[index]
30
+ if (name !== undefined) {
31
+ define(set, name, { value: true, enumerable: true })
32
+ }
33
+ }
34
+ return freeze(set)
35
+ }
36
+
37
+ export function inSet(set: NameSet, name: unknown): name is string {
38
+ return typeof name === 'string' && hasOwn(set, name)
39
+ }
40
+
41
+ /** The names in the order they were added. */
42
+ export function namesOf(set: NameSet): readonly string[] {
43
+ return ownKeys(set)
44
+ }
45
+
46
+ /**
47
+ * The elements of a plain array, each passed through `parse` before it is copied; undefined for anything else: a
48
+ * subclass, an array with an own property besides its indexes and `length` (an own `map` or `Symbol.iterator`),
49
+ * a hole, or an accessor element. The input's own methods never run.
50
+ */
51
+ export function parseList<T>(value: unknown, parse: (item: unknown) => T): T[] | undefined {
52
+ if (!isArray(value) || prototypeOf(value) !== ARRAY_PROTOTYPE || ownSymbols(value).length !== 0) {
53
+ return undefined
54
+ }
55
+ const length = value.length
56
+ if (ownNames(value).length !== length + 1) {
57
+ return undefined
58
+ }
59
+ const items: T[] = []
60
+ for (let index = 0; index < length; index++) {
61
+ const descriptor = ownDescriptor(value, index)
62
+ if (descriptor === undefined || !hasOwn(descriptor, 'value')) {
63
+ return undefined
64
+ }
65
+ items[index] = parse(descriptor.value)
66
+ }
67
+ return items
68
+ }
69
+
70
+ /** A frozen copy of a string map, read through the captured `Map.prototype.forEach`. */
71
+ export function recordOf(map: ReadonlyMap<string, string>): Readonly<Record<string, string>> {
72
+ const record: Record<string, string> = {}
73
+ apply(mapForEach, map, [
74
+ (value: string, key: string) => {
75
+ setOwn(record, key, value)
76
+ },
77
+ ])
78
+ return freeze(record)
79
+ }
80
+
81
+ /** Defines an own property: unlike an assignment, it cannot hit `__proto__` or an inherited setter. */
82
+ export function setOwn<V>(record: Record<string, V>, key: string, value: V): void {
83
+ define(record, key, { value, enumerable: true, writable: true, configurable: true })
84
+ }
85
+
86
+ /** Redefines an own data property of any object, as `setOwn` does for a record. */
87
+ export function defineValue(target: object, key: string, value: unknown): void {
88
+ define(target, key, { value, enumerable: true, writable: true, configurable: true })
89
+ }
90
+
91
+ /** An own property of a record, or undefined: an inherited one is never read. */
92
+ export function ownEntry<V>(record: Readonly<Record<string, V>>, key: string): V | undefined {
93
+ return hasOwn(record, key) ? record[key] : undefined
94
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * One authorized object, for one action. The framework mints the class that implements it; this package never
3
+ * constructs one. The `~capability` member is type-only: the implementing class declares it so that
4
+ * `Ref<'note', 'notes.read'>` and `Ref<'note', 'notes.delete'>` stay distinct types, and nothing reads it at runtime.
5
+ */
6
+ export interface Ref<Dim extends string, Action extends string> {
7
+ readonly '~capability': { readonly kind: 'ref'; readonly dimension: Dim; readonly action: Action }
8
+ }
9
+
10
+ /** The scope values of one dimension in which the caller holds one action. */
11
+ export interface Within<Dim extends string, Action extends string> {
12
+ readonly '~capability': { readonly kind: 'within'; readonly dimension: Dim; readonly action: Action }
13
+ }
14
+
15
+ /**
16
+ * One action held as a global grant: the capability of an `app` endpoint, for data of the whole application. Like a
17
+ * `Ref`, the member is type-only.
18
+ */
19
+ export interface AppCapability<Action extends string> {
20
+ readonly '~capability': { readonly kind: 'app'; readonly action: Action }
21
+ }
22
+
23
+ export interface RefGrant {
24
+ readonly kind: 'ref'
25
+ readonly dimension: string
26
+ readonly action: string
27
+ readonly principal: string
28
+ readonly id: string
29
+ /** The values of the containers this object lies in, by dimension (`workspace → w1` for a note). */
30
+ readonly chain: ReadonlyMap<string, string>
31
+ }
32
+
33
+ /** The values of one container dimension in which the caller holds the action: `organization → [o1]`. */
34
+ export interface ContainerGrant {
35
+ readonly dimension: string
36
+ readonly values: readonly string[]
37
+ }
38
+
39
+ /**
40
+ * The most scope values one `Within` carries, its own and its containers' together. Each becomes one bound
41
+ * parameter of every statement the `Within` scopes; Postgres binds at most 65535.
42
+ */
43
+ export const MAX_WITHIN_VALUES = 1000
44
+
45
+ export interface WithinGrant {
46
+ readonly kind: 'within'
47
+ readonly dimension: string
48
+ readonly action: string
49
+ readonly principal: string
50
+ /** The values of `dimension` itself; `all` comes from a grant that is not limited to scope values. */
51
+ readonly values: readonly string[] | 'all'
52
+ /**
53
+ * The containers of `dimension` whose grants also authorise this `Within`. They stay separate from `values`, so
54
+ * every statement judges a row by the chain columns it carries now: a scope moved out of a container is out.
55
+ */
56
+ readonly containers: readonly ContainerGrant[]
57
+ }
58
+
59
+ /** An `app` endpoint's grant: the action everywhere in the application, which the proxy checked was held globally. */
60
+ export interface AppGrant {
61
+ readonly kind: 'app'
62
+ readonly action: string
63
+ readonly principal: string
64
+ }
65
+
66
+ export type CapabilityGrant = RefGrant | WithinGrant | AppGrant
67
+
68
+ /** How a refusal names a capability: `a Ref of note`, `a Within of workspace`, `an app capability`. */
69
+ export function describeGrant(grant: CapabilityGrant): string {
70
+ return grant.kind === 'app' ? 'an app capability' : `a ${grant.kind === 'ref' ? 'Ref' : 'Within'} of ${grant.dimension}`
71
+ }
72
+
73
+ /** One generation of a job's lease, held by one attempt. A write of the attempt commits only while it still holds it. */
74
+ export interface LeaseFence {
75
+ readonly job: string
76
+ /** The attempt holding the lease. */
77
+ readonly holder: string
78
+ readonly generation: number
79
+ }
80
+
81
+ /**
82
+ * The framework side of a capability. `resolve` returns what an authentic capability of the current request grants,
83
+ * or throws when the value is not one, is forged, or belongs to a request that has ended or is not the current one.
84
+ * The data layer checks the kind, the dimension and the action itself, on every operation.
85
+ */
86
+ export interface CapabilityAuthority {
87
+ resolve(capability: unknown): CapabilityGrant
88
+ /**
89
+ * The lease the work running now must still hold for a write to commit: the job attempt's, or `null` in a
90
+ * request. Every mutation checks it inside its own transaction.
91
+ */
92
+ fence(): LeaseFence | null
93
+ }
package/src/codegen.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { databaseCodeQuery, type DatabaseObject } from '@fabrika/platform-node'
2
+ import { SQL } from 'bun'
3
+ import { z } from 'zod/v4'
4
+ import { isPlainIdentifier } from './identifier'
5
+
6
+ /** Where an application keeps the generated types and column list; committed, never hand-edited. */
7
+ export const SCHEMA_FILE = 'src/db/schema.generated.ts'
8
+
9
+ /** Framework tables (the migration ledger, and later files, job leases, webhook deliveries) are not application tables. */
10
+ const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
11
+
12
+ const HEADER = '// @generated by fabrika db codegen from the migrated database. Do not edit: write a migration, then run fabrika db codegen.'
13
+
14
+ /** A column's TypeScript types: what a read returns, and what an insert or update may bind when that differs. */
15
+ interface ScalarType {
16
+ readonly select: string
17
+ readonly write?: string
18
+ }
19
+
20
+ const STRING: ScalarType = { select: 'string' }
21
+ const NUMBER: ScalarType = { select: 'number' }
22
+ /** Bun decodes `int8` and `numeric` to strings, by column type; a bound number or bigint is accepted. */
23
+ const DECIMAL_STRING: ScalarType = { select: 'string', write: 'bigint | number | string' }
24
+ /** A calendar day: the layer answers `YYYY-MM-DD` text for the `Date` Bun decodes, and binds the same text. */
25
+ const DAY: ScalarType = { select: 'string' }
26
+ /** A point in time: Bun decodes a `Date`; a write binds a `Date` or its ISO 8601 text. */
27
+ const TIMESTAMP: ScalarType = { select: 'Date', write: 'Date | string' }
28
+
29
+ /** Exactly the types whose decoding by `Bun.SQL` the codegen test pins; anything else is refused, not guessed. */
30
+ const SCALAR_TYPES: ReadonlyMap<string, ScalarType> = new Map([
31
+ ['text', STRING],
32
+ ['varchar', STRING],
33
+ ['bpchar', STRING],
34
+ ['uuid', STRING],
35
+ ['time', STRING],
36
+ ['timetz', STRING],
37
+ ['interval', STRING],
38
+ ['int2', NUMBER],
39
+ ['int4', NUMBER],
40
+ ['float4', NUMBER],
41
+ ['float8', NUMBER],
42
+ ['int8', DECIMAL_STRING],
43
+ ['numeric', DECIMAL_STRING],
44
+ ['bool', { select: 'boolean' }],
45
+ ['date', DAY],
46
+ ['timestamp', TIMESTAMP],
47
+ ['timestamptz', TIMESTAMP],
48
+ ])
49
+
50
+ export class CodegenError extends Error {
51
+ override name = 'CodegenError'
52
+ }
53
+
54
+ const catalogColumnSchema = z.object({
55
+ table_name: z.string(),
56
+ column_name: z.string(),
57
+ type_name: z.string(),
58
+ /** `pg_type.typtype`: `b` base, `e` enum, `d` domain, … */
59
+ type_type: z.string(),
60
+ /** `pg_type.typcategory`: `A` for an array. */
61
+ type_category: z.string(),
62
+ nullable: z.boolean(),
63
+ generated: z.enum(['no', 'default', 'always']),
64
+ enum_labels: z.array(z.string()),
65
+ })
66
+
67
+ export type CatalogColumn = z.infer<typeof catalogColumnSchema>
68
+
69
+ const catalogForeignKeySchema = z.object({
70
+ table_name: z.string(),
71
+ constraint_name: z.string(),
72
+ columns: z.array(z.string()),
73
+ referenced_table: z.string(),
74
+ referenced_columns: z.array(z.string()),
75
+ })
76
+
77
+ export type CatalogForeignKey = z.infer<typeof catalogForeignKeySchema>
78
+
79
+ /** What codegen reads of the schema: every column, and every foreign key between its tables. */
80
+ export interface Catalog {
81
+ readonly columns: readonly CatalogColumn[]
82
+ readonly foreignKeys: readonly CatalogForeignKey[]
83
+ }
84
+
85
+ /** One row per column of every ordinary or partitioned table in the schema, in table then column order. */
86
+ const CATALOG_QUERY = `
87
+ SELECT
88
+ c.relname::text AS table_name,
89
+ a.attname::text AS column_name,
90
+ t.typname::text AS type_name,
91
+ t.typtype::text AS type_type,
92
+ t.typcategory::text AS type_category,
93
+ NOT a.attnotnull AS nullable,
94
+ CASE
95
+ WHEN a.attgenerated <> '' OR a.attidentity = 'a' THEN 'always'
96
+ WHEN a.atthasdef OR a.attidentity = 'd' THEN 'default'
97
+ ELSE 'no'
98
+ END AS generated,
99
+ ARRAY(SELECT e.enumlabel::text FROM pg_enum e WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder) AS enum_labels
100
+ FROM pg_attribute a
101
+ JOIN pg_class c ON c.oid = a.attrelid
102
+ JOIN pg_namespace n ON n.oid = c.relnamespace
103
+ JOIN pg_type t ON t.oid = a.atttypid
104
+ WHERE n.nspname = $1 AND c.relkind IN ('r', 'p') AND NOT c.relispartition AND a.attnum > 0 AND NOT a.attisdropped
105
+ ORDER BY c.relname, a.attnum
106
+ `
107
+
108
+ /** One row per foreign key between two tables of the schema, its columns in key order, by table then constraint name. */
109
+ const FOREIGN_KEY_QUERY = `
110
+ SELECT
111
+ c.relname::text AS table_name,
112
+ k.conname::text AS constraint_name,
113
+ ARRAY(
114
+ SELECT a.attname::text FROM unnest(k.conkey) WITH ORDINALITY AS u(attnum, position)
115
+ JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.attnum ORDER BY u.position
116
+ ) AS columns,
117
+ r.relname::text AS referenced_table,
118
+ ARRAY(
119
+ SELECT a.attname::text FROM unnest(k.confkey) WITH ORDINALITY AS u(attnum, position)
120
+ JOIN pg_attribute a ON a.attrelid = k.confrelid AND a.attnum = u.attnum ORDER BY u.position
121
+ ) AS referenced_columns
122
+ FROM pg_constraint k
123
+ JOIN pg_class c ON c.oid = k.conrelid
124
+ JOIN pg_namespace n ON n.oid = c.relnamespace
125
+ JOIN pg_class r ON r.oid = k.confrelid
126
+ JOIN pg_namespace rn ON rn.oid = r.relnamespace
127
+ WHERE k.contype = 'f' AND n.nspname = $1 AND rn.nspname = $1
128
+ ORDER BY c.relname, k.conname
129
+ `
130
+
131
+ /**
132
+ * Reads the columns of the application's schema, refusing one that escapes it or holds a database object that
133
+ * `objects`, the migrations' manifest (`PostgresApplicationMigrations.objects`), does not name. The URL is a
134
+ * credential: no message here contains it.
135
+ */
136
+ export async function introspectSchema(url: string, schema: string, objects: readonly DatabaseObject[]): Promise<readonly CatalogColumn[]> {
137
+ return (await introspectCatalog(url, schema, objects)).columns
138
+ }
139
+
140
+ /** `introspectSchema`, with the foreign keys between the schema's tables. */
141
+ export async function introspectCatalog(url: string, schema: string, objects: readonly DatabaseObject[]): Promise<Catalog> {
142
+ if (!isPlainIdentifier(schema)) throw new CodegenError(`the schema must match ^[a-z_][a-z0-9_]*$: ${schema}`)
143
+ const sql = new SQL({ url, adapter: 'postgres', max: 1, prepare: false })
144
+ try {
145
+ return await sql.begin('read only', async (tx) => {
146
+ // The role or the database may set either for its sessions; the catalog query must not depend on them.
147
+ await tx.unsafe('SET LOCAL search_path TO pg_catalog, pg_temp')
148
+ await tx.unsafe('SET LOCAL standard_conforming_strings = on')
149
+ const namespaces: unknown = await tx.unsafe('SELECT 1 FROM pg_namespace WHERE nspname = $1', [schema])
150
+ if (!Array.isArray(namespaces) || namespaces.length === 0) {
151
+ throw new CodegenError(`schema ${schema} does not exist; run fabrika db migrate first`)
152
+ }
153
+ // The migration runner's own pre-commit check, so an object created outside a migration fails every start.
154
+ const code = z.array(z.object({ violation: z.string() })).parse(await tx.unsafe(databaseCodeQuery(schema, objects)))
155
+ if (code.length > 0) {
156
+ throw new CodegenError(
157
+ `schema ${schema} holds database code that escapes it or that no migration declares: ${
158
+ code.map((row) => row.violation).join(', ')
159
+ }; declare it in a migration or drop it in a new one`,
160
+ )
161
+ }
162
+ return {
163
+ columns: z.array(catalogColumnSchema).parse(await tx.unsafe(CATALOG_QUERY, [schema])),
164
+ foreignKeys: z.array(catalogForeignKeySchema).parse(await tx.unsafe(FOREIGN_KEY_QUERY, [schema])),
165
+ }
166
+ })
167
+ } finally {
168
+ await sql.close()
169
+ }
170
+ }
171
+
172
+ /**
173
+ * `note_comments` → `NoteCommentsTable`; the suffix keeps every name clear of `DB` and the imported type names. A
174
+ * name that would not start with a letter (`_123`) gets a `T` prefix, so it stays a TypeScript identifier.
175
+ */
176
+ function interfaceName(table: string): string {
177
+ const words = table.split('_').filter((word) => word !== '')
178
+ const pascal = words.map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join('')
179
+ return `${/^[A-Z]/.test(pascal) ? pascal : `T${pascal}`}Table`
180
+ }
181
+
182
+ /** As keys of the generated object literal these would set or shadow the prototype instead of naming a table. */
183
+ const RESERVED_TABLE_NAMES: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype'])
184
+
185
+ function quote(value: string): string {
186
+ return `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`
187
+ }
188
+
189
+ function scalarType(column: CatalogColumn): ScalarType {
190
+ const where = `${column.table_name}.${column.column_name}`
191
+ if (column.type_type === 'e') {
192
+ if (column.enum_labels.length === 0) throw new CodegenError(`${where}: enum ${column.type_name} has no labels`)
193
+ return { select: column.enum_labels.map(quote).join(' | ') }
194
+ }
195
+ const scalar = column.type_category === 'A' || column.type_type !== 'b' ? undefined : SCALAR_TYPES.get(column.type_name)
196
+ if (scalar === undefined) {
197
+ throw new CodegenError(
198
+ `${where} has type ${column.type_name}, which the data layer does not read or bind (supported: ${[...SCALAR_TYPES.keys()].join(', ')}, enums)`,
199
+ )
200
+ }
201
+ return scalar
202
+ }
203
+
204
+ interface Rendered {
205
+ readonly type: string
206
+ readonly uses: 'ColumnType' | 'Generated' | 'GeneratedAlways' | undefined
207
+ }
208
+
209
+ function columnType(column: CatalogColumn): Rendered {
210
+ const scalar = scalarType(column)
211
+ const nullable = column.nullable ? ' | null' : ''
212
+ const select = `${scalar.select}${nullable}`
213
+ const write = scalar.write === undefined ? select : `${scalar.write}${nullable}`
214
+ if (column.generated === 'always') return { type: `GeneratedAlways<${select}>`, uses: 'GeneratedAlways' }
215
+ if (column.generated === 'default') {
216
+ return write === select
217
+ ? { type: `Generated<${select}>`, uses: 'Generated' }
218
+ : { type: `ColumnType<${select}, ${write} | undefined, ${write}>`, uses: 'ColumnType' }
219
+ }
220
+ return write === select ? { type: select, uses: undefined } : { type: `ColumnType<${select}, ${write}, ${write}>`, uses: 'ColumnType' }
221
+ }
222
+
223
+ /**
224
+ * The generated module: one interface per application table, the `DB` map Kysely and `defineTables` take, and the
225
+ * runtime column list with each table's foreign keys to application tables. Deterministic for a given catalog, so
226
+ * `fabrika db codegen --verify` can compare text.
227
+ */
228
+ export function renderSchema(catalog: readonly CatalogColumn[], foreignKeys: readonly CatalogForeignKey[] = []): string {
229
+ const tables = new Map<string, CatalogColumn[]>()
230
+ for (const column of catalog) {
231
+ if (column.table_name.startsWith(FRAMEWORK_TABLE_PREFIX)) continue
232
+ for (const name of [column.table_name, column.column_name]) {
233
+ if (!isPlainIdentifier(name)) throw new CodegenError(`${name} is not a plain identifier (^[a-z_][a-z0-9_]*$), which the data layer requires`)
234
+ }
235
+ if (RESERVED_TABLE_NAMES.has(column.table_name)) {
236
+ throw new CodegenError(`table ${column.table_name} has a reserved JavaScript name; rename it in a migration`)
237
+ }
238
+ const columns = tables.get(column.table_name) ?? []
239
+ columns.push(column)
240
+ tables.set(column.table_name, columns)
241
+ }
242
+ const names = [...tables.keys()].sort()
243
+ const owners = new Map<string, string>()
244
+ const used = new Set<string>()
245
+ const interfaces: string[] = []
246
+ for (const table of names) {
247
+ const name = interfaceName(table)
248
+ const owner = owners.get(name)
249
+ if (owner !== undefined) throw new CodegenError(`tables ${owner} and ${table} would both generate ${name}; rename one in a migration`)
250
+ owners.set(name, table)
251
+ const fields = (tables.get(table) ?? []).map((column) => {
252
+ const rendered = columnType(column)
253
+ if (rendered.uses !== undefined) used.add(rendered.uses)
254
+ return `\t${column.column_name}: ${rendered.type}\n`
255
+ })
256
+ interfaces.push(`export interface ${name} {\n${fields.join('')}}\n`)
257
+ }
258
+ const kyselyNames = ['ColumnType', 'Generated', 'GeneratedAlways'].filter((name) => used.has(name))
259
+ const imports = [
260
+ "import type { TypedColumnList } from '@fabrika/db'\n",
261
+ ...(kyselyNames.length === 0 ? [] : [`import type { ${kyselyNames.join(', ')} } from 'kysely'\n`]),
262
+ ]
263
+ const db = names.map((table) => `\t${table}: ${interfaceName(table)}\n`).join('')
264
+ const keysOf = (table: string): string => {
265
+ const keys = foreignKeys.filter((key) => key.table_name === table && tables.has(key.referenced_table))
266
+ if (keys.length === 0) return '\t\tforeignKeys: [],\n'
267
+ const rendered = keys.map((key) =>
268
+ `\t\t\t{\n\t\t\t\tcolumns: [${key.columns.map(quote).join(', ')}],\n\t\t\t\ttable: ${quote(key.referenced_table)},\n`
269
+ + `\t\t\t\treferences: [${key.referenced_columns.map(quote).join(', ')}],\n\t\t\t},\n`
270
+ )
271
+ return `\t\tforeignKeys: [\n${rendered.join('')}\t\t],\n`
272
+ }
273
+ /**
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.
276
+ */
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))
279
+ return listed.length === 0 ? '' : `\t\t${field}: [${listed.map((column) => quote(column.column_name)).join(', ')}],\n`
280
+ }
281
+ const datesOf = (table: string): string => `${listOf(table, 'dates', ['date'])}${listOf(table, 'timestamps', ['timestamp', 'timestamptz'])}`
282
+ const columns = names
283
+ .map((table) =>
284
+ `\t'${table}': {\n\t\tcolumns: [\n${(tables.get(table) ?? []).map((column) => `\t\t\t'${column.column_name}',\n`).join('')}\t\t],\n${
285
+ keysOf(table)
286
+ }${datesOf(table)}\t},\n`
287
+ )
288
+ .join('')
289
+ return [
290
+ `${HEADER}\n`,
291
+ imports.join(''),
292
+ ...interfaces,
293
+ `export interface DB {\n${db}}\n`,
294
+ `export const columns: TypedColumnList<DB> = {\n${columns}}\n`,
295
+ ].join('\n')
296
+ }
297
+
298
+ /** Introspects the migrated schema, checked against the migrations' manifest, and renders the module for `SCHEMA_FILE`. */
299
+ export async function generateSchema(url: string, schema: string, objects: readonly DatabaseObject[]): Promise<string> {
300
+ const catalog = await introspectCatalog(url, schema, objects)
301
+ return renderSchema(catalog.columns, catalog.foreignKeys)
302
+ }