@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.
- package/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/audit.ts +291 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +80 -17
- package/src/dialect.ts +24 -0
- package/src/index.ts +20 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/content-types.ts +41 -4
- 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 +443 -88
- 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 +26 -1
- package/src/repositories/nodes.ts +43 -7
- package/src/repositories/public-access.ts +30 -0
- 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/audit-log.ts +45 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +14 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/seed.ts +13 -2
- package/src/schema/transfer-answers.ts +50 -0
- package/src/schema/webhooks.ts +78 -0
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
- package/src/setup.ts +57 -0
- package/src/tree-invariants.ts +167 -0
- 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
|
+
}
|