@crombolo/data 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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/bundle-tables/builder.ts +264 -0
  5. package/src/bundle-tables/capability.ts +377 -0
  6. package/src/bundle-tables/chain.ts +105 -0
  7. package/src/bundle-tables/ddl.ts +124 -0
  8. package/src/bundle-tables/guard.ts +116 -0
  9. package/src/bundle-tables/index.ts +7 -0
  10. package/src/bundle-tables/names.ts +58 -0
  11. package/src/bundle-tables/schema.ts +485 -0
  12. package/src/change-report.ts +166 -0
  13. package/src/cluster.ts +108 -0
  14. package/src/database.ts +121 -0
  15. package/src/dialect.ts +136 -0
  16. package/src/index.ts +63 -0
  17. package/src/locks.ts +103 -0
  18. package/src/maintenance.ts +51 -0
  19. package/src/migrations.ts +547 -0
  20. package/src/node-ref.ts +107 -0
  21. package/src/pending.ts +25 -0
  22. package/src/repositories/bundles.ts +223 -0
  23. package/src/repositories/content-types.ts +831 -0
  24. package/src/repositories/data-types.ts +222 -0
  25. package/src/repositories/dictionary.ts +259 -0
  26. package/src/repositories/documents.ts +2389 -0
  27. package/src/repositories/domains.ts +108 -0
  28. package/src/repositories/folders.ts +120 -0
  29. package/src/repositories/form-entries.ts +314 -0
  30. package/src/repositories/form-workflows.ts +213 -0
  31. package/src/repositories/languages.ts +108 -0
  32. package/src/repositories/log-searches.ts +33 -0
  33. package/src/repositories/members.ts +427 -0
  34. package/src/repositories/nodes.ts +361 -0
  35. package/src/repositories/notifications.ts +54 -0
  36. package/src/repositories/public-access.ts +187 -0
  37. package/src/repositories/redirects.ts +274 -0
  38. package/src/repositories/references.ts +162 -0
  39. package/src/repositories/tags.ts +93 -0
  40. package/src/repositories/templates.ts +114 -0
  41. package/src/repositories/transfer-runs.ts +208 -0
  42. package/src/repositories/user-data.ts +90 -0
  43. package/src/repositories/users.ts +898 -0
  44. package/src/schema/assistant.ts +74 -0
  45. package/src/schema/built-in-content-types.ts +106 -0
  46. package/src/schema/built-in-media-types.ts +335 -0
  47. package/src/schema/built-in-member-types.ts +61 -0
  48. package/src/schema/bundle-components.ts +49 -0
  49. package/src/schema/bundle-package.ts +27 -0
  50. package/src/schema/bundles-rename.ts +32 -0
  51. package/src/schema/change-report.ts +46 -0
  52. package/src/schema/client-id-prefix.ts +43 -0
  53. package/src/schema/content-editing.ts +50 -0
  54. package/src/schema/content-seed.ts +586 -0
  55. package/src/schema/content.ts +263 -0
  56. package/src/schema/created-packages.ts +56 -0
  57. package/src/schema/elements.ts +62 -0
  58. package/src/schema/form-permission-prefix.ts +50 -0
  59. package/src/schema/form-permissions.ts +26 -0
  60. package/src/schema/form-workflows.ts +54 -0
  61. package/src/schema/forms.ts +63 -0
  62. package/src/schema/group-permissions.ts +20 -0
  63. package/src/schema/identity.ts +176 -0
  64. package/src/schema/log-viewer.ts +68 -0
  65. package/src/schema/member-property-type.ts +30 -0
  66. package/src/schema/members.ts +97 -0
  67. package/src/schema/plan.ts +77 -0
  68. package/src/schema/public-access.ts +56 -0
  69. package/src/schema/recycle-bin.ts +21 -0
  70. package/src/schema/redirects.ts +57 -0
  71. package/src/schema/rich-text-ui.ts +34 -0
  72. package/src/schema/seed.ts +298 -0
  73. package/src/schema/server-role.ts +21 -0
  74. package/src/schema/since-version.ts +26 -0
  75. package/src/schema/transfer-run-package.ts +27 -0
  76. package/src/schema/transfer-runs.ts +71 -0
  77. package/src/schema/upgrade-report.ts +43 -0
  78. package/src/schema/users-localisation.ts +85 -0
  79. package/src/schema/values.ts +108 -0
  80. package/src/schema-ops.ts +35 -0
  81. package/src/schema-state.ts +196 -0
  82. package/README.md +0 -3
@@ -0,0 +1,2389 @@
1
+ /**
2
+ * Documents, versions and publishing on the append-only value model.
3
+ *
4
+ * A value never changes in place: every change appends a `property_value` row
5
+ * tagged with the event that wrote it and the schema state the writer was at.
6
+ * `content_version` rows are events (save | publish | rollback | migrate).
7
+ * Publish is an event and a pointer, not a copy; rollback appends the old
8
+ * values under a new event. See docs/data-model.md.
9
+ */
10
+ import {
11
+ type DocumentAggregate,
12
+ type DocumentTreeItem,
13
+ type DocumentValidationError,
14
+ type DocumentValue,
15
+ type DocumentVariant,
16
+ type DocumentVersionSummary,
17
+ variesByCulture as flagVariesByCulture,
18
+ normaliseUuid,
19
+ ObjectTypes,
20
+ type Page,
21
+ STORAGE_COLUMN,
22
+ SystemNodes,
23
+ toEditorValue,
24
+ type ValueStorageTypeName,
25
+ type VariantState,
26
+ } from '@crombolo/core'
27
+ import { appendCacheInstruction } from '../cluster.ts'
28
+ import type { Db } from '../database.ts'
29
+ import { DbDate, fromDbBool } from '../dialect.ts'
30
+ import { Locks } from '../locks.ts'
31
+ import { isPending } from '../pending.ts'
32
+ import {
33
+ assertNodeMayWrite,
34
+ BASELINE_NODE_STATE,
35
+ type NodeSchemaState,
36
+ type SchemaStateRow,
37
+ visibleStateIdFor,
38
+ } from '../schema-state.ts'
39
+ import { NodeRepository, type NodeRow } from './nodes.ts'
40
+
41
+ export type EventKind = 'save' | 'publish' | 'rollback' | 'migrate'
42
+
43
+ interface PropertyTypeRow {
44
+ id: number
45
+ alias: string
46
+ name: string
47
+ variesByCulture: boolean
48
+ storage: ValueStorageTypeName
49
+ editorAlias: string
50
+ /** The data type's configuration, for converters that depend on it. */
51
+ config: Record<string, unknown>
52
+ mandatory: boolean
53
+ mandatoryMessage: string | null
54
+ regEx: string | null
55
+ regExMessage: string | null
56
+ /** Not yet live on this node: editable, never validated, never rendered. */
57
+ pending: boolean
58
+ }
59
+
60
+ interface LanguageRow {
61
+ id: number
62
+ isoCode: string
63
+ isDefault: boolean
64
+ isMandatory: boolean
65
+ }
66
+
67
+ export interface CurrentValue {
68
+ rowId: number
69
+ eventId: number
70
+ schemaStateId: number
71
+ alias: string
72
+ culture: string | null
73
+ segment: string | null
74
+ value: unknown
75
+ }
76
+
77
+ export interface MigratedValue {
78
+ alias: string
79
+ culture: string | null
80
+ segment: string | null
81
+ value: unknown
82
+ /** The event to file the value under; defaults to the row it supersedes. */
83
+ eventId?: number
84
+ /** The storage column to write, when the value is in a new editor's format ahead of the cut-over. */
85
+ storage?: ValueStorageTypeName
86
+ }
87
+
88
+ interface ValueRow {
89
+ eventId: number
90
+ id: number
91
+ propertyTypeId: number
92
+ languageId: number | null
93
+ segment: string | null
94
+ raw: unknown
95
+ }
96
+
97
+ export interface SaveDocumentInput {
98
+ key: string
99
+ contentTypeKey: string
100
+ componentKey: string | null
101
+ parentKey: string | null
102
+ values: DocumentValue[]
103
+ variants: Array<{ culture: string | null; segment: string | null; name: string }>
104
+ userId?: number
105
+ }
106
+
107
+ /** A published document plus the values of its published version. */
108
+ export interface PublishedNodeRow {
109
+ id: number
110
+ key: string
111
+ parentId: number
112
+ level: number
113
+ path: string
114
+ sortOrder: number
115
+ name: string
116
+ contentTypeAlias: string
117
+ componentAlias: string | null
118
+ createDate: Date
119
+ updateDate: Date
120
+ cultures: string[]
121
+ /** Each published culture's name, as it was published. */
122
+ names: Record<string, string>
123
+ values: DocumentValue[]
124
+ }
125
+
126
+ /**
127
+ * What a repository holds. Media, members and blueprints are stored exactly as
128
+ * documents are (node, content, versions, values); they differ in object type,
129
+ * recycle bin, and in never being published.
130
+ */
131
+ export type ContentKind = 'document' | 'media' | 'member' | 'blueprint' | 'element'
132
+
133
+ const KINDS: Record<
134
+ ContentKind,
135
+ { objectType: string; recycleBin: number | undefined; container: string | undefined }
136
+ > = {
137
+ document: {
138
+ objectType: ObjectTypes.Document,
139
+ recycleBin: SystemNodes.ContentRecycleBin,
140
+ container: undefined,
141
+ },
142
+ media: {
143
+ objectType: ObjectTypes.Media,
144
+ recycleBin: SystemNodes.MediaRecycleBin,
145
+ container: undefined,
146
+ },
147
+ // Members are a flat list at the tree root: no bin to trash them into, and
148
+ // no folders to gather them in.
149
+ member: {
150
+ objectType: ObjectTypes.Member,
151
+ recycleBin: undefined,
152
+ container: undefined,
153
+ },
154
+ blueprint: {
155
+ objectType: ObjectTypes.DocumentBlueprint,
156
+ recycleBin: undefined,
157
+ container: ObjectTypes.DocumentBlueprintContainer,
158
+ },
159
+ // Umbraco 18's Elements: publishable content with no URL, gathered in folders
160
+ // and trashed into a bin of its own at -22, as its Constants.System says.
161
+ element: {
162
+ objectType: ObjectTypes.Element,
163
+ recycleBin: SystemNodes.ElementRecycleBin,
164
+ container: ObjectTypes.ElementContainer,
165
+ },
166
+ }
167
+
168
+ /**
169
+ * A step every save runs on its values before they are stored, knowing each
170
+ * property's editor: where uploaded files are placed and file facts filled in.
171
+ */
172
+ export type ValueIntake = (
173
+ values: readonly DocumentValue[],
174
+ propertyTypes: ReadonlyArray<{ alias: string; editorAlias: string }>,
175
+ ) => Promise<DocumentValue[]>
176
+
177
+ export interface DocumentRepositoryOptions {
178
+ /** The schema state this process runs at; writes are refused if the database is ahead. */
179
+ nodeState?: NodeSchemaState
180
+ /** Stamped on the cache instructions this repository appends. */
181
+ nodeId?: string
182
+ kind?: ContentKind
183
+ valueIntake?: ValueIntake
184
+ /**
185
+ * Set when the caller already holds the content-tree lock, so the writes
186
+ * below must not take it again.
187
+ *
188
+ * Neither lock manager survives re-entry: the SQLite one is a promise queue
189
+ * that would wait on itself for ever, and the Postgres one acquires inside
190
+ * its own `transaction`. Transactions *do* nest — both implementations join
191
+ * an enclosing one — so this flag is only ever about the lock.
192
+ *
193
+ * It exists for an operation that spans many documents and has to be atomic
194
+ * across all of them, like importing a content bundle: one lock and one
195
+ * transaction for the run, rather than one of each per node.
196
+ */
197
+ inTreeLock?: boolean
198
+ }
199
+
200
+ /** Thrown when a publish is refused by a business rule rather than failing. */
201
+ export class PublishBlockedError extends Error {}
202
+
203
+ const isEmpty = (value: unknown) =>
204
+ value === null || value === undefined || (typeof value === 'string' && value.trim() === '')
205
+
206
+ /**
207
+ * Mandatory and pattern rules over a set of values. Invariant properties are
208
+ * checked once; culture-varying ones once per culture given. Pending
209
+ * properties are never checked.
210
+ */
211
+ function validateValues(
212
+ propertyTypes: readonly PropertyTypeRow[],
213
+ values: readonly DocumentValue[],
214
+ cultures: readonly string[],
215
+ ): DocumentValidationError[] {
216
+ const errors: DocumentValidationError[] = []
217
+ for (const type of propertyTypes) {
218
+ if (type.pending) continue
219
+ const targets: Array<string | null> = type.variesByCulture ? [...cultures] : [null]
220
+ if (type.variesByCulture && targets.length === 0) continue
221
+ for (const culture of targets) {
222
+ const value = values.find(
223
+ (v) =>
224
+ v.alias === type.alias &&
225
+ (type.variesByCulture ? v.culture === culture : true) &&
226
+ (v.segment ?? null) === null,
227
+ )
228
+ const messages: string[] = []
229
+ if (type.mandatory && (!value || isEmpty(value.value)))
230
+ messages.push(type.mandatoryMessage || 'Value cannot be empty')
231
+ if (type.regEx && value && !isEmpty(value.value) && typeof value.value === 'string') {
232
+ let matches = true
233
+ try {
234
+ matches = new RegExp(type.regEx).test(value.value)
235
+ } catch {
236
+ matches = true
237
+ }
238
+ if (!matches)
239
+ messages.push(
240
+ type.regExMessage || 'Value is invalid, it does not match the correct pattern',
241
+ )
242
+ }
243
+ if (messages.length > 0) errors.push({ alias: type.alias, culture, segment: null, messages })
244
+ }
245
+ }
246
+ return errors
247
+ }
248
+
249
+ const keyOf = (propertyTypeId: number, languageId: number | null, segment: string | null) =>
250
+ `${propertyTypeId}|${languageId ?? ''}|${segment ?? ''}`
251
+
252
+ export class DocumentRepository {
253
+ #db: Db
254
+ #nodes: NodeRepository
255
+ #nodeState: NodeSchemaState
256
+ #nodeId: string | undefined
257
+ readonly kind: ContentKind
258
+ readonly objectType: string
259
+ #recycleBin: number | undefined
260
+ #intake: ValueIntake | undefined
261
+ #inTreeLock: boolean
262
+
263
+ constructor(db: Db, options: DocumentRepositoryOptions = {}) {
264
+ this.#db = db
265
+ this.#nodes = new NodeRepository(db)
266
+ this.#nodeState = options.nodeState ?? BASELINE_NODE_STATE
267
+ this.#nodeId = options.nodeId
268
+ this.kind = options.kind ?? 'document'
269
+ this.objectType = KINDS[this.kind].objectType
270
+ this.#recycleBin = KINDS[this.kind].recycleBin
271
+ this.#intake = options.valueIntake
272
+ this.#inTreeLock = options.inTreeLock ?? false
273
+ }
274
+
275
+ get nodes(): NodeRepository {
276
+ return this.#nodes
277
+ }
278
+
279
+ /**
280
+ * The content-tree lock, unless the caller already holds it. Every write that
281
+ * depends on sibling order or on a branch not moving under it goes through
282
+ * here rather than taking the lock directly, so `inTreeLock` is honoured in
283
+ * one place instead of six.
284
+ */
285
+ #withTree<T>(fn: () => Promise<T>): Promise<T> {
286
+ return this.#inTreeLock ? fn() : this.#db.locks.withLock(Locks.ContentTree, fn)
287
+ }
288
+
289
+ #child(tx: Db): DocumentRepository {
290
+ return new DocumentRepository(tx, {
291
+ nodeState: this.#nodeState,
292
+ nodeId: this.#nodeId,
293
+ kind: this.kind,
294
+ valueIntake: this.#intake,
295
+ // A child runs inside whatever its parent took.
296
+ inTreeLock: true,
297
+ })
298
+ }
299
+
300
+ // ---------------------------------------------------------------- reads
301
+
302
+ async byKey(key: string): Promise<DocumentAggregate | undefined> {
303
+ const node = await this.#nodes.byKey(key)
304
+ if (!node || node.objectType !== this.objectType) return undefined
305
+ return this.#load(node)
306
+ }
307
+
308
+ async load(node: NodeRow): Promise<DocumentAggregate | undefined> {
309
+ return this.#load(node)
310
+ }
311
+
312
+ async children(parentKey: string | null, skip: number, take: number): Promise<Page<NodeRow>> {
313
+ const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
314
+ if (parentId === undefined) return { total: 0, items: [] }
315
+ return this.#nodes.children(parentId, this.objectType, skip, take)
316
+ }
317
+
318
+ /** The recycle bin's tree: trashed documents, top level being what was trashed directly. */
319
+ async trashed(parentKey: string | null, skip: number, take: number): Promise<Page<NodeRow>> {
320
+ const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : this.#recycleBin
321
+ if (parentId === undefined) return { total: 0, items: [] }
322
+ return this.#nodes.children(parentId, this.objectType, skip, take, true)
323
+ }
324
+
325
+ siblings(key: string, before: number, after: number) {
326
+ return this.#nodes.siblings(key, this.objectType, before, after)
327
+ }
328
+
329
+ async search(
330
+ query: string,
331
+ options: { trashed?: boolean; parentKey?: string | null; skip: number; take: number },
332
+ ): Promise<Page<NodeRow>> {
333
+ const parentId = options.parentKey
334
+ ? (await this.#nodes.byKey(options.parentKey))?.id
335
+ : undefined
336
+ if (options.parentKey && parentId === undefined) return { total: 0, items: [] }
337
+ return this.#nodes.search(this.objectType, query, { ...options, parentId })
338
+ }
339
+
340
+ async versions(key: string): Promise<DocumentVersionSummary[]> {
341
+ const node = await this.#nodes.byKey(key)
342
+ if (!node) return []
343
+ const rows = await this.#db.query(
344
+ `SELECT cv.id, cv.kind, cv.version_date, cv.current, cv.prevent_cleanup, dv.published, u.key AS user_key
345
+ FROM content_version cv
346
+ JOIN document_version dv ON dv.id = cv.id
347
+ LEFT JOIN user_account u ON u.id = cv.user_id
348
+ WHERE cv.node_id = ?
349
+ ORDER BY cv.id DESC`,
350
+ [node.id],
351
+ )
352
+ return rows.map((row) => ({
353
+ id: String(row.id),
354
+ documentKey: node.key,
355
+ versionDate: DbDate.fromDb(row.version_date) ?? new Date(0),
356
+ isCurrentDraft: fromDbBool(row.current),
357
+ isCurrentPublished: fromDbBool(row.published),
358
+ preventCleanup: fromDbBool(row.prevent_cleanup),
359
+ userKey: row.user_key ? normaliseUuid(String(row.user_key)) : null,
360
+ culture: null,
361
+ kind: String(row.kind) as EventKind,
362
+ }))
363
+ }
364
+
365
+ /**
366
+ * Every published document with the values of its published version, for the
367
+ * published cache. A node reads as-of its own schema state, so a conversion
368
+ * appended by a newer deployment is invisible to it.
369
+ */
370
+ /** Every published node of this kind, for the published cache. Elements too. */
371
+ async loadPublished(asOfSchemaStateId?: number): Promise<PublishedNodeRow[]> {
372
+ const t = this.#db.dialect.boolValue(true)
373
+ const f = this.#db.dialect.boolValue(false)
374
+ const rows = await this.#db.query(
375
+ `SELECT n.id, n.unique_id, n.parent_id, n.level, n.path, n.sort_order, n.create_date,
376
+ cv.id AS event_id, cv.text AS name, cv.version_date,
377
+ ct.alias AS content_type_alias, c.content_type_id, tpl.alias AS template_alias
378
+ FROM node n
379
+ JOIN content c ON c.node_id = n.id
380
+ JOIN content_type ct ON ct.node_id = c.content_type_id
381
+ JOIN document d ON d.node_id = n.id
382
+ JOIN content_version cv ON cv.node_id = n.id
383
+ JOIN document_version dv ON dv.id = cv.id AND dv.published = ?
384
+ LEFT JOIN template tpl ON tpl.node_id = dv.template_id
385
+ WHERE d.published = ? AND n.trashed = ? AND n.node_object_type = ?
386
+ ORDER BY n.level, n.sort_order, n.id`,
387
+ [t, t, f, this.objectType],
388
+ )
389
+ const languages = await this.#languages()
390
+ const published: PublishedNodeRow[] = []
391
+ for (const row of rows) {
392
+ // A pending property is never rendered, even if a template asks for it.
393
+ const propertyTypes = (await this.#propertyTypes(Number(row.content_type_id))).filter(
394
+ (p) => !p.pending,
395
+ )
396
+ const cultures = await this.#publishedCultureEvents(Number(row.id))
397
+ const values = await this.#readPublishedValues(
398
+ Number(row.id),
399
+ propertyTypes,
400
+ languages,
401
+ Number(row.event_id),
402
+ cultures,
403
+ asOfSchemaStateId,
404
+ )
405
+ published.push({
406
+ id: Number(row.id),
407
+ key: normaliseUuid(String(row.unique_id)),
408
+ parentId: Number(row.parent_id),
409
+ level: Number(row.level),
410
+ path: String(row.path),
411
+ sortOrder: Number(row.sort_order),
412
+ name: String(row.name ?? ''),
413
+ contentTypeAlias: String(row.content_type_alias),
414
+ componentAlias: (row.template_alias as string | null) ?? null,
415
+ createDate: DbDate.fromDb(row.create_date) ?? new Date(),
416
+ updateDate: DbDate.fromDb(row.version_date) ?? new Date(),
417
+ cultures: cultures.map((c) => c.isoCode),
418
+ names: Object.fromEntries(cultures.map((c) => [c.isoCode, c.name])),
419
+ values,
420
+ })
421
+ }
422
+ return published
423
+ }
424
+
425
+ // --------------------------------------------------------------- writes
426
+
427
+ /** Creates a document with its first save event. Under the content-tree lock so sibling sort orders cannot race. */
428
+ async create(input: SaveDocumentInput): Promise<DocumentAggregate> {
429
+ return this.#withTree(async () =>
430
+ this.#db.transaction(async (tx) => {
431
+ const repo = this.#child(tx)
432
+ const state = await assertNodeMayWrite(tx, this.#nodeState)
433
+ const contentTypeNode = await repo.nodes.byKey(input.contentTypeKey)
434
+ if (!contentTypeNode) throw new Error(`Unknown content type '${input.contentTypeKey}'.`)
435
+
436
+ const parentId = input.parentKey
437
+ ? ((await repo.nodes.byKey(input.parentKey))?.id ?? SystemNodes.Root)
438
+ : SystemNodes.Root
439
+ const name = input.variants[0]?.name ?? ''
440
+ const node = await repo.nodes.create({
441
+ key: input.key,
442
+ parentId,
443
+ objectType: this.objectType,
444
+ text: name,
445
+ userId: input.userId,
446
+ })
447
+
448
+ await tx.exec('INSERT INTO content (node_id, content_type_id) VALUES (?, ?)', [
449
+ node.id,
450
+ contentTypeNode.id,
451
+ ])
452
+ await tx.exec('INSERT INTO document (node_id, published, edited) VALUES (?, ?, ?)', [
453
+ node.id,
454
+ tx.dialect.boolValue(false),
455
+ tx.dialect.boolValue(true),
456
+ ])
457
+
458
+ const templateId = input.componentKey
459
+ ? ((await repo.nodes.byKey(input.componentKey))?.id ?? null)
460
+ : null
461
+ const eventId = await repo.#insertEvent(
462
+ node.id,
463
+ 'save',
464
+ name,
465
+ input.userId,
466
+ templateId,
467
+ false,
468
+ )
469
+ await repo.#appendValues(node.id, eventId, state, contentTypeNode.id, input.values)
470
+ await repo.#writeCultureVariations(node.id, eventId, input.variants)
471
+
472
+ const created = await repo.#load({ ...node, text: name })
473
+ if (!created) throw new Error('Document could not be read back after creation.')
474
+ return created
475
+ }),
476
+ )
477
+ }
478
+
479
+ /** Documents of these types (by content type node id), trashed ones excluded. */
480
+ async documentsOfTypes(contentTypeNodeIds: readonly number[]): Promise<
481
+ Array<{
482
+ key: string
483
+ nodeId: number
484
+ name: string
485
+ contentTypeNodeId: number
486
+ published: boolean
487
+ }>
488
+ > {
489
+ if (contentTypeNodeIds.length === 0) return []
490
+ const rows = await this.#db.query(
491
+ `SELECT n.id, n.unique_id, n.text, c.content_type_id, d.published FROM content c
492
+ JOIN node n ON n.id = c.node_id JOIN document d ON d.node_id = n.id
493
+ WHERE c.content_type_id IN (${contentTypeNodeIds.map(() => '?').join(', ')}) AND n.trashed = ?
494
+ ORDER BY n.path`,
495
+ [...contentTypeNodeIds, this.#db.dialect.boolValue(false)],
496
+ )
497
+ return rows.map((row) => ({
498
+ key: normaliseUuid(String(row.unique_id)),
499
+ nodeId: Number(row.id),
500
+ name: String(row.text ?? ''),
501
+ contentTypeNodeId: Number(row.content_type_id),
502
+ published: fromDbBool(row.published),
503
+ }))
504
+ }
505
+
506
+ /**
507
+ * Current values of the named properties, retired ones included: the check
508
+ * and the fix read what the editor no longer shows. Each carries the state
509
+ * that wrote it and the event it belongs to.
510
+ */
511
+ async currentValuesByAlias(nodeId: number, aliases: readonly string[]): Promise<CurrentValue[]> {
512
+ if (aliases.length === 0) return []
513
+ const languages = await this.#languages()
514
+ const isoById = new Map(languages.map((l) => [l.id, l.isoCode]))
515
+ const rows = await this.#db.query(
516
+ `SELECT pv.id, pv.event_id, pv.schema_state_id, pv.language_id, pv.segment, pv.int_value, pv.decimal_value,
517
+ pv.date_value, pv.varchar_value, pv.text_value, p.alias, d.db_type
518
+ FROM property_value pv
519
+ JOIN property_type p ON p.id = pv.property_type_id
520
+ JOIN data_type d ON d.node_id = p.data_type_id
521
+ WHERE pv.node_id = ? AND pv.is_current = ? AND p.alias IN (${aliases.map(() => '?').join(', ')})`,
522
+ [nodeId, this.#db.dialect.boolValue(true), ...aliases],
523
+ )
524
+ const out: CurrentValue[] = []
525
+ for (const row of rows) {
526
+ const storage = String(row.db_type) as ValueStorageTypeName
527
+ const value = this.#fromStorage(
528
+ (row as Record<string, unknown>)[STORAGE_COLUMN[storage]],
529
+ storage,
530
+ )
531
+ out.push({
532
+ rowId: Number(row.id),
533
+ eventId: Number(row.event_id),
534
+ schemaStateId: Number(row.schema_state_id),
535
+ alias: String(row.alias),
536
+ culture: row.language_id === null ? null : (isoById.get(Number(row.language_id)) ?? null),
537
+ segment: (row.segment as string | null) ?? null,
538
+ value,
539
+ })
540
+ }
541
+ return out
542
+ }
543
+
544
+ /**
545
+ * Appends values under an explicit schema state — the upgrade tool's write.
546
+ * Each value joins the event of the row it converts (so a published version
547
+ * keeps reading as one), or the document's published event, or its head.
548
+ * Not gated: the tool runs from the newer artifact by definition.
549
+ */
550
+ async appendUnderState(
551
+ key: string,
552
+ values: readonly MigratedValue[],
553
+ options: { schemaStateId: number; userId?: number },
554
+ ): Promise<boolean> {
555
+ return this.#db.transaction(async (tx) => {
556
+ const repo = this.#child(tx)
557
+ const node = await repo.nodes.byKey(key)
558
+ if (!node) return false
559
+ const contentTypeNodeId = await repo.#contentTypeNodeId(node.id)
560
+ if (!contentTypeNodeId) return false
561
+ const propertyTypes = await repo.#propertyTypes(contentTypeNodeId)
562
+ const byAlias = new Map(propertyTypes.map((p) => [p.alias, p]))
563
+ const languages = await repo.#languages()
564
+ const idByIso = new Map(languages.map((l) => [l.isoCode.toLowerCase(), l.id]))
565
+ const current = new Map(
566
+ (await repo.#currentValueRows(node.id)).map((row) => [
567
+ keyOf(row.propertyTypeId, row.languageId, row.segment),
568
+ row,
569
+ ]),
570
+ )
571
+ const head = await repo.#headEvent(node.id)
572
+ const published = await tx.query<{ id: number }>(
573
+ 'SELECT cv.id FROM content_version cv JOIN document_version dv ON dv.id = cv.id AND dv.published = ? WHERE cv.node_id = ? ORDER BY cv.id DESC LIMIT 1',
574
+ [tx.dialect.boolValue(true), node.id],
575
+ )
576
+ const fallbackEvent = Number(published[0]?.id ?? head?.id ?? 0)
577
+ let written = false
578
+ for (const value of values) {
579
+ const propertyType = byAlias.get(value.alias)
580
+ if (!propertyType) continue
581
+ const culture = propertyType.variesByCulture ? value.culture : null
582
+ const languageId = culture ? (idByIso.get(culture.toLowerCase()) ?? null) : null
583
+ const existing = current.get(keyOf(propertyType.id, languageId, value.segment ?? null))
584
+ const eventId = value.eventId ?? existing?.eventId ?? fallbackEvent
585
+ await repo.#insertValue(
586
+ node.id,
587
+ eventId,
588
+ options.schemaStateId,
589
+ value.storage ? { ...propertyType, storage: value.storage } : propertyType,
590
+ languageId,
591
+ value.segment ?? null,
592
+ value.value,
593
+ existing?.id,
594
+ )
595
+ written = true
596
+ }
597
+ if (written) await repo.#recomputeEdited(node.id)
598
+ return written
599
+ })
600
+ }
601
+
602
+ /** Saves the draft: one save event, and a new value version for each value that changed. */
603
+ async update(input: SaveDocumentInput): Promise<DocumentAggregate | undefined> {
604
+ return this.#db.transaction(async (tx) => {
605
+ const repo = this.#child(tx)
606
+ const state = await assertNodeMayWrite(tx, this.#nodeState)
607
+ const node = await repo.nodes.byKey(input.key)
608
+ if (!node) return undefined
609
+ const contentTypeNodeId = await repo.#contentTypeNodeId(node.id)
610
+ if (!contentTypeNodeId) return undefined
611
+
612
+ const name = input.variants[0]?.name ?? node.text ?? ''
613
+ await repo.nodes.rename(node.id, name)
614
+ const templateId = input.componentKey
615
+ ? ((await repo.nodes.byKey(input.componentKey))?.id ?? null)
616
+ : null
617
+ const eventId = await repo.#insertEvent(
618
+ node.id,
619
+ 'save',
620
+ name,
621
+ input.userId,
622
+ templateId,
623
+ false,
624
+ )
625
+
626
+ await repo.#appendValues(node.id, eventId, state, contentTypeNodeId, input.values)
627
+ await repo.#writeCultureVariations(node.id, eventId, input.variants)
628
+ await repo.#recomputeEdited(node.id)
629
+ return repo.#load({ ...node, text: name })
630
+ })
631
+ }
632
+
633
+ /**
634
+ * Publish is an event and a pointer; nothing is copied. For a document that
635
+ * varies by culture, each culture published points at this event, and the
636
+ * others keep pointing at the event they were last published with, so their
637
+ * newer drafts stay unpublished. Every mandatory language must end up
638
+ * published, as Umbraco requires.
639
+ */
640
+ async publish(
641
+ key: string,
642
+ cultures: readonly string[] | null,
643
+ ): Promise<DocumentAggregate | undefined> {
644
+ return this.#withTree(async () =>
645
+ this.#db.transaction(async (tx) => {
646
+ const repo = this.#child(tx)
647
+ await assertNodeMayWrite(tx, this.#nodeState)
648
+ const node = await repo.nodes.byKey(key)
649
+ if (!node) return undefined
650
+ // An unpublished ancestor makes a published descendant unreachable, so
651
+ // Umbraco refuses the publish rather than creating a dead route.
652
+ if (!(await repo.#ancestorsPublished(node)))
653
+ throw new PublishBlockedError('An ancestor is not published.')
654
+
655
+ const variant = await repo.#variesByCulture(node.id)
656
+ const available = variant ? await repo.#availableCultures(node.id) : []
657
+ const targets = variant
658
+ ? cultures && cultures.length > 0
659
+ ? [...cultures]
660
+ : available
661
+ : null
662
+ if (targets) {
663
+ const missing = targets.filter(
664
+ (c) => !available.some((a) => a.toLowerCase() === c.toLowerCase()),
665
+ )
666
+ if (missing.length > 0)
667
+ throw new PublishBlockedError(
668
+ `The document has no ${missing.join(', ')} variant to publish.`,
669
+ )
670
+ const published = await repo.#publishedCultures(node.id)
671
+ const settled = new Set([...targets, ...published].map((c) => c.toLowerCase()))
672
+ const mandatory = (await repo.#languages()).filter(
673
+ (l) => l.isMandatory && !settled.has(l.isoCode.toLowerCase()),
674
+ )
675
+ if (mandatory.length > 0)
676
+ throw new PublishBlockedError(
677
+ `The mandatory ${mandatory.map((l) => l.isoCode).join(', ')} must be published too.`,
678
+ )
679
+ }
680
+ await repo.#assertMandatoryFilled(node, targets)
681
+
682
+ const head = await repo.#headEvent(node.id)
683
+ const t = tx.dialect.boolValue(true)
684
+ const f = tx.dialect.boolValue(false)
685
+ await tx.exec(
686
+ 'UPDATE document_version SET published = ? WHERE id IN (SELECT id FROM content_version WHERE node_id = ?)',
687
+ [f, node.id],
688
+ )
689
+ const eventId = await repo.#insertEvent(
690
+ node.id,
691
+ 'publish',
692
+ head?.name ?? node.text ?? '',
693
+ head?.userId,
694
+ head?.templateId ?? null,
695
+ true,
696
+ )
697
+ await tx.exec('UPDATE document SET published = ? WHERE node_id = ?', [t, node.id])
698
+ for (const iso of targets ?? []) await repo.#setCulturePublished(node.id, iso, eventId)
699
+ await repo.#recomputeEdited(node.id)
700
+ await repo.#instructContent(node.key)
701
+ return repo.#load(node)
702
+ }),
703
+ )
704
+ }
705
+
706
+ /**
707
+ * Why publishing this document would be refused, without publishing it.
708
+ *
709
+ * `publish` throws on the first of these, which is right for one node and
710
+ * wrong for a branch: a caller publishing a selection asks this first, so the
711
+ * whole thing is refused rather than half of it landing. The checks and their
712
+ * order are `publish`'s own.
713
+ */
714
+ async publishBlockers(
715
+ key: string,
716
+ cultures: readonly string[] | null,
717
+ /**
718
+ * Node ids this caller is publishing in the same run, parents first.
719
+ * Without it, publishing a branch reports every child as blocked by the
720
+ * root that the same command is about to publish.
721
+ */
722
+ alsoPublishing: ReadonlySet<number> = new Set(),
723
+ ): Promise<string[]> {
724
+ const node = await this.#nodes.byKey(key)
725
+ if (!node) return ['there is no such document here']
726
+ const reasons: string[] = []
727
+ if (!(await this.#ancestorsPublished(node, alsoPublishing)))
728
+ reasons.push('an ancestor is not published')
729
+
730
+ const variant = await this.#variesByCulture(node.id)
731
+ const available = variant ? await this.#availableCultures(node.id) : []
732
+ const targets = variant ? (cultures && cultures.length > 0 ? [...cultures] : available) : null
733
+ if (targets) {
734
+ const missing = targets.filter(
735
+ (c) => !available.some((a) => a.toLowerCase() === c.toLowerCase()),
736
+ )
737
+ if (missing.length > 0) reasons.push(`there is no ${missing.join(', ')} variant to publish`)
738
+ const published = await this.#publishedCultures(node.id)
739
+ const settled = new Set([...targets, ...published].map((c) => c.toLowerCase()))
740
+ const mandatory = (await this.#languages()).filter(
741
+ (l) => l.isMandatory && !settled.has(l.isoCode.toLowerCase()),
742
+ )
743
+ if (mandatory.length > 0)
744
+ reasons.push(
745
+ `the mandatory ${mandatory.map((l) => l.isoCode).join(', ')} must be published too`,
746
+ )
747
+ }
748
+ try {
749
+ await this.#assertMandatoryFilled(node, targets)
750
+ } catch (error) {
751
+ reasons.push((error as Error).message)
752
+ }
753
+ return reasons
754
+ }
755
+
756
+ /**
757
+ * Unpublishes the document, or only the cultures named. Taking a mandatory
758
+ * culture offline takes the whole document offline, as in Umbraco.
759
+ */
760
+ async unpublish(
761
+ key: string,
762
+ cultures: readonly string[] | null,
763
+ ): Promise<DocumentAggregate | undefined> {
764
+ return this.#db.transaction(async (tx) => {
765
+ const repo = this.#child(tx)
766
+ await assertNodeMayWrite(tx, this.#nodeState)
767
+ const node = await repo.nodes.byKey(key)
768
+ if (!node) return undefined
769
+
770
+ const mandatory = new Set(
771
+ (await repo.#languages()).filter((l) => l.isMandatory).map((l) => l.isoCode.toLowerCase()),
772
+ )
773
+ const everything =
774
+ !cultures || cultures.length === 0 || cultures.some((c) => mandatory.has(c.toLowerCase()))
775
+ const isoCodes = everything ? await repo.#availableCultures(node.id) : cultures
776
+ for (const iso of isoCodes) await repo.#setCulturePublished(node.id, iso, null)
777
+ if (everything || !(await repo.#anyCulturePublished(node.id))) {
778
+ // Events survive an unpublish; only the flags change.
779
+ await tx.exec(
780
+ 'UPDATE document_version SET published = ? WHERE id IN (SELECT id FROM content_version WHERE node_id = ?)',
781
+ [tx.dialect.boolValue(false), node.id],
782
+ )
783
+ await tx.exec('UPDATE document SET published = ?, edited = ? WHERE node_id = ?', [
784
+ tx.dialect.boolValue(false),
785
+ tx.dialect.boolValue(true),
786
+ node.id,
787
+ ])
788
+ }
789
+ await repo.#instructContent(node.key)
790
+ return repo.#load(node)
791
+ })
792
+ }
793
+
794
+ /**
795
+ * Where a node stands in its own history: its latest event, and the event it
796
+ * is serving. Together these are "the state this node was in", which is what
797
+ * a content import records before touching it so a revert can put it back.
798
+ */
799
+ async eventMarkers(
800
+ key: string,
801
+ ): Promise<{ head: number | undefined; published: number | undefined } | undefined> {
802
+ const node = await this.#nodes.byKey(key)
803
+ if (!node) return undefined
804
+ return {
805
+ head: (await this.#headEvent(node.id))?.id,
806
+ published: await this.#publishedEventId(node.id),
807
+ }
808
+ }
809
+
810
+ /** Rollback appends the target version's values under a new event. History is never rewritten. */
811
+ async rollback(versionId: string): Promise<DocumentAggregate | undefined> {
812
+ return this.#db.transaction(async (tx) => {
813
+ const repo = this.#child(tx)
814
+ const state = await assertNodeMayWrite(tx, this.#nodeState)
815
+ const targetId = Number(versionId)
816
+ const rows = await tx.query<{ node_id: number; text: string | null; user_id: number | null }>(
817
+ 'SELECT node_id, text, user_id FROM content_version WHERE id = ?',
818
+ [targetId],
819
+ )
820
+ const target = rows[0]
821
+ if (!target) return undefined
822
+ const node = await repo.nodes.byId(Number(target.node_id))
823
+ if (!node) return undefined
824
+ const contentTypeNodeId = await repo.#contentTypeNodeId(node.id)
825
+ if (!contentTypeNodeId) return undefined
826
+
827
+ const templateRows = await tx.query<{ template_id: number | null }>(
828
+ 'SELECT template_id FROM document_version WHERE id = ?',
829
+ [targetId],
830
+ )
831
+ const name = String(target.text ?? node.text ?? '')
832
+ const eventId = await repo.#insertEvent(
833
+ node.id,
834
+ 'rollback',
835
+ name,
836
+ undefined,
837
+ templateRows[0]?.template_id ?? null,
838
+ false,
839
+ )
840
+
841
+ const snapshot = await repo.#valueRowsAsOf(node.id, { eventId: targetId })
842
+ const current = await repo.#currentValueRows(node.id)
843
+ await repo.#appendRows(node.id, eventId, state, snapshot, current)
844
+ await repo.#copyCultureVariations(targetId, eventId)
845
+ await repo.nodes.rename(node.id, name)
846
+ await repo.#recomputeEdited(node.id)
847
+ return repo.#load({ ...node, text: name })
848
+ })
849
+ }
850
+
851
+ async setPreventCleanup(versionId: string, prevent: boolean): Promise<boolean> {
852
+ const rows = await this.#db.query<{ id: number }>(
853
+ 'SELECT id FROM content_version WHERE id = ?',
854
+ [Number(versionId)],
855
+ )
856
+ if (!rows[0]) return false
857
+ await this.#db.exec('UPDATE content_version SET prevent_cleanup = ? WHERE id = ?', [
858
+ this.#db.dialect.boolValue(prevent),
859
+ Number(versionId),
860
+ ])
861
+ return true
862
+ }
863
+
864
+ async moveToRecycleBin(key: string): Promise<boolean> {
865
+ const node = await this.#nodes.byKey(key)
866
+ if (!node || this.#recycleBin === undefined) return false
867
+ return this.#withTree(async () => {
868
+ const t = this.#db.dialect.boolValue(true)
869
+ const f = this.#db.dialect.boolValue(false)
870
+ // The branch root remembers where it came from; the rest keeps its parent.
871
+ await this.#db.exec('UPDATE document SET original_parent_id = ? WHERE node_id = ?', [
872
+ node.parentId === SystemNodes.Root ? null : node.parentId,
873
+ node.id,
874
+ ])
875
+ // The whole branch goes with it, and stays unpublished while trashed.
876
+ await this.#db.exec(
877
+ `UPDATE node SET trashed = ?, parent_id = CASE WHEN id = ? THEN ? ELSE parent_id END
878
+ WHERE id = ? OR path LIKE ?`,
879
+ [t, node.id, this.#recycleBin, node.id, `${node.path},%`],
880
+ )
881
+ await this.#db.exec(
882
+ `UPDATE document SET published = ?, edited = ? WHERE node_id IN (SELECT id FROM node WHERE id = ? OR path LIKE ?)`,
883
+ [f, t, node.id, `${node.path},%`],
884
+ )
885
+ return true
886
+ })
887
+ }
888
+
889
+ async delete(key: string): Promise<boolean> {
890
+ const node = await this.#nodes.byKey(key)
891
+ if (!node) return false
892
+ return this.#db.transaction(async (tx) => {
893
+ const ids = await tx.query<{ id: number }>(
894
+ 'SELECT id FROM node WHERE id = ? OR path LIKE ?',
895
+ [node.id, `${node.path},%`],
896
+ )
897
+ for (const row of ids.map((r) => Number(r.id)).reverse()) {
898
+ await tx.exec('DELETE FROM property_value WHERE node_id = ?', [row])
899
+ await tx.exec('DELETE FROM content_schedule WHERE node_id = ?', [row])
900
+ await tx.exec('DELETE FROM user_notification WHERE node_id = ?', [row])
901
+ await tx.exec('DELETE FROM domain WHERE node_id = ?', [row])
902
+ await tx.exec(
903
+ 'DELETE FROM content_version_culture_variation WHERE version_id IN (SELECT id FROM content_version WHERE node_id = ?)',
904
+ [row],
905
+ )
906
+ await tx.exec('DELETE FROM document_culture_variation WHERE node_id = ?', [row])
907
+ await tx.exec(
908
+ 'DELETE FROM document_version WHERE id IN (SELECT id FROM content_version WHERE node_id = ?)',
909
+ [row],
910
+ )
911
+ await tx.exec('DELETE FROM content_version WHERE node_id = ?', [row])
912
+ await tx.exec('DELETE FROM document WHERE node_id = ?', [row])
913
+ await tx.exec('DELETE FROM content WHERE node_id = ?', [row])
914
+ await tx.exec('DELETE FROM node WHERE id = ?', [row])
915
+ }
916
+ return true
917
+ })
918
+ }
919
+
920
+ /**
921
+ * Re-parents a node and its branch under `targetKey` (null: the root), last
922
+ * among its new siblings. Refused when the target is the node or below it.
923
+ */
924
+ async move(key: string, targetKey: string | null): Promise<'moved' | 'notFound' | 'invalid'> {
925
+ return this.#withTree(async () =>
926
+ this.#db.transaction(async (tx) => {
927
+ const repo = this.#child(tx)
928
+ await assertNodeMayWrite(tx, this.#nodeState)
929
+ const node = await repo.nodes.byKey(key)
930
+ if (!node || node.objectType !== this.objectType) return 'notFound'
931
+ const target = targetKey ? await repo.nodes.byKey(targetKey) : undefined
932
+ const container = KINDS[this.kind].container
933
+ if (
934
+ targetKey &&
935
+ (!target || (target.objectType !== this.objectType && target.objectType !== container))
936
+ )
937
+ return 'notFound'
938
+ if (target && (target.id === node.id || `${target.path},`.startsWith(`${node.path},`)))
939
+ return 'invalid'
940
+ if (target?.trashed) return 'invalid'
941
+ const parentId = target?.id ?? SystemNodes.Root
942
+ await repo.nodes.move(node.id, parentId)
943
+ await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
944
+ await repo.#nextSortOrder(parentId, node.id),
945
+ node.id,
946
+ ])
947
+ await repo.#instructContent(node.key)
948
+ return 'moved'
949
+ }),
950
+ )
951
+ }
952
+
953
+ /**
954
+ * Copies a node's current draft (and, optionally, its branch) under
955
+ * `targetKey`. Copies are unpublished and take a free name among their new
956
+ * siblings (`Home (1)`), as Umbraco names them.
957
+ */
958
+ async copy(
959
+ key: string,
960
+ targetKey: string | null,
961
+ options: { includeDescendants: boolean; userId?: number },
962
+ ): Promise<string | undefined> {
963
+ const source = await this.byKey(key)
964
+ if (!source) return undefined
965
+ if (targetKey) {
966
+ const target = await this.#nodes.byKey(targetKey)
967
+ if (!target || target.objectType !== this.objectType) return undefined
968
+ }
969
+ const copyOne = async (from: DocumentAggregate, parentKey: string | null): Promise<string> => {
970
+ const siblings = await this.#siblingNames(parentKey)
971
+ const variants = from.variants.map((variant) => ({
972
+ culture: variant.culture,
973
+ segment: variant.segment,
974
+ name: variant.culture === null ? uniqueName(variant.name, siblings) : variant.name,
975
+ }))
976
+ const created = await this.create({
977
+ key: crypto.randomUUID(),
978
+ contentTypeKey: from.contentTypeKey,
979
+ componentKey: from.componentKey,
980
+ parentKey,
981
+ values: from.values,
982
+ variants,
983
+ userId: options.userId,
984
+ })
985
+ return created.key
986
+ }
987
+ const copyBranch = async (from: DocumentAggregate, parentKey: string | null) => {
988
+ const newKey = await copyOne(from, parentKey)
989
+ if (!options.includeDescendants) return newKey
990
+ const children = await this.children(from.key, 0, Number.MAX_SAFE_INTEGER)
991
+ for (const child of children.items) {
992
+ const loaded = await this.#load(child)
993
+ if (loaded) await copyBranch(loaded, newKey)
994
+ }
995
+ return newKey
996
+ }
997
+ return copyBranch(source, targetKey)
998
+ }
999
+
1000
+ /** Sets the sort order of the given children of `parentKey` (null: the root). */
1001
+ async sort(
1002
+ parentKey: string | null,
1003
+ sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
1004
+ ): Promise<'sorted' | 'notFound'> {
1005
+ const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
1006
+ if (parentId === undefined) return 'notFound'
1007
+ return this.#withTree(async () =>
1008
+ this.#db.transaction(async (tx) => {
1009
+ await assertNodeMayWrite(tx, this.#nodeState)
1010
+ const nodes = await new NodeRepository(tx).byKeys(sorting.map((s) => s.key))
1011
+ const byKey = new Map(nodes.map((n) => [n.key, n]))
1012
+ for (const item of sorting) {
1013
+ const node = byKey.get(normaliseUuid(item.key))
1014
+ if (!node || node.parentId !== parentId || node.objectType !== this.objectType)
1015
+ return 'notFound'
1016
+ }
1017
+ for (const item of sorting) {
1018
+ const node = byKey.get(normaliseUuid(item.key)) as NodeRow
1019
+ await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [item.sortOrder, node.id])
1020
+ await this.#child(tx).#instructContent(node.key)
1021
+ }
1022
+ return 'sorted'
1023
+ }),
1024
+ )
1025
+ }
1026
+
1027
+ /** Re-sorts every child of `parentKey` by name or date, as the "Sort children" dialog asks. */
1028
+ async sortChildrenBy(
1029
+ parentKey: string | null,
1030
+ field: 'Name' | 'CreateDate' | 'UpdateDate',
1031
+ direction: 'Ascending' | 'Descending',
1032
+ ): Promise<'sorted' | 'notFound'> {
1033
+ const children = await this.children(parentKey, 0, Number.MAX_SAFE_INTEGER)
1034
+ if (parentKey && !(await this.#nodes.byKey(parentKey))) return 'notFound'
1035
+ const loaded = await Promise.all(
1036
+ children.items.map(async (node) => ({ node, aggregate: await this.#load(node) })),
1037
+ )
1038
+ const sortKey = (entry: (typeof loaded)[number]): string | number => {
1039
+ const variant = entry.aggregate?.variants[0]
1040
+ if (field === 'Name') return (variant?.name ?? entry.node.text ?? '').toLowerCase()
1041
+ if (field === 'CreateDate') return (entry.node.createDate ?? new Date(0)).getTime()
1042
+ return (variant?.updateDate ?? entry.node.createDate ?? new Date(0)).getTime()
1043
+ }
1044
+ loaded.sort((a, b) => {
1045
+ const x = sortKey(a)
1046
+ const y = sortKey(b)
1047
+ const order = x < y ? -1 : x > y ? 1 : a.node.id - b.node.id
1048
+ return direction === 'Ascending' ? order : -order
1049
+ })
1050
+ return this.sort(
1051
+ parentKey,
1052
+ loaded.map((entry, index) => ({ key: entry.node.key, sortOrder: index })),
1053
+ )
1054
+ }
1055
+
1056
+ /**
1057
+ * Takes a branch out of the recycle bin to `targetKey`, or back where it was
1058
+ * when no target is given. Restored content is unpublished.
1059
+ */
1060
+ async restore(
1061
+ key: string,
1062
+ targetKey: string | null | undefined,
1063
+ ): Promise<'restored' | 'notFound' | 'invalid'> {
1064
+ const node = await this.#nodes.byKey(key)
1065
+ if (!node || node.objectType !== this.objectType || !node.trashed) return 'notFound'
1066
+ let parentKey: string | null
1067
+ if (targetKey !== undefined && targetKey !== null) parentKey = targetKey
1068
+ else if (targetKey === null) parentKey = null
1069
+ else parentKey = (await this.originalParent(key)) ?? null
1070
+ const parent = parentKey ? await this.#nodes.byKey(parentKey) : undefined
1071
+ if (parentKey && (!parent || parent.trashed)) return 'invalid'
1072
+ return this.#withTree(async () =>
1073
+ this.#db.transaction(async (tx) => {
1074
+ const repo = this.#child(tx)
1075
+ await assertNodeMayWrite(tx, this.#nodeState)
1076
+ const parentId = parent?.id ?? SystemNodes.Root
1077
+ await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
1078
+ tx.dialect.boolValue(false),
1079
+ node.id,
1080
+ `${node.path},%`,
1081
+ ])
1082
+ await repo.nodes.move(node.id, parentId)
1083
+ await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
1084
+ await repo.#nextSortOrder(parentId, node.id),
1085
+ node.id,
1086
+ ])
1087
+ await tx.exec('UPDATE document SET original_parent_id = NULL WHERE node_id = ?', [node.id])
1088
+ return 'restored' as const
1089
+ }),
1090
+ )
1091
+ }
1092
+
1093
+ /**
1094
+ * Publishes a node, then its descendants shallowest first: those already
1095
+ * published, or every one when `includeUnpublished`. A descendant that cannot
1096
+ * publish (a missing mandatory value, an unpublished parent) is reported and
1097
+ * skipped, and so is its branch below.
1098
+ */
1099
+ async publishBranch(
1100
+ key: string,
1101
+ cultures: readonly string[] | null,
1102
+ includeUnpublished: boolean,
1103
+ ): Promise<{ published: string[]; failed: Array<{ key: string; reason: string }> } | undefined> {
1104
+ const root = await this.#nodes.byKey(key)
1105
+ if (!root || root.objectType !== this.objectType || root.trashed) return undefined
1106
+ const published: string[] = []
1107
+ const failed: Array<{ key: string; reason: string }> = []
1108
+ try {
1109
+ await this.publish(root.key, cultures)
1110
+ published.push(root.key)
1111
+ } catch (error) {
1112
+ if (!(error instanceof PublishBlockedError)) throw error
1113
+ return { published, failed: [{ key: root.key, reason: error.message }] }
1114
+ }
1115
+ const rows = await this.#db.query(
1116
+ `SELECT n.id, n.unique_id, n.path, d.published FROM node n JOIN document d ON d.node_id = n.id
1117
+ WHERE n.path LIKE ? AND n.node_object_type = ? AND n.trashed = ?
1118
+ ORDER BY n.level, n.sort_order, n.id`,
1119
+ [`${root.path},%`, this.objectType, this.#db.dialect.boolValue(false)],
1120
+ )
1121
+ const skipped: string[] = []
1122
+ for (const row of rows) {
1123
+ const path = String(row.path)
1124
+ if (skipped.some((prefix) => path.startsWith(`${prefix},`))) continue
1125
+ const nodeKey = normaliseUuid(String(row.unique_id))
1126
+ if (!includeUnpublished && !fromDbBool(row.published)) {
1127
+ skipped.push(path)
1128
+ continue
1129
+ }
1130
+ try {
1131
+ await this.publish(nodeKey, cultures)
1132
+ published.push(nodeKey)
1133
+ } catch (error) {
1134
+ if (!(error instanceof PublishBlockedError)) throw error
1135
+ failed.push({ key: nodeKey, reason: error.message })
1136
+ skipped.push(path)
1137
+ }
1138
+ }
1139
+ return { published, failed }
1140
+ }
1141
+
1142
+ /** A document as it stood at one of its versions: that version's name and values. */
1143
+ async atVersion(
1144
+ versionId: string,
1145
+ ): Promise<{ versionId: string; document: DocumentAggregate } | undefined> {
1146
+ const id = Number(versionId)
1147
+ if (!Number.isInteger(id)) return undefined
1148
+ const rows = await this.#db.query<{ node_id: number; text: string | null }>(
1149
+ 'SELECT node_id, text FROM content_version WHERE id = ?',
1150
+ [id],
1151
+ )
1152
+ const row = rows[0]
1153
+ if (!row) return undefined
1154
+ const node = await this.#nodes.byId(Number(row.node_id))
1155
+ if (!node || node.objectType !== this.objectType) return undefined
1156
+ const current = await this.#load(node)
1157
+ const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
1158
+ if (!current || !contentTypeNodeId) return undefined
1159
+ const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
1160
+ const values = await this.#readValues(node.id, propertyTypes, await this.#languages(), {
1161
+ eventId: id,
1162
+ })
1163
+ const name = String(row.text ?? node.text ?? '')
1164
+ return {
1165
+ versionId: String(id),
1166
+ document: {
1167
+ ...current,
1168
+ values,
1169
+ variants: current.variants.map((v) => (v.culture === null ? { ...v, name } : v)),
1170
+ },
1171
+ }
1172
+ }
1173
+
1174
+ /**
1175
+ * Replaces the scheduled publish and unpublish of each culture given (null:
1176
+ * the invariant variant). A time of null clears that half of the schedule.
1177
+ */
1178
+ async setSchedule(
1179
+ key: string,
1180
+ entries: ReadonlyArray<{
1181
+ culture: string | null
1182
+ publishTime: Date | null
1183
+ unpublishTime: Date | null
1184
+ }>,
1185
+ ): Promise<boolean> {
1186
+ const node = await this.#nodes.byKey(key)
1187
+ if (!node || node.objectType !== this.objectType) return false
1188
+ const languages = await this.#languages()
1189
+ await this.#db.transaction(async (tx) => {
1190
+ for (const entry of entries) {
1191
+ const languageId = entry.culture
1192
+ ? (languages.find((l) => l.isoCode.toLowerCase() === entry.culture?.toLowerCase())?.id ??
1193
+ null)
1194
+ : null
1195
+ await tx.exec(
1196
+ `DELETE FROM content_schedule WHERE node_id = ? AND ${languageId === null ? 'language_id IS NULL' : 'language_id = ?'}`,
1197
+ languageId === null ? [node.id] : [node.id, languageId],
1198
+ )
1199
+ const add = async (action: 'Release' | 'Expire', date: Date | null) => {
1200
+ if (!date) return
1201
+ await tx.exec(
1202
+ 'INSERT INTO content_schedule (node_id, language_id, action, date) VALUES (?, ?, ?, ?)',
1203
+ [node.id, languageId, action, DbDate.toDb(date)],
1204
+ )
1205
+ }
1206
+ await add('Release', entry.publishTime)
1207
+ await add('Expire', entry.unpublishTime)
1208
+ }
1209
+ })
1210
+ return true
1211
+ }
1212
+
1213
+ async schedules(
1214
+ key: string,
1215
+ ): Promise<
1216
+ Array<{ id: number; culture: string | null; action: 'Release' | 'Expire'; date: Date }>
1217
+ > {
1218
+ const node = await this.#nodes.byKey(key)
1219
+ if (!node) return []
1220
+ const rows = await this.#db.query(
1221
+ `SELECT s.id, s.action, s.date, l.iso_code FROM content_schedule s
1222
+ LEFT JOIN language l ON l.id = s.language_id WHERE s.node_id = ? ORDER BY s.date`,
1223
+ [node.id],
1224
+ )
1225
+ return rows.map((row) => ({
1226
+ id: Number(row.id),
1227
+ culture: (row.iso_code as string | null) ?? null,
1228
+ action: String(row.action) as 'Release' | 'Expire',
1229
+ date: DbDate.fromDb(row.date) ?? new Date(0),
1230
+ }))
1231
+ }
1232
+
1233
+ /** Schedules whose time has come, oldest first. */
1234
+ async dueSchedules(
1235
+ now: Date,
1236
+ ): Promise<
1237
+ Array<{ id: number; key: string; culture: string | null; action: 'Release' | 'Expire' }>
1238
+ > {
1239
+ const rows = await this.#db.query(
1240
+ `SELECT s.id, s.action, n.unique_id, l.iso_code FROM content_schedule s
1241
+ JOIN node n ON n.id = s.node_id LEFT JOIN language l ON l.id = s.language_id
1242
+ WHERE s.date <= ? AND n.node_object_type = ? ORDER BY s.date, s.id`,
1243
+ [DbDate.toDb(now), this.objectType],
1244
+ )
1245
+ return rows.map((row) => ({
1246
+ id: Number(row.id),
1247
+ key: normaliseUuid(String(row.unique_id)),
1248
+ culture: (row.iso_code as string | null) ?? null,
1249
+ action: String(row.action) as 'Release' | 'Expire',
1250
+ }))
1251
+ }
1252
+
1253
+ /** Takes a due schedule for this process; false when another node already did. */
1254
+ async claimSchedule(id: number): Promise<boolean> {
1255
+ const rows = await this.#db.query('DELETE FROM content_schedule WHERE id = ? RETURNING id', [
1256
+ id,
1257
+ ])
1258
+ return rows.length > 0
1259
+ }
1260
+
1261
+ /**
1262
+ * Prunes old versions under the cleanup policy: the content type's own, else
1263
+ * `global`. Kept always: the current draft, the published version, and any
1264
+ * version pinned with prevent-cleanup. Kept by age: everything newer than
1265
+ * keep-all days, then the latest version of each day for keep-latest days.
1266
+ *
1267
+ * Values are append-only, so a pruned version's value row may still be what a
1268
+ * kept version reads ("latest per key at or before it"). Such a row is
1269
+ * re-pointed at the earliest kept version after it rather than deleted, so
1270
+ * every kept version still reads exactly what it did.
1271
+ */
1272
+ async cleanupVersions(
1273
+ now: Date,
1274
+ global: { keepAllVersionsNewerThanDays: number; keepLatestVersionPerDayForDays: number },
1275
+ ): Promise<{ nodes: number; versionsDeleted: number }> {
1276
+ const types = await this.#db.query(
1277
+ `SELECT c.node_id, ct.prevent_cleanup, ct.keep_all_versions_newer_than_days AS keep_all,
1278
+ ct.keep_latest_version_per_day_for_days AS keep_latest
1279
+ FROM content c JOIN content_type ct ON ct.node_id = c.content_type_id
1280
+ JOIN node n ON n.id = c.node_id WHERE n.node_object_type = ?`,
1281
+ [this.objectType],
1282
+ )
1283
+ const day = 24 * 60 * 60 * 1000
1284
+ let nodes = 0
1285
+ let versionsDeleted = 0
1286
+ for (const type of types) {
1287
+ if (fromDbBool(type.prevent_cleanup)) continue
1288
+ const keepAll =
1289
+ type.keep_all === null ? global.keepAllVersionsNewerThanDays : Number(type.keep_all)
1290
+ const keepLatest =
1291
+ type.keep_latest === null ? global.keepLatestVersionPerDayForDays : Number(type.keep_latest)
1292
+ const deleted = await this.#db.transaction((tx) =>
1293
+ this.#child(tx).#cleanupNode(
1294
+ Number(type.node_id),
1295
+ now.getTime() - keepAll * day,
1296
+ now.getTime() - keepLatest * day,
1297
+ ),
1298
+ )
1299
+ if (deleted > 0) {
1300
+ nodes += 1
1301
+ versionsDeleted += deleted
1302
+ }
1303
+ }
1304
+ return { nodes, versionsDeleted }
1305
+ }
1306
+
1307
+ async #cleanupNode(
1308
+ nodeId: number,
1309
+ keepAllAfter: number,
1310
+ keepLatestAfter: number,
1311
+ ): Promise<number> {
1312
+ const events = (
1313
+ await this.#db.query(
1314
+ `SELECT cv.id, cv.version_date, cv.current, cv.prevent_cleanup, dv.published
1315
+ FROM content_version cv JOIN document_version dv ON dv.id = cv.id
1316
+ WHERE cv.node_id = ? ORDER BY cv.id`,
1317
+ [nodeId],
1318
+ )
1319
+ ).map((row) => ({
1320
+ id: Number(row.id),
1321
+ date: (DbDate.fromDb(row.version_date) ?? new Date(0)).getTime(),
1322
+ pinned:
1323
+ fromDbBool(row.current) || fromDbBool(row.published) || fromDbBool(row.prevent_cleanup),
1324
+ }))
1325
+ const kept = new Set<number>()
1326
+ const latestPerDay = new Map<string, number>()
1327
+ for (const event of events) {
1328
+ if (event.pinned || event.date >= keepAllAfter) kept.add(event.id)
1329
+ else if (event.date >= keepLatestAfter) {
1330
+ // Ascending ids: the last one seen for a day is that day's latest.
1331
+ latestPerDay.set(new Date(event.date).toISOString().slice(0, 10), event.id)
1332
+ }
1333
+ }
1334
+ for (const id of latestPerDay.values()) kept.add(id)
1335
+ const pruned = events.filter((e) => !kept.has(e.id)).map((e) => e.id)
1336
+ if (pruned.length === 0) return 0
1337
+ const keptAscending = [...kept].sort((a, b) => a - b)
1338
+ const prunedSet = new Set(pruned)
1339
+
1340
+ const rows = await this.#db.query<{
1341
+ id: number
1342
+ property_type_id: number
1343
+ language_id: number | null
1344
+ segment: string | null
1345
+ event_id: number
1346
+ }>(
1347
+ `SELECT id, property_type_id, language_id, segment, event_id FROM property_value
1348
+ WHERE node_id = ? ORDER BY event_id, id`,
1349
+ [nodeId],
1350
+ )
1351
+ // Per key, the distinct events that wrote it, ascending.
1352
+ const eventsByKey = new Map<string, number[]>()
1353
+ for (const row of rows) {
1354
+ const key = keyOf(
1355
+ Number(row.property_type_id),
1356
+ row.language_id === null ? null : Number(row.language_id),
1357
+ row.segment,
1358
+ )
1359
+ const list = eventsByKey.get(key) ?? []
1360
+ if (list.at(-1) !== Number(row.event_id)) list.push(Number(row.event_id))
1361
+ eventsByKey.set(key, list)
1362
+ }
1363
+ for (const row of rows) {
1364
+ const eventId = Number(row.event_id)
1365
+ if (!prunedSet.has(eventId)) continue
1366
+ const key = keyOf(
1367
+ Number(row.property_type_id),
1368
+ row.language_id === null ? null : Number(row.language_id),
1369
+ row.segment,
1370
+ )
1371
+ const written = eventsByKey.get(key) as number[]
1372
+ const next = written[written.indexOf(eventId) + 1] ?? Number.POSITIVE_INFINITY
1373
+ const reader = keptAscending.find((k) => k >= eventId)
1374
+ if (reader !== undefined && reader < next)
1375
+ await this.#db.exec('UPDATE property_value SET event_id = ? WHERE id = ?', [
1376
+ reader,
1377
+ Number(row.id),
1378
+ ])
1379
+ else await this.#db.exec('DELETE FROM property_value WHERE id = ?', [Number(row.id)])
1380
+ }
1381
+ const marks = pruned.map(() => '?').join(', ')
1382
+ await this.#db.exec(
1383
+ `DELETE FROM content_version_culture_variation WHERE version_id IN (${marks})`,
1384
+ pruned,
1385
+ )
1386
+ await this.#db.exec(`DELETE FROM document_version WHERE id IN (${marks})`, pruned)
1387
+ await this.#db.exec(`DELETE FROM content_version WHERE id IN (${marks})`, pruned)
1388
+ return pruned.length
1389
+ }
1390
+
1391
+ /** Every top-level item in this kind's recycle bin, for emptying it. */
1392
+ async trashedRoots(): Promise<NodeRow[]> {
1393
+ return (await this.trashed(null, 0, Number.MAX_SAFE_INTEGER)).items
1394
+ }
1395
+
1396
+ async #nextSortOrder(parentId: number, excludingId: number): Promise<number> {
1397
+ const rows = await this.#db.query<{ next: number | null }>(
1398
+ 'SELECT MAX(sort_order) AS next FROM node WHERE parent_id = ? AND node_object_type = ? AND id <> ?',
1399
+ [parentId, this.objectType, excludingId],
1400
+ )
1401
+ const current = rows[0]?.next
1402
+ return current === null || current === undefined ? 0 : Number(current) + 1
1403
+ }
1404
+
1405
+ async #siblingNames(parentKey: string | null): Promise<Set<string>> {
1406
+ const siblings = await this.children(parentKey, 0, Number.MAX_SAFE_INTEGER)
1407
+ return new Set(siblings.items.map((node) => (node.text ?? '').toLowerCase()))
1408
+ }
1409
+
1410
+ // ------------------------------------------------------------ internals
1411
+
1412
+ async #load(node: NodeRow): Promise<DocumentAggregate | undefined> {
1413
+ const t = this.#db.dialect.boolValue(true)
1414
+ const rows = await this.#db.query(
1415
+ `SELECT c.content_type_id, ct.alias AS content_type_alias, ct.icon AS content_type_icon,
1416
+ ct.list_view AS content_type_collection,
1417
+ ctn.unique_id AS content_type_key, d.published, d.edited,
1418
+ head.text AS draft_name, head.version_date AS draft_date,
1419
+ hv.template_id, tn.unique_id AS template_key,
1420
+ pub.version_date AS published_date
1421
+ FROM node n
1422
+ JOIN content c ON c.node_id = n.id
1423
+ JOIN content_type ct ON ct.node_id = c.content_type_id
1424
+ JOIN node ctn ON ctn.id = ct.node_id
1425
+ JOIN document d ON d.node_id = n.id
1426
+ JOIN content_version head ON head.node_id = n.id AND head.current = ?
1427
+ JOIN document_version hv ON hv.id = head.id
1428
+ LEFT JOIN node tn ON tn.id = hv.template_id
1429
+ LEFT JOIN (
1430
+ SELECT pc.node_id, pc.version_date FROM content_version pc
1431
+ JOIN document_version pd ON pd.id = pc.id AND pd.published = ?
1432
+ ) pub ON pub.node_id = n.id
1433
+ WHERE n.id = ?`,
1434
+ [t, t, node.id],
1435
+ )
1436
+ const row = rows[0]
1437
+ if (!row) return undefined
1438
+
1439
+ const propertyTypes = await this.#propertyTypes(Number(row.content_type_id))
1440
+ const languages = await this.#languages()
1441
+ const values = await this.#readValues(node.id, propertyTypes, languages, {})
1442
+ const variants = await this.#readVariants(node, row)
1443
+ const parent = node.parentId > 0 ? await this.#nodes.byId(node.parentId) : undefined
1444
+
1445
+ return {
1446
+ key: node.key,
1447
+ contentTypeKey: normaliseUuid(String(row.content_type_key)),
1448
+ contentTypeAlias: String(row.content_type_alias),
1449
+ contentTypeIcon: String(row.content_type_icon ?? 'icon-document'),
1450
+ contentTypeCollectionKey: row.content_type_collection
1451
+ ? normaliseUuid(String(row.content_type_collection))
1452
+ : null,
1453
+ componentKey: row.template_key ? normaliseUuid(String(row.template_key)) : null,
1454
+ parentKey: parent && parent.id !== SystemNodes.Root ? parent.key : null,
1455
+ sortOrder: node.sortOrder,
1456
+ isTrashed: node.trashed,
1457
+ published: fromDbBool(row.published),
1458
+ edited: fromDbBool(row.edited),
1459
+ values,
1460
+ variants,
1461
+ }
1462
+ }
1463
+
1464
+ async #contentTypeNodeId(nodeId: number): Promise<number | undefined> {
1465
+ const rows = await this.#db.query<{ content_type_id: number }>(
1466
+ 'SELECT content_type_id FROM content WHERE node_id = ?',
1467
+ [nodeId],
1468
+ )
1469
+ return rows[0] ? Number(rows[0].content_type_id) : undefined
1470
+ }
1471
+
1472
+ async #languages(): Promise<LanguageRow[]> {
1473
+ const rows = await this.#db.query('SELECT id, iso_code, is_default, is_mandatory FROM language')
1474
+ return rows.map((row) => ({
1475
+ id: Number(row.id),
1476
+ isoCode: String(row.iso_code),
1477
+ isDefault: fromDbBool(row.is_default),
1478
+ isMandatory: fromDbBool(row.is_mandatory),
1479
+ }))
1480
+ }
1481
+
1482
+ /** Property types of a content type, including everything it composes; retired ones excluded. */
1483
+ async #propertyTypes(contentTypeNodeId: number): Promise<PropertyTypeRow[]> {
1484
+ const rows = await this.#db.query(
1485
+ `WITH RECURSIVE ancestry(id) AS (
1486
+ SELECT ?
1487
+ UNION
1488
+ SELECT c.parent_content_type_id FROM content_type_composition c JOIN ancestry a ON a.id = c.child_content_type_id
1489
+ )
1490
+ SELECT p.id, p.alias, p.name, p.variations, p.mandatory, p.mandatory_message,
1491
+ p.validation_reg_exp, p.validation_reg_exp_message, d.db_type, d.editor_alias, d.config,
1492
+ p.since_version, ss.version AS since_v, ss.revision AS since_r
1493
+ FROM property_type p JOIN data_type d ON d.node_id = p.data_type_id
1494
+ LEFT JOIN schema_state ss ON ss.id = p.since_state_id
1495
+ WHERE p.content_type_id IN (SELECT id FROM ancestry) AND p.retired_at IS NULL
1496
+ ORDER BY p.sort_order, p.id`,
1497
+ [contentTypeNodeId],
1498
+ )
1499
+ return rows.map((row) => ({
1500
+ id: Number(row.id),
1501
+ alias: String(row.alias),
1502
+ name: String(row.name),
1503
+ variesByCulture: flagVariesByCulture(Number(row.variations)),
1504
+ storage: String(row.db_type) as ValueStorageTypeName,
1505
+ editorAlias: String(row.editor_alias),
1506
+ config: parseConfig(row.config),
1507
+ mandatory: fromDbBool(row.mandatory),
1508
+ mandatoryMessage: (row.mandatory_message as string | null) ?? null,
1509
+ regEx: (row.validation_reg_exp as string | null) ?? null,
1510
+ regExMessage: (row.validation_reg_exp_message as string | null) ?? null,
1511
+ pending: isPending(
1512
+ {
1513
+ since: row.since_v
1514
+ ? { version: String(row.since_v), revision: String(row.since_r ?? '0') }
1515
+ : null,
1516
+ sinceVersion: (row.since_version as string | null) ?? null,
1517
+ },
1518
+ this.#nodeState,
1519
+ ),
1520
+ }))
1521
+ }
1522
+
1523
+ /**
1524
+ * What a read on this node sees: the current rows, unless the database has
1525
+ * states newer than the node — then the latest row per key at the node's own
1526
+ * state, so an early conversion appended under a prepared state is invisible
1527
+ * to the editor on an old node as well as to its renderer.
1528
+ */
1529
+ async #visibleValueRows(nodeId: number): Promise<ValueRow[]> {
1530
+ const stateId = await visibleStateIdFor(this.#db, this.#nodeState)
1531
+ return stateId === undefined
1532
+ ? this.#currentValueRows(nodeId)
1533
+ : this.#valueRowsAsOf(nodeId, { schemaStateId: stateId })
1534
+ }
1535
+
1536
+ async #currentValueRows(nodeId: number): Promise<ValueRow[]> {
1537
+ const rows = await this.#db.query(
1538
+ `SELECT id, event_id, property_type_id, language_id, segment, int_value, decimal_value, date_value, varchar_value, text_value
1539
+ FROM property_value WHERE node_id = ? AND is_current = ?`,
1540
+ [nodeId, this.#db.dialect.boolValue(true)],
1541
+ )
1542
+ return rows.map((row) => this.#toValueRow(row))
1543
+ }
1544
+
1545
+ /** Latest value per key at or before an event and/or a schema state. */
1546
+ async #valueRowsAsOf(
1547
+ nodeId: number,
1548
+ asOf: { eventId?: number; schemaStateId?: number },
1549
+ ): Promise<ValueRow[]> {
1550
+ const conditions = ['node_id = ?']
1551
+ const params: unknown[] = [nodeId]
1552
+ if (asOf.eventId !== undefined) {
1553
+ conditions.push('event_id <= ?')
1554
+ params.push(asOf.eventId)
1555
+ }
1556
+ if (asOf.schemaStateId !== undefined) {
1557
+ conditions.push('schema_state_id <= ?')
1558
+ params.push(asOf.schemaStateId)
1559
+ }
1560
+ const rows = await this.#db.query(
1561
+ `SELECT pv.id, pv.event_id, pv.property_type_id, pv.language_id, pv.segment,
1562
+ pv.int_value, pv.decimal_value, pv.date_value, pv.varchar_value, pv.text_value
1563
+ FROM property_value pv
1564
+ JOIN (
1565
+ SELECT property_type_id, language_id, segment, MAX(id) AS id
1566
+ FROM property_value WHERE ${conditions.join(' AND ')}
1567
+ GROUP BY property_type_id, language_id, segment
1568
+ ) latest ON latest.id = pv.id`,
1569
+ params,
1570
+ )
1571
+ return rows.map((row) => this.#toValueRow(row))
1572
+ }
1573
+
1574
+ #toValueRow(row: Record<string, unknown>): ValueRow {
1575
+ return {
1576
+ id: Number(row.id),
1577
+ propertyTypeId: Number(row.property_type_id),
1578
+ languageId:
1579
+ row.language_id === null || row.language_id === undefined ? null : Number(row.language_id),
1580
+ segment: (row.segment as string | null) ?? null,
1581
+ eventId: Number(row.event_id),
1582
+ raw: row,
1583
+ }
1584
+ }
1585
+
1586
+ async #readValues(
1587
+ nodeId: number,
1588
+ propertyTypes: readonly PropertyTypeRow[],
1589
+ languages: readonly LanguageRow[],
1590
+ asOf: { eventId?: number; schemaStateId?: number },
1591
+ ): Promise<DocumentValue[]> {
1592
+ if (propertyTypes.length === 0) return []
1593
+ const rows =
1594
+ asOf.eventId === undefined && asOf.schemaStateId === undefined
1595
+ ? await this.#visibleValueRows(nodeId)
1596
+ : await this.#valueRowsAsOf(nodeId, asOf)
1597
+ return this.#valuesFromRows(rows, propertyTypes, languages)
1598
+ }
1599
+
1600
+ #valuesFromRows(
1601
+ rows: readonly ValueRow[],
1602
+ propertyTypes: readonly PropertyTypeRow[],
1603
+ languages: readonly LanguageRow[],
1604
+ ): DocumentValue[] {
1605
+ const byId = new Map(propertyTypes.map((p) => [p.id, p]))
1606
+ const isoById = new Map(languages.map((l) => [l.id, l.isoCode]))
1607
+ const values: DocumentValue[] = []
1608
+ for (const row of rows) {
1609
+ const propertyType = byId.get(row.propertyTypeId)
1610
+ if (!propertyType) continue
1611
+ const raw = (row.raw as Record<string, unknown>)[STORAGE_COLUMN[propertyType.storage]]
1612
+ const value = this.#fromStorage(raw, propertyType.storage)
1613
+ if (value === null) continue
1614
+ values.push({
1615
+ alias: propertyType.alias,
1616
+ culture: row.languageId === null ? null : (isoById.get(row.languageId) ?? null),
1617
+ segment: row.segment,
1618
+ value: toEditorValue(propertyType.editorAlias, value),
1619
+ editorAlias: propertyType.editorAlias,
1620
+ config: propertyType.config,
1621
+ })
1622
+ }
1623
+ return values
1624
+ }
1625
+
1626
+ #fromStorage(raw: unknown, storage: ValueStorageTypeName): unknown {
1627
+ if (raw === null || raw === undefined) return null
1628
+ switch (storage) {
1629
+ case 'Integer':
1630
+ case 'Decimal':
1631
+ return Number(raw)
1632
+ case 'Date':
1633
+ return DbDate.fromDb(raw)?.toISOString() ?? null
1634
+ default:
1635
+ return raw
1636
+ }
1637
+ }
1638
+
1639
+ #toStorage(value: unknown, storage: ValueStorageTypeName): unknown {
1640
+ if (value === null || value === undefined) return null
1641
+ switch (storage) {
1642
+ case 'Integer':
1643
+ return typeof value === 'boolean' ? (value ? 1 : 0) : Number(value)
1644
+ case 'Decimal':
1645
+ return String(Number(value))
1646
+ case 'Date': {
1647
+ if (value instanceof Date) return DbDate.toDb(value)
1648
+ // The date pickers send wall-clock time with no zone ("2026-01-02 03:04:05");
1649
+ // it is kept as that wall-clock time, not shifted by the server's zone.
1650
+ const text = String(value).trim()
1651
+ const naive = /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/.test(text)
1652
+ const date = new Date(
1653
+ naive ? `${text.replace(' ', 'T')}${text.length === 10 ? 'T00:00:00' : ''}Z` : text,
1654
+ )
1655
+ return Number.isNaN(date.getTime()) ? null : DbDate.toDb(date)
1656
+ }
1657
+ default:
1658
+ return typeof value === 'string' ? value : JSON.stringify(value)
1659
+ }
1660
+ }
1661
+
1662
+ /** Whether a stored cell already holds an incoming value, so an unchanged value appends nothing. */
1663
+ #storedEquals(raw: unknown, incoming: unknown, storage: ValueStorageTypeName): boolean {
1664
+ const a = this.#fromStorage(raw, storage)
1665
+ const b = this.#fromStorage(this.#toStorage(incoming, storage), storage)
1666
+ if (a === null || b === null) return a === b
1667
+ return String(a) === String(b)
1668
+ }
1669
+
1670
+ /**
1671
+ * Appends a value version for every incoming value that differs from the
1672
+ * current one, and a cleared version for every current value that is absent.
1673
+ */
1674
+ async #appendValues(
1675
+ nodeId: number,
1676
+ eventId: number,
1677
+ state: SchemaStateRow,
1678
+ contentTypeNodeId: number,
1679
+ incoming: readonly DocumentValue[],
1680
+ ): Promise<void> {
1681
+ const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
1682
+ const values = this.#intake ? await this.#intake(incoming, propertyTypes) : incoming
1683
+ const byAlias = new Map(propertyTypes.map((p) => [p.alias, p]))
1684
+ const byId = new Map(propertyTypes.map((p) => [p.id, p]))
1685
+ const languages = await this.#languages()
1686
+ const idByIso = new Map(languages.map((l) => [l.isoCode.toLowerCase(), l.id]))
1687
+ // Equality is judged against what this node sees, so re-saving an unchanged
1688
+ // value never overwrites a conversion appended under a newer state; the
1689
+ // row superseded is the true current one.
1690
+ const current = new Map(
1691
+ (await this.#visibleValueRows(nodeId)).map((row) => [
1692
+ keyOf(row.propertyTypeId, row.languageId, row.segment),
1693
+ row,
1694
+ ]),
1695
+ )
1696
+ const trueCurrent = new Map(
1697
+ (await this.#currentValueRows(nodeId)).map((row) => [
1698
+ keyOf(row.propertyTypeId, row.languageId, row.segment),
1699
+ row,
1700
+ ]),
1701
+ )
1702
+
1703
+ const seen = new Set<string>()
1704
+ for (const value of values) {
1705
+ const propertyType = byAlias.get(value.alias)
1706
+ if (!propertyType) continue
1707
+ const culture = propertyType.variesByCulture ? value.culture : null
1708
+ const languageId = culture ? (idByIso.get(culture.toLowerCase()) ?? null) : null
1709
+ const key = keyOf(propertyType.id, languageId, value.segment ?? null)
1710
+ if (seen.has(key)) continue
1711
+ seen.add(key)
1712
+ const existing = current.get(key)
1713
+ const raw = existing
1714
+ ? (existing.raw as Record<string, unknown>)[STORAGE_COLUMN[propertyType.storage]]
1715
+ : null
1716
+ if (existing && this.#storedEquals(raw, value.value, propertyType.storage)) continue
1717
+ if (!existing && (value.value === null || value.value === undefined)) continue
1718
+ await this.#insertValue(
1719
+ nodeId,
1720
+ eventId,
1721
+ state.id,
1722
+ propertyType,
1723
+ languageId,
1724
+ value.segment ?? null,
1725
+ value.value,
1726
+ trueCurrent.get(key)?.id ?? existing?.id,
1727
+ )
1728
+ }
1729
+ // A value that was current but is no longer sent is cleared, as a version.
1730
+ for (const [key, row] of current) {
1731
+ if (seen.has(key)) continue
1732
+ const propertyType = byId.get(row.propertyTypeId)
1733
+ if (!propertyType) continue
1734
+ const raw = (row.raw as Record<string, unknown>)[STORAGE_COLUMN[propertyType.storage]]
1735
+ if (raw === null || raw === undefined) continue
1736
+ await this.#insertValue(
1737
+ nodeId,
1738
+ eventId,
1739
+ state.id,
1740
+ propertyType,
1741
+ row.languageId,
1742
+ row.segment,
1743
+ null,
1744
+ trueCurrent.get(key)?.id ?? row.id,
1745
+ )
1746
+ }
1747
+ }
1748
+
1749
+ /** Makes `target` the current set: used by rollback. */
1750
+ async #appendRows(
1751
+ nodeId: number,
1752
+ eventId: number,
1753
+ state: SchemaStateRow,
1754
+ target: readonly ValueRow[],
1755
+ current: readonly ValueRow[],
1756
+ ): Promise<void> {
1757
+ const types = new Map<number, PropertyTypeRow>()
1758
+ for (const row of [...target, ...current]) {
1759
+ if (types.has(row.propertyTypeId)) continue
1760
+ const found = await this.#db.query(
1761
+ 'SELECT p.id, p.alias, p.variations, d.db_type, d.editor_alias FROM property_type p JOIN data_type d ON d.node_id = p.data_type_id WHERE p.id = ?',
1762
+ [row.propertyTypeId],
1763
+ )
1764
+ const r = found[0]
1765
+ if (r)
1766
+ types.set(row.propertyTypeId, {
1767
+ id: Number(r.id),
1768
+ alias: String(r.alias),
1769
+ name: String(r.alias),
1770
+ variesByCulture: flagVariesByCulture(Number(r.variations)),
1771
+ storage: String(r.db_type) as ValueStorageTypeName,
1772
+ editorAlias: String(r.editor_alias),
1773
+ config: {},
1774
+ // Only storage matters here: writes never validate or render.
1775
+ mandatory: false,
1776
+ mandatoryMessage: null,
1777
+ regEx: null,
1778
+ regExMessage: null,
1779
+ pending: false,
1780
+ })
1781
+ }
1782
+ const currentByKey = new Map(
1783
+ current.map((row) => [keyOf(row.propertyTypeId, row.languageId, row.segment), row]),
1784
+ )
1785
+ const seen = new Set<string>()
1786
+ for (const row of target) {
1787
+ const propertyType = types.get(row.propertyTypeId)
1788
+ if (!propertyType) continue
1789
+ const key = keyOf(row.propertyTypeId, row.languageId, row.segment)
1790
+ seen.add(key)
1791
+ const column = STORAGE_COLUMN[propertyType.storage]
1792
+ const wanted = this.#fromStorage(
1793
+ (row.raw as Record<string, unknown>)[column],
1794
+ propertyType.storage,
1795
+ )
1796
+ const existing = currentByKey.get(key)
1797
+ if (
1798
+ existing &&
1799
+ this.#storedEquals(
1800
+ (existing.raw as Record<string, unknown>)[column],
1801
+ wanted,
1802
+ propertyType.storage,
1803
+ )
1804
+ )
1805
+ continue
1806
+ await this.#insertValue(
1807
+ nodeId,
1808
+ eventId,
1809
+ state.id,
1810
+ propertyType,
1811
+ row.languageId,
1812
+ row.segment,
1813
+ wanted,
1814
+ existing?.id,
1815
+ )
1816
+ }
1817
+ for (const [key, row] of currentByKey) {
1818
+ if (seen.has(key)) continue
1819
+ const propertyType = types.get(row.propertyTypeId)
1820
+ if (!propertyType) continue
1821
+ await this.#insertValue(
1822
+ nodeId,
1823
+ eventId,
1824
+ state.id,
1825
+ propertyType,
1826
+ row.languageId,
1827
+ row.segment,
1828
+ null,
1829
+ row.id,
1830
+ )
1831
+ }
1832
+ }
1833
+
1834
+ async #insertValue(
1835
+ nodeId: number,
1836
+ eventId: number,
1837
+ schemaStateId: number,
1838
+ propertyType: PropertyTypeRow,
1839
+ languageId: number | null,
1840
+ segment: string | null,
1841
+ value: unknown,
1842
+ supersedes: number | undefined,
1843
+ ): Promise<void> {
1844
+ const column = STORAGE_COLUMN[propertyType.storage]
1845
+ if (supersedes !== undefined) {
1846
+ await this.#db.exec('UPDATE property_value SET is_current = ? WHERE id = ?', [
1847
+ this.#db.dialect.boolValue(false),
1848
+ supersedes,
1849
+ ])
1850
+ }
1851
+ await this.#db.exec(
1852
+ `INSERT INTO property_value (node_id, property_type_id, language_id, segment, event_id, schema_state_id, is_current, ${column}, sortable_value)
1853
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1854
+ [
1855
+ nodeId,
1856
+ propertyType.id,
1857
+ languageId,
1858
+ segment,
1859
+ eventId,
1860
+ schemaStateId,
1861
+ this.#db.dialect.boolValue(true),
1862
+ this.#toStorage(value, propertyType.storage),
1863
+ typeof value === 'string' ? value.slice(0, 512) : null,
1864
+ ],
1865
+ )
1866
+ }
1867
+
1868
+ /**
1869
+ * A mandatory property must have a current value before publishing. Pending
1870
+ * properties are exempt: a field created early for the next deploy never
1871
+ * blocks a publish on the live site.
1872
+ */
1873
+ async #assertMandatoryFilled(node: NodeRow, cultures: readonly string[] | null): Promise<void> {
1874
+ const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
1875
+ if (!contentTypeNodeId) return
1876
+ const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
1877
+ const languages = await this.#languages()
1878
+ const values = await this.#readValues(node.id, propertyTypes, languages, {})
1879
+ const errors = validateValues(
1880
+ propertyTypes,
1881
+ values,
1882
+ cultures ?? languages.map((l) => l.isoCode),
1883
+ )
1884
+ if (errors.length > 0) {
1885
+ const names = errors.map(
1886
+ (e) => `'${propertyTypes.find((p) => p.alias === e.alias)?.name ?? e.alias}'`,
1887
+ )
1888
+ throw new PublishBlockedError(
1889
+ `Required: ${[...new Set(names)].join(', ')} must have a value before publishing.`,
1890
+ )
1891
+ }
1892
+ }
1893
+
1894
+ /**
1895
+ * What the editor is told before a save: mandatory and pattern rules over the
1896
+ * values as sent, per culture the document has (or the cultures asked for).
1897
+ * Pending properties are exempt, as at publish.
1898
+ */
1899
+ async validate(
1900
+ input: {
1901
+ contentTypeKey?: string
1902
+ key?: string
1903
+ values: readonly DocumentValue[]
1904
+ variants: SaveDocumentInput['variants']
1905
+ },
1906
+ cultures: readonly string[] | null = null,
1907
+ ): Promise<DocumentValidationError[]> {
1908
+ let contentTypeNodeId: number | undefined
1909
+ if (input.contentTypeKey)
1910
+ contentTypeNodeId = (await this.#nodes.byKey(input.contentTypeKey))?.id
1911
+ else if (input.key) {
1912
+ const node = await this.#nodes.byKey(input.key)
1913
+ if (node) contentTypeNodeId = await this.#contentTypeNodeId(node.id)
1914
+ }
1915
+ if (!contentTypeNodeId) return []
1916
+ const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
1917
+ const variantCultures = input.variants
1918
+ .map((v) => v.culture)
1919
+ .filter((c): c is string => typeof c === 'string')
1920
+ return validateValues(propertyTypes, input.values, cultures ?? variantCultures)
1921
+ }
1922
+
1923
+ /** Creates and publishes in one go, or refuses before creating anything. */
1924
+ async createAndPublish(
1925
+ input: SaveDocumentInput,
1926
+ cultures: readonly string[] | null,
1927
+ ): Promise<
1928
+ { ok: true; document: DocumentAggregate } | { ok: false; errors: DocumentValidationError[] }
1929
+ > {
1930
+ const errors = await this.validate(input, cultures)
1931
+ if (errors.length > 0) return { ok: false, errors }
1932
+ if (input.parentKey) {
1933
+ const parent = await this.#nodes.byKey(input.parentKey)
1934
+ if (parent && !(await this.#ancestorsPublished({ ...parent, path: `${parent.path},0` })))
1935
+ throw new PublishBlockedError('An ancestor is not published.')
1936
+ }
1937
+ const created = await this.create(input)
1938
+ const published = await this.publish(created.key, cultures)
1939
+ return { ok: true, document: published ?? created }
1940
+ }
1941
+
1942
+ async updateAndPublish(
1943
+ input: SaveDocumentInput,
1944
+ cultures: readonly string[] | null,
1945
+ ): Promise<
1946
+ | { ok: true; document: DocumentAggregate }
1947
+ | { ok: false; errors: DocumentValidationError[] }
1948
+ | undefined
1949
+ > {
1950
+ const errors = await this.validate(input, cultures)
1951
+ if (errors.length > 0) return { ok: false, errors }
1952
+ const updated = await this.update(input)
1953
+ if (!updated) return undefined
1954
+ const published = await this.publish(input.key, cultures)
1955
+ return { ok: true, document: published ?? updated }
1956
+ }
1957
+
1958
+ /** The published version's values, or undefined when nothing is published. */
1959
+ async byKeyPublished(key: string): Promise<DocumentAggregate | undefined> {
1960
+ const node = await this.#nodes.byKey(key)
1961
+ if (!node) return undefined
1962
+ const draft = await this.#load(node)
1963
+ if (!draft?.published) return undefined
1964
+ const published = await this.#db.query<{ id: number }>(
1965
+ 'SELECT cv.id FROM content_version cv JOIN document_version dv ON dv.id = cv.id AND dv.published = ? WHERE cv.node_id = ? ORDER BY cv.id DESC LIMIT 1',
1966
+ [this.#db.dialect.boolValue(true), node.id],
1967
+ )
1968
+ const eventId = Number(published[0]?.id)
1969
+ const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
1970
+ if (!contentTypeNodeId || !eventId) return undefined
1971
+ const propertyTypes = await this.#propertyTypes(contentTypeNodeId)
1972
+ const values = await this.#readPublishedValues(
1973
+ node.id,
1974
+ propertyTypes,
1975
+ await this.#languages(),
1976
+ eventId,
1977
+ await this.#publishedCultureEvents(node.id),
1978
+ )
1979
+ return { ...draft, values }
1980
+ }
1981
+
1982
+ /** The content tree's view of documents, one query per page for the aggregates it decorates with. */
1983
+ async treeItems(rows: readonly NodeRow[]): Promise<DocumentTreeItem[]> {
1984
+ const counts = await this.#nodes.childCounts(
1985
+ rows.map((row) => row.id),
1986
+ this.objectType,
1987
+ )
1988
+ const items: DocumentTreeItem[] = []
1989
+ for (const row of rows) {
1990
+ const aggregate = await this.#load(row)
1991
+ if (!aggregate) continue
1992
+ const ancestors = await this.#nodes.ancestors(row.key)
1993
+ items.push({
1994
+ key: row.key,
1995
+ name: row.text ?? '',
1996
+ hasChildren: (counts.get(row.id) ?? 0) > 0,
1997
+ parentKey: aggregate.parentKey,
1998
+ contentTypeKey: aggregate.contentTypeKey,
1999
+ contentTypeCollectionKey: aggregate.contentTypeCollectionKey ?? null,
2000
+ icon: aggregate.contentTypeIcon,
2001
+ isTrashed: row.trashed,
2002
+ createDate: row.createDate ?? new Date(),
2003
+ ancestorKeys: ancestors.filter((a) => a.objectType === this.objectType).map((a) => a.key),
2004
+ variants: aggregate.variants.map((v) => ({
2005
+ culture: v.culture,
2006
+ name: v.name,
2007
+ state: v.state,
2008
+ })),
2009
+ })
2010
+ }
2011
+ return items
2012
+ }
2013
+
2014
+ /** Where a trashed document was before the bin, when it was not at the root. */
2015
+ async originalParent(key: string): Promise<string | null | undefined> {
2016
+ const node = await this.#nodes.byKey(key)
2017
+ if (!node) return undefined
2018
+ const rows = await this.#db.query<{ original_parent_id: number | null }>(
2019
+ 'SELECT original_parent_id FROM document WHERE node_id = ?',
2020
+ [node.id],
2021
+ )
2022
+ const parentId = rows[0]?.original_parent_id
2023
+ if (parentId === null || parentId === undefined) return null
2024
+ return (await this.#nodes.byId(Number(parentId)))?.key ?? null
2025
+ }
2026
+
2027
+ /** Other nodes drop their published cache when they see this. */
2028
+ async #instructContent(key: string): Promise<void> {
2029
+ await appendCacheInstruction(this.#db, { kind: 'content', payload: { key }, by: this.#nodeId })
2030
+ }
2031
+
2032
+ async #insertEvent(
2033
+ nodeId: number,
2034
+ kind: EventKind,
2035
+ name: string,
2036
+ userId: number | undefined,
2037
+ templateId: number | null,
2038
+ published: boolean,
2039
+ ): Promise<number> {
2040
+ const t = this.#db.dialect.boolValue(true)
2041
+ const f = this.#db.dialect.boolValue(false)
2042
+ await this.#db.exec('UPDATE content_version SET current = ? WHERE node_id = ?', [f, nodeId])
2043
+ await this.#db.exec(
2044
+ `INSERT INTO content_version (node_id, kind, version_date, user_id, current, text, prevent_cleanup) VALUES (?, ?, ?, ?, ?, ?, ?)`,
2045
+ [nodeId, kind, DbDate.toDb(new Date()), userId ?? null, t, name, f],
2046
+ )
2047
+ const rows = await this.#db.query<{ id: number }>(
2048
+ 'SELECT MAX(id) AS id FROM content_version WHERE node_id = ?',
2049
+ [nodeId],
2050
+ )
2051
+ const eventId = Number(rows[0]?.id)
2052
+ await this.#db.exec(
2053
+ 'INSERT INTO document_version (id, template_id, published) VALUES (?, ?, ?)',
2054
+ [eventId, templateId, this.#db.dialect.boolValue(published)],
2055
+ )
2056
+ return eventId
2057
+ }
2058
+
2059
+ async #headEvent(
2060
+ nodeId: number,
2061
+ ): Promise<
2062
+ { id: number; name: string; userId: number | undefined; templateId: number | null } | undefined
2063
+ > {
2064
+ const rows = await this.#db.query(
2065
+ `SELECT cv.id, cv.text, cv.user_id, dv.template_id FROM content_version cv JOIN document_version dv ON dv.id = cv.id
2066
+ WHERE cv.node_id = ? ORDER BY cv.id DESC LIMIT 1`,
2067
+ [nodeId],
2068
+ )
2069
+ const row = rows[0]
2070
+ if (!row) return undefined
2071
+ return {
2072
+ id: Number(row.id),
2073
+ name: String(row.text ?? ''),
2074
+ userId: row.user_id === null || row.user_id === undefined ? undefined : Number(row.user_id),
2075
+ templateId:
2076
+ row.template_id === null || row.template_id === undefined ? null : Number(row.template_id),
2077
+ }
2078
+ }
2079
+
2080
+ async #publishedEventId(nodeId: number): Promise<number | undefined> {
2081
+ const rows = await this.#db.query<{ id: number }>(
2082
+ 'SELECT cv.id FROM content_version cv JOIN document_version dv ON dv.id = cv.id WHERE cv.node_id = ? AND dv.published = ?',
2083
+ [nodeId, this.#db.dialect.boolValue(true)],
2084
+ )
2085
+ return rows[0] ? Number(rows[0].id) : undefined
2086
+ }
2087
+
2088
+ /** `edited`: any value newer than the published event, or a changed name. */
2089
+ async #recomputeEdited(nodeId: number): Promise<void> {
2090
+ const publishedId = await this.#publishedEventId(nodeId)
2091
+ let edited = true
2092
+ if (publishedId !== undefined) {
2093
+ const newer = await this.#db.query<{ n: number }>(
2094
+ 'SELECT COUNT(*) AS n FROM property_value WHERE node_id = ? AND event_id > ?',
2095
+ [nodeId, publishedId],
2096
+ )
2097
+ const names = await this.#db.query<{ head: string | null; pub: string | null }>(
2098
+ `SELECT (SELECT text FROM content_version WHERE node_id = ? ORDER BY id DESC LIMIT 1) AS head,
2099
+ (SELECT text FROM content_version WHERE id = ?) AS pub`,
2100
+ [nodeId, publishedId],
2101
+ )
2102
+ edited = Number(newer[0]?.n ?? 0) > 0 || (names[0]?.head ?? '') !== (names[0]?.pub ?? '')
2103
+ }
2104
+ await this.#db.exec('UPDATE document SET edited = ? WHERE node_id = ?', [
2105
+ this.#db.dialect.boolValue(edited),
2106
+ nodeId,
2107
+ ])
2108
+ // A published culture is edited when its values, the invariant ones, or its name moved on
2109
+ for (const culture of await this.#publishedCultureEvents(nodeId)) {
2110
+ const newer = await this.#db.query<{ n: number }>(
2111
+ `SELECT COUNT(*) AS n FROM property_value
2112
+ WHERE node_id = ? AND event_id > ? AND (language_id = ? OR language_id IS NULL)`,
2113
+ [nodeId, culture.eventId, culture.languageId],
2114
+ )
2115
+ const names = await this.#db.query<{ name: string | null; published_name: string | null }>(
2116
+ 'SELECT name, published_name FROM document_culture_variation WHERE node_id = ? AND language_id = ?',
2117
+ [nodeId, culture.languageId],
2118
+ )
2119
+ const cultureEdited =
2120
+ Number(newer[0]?.n ?? 0) > 0 || (names[0]?.name ?? '') !== (names[0]?.published_name ?? '')
2121
+ await this.#db.exec(
2122
+ 'UPDATE document_culture_variation SET edited = ? WHERE node_id = ? AND language_id = ?',
2123
+ [this.#db.dialect.boolValue(cultureEdited), nodeId, culture.languageId],
2124
+ )
2125
+ }
2126
+ }
2127
+
2128
+ async #readVariants(node: NodeRow, row: Record<string, unknown>): Promise<DocumentVariant[]> {
2129
+ const variants = await this.#readVariantsUnscheduled(node, row)
2130
+ const schedules = await this.schedules(node.key)
2131
+ if (schedules.length === 0) return variants
2132
+ return variants.map((variant) => {
2133
+ const mine = schedules.filter(
2134
+ (s) => (s.culture ?? '').toLowerCase() === (variant.culture ?? '').toLowerCase(),
2135
+ )
2136
+ return {
2137
+ ...variant,
2138
+ scheduledPublishDate: mine.find((s) => s.action === 'Release')?.date ?? null,
2139
+ scheduledUnpublishDate: mine.find((s) => s.action === 'Expire')?.date ?? null,
2140
+ }
2141
+ })
2142
+ }
2143
+
2144
+ async #readVariantsUnscheduled(
2145
+ node: NodeRow,
2146
+ row: Record<string, unknown>,
2147
+ ): Promise<DocumentVariant[]> {
2148
+ const published = fromDbBool(row.published)
2149
+ const edited = fromDbBool(row.edited)
2150
+ const createDate = node.createDate ?? new Date()
2151
+ const updateDate = DbDate.fromDb(row.draft_date) ?? createDate
2152
+ const publishDate = DbDate.fromDb(row.published_date) ?? null
2153
+ const cultureRows = await this.#db.query(
2154
+ `SELECT dcv.name, dcv.published, dcv.edited, dcv.available, l.iso_code
2155
+ FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id WHERE dcv.node_id = ?`,
2156
+ [node.id],
2157
+ )
2158
+ if (cultureRows.length === 0) {
2159
+ return [
2160
+ {
2161
+ culture: null,
2162
+ segment: null,
2163
+ name: String(row.draft_name ?? node.text ?? ''),
2164
+ state: stateFor(published, edited, node.trashed),
2165
+ createDate,
2166
+ updateDate,
2167
+ publishDate,
2168
+ },
2169
+ ]
2170
+ }
2171
+ return cultureRows.map((cultureRow) => ({
2172
+ culture: String(cultureRow.iso_code),
2173
+ segment: null,
2174
+ name: String(cultureRow.name ?? ''),
2175
+ state: stateFor(
2176
+ fromDbBool(cultureRow.published),
2177
+ fromDbBool(cultureRow.edited),
2178
+ node.trashed,
2179
+ fromDbBool(cultureRow.available),
2180
+ ),
2181
+ createDate,
2182
+ updateDate,
2183
+ publishDate,
2184
+ }))
2185
+ }
2186
+
2187
+ async #writeCultureVariations(
2188
+ nodeId: number,
2189
+ eventId: number,
2190
+ variants: ReadonlyArray<{ culture: string | null; name: string }>,
2191
+ ): Promise<void> {
2192
+ const cultured = variants.filter((variant) => variant.culture !== null)
2193
+ if (cultured.length === 0) return
2194
+ const languages = await this.#languages()
2195
+ const idByIso = new Map(languages.map((l) => [l.isoCode.toLowerCase(), l.id]))
2196
+ const now = DbDate.toDb(new Date())
2197
+ const t = this.#db.dialect.boolValue(true)
2198
+ const f = this.#db.dialect.boolValue(false)
2199
+ for (const variant of cultured) {
2200
+ const languageId = idByIso.get(String(variant.culture).toLowerCase())
2201
+ if (!languageId) continue
2202
+ await this.#db.exec(
2203
+ 'INSERT INTO content_version_culture_variation (version_id, language_id, name, date) VALUES (?, ?, ?, ?)',
2204
+ [eventId, languageId, variant.name, now],
2205
+ )
2206
+ const existing = await this.#db.query<{ id: number }>(
2207
+ 'SELECT id FROM document_culture_variation WHERE node_id = ? AND language_id = ?',
2208
+ [nodeId, languageId],
2209
+ )
2210
+ if (existing[0]) {
2211
+ await this.#db.exec(
2212
+ 'UPDATE document_culture_variation SET name = ?, available = ?, edited = ? WHERE id = ?',
2213
+ [variant.name, t, t, Number(existing[0].id)],
2214
+ )
2215
+ } else {
2216
+ await this.#db.exec(
2217
+ 'INSERT INTO document_culture_variation (node_id, language_id, edited, available, published, name) VALUES (?, ?, ?, ?, ?, ?)',
2218
+ [nodeId, languageId, t, t, f, variant.name],
2219
+ )
2220
+ }
2221
+ }
2222
+ }
2223
+
2224
+ async #copyCultureVariations(fromEventId: number, toEventId: number): Promise<void> {
2225
+ await this.#db.exec(
2226
+ `INSERT INTO content_version_culture_variation (version_id, language_id, name, date, available_user_id)
2227
+ SELECT ?, language_id, name, date, available_user_id FROM content_version_culture_variation WHERE version_id = ?`,
2228
+ [toEventId, fromEventId],
2229
+ )
2230
+ }
2231
+
2232
+ async #availableCultures(nodeId: number): Promise<string[]> {
2233
+ const rows = await this.#db.query<{ iso_code: string }>(
2234
+ 'SELECT l.iso_code FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id WHERE dcv.node_id = ? AND dcv.available = ?',
2235
+ [nodeId, this.#db.dialect.boolValue(true)],
2236
+ )
2237
+ return rows.map((row) => String(row.iso_code))
2238
+ }
2239
+
2240
+ async #publishedCultures(nodeId: number): Promise<string[]> {
2241
+ const rows = await this.#db.query<{ iso_code: string }>(
2242
+ 'SELECT l.iso_code FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id WHERE dcv.node_id = ? AND dcv.published = ?',
2243
+ [nodeId, this.#db.dialect.boolValue(true)],
2244
+ )
2245
+ return rows.map((row) => String(row.iso_code))
2246
+ }
2247
+
2248
+ /** Publishes a culture at `eventId` with its current name, or unpublishes it (`null`). */
2249
+ async #setCulturePublished(nodeId: number, iso: string, eventId: number | null): Promise<void> {
2250
+ const languages = await this.#languages()
2251
+ const languageId = languages.find((l) => l.isoCode.toLowerCase() === iso.toLowerCase())?.id
2252
+ if (!languageId) return
2253
+ if (eventId === null)
2254
+ await this.#db.exec(
2255
+ 'UPDATE document_culture_variation SET published = ?, edited = ? WHERE node_id = ? AND language_id = ?',
2256
+ [this.#db.dialect.boolValue(false), this.#db.dialect.boolValue(true), nodeId, languageId],
2257
+ )
2258
+ else
2259
+ await this.#db.exec(
2260
+ `UPDATE document_culture_variation SET published = ?, edited = ?, published_event_id = ?,
2261
+ published_name = name WHERE node_id = ? AND language_id = ?`,
2262
+ [
2263
+ this.#db.dialect.boolValue(true),
2264
+ this.#db.dialect.boolValue(false),
2265
+ eventId,
2266
+ nodeId,
2267
+ languageId,
2268
+ ],
2269
+ )
2270
+ }
2271
+
2272
+ /** Each published culture: its language, the event it was published at, and its published name. */
2273
+ async #publishedCultureEvents(
2274
+ nodeId: number,
2275
+ ): Promise<Array<{ languageId: number; isoCode: string; eventId: number; name: string }>> {
2276
+ const rows = await this.#db.query(
2277
+ `SELECT dcv.language_id, l.iso_code, dcv.published_event_id, dcv.published_name, dcv.name
2278
+ FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
2279
+ WHERE dcv.node_id = ? AND dcv.published = ?`,
2280
+ [nodeId, this.#db.dialect.boolValue(true)],
2281
+ )
2282
+ return rows.map((row) => ({
2283
+ languageId: Number(row.language_id),
2284
+ isoCode: String(row.iso_code),
2285
+ eventId: Number(row.published_event_id ?? 0),
2286
+ name: String(row.published_name ?? row.name ?? ''),
2287
+ }))
2288
+ }
2289
+
2290
+ async #variesByCulture(nodeId: number): Promise<boolean> {
2291
+ const rows = await this.#db.query<{ variations: number }>(
2292
+ 'SELECT ct.variations FROM content c JOIN content_type ct ON ct.node_id = c.content_type_id WHERE c.node_id = ?',
2293
+ [nodeId],
2294
+ )
2295
+ return flagVariesByCulture(Number(rows[0]?.variations ?? 0))
2296
+ }
2297
+
2298
+ /**
2299
+ * Published values: invariant ones as of the latest publish, each culture's
2300
+ * as of its own. A culture that is not published contributes nothing.
2301
+ */
2302
+ async #readPublishedValues(
2303
+ nodeId: number,
2304
+ propertyTypes: readonly PropertyTypeRow[],
2305
+ languages: readonly LanguageRow[],
2306
+ invariantEventId: number,
2307
+ cultures: ReadonlyArray<{ languageId: number; eventId: number }>,
2308
+ schemaStateId?: number,
2309
+ ): Promise<DocumentValue[]> {
2310
+ const cutoff = new Map(cultures.map((c) => [c.languageId, c.eventId]))
2311
+ const latest = Math.max(invariantEventId, ...cultures.map((c) => c.eventId))
2312
+ const conditions = ['node_id = ?', 'event_id <= ?']
2313
+ const params: unknown[] = [nodeId, latest]
2314
+ if (schemaStateId !== undefined) {
2315
+ conditions.push('schema_state_id <= ?')
2316
+ params.push(schemaStateId)
2317
+ }
2318
+ const all = (
2319
+ await this.#db.query(
2320
+ `SELECT id, event_id, property_type_id, language_id, segment,
2321
+ int_value, decimal_value, date_value, varchar_value, text_value
2322
+ FROM property_value WHERE ${conditions.join(' AND ')} ORDER BY id`,
2323
+ params,
2324
+ )
2325
+ ).map((row) => this.#toValueRow(row))
2326
+ const chosen = new Map<string, ValueRow>()
2327
+ for (const row of all) {
2328
+ const limit = row.languageId === null ? invariantEventId : cutoff.get(row.languageId)
2329
+ if (limit === undefined || row.eventId > limit) continue
2330
+ chosen.set(keyOf(row.propertyTypeId, row.languageId, row.segment), row)
2331
+ }
2332
+ return this.#valuesFromRows([...chosen.values()], propertyTypes, languages)
2333
+ }
2334
+
2335
+ async #anyCulturePublished(nodeId: number): Promise<boolean> {
2336
+ const rows = await this.#db.query<{ n: number }>(
2337
+ 'SELECT COUNT(*) AS n FROM document_culture_variation WHERE node_id = ? AND published = ?',
2338
+ [nodeId, this.#db.dialect.boolValue(true)],
2339
+ )
2340
+ return Number(rows[0]?.n ?? 0) > 0
2341
+ }
2342
+
2343
+ async #ancestorsPublished(
2344
+ node: NodeRow,
2345
+ alsoPublishing: ReadonlySet<number> = new Set(),
2346
+ ): Promise<boolean> {
2347
+ const ids = node.path
2348
+ .split(',')
2349
+ .map(Number)
2350
+ .filter((id) => id > 0 && id !== node.id && !alsoPublishing.has(id))
2351
+ if (ids.length === 0) return true
2352
+ const rows = await this.#db.query<{ n: number }>(
2353
+ `SELECT COUNT(*) AS n FROM document WHERE node_id IN (${ids.map(() => '?').join(', ')}) AND published = ?`,
2354
+ [...ids, this.#db.dialect.boolValue(false)],
2355
+ )
2356
+ return Number(rows[0]?.n ?? 0) === 0
2357
+ }
2358
+ }
2359
+
2360
+ function stateFor(
2361
+ published: boolean,
2362
+ edited: boolean,
2363
+ trashed: boolean,
2364
+ available = true,
2365
+ ): VariantState {
2366
+ if (trashed) return 'Trashed'
2367
+ if (!available) return 'NotCreated'
2368
+ if (published) return edited ? 'PublishedPendingChanges' : 'Published'
2369
+ return 'Draft'
2370
+ }
2371
+
2372
+ /** `name`, or the first of `name (1)`, `name (2)`… no sibling already has. */
2373
+ export function uniqueName(name: string, taken: ReadonlySet<string>): string {
2374
+ if (!taken.has(name.toLowerCase())) return name
2375
+ for (let n = 1; ; n++) {
2376
+ const candidate = `${name} (${n})`
2377
+ if (!taken.has(candidate.toLowerCase())) return candidate
2378
+ }
2379
+ }
2380
+
2381
+ function parseConfig(raw: unknown): Record<string, unknown> {
2382
+ if (typeof raw !== 'string' || !raw) return {}
2383
+ try {
2384
+ const parsed = JSON.parse(raw)
2385
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
2386
+ } catch {
2387
+ return {}
2388
+ }
2389
+ }