@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/src/setup.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Whether a site still waits for its owner to set it up from the browser. Kept
3
+ * in the database, not in memory, so a restart can never reopen it.
4
+ */
5
+ import type { Db } from './database.ts'
6
+ import { Locks } from './locks.ts'
7
+ import { insertSuperUser, type SeedAdmin, type SeedOptions } from './schema/seed.ts'
8
+
9
+ export const SETUP_STATE_KEY = 'crombolo:setup'
10
+
11
+ /** `none` is every database set up before this existed, or seeded with an administrator. */
12
+ export type SetupState = 'pending' | 'complete' | 'none'
13
+
14
+ export async function readSetupState(db: Db): Promise<SetupState> {
15
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
16
+ SETUP_STATE_KEY,
17
+ ])
18
+ const value = rows[0]?.value
19
+ return value === 'pending' || value === 'complete' ? value : 'none'
20
+ }
21
+
22
+ async function writeSetupState(db: Db, state: 'pending' | 'complete'): Promise<void> {
23
+ const now = db.dialect.now()
24
+ const updated = await db.query<{ key: string }>(
25
+ `UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
26
+ [state, SETUP_STATE_KEY],
27
+ )
28
+ if (updated.length === 0)
29
+ await db.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
30
+ SETUP_STATE_KEY,
31
+ state,
32
+ ])
33
+ }
34
+
35
+ /** Opens setup on a database that has just been created with no administrator. */
36
+ export function markSetupPending(db: Db): Promise<void> {
37
+ return writeSetupState(db, 'pending')
38
+ }
39
+
40
+ /**
41
+ * Creates the administrator and closes setup for good, or answers `closed` when
42
+ * it was already done. Under a lock, so two attempts at once cannot both win.
43
+ */
44
+ export function completeSetup(
45
+ db: Db,
46
+ admin: SeedAdmin,
47
+ options: Pick<SeedOptions, 'hashPassword' | 'passwordConfig'>,
48
+ ): Promise<'created' | 'closed'> {
49
+ return db.locks.withLock(Locks.KeyValues, () =>
50
+ db.transaction(async (tx) => {
51
+ if ((await readSetupState(tx)) !== 'pending') return 'closed'
52
+ await insertSuperUser(tx, { ...options, admin })
53
+ await writeSetupState(tx, 'complete')
54
+ return 'created'
55
+ }),
56
+ )
57
+ }
@@ -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
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The approval workflows as last synced from `schema/workflows/` (story 028),
3
+ * each with its version, so a running node reads them without the files and a
4
+ * sync can tell which changed. docs/workflows.md.
5
+ */
6
+ import { type SchemaWorkflow, startStages } from '@crombolo/core'
7
+ import type { Db } from './database.ts'
8
+ import { ApprovalRepository } from './repositories/approvals.ts'
9
+
10
+ export const WORKFLOWS_KEY = 'crombolo:workflows'
11
+
12
+ export interface StoredWorkflow {
13
+ workflow: SchemaWorkflow
14
+ version: string
15
+ }
16
+
17
+ /** Stored workflows read leniently: anything unreadable is none. Pure. */
18
+ export function parseStoredWorkflows(value: string | undefined): StoredWorkflow[] {
19
+ if (!value) return []
20
+ try {
21
+ const parsed = JSON.parse(value) as unknown
22
+ return Array.isArray(parsed)
23
+ ? parsed.filter(
24
+ (w): w is StoredWorkflow =>
25
+ !!w && typeof w === 'object' && 'workflow' in w && 'version' in w,
26
+ )
27
+ : []
28
+ } catch {
29
+ return []
30
+ }
31
+ }
32
+
33
+ export async function readWorkflows(db: Db): Promise<StoredWorkflow[]> {
34
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
35
+ WORKFLOWS_KEY,
36
+ ])
37
+ return parseStoredWorkflows(rows[0]?.value)
38
+ }
39
+
40
+ /** Stores the workflows; answers what was there before. */
41
+ export async function writeWorkflows(
42
+ db: Db,
43
+ workflows: readonly StoredWorkflow[],
44
+ ): Promise<StoredWorkflow[]> {
45
+ return db.transaction(async (tx) => {
46
+ const before = await readWorkflows(tx)
47
+ const value = JSON.stringify(workflows)
48
+ const now = tx.dialect.now()
49
+ const updated = await tx.query<{ key: string }>(
50
+ `UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
51
+ [value, WORKFLOWS_KEY],
52
+ )
53
+ if (updated.length === 0)
54
+ await tx.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
55
+ WORKFLOWS_KEY,
56
+ value,
57
+ ])
58
+ return before
59
+ })
60
+ }
61
+
62
+ export interface WorkflowSyncReport {
63
+ /** Aliases whose meaning changed, or that are new or gone. */
64
+ changed: string[]
65
+ /** What `on-change` did to requests in flight, by request key. */
66
+ requests: Array<{ key: string; workflow: string; outcome: 'kept' | 'restarted' | 'withdrawn' }>
67
+ }
68
+
69
+ /**
70
+ * Stores the workflows and applies each changed one's `on-change` to the
71
+ * requests in flight under it: `keep` leaves them on the version they started
72
+ * with, `restart` starts them again from the first stage of the new one, and
73
+ * `withdraw` ends them. A workflow that is gone cannot be restarted under, so
74
+ * its requests are kept or withdrawn as it said.
75
+ */
76
+ export async function syncWorkflows(
77
+ db: Db,
78
+ workflows: readonly StoredWorkflow[],
79
+ ): Promise<WorkflowSyncReport> {
80
+ const report: WorkflowSyncReport = { changed: [], requests: [] }
81
+ const before = await writeWorkflows(db, workflows)
82
+ const identity = (w: SchemaWorkflow) => w.key ?? w.alias
83
+ const previous = new Map(before.map((s) => [identity(s.workflow), s]))
84
+ const current = new Map(workflows.map((s) => [identity(s.workflow), s]))
85
+ const requests = new ApprovalRepository(db)
86
+ for (const id of new Set([...previous.keys(), ...current.keys()])) {
87
+ const was = previous.get(id)
88
+ const now = current.get(id)
89
+ if (was && now && was.version === now.version) continue
90
+ report.changed.push((now ?? was)?.workflow.alias ?? id)
91
+ const rule = (now ?? was)?.workflow.onChange ?? 'keep'
92
+ for (const request of await requests.waitingUnder(id)) {
93
+ if (request.workflowVersion === now?.version) continue
94
+ if (rule === 'keep') {
95
+ report.requests.push({ key: request.key, workflow: id, outcome: 'kept' })
96
+ continue
97
+ }
98
+ if (rule === 'restart' && now) {
99
+ await requests.rebase(request.id, now.workflow, now.version)
100
+ for (const stage of startStages(now.workflow)) await requests.addStep(request.id, stage)
101
+ await requests.record(request.id, { kind: 'restarted', comment: 'the workflow changed' })
102
+ report.requests.push({ key: request.key, workflow: id, outcome: 'restarted' })
103
+ continue
104
+ }
105
+ await requests.setState(request.id, 'withdrawn')
106
+ await requests.record(request.id, {
107
+ kind: 'withdrawn',
108
+ comment: now ? 'the workflow changed' : 'the workflow was removed',
109
+ })
110
+ report.requests.push({ key: request.key, workflow: id, outcome: 'withdrawn' })
111
+ }
112
+ }
113
+ return report
114
+ }