@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/src/export.ts ADDED
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Database → files. `exportSchemaSet` reads the current definitions as the
3
+ * schema model; `writeSchemaFiles` lays them out canonically; `writeKeysBack`
4
+ * records the keys a sync minted so the next deploy carries them.
5
+ */
6
+ import { mkdirSync, writeFileSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import {
9
+ type ContentTypeAggregate,
10
+ type DataTypeModel,
11
+ ObjectTypes,
12
+ type PropertyTypeModel,
13
+ } from '@crombolo/core'
14
+ import {
15
+ ComponentRepository,
16
+ ContentTypeRepository,
17
+ DataTypeRepository,
18
+ type Db,
19
+ FolderRepository,
20
+ LanguageRepository,
21
+ SYSTEM_CONTENT_TYPES,
22
+ SYSTEM_MEDIA_TYPES,
23
+ } from '@crombolo/data'
24
+ import { BUILTIN_DATA_TYPES, builtInContentTypeSchema, builtInMediaTypeSchema } from './builtin.ts'
25
+ import type { LoadedSchema } from './load.ts'
26
+ import type {
27
+ SchemaContentType,
28
+ SchemaDataType,
29
+ SchemaLanguage,
30
+ SchemaProperty,
31
+ SchemaSet,
32
+ } from './model.ts'
33
+ import { TYPE_KIND_FILES, typeFileKey } from './model.ts'
34
+ import type { AssignedKey } from './sync.ts'
35
+ import {
36
+ fileNameFor,
37
+ writeContentType,
38
+ writeDataType,
39
+ writeLanguages,
40
+ writeMediaType,
41
+ writeMemberType,
42
+ writeSchemaVersion,
43
+ } from './write.ts'
44
+
45
+ /** Key → alias lookups for everything a content type refers to. */
46
+ export interface ExportContext {
47
+ dataTypeAlias(key: string): string | undefined
48
+ contentTypeAlias(key: string): string | undefined
49
+ componentAlias(key: string): string | undefined
50
+ }
51
+
52
+ function toSchemaProperty(p: PropertyTypeModel, ctx: ExportContext): SchemaProperty {
53
+ const property: SchemaProperty = {
54
+ key: p.key,
55
+ alias: p.alias,
56
+ name: p.name,
57
+ type: ctx.dataTypeAlias(p.dataTypeKey) ?? p.dataTypeKey,
58
+ mandatory: p.mandatory,
59
+ variesByCulture: p.variesByCulture,
60
+ variesBySegment: p.variesBySegment,
61
+ labelOnTop: p.labelOnTop,
62
+ }
63
+ if (p.description) property.description = p.description
64
+ if (p.mandatoryMessage) property.mandatoryMessage = p.mandatoryMessage
65
+ if (p.regEx) property.regex = p.regEx
66
+ if (p.regExMessage) property.regexMessage = p.regExMessage
67
+ if (p.memberCanView) property.memberCanView = true
68
+ if (p.memberCanEdit) property.memberCanEdit = true
69
+ if (p.isSensitive) property.sensitive = true
70
+ return property
71
+ }
72
+
73
+ export function toSchemaContentType(
74
+ aggregate: ContentTypeAggregate,
75
+ ctx: ExportContext,
76
+ ): SchemaContentType {
77
+ const byOrder = (a: { sortOrder: number }, b: { sortOrder: number }) => a.sortOrder - b.sortOrder
78
+ const properties = [...aggregate.properties].sort(byOrder)
79
+ const containers = [...aggregate.containers].sort(byOrder)
80
+ const under = (containerKey: string | null) =>
81
+ properties
82
+ .filter((p) => (p.containerKey ?? null) === containerKey)
83
+ .map((p) => toSchemaProperty(p, ctx))
84
+
85
+ const type: SchemaContentType = {
86
+ key: aggregate.key,
87
+ alias: aggregate.alias,
88
+ name: aggregate.name,
89
+ icon: aggregate.icon,
90
+ allowAtRoot: aggregate.allowedAsRoot,
91
+ isElement: aggregate.isElement,
92
+ allowInLibrary: aggregate.allowedInLibrary,
93
+ variesByCulture: aggregate.variesByCulture,
94
+ variesBySegment: aggregate.variesBySegment,
95
+ compositions: aggregate.compositions
96
+ .map((c) => ctx.contentTypeAlias(c.contentTypeKey))
97
+ .filter((alias): alias is string => alias !== undefined),
98
+ allowChildren: [...aggregate.allowedContentTypes]
99
+ .sort(byOrder)
100
+ .map((c) => ctx.contentTypeAlias(c.contentTypeKey))
101
+ .filter((alias): alias is string => alias !== undefined),
102
+ components: aggregate.allowedComponentKeys
103
+ .map((key) => ctx.componentAlias(key))
104
+ .filter((alias): alias is string => alias !== undefined),
105
+ cleanup: { prevent: aggregate.cleanup.preventCleanup },
106
+ properties: under(null),
107
+ tabs: [],
108
+ }
109
+ if (aggregate.description) type.description = aggregate.description
110
+ if (aggregate.defaultComponentKey) {
111
+ const alias = ctx.componentAlias(aggregate.defaultComponentKey)
112
+ if (alias) type.defaultComponent = alias
113
+ }
114
+ if (aggregate.collectionKey) {
115
+ const alias = ctx.dataTypeAlias(aggregate.collectionKey)
116
+ if (alias) type.collection = alias
117
+ }
118
+ if (aggregate.cleanup.keepAllVersionsNewerThanDays !== null)
119
+ type.cleanup.keepAllNewerThanDays = aggregate.cleanup.keepAllVersionsNewerThanDays
120
+ if (aggregate.cleanup.keepLatestVersionPerDayForDays !== null)
121
+ type.cleanup.keepLatestPerDayForDays = aggregate.cleanup.keepLatestVersionPerDayForDays
122
+
123
+ // Umbraco allows a group without a tab: that is a top-level `[[group]]`.
124
+ const tabs = containers.filter((c) => c.type === 'Tab')
125
+ const orphanGroups = containers.filter((c) => c.type === 'Group' && !c.parentKey)
126
+ for (const tab of tabs) {
127
+ const groups = containers.filter((c) => c.type === 'Group' && c.parentKey === tab.key)
128
+ const entry: SchemaContentType['tabs'][number] = {
129
+ name: tab.name ?? '',
130
+ properties: under(tab.key),
131
+ groups: groups.map((g) => {
132
+ const group: SchemaContentType['tabs'][number]['groups'][number] = {
133
+ name: g.name ?? '',
134
+ properties: under(g.key),
135
+ }
136
+ if (g.alias) group.alias = g.alias
137
+ return group
138
+ }),
139
+ }
140
+ if (tab.alias) entry.alias = tab.alias
141
+ type.tabs.push(entry)
142
+ }
143
+ if (orphanGroups.length > 0) {
144
+ type.groups = orphanGroups.map((g) => {
145
+ const group: NonNullable<SchemaContentType['groups']>[number] = {
146
+ name: g.name ?? '',
147
+ properties: under(g.key),
148
+ }
149
+ if (g.alias) group.alias = g.alias
150
+ return group
151
+ })
152
+ }
153
+ return type
154
+ }
155
+
156
+ export function toSchemaDataType(model: DataTypeModel, alias: string): SchemaDataType {
157
+ const dataType: SchemaDataType = {
158
+ key: model.key,
159
+ alias,
160
+ name: model.name,
161
+ editor: model.editorAlias,
162
+ config: Object.fromEntries(model.values.map((v) => [v.alias, v.value])),
163
+ }
164
+ if (model.editorUiAlias) dataType.editorUi = model.editorUiAlias
165
+ return dataType
166
+ }
167
+
168
+ /** A data type created in the backoffice has no alias; derive one from its name. */
169
+ export function aliasFromName(name: string): string {
170
+ const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
171
+ const camel = words
172
+ .map((w, i) =>
173
+ i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1),
174
+ )
175
+ .join('')
176
+ return /^[A-Za-z]/.test(camel) ? camel : `dataType${camel}`
177
+ }
178
+
179
+ /** A built-in needs no file unless the site changed its editor or configuration. */
180
+ function isUnchangedBuiltin(dataType: SchemaDataType): boolean {
181
+ const builtin = BUILTIN_DATA_TYPES.find((b) => b.alias === dataType.alias)
182
+ if (!builtin) return false
183
+ const config = (c: Record<string, unknown>) =>
184
+ JSON.stringify(Object.fromEntries(Object.entries(c).sort(([a], [b]) => a.localeCompare(b))))
185
+ return builtin.editor === dataType.editor && config(builtin.config) === config(dataType.config)
186
+ }
187
+
188
+ /** Every live definition in the database as a schema set; keys are always present. */
189
+ export async function exportSchemaSet(db: Db, version: string): Promise<SchemaSet> {
190
+ const types = new ContentTypeRepository(db)
191
+ const dataTypes = new DataTypeRepository(db)
192
+ const templates = new ComponentRepository(db)
193
+
194
+ const mediaTypeRepo = new ContentTypeRepository(db, { kind: 'media' })
195
+ const liveTypes = async (repo: ContentTypeRepository) => {
196
+ const rows = await db.query<{ unique_id: string }>(
197
+ `SELECT n.unique_id FROM content_type ct JOIN node n ON n.id = ct.node_id
198
+ WHERE ct.retired_at IS NULL AND n.node_object_type = ? ORDER BY ct.alias`,
199
+ [repo.objectType],
200
+ )
201
+ const out: ContentTypeAggregate[] = []
202
+ for (const row of rows) {
203
+ const aggregate = await repo.byKey(String(row.unique_id))
204
+ if (aggregate) out.push(aggregate)
205
+ }
206
+ return out
207
+ }
208
+ const aggregates = await liveTypes(types)
209
+ const mediaAggregates = await liveTypes(mediaTypeRepo)
210
+ const memberAggregates = await liveTypes(new ContentTypeRepository(db, { kind: 'member' }))
211
+
212
+ const dataTypeRows = await db.query<{ unique_id: string }>(
213
+ 'SELECT n.unique_id FROM data_type d JOIN node n ON n.id = d.node_id ORDER BY n.text',
214
+ )
215
+ const dataTypeAliasByKey = new Map<string, string>()
216
+ const exportedDataTypes: SchemaDataType[] = []
217
+ const taken = new Set<string>()
218
+ for (const row of dataTypeRows) {
219
+ const model = await dataTypes.byKey(String(row.unique_id))
220
+ if (!model) continue
221
+ let alias = model.alias
222
+ if (!alias) {
223
+ alias = aliasFromName(model.name)
224
+ while (taken.has(alias)) alias = `${alias}2`
225
+ // Recorded so the next export and the sync agree on the name.
226
+ await dataTypes.save({ ...model, alias })
227
+ }
228
+ taken.add(alias)
229
+ dataTypeAliasByKey.set(model.key, alias)
230
+ const dataType = toSchemaDataType(model, alias)
231
+ if (!isUnchangedBuiltin(dataType)) exportedDataTypes.push(dataType)
232
+ }
233
+
234
+ const templateAliasByKey = new Map<string, string>()
235
+ const templatePage = await templates.list(0, 10_000)
236
+ for (const template of templatePage.items) templateAliasByKey.set(template.key, template.alias)
237
+ const contentTypeAliasByKey = new Map(
238
+ [...aggregates, ...mediaAggregates, ...memberAggregates].map((a) => [a.key, a.alias]),
239
+ )
240
+
241
+ const typeFolders = new FolderRepository(
242
+ db,
243
+ ObjectTypes.DocumentTypeContainer,
244
+ ObjectTypes.DocumentType,
245
+ )
246
+ const dataTypeFolders = new FolderRepository(
247
+ db,
248
+ ObjectTypes.DataTypeContainer,
249
+ ObjectTypes.DataType,
250
+ )
251
+ const folderOf = async (repo: FolderRepository, key: string) => {
252
+ const path = await repo.pathOf(key)
253
+ return path.length > 0 ? path.join('/') : undefined
254
+ }
255
+ for (const d of exportedDataTypes) {
256
+ const folder = await folderOf(dataTypeFolders, d.key as string)
257
+ if (folder) d.folder = folder
258
+ }
259
+ const ctx: ExportContext = {
260
+ dataTypeAlias: (key) => dataTypeAliasByKey.get(key.toLowerCase()),
261
+ contentTypeAlias: (key) => contentTypeAliasByKey.get(key.toLowerCase()),
262
+ componentAlias: (key) => templateAliasByKey.get(key.toLowerCase()),
263
+ }
264
+ const languages: SchemaLanguage[] = (await new LanguageRepository(db).all()).map((l) => {
265
+ const language: SchemaLanguage = {
266
+ iso: l.isoCode,
267
+ name: l.cultureName,
268
+ default: l.isDefault,
269
+ mandatory: l.isMandatory,
270
+ }
271
+ if (l.fallbackIsoCode) language.fallback = l.fallbackIsoCode
272
+ return language
273
+ })
274
+ const contentTypes: SchemaContentType[] = []
275
+ for (const a of aggregates) {
276
+ const type = toSchemaContentType(a, ctx)
277
+ const folder = await folderOf(typeFolders, a.key)
278
+ if (folder) type.folder = folder
279
+ // A system type needs a file only once a site has extended it.
280
+ const shipped = SYSTEM_CONTENT_TYPES.find((t) => t.key === a.key)
281
+ if (shipped && writeContentType(type) === writeContentType(builtInContentTypeSchema(shipped)))
282
+ continue
283
+ contentTypes.push(type)
284
+ }
285
+ const mediaTypeFolders = new FolderRepository(
286
+ db,
287
+ ObjectTypes.MediaTypeContainer,
288
+ ObjectTypes.MediaType,
289
+ )
290
+ const mediaTypes: SchemaContentType[] = []
291
+ for (const a of mediaAggregates) {
292
+ // Media types carry neither templates nor a cleanup policy.
293
+ const type = toSchemaContentType(
294
+ { ...a, allowedComponentKeys: [], defaultComponentKey: null },
295
+ ctx,
296
+ )
297
+ type.cleanup = { prevent: false }
298
+ const folder = await folderOf(mediaTypeFolders, a.key)
299
+ if (folder) type.folder = folder
300
+ const shipped = SYSTEM_MEDIA_TYPES.find((t) => t.key === a.key)
301
+ if (shipped) {
302
+ const present = new Set(mediaAggregates.map((m) => m.alias))
303
+ if (writeMediaType(type) === writeMediaType(builtInMediaTypeSchema(shipped, present)))
304
+ continue
305
+ }
306
+ mediaTypes.push(type)
307
+ }
308
+ const memberTypeFolders = new FolderRepository(
309
+ db,
310
+ ObjectTypes.MemberTypeContainer,
311
+ ObjectTypes.MemberType,
312
+ )
313
+ const memberTypes: SchemaContentType[] = []
314
+ for (const a of memberAggregates) {
315
+ // Member types carry neither templates, a cleanup policy, nor children.
316
+ const type = toSchemaContentType(
317
+ { ...a, allowedComponentKeys: [], defaultComponentKey: null, allowedContentTypes: [] },
318
+ ctx,
319
+ )
320
+ type.cleanup = { prevent: false }
321
+ const folder = await folderOf(memberTypeFolders, a.key)
322
+ if (folder) type.folder = folder
323
+ memberTypes.push(type)
324
+ }
325
+ return {
326
+ version,
327
+ contentTypes,
328
+ mediaTypes,
329
+ memberTypes,
330
+ dataTypes: exportedDataTypes,
331
+ languages,
332
+ }
333
+ }
334
+
335
+ /** Writes a set as canonical files; returns the paths written. Never deletes. */
336
+ export function writeSchemaFiles(schemaDir: string, set: SchemaSet): string[] {
337
+ const written: string[] = []
338
+ const put = (relative: string, content: string) => {
339
+ const file = join(schemaDir, relative)
340
+ mkdirSync(join(file, '..'), { recursive: true })
341
+ writeFileSync(file, content)
342
+ written.push(file)
343
+ }
344
+ put('schema.toml', writeSchemaVersion(set.version))
345
+ for (const t of set.contentTypes)
346
+ put(join(TYPE_KIND_FILES.document.dir, fileNameFor(t.alias)), writeContentType(t))
347
+ for (const t of set.mediaTypes ?? [])
348
+ put(join('media-types', fileNameFor(t.alias)), writeMediaType(t))
349
+ for (const t of set.memberTypes ?? [])
350
+ put(join('member-types', fileNameFor(t.alias)), writeMemberType(t))
351
+ for (const d of set.dataTypes) put(join('data-types', fileNameFor(d.alias)), writeDataType(d))
352
+ if (set.languages.length > 0) put('languages.toml', writeLanguages(set.languages))
353
+ return written
354
+ }
355
+
356
+ /** Rewrites, canonically, every file a sync assigned keys in. */
357
+ export function writeKeysBack(loaded: LoadedSchema, assigned: readonly AssignedKey[]): string[] {
358
+ const touched = new Set<string>()
359
+ for (const a of assigned) {
360
+ const allTypes = [
361
+ ...loaded.set.contentTypes,
362
+ ...(loaded.set.mediaTypes ?? []),
363
+ ...(loaded.set.memberTypes ?? []),
364
+ ]
365
+ if (a.propertyAlias === undefined) {
366
+ const type = allTypes.find((t) => t.alias === a.typeAlias)
367
+ if (type) type.key = a.key
368
+ const dataType = loaded.set.dataTypes.find((d) => d.alias === a.typeAlias)
369
+ if (dataType) dataType.key = a.key
370
+ } else {
371
+ const type = allTypes.find((t) => t.alias === a.typeAlias)
372
+ const property = type && allPropertiesOf(type).find((p) => p.alias === a.propertyAlias)
373
+ if (property) property.key = a.key
374
+ }
375
+ if (a.file) touched.add(a.file)
376
+ }
377
+ const written: string[] = []
378
+ for (const file of touched) {
379
+ const type = loaded.set.contentTypes.find((t) => loaded.files.get(t.alias) === file)
380
+ if (type) {
381
+ writeFileSync(file, writeContentType(type))
382
+ written.push(file)
383
+ continue
384
+ }
385
+ const mediaType = (loaded.set.mediaTypes ?? []).find(
386
+ (t) => loaded.files.get(typeFileKey('media', t.alias)) === file,
387
+ )
388
+ if (mediaType) {
389
+ writeFileSync(file, writeMediaType(mediaType))
390
+ written.push(file)
391
+ continue
392
+ }
393
+ const memberType = (loaded.set.memberTypes ?? []).find(
394
+ (t) => loaded.files.get(typeFileKey('member', t.alias)) === file,
395
+ )
396
+ if (memberType) {
397
+ writeFileSync(file, writeMemberType(memberType))
398
+ written.push(file)
399
+ continue
400
+ }
401
+ const dataType = loaded.set.dataTypes.find(
402
+ (d) => loaded.files.get(`data-type:${d.alias}`) === file,
403
+ )
404
+ if (dataType) {
405
+ writeFileSync(file, writeDataType(dataType))
406
+ written.push(file)
407
+ }
408
+ }
409
+ return written
410
+ }
411
+
412
+ function allPropertiesOf(type: SchemaContentType): SchemaProperty[] {
413
+ const out = [...type.properties]
414
+ for (const tab of type.tabs) {
415
+ out.push(...tab.properties)
416
+ for (const group of tab.groups) out.push(...group.properties)
417
+ }
418
+ return out
419
+ }