@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.
@@ -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'
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'