@crombolo/schema 0.0.0-stage → 0.9.1
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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/builtin.ts +215 -0
- package/src/check.ts +502 -0
- package/src/export.ts +419 -0
- package/src/forms.ts +328 -0
- package/src/generate.ts +149 -0
- package/src/index.ts +19 -0
- package/src/load.ts +137 -0
- package/src/model.ts +156 -0
- package/src/parse-forms.ts +229 -0
- package/src/parse.ts +260 -0
- package/src/reader.ts +99 -0
- package/src/scaffold.ts +172 -0
- package/src/sync.ts +615 -0
- package/src/upgrade.ts +296 -0
- package/src/validate-forms.ts +346 -0
- package/src/validate.ts +242 -0
- package/src/value-migrations.ts +122 -0
- package/src/version.ts +53 -0
- package/src/write-forms.ts +139 -0
- package/src/write.ts +176 -0
- package/README.md +0 -3
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/10-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
|
+
}
|