@crombolo/data 0.9.1 → 0.10.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/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/dialect.ts +8 -0
- package/src/index.ts +3 -0
- package/src/migrations.ts +147 -31
- package/src/pending.ts +1 -1
- package/src/repositories/bundles.ts +36 -4
- package/src/repositories/documents.ts +1 -1
- package/src/repositories/form-entries.ts +1 -1
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/transfer-runs.ts +20 -2
- 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 +4 -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
|
@@ -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'
|
package/src/dialect.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface ColumnTypes {
|
|
|
15
15
|
textCi: string
|
|
16
16
|
text: string
|
|
17
17
|
varchar: (n: number) => string
|
|
18
|
+
/** A varchar that compares case-sensitively on both dialects, as a bundle's tables do. */
|
|
19
|
+
varcharExact: (n: number) => string
|
|
20
|
+
/** JSON, held as text and parsed by whoever reads it. */
|
|
21
|
+
json: string
|
|
18
22
|
timestamp: string
|
|
19
23
|
numeric: string
|
|
20
24
|
blob: string
|
|
@@ -53,6 +57,8 @@ export const sqliteDialect: Dialect = {
|
|
|
53
57
|
textCi: 'TEXT COLLATE NOCASE',
|
|
54
58
|
text: 'TEXT',
|
|
55
59
|
varchar: (n) => `VARCHAR(${n}) COLLATE NOCASE`,
|
|
60
|
+
varcharExact: (n) => `VARCHAR(${n})`,
|
|
61
|
+
json: 'TEXT',
|
|
56
62
|
timestamp: 'TEXT',
|
|
57
63
|
// REAL would be lossy for decimal property values.
|
|
58
64
|
numeric: 'TEXT',
|
|
@@ -80,6 +86,8 @@ export const postgresDialect: Dialect = {
|
|
|
80
86
|
textCi: 'CITEXT',
|
|
81
87
|
text: 'TEXT',
|
|
82
88
|
varchar: (n) => `VARCHAR(${n})`,
|
|
89
|
+
varcharExact: (n) => `VARCHAR(${n})`,
|
|
90
|
+
json: 'TEXT',
|
|
83
91
|
timestamp: 'TIMESTAMPTZ',
|
|
84
92
|
numeric: 'NUMERIC',
|
|
85
93
|
blob: 'BYTEA',
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from './bundle-tables/index.ts'
|
|
1
2
|
export * from './change-report.ts'
|
|
2
3
|
export * from './cluster.ts'
|
|
3
4
|
export * from './database.ts'
|
|
@@ -33,6 +34,7 @@ export * from './schema/assistant.ts'
|
|
|
33
34
|
export * from './schema/built-in-content-types.ts'
|
|
34
35
|
export * from './schema/built-in-media-types.ts'
|
|
35
36
|
export * from './schema/built-in-member-types.ts'
|
|
37
|
+
export * from './schema/bundle-package.ts'
|
|
36
38
|
export * from './schema/bundles-rename.ts'
|
|
37
39
|
export * from './schema/change-report.ts'
|
|
38
40
|
export * from './schema/client-id-prefix.ts'
|
|
@@ -52,6 +54,7 @@ export * from './schema/recycle-bin.ts'
|
|
|
52
54
|
export * from './schema/rich-text-ui.ts'
|
|
53
55
|
export * from './schema/seed.ts'
|
|
54
56
|
export * from './schema/since-version.ts'
|
|
57
|
+
export * from './schema/transfer-run-package.ts'
|
|
55
58
|
export * from './schema/transfer-runs.ts'
|
|
56
59
|
export * from './schema/upgrade-report.ts'
|
|
57
60
|
export * from './schema/users-localisation.ts'
|