@crombolo/data 0.9.1 → 0.11.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 +2 -2
- package/src/audit.ts +291 -0
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/cluster.ts +1 -1
- package/src/database.ts +39 -9
- package/src/dialect.ts +32 -0
- package/src/index.ts +9 -0
- package/src/migrations.ts +147 -31
- package/src/pending.ts +1 -1
- package/src/repositories/bundles.ts +36 -4
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/documents.ts +13 -3
- package/src/repositories/form-entries.ts +1 -1
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +15 -1
- package/src/repositories/nodes.ts +32 -7
- package/src/repositories/transfer-runs.ts +20 -2
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/bundle-components.ts +1 -1
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +1 -1
- package/src/schema/content.ts +2 -2
- package/src/schema/created-packages.ts +1 -1
- package/src/schema/form-permissions.ts +1 -1
- package/src/schema/form-workflows.ts +1 -1
- package/src/schema/forms.ts +1 -1
- package/src/schema/identity.ts +1 -1
- package/src/schema/plan.ts +10 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/upgrade-report.ts +1 -1
- package/src/schema-state.ts +1 -1
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one check every statement a bundle's builder or capability emits passes.
|
|
3
|
+
* `docs/bundles.md`, Part 4.
|
|
4
|
+
*
|
|
5
|
+
* It only ever sees SQL this module wrote, which is regular enough to check
|
|
6
|
+
* token by token: every identifier is double-quoted and lowercase, every value
|
|
7
|
+
* is a `?` placeholder or a literal the builder rendered, and there is exactly
|
|
8
|
+
* one statement. What it asserts is that every table or index the statement
|
|
9
|
+
* names carries the bundle's own prefix. A statement that fails is refused
|
|
10
|
+
* before it reaches the database, so a bug in the code above it is a refusal
|
|
11
|
+
* rather than a query against a core table.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** A refusal: the statement never ran. */
|
|
15
|
+
export class BundleStatementError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly bundle: string,
|
|
18
|
+
readonly reason: string,
|
|
19
|
+
) {
|
|
20
|
+
super(`Bundle "${bundle}": a statement was refused before it ran: ${reason}.`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Keywords after which the next identifier names a table or an index. */
|
|
25
|
+
const OBJECT_POSITIONS = new Set([
|
|
26
|
+
'FROM',
|
|
27
|
+
'INTO',
|
|
28
|
+
'UPDATE',
|
|
29
|
+
'TABLE',
|
|
30
|
+
'JOIN',
|
|
31
|
+
'INDEX',
|
|
32
|
+
'ON',
|
|
33
|
+
'REFERENCES',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** Keywords that may sit between a position keyword and the object it names. */
|
|
37
|
+
const FILLERS = new Set(['IF', 'NOT', 'EXISTS', 'ONLY'])
|
|
38
|
+
|
|
39
|
+
type Token =
|
|
40
|
+
| { kind: 'word'; value: string }
|
|
41
|
+
| { kind: 'identifier'; value: string }
|
|
42
|
+
| { kind: 'other'; value: string }
|
|
43
|
+
|
|
44
|
+
const QUOTED = /^"([a-z][a-z0-9_]*)"/
|
|
45
|
+
const WORD = /^[A-Za-z_][A-Za-z0-9_]*/
|
|
46
|
+
const STRING = /^'(?:[^']|'')*'/
|
|
47
|
+
const NUMBER = /^-?\d+(?:\.\d+)?/
|
|
48
|
+
const PUNCTUATION = /^(?:<=|>=|<>|[(),=<>*?.])/
|
|
49
|
+
|
|
50
|
+
function tokenize(bundle: string, sql: string): Token[] {
|
|
51
|
+
const tokens: Token[] = []
|
|
52
|
+
let rest = sql
|
|
53
|
+
for (;;) {
|
|
54
|
+
rest = rest.replace(/^\s+/, '')
|
|
55
|
+
if (rest === '') return tokens
|
|
56
|
+
if (rest.startsWith('--') || rest.startsWith('/*'))
|
|
57
|
+
throw new BundleStatementError(bundle, 'it contains a comment')
|
|
58
|
+
if (rest.startsWith(';')) throw new BundleStatementError(bundle, 'it contains a ";"')
|
|
59
|
+
if (rest.startsWith('"')) {
|
|
60
|
+
const match = QUOTED.exec(rest)
|
|
61
|
+
if (!match)
|
|
62
|
+
throw new BundleStatementError(bundle, 'it quotes an identifier no bundle may write')
|
|
63
|
+
tokens.push({ kind: 'identifier', value: match[1] as string })
|
|
64
|
+
rest = rest.slice(match[0].length)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const string = STRING.exec(rest)
|
|
68
|
+
if (string) {
|
|
69
|
+
tokens.push({ kind: 'other', value: string[0] })
|
|
70
|
+
rest = rest.slice(string[0].length)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
const word = WORD.exec(rest)
|
|
74
|
+
if (word) {
|
|
75
|
+
tokens.push({ kind: 'word', value: word[0].toUpperCase() })
|
|
76
|
+
rest = rest.slice(word[0].length)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const other = NUMBER.exec(rest) ?? PUNCTUATION.exec(rest)
|
|
80
|
+
if (!other) throw new BundleStatementError(bundle, `it contains "${rest[0]}"`)
|
|
81
|
+
tokens.push({ kind: 'other', value: other[0] })
|
|
82
|
+
rest = rest.slice(other[0].length)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The tables and indexes a statement names, as the guard sees them. */
|
|
87
|
+
export function objectsNamed(bundle: string, sql: string): string[] {
|
|
88
|
+
const tokens = tokenize(bundle, sql)
|
|
89
|
+
const named: string[] = []
|
|
90
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
91
|
+
const token = tokens[i] as Token
|
|
92
|
+
if (token.kind !== 'word' || !OBJECT_POSITIONS.has(token.value)) continue
|
|
93
|
+
// `ON CONFLICT` and `ON DELETE` are not objects; only an index's `ON <table>` is.
|
|
94
|
+
let j = i + 1
|
|
95
|
+
while (tokens[j]?.kind === 'word' && FILLERS.has((tokens[j] as Token).value)) j++
|
|
96
|
+
const next = tokens[j]
|
|
97
|
+
if (token.value === 'ON' && next?.kind !== 'identifier') continue
|
|
98
|
+
if (next?.kind !== 'identifier')
|
|
99
|
+
throw new BundleStatementError(bundle, `"${token.value}" is not followed by a quoted name`)
|
|
100
|
+
named.push(next.value)
|
|
101
|
+
}
|
|
102
|
+
return named
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Refuses a statement unless every table and index it names carries `prefix`,
|
|
107
|
+
* and it names at least one.
|
|
108
|
+
*/
|
|
109
|
+
export function guardStatement(bundle: string, prefix: string, sql: string): string {
|
|
110
|
+
const named = objectsNamed(bundle, sql)
|
|
111
|
+
if (named.length === 0) throw new BundleStatementError(bundle, 'it names no table')
|
|
112
|
+
for (const name of named)
|
|
113
|
+
if (!name.startsWith(prefix))
|
|
114
|
+
throw new BundleStatementError(bundle, `it names "${name}", which is not this bundle's`)
|
|
115
|
+
return sql
|
|
116
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The names a bundle's tables are stored under. `docs/bundles.md`, Part 4.
|
|
3
|
+
*
|
|
4
|
+
* Every physical name is `bundle_<id>__<name>`, with the id's hyphens written
|
|
5
|
+
* as underscores. An id may not contain `--` and a logical name may not contain
|
|
6
|
+
* `__`, so the double underscore is an unambiguous separator and no two bundles
|
|
7
|
+
* can produce the same physical name. Core reserves the `bundle_` prefix, so a
|
|
8
|
+
* match on a bundle's own prefix can only ever be that bundle's table.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Postgres truncates a longer identifier silently, so a longer one is refused. */
|
|
12
|
+
export const MAX_IDENTIFIER = 63
|
|
13
|
+
|
|
14
|
+
/** Table and column names, as a bundle writes them. */
|
|
15
|
+
export const LOGICAL_NAME = /^[a-z][a-z0-9_]*$/
|
|
16
|
+
|
|
17
|
+
/** The prefix every one of this bundle's objects carries. */
|
|
18
|
+
export function bundlePrefix(id: string): string {
|
|
19
|
+
return `bundle_${id.replaceAll('-', '_')}__`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const physicalTable = (id: string, table: string): string => `${bundlePrefix(id)}${table}`
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* An index's name, generated rather than chosen, from what it covers. The
|
|
26
|
+
* `ix_` that follows the prefix is a namespace a table may not enter, because
|
|
27
|
+
* Postgres keeps tables and indexes in one.
|
|
28
|
+
*/
|
|
29
|
+
export function indexName(id: string, table: string, columns: readonly string[]): string {
|
|
30
|
+
const hash = new Bun.CryptoHasher('sha256').update(`${table}(${columns.join(',')})`).digest('hex')
|
|
31
|
+
return `${bundlePrefix(id)}ix_${hash.slice(0, 12)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Why an id cannot own tables, or undefined when it can. */
|
|
35
|
+
export function bundleIdProblem(id: string): string | undefined {
|
|
36
|
+
if (id.includes('--'))
|
|
37
|
+
return `the id "${id}" contains "--", which would make its table names ambiguous`
|
|
38
|
+
if (id.endsWith('-'))
|
|
39
|
+
return `the id "${id}" ends with "-", which would make its table names ambiguous`
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Why a table or column name is not allowed, or undefined when it is. */
|
|
44
|
+
export function logicalNameProblem(kind: 'table' | 'column', name: string): string | undefined {
|
|
45
|
+
if (typeof name !== 'string' || !LOGICAL_NAME.test(name))
|
|
46
|
+
return `the ${kind} name "${String(name)}" is not lowercase letters, digits and underscores starting with a letter`
|
|
47
|
+
if (name.includes('__')) return `the ${kind} name "${name}" contains "__", the separator`
|
|
48
|
+
if (kind === 'table' && name.startsWith('ix_'))
|
|
49
|
+
return `the table name "${name}" starts with "ix_", which index names use`
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Why a physical name is too long for Postgres, or undefined when it fits. */
|
|
54
|
+
export function lengthProblem(physical: string): string | undefined {
|
|
55
|
+
return physical.length > MAX_IDENTIFIER
|
|
56
|
+
? `"${physical}" is ${physical.length} characters, past the ${MAX_IDENTIFIER} Postgres keeps`
|
|
57
|
+
: undefined
|
|
58
|
+
}
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bundle's declaration, compiled: its steps in order, each with the schema it
|
|
3
|
+
* starts from and the one it leaves, its kind, and its checksum. And the rules
|
|
4
|
+
* a declaration is held to, which core's own plan is held to as well — a
|
|
5
|
+
* contract only in a major, and only removing what an earlier release added.
|
|
6
|
+
* `docs/bundles.md`, Part 4.
|
|
7
|
+
*/
|
|
8
|
+
import { isMajorRelease } from '../migrations.ts'
|
|
9
|
+
import {
|
|
10
|
+
type ColumnSpec,
|
|
11
|
+
type Op,
|
|
12
|
+
type Operation,
|
|
13
|
+
type SchemaBuilder,
|
|
14
|
+
type ShapeOf,
|
|
15
|
+
schemaBuilder,
|
|
16
|
+
type TablesShape,
|
|
17
|
+
} from './builder.ts'
|
|
18
|
+
import {
|
|
19
|
+
bundleIdProblem,
|
|
20
|
+
bundlePrefix,
|
|
21
|
+
indexName,
|
|
22
|
+
lengthProblem,
|
|
23
|
+
logicalNameProblem,
|
|
24
|
+
physicalTable,
|
|
25
|
+
} from './names.ts'
|
|
26
|
+
|
|
27
|
+
/** One step of a bundle's chain, as the bundle writes it. */
|
|
28
|
+
export interface BundleStep {
|
|
29
|
+
/** Unique within the bundle; what the ledger records and a refusal names. */
|
|
30
|
+
name: string
|
|
31
|
+
/** The bundle's own release that ships this step. */
|
|
32
|
+
release: string
|
|
33
|
+
up(s: SchemaBuilder): Op<unknown> | readonly Op<unknown>[]
|
|
34
|
+
/**
|
|
35
|
+
* Writes data once `up` has run, in the same transaction, over the tables as
|
|
36
|
+
* they now are. Untyped, because a step's shape is only known once the steps
|
|
37
|
+
* before it are; every name is still checked against that shape when it runs.
|
|
38
|
+
*/
|
|
39
|
+
backfill?(data: BackfillTables): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: the shape at a step is not known to the step's own type
|
|
43
|
+
export type BackfillTables = any
|
|
44
|
+
|
|
45
|
+
/** A bundle's declared tables: what `ServerBundle.tables` holds. */
|
|
46
|
+
export interface BundleTables<S extends TablesShape = TablesShape> {
|
|
47
|
+
readonly id: string
|
|
48
|
+
readonly steps: readonly BundleStep[]
|
|
49
|
+
/** Type-level only: the shape after every step. */
|
|
50
|
+
readonly __shape?: S
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Declares a bundle's tables. Nothing is checked or run here: the declaration
|
|
55
|
+
* is checked when the bundle is wired, so every problem is reported at once.
|
|
56
|
+
*/
|
|
57
|
+
export function defineBundleTables<const Steps extends readonly BundleStep[]>(
|
|
58
|
+
id: string,
|
|
59
|
+
steps: Steps,
|
|
60
|
+
): BundleTables<ShapeOf<Steps>> {
|
|
61
|
+
return Object.freeze({ id, steps: Object.freeze([...steps]) })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ResolvedColumn {
|
|
65
|
+
name: string
|
|
66
|
+
spec: ColumnSpec
|
|
67
|
+
/** The release of the step that added it, for the contract rule. */
|
|
68
|
+
addedIn: { step: string; release: string }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ResolvedIndex {
|
|
72
|
+
name: string
|
|
73
|
+
columns: string[]
|
|
74
|
+
unique: boolean
|
|
75
|
+
addedIn: { step: string; release: string }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ResolvedTable {
|
|
79
|
+
name: string
|
|
80
|
+
physical: string
|
|
81
|
+
columns: Map<string, ResolvedColumn>
|
|
82
|
+
/** By the columns they cover, joined with commas. */
|
|
83
|
+
indexes: Map<string, ResolvedIndex>
|
|
84
|
+
addedIn: { step: string; release: string }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The bundle's tables at one point in its chain. */
|
|
88
|
+
export interface ResolvedSchema {
|
|
89
|
+
id: string
|
|
90
|
+
prefix: string
|
|
91
|
+
tables: Map<string, ResolvedTable>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type StepKind = 'expand' | 'contract'
|
|
95
|
+
|
|
96
|
+
export interface CompiledStep {
|
|
97
|
+
name: string
|
|
98
|
+
release: string
|
|
99
|
+
position: number
|
|
100
|
+
from: string
|
|
101
|
+
to: string
|
|
102
|
+
kind: StepKind
|
|
103
|
+
operations: Operation[]
|
|
104
|
+
/** A hash of the operations, so a step edited after it shipped is caught. */
|
|
105
|
+
checksum: string
|
|
106
|
+
/** For a contract: the step that added the first thing it removes. */
|
|
107
|
+
contracts: string | undefined
|
|
108
|
+
before: ResolvedSchema
|
|
109
|
+
after: ResolvedSchema
|
|
110
|
+
backfill: BundleStep['backfill']
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface CompiledTables {
|
|
114
|
+
id: string
|
|
115
|
+
stateKey: string
|
|
116
|
+
initialState: string
|
|
117
|
+
steps: CompiledStep[]
|
|
118
|
+
/** The shape after every step: what the capability is resolved against. */
|
|
119
|
+
schema: ResolvedSchema
|
|
120
|
+
problems: string[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const bundleStateKey = (id: string): string => `Crombolo.Bundle.${id}.State`
|
|
124
|
+
export const bundleInitialState = (id: string): string => `bundle:${id}:0`
|
|
125
|
+
const stateAt = (id: string, position: number, name: string): string =>
|
|
126
|
+
`bundle:${id}:${position}:${name}`
|
|
127
|
+
|
|
128
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
|
129
|
+
const STEP_NAME = /^[a-z0-9][a-z0-9._-]{0,99}$/
|
|
130
|
+
/** What a string default may hold: it is written into DDL as a literal. */
|
|
131
|
+
const SAFE_DEFAULT = /^[A-Za-z0-9 _.:@/-]{0,200}$/
|
|
132
|
+
const MAX_VARCHAR = 10_485_760
|
|
133
|
+
/** Names that would collide with the capability's own members or filter keys. */
|
|
134
|
+
const RESERVED_TABLES = new Set(['transaction'])
|
|
135
|
+
const RESERVED_COLUMNS = new Set(['and', 'or'])
|
|
136
|
+
|
|
137
|
+
export function compareRelease(a: string, b: string): number {
|
|
138
|
+
const pa = a.split(/[.+-]/).map(Number)
|
|
139
|
+
const pb = b.split(/[.+-]/).map(Number)
|
|
140
|
+
for (let i = 0; i < 3; i++) {
|
|
141
|
+
const da = pa[i] ?? 0
|
|
142
|
+
const db = pb[i] ?? 0
|
|
143
|
+
if (da !== db) return da < db ? -1 : 1
|
|
144
|
+
}
|
|
145
|
+
return 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** JSON with keys sorted, so the same operations always hash the same. */
|
|
149
|
+
function canonical(value: unknown): string {
|
|
150
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
151
|
+
if (value && typeof value === 'object')
|
|
152
|
+
return `{${Object.keys(value)
|
|
153
|
+
.sort()
|
|
154
|
+
.map((key) => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`)
|
|
155
|
+
.join(',')}}`
|
|
156
|
+
return JSON.stringify(value) ?? 'null'
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function clone(schema: ResolvedSchema): ResolvedSchema {
|
|
160
|
+
const tables = new Map<string, ResolvedTable>()
|
|
161
|
+
for (const [name, table] of schema.tables)
|
|
162
|
+
tables.set(name, {
|
|
163
|
+
...table,
|
|
164
|
+
columns: new Map(table.columns),
|
|
165
|
+
indexes: new Map(table.indexes),
|
|
166
|
+
})
|
|
167
|
+
return { ...schema, tables }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function specProblem(spec: ColumnSpec, where: string): string | undefined {
|
|
171
|
+
if (spec.type === 'varchar') {
|
|
172
|
+
const length = spec.length ?? 0
|
|
173
|
+
if (!Number.isInteger(length) || length < 1 || length > MAX_VARCHAR)
|
|
174
|
+
return `${where} is a varchar of length ${spec.length}, which must be a whole number from 1`
|
|
175
|
+
}
|
|
176
|
+
if (spec.primaryKey && spec.nullable) return `${where} is a primary key, so it cannot be nullable`
|
|
177
|
+
if (spec.default === undefined) return undefined
|
|
178
|
+
if (spec.type === 'json' || spec.type === 'timestamp')
|
|
179
|
+
return `${where} is ${spec.type}, which cannot have a default`
|
|
180
|
+
const expected =
|
|
181
|
+
spec.type === 'boolean'
|
|
182
|
+
? 'boolean'
|
|
183
|
+
: spec.type === 'integer' || spec.type === 'bigint'
|
|
184
|
+
? 'number'
|
|
185
|
+
: 'string'
|
|
186
|
+
if (typeof spec.default !== expected)
|
|
187
|
+
return `${where} has the default ${JSON.stringify(spec.default)}, which is not a ${expected}`
|
|
188
|
+
if (typeof spec.default === 'number' && !Number.isSafeInteger(spec.default))
|
|
189
|
+
return `${where} has the default ${spec.default}, which is not a whole number`
|
|
190
|
+
if (typeof spec.default === 'string' && !SAFE_DEFAULT.test(spec.default))
|
|
191
|
+
return `${where} has a default with characters a default may not hold: letters, digits, spaces and _ . : @ / - only`
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const WIDER: Record<string, readonly string[]> = {
|
|
196
|
+
varchar: ['varchar', 'text'],
|
|
197
|
+
integer: ['bigint'],
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Applies one operation, or says why it cannot be applied. */
|
|
201
|
+
function apply(
|
|
202
|
+
schema: ResolvedSchema,
|
|
203
|
+
op: Operation,
|
|
204
|
+
step: { name: string; release: string },
|
|
205
|
+
): string | undefined {
|
|
206
|
+
const at = { step: step.name, release: step.release }
|
|
207
|
+
const table = schema.tables.get(op.table)
|
|
208
|
+
const label = `step "${step.name}"`
|
|
209
|
+
switch (op.op) {
|
|
210
|
+
case 'createTable': {
|
|
211
|
+
const nameProblem = logicalNameProblem('table', op.table)
|
|
212
|
+
if (nameProblem) return `${label}: ${nameProblem}`
|
|
213
|
+
if (RESERVED_TABLES.has(op.table)) return `${label}: the table name "${op.table}" is reserved`
|
|
214
|
+
if (table) return `${label} creates "${op.table}", which already exists`
|
|
215
|
+
const physical = physicalTable(schema.id, op.table)
|
|
216
|
+
const tooLong = lengthProblem(physical)
|
|
217
|
+
if (tooLong) return `${label}: ${tooLong}`
|
|
218
|
+
const columns = new Map<string, ResolvedColumn>()
|
|
219
|
+
const entries = Object.entries(op.columns)
|
|
220
|
+
if (entries.length === 0) return `${label} creates "${op.table}" with no columns`
|
|
221
|
+
for (const [name, spec] of entries) {
|
|
222
|
+
const problem =
|
|
223
|
+
logicalNameProblem('column', name) ??
|
|
224
|
+
(RESERVED_COLUMNS.has(name) ? `the column name "${name}" is reserved` : undefined) ??
|
|
225
|
+
specProblem(spec, `"${op.table}.${name}"`)
|
|
226
|
+
if (problem) return `${label}: ${problem}`
|
|
227
|
+
columns.set(name, { name, spec, addedIn: at })
|
|
228
|
+
}
|
|
229
|
+
const keys = [...columns.values()].filter((column) => column.spec.primaryKey)
|
|
230
|
+
if (keys.length !== 1)
|
|
231
|
+
return `${label} creates "${op.table}" with ${keys.length} primary keys, not one`
|
|
232
|
+
const created: ResolvedTable = {
|
|
233
|
+
name: op.table,
|
|
234
|
+
physical,
|
|
235
|
+
columns,
|
|
236
|
+
indexes: new Map(),
|
|
237
|
+
addedIn: at,
|
|
238
|
+
}
|
|
239
|
+
for (const column of columns.values())
|
|
240
|
+
if (column.spec.indexed && !column.spec.primaryKey)
|
|
241
|
+
created.indexes.set(column.name, {
|
|
242
|
+
name: indexName(schema.id, op.table, [column.name]),
|
|
243
|
+
columns: [column.name],
|
|
244
|
+
unique: false,
|
|
245
|
+
addedIn: at,
|
|
246
|
+
})
|
|
247
|
+
schema.tables.set(op.table, created)
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
case 'addColumn': {
|
|
251
|
+
if (!table) return `${label} adds a column to "${op.table}", which does not exist`
|
|
252
|
+
const problem =
|
|
253
|
+
logicalNameProblem('column', op.column) ??
|
|
254
|
+
(RESERVED_COLUMNS.has(op.column)
|
|
255
|
+
? `the column name "${op.column}" is reserved`
|
|
256
|
+
: undefined) ??
|
|
257
|
+
specProblem(op.spec, `"${op.table}.${op.column}"`)
|
|
258
|
+
if (problem) return `${label}: ${problem}`
|
|
259
|
+
if (table.columns.has(op.column))
|
|
260
|
+
return `${label} adds "${op.table}.${op.column}", which exists`
|
|
261
|
+
if (op.spec.primaryKey)
|
|
262
|
+
return `${label} adds a primary key to "${op.table}", which a table has already`
|
|
263
|
+
if (!op.spec.nullable && op.spec.default === undefined)
|
|
264
|
+
return `${label} adds "${op.table}.${op.column}" neither nullable nor with a default, which the rows already there cannot satisfy`
|
|
265
|
+
table.columns.set(op.column, { name: op.column, spec: op.spec, addedIn: at })
|
|
266
|
+
if (op.spec.indexed)
|
|
267
|
+
table.indexes.set(op.column, {
|
|
268
|
+
name: indexName(schema.id, op.table, [op.column]),
|
|
269
|
+
columns: [op.column],
|
|
270
|
+
unique: false,
|
|
271
|
+
addedIn: at,
|
|
272
|
+
})
|
|
273
|
+
return undefined
|
|
274
|
+
}
|
|
275
|
+
case 'addIndex': {
|
|
276
|
+
if (!table) return `${label} indexes "${op.table}", which does not exist`
|
|
277
|
+
if (op.columns.length === 0) return `${label} adds an index on no columns`
|
|
278
|
+
for (const column of op.columns)
|
|
279
|
+
if (!table.columns.has(column))
|
|
280
|
+
return `${label} indexes "${op.table}.${column}", which does not exist`
|
|
281
|
+
const key = op.columns.join(',')
|
|
282
|
+
if (table.indexes.has(key)) return `${label} indexes "${op.table}" (${key}) twice`
|
|
283
|
+
table.indexes.set(key, {
|
|
284
|
+
name: indexName(schema.id, op.table, op.columns),
|
|
285
|
+
columns: [...op.columns],
|
|
286
|
+
unique: op.unique,
|
|
287
|
+
addedIn: at,
|
|
288
|
+
})
|
|
289
|
+
return undefined
|
|
290
|
+
}
|
|
291
|
+
case 'widenColumn': {
|
|
292
|
+
const column = table?.columns.get(op.column)
|
|
293
|
+
if (!table || !column)
|
|
294
|
+
return `${label} widens "${op.table}.${op.column}", which does not exist`
|
|
295
|
+
const from = column.spec
|
|
296
|
+
const to = op.spec
|
|
297
|
+
const wider =
|
|
298
|
+
(from.type === to.type &&
|
|
299
|
+
from.type === 'varchar' &&
|
|
300
|
+
(to.length ?? 0) > (from.length ?? 0)) ||
|
|
301
|
+
(from.type !== to.type && (WIDER[from.type] ?? []).includes(to.type))
|
|
302
|
+
if (!wider)
|
|
303
|
+
return `${label} changes "${op.table}.${op.column}" from ${from.type}${from.length ? `(${from.length})` : ''} to ${to.type}${to.length ? `(${to.length})` : ''}, which is not wider`
|
|
304
|
+
if (
|
|
305
|
+
to.nullable !== from.nullable ||
|
|
306
|
+
to.default !== from.default ||
|
|
307
|
+
to.primaryKey !== from.primaryKey
|
|
308
|
+
)
|
|
309
|
+
return `${label} widens "${op.table}.${op.column}" and changes more than its type`
|
|
310
|
+
const problem = specProblem(to, `"${op.table}.${op.column}"`)
|
|
311
|
+
if (problem) return `${label}: ${problem}`
|
|
312
|
+
table.columns.set(op.column, { ...column, spec: { ...to, indexed: from.indexed } })
|
|
313
|
+
return undefined
|
|
314
|
+
}
|
|
315
|
+
case 'dropTable':
|
|
316
|
+
if (!table) return `${label} drops "${op.table}", which does not exist`
|
|
317
|
+
schema.tables.delete(op.table)
|
|
318
|
+
return undefined
|
|
319
|
+
case 'dropColumn': {
|
|
320
|
+
const column = table?.columns.get(op.column)
|
|
321
|
+
if (!table || !column)
|
|
322
|
+
return `${label} drops "${op.table}.${op.column}", which does not exist`
|
|
323
|
+
if (column.spec.primaryKey)
|
|
324
|
+
return `${label} drops "${op.table}.${op.column}", the primary key`
|
|
325
|
+
for (const index of table.indexes.values())
|
|
326
|
+
if (index.columns.includes(op.column))
|
|
327
|
+
return `${label} drops "${op.table}.${op.column}" while an index covers it; drop the index first`
|
|
328
|
+
table.columns.delete(op.column)
|
|
329
|
+
return undefined
|
|
330
|
+
}
|
|
331
|
+
case 'dropIndex': {
|
|
332
|
+
const key = op.columns.join(',')
|
|
333
|
+
if (!table?.indexes.has(key))
|
|
334
|
+
return `${label} drops an index on "${op.table}" (${key}), which does not exist`
|
|
335
|
+
table.indexes.delete(key)
|
|
336
|
+
return undefined
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** What a drop removes, for the contract rule: when it was added. */
|
|
342
|
+
function removedAddedIn(
|
|
343
|
+
schema: ResolvedSchema,
|
|
344
|
+
op: Operation,
|
|
345
|
+
): { step: string; release: string } | undefined {
|
|
346
|
+
const table = schema.tables.get(op.table)
|
|
347
|
+
if (op.op === 'dropTable') return table?.addedIn
|
|
348
|
+
if (op.op === 'dropColumn') return table?.columns.get(op.column)?.addedIn
|
|
349
|
+
if (op.op === 'dropIndex') return table?.indexes.get(op.columns.join(','))?.addedIn
|
|
350
|
+
return undefined
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const DROPS = new Set(['dropTable', 'dropColumn', 'dropIndex'])
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Compiles a declaration, collecting every problem rather than stopping at the
|
|
357
|
+
* first. `version` is the bundle's own: no step may claim a release after it.
|
|
358
|
+
*/
|
|
359
|
+
export function compileBundleTables(tables: BundleTables, version?: string): CompiledTables {
|
|
360
|
+
const id = tables.id
|
|
361
|
+
const problems: string[] = []
|
|
362
|
+
const idProblem = bundleIdProblem(id)
|
|
363
|
+
if (idProblem) problems.push(idProblem)
|
|
364
|
+
let schema: ResolvedSchema = { id, prefix: bundlePrefix(id), tables: new Map() }
|
|
365
|
+
const steps: CompiledStep[] = []
|
|
366
|
+
const names = new Set<string>()
|
|
367
|
+
let from = bundleInitialState(id)
|
|
368
|
+
let previous: string | undefined
|
|
369
|
+
|
|
370
|
+
for (const [index, step] of tables.steps.entries()) {
|
|
371
|
+
const label = `step "${step.name}"`
|
|
372
|
+
if (typeof step.name !== 'string' || !STEP_NAME.test(step.name))
|
|
373
|
+
problems.push(
|
|
374
|
+
`${label}: a step name is lowercase letters, digits, dots, hyphens and underscores`,
|
|
375
|
+
)
|
|
376
|
+
else if (names.has(step.name)) problems.push(`two steps are named "${step.name}"`)
|
|
377
|
+
names.add(step.name)
|
|
378
|
+
if (typeof step.release !== 'string' || !SEMVER.test(step.release)) {
|
|
379
|
+
problems.push(`${label} declares the release "${step.release}", which is not a version`)
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
if (previous && compareRelease(step.release, previous) < 0)
|
|
383
|
+
problems.push(`${label} ships in ${step.release}, before the step ahead of it (${previous})`)
|
|
384
|
+
if (version && compareRelease(step.release, version) > 0)
|
|
385
|
+
problems.push(
|
|
386
|
+
`${label} ships in ${step.release}, which is later than this release of the bundle (${version})`,
|
|
387
|
+
)
|
|
388
|
+
previous = step.release
|
|
389
|
+
|
|
390
|
+
let operations: Operation[]
|
|
391
|
+
try {
|
|
392
|
+
const returned = step.up(schemaBuilder)
|
|
393
|
+
operations = (Array.isArray(returned) ? returned : [returned]) as Operation[]
|
|
394
|
+
} catch (error) {
|
|
395
|
+
problems.push(
|
|
396
|
+
`${label} failed to describe itself: ${error instanceof Error ? error.message : String(error)}`,
|
|
397
|
+
)
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
if (operations.length === 0) {
|
|
401
|
+
problems.push(`${label} does nothing`)
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const drops = operations.filter((op) => DROPS.has(op.op))
|
|
406
|
+
const kind: StepKind = drops.length > 0 ? 'contract' : 'expand'
|
|
407
|
+
if (kind === 'contract' && drops.length !== operations.length)
|
|
408
|
+
problems.push(`${label} both adds and removes; a step is an expand or a contract, never both`)
|
|
409
|
+
|
|
410
|
+
const before = clone(schema)
|
|
411
|
+
const working = clone(schema)
|
|
412
|
+
let contracts: string | undefined
|
|
413
|
+
for (const op of operations) {
|
|
414
|
+
if (DROPS.has(op.op)) {
|
|
415
|
+
const added = removedAddedIn(working, op)
|
|
416
|
+
if (added) {
|
|
417
|
+
contracts ??= added.step
|
|
418
|
+
if (compareRelease(added.release, step.release) >= 0)
|
|
419
|
+
problems.push(
|
|
420
|
+
`${label} (${step.release}) removes what "${added.step}" added in ${added.release}; a contract may only remove what an earlier release added`,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const problem = apply(working, op, step)
|
|
425
|
+
if (problem) problems.push(problem)
|
|
426
|
+
}
|
|
427
|
+
if (kind === 'contract' && !isMajorRelease(step.release))
|
|
428
|
+
problems.push(
|
|
429
|
+
`${label} removes something in ${step.release}, but a contract may only ship in a major release of the bundle (x.0.0)`,
|
|
430
|
+
)
|
|
431
|
+
schema = working
|
|
432
|
+
|
|
433
|
+
const to = stateAt(id, index + 1, step.name)
|
|
434
|
+
steps.push({
|
|
435
|
+
name: step.name,
|
|
436
|
+
release: step.release,
|
|
437
|
+
position: index + 1,
|
|
438
|
+
from,
|
|
439
|
+
to,
|
|
440
|
+
kind,
|
|
441
|
+
operations,
|
|
442
|
+
// Bare hex: the ledger's checksum column holds 64 characters.
|
|
443
|
+
checksum: new Bun.CryptoHasher('sha256').update(canonical(operations)).digest('hex'),
|
|
444
|
+
contracts,
|
|
445
|
+
before,
|
|
446
|
+
after: clone(schema),
|
|
447
|
+
backfill: step.backfill,
|
|
448
|
+
})
|
|
449
|
+
from = to
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
id,
|
|
454
|
+
stateKey: bundleStateKey(id),
|
|
455
|
+
initialState: bundleInitialState(id),
|
|
456
|
+
steps,
|
|
457
|
+
schema,
|
|
458
|
+
problems,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Throws when a declaration breaks a rule, naming every problem. For a bundle's
|
|
464
|
+
* own test suite, so it is held to the same rules before it ships as it is when
|
|
465
|
+
* a site wires it.
|
|
466
|
+
*/
|
|
467
|
+
export function assertBundleTables(tables: BundleTables, version: string): void {
|
|
468
|
+
const { problems } = compileBundleTables(tables, version)
|
|
469
|
+
if (problems.length > 0)
|
|
470
|
+
throw new Error(
|
|
471
|
+
`The bundle "${tables.id}"'s tables break the rules:\n- ${problems.join('\n- ')}`,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `breaking` exactly when this release of the bundle ships a contract: what its package.json declares. */
|
|
476
|
+
export function bundleTablesCompatibility(
|
|
477
|
+
tables: BundleTables,
|
|
478
|
+
version: string,
|
|
479
|
+
): 'compatible' | 'breaking' {
|
|
480
|
+
return compileBundleTables(tables).steps.some(
|
|
481
|
+
(step) => step.kind === 'contract' && compareRelease(step.release, version) === 0,
|
|
482
|
+
)
|
|
483
|
+
? 'breaking'
|
|
484
|
+
: 'compatible'
|
|
485
|
+
}
|
package/src/cluster.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Multi-node plumbing: node identity in `server`, and the polled
|
|
3
3
|
* `cache_instruction` table that tells other nodes what changed.
|
|
4
|
-
* docs/
|
|
4
|
+
* docs/schema-as-code.md, "Cache coherence".
|
|
5
5
|
*/
|
|
6
6
|
import type { Db } from './database.ts'
|
|
7
7
|
import { DbDate } from './dialect.ts'
|