@fabrika/db 0.0.0-stage → 0.0.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 +69 -5
- package/src/builtins.ts +94 -0
- package/src/capability.ts +93 -0
- package/src/codegen.ts +302 -0
- package/src/database.ts +413 -0
- package/src/description.ts +309 -0
- package/src/errors.ts +124 -0
- package/src/file-table-sql.ts +133 -0
- package/src/file-tables.ts +577 -0
- package/src/form-admissions.ts +82 -0
- package/src/framework-tables.ts +147 -0
- package/src/framework.ts +89 -0
- package/src/guard.ts +439 -0
- package/src/identifier.ts +43 -0
- package/src/index.ts +129 -0
- package/src/job-leases.ts +373 -0
- package/src/links.ts +61 -0
- package/src/migrations.ts +97 -0
- package/src/nodes.ts +344 -0
- package/src/orm-types.ts +596 -0
- package/src/orm.ts +891 -0
- package/src/policy.ts +1194 -0
- package/src/production.ts +26 -0
- package/src/registry.ts +57 -0
- package/src/sql.ts +732 -0
- package/src/store.ts +1693 -0
- package/src/tables.ts +167 -0
- package/src/temporal.ts +91 -0
- package/src/testing.ts +695 -0
- package/src/vocabulary-tables.ts +206 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@fabrika/db",
|
|
3
|
+
"version": "0.0.1",
|
|
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.0.1",
|
|
65
|
+
"@fabrika/platform-node": "^0.0.1",
|
|
66
|
+
"kysely": "0.29.6",
|
|
67
|
+
"kysely-postgres-js": "5.0.1",
|
|
68
|
+
"zod": "^3.25.76"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/builtins.ts
ADDED
|
@@ -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
|
+
}
|