@crombolo/data 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,7 +27,7 @@ import {
27
27
  import { appendCacheInstruction } from '../cluster.ts'
28
28
  import type { Db } from '../database.ts'
29
29
  import { DbDate, fromDbBool } from '../dialect.ts'
30
- import { Locks } from '../locks.ts'
30
+ import { type LockId, Locks } from '../locks.ts'
31
31
  import { isPending } from '../pending.ts'
32
32
  import {
33
33
  assertNodeMayWrite,
@@ -86,6 +86,13 @@ export interface MigratedValue {
86
86
  storage?: ValueStorageTypeName
87
87
  }
88
88
 
89
+ interface PublishedCulture {
90
+ languageId: number
91
+ isoCode: string
92
+ eventId: number
93
+ name: string
94
+ }
95
+
89
96
  interface ValueRow {
90
97
  eventId: number
91
98
  id: number
@@ -133,17 +140,24 @@ export type ContentKind = 'document' | 'media' | 'member' | 'blueprint' | 'eleme
133
140
 
134
141
  const KINDS: Record<
135
142
  ContentKind,
136
- { objectType: string; recycleBin: number | undefined; container: string | undefined }
143
+ {
144
+ objectType: string
145
+ recycleBin: number | undefined
146
+ container: string | undefined
147
+ lock: LockId
148
+ }
137
149
  > = {
138
150
  document: {
139
151
  objectType: ObjectTypes.Document,
140
152
  recycleBin: SystemNodes.ContentRecycleBin,
141
153
  container: undefined,
154
+ lock: Locks.ContentTree,
142
155
  },
143
156
  media: {
144
157
  objectType: ObjectTypes.Media,
145
158
  recycleBin: SystemNodes.MediaRecycleBin,
146
159
  container: undefined,
160
+ lock: Locks.MediaTree,
147
161
  },
148
162
  // Members are a flat list at the tree root: no bin to trash them into, and
149
163
  // no folders to gather them in.
@@ -151,11 +165,13 @@ const KINDS: Record<
151
165
  objectType: ObjectTypes.Member,
152
166
  recycleBin: undefined,
153
167
  container: undefined,
168
+ lock: Locks.MemberTree,
154
169
  },
155
170
  blueprint: {
156
171
  objectType: ObjectTypes.DocumentBlueprint,
157
172
  recycleBin: undefined,
158
173
  container: ObjectTypes.DocumentBlueprintContainer,
174
+ lock: Locks.ContentTree,
159
175
  },
160
176
  // Umbraco 18's Elements: publishable content with no URL, gathered in folders
161
177
  // and trashed into a bin of its own at -22, as its Constants.System says.
@@ -163,6 +179,7 @@ const KINDS: Record<
163
179
  objectType: ObjectTypes.Element,
164
180
  recycleBin: SystemNodes.ElementRecycleBin,
165
181
  container: ObjectTypes.ElementContainer,
182
+ lock: Locks.ContentTree,
166
183
  },
167
184
  }
168
185
 
@@ -201,6 +218,9 @@ export interface DocumentRepositoryOptions {
201
218
  /** Thrown when a publish is refused by a business rule rather than failing. */
202
219
  export class PublishBlockedError extends Error {}
203
220
 
221
+ /** The parent named for a new item is gone, or in the recycle bin. */
222
+ export class InvalidParentError extends Error {}
223
+
204
224
  const isEmpty = (value: unknown) =>
205
225
  value === null || value === undefined || (typeof value === 'string' && value.trim() === '')
206
226
 
@@ -250,6 +270,39 @@ function validateValues(
250
270
  const keyOf = (propertyTypeId: number, languageId: number | null, segment: string | null) =>
251
271
  `${propertyTypeId}|${languageId ?? ''}|${segment ?? ''}`
252
272
 
273
+ /**
274
+ * The value rows a published node shows, from its history in id order:
275
+ * invariant values as of its latest publish, each culture's as of its own.
276
+ * A culture that is not published contributes nothing. Pure.
277
+ */
278
+ export function publishedValueRows<
279
+ R extends {
280
+ eventId: number
281
+ propertyTypeId: number
282
+ languageId: number | null
283
+ segment: string | null
284
+ },
285
+ >(
286
+ history: readonly R[],
287
+ invariantEventId: number,
288
+ cultures: ReadonlyArray<{ languageId: number; eventId: number }>,
289
+ ): R[] {
290
+ const cutoff = new Map(cultures.map((c) => [c.languageId, c.eventId]))
291
+ const chosen = new Map<string, R>()
292
+ for (const row of history) {
293
+ const limit = row.languageId === null ? invariantEventId : cutoff.get(row.languageId)
294
+ if (limit === undefined || row.eventId > limit) continue
295
+ chosen.set(keyOf(row.propertyTypeId, row.languageId, row.segment), row)
296
+ }
297
+ return [...chosen.values()]
298
+ }
299
+
300
+ /** Ids in slices small enough for either dialect's parameter limit. */
301
+ const slices = <T>(items: readonly T[], size = 500): T[][] =>
302
+ Array.from({ length: Math.ceil(items.length / size) }, (_, n) =>
303
+ items.slice(n * size, (n + 1) * size),
304
+ )
305
+
253
306
  export class DocumentRepository {
254
307
  #db: Db
255
308
  #nodes: NodeRepository
@@ -278,13 +331,13 @@ export class DocumentRepository {
278
331
  }
279
332
 
280
333
  /**
281
- * The content-tree lock, unless the caller already holds it. Every write that
282
- * depends on sibling order or on a branch not moving under it goes through
283
- * here rather than taking the lock directly, so `inTreeLock` is honoured in
284
- * one place instead of six.
334
+ * The tree's lock, unless the caller already holds it. Every write goes
335
+ * through here, as every Umbraco content service write takes its tree's write
336
+ * lock: two saves, or a save and a move, never interleave. The node a write
337
+ * acts on is read inside, so it acts on where the node is now.
285
338
  */
286
339
  #withTree<T>(fn: () => Promise<T>): Promise<T> {
287
- return this.#inTreeLock ? fn() : this.#db.locks.withLock(Locks.ContentTree, fn)
340
+ return this.#inTreeLock ? fn() : this.#db.locks.withLock(KINDS[this.kind].lock, fn)
288
341
  }
289
342
 
290
343
  #child(tx: Db): DocumentRepository {
@@ -392,20 +445,34 @@ export class DocumentRepository {
392
445
  [t, t, f, this.objectType],
393
446
  )
394
447
  const languages = await this.#languages()
448
+ // Read in slices rather than per node: a large site is tens of thousands
449
+ // of nodes, and a query each is most of a rebuild's time.
450
+ const typesById = new Map<number, PropertyTypeRow[]>()
451
+ const culturesById = new Map<number, PublishedCulture[]>()
452
+ const historyById = new Map<number, ValueRow[]>()
453
+ for (const slice of slices(rows.map((row) => Number(row.id)))) {
454
+ for (const [id, cultures] of await this.#publishedCultureEventsFor(slice))
455
+ culturesById.set(id, cultures)
456
+ for (const [id, history] of await this.#valueHistoryFor(slice, asOfSchemaStateId))
457
+ historyById.set(id, history)
458
+ }
395
459
  const published: PublishedNodeRow[] = []
396
460
  for (const row of rows) {
397
- // A pending property is never rendered, even if a template asks for it.
398
- const propertyTypes = (await this.#propertyTypes(Number(row.content_type_id))).filter(
399
- (p) => !p.pending,
400
- )
401
- const cultures = await this.#publishedCultureEvents(Number(row.id))
402
- const values = await this.#readPublishedValues(
403
- Number(row.id),
461
+ const typeId = Number(row.content_type_id)
462
+ if (!typesById.has(typeId))
463
+ // A pending property is never rendered, even if a template asks for it.
464
+ typesById.set(
465
+ typeId,
466
+ (await this.#propertyTypes(typeId)).filter((p) => !p.pending),
467
+ )
468
+ const propertyTypes = typesById.get(typeId) as PropertyTypeRow[]
469
+ const cultures = culturesById.get(Number(row.id)) ?? []
470
+ const latest = Math.max(Number(row.event_id), ...cultures.map((c) => c.eventId))
471
+ const history = (historyById.get(Number(row.id)) ?? []).filter((v) => v.eventId <= latest)
472
+ const values = this.#valuesFromRows(
473
+ publishedValueRows(history, Number(row.event_id), cultures),
404
474
  propertyTypes,
405
475
  languages,
406
- Number(row.event_id),
407
- cultures,
408
- asOfSchemaStateId,
409
476
  )
410
477
  published.push({
411
478
  id: Number(row.id),
@@ -427,6 +494,87 @@ export class DocumentRepository {
427
494
  return published
428
495
  }
429
496
 
497
+ /**
498
+ * Every node of this kind that is not in the bin, with the values an editor
499
+ * sees now: what the published cache holds for media, which has no publish.
500
+ * Read in slices, like `loadPublished`.
501
+ */
502
+ async loadCurrent(): Promise<
503
+ Array<{
504
+ id: number
505
+ key: string
506
+ name: string
507
+ contentTypeAlias: string
508
+ values: DocumentValue[]
509
+ parentId: number
510
+ sortOrder: number
511
+ createDate: Date
512
+ updateDate: Date
513
+ }>
514
+ > {
515
+ const t = this.#db.dialect.boolValue(true)
516
+ const rows = await this.#db.query(
517
+ `SELECT n.id, n.unique_id, n.text, n.parent_id, n.sort_order, n.create_date,
518
+ head.text AS draft_name, head.version_date,
519
+ ct.alias AS content_type_alias, c.content_type_id
520
+ FROM node n
521
+ JOIN content c ON c.node_id = n.id
522
+ JOIN content_type ct ON ct.node_id = c.content_type_id
523
+ JOIN content_version head ON head.node_id = n.id AND head.current = ?
524
+ WHERE n.node_object_type = ? AND n.trashed = ?
525
+ ORDER BY n.level, n.sort_order, n.id`,
526
+ [t, this.objectType, this.#db.dialect.boolValue(false)],
527
+ )
528
+ const languages = await this.#languages()
529
+ const stateId = await visibleStateIdFor(this.#db, this.#nodeState)
530
+ const typesById = new Map<number, PropertyTypeRow[]>()
531
+ const rowsById = new Map<number, ValueRow[]>()
532
+ for (const slice of slices(rows.map((row) => Number(row.id)))) {
533
+ if (stateId === undefined) {
534
+ const current = await this.#db.query(
535
+ `SELECT id, node_id, event_id, property_type_id, language_id, segment,
536
+ int_value, decimal_value, date_value, varchar_value, text_value
537
+ FROM property_value WHERE node_id IN (${slice.map(() => '?').join(', ')}) AND is_current = ?`,
538
+ [...slice, t],
539
+ )
540
+ for (const row of current) {
541
+ const id = Number(row.node_id)
542
+ rowsById.set(id, [...(rowsById.get(id) ?? []), this.#toValueRow(row)])
543
+ }
544
+ } else {
545
+ // As of the state this node reads: the latest value per key at or before it.
546
+ for (const [id, history] of await this.#valueHistoryFor(slice, stateId)) {
547
+ const latest = new Map<string, ValueRow>()
548
+ for (const row of history)
549
+ latest.set(keyOf(row.propertyTypeId, row.languageId, row.segment), row)
550
+ rowsById.set(id, [...latest.values()])
551
+ }
552
+ }
553
+ }
554
+ const out = []
555
+ for (const row of rows) {
556
+ const typeId = Number(row.content_type_id)
557
+ if (!typesById.has(typeId)) typesById.set(typeId, await this.#propertyTypes(typeId))
558
+ const propertyTypes = typesById.get(typeId) as PropertyTypeRow[]
559
+ out.push({
560
+ id: Number(row.id),
561
+ key: normaliseUuid(String(row.unique_id)),
562
+ name: String(row.draft_name ?? row.text ?? ''),
563
+ contentTypeAlias: String(row.content_type_alias),
564
+ parentId: Number(row.parent_id),
565
+ sortOrder: Number(row.sort_order),
566
+ createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
567
+ updateDate:
568
+ DbDate.fromDb(row.version_date) ?? DbDate.fromDb(row.create_date) ?? new Date(0),
569
+ values:
570
+ propertyTypes.length === 0
571
+ ? []
572
+ : this.#valuesFromRows(rowsById.get(Number(row.id)) ?? [], propertyTypes, languages),
573
+ })
574
+ }
575
+ return out
576
+ }
577
+
430
578
  // --------------------------------------------------------------- writes
431
579
 
432
580
  /** Creates a document with its first save event. Under the content-tree lock so sibling sort orders cannot race. */
@@ -438,9 +586,11 @@ export class DocumentRepository {
438
586
  const contentTypeNode = await repo.nodes.byKey(input.contentTypeKey)
439
587
  if (!contentTypeNode) throw new Error(`Unknown content type '${input.contentTypeKey}'.`)
440
588
 
441
- const parentId = input.parentKey
442
- ? ((await repo.nodes.byKey(input.parentKey))?.id ?? SystemNodes.Root)
443
- : SystemNodes.Root
589
+ const parent = input.parentKey ? await repo.nodes.byKey(input.parentKey) : undefined
590
+ if (input.parentKey && !parent)
591
+ throw new InvalidParentError('The parent could not be found.')
592
+ if (parent?.trashed) throw new InvalidParentError('The parent is in the recycle bin.')
593
+ const parentId = parent?.id ?? SystemNodes.Root
444
594
  const name = input.variants[0]?.name ?? ''
445
595
  const node = await repo.nodes.create({
446
596
  key: input.key,
@@ -556,6 +706,14 @@ export class DocumentRepository {
556
706
  key: string,
557
707
  values: readonly MigratedValue[],
558
708
  options: { schemaStateId: number; userId?: number },
709
+ ): Promise<boolean> {
710
+ return this.#withTree(() => this.#appendUnderState(key, values, options))
711
+ }
712
+
713
+ async #appendUnderState(
714
+ key: string,
715
+ values: readonly MigratedValue[],
716
+ options: { schemaStateId: number; userId?: number },
559
717
  ): Promise<boolean> {
560
718
  return this.#db.transaction(async (tx) => {
561
719
  const repo = this.#child(tx)
@@ -609,6 +767,10 @@ export class DocumentRepository {
609
767
 
610
768
  /** Saves the draft: one save event, and a new value version for each value that changed. */
611
769
  async update(input: SaveDocumentInput): Promise<DocumentAggregate | undefined> {
770
+ return this.#withTree(() => this.#update(input))
771
+ }
772
+
773
+ async #update(input: SaveDocumentInput): Promise<DocumentAggregate | undefined> {
612
774
  return this.#db.transaction(async (tx) => {
613
775
  const repo = this.#child(tx)
614
776
  const state = await assertNodeMayWrite(tx, this.#nodeState)
@@ -655,6 +817,9 @@ export class DocumentRepository {
655
817
  await assertNodeMayWrite(tx, this.#nodeState)
656
818
  const node = await repo.nodes.byKey(key)
657
819
  if (!node) return undefined
820
+ // The bin is not a document, so the ancestor check below passes for a
821
+ // node trashed directly; Umbraco refuses it as FailedPublishIsTrashed.
822
+ if (node.trashed) throw new PublishBlockedError('The document is in the recycle bin.')
658
823
  // An unpublished ancestor makes a published descendant unreachable, so
659
824
  // Umbraco refuses the publish rather than creating a dead route.
660
825
  if (!(await repo.#ancestorsPublished(node)))
@@ -731,6 +896,7 @@ export class DocumentRepository {
731
896
  ): Promise<string[]> {
732
897
  const node = await this.#nodes.byKey(key)
733
898
  if (!node) return ['there is no such document here']
899
+ if (node.trashed) return ['it is in the recycle bin']
734
900
  const reasons: string[] = []
735
901
  if (!(await this.#ancestorsPublished(node, alsoPublishing)))
736
902
  reasons.push('an ancestor is not published')
@@ -768,6 +934,13 @@ export class DocumentRepository {
768
934
  async unpublish(
769
935
  key: string,
770
936
  cultures: readonly string[] | null,
937
+ ): Promise<DocumentAggregate | undefined> {
938
+ return this.#withTree(() => this.#unpublish(key, cultures))
939
+ }
940
+
941
+ async #unpublish(
942
+ key: string,
943
+ cultures: readonly string[] | null,
771
944
  ): Promise<DocumentAggregate | undefined> {
772
945
  return this.#db.transaction(async (tx) => {
773
946
  const repo = this.#child(tx)
@@ -817,6 +990,10 @@ export class DocumentRepository {
817
990
 
818
991
  /** Rollback appends the target version's values under a new event. History is never rewritten. */
819
992
  async rollback(versionId: string): Promise<DocumentAggregate | undefined> {
993
+ return this.#withTree(() => this.#rollback(versionId))
994
+ }
995
+
996
+ async #rollback(versionId: string): Promise<DocumentAggregate | undefined> {
820
997
  return this.#db.transaction(async (tx) => {
821
998
  const repo = this.#child(tx)
822
999
  const state = await assertNodeMayWrite(tx, this.#nodeState)
@@ -869,35 +1046,54 @@ export class DocumentRepository {
869
1046
  return true
870
1047
  }
871
1048
 
1049
+ /**
1050
+ * Re-parents the branch under the bin, as Umbraco does: paths stay true, so a
1051
+ * later restore of an ancestor cannot reach into it. The branch root
1052
+ * remembers where it came from for the restore.
1053
+ */
872
1054
  async moveToRecycleBin(key: string): Promise<boolean> {
873
- const node = await this.#nodes.byKey(key)
874
- if (!node || this.#recycleBin === undefined) return false
875
- return this.#withTree(async () => {
876
- const t = this.#db.dialect.boolValue(true)
877
- const f = this.#db.dialect.boolValue(false)
878
- // The branch root remembers where it came from; the rest keeps its parent.
879
- await this.#db.exec('UPDATE document SET original_parent_id = ? WHERE node_id = ?', [
880
- node.parentId === SystemNodes.Root ? null : node.parentId,
881
- node.id,
882
- ])
883
- // The whole branch goes with it, and stays unpublished while trashed.
884
- await this.#db.exec(
885
- `UPDATE node SET trashed = ?, parent_id = CASE WHEN id = ? THEN ? ELSE parent_id END
886
- WHERE id = ? OR path LIKE ?`,
887
- [t, node.id, this.#recycleBin, node.id, `${node.path},%`],
888
- )
889
- await this.#db.exec(
890
- `UPDATE document SET published = ?, edited = ? WHERE node_id IN (SELECT id FROM node WHERE id = ? OR path LIKE ?)`,
891
- [f, t, node.id, `${node.path},%`],
892
- )
893
- return true
894
- })
1055
+ const bin = this.#recycleBin
1056
+ if (bin === undefined) return false
1057
+ return this.#withTree(() =>
1058
+ this.#db.transaction(async (tx) => {
1059
+ const repo = this.#child(tx)
1060
+ const node = await repo.nodes.byKey(key)
1061
+ if (!node) return false
1062
+ if (node.parentId === bin) return true
1063
+ const t = tx.dialect.boolValue(true)
1064
+ const f = tx.dialect.boolValue(false)
1065
+ await tx.exec('UPDATE document SET original_parent_id = ? WHERE node_id = ?', [
1066
+ node.parentId === SystemNodes.Root ? null : node.parentId,
1067
+ node.id,
1068
+ ])
1069
+ // The whole branch goes with it, and stays unpublished while trashed.
1070
+ await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
1071
+ t,
1072
+ node.id,
1073
+ `${node.path},%`,
1074
+ ])
1075
+ await tx.exec(
1076
+ `UPDATE document SET published = ?, edited = ? WHERE node_id IN (SELECT id FROM node WHERE id = ? OR path LIKE ?)`,
1077
+ [f, t, node.id, `${node.path},%`],
1078
+ )
1079
+ await repo.nodes.move(node.id, bin)
1080
+ await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
1081
+ await repo.#nextSortOrder(bin, node.id),
1082
+ node.id,
1083
+ ])
1084
+ return true
1085
+ }),
1086
+ )
895
1087
  }
896
1088
 
897
1089
  async delete(key: string): Promise<boolean> {
898
- const node = await this.#nodes.byKey(key)
899
- if (!node) return false
1090
+ return this.#withTree(() => this.#delete(key))
1091
+ }
1092
+
1093
+ async #delete(key: string): Promise<boolean> {
900
1094
  return this.#db.transaction(async (tx) => {
1095
+ const node = await new NodeRepository(tx).byKey(key)
1096
+ if (!node) return false
901
1097
  const ids = await tx.query<{ id: number }>(
902
1098
  'SELECT id FROM node WHERE id = ? OR path LIKE ?',
903
1099
  [node.id, `${node.path},%`],
@@ -947,6 +1143,17 @@ export class DocumentRepository {
947
1143
  return 'invalid'
948
1144
  if (target?.trashed) return 'invalid'
949
1145
  const parentId = target?.id ?? SystemNodes.Root
1146
+ // Out of the bin is a restore, as Umbraco's Move treats it.
1147
+ if (node.trashed) {
1148
+ await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
1149
+ tx.dialect.boolValue(false),
1150
+ node.id,
1151
+ `${node.path},%`,
1152
+ ])
1153
+ await tx.exec('UPDATE document SET original_parent_id = NULL WHERE node_id = ?', [
1154
+ node.id,
1155
+ ])
1156
+ }
950
1157
  await repo.nodes.move(node.id, parentId)
951
1158
  await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
952
1159
  await repo.#nextSortOrder(parentId, node.id),
@@ -1010,11 +1217,13 @@ export class DocumentRepository {
1010
1217
  parentKey: string | null,
1011
1218
  sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
1012
1219
  ): Promise<'sorted' | 'notFound'> {
1013
- const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
1014
- if (parentId === undefined) return 'notFound'
1015
1220
  return this.#withTree(async () =>
1016
1221
  this.#db.transaction(async (tx) => {
1017
1222
  await assertNodeMayWrite(tx, this.#nodeState)
1223
+ const parentId = parentKey
1224
+ ? (await new NodeRepository(tx).byKey(parentKey))?.id
1225
+ : SystemNodes.Root
1226
+ if (parentId === undefined) return 'notFound'
1018
1227
  const nodes = await new NodeRepository(tx).byKeys(sorting.map((s) => s.key))
1019
1228
  const byKey = new Map(nodes.map((n) => [n.key, n]))
1020
1229
  for (const item of sorting) {
@@ -1069,17 +1278,17 @@ export class DocumentRepository {
1069
1278
  key: string,
1070
1279
  targetKey: string | null | undefined,
1071
1280
  ): Promise<'restored' | 'notFound' | 'invalid'> {
1072
- const node = await this.#nodes.byKey(key)
1073
- if (!node || node.objectType !== this.objectType || !node.trashed) return 'notFound'
1074
- let parentKey: string | null
1075
- if (targetKey !== undefined && targetKey !== null) parentKey = targetKey
1076
- else if (targetKey === null) parentKey = null
1077
- else parentKey = (await this.originalParent(key)) ?? null
1078
- const parent = parentKey ? await this.#nodes.byKey(parentKey) : undefined
1079
- if (parentKey && (!parent || parent.trashed)) return 'invalid'
1080
1281
  return this.#withTree(async () =>
1081
1282
  this.#db.transaction(async (tx) => {
1082
1283
  const repo = this.#child(tx)
1284
+ const node = await repo.nodes.byKey(key)
1285
+ if (!node || node.objectType !== this.objectType || !node.trashed) return 'notFound'
1286
+ let parentKey: string | null
1287
+ if (targetKey !== undefined && targetKey !== null) parentKey = targetKey
1288
+ else if (targetKey === null) parentKey = null
1289
+ else parentKey = (await repo.originalParent(key)) ?? null
1290
+ const parent = parentKey ? await repo.nodes.byKey(parentKey) : undefined
1291
+ if (parentKey && (!parent || parent.trashed)) return 'invalid'
1083
1292
  await assertNodeMayWrite(tx, this.#nodeState)
1084
1293
  const parentId = parent?.id ?? SystemNodes.Root
1085
1294
  await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
@@ -1108,6 +1317,14 @@ export class DocumentRepository {
1108
1317
  key: string,
1109
1318
  cultures: readonly string[] | null,
1110
1319
  includeUnpublished: boolean,
1320
+ ): Promise<{ published: string[]; failed: Array<{ key: string; reason: string }> } | undefined> {
1321
+ return this.#withTree(() => this.#publishBranch(key, cultures, includeUnpublished))
1322
+ }
1323
+
1324
+ async #publishBranch(
1325
+ key: string,
1326
+ cultures: readonly string[] | null,
1327
+ includeUnpublished: boolean,
1111
1328
  ): Promise<{ published: string[]; failed: Array<{ key: string; reason: string }> } | undefined> {
1112
1329
  const root = await this.#nodes.byKey(key)
1113
1330
  if (!root || root.objectType !== this.objectType || root.trashed) return undefined
@@ -1190,6 +1407,17 @@ export class DocumentRepository {
1190
1407
  publishTime: Date | null
1191
1408
  unpublishTime: Date | null
1192
1409
  }>,
1410
+ ): Promise<boolean> {
1411
+ return this.#withTree(() => this.#setSchedule(key, entries))
1412
+ }
1413
+
1414
+ async #setSchedule(
1415
+ key: string,
1416
+ entries: ReadonlyArray<{
1417
+ culture: string | null
1418
+ publishTime: Date | null
1419
+ unpublishTime: Date | null
1420
+ }>,
1193
1421
  ): Promise<boolean> {
1194
1422
  const node = await this.#nodes.byKey(key)
1195
1423
  if (!node || node.objectType !== this.objectType) return false
@@ -1297,11 +1525,13 @@ export class DocumentRepository {
1297
1525
  type.keep_all === null ? global.keepAllVersionsNewerThanDays : Number(type.keep_all)
1298
1526
  const keepLatest =
1299
1527
  type.keep_latest === null ? global.keepLatestVersionPerDayForDays : Number(type.keep_latest)
1300
- const deleted = await this.#db.transaction((tx) =>
1301
- this.#child(tx).#cleanupNode(
1302
- Number(type.node_id),
1303
- now.getTime() - keepAll * day,
1304
- now.getTime() - keepLatest * day,
1528
+ const deleted = await this.#withTree(() =>
1529
+ this.#db.transaction((tx) =>
1530
+ this.#child(tx).#cleanupNode(
1531
+ Number(type.node_id),
1532
+ now.getTime() - keepAll * day,
1533
+ now.getTime() - keepLatest * day,
1534
+ ),
1305
1535
  ),
1306
1536
  )
1307
1537
  if (deleted > 0) {
@@ -1487,6 +1717,15 @@ export class DocumentRepository {
1487
1717
  }))
1488
1718
  }
1489
1719
 
1720
+ /** The property aliases a document's type has, compositions included; undefined when there is no document. */
1721
+ async propertyAliases(key: string): Promise<string[] | undefined> {
1722
+ const node = await this.#nodes.byKey(key)
1723
+ if (!node) return undefined
1724
+ const contentTypeNodeId = await this.#contentTypeNodeId(node.id)
1725
+ if (!contentTypeNodeId) return undefined
1726
+ return (await this.#propertyTypes(contentTypeNodeId)).map((p) => p.alias)
1727
+ }
1728
+
1490
1729
  /** Property types of a content type, including everything it composes; retired ones excluded. */
1491
1730
  async #propertyTypes(contentTypeNodeId: number): Promise<PropertyTypeRow[]> {
1492
1731
  const rows = await this.#db.query(
@@ -1987,33 +2226,90 @@ export class DocumentRepository {
1987
2226
  return { ...draft, values }
1988
2227
  }
1989
2228
 
1990
- /** The content tree's view of documents, one query per page for the aggregates it decorates with. */
2229
+ /**
2230
+ * The content tree's view of documents: four queries a page, whatever its
2231
+ * size. Only what a tree item shows is read — not the values, nor the
2232
+ * schedules the editor needs.
2233
+ */
1991
2234
  async treeItems(rows: readonly NodeRow[]): Promise<DocumentTreeItem[]> {
1992
- const counts = await this.#nodes.childCounts(
1993
- rows.map((row) => row.id),
1994
- this.objectType,
2235
+ if (rows.length === 0) return []
2236
+ const ids = rows.map((row) => row.id)
2237
+ const placeholders = ids.map(() => '?').join(', ')
2238
+ const counts = await this.#nodes.childCounts(ids, this.objectType)
2239
+ const facts = await this.#db.query(
2240
+ `SELECT n.id, ctn.unique_id AS content_type_key, ct.icon AS content_type_icon,
2241
+ ct.list_view AS content_type_collection, d.published, d.edited,
2242
+ head.text AS draft_name
2243
+ FROM node n
2244
+ JOIN content c ON c.node_id = n.id
2245
+ JOIN content_type ct ON ct.node_id = c.content_type_id
2246
+ JOIN node ctn ON ctn.id = ct.node_id
2247
+ JOIN document d ON d.node_id = n.id
2248
+ JOIN content_version head ON head.node_id = n.id AND head.current = ?
2249
+ WHERE n.id IN (${placeholders})`,
2250
+ [this.#db.dialect.boolValue(true), ...ids],
1995
2251
  )
2252
+ const factsById = new Map(facts.map((row) => [Number(row.id), row]))
2253
+ const cultures = await this.#db.query(
2254
+ `SELECT dcv.node_id, dcv.name, dcv.published, dcv.edited, dcv.available, l.iso_code
2255
+ FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
2256
+ WHERE dcv.node_id IN (${placeholders})`,
2257
+ ids,
2258
+ )
2259
+ const culturesById = new Map<number, Record<string, unknown>[]>()
2260
+ for (const row of cultures)
2261
+ culturesById.set(Number(row.node_id), [...(culturesById.get(Number(row.node_id)) ?? []), row])
2262
+ // Every ancestor the page's paths name, parents included, in one read.
2263
+ const pathIds = (row: NodeRow) =>
2264
+ row.path
2265
+ .split(',')
2266
+ .map(Number)
2267
+ .filter((id) => id > 0 && id !== row.id)
2268
+ const above = new Map(
2269
+ (await this.#nodes.byIds([...new Set(rows.flatMap(pathIds))])).map((n) => [n.id, n]),
2270
+ )
2271
+
1996
2272
  const items: DocumentTreeItem[] = []
1997
2273
  for (const row of rows) {
1998
- const aggregate = await this.#load(row)
1999
- if (!aggregate) continue
2000
- const ancestors = await this.#nodes.ancestors(row.key)
2274
+ const fact = factsById.get(row.id)
2275
+ if (!fact) continue
2276
+ const ancestors = pathIds(row)
2277
+ .map((id) => above.get(id))
2278
+ .filter((n): n is NodeRow => n !== undefined)
2279
+ const parent = row.parentId > 0 ? above.get(row.parentId) : undefined
2280
+ const own = culturesById.get(row.id) ?? []
2001
2281
  items.push({
2002
2282
  key: row.key,
2003
2283
  name: row.text ?? '',
2004
2284
  hasChildren: (counts.get(row.id) ?? 0) > 0,
2005
- parentKey: aggregate.parentKey,
2006
- contentTypeKey: aggregate.contentTypeKey,
2007
- contentTypeCollectionKey: aggregate.contentTypeCollectionKey ?? null,
2008
- icon: aggregate.contentTypeIcon,
2285
+ parentKey: parent ? parent.key : null,
2286
+ contentTypeKey: normaliseUuid(String(fact.content_type_key)),
2287
+ contentTypeCollectionKey: fact.content_type_collection
2288
+ ? normaliseUuid(String(fact.content_type_collection))
2289
+ : null,
2290
+ icon: String(fact.content_type_icon ?? 'icon-document'),
2009
2291
  isTrashed: row.trashed,
2010
2292
  createDate: row.createDate ?? new Date(),
2011
2293
  ancestorKeys: ancestors.filter((a) => a.objectType === this.objectType).map((a) => a.key),
2012
- variants: aggregate.variants.map((v) => ({
2013
- culture: v.culture,
2014
- name: v.name,
2015
- state: v.state,
2016
- })),
2294
+ variants:
2295
+ own.length === 0
2296
+ ? [
2297
+ {
2298
+ culture: null,
2299
+ name: String(fact.draft_name ?? row.text ?? ''),
2300
+ state: stateFor(fromDbBool(fact.published), fromDbBool(fact.edited), row.trashed),
2301
+ },
2302
+ ]
2303
+ : own.map((c) => ({
2304
+ culture: String(c.iso_code),
2305
+ name: String(c.name ?? ''),
2306
+ state: stateFor(
2307
+ fromDbBool(c.published),
2308
+ fromDbBool(c.edited),
2309
+ row.trashed,
2310
+ fromDbBool(c.available),
2311
+ ),
2312
+ })),
2017
2313
  })
2018
2314
  }
2019
2315
  return items
@@ -2280,9 +2576,61 @@ export class DocumentRepository {
2280
2576
  }
2281
2577
 
2282
2578
  /** Each published culture: its language, the event it was published at, and its published name. */
2283
- async #publishedCultureEvents(
2284
- nodeId: number,
2285
- ): Promise<Array<{ languageId: number; isoCode: string; eventId: number; name: string }>> {
2579
+ async #publishedCultureEventsFor(
2580
+ ids: readonly number[],
2581
+ ): Promise<Map<number, PublishedCulture[]>> {
2582
+ const out = new Map<number, PublishedCulture[]>()
2583
+ if (ids.length === 0) return out
2584
+ const rows = await this.#db.query(
2585
+ `SELECT dcv.node_id, dcv.language_id, l.iso_code, dcv.published_event_id, dcv.published_name, dcv.name
2586
+ FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
2587
+ WHERE dcv.node_id IN (${ids.map(() => '?').join(', ')}) AND dcv.published = ?`,
2588
+ [...ids, this.#db.dialect.boolValue(true)],
2589
+ )
2590
+ for (const row of rows) {
2591
+ const id = Number(row.node_id)
2592
+ out.set(id, [
2593
+ ...(out.get(id) ?? []),
2594
+ {
2595
+ languageId: Number(row.language_id),
2596
+ isoCode: String(row.iso_code),
2597
+ eventId: Number(row.published_event_id ?? 0),
2598
+ name: String(row.published_name ?? row.name ?? ''),
2599
+ },
2600
+ ])
2601
+ }
2602
+ return out
2603
+ }
2604
+
2605
+ /** Each node's value history, oldest first. */
2606
+ async #valueHistoryFor(
2607
+ ids: readonly number[],
2608
+ schemaStateId?: number,
2609
+ ): Promise<Map<number, ValueRow[]>> {
2610
+ const out = new Map<number, ValueRow[]>()
2611
+ if (ids.length === 0) return out
2612
+ const params: unknown[] = [...ids]
2613
+ let where = `node_id IN (${ids.map(() => '?').join(', ')})`
2614
+ if (schemaStateId !== undefined) {
2615
+ where += ' AND schema_state_id <= ?'
2616
+ params.push(schemaStateId)
2617
+ }
2618
+ const rows = await this.#db.query(
2619
+ `SELECT id, node_id, event_id, property_type_id, language_id, segment,
2620
+ int_value, decimal_value, date_value, varchar_value, text_value
2621
+ FROM property_value WHERE ${where} ORDER BY node_id, id`,
2622
+ params,
2623
+ )
2624
+ for (const row of rows) {
2625
+ const id = Number(row.node_id)
2626
+ const list = out.get(id)
2627
+ if (list) list.push(this.#toValueRow(row))
2628
+ else out.set(id, [this.#toValueRow(row)])
2629
+ }
2630
+ return out
2631
+ }
2632
+
2633
+ async #publishedCultureEvents(nodeId: number): Promise<PublishedCulture[]> {
2286
2634
  const rows = await this.#db.query(
2287
2635
  `SELECT dcv.language_id, l.iso_code, dcv.published_event_id, dcv.published_name, dcv.name
2288
2636
  FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
@@ -2317,7 +2665,6 @@ export class DocumentRepository {
2317
2665
  cultures: ReadonlyArray<{ languageId: number; eventId: number }>,
2318
2666
  schemaStateId?: number,
2319
2667
  ): Promise<DocumentValue[]> {
2320
- const cutoff = new Map(cultures.map((c) => [c.languageId, c.eventId]))
2321
2668
  const latest = Math.max(invariantEventId, ...cultures.map((c) => c.eventId))
2322
2669
  const conditions = ['node_id = ?', 'event_id <= ?']
2323
2670
  const params: unknown[] = [nodeId, latest]
@@ -2333,13 +2680,11 @@ export class DocumentRepository {
2333
2680
  params,
2334
2681
  )
2335
2682
  ).map((row) => this.#toValueRow(row))
2336
- const chosen = new Map<string, ValueRow>()
2337
- for (const row of all) {
2338
- const limit = row.languageId === null ? invariantEventId : cutoff.get(row.languageId)
2339
- if (limit === undefined || row.eventId > limit) continue
2340
- chosen.set(keyOf(row.propertyTypeId, row.languageId, row.segment), row)
2341
- }
2342
- return this.#valuesFromRows([...chosen.values()], propertyTypes, languages)
2683
+ return this.#valuesFromRows(
2684
+ publishedValueRows(all, invariantEventId, cultures),
2685
+ propertyTypes,
2686
+ languages,
2687
+ )
2343
2688
  }
2344
2689
 
2345
2690
  async #anyCulturePublished(nodeId: number): Promise<boolean> {