@crombolo/data 0.11.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +46 -13
- package/src/index.ts +17 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/approvals.ts +277 -0
- package/src/repositories/data-types.ts +38 -0
- package/src/repositories/delivery-member-tokens.ts +110 -0
- package/src/repositories/dictionary.ts +18 -0
- package/src/repositories/documents.ts +431 -86
- package/src/repositories/dynamic-roots.ts +151 -0
- package/src/repositories/form-entries.ts +32 -11
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +11 -0
- package/src/repositories/nodes.ts +11 -0
- package/src/repositories/public-access.ts +30 -0
- package/src/repositories/redirects.ts +30 -45
- package/src/repositories/references.ts +15 -0
- package/src/repositories/transfer-answers.ts +151 -0
- package/src/repositories/webhooks.ts +299 -0
- package/src/schema/approvals.ts +77 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +11 -0
- package/src/schema/seed.ts +16 -4
- package/src/schema/transfer-answers.ts +50 -0
- package/src/schema/webhooks.ts +78 -0
- package/src/setup.ts +57 -0
- package/src/tree-invariants.ts +167 -0
- package/src/url-options.ts +61 -0
- package/src/workflow-store.ts +114 -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,
|
|
@@ -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
|
-
{
|
|
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
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
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(
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
874
|
-
if (
|
|
875
|
-
return this.#withTree(
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
node.parentId ===
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
899
|
-
|
|
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.#
|
|
1301
|
-
this.#
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
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
|
|
1999
|
-
if (!
|
|
2000
|
-
const ancestors =
|
|
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:
|
|
2006
|
-
contentTypeKey:
|
|
2007
|
-
contentTypeCollectionKey:
|
|
2008
|
-
|
|
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:
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
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 #
|
|
2284
|
-
|
|
2285
|
-
): Promise<
|
|
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
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
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> {
|