@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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/audit.ts +291 -0
  3. package/src/bundle-tables/builder.ts +264 -0
  4. package/src/bundle-tables/capability.ts +377 -0
  5. package/src/bundle-tables/chain.ts +105 -0
  6. package/src/bundle-tables/ddl.ts +124 -0
  7. package/src/bundle-tables/guard.ts +116 -0
  8. package/src/bundle-tables/index.ts +7 -0
  9. package/src/bundle-tables/names.ts +58 -0
  10. package/src/bundle-tables/schema.ts +485 -0
  11. package/src/cluster.ts +1 -1
  12. package/src/database.ts +39 -9
  13. package/src/dialect.ts +32 -0
  14. package/src/index.ts +9 -0
  15. package/src/migrations.ts +147 -31
  16. package/src/pending.ts +1 -1
  17. package/src/repositories/bundles.ts +36 -4
  18. package/src/repositories/content-types.ts +41 -4
  19. package/src/repositories/documents.ts +13 -3
  20. package/src/repositories/form-entries.ts +1 -1
  21. package/src/repositories/form-workflows.ts +1 -1
  22. package/src/repositories/members.ts +15 -1
  23. package/src/repositories/nodes.ts +32 -7
  24. package/src/repositories/transfer-runs.ts +20 -2
  25. package/src/schema/audit-log.ts +45 -0
  26. package/src/schema/bundle-components.ts +1 -1
  27. package/src/schema/bundle-package.ts +27 -0
  28. package/src/schema/bundles-rename.ts +1 -1
  29. package/src/schema/content.ts +2 -2
  30. package/src/schema/created-packages.ts +1 -1
  31. package/src/schema/form-permissions.ts +1 -1
  32. package/src/schema/form-workflows.ts +1 -1
  33. package/src/schema/forms.ts +1 -1
  34. package/src/schema/identity.ts +1 -1
  35. package/src/schema/plan.ts +10 -0
  36. package/src/schema/property-searchable.ts +29 -0
  37. package/src/schema/search-index.ts +92 -0
  38. package/src/schema/transfer-run-package.ts +27 -0
  39. package/src/schema/upgrade-report.ts +1 -1
  40. package/src/schema-state.ts +1 -1
  41. package/src/search-text.ts +133 -0
  42. 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,7 @@
1
+ export * from './builder.ts'
2
+ export * from './capability.ts'
3
+ export * from './chain.ts'
4
+ export * from './ddl.ts'
5
+ export * from './guard.ts'
6
+ export * from './names.ts'
7
+ export * from './schema.ts'
@@ -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/09-schema-as-code.md, "Cache coherence".
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'