@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
package/src/relations.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbraco's built-in relation types, and their relations derived rather than
|
|
3
|
+
* stored. "Related …" comes from the current values, as references do (story
|
|
4
|
+
* 041). "Relate parent … on delete" comes from the original parent a trashed
|
|
5
|
+
* item remembers. Copies record no origin, so "Relate Document On Copy" has none.
|
|
6
|
+
* docs/data-model.md.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto'
|
|
9
|
+
import { normaliseUuid, ObjectTypes } from '@crombolo/core'
|
|
10
|
+
import type { Db } from './database.ts'
|
|
11
|
+
import { DbDate, fromDbBool } from './dialect.ts'
|
|
12
|
+
|
|
13
|
+
/** RFC 4122's URL namespace, which Umbraco's `string.ToGuid()` hashes in. */
|
|
14
|
+
const URL_NAMESPACE = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'
|
|
15
|
+
|
|
16
|
+
/** A name-based (version 3, MD5) UUID: the same input always gives the same key. */
|
|
17
|
+
export function nameBasedUuid(name: string, namespace = URL_NAMESPACE): string {
|
|
18
|
+
const bytes = createHash('md5')
|
|
19
|
+
.update(Buffer.from(namespace.replaceAll('-', ''), 'hex'))
|
|
20
|
+
.update(name, 'utf8')
|
|
21
|
+
.digest()
|
|
22
|
+
bytes[6] = ((bytes[6] as number) & 0x0f) | 0x30
|
|
23
|
+
bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80
|
|
24
|
+
const hex = bytes.subarray(0, 16).toString('hex')
|
|
25
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type RelationDerivation = 'reference' | 'original-parent' | 'none'
|
|
29
|
+
|
|
30
|
+
export interface RelationType {
|
|
31
|
+
key: string
|
|
32
|
+
alias: string
|
|
33
|
+
name: string
|
|
34
|
+
parentObjectType: string | null
|
|
35
|
+
childObjectType: string | null
|
|
36
|
+
isBidirectional: boolean
|
|
37
|
+
isDependency: boolean
|
|
38
|
+
derivation: RelationDerivation
|
|
39
|
+
/** For a reference type, what is referred to; for an original-parent type, what was trashed. */
|
|
40
|
+
target: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const define = (
|
|
44
|
+
alias: string,
|
|
45
|
+
name: string,
|
|
46
|
+
parentObjectType: string | null,
|
|
47
|
+
childObjectType: string | null,
|
|
48
|
+
isBidirectional: boolean,
|
|
49
|
+
isDependency: boolean,
|
|
50
|
+
derivation: RelationDerivation,
|
|
51
|
+
target: string | null,
|
|
52
|
+
): RelationType => ({
|
|
53
|
+
// Umbraco's `CreateUniqueRelationTypeId`, so the keys match a real install's.
|
|
54
|
+
key: nameBasedUuid(`${alias}____${name}`),
|
|
55
|
+
alias,
|
|
56
|
+
name,
|
|
57
|
+
parentObjectType,
|
|
58
|
+
childObjectType,
|
|
59
|
+
isBidirectional,
|
|
60
|
+
isDependency,
|
|
61
|
+
derivation,
|
|
62
|
+
target,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** In Umbraco's install order. */
|
|
66
|
+
export const RELATION_TYPES: readonly RelationType[] = [
|
|
67
|
+
define(
|
|
68
|
+
'relateDocumentOnCopy',
|
|
69
|
+
'Relate Document On Copy',
|
|
70
|
+
ObjectTypes.Document,
|
|
71
|
+
ObjectTypes.Document,
|
|
72
|
+
true,
|
|
73
|
+
false,
|
|
74
|
+
'none',
|
|
75
|
+
null,
|
|
76
|
+
),
|
|
77
|
+
define(
|
|
78
|
+
'relateParentDocumentOnDelete',
|
|
79
|
+
'Relate Parent Document On Delete',
|
|
80
|
+
ObjectTypes.Document,
|
|
81
|
+
ObjectTypes.Document,
|
|
82
|
+
false,
|
|
83
|
+
false,
|
|
84
|
+
'original-parent',
|
|
85
|
+
ObjectTypes.Document,
|
|
86
|
+
),
|
|
87
|
+
define(
|
|
88
|
+
'relateParentMediaFolderOnDelete',
|
|
89
|
+
'Relate Parent Media Folder On Delete',
|
|
90
|
+
ObjectTypes.Media,
|
|
91
|
+
ObjectTypes.Media,
|
|
92
|
+
false,
|
|
93
|
+
false,
|
|
94
|
+
'original-parent',
|
|
95
|
+
ObjectTypes.Media,
|
|
96
|
+
),
|
|
97
|
+
define('umbMedia', 'Related Media', null, null, false, true, 'reference', ObjectTypes.Media),
|
|
98
|
+
define(
|
|
99
|
+
'umbDocument',
|
|
100
|
+
'Related Document',
|
|
101
|
+
null,
|
|
102
|
+
null,
|
|
103
|
+
false,
|
|
104
|
+
true,
|
|
105
|
+
'reference',
|
|
106
|
+
ObjectTypes.Document,
|
|
107
|
+
),
|
|
108
|
+
define('umbMember', 'Related Member', null, null, false, true, 'reference', ObjectTypes.Member),
|
|
109
|
+
define(
|
|
110
|
+
'umbElement',
|
|
111
|
+
'Related Element',
|
|
112
|
+
null,
|
|
113
|
+
null,
|
|
114
|
+
false,
|
|
115
|
+
true,
|
|
116
|
+
'reference',
|
|
117
|
+
ObjectTypes.Element,
|
|
118
|
+
),
|
|
119
|
+
define(
|
|
120
|
+
'relateParentElementContainerOnElementDelete',
|
|
121
|
+
'Relate Parent Element Container On Element Delete',
|
|
122
|
+
ObjectTypes.ElementContainer,
|
|
123
|
+
ObjectTypes.Element,
|
|
124
|
+
false,
|
|
125
|
+
false,
|
|
126
|
+
'original-parent',
|
|
127
|
+
ObjectTypes.Element,
|
|
128
|
+
),
|
|
129
|
+
define(
|
|
130
|
+
'relateParentElementContainerOnContainerDelete',
|
|
131
|
+
'Relate Parent Element Container On Container Delete',
|
|
132
|
+
ObjectTypes.ElementContainer,
|
|
133
|
+
ObjectTypes.ElementContainer,
|
|
134
|
+
false,
|
|
135
|
+
false,
|
|
136
|
+
'none',
|
|
137
|
+
null,
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
export function relationTypeByKey(key: string): RelationType | undefined {
|
|
142
|
+
const wanted = normaliseUuid(key)
|
|
143
|
+
return RELATION_TYPES.find((type) => type.key === wanted)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const UDI = /umb:\/\/[a-z-]+\/([0-9a-f]{32})/gi
|
|
147
|
+
const UUID = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi
|
|
148
|
+
|
|
149
|
+
/** Every key a stored value mentions, as a picker writes it: `umb://<type>/<hex>` or a bare uuid. */
|
|
150
|
+
export function referencedKeysIn(text: string): string[] {
|
|
151
|
+
const keys = new Set<string>()
|
|
152
|
+
for (const match of text.matchAll(UDI)) {
|
|
153
|
+
const hex = (match[1] as string).toLowerCase()
|
|
154
|
+
keys.add(
|
|
155
|
+
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
for (const match of text.matchAll(UUID)) keys.add(normaliseUuid(match[1] as string))
|
|
159
|
+
return [...keys]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ValueText {
|
|
163
|
+
nodeId: number
|
|
164
|
+
text: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface KnownNode {
|
|
168
|
+
id: number
|
|
169
|
+
key: string
|
|
170
|
+
objectType: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ReferenceEdge {
|
|
174
|
+
fromId: number
|
|
175
|
+
toId: number
|
|
176
|
+
toObjectType: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Who refers to whom, once per pair, never a node to itself. Pure. */
|
|
180
|
+
export function deriveReferences(
|
|
181
|
+
values: readonly ValueText[],
|
|
182
|
+
nodesByKey: ReadonlyMap<string, KnownNode>,
|
|
183
|
+
): ReferenceEdge[] {
|
|
184
|
+
const seen = new Set<string>()
|
|
185
|
+
const edges: ReferenceEdge[] = []
|
|
186
|
+
for (const value of values) {
|
|
187
|
+
for (const key of referencedKeysIn(value.text)) {
|
|
188
|
+
const target = nodesByKey.get(key)
|
|
189
|
+
if (!target || target.id === value.nodeId) continue
|
|
190
|
+
const pair = `${value.nodeId}>${target.id}`
|
|
191
|
+
if (seen.has(pair)) continue
|
|
192
|
+
seen.add(pair)
|
|
193
|
+
edges.push({ fromId: value.nodeId, toId: target.id, toObjectType: target.objectType })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return edges
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Every reference the current values make. A scan, which is the price of never going stale. */
|
|
200
|
+
export async function loadReferences(db: Db): Promise<ReferenceEdge[]> {
|
|
201
|
+
const values = await db.query<{ node_id: number; v: string | null; t: string | null }>(
|
|
202
|
+
`SELECT node_id, varchar_value AS v, text_value AS t FROM property_value
|
|
203
|
+
WHERE is_current = ? AND (varchar_value LIKE '%-%-%-%-%' OR text_value LIKE '%-%-%-%-%'
|
|
204
|
+
OR varchar_value LIKE '%umb://%' OR text_value LIKE '%umb://%')`,
|
|
205
|
+
[db.dialect.boolValue(true)],
|
|
206
|
+
)
|
|
207
|
+
const texts: ValueText[] = []
|
|
208
|
+
for (const row of values) {
|
|
209
|
+
const text = `${row.v ?? ''} ${row.t ?? ''}`
|
|
210
|
+
texts.push({ nodeId: Number(row.node_id), text })
|
|
211
|
+
}
|
|
212
|
+
const wanted = new Set(texts.flatMap((value) => referencedKeysIn(value.text)))
|
|
213
|
+
if (wanted.size === 0) return []
|
|
214
|
+
const nodes = await db.query<{ id: number; unique_id: string; node_object_type: string }>(
|
|
215
|
+
'SELECT id, unique_id, node_object_type FROM node',
|
|
216
|
+
)
|
|
217
|
+
const byKey = new Map<string, KnownNode>()
|
|
218
|
+
for (const node of nodes) {
|
|
219
|
+
const key = normaliseUuid(String(node.unique_id))
|
|
220
|
+
if (wanted.has(key))
|
|
221
|
+
byKey.set(key, {
|
|
222
|
+
id: Number(node.id),
|
|
223
|
+
key,
|
|
224
|
+
objectType: String(node.node_object_type).toLowerCase(),
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
return deriveReferences(texts, byKey)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface Relation {
|
|
231
|
+
key: string
|
|
232
|
+
typeKey: string
|
|
233
|
+
parent: { key: string; name: string | null }
|
|
234
|
+
child: { key: string; name: string | null }
|
|
235
|
+
createDate: Date
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface NodeFacts {
|
|
239
|
+
key: string
|
|
240
|
+
name: string | null
|
|
241
|
+
date: Date
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function describeNodes(db: Db, ids: readonly number[]): Promise<Map<number, NodeFacts>> {
|
|
245
|
+
const facts = new Map<number, NodeFacts>()
|
|
246
|
+
if (ids.length === 0) return facts
|
|
247
|
+
const holes = ids.map(() => '?').join(', ')
|
|
248
|
+
const rows = await db.query<{ id: number; unique_id: string; text: string | null; d: unknown }>(
|
|
249
|
+
`SELECT n.id, n.unique_id, n.text,
|
|
250
|
+
COALESCE((SELECT MAX(cv.version_date) FROM content_version cv WHERE cv.node_id = n.id), n.create_date) AS d
|
|
251
|
+
FROM node n WHERE n.id IN (${holes})`,
|
|
252
|
+
[...ids],
|
|
253
|
+
)
|
|
254
|
+
for (const row of rows)
|
|
255
|
+
facts.set(Number(row.id), {
|
|
256
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
257
|
+
name: row.text,
|
|
258
|
+
date: DbDate.fromDb(row.d) ?? new Date(0),
|
|
259
|
+
})
|
|
260
|
+
return facts
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A stable key for a derived relation: its type and its two ends. */
|
|
264
|
+
export const relationKey = (type: RelationType, parentKey: string, childKey: string) =>
|
|
265
|
+
nameBasedUuid(`${type.alias}|${parentKey}|${childKey}`)
|
|
266
|
+
|
|
267
|
+
export class RelationRepository {
|
|
268
|
+
#db: Db
|
|
269
|
+
|
|
270
|
+
constructor(db: Db) {
|
|
271
|
+
this.#db = db
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The relations of one type, ordered by the parent's name then the child's, paged. */
|
|
275
|
+
async relations(
|
|
276
|
+
type: RelationType,
|
|
277
|
+
skip: number,
|
|
278
|
+
take: number,
|
|
279
|
+
): Promise<{ total: number; items: Relation[] }> {
|
|
280
|
+
const pairs = await this.#pairs(type)
|
|
281
|
+
const facts = await describeNodes(this.#db, [...new Set(pairs.flat())])
|
|
282
|
+
const relations: Relation[] = []
|
|
283
|
+
for (const [parentId, childId] of pairs) {
|
|
284
|
+
const parent = facts.get(parentId)
|
|
285
|
+
const child = facts.get(childId)
|
|
286
|
+
if (!parent || !child) continue
|
|
287
|
+
relations.push({
|
|
288
|
+
key: relationKey(type, parent.key, child.key),
|
|
289
|
+
typeKey: type.key,
|
|
290
|
+
parent: { key: parent.key, name: parent.name },
|
|
291
|
+
child: { key: child.key, name: child.name },
|
|
292
|
+
createDate: type.derivation === 'reference' ? parent.date : child.date,
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
relations.sort(
|
|
296
|
+
(a, b) =>
|
|
297
|
+
(a.parent.name ?? '').localeCompare(b.parent.name ?? '') ||
|
|
298
|
+
(a.child.name ?? '').localeCompare(b.child.name ?? ''),
|
|
299
|
+
)
|
|
300
|
+
return { total: relations.length, items: relations.slice(skip, skip + take) }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async #pairs(type: RelationType): Promise<Array<[number, number]>> {
|
|
304
|
+
if (type.derivation === 'reference')
|
|
305
|
+
return (await loadReferences(this.#db))
|
|
306
|
+
.filter((edge) => edge.toObjectType === type.target)
|
|
307
|
+
.map((edge) => [edge.fromId, edge.toId])
|
|
308
|
+
if (type.derivation === 'original-parent') {
|
|
309
|
+
const rows = await this.#db.query<{ id: number; parent: number; parent_type: string }>(
|
|
310
|
+
`SELECT n.id, d.original_parent_id AS parent, p.node_object_type AS parent_type
|
|
311
|
+
FROM node n
|
|
312
|
+
JOIN document d ON d.node_id = n.id
|
|
313
|
+
JOIN node p ON p.id = d.original_parent_id
|
|
314
|
+
WHERE n.trashed = ? AND n.node_object_type = ?`,
|
|
315
|
+
[this.#db.dialect.boolValue(true), type.target],
|
|
316
|
+
)
|
|
317
|
+
return rows
|
|
318
|
+
.filter(
|
|
319
|
+
(row) =>
|
|
320
|
+
!type.parentObjectType ||
|
|
321
|
+
String(row.parent_type).toLowerCase() === type.parentObjectType,
|
|
322
|
+
)
|
|
323
|
+
.map((row) => [Number(row.parent), Number(row.id)])
|
|
324
|
+
}
|
|
325
|
+
return []
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Nodes that refer to anything of `objectType` in the recycle bin: what emptying it breaks. */
|
|
329
|
+
async referencingRecycleBin(objectType: string): Promise<number[]> {
|
|
330
|
+
const trashed = new Set(
|
|
331
|
+
(
|
|
332
|
+
await this.#db.query<{ id: number; trashed: unknown }>(
|
|
333
|
+
'SELECT id, trashed FROM node WHERE node_object_type = ? AND trashed = ?',
|
|
334
|
+
[objectType, this.#db.dialect.boolValue(true)],
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
.filter((row) => fromDbBool(row.trashed))
|
|
338
|
+
.map((row) => Number(row.id)),
|
|
339
|
+
)
|
|
340
|
+
if (trashed.size === 0) return []
|
|
341
|
+
const from = new Set<number>()
|
|
342
|
+
for (const edge of await loadReferences(this.#db))
|
|
343
|
+
if (trashed.has(edge.toId)) from.add(edge.fromId)
|
|
344
|
+
return [...from]
|
|
345
|
+
}
|
|
346
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval requests (story 028): a page sent for approval, the stages it has
|
|
3
|
+
* reached, and its history. What the rules decide is the engine's
|
|
4
|
+
* (`packages/server/src/approvals.ts`); this only keeps the record.
|
|
5
|
+
* docs/workflows.md.
|
|
6
|
+
*/
|
|
7
|
+
import { normaliseUuid, type SchemaWorkflow } from '@crombolo/core'
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import { DbDate } from '../dialect.ts'
|
|
10
|
+
|
|
11
|
+
export type ApprovalAction = 'publish' | 'unpublish'
|
|
12
|
+
export type ApprovalState = 'waiting' | 'approved' | 'sent-back' | 'withdrawn'
|
|
13
|
+
export type ApprovalStepState = 'waiting' | 'approved' | 'sent-back' | 'discarded'
|
|
14
|
+
export type ApprovalHistoryKind =
|
|
15
|
+
| 'sent'
|
|
16
|
+
| 'approved'
|
|
17
|
+
| 'sent-back'
|
|
18
|
+
| 'withdrawn'
|
|
19
|
+
| 'edited'
|
|
20
|
+
| 'restarted'
|
|
21
|
+
| 'published'
|
|
22
|
+
| 'unpublished'
|
|
23
|
+
|
|
24
|
+
export interface ApprovalRequest {
|
|
25
|
+
id: number
|
|
26
|
+
key: string
|
|
27
|
+
nodeKey: string
|
|
28
|
+
/** Null for the invariant variant, or the whole page. */
|
|
29
|
+
cultures: string[] | null
|
|
30
|
+
action: ApprovalAction
|
|
31
|
+
/** The page's head event when it was sent, or after the last edit the workflow let through. */
|
|
32
|
+
eventId: number | null
|
|
33
|
+
workflowKey: string
|
|
34
|
+
workflowVersion: string
|
|
35
|
+
/** The workflow as it stood when the request started, or was restarted. */
|
|
36
|
+
workflow: SchemaWorkflow
|
|
37
|
+
state: ApprovalState
|
|
38
|
+
/** The user's key. */
|
|
39
|
+
requestedBy: string
|
|
40
|
+
requestedAt: Date
|
|
41
|
+
finishedAt: Date | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ApprovalStep {
|
|
45
|
+
id: number
|
|
46
|
+
requestId: number
|
|
47
|
+
stage: string
|
|
48
|
+
state: ApprovalStepState
|
|
49
|
+
reachedAt: Date
|
|
50
|
+
doneAt: Date | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApprovalHistoryEntry {
|
|
54
|
+
id: number
|
|
55
|
+
requestId: number
|
|
56
|
+
/** The stage visit it belongs to: approvals count only toward the visit they were given in. */
|
|
57
|
+
stepId: number | null
|
|
58
|
+
stage: string | null
|
|
59
|
+
kind: ApprovalHistoryKind
|
|
60
|
+
userKey: string | null
|
|
61
|
+
comment: string | null
|
|
62
|
+
createDate: Date
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const requestFrom = (row: Record<string, unknown>): ApprovalRequest => ({
|
|
66
|
+
id: Number(row.id),
|
|
67
|
+
key: normaliseUuid(String(row.key)),
|
|
68
|
+
nodeKey: normaliseUuid(String(row.node_key)),
|
|
69
|
+
cultures: row.cultures ? (JSON.parse(String(row.cultures)) as string[]) : null,
|
|
70
|
+
action: row.action === 'unpublish' ? 'unpublish' : 'publish',
|
|
71
|
+
eventId: row.event_id === null || row.event_id === undefined ? null : Number(row.event_id),
|
|
72
|
+
workflowKey: String(row.workflow_key),
|
|
73
|
+
workflowVersion: String(row.workflow_version),
|
|
74
|
+
workflow: JSON.parse(String(row.workflow)) as SchemaWorkflow,
|
|
75
|
+
state: String(row.state) as ApprovalState,
|
|
76
|
+
requestedBy: normaliseUuid(String(row.requested_by)),
|
|
77
|
+
requestedAt: DbDate.fromDb(row.requested_at) ?? new Date(0),
|
|
78
|
+
finishedAt: DbDate.fromDb(row.finished_at) ?? null,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const stepFrom = (row: Record<string, unknown>): ApprovalStep => ({
|
|
82
|
+
id: Number(row.id),
|
|
83
|
+
requestId: Number(row.request_id),
|
|
84
|
+
stage: String(row.stage),
|
|
85
|
+
state: String(row.state) as ApprovalStepState,
|
|
86
|
+
reachedAt: DbDate.fromDb(row.reached_at) ?? new Date(0),
|
|
87
|
+
doneAt: DbDate.fromDb(row.done_at) ?? null,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const historyFrom = (row: Record<string, unknown>): ApprovalHistoryEntry => ({
|
|
91
|
+
id: Number(row.id),
|
|
92
|
+
requestId: Number(row.request_id),
|
|
93
|
+
stepId: row.step_id === null || row.step_id === undefined ? null : Number(row.step_id),
|
|
94
|
+
stage: row.stage === null || row.stage === undefined ? null : String(row.stage),
|
|
95
|
+
kind: String(row.kind) as ApprovalHistoryKind,
|
|
96
|
+
userKey:
|
|
97
|
+
row.user_key === null || row.user_key === undefined
|
|
98
|
+
? null
|
|
99
|
+
: normaliseUuid(String(row.user_key)),
|
|
100
|
+
comment: row.comment === null || row.comment === undefined ? null : String(row.comment),
|
|
101
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
/** The workflow's identity on a request: its key, or its alias when it has none. Pure. */
|
|
105
|
+
export const workflowIdentity = (workflow: SchemaWorkflow): string => workflow.key ?? workflow.alias
|
|
106
|
+
|
|
107
|
+
export class ApprovalRepository {
|
|
108
|
+
#db: Db
|
|
109
|
+
constructor(db: Db) {
|
|
110
|
+
this.#db = db
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async create(input: {
|
|
114
|
+
nodeKey: string
|
|
115
|
+
cultures: string[] | null
|
|
116
|
+
action: ApprovalAction
|
|
117
|
+
eventId: number | null
|
|
118
|
+
workflow: SchemaWorkflow
|
|
119
|
+
workflowVersion: string
|
|
120
|
+
/** The user's key. */
|
|
121
|
+
requestedBy: string
|
|
122
|
+
}): Promise<ApprovalRequest> {
|
|
123
|
+
const key = crypto.randomUUID()
|
|
124
|
+
await this.#db.exec(
|
|
125
|
+
`INSERT INTO approval_request (key, node_key, cultures, action, event_id, workflow_key,
|
|
126
|
+
workflow_version, workflow, state, requested_by, requested_at)
|
|
127
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, ?)`,
|
|
128
|
+
[
|
|
129
|
+
key,
|
|
130
|
+
normaliseUuid(input.nodeKey),
|
|
131
|
+
input.cultures ? JSON.stringify(input.cultures) : null,
|
|
132
|
+
input.action,
|
|
133
|
+
input.eventId,
|
|
134
|
+
workflowIdentity(input.workflow),
|
|
135
|
+
input.workflowVersion,
|
|
136
|
+
JSON.stringify(input.workflow),
|
|
137
|
+
normaliseUuid(input.requestedBy),
|
|
138
|
+
DbDate.toDb(new Date()),
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
return (await this.byKey(key)) as ApprovalRequest
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async byKey(key: string): Promise<ApprovalRequest | undefined> {
|
|
145
|
+
const rows = await this.#db.query('SELECT * FROM approval_request WHERE key = ?', [
|
|
146
|
+
normaliseUuid(key),
|
|
147
|
+
])
|
|
148
|
+
return rows[0] ? requestFrom(rows[0]) : undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The request a page is waiting on, if any: at most one at a time. */
|
|
152
|
+
async waitingFor(nodeKey: string): Promise<ApprovalRequest | undefined> {
|
|
153
|
+
const rows = await this.#db.query(
|
|
154
|
+
"SELECT * FROM approval_request WHERE node_key = ? AND state = 'waiting' ORDER BY id DESC",
|
|
155
|
+
[normaliseUuid(nodeKey)],
|
|
156
|
+
)
|
|
157
|
+
return rows[0] ? requestFrom(rows[0]) : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A page's requests, newest first. */
|
|
161
|
+
async forNode(nodeKey: string, limit = 20): Promise<ApprovalRequest[]> {
|
|
162
|
+
const rows = await this.#db.query(
|
|
163
|
+
'SELECT * FROM approval_request WHERE node_key = ? ORDER BY id DESC LIMIT ?',
|
|
164
|
+
[normaliseUuid(nodeKey), limit],
|
|
165
|
+
)
|
|
166
|
+
return rows.map(requestFrom)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Every request still waiting, oldest first. */
|
|
170
|
+
async waiting(): Promise<ApprovalRequest[]> {
|
|
171
|
+
const rows = await this.#db.query(
|
|
172
|
+
"SELECT * FROM approval_request WHERE state = 'waiting' ORDER BY id",
|
|
173
|
+
)
|
|
174
|
+
return rows.map(requestFrom)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Requests that have finished, newest first. */
|
|
178
|
+
async finished(skip: number, take: number): Promise<{ total: number; items: ApprovalRequest[] }> {
|
|
179
|
+
const counted = await this.#db.query<{ n: number }>(
|
|
180
|
+
"SELECT COUNT(*) AS n FROM approval_request WHERE state <> 'waiting'",
|
|
181
|
+
)
|
|
182
|
+
const rows = await this.#db.query(
|
|
183
|
+
"SELECT * FROM approval_request WHERE state <> 'waiting' ORDER BY id DESC LIMIT ? OFFSET ?",
|
|
184
|
+
[take, skip],
|
|
185
|
+
)
|
|
186
|
+
return { total: Number(counted[0]?.n ?? 0), items: rows.map(requestFrom) }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async waitingUnder(workflowKey: string): Promise<ApprovalRequest[]> {
|
|
190
|
+
const rows = await this.#db.query(
|
|
191
|
+
"SELECT * FROM approval_request WHERE workflow_key = ? AND state = 'waiting' ORDER BY id",
|
|
192
|
+
[workflowKey],
|
|
193
|
+
)
|
|
194
|
+
return rows.map(requestFrom)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async setState(id: number, state: ApprovalState): Promise<void> {
|
|
198
|
+
await this.#db.exec('UPDATE approval_request SET state = ?, finished_at = ? WHERE id = ?', [
|
|
199
|
+
state,
|
|
200
|
+
state === 'waiting' ? null : DbDate.toDb(new Date()),
|
|
201
|
+
id,
|
|
202
|
+
])
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async setEvent(id: number, eventId: number | null): Promise<void> {
|
|
206
|
+
await this.#db.exec('UPDATE approval_request SET event_id = ? WHERE id = ?', [eventId, id])
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Puts a request under another version of its workflow, starting over: its
|
|
211
|
+
* stage visits are cleared, and its history keeps what happened.
|
|
212
|
+
*/
|
|
213
|
+
async rebase(id: number, workflow: SchemaWorkflow, version: string): Promise<void> {
|
|
214
|
+
await this.#db.exec(
|
|
215
|
+
'UPDATE approval_request SET workflow = ?, workflow_version = ? WHERE id = ?',
|
|
216
|
+
[JSON.stringify(workflow), version, id],
|
|
217
|
+
)
|
|
218
|
+
await this.#db.exec('DELETE FROM approval_step WHERE request_id = ?', [id])
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async steps(requestId: number): Promise<ApprovalStep[]> {
|
|
222
|
+
const rows = await this.#db.query(
|
|
223
|
+
'SELECT * FROM approval_step WHERE request_id = ? ORDER BY id',
|
|
224
|
+
[requestId],
|
|
225
|
+
)
|
|
226
|
+
return rows.map(stepFrom)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async addStep(requestId: number, stage: string): Promise<number> {
|
|
230
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
231
|
+
"INSERT INTO approval_step (request_id, stage, state, reached_at) VALUES (?, ?, 'waiting', ?) RETURNING id",
|
|
232
|
+
[requestId, stage, DbDate.toDb(new Date())],
|
|
233
|
+
)
|
|
234
|
+
return Number(rows[0]?.id)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async setStepState(id: number, state: ApprovalStepState): Promise<void> {
|
|
238
|
+
await this.#db.exec('UPDATE approval_step SET state = ?, done_at = ? WHERE id = ?', [
|
|
239
|
+
state,
|
|
240
|
+
state === 'waiting' ? null : DbDate.toDb(new Date()),
|
|
241
|
+
id,
|
|
242
|
+
])
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async record(
|
|
246
|
+
requestId: number,
|
|
247
|
+
entry: {
|
|
248
|
+
kind: ApprovalHistoryKind
|
|
249
|
+
stepId?: number | null
|
|
250
|
+
stage?: string | null
|
|
251
|
+
userKey?: string | null
|
|
252
|
+
comment?: string | null
|
|
253
|
+
},
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
await this.#db.exec(
|
|
256
|
+
`INSERT INTO approval_history (request_id, step_id, stage, kind, user_key, comment, create_date)
|
|
257
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
258
|
+
[
|
|
259
|
+
requestId,
|
|
260
|
+
entry.stepId ?? null,
|
|
261
|
+
entry.stage ?? null,
|
|
262
|
+
entry.kind,
|
|
263
|
+
entry.userKey ? normaliseUuid(entry.userKey) : null,
|
|
264
|
+
entry.comment ?? null,
|
|
265
|
+
DbDate.toDb(new Date()),
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async history(requestId: number): Promise<ApprovalHistoryEntry[]> {
|
|
271
|
+
const rows = await this.#db.query(
|
|
272
|
+
'SELECT * FROM approval_history WHERE request_id = ? ORDER BY id',
|
|
273
|
+
[requestId],
|
|
274
|
+
)
|
|
275
|
+
return rows.map(historyFrom)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -121,6 +121,44 @@ export class DataTypeRepository {
|
|
|
121
121
|
return { total: items.length, items: items.slice(paging.skip, paging.skip + paging.take) }
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** Whether any current value is saved through this data type; undefined when there is no such type. */
|
|
125
|
+
async isUsed(key: string): Promise<boolean | undefined> {
|
|
126
|
+
const node = await this.#nodes.byKey(key)
|
|
127
|
+
if (!node || node.objectType !== ObjectTypes.DataType) return undefined
|
|
128
|
+
return this.#hasValues('p.data_type_id = ?', [node.id])
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether any current value is saved for the property `alias` of a content type;
|
|
133
|
+
* undefined when the type or the property does not exist.
|
|
134
|
+
*/
|
|
135
|
+
async isPropertyUsed(contentTypeKey: string, alias: string): Promise<boolean | undefined> {
|
|
136
|
+
const rows = await this.#db.query<{ id: number }>(
|
|
137
|
+
`SELECT p.id FROM property_type p
|
|
138
|
+
JOIN node ctn ON ctn.id = p.content_type_id
|
|
139
|
+
WHERE ctn.unique_id = ? AND p.alias = ? AND p.retired_at IS NULL`,
|
|
140
|
+
[normaliseUuid(contentTypeKey), alias],
|
|
141
|
+
)
|
|
142
|
+
const property = rows[0]
|
|
143
|
+
if (!property) return undefined
|
|
144
|
+
return this.#hasValues('p.id = ?', [Number(property.id)])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A current value that holds something: an empty string or an all-null row is not a use. */
|
|
148
|
+
async #hasValues(where: string, params: unknown[]): Promise<boolean> {
|
|
149
|
+
const rows = await this.#db.query(
|
|
150
|
+
`SELECT 1 AS used FROM property_value v
|
|
151
|
+
JOIN property_type p ON p.id = v.property_type_id
|
|
152
|
+
WHERE ${where} AND v.is_current = ? AND p.retired_at IS NULL
|
|
153
|
+
AND (v.int_value IS NOT NULL OR v.decimal_value IS NOT NULL OR v.date_value IS NOT NULL
|
|
154
|
+
OR (v.varchar_value IS NOT NULL AND v.varchar_value <> '')
|
|
155
|
+
OR (v.text_value IS NOT NULL AND v.text_value <> ''))
|
|
156
|
+
LIMIT 1`,
|
|
157
|
+
[...params, this.#db.dialect.boolValue(true)],
|
|
158
|
+
)
|
|
159
|
+
return rows.length > 0
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
/** Property types using this data type, with the type each belongs to. */
|
|
125
163
|
async referencedBy(key: string): Promise<
|
|
126
164
|
Array<{
|