@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/src/migrations.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * version number and not a list of applied migrations: the plan is a chain of
4
4
  * transitions walked forward to its final state. That design handles concurrent
5
5
  * development branches far better than sequence numbers, so we keep it — and
6
- * harden it (docs/10-packaging-and-upgrades.md): a ledger of every step that
6
+ * harden it (docs/packaging-and-upgrades.md): a ledger of every step that
7
7
  * ran, expand/contract declared and linted, a recorded plan, and an explicit
8
8
  * refusal when the database is newer than the code.
9
9
  */
@@ -29,7 +29,24 @@ export interface Migration {
29
29
  release?: string
30
30
  /** For a contract: the name of the expand migration it removes. */
31
31
  contracts?: string
32
+ /** Recorded in the ledger, and compared when the plan verifies what ran. Defaults to `name`. */
33
+ checksum?: string
32
34
  up(db: Db): Promise<void>
35
+ /**
36
+ * Writes data once `up` has run, in the same transaction. `planUpgrade` cannot
37
+ * predict what it writes, so a step that has one is reported as unplannable.
38
+ */
39
+ backfill?(db: Db): Promise<void>
40
+ }
41
+
42
+ /** Where a chain keeps its state, and what it calls itself in the ledger. */
43
+ export interface ChainOptions {
44
+ /** The `key_value` key holding the chain's state. */
45
+ stateKey?: string
46
+ /** The state before the first step. */
47
+ initialState?: string
48
+ /** A bundle's id, for a bundle's chain; absent for core's. */
49
+ bundle?: string
33
50
  }
34
51
 
35
52
  function compareRelease(a: string, b: string): number {
@@ -71,10 +88,16 @@ export function isMajorRelease(release: string): boolean {
71
88
 
72
89
  export class MigrationPlan {
73
90
  readonly migrations: readonly Migration[]
91
+ readonly stateKey: string
92
+ readonly initialState: string
93
+ readonly bundle: string | undefined
74
94
 
75
- constructor(migrations: readonly Migration[]) {
95
+ constructor(migrations: readonly Migration[], options: ChainOptions = {}) {
76
96
  this.migrations = migrations
77
- let expected = INITIAL_STATE
97
+ this.stateKey = options.stateKey ?? MIGRATION_STATE_KEY
98
+ this.initialState = options.initialState ?? INITIAL_STATE
99
+ this.bundle = options.bundle
100
+ let expected = this.initialState
78
101
  const seen = new Map<string, Migration>()
79
102
  for (const migration of migrations) {
80
103
  if (migration.from !== expected) {
@@ -111,7 +134,14 @@ export class MigrationPlan {
111
134
  }
112
135
 
113
136
  get finalState(): string {
114
- return this.migrations.at(-1)?.to ?? INITIAL_STATE
137
+ return this.migrations.at(-1)?.to ?? this.initialState
138
+ }
139
+
140
+ /** The steps that have run when the chain is at a state; undefined when the state is not in it. */
141
+ appliedAt(state: string): Migration[] | undefined {
142
+ if (state === this.initialState) return []
143
+ const index = this.migrations.findIndex((m) => m.to === state)
144
+ return index < 0 ? undefined : this.migrations.slice(0, index + 1)
115
145
  }
116
146
 
117
147
  /** The steps still to run from a state; empty when current. Undefined when the state is not in the plan. */
@@ -123,7 +153,11 @@ export class MigrationPlan {
123
153
  }
124
154
  }
125
155
 
156
+ /** Connections whose ledger tables are known to exist with every column. */
157
+ const ensured = new WeakSet<Db>()
158
+
126
159
  async function ensureTables(db: Db): Promise<void> {
160
+ if (ensured.has(db)) return
127
161
  const t = db.dialect.types
128
162
  await db.exec(
129
163
  `CREATE TABLE IF NOT EXISTS key_value (
@@ -143,36 +177,62 @@ async function ensureTables(db: Db): Promise<void> {
143
177
  duration_ms ${t.integer} NOT NULL,
144
178
  checksum ${t.varchar(64)},
145
179
  applied_by ${t.varchar(255)},
146
- note ${t.text}
180
+ note ${t.text},
181
+ bundle ${t.varchar(64)}
147
182
  )`,
148
183
  )
184
+ // A ledger from before bundles could own tables has no `bundle` column. It is
185
+ // added here, where the table is, rather than by a core migration: the ledger
186
+ // has to be able to record a bundle's step whatever state core is at.
187
+ if (!(await hasColumn(db, 'migration_history', 'bundle')))
188
+ await db.exec(`ALTER TABLE migration_history ADD COLUMN bundle ${t.varchar(64)}`)
189
+ ensured.add(db)
190
+ }
191
+
192
+ async function hasColumn(db: Db, table: string, column: string): Promise<boolean> {
193
+ const rows =
194
+ db.dialect.name === 'sqlite'
195
+ ? await db.query<{ name: string }>(`SELECT name FROM pragma_table_info('${table}')`)
196
+ : await db.query<{ name: string }>(
197
+ 'SELECT column_name AS name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?',
198
+ [table],
199
+ )
200
+ return rows.some((row) => row.name === column)
149
201
  }
150
202
 
151
- export async function readState(db: Db): Promise<string> {
203
+ /** A chain's state: core's unless a plan says otherwise. */
204
+ export async function readState(
205
+ db: Db,
206
+ chain: Pick<MigrationPlan, 'stateKey' | 'initialState'> = {
207
+ stateKey: MIGRATION_STATE_KEY,
208
+ initialState: INITIAL_STATE,
209
+ },
210
+ ): Promise<string> {
152
211
  await ensureTables(db)
153
212
  const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
154
- MIGRATION_STATE_KEY,
213
+ chain.stateKey,
155
214
  ])
156
- return rows[0]?.value ?? INITIAL_STATE
215
+ return rows[0]?.value ?? chain.initialState
157
216
  }
158
217
 
159
- async function writeState(db: Db, state: string): Promise<void> {
218
+ async function writeState(db: Db, state: string, key = MIGRATION_STATE_KEY): Promise<void> {
160
219
  const now = db.dialect.now()
161
- await db.query(`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ?`, [
162
- state,
163
- MIGRATION_STATE_KEY,
164
- ])
165
- const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
166
- MIGRATION_STATE_KEY,
167
- ])
220
+ await db.query(`UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ?`, [state, key])
221
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [key])
168
222
  if (rows.length === 0) {
169
223
  await db.exec(
170
224
  `INSERT INTO key_value (key, value, update_date) VALUES (${db.dialect.placeholder(1)}, ${db.dialect.placeholder(2)}, ${now})`,
171
- [MIGRATION_STATE_KEY, state],
225
+ [key, state],
172
226
  )
173
227
  }
174
228
  }
175
229
 
230
+ /** Forgets a setting entirely, as a purged bundle's state is forgotten. */
231
+ export async function deleteKeyValue(db: Db, key: string): Promise<void> {
232
+ await ensureTables(db)
233
+ await db.exec('DELETE FROM key_value WHERE key = ?', [key])
234
+ }
235
+
176
236
  export interface LedgerEntry {
177
237
  name: string
178
238
  /** 'expand' | 'contract' for framework steps; 'value' for a value migration; 'upgrade' for a cut-over. */
@@ -183,6 +243,8 @@ export interface LedgerEntry {
183
243
  checksum?: string | null
184
244
  appliedBy?: string | null
185
245
  note?: string | null
246
+ /** The bundle whose chain this step belongs to; null for core. */
247
+ bundle?: string | null
186
248
  }
187
249
 
188
250
  /**
@@ -226,8 +288,8 @@ export async function ensureKeyValue(db: Db, key: string, create: () => string):
226
288
  export async function recordInLedger(db: Db, entry: LedgerEntry): Promise<void> {
227
289
  await ensureTables(db)
228
290
  await db.exec(
229
- `INSERT INTO migration_history (name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note)
230
- VALUES (?, ?, ?, ?, ${db.dialect.now()}, ?, ?, ?, ?)`,
291
+ `INSERT INTO migration_history (name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note, bundle)
292
+ VALUES (?, ?, ?, ?, ${db.dialect.now()}, ?, ?, ?, ?, ?)`,
231
293
  [
232
294
  entry.name,
233
295
  entry.kind,
@@ -237,6 +299,7 @@ export async function recordInLedger(db: Db, entry: LedgerEntry): Promise<void>
237
299
  entry.checksum ?? null,
238
300
  entry.appliedBy ?? null,
239
301
  entry.note ?? null,
302
+ entry.bundle ?? null,
240
303
  ],
241
304
  )
242
305
  }
@@ -248,10 +311,14 @@ export async function latestLedgerId(db: Db): Promise<number> {
248
311
  return Number(rows[0]?.n ?? 0)
249
312
  }
250
313
 
251
- /** How many contract steps — framework contracts and site purges — were ledgered after a row id. */
314
+ /**
315
+ * How many contract steps — framework contracts and site purges — were ledgered
316
+ * after a row id. A bundle's contract is not counted: it removes only the
317
+ * bundle's own tables, which no read of core's depends on.
318
+ */
252
319
  export async function contractsLedgeredAfter(db: Db, afterId: number): Promise<number> {
253
320
  const rows = await db.query<{ n: number }>(
254
- "SELECT COUNT(*) AS n FROM migration_history WHERE id > ? AND kind = 'contract'",
321
+ "SELECT COUNT(*) AS n FROM migration_history WHERE id > ? AND kind = 'contract' AND bundle IS NULL",
255
322
  [afterId],
256
323
  )
257
324
  return Number(rows[0]?.n ?? 0)
@@ -265,7 +332,7 @@ export interface LedgerRow extends LedgerEntry {
265
332
  export async function readLedger(db: Db): Promise<LedgerRow[]> {
266
333
  await ensureTables(db)
267
334
  const rows = await db.query(
268
- 'SELECT id, name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note FROM migration_history ORDER BY id',
335
+ 'SELECT id, name, kind, from_state, to_state, applied_at, duration_ms, checksum, applied_by, note, bundle FROM migration_history ORDER BY id',
269
336
  )
270
337
  return rows.map((row) => ({
271
338
  id: Number(row.id),
@@ -278,6 +345,7 @@ export async function readLedger(db: Db): Promise<LedgerRow[]> {
278
345
  checksum: (row.checksum as string | null) ?? null,
279
346
  appliedBy: (row.applied_by as string | null) ?? null,
280
347
  note: (row.note as string | null) ?? null,
348
+ bundle: (row.bundle as string | null) ?? null,
281
349
  }))
282
350
  }
283
351
 
@@ -286,22 +354,66 @@ export class UpgradeStateError extends Error {
286
354
  constructor(
287
355
  readonly databaseState: string,
288
356
  readonly codeState: string,
357
+ /** The bundle whose chain it is, when it is not core's. */
358
+ readonly bundle?: string,
359
+ ) {
360
+ super(
361
+ bundle
362
+ ? `The database holds the bundle "${bundle}"'s tables at state ${databaseState}, which this version of the bundle (its steps end at ${codeState}) does not know. ` +
363
+ 'A newer release of the bundle wrote them; install that release, or restore the backup taken before the upgrade.'
364
+ : `The database is at migration state ${databaseState}, which this version of crombolo (plan ends at ${codeState}) does not know. ` +
365
+ 'It was upgraded by a newer version; run that version, or restore the backup taken before the upgrade.',
366
+ )
367
+ }
368
+ }
369
+
370
+ /** A bundle step changed after it ran here: what is in the database is not what the code says made it. */
371
+ export class BundleStepEditedError extends Error {
372
+ constructor(
373
+ readonly bundle: string,
374
+ readonly step: string,
289
375
  ) {
290
376
  super(
291
- `The database is at migration state ${databaseState}, which this version of crombolo (plan ends at ${codeState}) does not know. ` +
292
- 'It was upgraded by a newer version; run that version, or restore the backup taken before the upgrade.',
377
+ `The bundle "${bundle}"'s step "${step}" is not the step that ran on this database: it was edited after it shipped. ` +
378
+ 'A shipped step is never changed; put the change in a new step.',
293
379
  )
294
380
  }
295
381
  }
296
382
 
297
383
  /** Steps pending on this database, in order. Throws when the database is ahead of the code. */
298
384
  export async function pendingMigrations(db: Db, plan: MigrationPlan): Promise<Migration[]> {
299
- const state = await readState(db)
385
+ const state = await readState(db, plan)
300
386
  const pending = plan.pendingFrom(state)
301
- if (!pending) throw new UpgradeStateError(state, plan.finalState)
387
+ if (!pending) throw new UpgradeStateError(state, plan.finalState, plan.bundle)
302
388
  return pending
303
389
  }
304
390
 
391
+ /**
392
+ * Refuses a bundle chain whose applied steps no longer match the code. Only the
393
+ * rows since the bundle was last purged count: a purge starts its chain again.
394
+ */
395
+ async function verifyApplied(db: Db, plan: MigrationPlan, state: string): Promise<void> {
396
+ if (!plan.bundle) return
397
+ const applied = plan.appliedAt(state) ?? []
398
+ if (applied.length === 0) return
399
+ const rows = await db.query<{ name: string; kind: string; checksum: string | null }>(
400
+ 'SELECT name, kind, checksum FROM migration_history WHERE bundle = ? ORDER BY id',
401
+ [plan.bundle],
402
+ )
403
+ const purged = rows.map((row) => row.kind).lastIndexOf('purge')
404
+ const ran = new Map(
405
+ rows
406
+ .slice(purged + 1)
407
+ .filter((row) => row.kind === 'expand' || row.kind === 'contract')
408
+ .map((row) => [row.name, row.checksum]),
409
+ )
410
+ for (const step of applied) {
411
+ const recorded = ran.get(step.name)
412
+ if (recorded !== undefined && recorded !== (step.checksum ?? step.name))
413
+ throw new BundleStepEditedError(plan.bundle, step.name)
414
+ }
415
+ }
416
+
305
417
  export interface MigrateResult {
306
418
  from: string
307
419
  to: string
@@ -320,27 +432,30 @@ export async function migrate(
320
432
  options: MigrateOptions = {},
321
433
  ): Promise<MigrateResult> {
322
434
  return db.locks.withLock(Locks.KeyValues, async () => {
323
- const from = await readState(db)
435
+ const from = await readState(db, plan)
324
436
  let state = from
325
437
  const applied: string[] = []
326
438
  const pending = plan.pendingFrom(state)
327
- if (!pending) throw new UpgradeStateError(state, plan.finalState)
439
+ if (!pending) throw new UpgradeStateError(state, plan.finalState, plan.bundle)
440
+ await verifyApplied(db, plan, state)
328
441
  for (const next of pending) {
329
442
  const started = performance.now()
330
443
  await db.transaction(async (tx) => {
331
444
  await next.up(tx)
445
+ await next.backfill?.(tx)
332
446
  })
333
447
  state = next.to
334
- await writeState(db, state)
448
+ await writeState(db, state, plan.stateKey)
335
449
  await recordInLedger(db, {
336
450
  name: next.name,
337
451
  kind: next.kind ?? 'expand',
338
452
  fromState: next.from,
339
453
  toState: next.to,
340
454
  durationMs: Math.round(performance.now() - started),
341
- checksum: next.name,
455
+ checksum: next.checksum ?? next.name,
342
456
  appliedBy: options.by ?? null,
343
457
  note: next.release ? `release ${next.release}` : null,
458
+ bundle: plan.bundle ?? null,
344
459
  })
345
460
  applied.push(next.name)
346
461
  }
@@ -351,7 +466,7 @@ export async function migrate(
351
466
  /** A fresh install stamps the final state so no migrations then run. */
352
467
  export async function stampFinalState(db: Db, plan: MigrationPlan): Promise<void> {
353
468
  await ensureTables(db)
354
- await writeState(db, plan.finalState)
469
+ await writeState(db, plan.finalState, plan.stateKey)
355
470
  }
356
471
 
357
472
  // ---------------------------------------------------------------- the plan
@@ -421,6 +536,7 @@ export async function planUpgrade(db: Db, plan: MigrationPlan): Promise<PlannedS
421
536
  try {
422
537
  await migration.up(recorder)
423
538
  step.statements = recorder.statements
539
+ if (migration.backfill) step.unplannable = 'backfills data, which only running it can show'
424
540
  } catch (error) {
425
541
  if (error instanceof UnplannableError) step.unplannable = error.message
426
542
  else throw error
package/src/pending.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * An element is pending on a node when the deployment that created it is newer
3
3
  * than the node, or its file says `since` a version the node has not reached.
4
- * The one place the rule lives. docs/09-schema-as-code.md, "since".
4
+ * The one place the rule lives. docs/schema-as-code.md, "since".
5
5
  */
6
6
  import { compareStates, compareVersions, type NodeSchemaState } from './schema-state.ts'
7
7
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The bundle definitions: what a bundle would contain, not a built bundle.
3
- * `docs/17-bundles.md`.
3
+ * `docs/bundles.md`.
4
4
  *
5
5
  * The selection lists travel as JSON text in single columns. A malformed list
6
6
  * reads back as empty rather than throwing: a definition whose row was edited
@@ -26,12 +26,21 @@ export interface Bundle {
26
26
  scripts: string[]
27
27
  languages: string[]
28
28
  dictionaryItems: string[]
29
+ /**
30
+ * The npm package the bundle is built as. Not in Umbraco's definition, so the
31
+ * contract never writes these; null until someone sets them.
32
+ */
33
+ packageName: string | null
34
+ packageVersion: string | null
29
35
  createDate: Date
30
36
  updateDate: Date
31
37
  }
32
38
 
33
39
  /** A definition to store; `id` is assigned when it is new, the dates always. */
34
- export type BundleInput = Omit<Bundle, 'id' | 'createDate' | 'updateDate'>
40
+ export type BundleInput = Omit<
41
+ Bundle,
42
+ 'id' | 'createDate' | 'updateDate' | 'packageName' | 'packageVersion'
43
+ >
35
44
 
36
45
  interface Row {
37
46
  id: string
@@ -49,6 +58,8 @@ interface Row {
49
58
  scripts: string
50
59
  languages: string
51
60
  dictionary_items: string
61
+ package_name: string | null
62
+ package_version: string | null
52
63
  create_date: unknown
53
64
  update_date: unknown
54
65
  }
@@ -56,7 +67,8 @@ interface Row {
56
67
  const SELECT = `SELECT id, name, content_node_id, content_load_child_nodes,
57
68
  media_load_child_nodes, media_ids, element_ids, content_types,
58
69
  media_types, data_types, components, stylesheets,
59
- scripts, languages, dictionary_items, create_date, update_date
70
+ scripts, languages, dictionary_items, package_name, package_version,
71
+ create_date, update_date
60
72
  FROM bundle`
61
73
 
62
74
  const list = (value: string | null): string[] => {
@@ -87,6 +99,8 @@ const hydrate = (row: Row): Bundle => ({
87
99
  scripts: list(row.scripts),
88
100
  languages: list(row.languages),
89
101
  dictionaryItems: list(row.dictionary_items),
102
+ packageName: row.package_name ?? null,
103
+ packageVersion: row.package_version ?? null,
90
104
  createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
91
105
  updateDate: DbDate.fromDb(row.update_date) ?? new Date(0),
92
106
  })
@@ -152,7 +166,14 @@ export class BundleRepository {
152
166
  DbDate.toDb(now),
153
167
  ],
154
168
  )
155
- return { ...input, id, createDate: now, updateDate: now }
169
+ return {
170
+ ...input,
171
+ id,
172
+ packageName: null,
173
+ packageVersion: null,
174
+ createDate: now,
175
+ updateDate: now,
176
+ }
156
177
  }
157
178
 
158
179
  async update(id: string, input: BundleInput): Promise<void> {
@@ -185,6 +206,17 @@ export class BundleRepository {
185
206
  )
186
207
  }
187
208
 
209
+ /** The npm package a definition is built as; null clears a value back to its default. */
210
+ async setPackage(
211
+ id: string,
212
+ pkg: { name: string | null; version: string | null },
213
+ ): Promise<void> {
214
+ await this.#db.exec(
215
+ 'UPDATE bundle SET package_name = ?, package_version = ?, update_date = ? WHERE id = ?',
216
+ [pkg.name, pkg.version, DbDate.toDb(new Date()), id],
217
+ )
218
+ }
219
+
188
220
  async delete(id: string): Promise<void> {
189
221
  await this.#db.exec('DELETE FROM bundle WHERE id = ?', [id])
190
222
  }
@@ -5,7 +5,7 @@
5
5
  * tagged with the event that wrote it and the schema state the writer was at.
6
6
  * `content_version` rows are events (save | publish | rollback | migrate).
7
7
  * Publish is an event and a pointer, not a copy; rollback appends the old
8
- * values under a new event. See docs/02-data-model.md.
8
+ * values under a new event. See docs/data-model.md.
9
9
  */
10
10
  import {
11
11
  type DocumentAggregate,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Form entries: what visitors submitted. `docs/18-forms.md`.
2
+ * Form entries: what visitors submitted. `docs/forms.md`.
3
3
  *
4
4
  * Keyed by the form's UUID rather than a foreign key, because the definition is
5
5
  * a file. An entry outlives its definition on purpose.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The form workflow queue. `docs/18-forms.md`.
2
+ * The form workflow queue. `docs/forms.md`.
3
3
  *
4
4
  * Claimed with `UPDATE … WHERE state = 'pending' … RETURNING`, which is atomic
5
5
  * on both dialects: on a multi-node Postgres site every node polls, and exactly
@@ -24,6 +24,9 @@ export interface TransferRun {
24
24
  id: string
25
25
  bundleId: string
26
26
  bundleLabel: string | null
27
+ /** The npm package the content arrived in, and its version. */
28
+ packageName: string | null
29
+ packageVersion: string | null
27
30
  direction: TransferDirection
28
31
  revertsRunId: string | null
29
32
  schemaStateId: number
@@ -57,12 +60,14 @@ export class TransferRunRepository {
57
60
  async start(run: Omit<TransferRun, 'finishedAt' | 'status'>): Promise<void> {
58
61
  await this.#db.exec(
59
62
  `INSERT INTO content_transfer_run
60
- (id, bundle_id, bundle_label, direction, reverts_run_id, schema_state_id, started_at, applied_by, node_count, status)
61
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'applied')`,
63
+ (id, bundle_id, bundle_label, package_name, package_version, direction, reverts_run_id, schema_state_id, started_at, applied_by, node_count, status)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'applied')`,
62
65
  [
63
66
  run.id,
64
67
  run.bundleId,
65
68
  run.bundleLabel,
69
+ run.packageName,
70
+ run.packageVersion,
66
71
  run.direction,
67
72
  run.revertsRunId,
68
73
  run.schemaStateId,
@@ -121,6 +126,17 @@ export class TransferRunRepository {
121
126
  return rows[0] ? mapRun(rows[0]) : undefined
122
127
  }
123
128
 
129
+ /** The latest standing import of a package's content, whichever version brought it. */
130
+ async appliedForPackage(packageName: string): Promise<TransferRun | undefined> {
131
+ const rows = await this.#db.query(
132
+ `SELECT * FROM content_transfer_run
133
+ WHERE package_name = ? AND direction = 'import' AND status = 'applied'
134
+ ORDER BY started_at DESC, id DESC LIMIT 1`,
135
+ [packageName],
136
+ )
137
+ return rows[0] ? mapRun(rows[0]) : undefined
138
+ }
139
+
124
140
  /** Runs, newest first. */
125
141
  async recent(limit = 20): Promise<TransferRun[]> {
126
142
  const rows = await this.#db.query(
@@ -164,6 +180,8 @@ function mapRun(row: Record<string, unknown>): TransferRun {
164
180
  id: String(row.id),
165
181
  bundleId: String(row.bundle_id),
166
182
  bundleLabel: (row.bundle_label as string | null) ?? null,
183
+ packageName: (row.package_name as string | null) ?? null,
184
+ packageVersion: (row.package_version as string | null) ?? null,
167
185
  direction: String(row.direction) as TransferDirection,
168
186
  revertsRunId: (row.reverts_run_id as string | null) ?? null,
169
187
  schemaStateId: Number(row.schema_state_id),
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Migration 028 — a bundle's `templates` and `partial_views` become `components`.
3
3
  *
4
- * There is one root and one kind of file on disk now (`docs/05-rendering.md`),
4
+ * There is one root and one kind of file on disk now (`docs/rendering.md`),
5
5
  * so a bundle definition that still kept two lists was recording a distinction
6
6
  * the rest of the system no longer makes. The two are merged rather than one
7
7
  * dropped: both held component aliases, and which tree the backoffice picked
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Migration 029 — a created bundle records the npm package it is written as.
3
+ *
4
+ * Every bundle is an npm package (`docs/bundles.md`), so the Created view's
5
+ * download needs a package name and version. Umbraco's package definition has
6
+ * neither, so they live here, beside the columns the contract does name. Both
7
+ * are nullable: an unset name or version is derived when the bundle is built.
8
+ */
9
+ import type { Db } from '../database.ts'
10
+ import type { Migration } from '../migrations.ts'
11
+ import { schemaOps } from '../schema-ops.ts'
12
+ import { STATE_BUNDLE_COMPONENTS } from './bundle-components.ts'
13
+
14
+ export const STATE_BUNDLE_PACKAGE = '{6f1d4a8e-0029-4c5e-9a17-3d8b2e6f4c29}'
15
+
16
+ export const bundlePackageMigration: Migration = {
17
+ from: STATE_BUNDLE_COMPONENTS,
18
+ to: STATE_BUNDLE_PACKAGE,
19
+ name: 'AddBundlePackageNameAndVersion',
20
+ kind: 'expand',
21
+ release: '0.10.0',
22
+ async up(db: Db) {
23
+ const ops = schemaOps(db)
24
+ await ops.addColumn('bundle', 'package_name', db.dialect.types.varchar(214))
25
+ await ops.addColumn('bundle', 'package_version', db.dialect.types.varchar(64))
26
+ },
27
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The table was named after Umbraco's `umbracoCreatedPackageSchema`, which it
5
5
  * mirrors column for column. What it holds is a bundle definition
6
- * (`docs/17-bundles.md`), and the backoffice now says so, so the table does too.
6
+ * (`docs/bundles.md`), and the backoffice now says so, so the table does too.
7
7
  *
8
8
  * A rename rather than an edit to migration 022: 022 has already run on every
9
9
  * database that exists, and rewriting it would leave those sites with a table
@@ -2,7 +2,7 @@
2
2
  * Migration 002 — the content schema.
3
3
  *
4
4
  * `node` is the universal polymorphic tree; every other table here is a facet of
5
- * it. See docs/02-data-model.md for the model and why it is kept.
5
+ * it. See docs/data-model.md for the model and why it is kept.
6
6
  */
7
7
  import type { Db } from '../database.ts'
8
8
  import type { Migration } from '../migrations.ts'
@@ -164,7 +164,7 @@ export const contentMigration: Migration = {
164
164
  )`,
165
165
  )
166
166
 
167
- // ---- documents and versions (Phase 4 uses these) ----
167
+ // ---- documents and versions (story 005 uses these) ----
168
168
 
169
169
  await db.exec(
170
170
  `CREATE TABLE content (
@@ -2,7 +2,7 @@
2
2
  * Migration 022 — the created-package definitions the Packages section builds.
3
3
  *
4
4
  * A definition, never an artifact: the zip is built when it is downloaded
5
- * (`docs/17-bundles.md`), so there are no bytes here to go stale when a
5
+ * (`docs/bundles.md`), so there are no bytes here to go stale when a
6
6
  * content type the package carries is edited afterwards.
7
7
  *
8
8
  * The selection lists are JSON text rather than a row each. They are read and
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Migration 025 — the form permission verbs, for databases seeded before they
3
- * existed. `docs/18-forms.md`.
3
+ * existed. `docs/forms.md`.
4
4
  *
5
5
  * The same shape as migration 007: granting what the seed now grants, so an
6
6
  * upgraded site does not have a Forms section nobody can open. Idempotent, and
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Migration 024 — the queue a form's workflows run from. `docs/18-forms.md`.
2
+ * Migration 024 — the queue a form's workflows run from. `docs/forms.md`.
3
3
  *
4
4
  * A submission is stored and *then* its workflows run, so a mail server being
5
5
  * down never loses an entry. That means a row per workflow per submission,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Migration 023 — form entries. `docs/18-forms.md`.
2
+ * Migration 023 — form entries. `docs/forms.md`.
3
3
  *
4
4
  * The definition is a file, so there is no form table and nothing to point at:
5
5
  * an entry carries `form_key`, the UUID from `schema/forms/*.toml`, and keeps
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Migration 001 — back-office identity.
3
3
  *
4
- * Table and column names follow docs/02-data-model.md: Umbraco's relational
4
+ * Table and column names follow docs/data-model.md: Umbraco's relational
5
5
  * model with snake_case names. `user` is reserved in Postgres, hence
6
6
  * `user_account`.
7
7
  */
@@ -14,6 +14,7 @@
14
14
  import { MigrationPlan } from '../migrations.ts'
15
15
  import { assistantMigration } from './assistant.ts'
16
16
  import { bundleComponentsMigration } from './bundle-components.ts'
17
+ import { bundlePackageMigration } from './bundle-package.ts'
17
18
  import { bundlesRenameMigration } from './bundles-rename.ts'
18
19
  import { changeReportMigration } from './change-report.ts'
19
20
  import { clientIdPrefixMigration } from './client-id-prefix.ts'
@@ -36,6 +37,7 @@ import { redirectsMigration } from './redirects.ts'
36
37
  import { richTextUiMigration } from './rich-text-ui.ts'
37
38
  import { serverRoleMigration } from './server-role.ts'
38
39
  import { sinceVersionMigration } from './since-version.ts'
40
+ import { transferRunPackageMigration } from './transfer-run-package.ts'
39
41
  import { transferRunsMigration } from './transfer-runs.ts'
40
42
  import { upgradeReportMigration } from './upgrade-report.ts'
41
43
  import { usersLocalisationMigration } from './users-localisation.ts'
@@ -70,4 +72,6 @@ export const cromboloPlan = new MigrationPlan([
70
72
  formPermissionPrefixMigration,
71
73
  bundlesRenameMigration,
72
74
  bundleComponentsMigration,
75
+ bundlePackageMigration,
76
+ transferRunPackageMigration,
73
77
  ])