@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/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
|
+
}
|