@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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What must hold of the content trees after any mix of concurrent writes: the
3
+ * check is a pure function over rows, and `loadTreeSnapshot` reads those rows.
4
+ */
5
+ import { ObjectTypes, SystemNodes } from '@crombolo/core'
6
+ import type { Db } from './database.ts'
7
+ import { fromDbBool } from './dialect.ts'
8
+
9
+ export interface TreeNode {
10
+ id: number
11
+ parentId: number
12
+ level: number
13
+ path: string
14
+ sortOrder: number
15
+ trashed: boolean
16
+ objectType: string
17
+ }
18
+
19
+ export interface TreeDocument {
20
+ nodeId: number
21
+ published: boolean
22
+ }
23
+
24
+ export interface TreeVersion {
25
+ id: number
26
+ nodeId: number
27
+ current: boolean
28
+ published: boolean
29
+ }
30
+
31
+ export interface TreeSnapshot {
32
+ nodes: readonly TreeNode[]
33
+ /** Rows of `document`, which media and members share with documents. */
34
+ documents: readonly TreeDocument[]
35
+ versions: readonly TreeVersion[]
36
+ /** Node ids with a `content` row. */
37
+ contentNodeIds: readonly number[]
38
+ }
39
+
40
+ export type TreeRule =
41
+ | 'orphan'
42
+ | 'path'
43
+ | 'level'
44
+ | 'trashed'
45
+ | 'sort-order'
46
+ | 'current-version'
47
+ | 'published-version'
48
+ | 'published-trashed'
49
+ | 'content-row'
50
+
51
+ export interface TreeViolation {
52
+ nodeId: number
53
+ rule: TreeRule
54
+ detail: string
55
+ }
56
+
57
+ export const TREE_OBJECT_TYPES: readonly string[] = [ObjectTypes.Document, ObjectTypes.Media]
58
+
59
+ const RECYCLE_BINS: ReadonlySet<number> = new Set([
60
+ SystemNodes.ContentRecycleBin,
61
+ SystemNodes.MediaRecycleBin,
62
+ SystemNodes.ElementRecycleBin,
63
+ ])
64
+
65
+ /** Every broken rule in the trees of `objectTypes`; empty when they are consistent. */
66
+ export function checkTreeInvariants(
67
+ snapshot: TreeSnapshot,
68
+ objectTypes: readonly string[] = TREE_OBJECT_TYPES,
69
+ ): TreeViolation[] {
70
+ const checked = new Set(objectTypes)
71
+ const byId = new Map(snapshot.nodes.map((node) => [node.id, node]))
72
+ const documents = new Map(snapshot.documents.map((doc) => [doc.nodeId, doc]))
73
+ const content = new Set(snapshot.contentNodeIds)
74
+ const versions = new Map<number, TreeVersion[]>()
75
+ for (const version of snapshot.versions) {
76
+ const list = versions.get(version.nodeId)
77
+ if (list) list.push(version)
78
+ else versions.set(version.nodeId, [version])
79
+ }
80
+ const violations: TreeViolation[] = []
81
+ const fail = (nodeId: number, rule: TreeRule, detail: string) =>
82
+ violations.push({ nodeId, rule, detail })
83
+ const siblings = new Map<string, number>()
84
+
85
+ for (const node of snapshot.nodes) {
86
+ if (!checked.has(node.objectType)) continue
87
+ const parent = byId.get(node.parentId)
88
+ if (!parent) {
89
+ fail(node.id, 'orphan', `parent ${node.parentId} does not exist`)
90
+ continue
91
+ }
92
+ const inBin = RECYCLE_BINS.has(parent.id)
93
+ const expected = `${parent.path},${node.id}`
94
+ if (node.path !== expected) fail(node.id, 'path', `path ${node.path}, expected ${expected}`)
95
+ if (node.level !== parent.level + 1)
96
+ fail(node.id, 'level', `level ${node.level}, expected ${parent.level + 1}`)
97
+ if ((inBin || parent.trashed) && !node.trashed)
98
+ fail(node.id, 'trashed', 'under the recycle bin but not trashed')
99
+ if (node.trashed && !inBin && !parent.trashed)
100
+ fail(node.id, 'trashed', 'trashed but its parent is not')
101
+
102
+ const slot = `${node.parentId}|${node.objectType}|${node.sortOrder}`
103
+ const holder = siblings.get(slot)
104
+ if (holder !== undefined)
105
+ fail(node.id, 'sort-order', `shares sort order ${node.sortOrder} with ${holder}`)
106
+ else siblings.set(slot, node.id)
107
+
108
+ const doc = documents.get(node.id)
109
+ if (!doc || !content.has(node.id)) {
110
+ fail(node.id, 'content-row', doc ? 'no content row' : 'no document row')
111
+ continue
112
+ }
113
+ const own = versions.get(node.id) ?? []
114
+ const current = own.filter((v) => v.current).length
115
+ if (own.length > 0 && current !== 1)
116
+ fail(node.id, 'current-version', `${current} current versions`)
117
+ if (doc.published) {
118
+ const published = own.filter((v) => v.published).length
119
+ if (published !== 1) fail(node.id, 'published-version', `${published} published versions`)
120
+ if (node.trashed) fail(node.id, 'published-trashed', 'published while trashed')
121
+ }
122
+ }
123
+ return violations
124
+ }
125
+
126
+ /** The rows `checkTreeInvariants` reads, for the trees of `objectTypes`. */
127
+ export async function loadTreeSnapshot(
128
+ db: Db,
129
+ objectTypes: readonly string[] = TREE_OBJECT_TYPES,
130
+ ): Promise<TreeSnapshot> {
131
+ const marks = objectTypes.map(() => '?').join(', ')
132
+ const scoped = `SELECT id FROM node WHERE node_object_type IN (${marks})`
133
+ const [nodes, documents, versions, content] = await Promise.all([
134
+ db.query('SELECT id, parent_id, level, path, sort_order, trashed, node_object_type FROM node'),
135
+ db.query(`SELECT node_id, published FROM document WHERE node_id IN (${scoped})`, [
136
+ ...objectTypes,
137
+ ]),
138
+ db.query(
139
+ `SELECT cv.id, cv.node_id, cv.current, dv.published FROM content_version cv
140
+ LEFT JOIN document_version dv ON dv.id = cv.id WHERE cv.node_id IN (${scoped})`,
141
+ [...objectTypes],
142
+ ),
143
+ db.query(`SELECT node_id FROM content WHERE node_id IN (${scoped})`, [...objectTypes]),
144
+ ])
145
+ return {
146
+ nodes: nodes.map((row) => ({
147
+ id: Number(row.id),
148
+ parentId: Number(row.parent_id),
149
+ level: Number(row.level),
150
+ path: String(row.path),
151
+ sortOrder: Number(row.sort_order),
152
+ trashed: fromDbBool(row.trashed),
153
+ objectType: String(row.node_object_type).toLowerCase(),
154
+ })),
155
+ documents: documents.map((row) => ({
156
+ nodeId: Number(row.node_id),
157
+ published: fromDbBool(row.published),
158
+ })),
159
+ versions: versions.map((row) => ({
160
+ id: Number(row.id),
161
+ nodeId: Number(row.node_id),
162
+ current: fromDbBool(row.current),
163
+ published: fromDbBool(row.published),
164
+ })),
165
+ contentNodeIds: content.map((row) => Number(row.node_id)),
166
+ }
167
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * How this site's URLs are built, as Umbraco's `HideTopLevelNodeFromPath`
3
+ * sets it. Synced from `schema/schema.toml` and changed in Settings; read by
4
+ * the published cache. docs/rendering.md, "URLs".
5
+ */
6
+ import type { Db } from './database.ts'
7
+
8
+ export const URL_OPTIONS_KEY = 'crombolo:urls'
9
+
10
+ export interface UrlOptions {
11
+ /** Leave the top-level page's segment out of every path below it. Umbraco's default. */
12
+ hideTopLevel: boolean
13
+ }
14
+
15
+ export const DEFAULT_URL_OPTIONS: UrlOptions = { hideTopLevel: true }
16
+
17
+ /** Whether two settings build the same URLs. Pure. */
18
+ export const sameUrlOptions = (a: UrlOptions, b: UrlOptions): boolean =>
19
+ a.hideTopLevel === b.hideTopLevel
20
+
21
+ /** A stored value read leniently: anything unreadable is the default. Pure. */
22
+ export function parseUrlOptions(value: string | undefined): UrlOptions {
23
+ if (!value) return { ...DEFAULT_URL_OPTIONS }
24
+ try {
25
+ const parsed = JSON.parse(value) as Partial<UrlOptions>
26
+ return {
27
+ hideTopLevel:
28
+ typeof parsed.hideTopLevel === 'boolean'
29
+ ? parsed.hideTopLevel
30
+ : DEFAULT_URL_OPTIONS.hideTopLevel,
31
+ }
32
+ } catch {
33
+ return { ...DEFAULT_URL_OPTIONS }
34
+ }
35
+ }
36
+
37
+ export async function readUrlOptions(db: Db): Promise<UrlOptions> {
38
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
39
+ URL_OPTIONS_KEY,
40
+ ])
41
+ return parseUrlOptions(rows[0]?.value)
42
+ }
43
+
44
+ /** Stores the setting; answers what it was before. */
45
+ export async function writeUrlOptions(db: Db, options: UrlOptions): Promise<UrlOptions> {
46
+ return db.transaction(async (tx) => {
47
+ const before = await readUrlOptions(tx)
48
+ const value = JSON.stringify({ hideTopLevel: options.hideTopLevel })
49
+ const now = tx.dialect.now()
50
+ const updated = await tx.query<{ key: string }>(
51
+ `UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
52
+ [value, URL_OPTIONS_KEY],
53
+ )
54
+ if (updated.length === 0)
55
+ await tx.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
56
+ URL_OPTIONS_KEY,
57
+ value,
58
+ ])
59
+ return before
60
+ })
61
+ }