@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/check.ts ADDED
@@ -0,0 +1,502 @@
1
+ /**
2
+ * The pre-upgrade check: what would this change do to the data, and what does
3
+ * it need? One function behind `crombolo upgrade check` and `crombolo schema
4
+ * check`. Read-only. docs/packaging-and-upgrades.md, "Three kinds of finding".
5
+ */
6
+ import type { ContentTypeAggregate, PropertyTypeModel } from '@crombolo/core'
7
+ import { DEFAULT_BACKOFFICE_PATH } from '@crombolo/core'
8
+ import {
9
+ ContentTypeRepository,
10
+ cromboloPlan,
11
+ currentSchemaState,
12
+ DataTypeRepository,
13
+ type Db,
14
+ DocumentRepository,
15
+ type Finding,
16
+ LanguageRepository,
17
+ latestPreparedState,
18
+ pendingMigrations,
19
+ readLedger,
20
+ } from '@crombolo/data'
21
+ import { BUILTIN_DATA_TYPES, storageTypeFor } from './builtin.ts'
22
+ import type { LoadedSchema } from './load.ts'
23
+ import { allProperties, type SchemaContentType, type SchemaProperty } from './model.ts'
24
+ import {
25
+ emptySyncReport,
26
+ type SyncNode,
27
+ type SyncOptions,
28
+ type SyncReport,
29
+ syncSchema,
30
+ } from './sync.ts'
31
+ import { findConverter, outputsOf, type ValueMigration } from './value-migrations.ts'
32
+
33
+ export type ChangeClass = 'none' | 'additive' | 'data-requiring' | 'breaking'
34
+
35
+ export interface CheckOptions extends Omit<SyncOptions, 'dryRun' | 'status'> {
36
+ /** `--set type.property=value`: a value to apply wherever the property is missing. */
37
+ set?: Record<string, unknown>
38
+ migrations?: readonly ValueMigration[]
39
+ /** Where finding links point. */
40
+ backOfficePath?: string
41
+ }
42
+
43
+ export interface CheckReport {
44
+ findings: Finding[]
45
+ /** Findings an upgrade must not proceed over (everything but the framework's own pending steps). */
46
+ outstanding: Finding[]
47
+ classification: ChangeClass
48
+ /** The dry-run sync of the site diff. */
49
+ sync: SyncReport
50
+ frameworkPending: string[]
51
+ /** False when framework migrations are pending: the site part runs once they have. */
52
+ siteChecked: boolean
53
+ }
54
+
55
+ /** The ledger name of an editor conversion, so a re-run knows it ran. */
56
+ export function conversionLedgerName(
57
+ type: string,
58
+ property: string,
59
+ from: string,
60
+ to: string,
61
+ ): string {
62
+ return `convert:${type}.${property}:${from}->${to}`
63
+ }
64
+
65
+ export interface AffectedProperty {
66
+ type: SchemaContentType
67
+ typeAggregate: ContentTypeAggregate
68
+ property: SchemaProperty
69
+ live: PropertyTypeModel | undefined
70
+ /** Editor aliases when the property's editor changes. */
71
+ editorChange?: { from: string; to: string; toStorage: ReturnType<typeof storageTypeFor> }
72
+ /** True when the property is newly mandatory on a type with content — or still pending from an early fix. */
73
+ newlyMandatory: boolean
74
+ /** True when the property has been mandatory and live all along. */
75
+ liveMandatory: boolean
76
+ }
77
+
78
+ /** Every file property whose change needs data work, with what the database has now. */
79
+ export async function affectedProperties(
80
+ db: Db,
81
+ loaded: LoadedSchema,
82
+ ): Promise<AffectedProperty[]> {
83
+ // Relative to the database's own current state, so what an early fix created is still pending.
84
+ const types = new ContentTypeRepository(db, { nodeState: await currentSchemaState(db) })
85
+ const dataTypes = new DataTypeRepository(db)
86
+ const out: AffectedProperty[] = []
87
+ const editorOf = async (dataTypeAlias: string): Promise<string | undefined> =>
88
+ loaded.set.dataTypes.find((d) => d.alias === dataTypeAlias)?.editor ??
89
+ BUILTIN_DATA_TYPES.find((b) => b.alias === dataTypeAlias)?.editor ??
90
+ (await dataTypes.byAlias(dataTypeAlias))?.editorAlias
91
+ for (const t of loaded.set.contentTypes) {
92
+ const existing = t.key
93
+ ? await types.byKey(t.key)
94
+ : await types.byAlias(t.alias, { includeRetired: true })
95
+ if (!existing) continue
96
+ for (const p of allProperties(t)) {
97
+ const live = existing.properties.find((x) => x.alias === p.alias)
98
+ const incoming = !live?.mandatory || live.pending !== null
99
+ const entry: AffectedProperty = {
100
+ type: t,
101
+ typeAggregate: existing,
102
+ property: p,
103
+ live,
104
+ newlyMandatory: p.mandatory && incoming,
105
+ liveMandatory: p.mandatory && !incoming,
106
+ }
107
+ if (live) {
108
+ const toEditor = await editorOf(p.type)
109
+ const fromEditor = (await dataTypes.byKey(live.dataTypeKey))?.editorAlias
110
+ if (toEditor && fromEditor && toEditor !== fromEditor)
111
+ entry.editorChange = {
112
+ from: fromEditor,
113
+ to: toEditor,
114
+ toStorage: storageTypeFor(toEditor),
115
+ }
116
+ }
117
+ if (entry.newlyMandatory || entry.liveMandatory || entry.editorChange) out.push(entry)
118
+ }
119
+ }
120
+ return out
121
+ }
122
+
123
+ /** A type's node id plus every type that composes it, transitively: their documents carry its properties. */
124
+ export async function typeNodeIdsCarrying(db: Db, typeKey: string): Promise<number[]> {
125
+ const rows = await db.query<{ id: number }>(
126
+ `WITH RECURSIVE carriers(id) AS (
127
+ SELECT id FROM node WHERE unique_id = ?
128
+ UNION
129
+ SELECT c.child_content_type_id FROM content_type_composition c JOIN carriers p ON p.id = c.parent_content_type_id
130
+ )
131
+ SELECT id FROM carriers`,
132
+ [typeKey],
133
+ )
134
+ return rows.map((r) => Number(r.id))
135
+ }
136
+
137
+ function documentLink(backOfficePath: string, key: string): string {
138
+ return `${backOfficePath}/section/content/workspace/document/edit/${key}`
139
+ }
140
+
141
+ export async function runCheck(
142
+ db: Db,
143
+ loaded: LoadedSchema,
144
+ node: SyncNode,
145
+ options: CheckOptions,
146
+ ): Promise<CheckReport> {
147
+ const findings: Finding[] = []
148
+ const backOfficePath = options.backOfficePath ?? DEFAULT_BACKOFFICE_PATH
149
+ const set = options.set ?? {}
150
+ const migrations = options.migrations ?? []
151
+
152
+ // ---- the framework's own steps
153
+ const frameworkPending = (await pendingMigrations(db, cromboloPlan)).map((m) => m.name)
154
+ for (const name of frameworkPending)
155
+ findings.push({
156
+ kind: 'auto',
157
+ code: 'framework-migration',
158
+ subjectType: 'migration',
159
+ subjectKey: name,
160
+ subjectName: name,
161
+ propertyAlias: null,
162
+ culture: null,
163
+ message: `framework migration ${name} will run at upgrade`,
164
+ link: null,
165
+ })
166
+
167
+ // A database behind the framework's own schema cannot be read for the site
168
+ // part yet; `--fix` and `upgrade` run those steps first, then check again.
169
+ if (frameworkPending.length > 0)
170
+ return {
171
+ findings,
172
+ outstanding: [],
173
+ classification: 'none',
174
+ sync: emptySyncReport('skipped-same', {
175
+ reason: 'framework migrations pending; the site schema is checked once they have run',
176
+ }),
177
+ frameworkPending,
178
+ siteChecked: false,
179
+ }
180
+
181
+ // ---- the files themselves, and what the sync would do
182
+ const sync = await syncSchema(db, loaded, node, { ...options, dryRun: true, status: 'prepared' })
183
+ for (const problem of sync.problems)
184
+ findings.push({
185
+ kind: 'blocking',
186
+ code: 'schema-problem',
187
+ subjectType: 'file',
188
+ subjectKey: problem.file,
189
+ subjectName: problem.file,
190
+ propertyAlias: problem.path || null,
191
+ culture: null,
192
+ message: problem.message,
193
+ link: null,
194
+ })
195
+ if (sync.action === 'refused' && sync.problems.length === 0)
196
+ findings.push({
197
+ kind: 'blocking',
198
+ code: 'sync-refused',
199
+ subjectType: 'file',
200
+ subjectKey: 'schema',
201
+ subjectName: 'schema/',
202
+ propertyAlias: null,
203
+ culture: null,
204
+ message: sync.reason ?? 'the sync refused',
205
+ link: null,
206
+ })
207
+
208
+ const types = new ContentTypeRepository(db)
209
+ const docs = new DocumentRepository(db)
210
+ const languages = await new LanguageRepository(db).all()
211
+ const cultures = languages.map((l) => l.isoCode)
212
+ const ledger = await readLedger(db)
213
+ const ledgered = new Set(ledger.filter((r) => r.kind === 'value').map((r) => r.name))
214
+ const prepared = await latestPreparedState(db)
215
+
216
+ // ---- types removed while they have content
217
+ const inFiles = new Set(loaded.set.contentTypes.map((t) => t.alias))
218
+ const dbTypes = await db.query<{ unique_id: string; alias: string }>(
219
+ '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',
220
+ )
221
+ for (const row of dbTypes) {
222
+ if (inFiles.has(String(row.alias))) continue
223
+ const count = await types.contentCount(String(row.unique_id).toLowerCase())
224
+ if (count === 0) continue
225
+ findings.push({
226
+ kind: 'blocking',
227
+ code: 'type-removed-with-content',
228
+ subjectType: 'document-type',
229
+ subjectKey: String(row.unique_id).toLowerCase(),
230
+ subjectName: String(row.alias),
231
+ propertyAlias: null,
232
+ culture: null,
233
+ message: `content type "${row.alias}" was removed from schema/ but ${count} document(s) of it exist`,
234
+ link: null,
235
+ })
236
+ }
237
+
238
+ // ---- properties that need data: newly mandatory, or changing editor
239
+ let breaking = false
240
+ let dataRequiring = false
241
+ for (const affected of await affectedProperties(db, loaded)) {
242
+ const { type: t, property: p, typeAggregate } = affected
243
+ const carriers = await typeNodeIdsCarrying(db, typeAggregate.key)
244
+ const documents = await docs.documentsOfTypes(carriers)
245
+ const propertySubject = `${typeAggregate.key}:${p.alias}`
246
+ const perCulture = p.variesByCulture ? cultures : [null]
247
+
248
+ if (affected.newlyMandatory && documents.length > 0) {
249
+ dataRequiring = true
250
+ const supplied = p.default !== undefined || set[`${t.alias}.${p.alias}`] !== undefined
251
+ let missing = 0
252
+ for (const doc of documents) {
253
+ const values = await docs.currentValuesByAlias(doc.nodeId, [p.alias])
254
+ for (const culture of perCulture) {
255
+ const has = values.some(
256
+ (v) =>
257
+ v.culture === culture && v.value !== null && v.value !== undefined && v.value !== '',
258
+ )
259
+ if (has) continue
260
+ missing += 1
261
+ if (!supplied)
262
+ findings.push({
263
+ kind: 'person',
264
+ code: 'mandatory-missing',
265
+ subjectType: 'document',
266
+ subjectKey: doc.key,
267
+ subjectName: doc.name,
268
+ propertyAlias: p.alias,
269
+ culture,
270
+ message: `"${p.name}" is required from ${loaded.set.version} and "${doc.name}"${culture ? ` (${culture})` : ''} has no value`,
271
+ link: documentLink(backOfficePath, doc.key),
272
+ })
273
+ }
274
+ }
275
+ if (supplied && missing > 0)
276
+ findings.push({
277
+ kind: 'auto',
278
+ code: 'mandatory-default',
279
+ subjectType: 'property',
280
+ subjectKey: propertySubject,
281
+ subjectName: `${t.alias}.${p.alias}`,
282
+ propertyAlias: p.alias,
283
+ culture: null,
284
+ message: `"${p.name}" is required and ${missing} value(s) will be filled with ${p.default !== undefined ? 'its default' : '--set'}`,
285
+ link: null,
286
+ })
287
+ }
288
+
289
+ // A live mandatory property a published page lacks: it will fail its next publish.
290
+ // Reported for the dashboard, never a reason to refuse an upgrade.
291
+ if (affected.liveMandatory) {
292
+ for (const doc of documents) {
293
+ if (!doc.published) continue
294
+ const values = await docs.currentValuesByAlias(doc.nodeId, [p.alias])
295
+ for (const culture of perCulture) {
296
+ if (
297
+ values.some(
298
+ (v) =>
299
+ v.culture === culture &&
300
+ v.value !== null &&
301
+ v.value !== undefined &&
302
+ v.value !== '',
303
+ )
304
+ )
305
+ continue
306
+ findings.push({
307
+ kind: 'person',
308
+ code: 'mandatory-unfilled',
309
+ subjectType: 'document',
310
+ subjectKey: doc.key,
311
+ subjectName: doc.name,
312
+ propertyAlias: p.alias,
313
+ culture,
314
+ message: `"${p.name}" is required and published page "${doc.name}"${culture ? ` (${culture})` : ''} has no value`,
315
+ link: documentLink(backOfficePath, doc.key),
316
+ })
317
+ }
318
+ }
319
+ }
320
+
321
+ if (affected.editorChange && affected.live) {
322
+ breaking = true
323
+ const { from, to } = affected.editorChange
324
+ const converter = findConverter(from, to)
325
+ if (!converter) {
326
+ findings.push({
327
+ kind: 'blocking',
328
+ code: 'no-converter',
329
+ subjectType: 'property',
330
+ subjectKey: propertySubject,
331
+ subjectName: `${t.alias}.${p.alias}`,
332
+ propertyAlias: p.alias,
333
+ culture: null,
334
+ message: `"${p.name}" changes editor from ${from} to ${to} and no converter exists; write one in schema/migrations/ or keep the editor`,
335
+ link: null,
336
+ })
337
+ continue
338
+ }
339
+ const ran = ledgered.has(conversionLedgerName(t.alias, p.alias, from, to))
340
+ let convertible = 0
341
+ for (const doc of documents) {
342
+ for (const v of await docs.currentValuesByAlias(doc.nodeId, [p.alias])) {
343
+ if (v.value === null || v.value === undefined) continue
344
+ // After the fix ran, a value still at an older state was written by an old node since.
345
+ const stale = ran && prepared !== undefined && v.schemaStateId < prepared.id
346
+ if (ran && !stale) continue
347
+ try {
348
+ converter.convert(v.value, {
349
+ documentKey: doc.key,
350
+ culture: v.culture,
351
+ segment: v.segment,
352
+ })
353
+ if (stale)
354
+ findings.push({
355
+ kind: 'auto',
356
+ code: 'unconverted-value',
357
+ subjectType: 'document',
358
+ subjectKey: doc.key,
359
+ subjectName: doc.name,
360
+ propertyAlias: p.alias,
361
+ culture: v.culture,
362
+ message: `"${doc.name}" was edited after the fix; "${p.name}" needs converting again — run check --fix`,
363
+ link: documentLink(backOfficePath, doc.key),
364
+ })
365
+ else convertible += 1
366
+ } catch (error) {
367
+ findings.push({
368
+ kind: 'person',
369
+ code: 'value-conversion',
370
+ subjectType: 'document',
371
+ subjectKey: doc.key,
372
+ subjectName: doc.name,
373
+ propertyAlias: p.alias,
374
+ culture: v.culture,
375
+ message: `"${p.name}" on "${doc.name}"${v.culture ? ` (${v.culture})` : ''} cannot be converted from ${from} to ${to}: ${(error as Error).message}`,
376
+ link: documentLink(backOfficePath, doc.key),
377
+ })
378
+ }
379
+ }
380
+ }
381
+ if (!ran && convertible > 0)
382
+ findings.push({
383
+ kind: 'auto',
384
+ code: 'value-conversion',
385
+ subjectType: 'property',
386
+ subjectKey: propertySubject,
387
+ subjectName: `${t.alias}.${p.alias}`,
388
+ propertyAlias: p.alias,
389
+ culture: null,
390
+ message: `"${p.name}": ${convertible} value(s) convert from ${from} to ${to}`,
391
+ link: null,
392
+ })
393
+ }
394
+ }
395
+
396
+ // ---- the site's value migrations
397
+ for (const m of migrations) {
398
+ const fileType = loaded.set.contentTypes.find((t) => t.alias === m.from.type)
399
+ const existing = await types.byAlias(m.from.type, { includeRetired: true })
400
+ if (!fileType || !existing) continue
401
+ const targets = m.to.map((x) => x.property)
402
+ const fileProps = allProperties(fileType).map((p) => p.alias)
403
+ if (!targets.every((alias) => fileProps.includes(alias))) {
404
+ findings.push({
405
+ kind: 'blocking',
406
+ code: 'migration-target-missing',
407
+ subjectType: 'migration',
408
+ subjectKey: m.id,
409
+ subjectName: m.id,
410
+ propertyAlias: null,
411
+ culture: null,
412
+ message: `migration ${m.id} writes to ${targets.join(', ')} but ${fileType.alias} does not define them all`,
413
+ link: null,
414
+ })
415
+ continue
416
+ }
417
+ const carriers = await typeNodeIdsCarrying(db, existing.key)
418
+ const documents = await docs.documentsOfTypes(carriers)
419
+ const ran = ledgered.has(m.id)
420
+ let convertible = 0
421
+ for (const doc of documents) {
422
+ for (const v of await docs.currentValuesByAlias(doc.nodeId, [m.from.property])) {
423
+ if (v.value === null || v.value === undefined) continue
424
+ let stale = false
425
+ if (ran) {
426
+ // Row order is the whole signal: a target written after this source
427
+ // value is up to date. Requiring the prepared state as well reported
428
+ // every surviving source value as stale once the cut-over had made that
429
+ // state current, so re-running `upgrade` on an upgraded database refused.
430
+ const targetsWritten = (await docs.currentValuesByAlias(doc.nodeId, targets)).some(
431
+ (tv) => tv.rowId > v.rowId,
432
+ )
433
+ stale = !targetsWritten
434
+ if (!stale) continue
435
+ }
436
+ try {
437
+ outputsOf(
438
+ m,
439
+ m.convert(v.value, { documentKey: doc.key, culture: v.culture, segment: v.segment }),
440
+ )
441
+ if (stale)
442
+ findings.push({
443
+ kind: 'auto',
444
+ code: 'unconverted-value',
445
+ subjectType: 'document',
446
+ subjectKey: doc.key,
447
+ subjectName: doc.name,
448
+ propertyAlias: m.from.property,
449
+ culture: v.culture,
450
+ message: `"${doc.name}" changed "${m.from.property}" after migration ${m.id} ran — run check --fix again`,
451
+ link: documentLink(backOfficePath, doc.key),
452
+ })
453
+ else convertible += 1
454
+ } catch (error) {
455
+ findings.push({
456
+ kind: 'person',
457
+ code: 'value-migration',
458
+ subjectType: 'document',
459
+ subjectKey: doc.key,
460
+ subjectName: doc.name,
461
+ propertyAlias: m.from.property,
462
+ culture: v.culture,
463
+ message: `migration ${m.id} cannot convert "${m.from.property}" on "${doc.name}": ${(error as Error).message}`,
464
+ link: documentLink(backOfficePath, doc.key),
465
+ })
466
+ }
467
+ }
468
+ }
469
+ if (!ran) {
470
+ dataRequiring = true
471
+ findings.push({
472
+ kind: 'auto',
473
+ code: 'value-migration',
474
+ subjectType: 'migration',
475
+ subjectKey: m.id,
476
+ subjectName: m.id,
477
+ propertyAlias: m.from.property,
478
+ culture: null,
479
+ message: `migration ${m.id} moves ${convertible} value(s) from ${m.from.type}.${m.from.property} to ${targets.join(', ')}`,
480
+ link: null,
481
+ })
482
+ }
483
+ }
484
+
485
+ const classification: ChangeClass = breaking
486
+ ? 'breaking'
487
+ : dataRequiring
488
+ ? 'data-requiring'
489
+ : sync.action === 'would-apply' || sync.action === 'applied'
490
+ ? 'additive'
491
+ : 'none'
492
+ return {
493
+ findings,
494
+ outstanding: findings.filter(
495
+ (f) => f.code !== 'framework-migration' && f.code !== 'mandatory-unfilled',
496
+ ),
497
+ classification,
498
+ sync,
499
+ frameworkPending,
500
+ siteChecked: true,
501
+ }
502
+ }