@crombolo/data 0.10.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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/ambient.ts +20 -0
  3. package/src/audit.ts +291 -0
  4. package/src/busy.ts +74 -0
  5. package/src/change-report.ts +6 -0
  6. package/src/cluster.ts +17 -5
  7. package/src/database.ts +80 -17
  8. package/src/dialect.ts +24 -0
  9. package/src/index.ts +20 -0
  10. package/src/locks.ts +94 -25
  11. package/src/migrations.ts +42 -16
  12. package/src/relations.ts +346 -0
  13. package/src/repositories/content-types.ts +41 -4
  14. package/src/repositories/data-types.ts +38 -0
  15. package/src/repositories/delivery-member-tokens.ts +110 -0
  16. package/src/repositories/dictionary.ts +18 -0
  17. package/src/repositories/documents.ts +443 -88
  18. package/src/repositories/dynamic-roots.ts +151 -0
  19. package/src/repositories/form-entries.ts +32 -11
  20. package/src/repositories/form-workflows.ts +1 -1
  21. package/src/repositories/members.ts +26 -1
  22. package/src/repositories/nodes.ts +43 -7
  23. package/src/repositories/public-access.ts +30 -0
  24. package/src/repositories/references.ts +15 -0
  25. package/src/repositories/transfer-answers.ts +151 -0
  26. package/src/repositories/webhooks.ts +299 -0
  27. package/src/schema/audit-log.ts +45 -0
  28. package/src/schema/delivery-member-tokens.ts +40 -0
  29. package/src/schema/diagnostics.ts +28 -0
  30. package/src/schema/plan.ts +14 -0
  31. package/src/schema/property-searchable.ts +29 -0
  32. package/src/schema/search-index.ts +92 -0
  33. package/src/schema/seed.ts +13 -2
  34. package/src/schema/transfer-answers.ts +50 -0
  35. package/src/schema/webhooks.ts +78 -0
  36. package/src/search-text.ts +133 -0
  37. package/src/search.ts +397 -0
  38. package/src/setup.ts +57 -0
  39. package/src/tree-invariants.ts +167 -0
  40. package/src/url-options.ts +61 -0
@@ -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,
@@ -36,6 +36,7 @@ import {
36
36
  type SchemaStateRow,
37
37
  visibleStateIdFor,
38
38
  } from '../schema-state.ts'
39
+ import { queueForSearch } from '../search.ts'
39
40
  import { NodeRepository, type NodeRow } from './nodes.ts'
40
41
 
41
42
  export type EventKind = 'save' | 'publish' | 'rollback' | 'migrate'
@@ -85,6 +86,13 @@ export interface MigratedValue {
85
86
  storage?: ValueStorageTypeName
86
87
  }
87
88
 
89
+ interface PublishedCulture {
90
+ languageId: number
91
+ isoCode: string
92
+ eventId: number
93
+ name: string
94
+ }
95
+
88
96
  interface ValueRow {
89
97
  eventId: number
90
98
  id: number
@@ -132,17 +140,24 @@ export type ContentKind = 'document' | 'media' | 'member' | 'blueprint' | 'eleme
132
140
 
133
141
  const KINDS: Record<
134
142
  ContentKind,
135
- { objectType: string; recycleBin: number | undefined; container: string | undefined }
143
+ {
144
+ objectType: string
145
+ recycleBin: number | undefined
146
+ container: string | undefined
147
+ lock: LockId
148
+ }
136
149
  > = {
137
150
  document: {
138
151
  objectType: ObjectTypes.Document,
139
152
  recycleBin: SystemNodes.ContentRecycleBin,
140
153
  container: undefined,
154
+ lock: Locks.ContentTree,
141
155
  },
142
156
  media: {
143
157
  objectType: ObjectTypes.Media,
144
158
  recycleBin: SystemNodes.MediaRecycleBin,
145
159
  container: undefined,
160
+ lock: Locks.MediaTree,
146
161
  },
147
162
  // Members are a flat list at the tree root: no bin to trash them into, and
148
163
  // no folders to gather them in.
@@ -150,11 +165,13 @@ const KINDS: Record<
150
165
  objectType: ObjectTypes.Member,
151
166
  recycleBin: undefined,
152
167
  container: undefined,
168
+ lock: Locks.MemberTree,
153
169
  },
154
170
  blueprint: {
155
171
  objectType: ObjectTypes.DocumentBlueprint,
156
172
  recycleBin: undefined,
157
173
  container: ObjectTypes.DocumentBlueprintContainer,
174
+ lock: Locks.ContentTree,
158
175
  },
159
176
  // Umbraco 18's Elements: publishable content with no URL, gathered in folders
160
177
  // and trashed into a bin of its own at -22, as its Constants.System says.
@@ -162,6 +179,7 @@ const KINDS: Record<
162
179
  objectType: ObjectTypes.Element,
163
180
  recycleBin: SystemNodes.ElementRecycleBin,
164
181
  container: ObjectTypes.ElementContainer,
182
+ lock: Locks.ContentTree,
165
183
  },
166
184
  }
167
185
 
@@ -200,6 +218,9 @@ export interface DocumentRepositoryOptions {
200
218
  /** Thrown when a publish is refused by a business rule rather than failing. */
201
219
  export class PublishBlockedError extends Error {}
202
220
 
221
+ /** The parent named for a new item is gone, or in the recycle bin. */
222
+ export class InvalidParentError extends Error {}
223
+
203
224
  const isEmpty = (value: unknown) =>
204
225
  value === null || value === undefined || (typeof value === 'string' && value.trim() === '')
205
226
 
@@ -249,6 +270,39 @@ function validateValues(
249
270
  const keyOf = (propertyTypeId: number, languageId: number | null, segment: string | null) =>
250
271
  `${propertyTypeId}|${languageId ?? ''}|${segment ?? ''}`
251
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
+
252
306
  export class DocumentRepository {
253
307
  #db: Db
254
308
  #nodes: NodeRepository
@@ -277,13 +331,13 @@ export class DocumentRepository {
277
331
  }
278
332
 
279
333
  /**
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.
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.
284
338
  */
285
339
  #withTree<T>(fn: () => Promise<T>): Promise<T> {
286
- 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)
287
341
  }
288
342
 
289
343
  #child(tx: Db): DocumentRepository {
@@ -334,7 +388,11 @@ export class DocumentRepository {
334
388
  ? (await this.#nodes.byKey(options.parentKey))?.id
335
389
  : undefined
336
390
  if (options.parentKey && parentId === undefined) return { total: 0, items: [] }
337
- return this.#nodes.search(this.objectType, query, { ...options, parentId })
391
+ return this.#nodes.search(this.objectType, query, {
392
+ ...options,
393
+ parentId,
394
+ fullText: this.kind === 'document' || this.kind === 'media',
395
+ })
338
396
  }
339
397
 
340
398
  async versions(key: string): Promise<DocumentVersionSummary[]> {
@@ -387,20 +445,34 @@ export class DocumentRepository {
387
445
  [t, t, f, this.objectType],
388
446
  )
389
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
+ }
390
459
  const published: PublishedNodeRow[] = []
391
460
  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),
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),
399
474
  propertyTypes,
400
475
  languages,
401
- Number(row.event_id),
402
- cultures,
403
- asOfSchemaStateId,
404
476
  )
405
477
  published.push({
406
478
  id: Number(row.id),
@@ -422,6 +494,87 @@ export class DocumentRepository {
422
494
  return published
423
495
  }
424
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
+
425
578
  // --------------------------------------------------------------- writes
426
579
 
427
580
  /** Creates a document with its first save event. Under the content-tree lock so sibling sort orders cannot race. */
@@ -433,9 +586,11 @@ export class DocumentRepository {
433
586
  const contentTypeNode = await repo.nodes.byKey(input.contentTypeKey)
434
587
  if (!contentTypeNode) throw new Error(`Unknown content type '${input.contentTypeKey}'.`)
435
588
 
436
- const parentId = input.parentKey
437
- ? ((await repo.nodes.byKey(input.parentKey))?.id ?? SystemNodes.Root)
438
- : 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
439
594
  const name = input.variants[0]?.name ?? ''
440
595
  const node = await repo.nodes.create({
441
596
  key: input.key,
@@ -551,6 +706,14 @@ export class DocumentRepository {
551
706
  key: string,
552
707
  values: readonly MigratedValue[],
553
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 },
554
717
  ): Promise<boolean> {
555
718
  return this.#db.transaction(async (tx) => {
556
719
  const repo = this.#child(tx)
@@ -594,13 +757,20 @@ export class DocumentRepository {
594
757
  )
595
758
  written = true
596
759
  }
597
- if (written) await repo.#recomputeEdited(node.id)
760
+ if (written) {
761
+ await repo.#recomputeEdited(node.id)
762
+ await queueForSearch(tx, [node.id])
763
+ }
598
764
  return written
599
765
  })
600
766
  }
601
767
 
602
768
  /** Saves the draft: one save event, and a new value version for each value that changed. */
603
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> {
604
774
  return this.#db.transaction(async (tx) => {
605
775
  const repo = this.#child(tx)
606
776
  const state = await assertNodeMayWrite(tx, this.#nodeState)
@@ -647,6 +817,9 @@ export class DocumentRepository {
647
817
  await assertNodeMayWrite(tx, this.#nodeState)
648
818
  const node = await repo.nodes.byKey(key)
649
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.')
650
823
  // An unpublished ancestor makes a published descendant unreachable, so
651
824
  // Umbraco refuses the publish rather than creating a dead route.
652
825
  if (!(await repo.#ancestorsPublished(node)))
@@ -723,6 +896,7 @@ export class DocumentRepository {
723
896
  ): Promise<string[]> {
724
897
  const node = await this.#nodes.byKey(key)
725
898
  if (!node) return ['there is no such document here']
899
+ if (node.trashed) return ['it is in the recycle bin']
726
900
  const reasons: string[] = []
727
901
  if (!(await this.#ancestorsPublished(node, alsoPublishing)))
728
902
  reasons.push('an ancestor is not published')
@@ -760,6 +934,13 @@ export class DocumentRepository {
760
934
  async unpublish(
761
935
  key: string,
762
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,
763
944
  ): Promise<DocumentAggregate | undefined> {
764
945
  return this.#db.transaction(async (tx) => {
765
946
  const repo = this.#child(tx)
@@ -809,6 +990,10 @@ export class DocumentRepository {
809
990
 
810
991
  /** Rollback appends the target version's values under a new event. History is never rewritten. */
811
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> {
812
997
  return this.#db.transaction(async (tx) => {
813
998
  const repo = this.#child(tx)
814
999
  const state = await assertNodeMayWrite(tx, this.#nodeState)
@@ -861,35 +1046,54 @@ export class DocumentRepository {
861
1046
  return true
862
1047
  }
863
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
+ */
864
1054
  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
- })
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
+ )
887
1087
  }
888
1088
 
889
1089
  async delete(key: string): Promise<boolean> {
890
- const node = await this.#nodes.byKey(key)
891
- if (!node) return false
1090
+ return this.#withTree(() => this.#delete(key))
1091
+ }
1092
+
1093
+ async #delete(key: string): Promise<boolean> {
892
1094
  return this.#db.transaction(async (tx) => {
1095
+ const node = await new NodeRepository(tx).byKey(key)
1096
+ if (!node) return false
893
1097
  const ids = await tx.query<{ id: number }>(
894
1098
  'SELECT id FROM node WHERE id = ? OR path LIKE ?',
895
1099
  [node.id, `${node.path},%`],
@@ -939,6 +1143,17 @@ export class DocumentRepository {
939
1143
  return 'invalid'
940
1144
  if (target?.trashed) return 'invalid'
941
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
+ }
942
1157
  await repo.nodes.move(node.id, parentId)
943
1158
  await tx.exec('UPDATE node SET sort_order = ? WHERE id = ?', [
944
1159
  await repo.#nextSortOrder(parentId, node.id),
@@ -1002,11 +1217,13 @@ export class DocumentRepository {
1002
1217
  parentKey: string | null,
1003
1218
  sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
1004
1219
  ): Promise<'sorted' | 'notFound'> {
1005
- const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
1006
- if (parentId === undefined) return 'notFound'
1007
1220
  return this.#withTree(async () =>
1008
1221
  this.#db.transaction(async (tx) => {
1009
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'
1010
1227
  const nodes = await new NodeRepository(tx).byKeys(sorting.map((s) => s.key))
1011
1228
  const byKey = new Map(nodes.map((n) => [n.key, n]))
1012
1229
  for (const item of sorting) {
@@ -1061,17 +1278,17 @@ export class DocumentRepository {
1061
1278
  key: string,
1062
1279
  targetKey: string | null | undefined,
1063
1280
  ): 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
1281
  return this.#withTree(async () =>
1073
1282
  this.#db.transaction(async (tx) => {
1074
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'
1075
1292
  await assertNodeMayWrite(tx, this.#nodeState)
1076
1293
  const parentId = parent?.id ?? SystemNodes.Root
1077
1294
  await tx.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
@@ -1100,6 +1317,14 @@ export class DocumentRepository {
1100
1317
  key: string,
1101
1318
  cultures: readonly string[] | null,
1102
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,
1103
1328
  ): Promise<{ published: string[]; failed: Array<{ key: string; reason: string }> } | undefined> {
1104
1329
  const root = await this.#nodes.byKey(key)
1105
1330
  if (!root || root.objectType !== this.objectType || root.trashed) return undefined
@@ -1182,6 +1407,17 @@ export class DocumentRepository {
1182
1407
  publishTime: Date | null
1183
1408
  unpublishTime: Date | null
1184
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
+ }>,
1185
1421
  ): Promise<boolean> {
1186
1422
  const node = await this.#nodes.byKey(key)
1187
1423
  if (!node || node.objectType !== this.objectType) return false
@@ -1289,11 +1525,13 @@ export class DocumentRepository {
1289
1525
  type.keep_all === null ? global.keepAllVersionsNewerThanDays : Number(type.keep_all)
1290
1526
  const keepLatest =
1291
1527
  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,
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
+ ),
1297
1535
  ),
1298
1536
  )
1299
1537
  if (deleted > 0) {
@@ -1479,6 +1717,15 @@ export class DocumentRepository {
1479
1717
  }))
1480
1718
  }
1481
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
+
1482
1729
  /** Property types of a content type, including everything it composes; retired ones excluded. */
1483
1730
  async #propertyTypes(contentTypeNodeId: number): Promise<PropertyTypeRow[]> {
1484
1731
  const rows = await this.#db.query(
@@ -1979,33 +2226,90 @@ export class DocumentRepository {
1979
2226
  return { ...draft, values }
1980
2227
  }
1981
2228
 
1982
- /** 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
+ */
1983
2234
  async treeItems(rows: readonly NodeRow[]): Promise<DocumentTreeItem[]> {
1984
- const counts = await this.#nodes.childCounts(
1985
- rows.map((row) => row.id),
1986
- 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],
1987
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
+
1988
2272
  const items: DocumentTreeItem[] = []
1989
2273
  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)
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) ?? []
1993
2281
  items.push({
1994
2282
  key: row.key,
1995
2283
  name: row.text ?? '',
1996
2284
  hasChildren: (counts.get(row.id) ?? 0) > 0,
1997
- parentKey: aggregate.parentKey,
1998
- contentTypeKey: aggregate.contentTypeKey,
1999
- contentTypeCollectionKey: aggregate.contentTypeCollectionKey ?? null,
2000
- 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'),
2001
2291
  isTrashed: row.trashed,
2002
2292
  createDate: row.createDate ?? new Date(),
2003
2293
  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
- })),
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
+ })),
2009
2313
  })
2010
2314
  }
2011
2315
  return items
@@ -2053,6 +2357,8 @@ export class DocumentRepository {
2053
2357
  'INSERT INTO document_version (id, template_id, published) VALUES (?, ?, ?)',
2054
2358
  [eventId, templateId, this.#db.dialect.boolValue(published)],
2055
2359
  )
2360
+ // Every change to what an item says is an event, so this is where the search index hears of it.
2361
+ await queueForSearch(this.#db, [nodeId])
2056
2362
  return eventId
2057
2363
  }
2058
2364
 
@@ -2270,9 +2576,61 @@ export class DocumentRepository {
2270
2576
  }
2271
2577
 
2272
2578
  /** 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 }>> {
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[]> {
2276
2634
  const rows = await this.#db.query(
2277
2635
  `SELECT dcv.language_id, l.iso_code, dcv.published_event_id, dcv.published_name, dcv.name
2278
2636
  FROM document_culture_variation dcv JOIN language l ON l.id = dcv.language_id
@@ -2307,7 +2665,6 @@ export class DocumentRepository {
2307
2665
  cultures: ReadonlyArray<{ languageId: number; eventId: number }>,
2308
2666
  schemaStateId?: number,
2309
2667
  ): Promise<DocumentValue[]> {
2310
- const cutoff = new Map(cultures.map((c) => [c.languageId, c.eventId]))
2311
2668
  const latest = Math.max(invariantEventId, ...cultures.map((c) => c.eventId))
2312
2669
  const conditions = ['node_id = ?', 'event_id <= ?']
2313
2670
  const params: unknown[] = [nodeId, latest]
@@ -2323,13 +2680,11 @@ export class DocumentRepository {
2323
2680
  params,
2324
2681
  )
2325
2682
  ).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)
2683
+ return this.#valuesFromRows(
2684
+ publishedValueRows(all, invariantEventId, cultures),
2685
+ propertyTypes,
2686
+ languages,
2687
+ )
2333
2688
  }
2334
2689
 
2335
2690
  async #anyCulturePublished(nodeId: number): Promise<boolean> {