@crombolo/schema 0.0.0-stage → 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/sync.ts ADDED
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Files → database. Version-gated, locked, and never destructive: removal is
3
+ * retirement, and a type with content refuses to disappear. The algorithm and
4
+ * the multi-node rules are in docs/schema-as-code.md, "Synchronisation".
5
+ */
6
+ import { type ContentTypeAggregate, ObjectTypes, type PropertyTypeModel } from '@crombolo/core'
7
+ import {
8
+ appendCacheInstruction,
9
+ appendSchemaState,
10
+ BUILT_IN_MEDIA_TYPES,
11
+ ComponentRepository,
12
+ ContentTypeRepository,
13
+ compareStates,
14
+ currentSchemaState,
15
+ DataTypeRepository,
16
+ type Db,
17
+ DbDate,
18
+ FolderRepository,
19
+ LanguageRepository,
20
+ Locks,
21
+ latestPreparedState,
22
+ makeStateCurrent,
23
+ type NodeSchemaState,
24
+ type SchemaStateRow,
25
+ SYSTEM_CONTENT_TYPES,
26
+ SYSTEM_MEDIA_TYPES,
27
+ SYSTEM_MEMBER_TYPE,
28
+ updateSchemaStateHash,
29
+ } from '@crombolo/data'
30
+ import { BUILTIN_DATA_TYPES, storageTypeFor, systemPropertyKeys } from './builtin.ts'
31
+ import { hashSchemaSet, type LoadedSchema } from './load.ts'
32
+ import {
33
+ allProperties,
34
+ type SchemaContentType,
35
+ type SchemaProblem,
36
+ type SchemaProperty,
37
+ type SchemaSet,
38
+ typeFileKey,
39
+ } from './model.ts'
40
+ import { validateSchemaSet } from './validate.ts'
41
+
42
+ export interface SyncNode extends NodeSchemaState {
43
+ nodeId: string
44
+ }
45
+
46
+ export interface SyncOptions {
47
+ mode: 'development' | 'production'
48
+ /** Template aliases that exist as view files. */
49
+ componentAliases?: ReadonlySet<string>
50
+ /** Retire a type even though documents of it exist. */
51
+ forceRetireTypes?: boolean
52
+ /** Compute the report and roll back: `schema check`. */
53
+ dryRun?: boolean
54
+ /**
55
+ * `prepared` creates everything under a state no live node has reached, so it
56
+ * is pending everywhere until `upgrade` makes the state current. Default current.
57
+ */
58
+ status?: 'prepared' | 'current'
59
+ }
60
+
61
+ export type SyncAction = 'applied' | 'would-apply' | 'skipped-older' | 'skipped-same' | 'refused'
62
+
63
+ export interface SyncReportBase {
64
+ /** Existing properties whose data type change was deferred to the cut-over (prepared syncs only). */
65
+ deferred: string[]
66
+ }
67
+
68
+ /** A key the sync minted or recovered for an element whose file has none. */
69
+ export interface AssignedKey {
70
+ file: string
71
+ typeAlias: string
72
+ /** Undefined for the type itself. */
73
+ propertyAlias?: string
74
+ key: string
75
+ /** True when the key belongs to a retired property that was revived by alias. */
76
+ revived: boolean
77
+ }
78
+
79
+ export interface SyncReport {
80
+ action: SyncAction
81
+ reason?: string
82
+ problems: SchemaProblem[]
83
+ state?: { id: number; version: string; revision: string }
84
+ created: { types: number; properties: number; dataTypes: number; languages: number }
85
+ updated: { types: number; dataTypes: number; languages: number }
86
+ retired: string[]
87
+ retiredTypes: string[]
88
+ revived: string[]
89
+ assignedKeys: AssignedKey[]
90
+ deferred: string[]
91
+ }
92
+
93
+ export const emptySyncReport = (
94
+ action: SyncAction,
95
+ extra: Partial<SyncReport> = {},
96
+ ): SyncReport => ({
97
+ action,
98
+ problems: [],
99
+ created: { types: 0, properties: 0, dataTypes: 0, languages: 0 },
100
+ updated: { types: 0, dataTypes: 0, languages: 0 },
101
+ retired: [],
102
+ retiredTypes: [],
103
+ revived: [],
104
+ assignedKeys: [],
105
+ deferred: [],
106
+ ...extra,
107
+ })
108
+
109
+ /** Composition dependencies first. Cycles were rejected by validation. */
110
+ function inCompositionOrder(types: readonly SchemaContentType[]): SchemaContentType[] {
111
+ const byAlias = new Map(types.map((t) => [t.alias, t]))
112
+ const done = new Set<string>()
113
+ const out: SchemaContentType[] = []
114
+ const visit = (t: SchemaContentType) => {
115
+ if (done.has(t.alias)) return
116
+ done.add(t.alias)
117
+ for (const c of t.compositions) {
118
+ const dep = byAlias.get(c)
119
+ if (dep) visit(dep)
120
+ }
121
+ out.push(t)
122
+ }
123
+ for (const t of types) visit(t)
124
+ return out
125
+ }
126
+
127
+ export async function syncSchema(
128
+ db: Db,
129
+ loaded: LoadedSchema,
130
+ node: SyncNode,
131
+ options: SyncOptions,
132
+ ): Promise<SyncReport> {
133
+ const problems = [
134
+ ...loaded.problems,
135
+ ...validateSchemaSet(loaded.set, { componentAliases: options.componentAliases }),
136
+ ]
137
+ if (problems.length > 0)
138
+ return emptySyncReport('refused', { reason: 'the schema has problems', problems })
139
+
140
+ const set = loaded.set
141
+ if (options.mode === 'production') {
142
+ const missing = elementsWithoutKeys(set)
143
+ if (missing.length > 0)
144
+ return emptySyncReport('refused', {
145
+ reason: `production requires every element to carry a key; run \`crombolo schema sync\` in development to assign them: ${missing.join(', ')}`,
146
+ })
147
+ }
148
+ const hash = hashSchemaSet(set)
149
+ const fileState: NodeSchemaState = { version: set.version, revision: node.revision }
150
+
151
+ return db.locks
152
+ .withLock(Locks.Schema, async () =>
153
+ db.transaction(async (tx) => {
154
+ const current = await currentSchemaState(tx, true)
155
+ const prepared = await latestPreparedState(tx)
156
+ const preparedHere =
157
+ prepared && prepared.id > current.id && compareStates(prepared, fileState) === 0
158
+ ? prepared
159
+ : undefined
160
+ const status = options.status ?? 'current'
161
+ // An early fix already created this state: a second fix is a no-op, and a
162
+ // current sync of the same files is the cut-over.
163
+ if (preparedHere && status === 'prepared' && preparedHere.hash === hash)
164
+ return emptySyncReport('skipped-same', {
165
+ state: {
166
+ id: preparedHere.id,
167
+ version: preparedHere.version,
168
+ revision: preparedHere.revision,
169
+ },
170
+ })
171
+ const cutOver = preparedHere !== undefined && status === 'current'
172
+ const cmp = compareStates(fileState, current)
173
+ const neverSynced = current.hash === null
174
+ if (cmp < 0)
175
+ return emptySyncReport('skipped-older', {
176
+ reason: `files are at ${fileState.version}+${fileState.revision}, database at ${current.version}+${current.revision}; running in compatibility mode`,
177
+ })
178
+ if (cmp === 0 && !neverSynced && !cutOver) {
179
+ if (hash === current.hash) return emptySyncReport('skipped-same')
180
+ if (options.mode === 'production')
181
+ return emptySyncReport('refused', {
182
+ reason: `schema changed without a version bump (still ${set.version}); bump schema/schema.toml or deploy with a newer revision`,
183
+ })
184
+ }
185
+
186
+ const report = emptySyncReport('applied')
187
+ const state = cutOver
188
+ ? (preparedHere as SchemaStateRow)
189
+ : await appendSchemaState(tx, {
190
+ version: set.version,
191
+ revision: node.revision,
192
+ hash,
193
+ status,
194
+ by: node.nodeId,
195
+ })
196
+ report.state = { id: state.id, version: state.version, revision: state.revision }
197
+
198
+ const dataTypes = new DataTypeRepository(tx)
199
+ const typeFolders = new FolderRepository(
200
+ tx,
201
+ ObjectTypes.DocumentTypeContainer,
202
+ ObjectTypes.DocumentType,
203
+ )
204
+ const dataTypeFolders = new FolderRepository(
205
+ tx,
206
+ ObjectTypes.DataTypeContainer,
207
+ ObjectTypes.DataType,
208
+ )
209
+ const folderKey = (repo: FolderRepository, folder: string | undefined) =>
210
+ folder
211
+ ? repo.ensurePath(
212
+ folder
213
+ .split('/')
214
+ .map((s) => s.trim())
215
+ .filter(Boolean),
216
+ )
217
+ : Promise.resolve(null)
218
+ const types = new ContentTypeRepository(tx)
219
+ const templates = new ComponentRepository(tx)
220
+ const languages = new LanguageRepository(tx)
221
+
222
+ // ---- data types: built-ins are seeded; files add or override by alias
223
+ for (const d of set.dataTypes) {
224
+ const existing = await dataTypes.byAlias(d.alias)
225
+ const builtin = BUILTIN_DATA_TYPES.find((b) => b.alias === d.alias)
226
+ const key = d.key ?? existing?.key ?? crypto.randomUUID()
227
+ if (!d.key && !existing)
228
+ report.assignedKeys.push({
229
+ file: loaded.files.get(`data-type:${d.alias}`) ?? '',
230
+ typeAlias: d.alias,
231
+ key,
232
+ revived: false,
233
+ })
234
+ await dataTypes.save({
235
+ key,
236
+ alias: d.alias,
237
+ name: d.name,
238
+ editorAlias: d.editor,
239
+ editorUiAlias: d.editorUi ?? builtin?.editorUi ?? null,
240
+ dbType: existing?.dbType ?? storageTypeFor(d.editor),
241
+ values: Object.entries(d.config).map(([alias, value]) => ({ alias, value })),
242
+ parentKey: existing?.parentKey ?? (await folderKey(dataTypeFolders, d.folder)),
243
+ })
244
+ if (existing) report.updated.dataTypes += 1
245
+ else report.created.dataTypes += 1
246
+ }
247
+ const dataTypeKeyByAlias = new Map<string, string>()
248
+ for (const alias of new Set([
249
+ ...BUILTIN_DATA_TYPES.map((b) => b.alias),
250
+ ...set.dataTypes.map((d) => d.alias),
251
+ ])) {
252
+ const found = await dataTypes.byAlias(alias)
253
+ if (found) dataTypeKeyByAlias.set(alias, found.key)
254
+ }
255
+
256
+ // ---- languages
257
+ if (set.languages.length > 0) {
258
+ const result = await languages.upsertAll(
259
+ set.languages.map((l) => ({
260
+ isoCode: l.iso,
261
+ cultureName: l.name,
262
+ isDefault: l.default,
263
+ isMandatory: l.mandatory,
264
+ fallbackIsoCode: l.fallback ?? null,
265
+ })),
266
+ )
267
+ report.created.languages += result.created
268
+ report.updated.languages += result.updated
269
+ }
270
+
271
+ // ---- templates referenced by types must have a row
272
+ const componentKeyByAlias = new Map<string, string>()
273
+ for (const alias of new Set(set.contentTypes.flatMap((t) => t.components))) {
274
+ const existing = await templates.byAlias(alias)
275
+ const key = existing?.key ?? crypto.randomUUID()
276
+ if (!existing) await templates.save({ key, name: alias, alias, content: null })
277
+ componentKeyByAlias.set(alias, key)
278
+ }
279
+
280
+ // Content types, then media types: one table, two kinds, the same rules.
281
+ const kinds = [
282
+ {
283
+ name: 'document' as const,
284
+ types: set.contentTypes,
285
+ repo: types,
286
+ folders: typeFolders,
287
+ noun: 'content type',
288
+ items: 'document(s)',
289
+ },
290
+ {
291
+ name: 'media' as const,
292
+ types: set.mediaTypes ?? [],
293
+ repo: new ContentTypeRepository(tx, { kind: 'media' }),
294
+ folders: new FolderRepository(
295
+ tx,
296
+ ObjectTypes.MediaTypeContainer,
297
+ ObjectTypes.MediaType,
298
+ ),
299
+ noun: 'media type',
300
+ items: 'media item(s)',
301
+ },
302
+ {
303
+ name: 'member' as const,
304
+ types: set.memberTypes ?? [],
305
+ repo: new ContentTypeRepository(tx, { kind: 'member' }),
306
+ folders: new FolderRepository(
307
+ tx,
308
+ ObjectTypes.MemberTypeContainer,
309
+ ObjectTypes.MemberType,
310
+ ),
311
+ noun: 'member type',
312
+ items: 'member(s)',
313
+ },
314
+ ]
315
+ // Umbraco's fixed keys for the built-in media types' groups, so a site's
316
+ // video.toml keeps the key Umbraco gave its "Video" group.
317
+ const fixedGroupKeys = new Map([
318
+ ...BUILT_IN_MEDIA_TYPES.flatMap((t) =>
319
+ t.group ? [[`${t.alias}|${t.group.name}`, t.group.key] as const] : [],
320
+ ),
321
+ ...SYSTEM_CONTENT_TYPES.map((t) => [`${t.alias}|${t.tab.name}`, t.tab.key] as const),
322
+ ])
323
+ for (const kind of kinds) {
324
+ // ---- types: resolve keys, then save in composition order, twice
325
+ // (allowed children may reference types created later in the order)
326
+ const typeKeyByAlias = new Map<string, string>(
327
+ kind.name === 'media'
328
+ ? SYSTEM_MEDIA_TYPES.map((t) => [t.alias, t.key])
329
+ : kind.name === 'member'
330
+ ? [[SYSTEM_MEMBER_TYPE.alias, SYSTEM_MEMBER_TYPE.key]]
331
+ : SYSTEM_CONTENT_TYPES.map((t) => [t.alias, t.key]),
332
+ )
333
+ const aggregates: Array<{
334
+ file: string
335
+ type: SchemaContentType
336
+ aggregate: ContentTypeAggregate
337
+ }> = []
338
+ for (const t of inCompositionOrder(kind.types)) {
339
+ const file = loaded.files.get(typeFileKey(kind.name, t.alias)) ?? ''
340
+ const existing = t.key
341
+ ? await kind.repo.byKey(t.key)
342
+ : await kind.repo.byAlias(t.alias, { includeRetired: true })
343
+ const shippedKey =
344
+ kind.name === 'document'
345
+ ? SYSTEM_CONTENT_TYPES.find((d) => d.alias === t.alias)?.key
346
+ : undefined
347
+ const key = t.key ?? existing?.key ?? shippedKey ?? crypto.randomUUID()
348
+ if (!t.key && !existing)
349
+ report.assignedKeys.push({ file, typeAlias: t.alias, key, revived: false })
350
+ typeKeyByAlias.set(t.alias, key)
351
+ const retired = existing ? await kind.repo.retiredProperties(existing.key) : []
352
+ const liveByAlias = new Map((existing?.properties ?? []).map((p) => [p.alias, p]))
353
+ const shippedPropertyKeys: ReadonlyMap<string, string> = shippedKey
354
+ ? systemPropertyKeys(t.alias)
355
+ : new Map()
356
+
357
+ const containers: ContentTypeAggregate['containers'] = []
358
+ const properties: PropertyTypeModel[] = []
359
+ let sortOrder = 0
360
+ const resolveProperty = (
361
+ p: SchemaProperty,
362
+ containerKey: string | null,
363
+ ): PropertyTypeModel => {
364
+ let key = p.key
365
+ let revived = false
366
+ if (!key) {
367
+ const live = liveByAlias.get(p.alias)
368
+ const back = retired.find((r) => r.alias === p.alias)
369
+ key =
370
+ live?.key ?? back?.key ?? shippedPropertyKeys.get(p.alias) ?? crypto.randomUUID()
371
+ revived = !live && back !== undefined
372
+ if (!live)
373
+ report.assignedKeys.push({
374
+ file,
375
+ typeAlias: t.alias,
376
+ propertyAlias: p.alias,
377
+ key,
378
+ revived,
379
+ })
380
+ if (revived) report.revived.push(`${t.alias}.${p.alias}`)
381
+ } else if (retired.some((r) => r.key === key)) {
382
+ report.revived.push(`${t.alias}.${p.alias}`)
383
+ }
384
+ // Under a prepared state an existing property keeps its editor: the old
385
+ // nodes still edit it, and the converted values wait for the cut-over.
386
+ let dataTypeKey = dataTypeKeyByAlias.get(p.type) ?? ''
387
+ const liveProperty = liveByAlias.get(p.alias)
388
+ if (
389
+ status === 'prepared' &&
390
+ liveProperty &&
391
+ liveProperty.dataTypeKey !== dataTypeKey
392
+ ) {
393
+ report.deferred.push(`${t.alias}.${p.alias}`)
394
+ dataTypeKey = liveProperty.dataTypeKey
395
+ }
396
+ // Likewise a property becoming mandatory: old nodes must keep publishing.
397
+ let mandatory = p.mandatory
398
+ if (status === 'prepared' && liveProperty && !liveProperty.mandatory && p.mandatory) {
399
+ report.deferred.push(`${t.alias}.${p.alias} (mandatory)`)
400
+ mandatory = false
401
+ }
402
+ return {
403
+ key,
404
+ alias: p.alias,
405
+ name: p.name,
406
+ description: p.description ?? null,
407
+ dataTypeKey,
408
+ containerKey,
409
+ sortOrder: sortOrder++,
410
+ variesByCulture: p.variesByCulture,
411
+ variesBySegment: p.variesBySegment,
412
+ mandatory,
413
+ mandatoryMessage: p.mandatoryMessage ?? null,
414
+ regEx: p.regex ?? null,
415
+ sinceVersion: p.since ?? null,
416
+ regExMessage: p.regexMessage ?? null,
417
+ labelOnTop: p.labelOnTop,
418
+ memberCanView: p.memberCanView === true,
419
+ memberCanEdit: p.memberCanEdit === true,
420
+ isSensitive: p.sensitive === true,
421
+ }
422
+ }
423
+ // Groups have no key in the file; they match an existing group of the same name.
424
+ const existingContainers = existing?.containers ?? []
425
+ const containerKeyFor = (
426
+ name: string,
427
+ parentKey: string | null,
428
+ type: 'Tab' | 'Group' = parentKey ? 'Group' : 'Tab',
429
+ ): string =>
430
+ existingContainers.find(
431
+ (c) => c.name === name && (c.parentKey ?? null) === parentKey && c.type === type,
432
+ )?.key ??
433
+ fixedGroupKeys.get(`${t.alias}|${name}`) ??
434
+ crypto.randomUUID()
435
+ for (const p of t.properties) properties.push(resolveProperty(p, null))
436
+ let rootGroupOrder = 0
437
+ for (const g of t.groups ?? []) {
438
+ const groupKey = containerKeyFor(g.name, null, 'Group')
439
+ containers.push({
440
+ key: groupKey,
441
+ name: g.name,
442
+ alias: g.alias ?? null,
443
+ type: 'Group',
444
+ sortOrder: rootGroupOrder++,
445
+ parentKey: null,
446
+ })
447
+ for (const p of g.properties) properties.push(resolveProperty(p, groupKey))
448
+ }
449
+ let tabOrder = 0
450
+ for (const tab of t.tabs) {
451
+ const tabKey = containerKeyFor(tab.name, null)
452
+ containers.push({
453
+ key: tabKey,
454
+ name: tab.name,
455
+ alias: tab.alias ?? null,
456
+ type: 'Tab',
457
+ sortOrder: tabOrder++,
458
+ parentKey: null,
459
+ })
460
+ for (const p of tab.properties) properties.push(resolveProperty(p, tabKey))
461
+ let groupOrder = 0
462
+ for (const g of tab.groups) {
463
+ const groupKey = containerKeyFor(g.name, tabKey)
464
+ containers.push({
465
+ key: groupKey,
466
+ name: g.name,
467
+ alias: g.alias ?? null,
468
+ type: 'Group',
469
+ sortOrder: groupOrder++,
470
+ parentKey: tabKey,
471
+ })
472
+ for (const p of g.properties) properties.push(resolveProperty(p, groupKey))
473
+ }
474
+ }
475
+ aggregates.push({
476
+ file,
477
+ type: t,
478
+ aggregate: {
479
+ key,
480
+ alias: t.alias,
481
+ name: t.name,
482
+ description: t.description ?? null,
483
+ icon: t.icon,
484
+ allowedAsRoot: t.allowAtRoot,
485
+ variesByCulture: t.variesByCulture,
486
+ variesBySegment: t.variesBySegment,
487
+ isElement: t.isElement,
488
+ allowedInLibrary: t.allowInLibrary,
489
+ collectionKey: t.collection ? (dataTypeKeyByAlias.get(t.collection) ?? null) : null,
490
+ sinceVersion: t.since ?? null,
491
+ cleanup: {
492
+ preventCleanup: t.cleanup.prevent,
493
+ keepAllVersionsNewerThanDays: t.cleanup.keepAllNewerThanDays ?? null,
494
+ keepLatestVersionPerDayForDays: t.cleanup.keepLatestPerDayForDays ?? null,
495
+ },
496
+ properties,
497
+ containers,
498
+ compositions: t.compositions.map((alias) => ({
499
+ contentTypeKey: typeKeyByAlias.get(alias) ?? '',
500
+ compositionType: 'Composition' as const,
501
+ })),
502
+ allowedContentTypes: [],
503
+ allowedComponentKeys: t.components.map(
504
+ (alias) => componentKeyByAlias.get(alias) ?? '',
505
+ ),
506
+ defaultComponentKey: t.defaultComponent
507
+ ? (componentKeyByAlias.get(t.defaultComponent) ?? null)
508
+ : null,
509
+ parentKey:
510
+ t.folder !== undefined
511
+ ? await folderKey(kind.folders, t.folder)
512
+ : (existing?.parentKey ?? null),
513
+ },
514
+ })
515
+ const before = existing
516
+ ? new Set(existing.properties.map((p) => p.alias))
517
+ : new Set<string>()
518
+ if (existing) report.updated.types += 1
519
+ else report.created.types += 1
520
+ report.created.properties += properties.filter((p) => !before.has(p.alias)).length
521
+ for (const alias of before) {
522
+ if (allProperties(t).some((p) => p.alias === alias)) continue
523
+ if (status === 'prepared') {
524
+ // Old nodes keep editing it until the cut-over retires it.
525
+ const live = existing?.properties.find((p) => p.alias === alias)
526
+ if (live) properties.push({ ...live, sortOrder: sortOrder++ })
527
+ report.deferred.push(`${t.alias}.${alias} (retire)`)
528
+ } else report.retired.push(`${t.alias}.${alias}`)
529
+ }
530
+ }
531
+ for (const { aggregate } of aggregates)
532
+ await kind.repo.save(aggregate, { sinceStateId: state.id })
533
+ for (const { type, aggregate } of aggregates) {
534
+ if (type.allowChildren.length === 0) continue
535
+ await kind.repo.save(
536
+ {
537
+ ...aggregate,
538
+ allowedContentTypes: type.allowChildren.map((alias, sortOrder) => ({
539
+ contentTypeKey: typeKeyByAlias.get(alias) ?? '',
540
+ sortOrder,
541
+ })),
542
+ },
543
+ { sinceStateId: state.id },
544
+ )
545
+ }
546
+
547
+ // ---- types in the database but not in files: retire, unless they have content
548
+ const inFiles = new Set(typeKeyByAlias.values())
549
+ const dbTypes = await tx.query<{ unique_id: string; alias: string }>(
550
+ 'SELECT n.unique_id, ct.alias FROM content_type ct JOIN node n ON n.id = ct.node_id WHERE ct.retired_at IS NULL AND n.node_object_type = ?',
551
+ [kind.repo.objectType],
552
+ )
553
+ for (const row of dbTypes) {
554
+ const key = String(row.unique_id).toLowerCase()
555
+ if (inFiles.has(key)) continue
556
+ // Shipped with the framework: present whether or not a file defines it.
557
+ if (SYSTEM_MEDIA_TYPES.some((t) => t.key === key)) continue
558
+ if (key === SYSTEM_MEMBER_TYPE.key) continue
559
+ if (SYSTEM_CONTENT_TYPES.some((t) => t.key === key)) continue
560
+ if (status === 'prepared') {
561
+ report.deferred.push(`${row.alias} (retire type)`)
562
+ continue
563
+ }
564
+ const count = await kind.repo.contentCount(key)
565
+ if (count > 0 && !options.forceRetireTypes) {
566
+ throw new SchemaSyncRefused(
567
+ `${kind.noun} "${row.alias}" was removed from schema/ but ${count} ${kind.items} of it exist; restore the file, or retire it deliberately with --force-retire-types`,
568
+ )
569
+ }
570
+ await tx.exec(
571
+ 'UPDATE content_type SET retired_at = ? WHERE node_id = (SELECT id FROM node WHERE unique_id = ?)',
572
+ [DbDate.toDb(new Date()), key],
573
+ )
574
+ report.retiredTypes.push(String(row.alias))
575
+ }
576
+ }
577
+
578
+ if (cutOver) {
579
+ await makeStateCurrent(tx, state.id)
580
+ await updateSchemaStateHash(tx, state.id, hash)
581
+ }
582
+ await appendCacheInstruction(tx, {
583
+ kind: 'schema',
584
+ payload: { stateId: state.id },
585
+ by: node.nodeId,
586
+ })
587
+ if (options.dryRun) throw new DryRun(report)
588
+ return report
589
+ }),
590
+ )
591
+ .catch((error: unknown) => {
592
+ if (error instanceof SchemaSyncRefused)
593
+ return emptySyncReport('refused', { reason: error.message })
594
+ if (error instanceof DryRun) return { ...error.report, action: 'would-apply' as const }
595
+ throw error
596
+ })
597
+ }
598
+
599
+ export class SchemaSyncRefused extends Error {}
600
+
601
+ class DryRun extends Error {
602
+ constructor(readonly report: SyncReport) {
603
+ super('dry run')
604
+ }
605
+ }
606
+
607
+ function elementsWithoutKeys(set: SchemaSet): string[] {
608
+ const missing: string[] = []
609
+ for (const t of [...set.contentTypes, ...(set.mediaTypes ?? []), ...(set.memberTypes ?? [])]) {
610
+ if (!t.key) missing.push(t.alias)
611
+ for (const p of allProperties(t)) if (!p.key) missing.push(`${t.alias}.${p.alias}`)
612
+ }
613
+ for (const d of set.dataTypes) if (!d.key) missing.push(`data-type ${d.alias}`)
614
+ return missing
615
+ }