@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/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/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
|
+
}
|