@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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhooks kept in this environment, and the delivery queue and log every
|
|
3
|
+
* webhook and bundle event handler shares. docs/webhooks.md.
|
|
4
|
+
*
|
|
5
|
+
* A delivery is claimed with `UPDATE … WHERE state = 'pending' … RETURNING`, as
|
|
6
|
+
* form workflows are, so on a cluster exactly one node delivers each.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from 'node:crypto'
|
|
9
|
+
import type { Db } from '../database.ts'
|
|
10
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
11
|
+
|
|
12
|
+
export interface WebhookRow {
|
|
13
|
+
key: string
|
|
14
|
+
name: string | null
|
|
15
|
+
description: string | null
|
|
16
|
+
url: string
|
|
17
|
+
enabled: boolean
|
|
18
|
+
events: string[]
|
|
19
|
+
contentTypeKeys: string[]
|
|
20
|
+
/** The headers as one sealed value; the server holds the key that opens it. */
|
|
21
|
+
sealedHeaders: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parseList = (value: unknown): string[] => {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(String(value ?? '[]')) as unknown
|
|
27
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class WebhookRepository {
|
|
34
|
+
#db: Db
|
|
35
|
+
constructor(db: Db) {
|
|
36
|
+
this.#db = db
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async all(): Promise<WebhookRow[]> {
|
|
40
|
+
const rows = await this.#db.query(
|
|
41
|
+
'SELECT key, name, description, url, enabled, events, content_type_keys, headers FROM webhook ORDER BY create_date, key',
|
|
42
|
+
)
|
|
43
|
+
return rows.map((row) => ({
|
|
44
|
+
key: String(row.key),
|
|
45
|
+
name: (row.name as string | null) ?? null,
|
|
46
|
+
description: (row.description as string | null) ?? null,
|
|
47
|
+
url: String(row.url),
|
|
48
|
+
enabled: fromDbBool(row.enabled),
|
|
49
|
+
events: parseList(row.events),
|
|
50
|
+
contentTypeKeys: parseList(row.content_type_keys),
|
|
51
|
+
sealedHeaders: (row.headers as string | null) ?? null,
|
|
52
|
+
}))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async byKey(key: string): Promise<WebhookRow | undefined> {
|
|
56
|
+
return (await this.all()).find((row) => row.key === key.toLowerCase())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async save(webhook: WebhookRow): Promise<void> {
|
|
60
|
+
const now = DbDate.toDb(new Date())
|
|
61
|
+
const values = [
|
|
62
|
+
webhook.name,
|
|
63
|
+
webhook.description,
|
|
64
|
+
webhook.url,
|
|
65
|
+
this.#db.dialect.boolValue(webhook.enabled),
|
|
66
|
+
JSON.stringify(webhook.events),
|
|
67
|
+
JSON.stringify(webhook.contentTypeKeys),
|
|
68
|
+
webhook.sealedHeaders,
|
|
69
|
+
now,
|
|
70
|
+
]
|
|
71
|
+
const updated = await this.#db.query(
|
|
72
|
+
`UPDATE webhook SET name = ?, description = ?, url = ?, enabled = ?, events = ?,
|
|
73
|
+
content_type_keys = ?, headers = ?, update_date = ?
|
|
74
|
+
WHERE key = ? RETURNING key`,
|
|
75
|
+
[...values, webhook.key.toLowerCase()],
|
|
76
|
+
)
|
|
77
|
+
if (updated.length > 0) return
|
|
78
|
+
await this.#db.exec(
|
|
79
|
+
`INSERT INTO webhook (name, description, url, enabled, events, content_type_keys, headers,
|
|
80
|
+
update_date, key, create_date)
|
|
81
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
82
|
+
[...values, webhook.key.toLowerCase(), now],
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async remove(key: string): Promise<boolean> {
|
|
87
|
+
const rows = await this.#db.query('DELETE FROM webhook WHERE key = ? RETURNING key', [
|
|
88
|
+
key.toLowerCase(),
|
|
89
|
+
])
|
|
90
|
+
return rows.length > 0
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type WebhookDeliveryState = 'pending' | 'running' | 'done' | 'failed'
|
|
95
|
+
|
|
96
|
+
export interface WebhookDelivery {
|
|
97
|
+
id: string
|
|
98
|
+
/** A webhook's key, or `bundle:<id>` for a bundle's handler. */
|
|
99
|
+
subscriber: string
|
|
100
|
+
eventAlias: string
|
|
101
|
+
payload: unknown
|
|
102
|
+
state: WebhookDeliveryState
|
|
103
|
+
attempts: number
|
|
104
|
+
lastError: string | null
|
|
105
|
+
runAfter: Date
|
|
106
|
+
createDate: Date
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface WebhookLogEntry {
|
|
110
|
+
key: string
|
|
111
|
+
deliveryId: string
|
|
112
|
+
subscriber: string
|
|
113
|
+
eventAlias: string
|
|
114
|
+
url: string
|
|
115
|
+
statusCode: number | null
|
|
116
|
+
success: boolean
|
|
117
|
+
exception: boolean
|
|
118
|
+
retryCount: number
|
|
119
|
+
requestHeaders: string
|
|
120
|
+
requestBody: string
|
|
121
|
+
responseHeaders: string
|
|
122
|
+
responseBody: string
|
|
123
|
+
date: Date
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const DELIVERY_COLUMNS =
|
|
127
|
+
'id, subscriber, event_alias, payload, state, attempts, last_error, run_after, create_date'
|
|
128
|
+
|
|
129
|
+
const hydrateDelivery = (row: Record<string, unknown>): WebhookDelivery => {
|
|
130
|
+
let payload: unknown = null
|
|
131
|
+
try {
|
|
132
|
+
payload = JSON.parse(String(row.payload))
|
|
133
|
+
} catch {
|
|
134
|
+
payload = null
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
id: String(row.id),
|
|
138
|
+
subscriber: String(row.subscriber),
|
|
139
|
+
eventAlias: String(row.event_alias),
|
|
140
|
+
payload,
|
|
141
|
+
state: String(row.state) as WebhookDeliveryState,
|
|
142
|
+
attempts: Number(row.attempts),
|
|
143
|
+
lastError: (row.last_error as string | null) ?? null,
|
|
144
|
+
runAfter: DbDate.fromDb(row.run_after) ?? new Date(0),
|
|
145
|
+
createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Bodies and headers are kept to what a person will read, not what a server sent. */
|
|
150
|
+
const clip = (value: string) => value.slice(0, 10_000)
|
|
151
|
+
|
|
152
|
+
export class WebhookDeliveryRepository {
|
|
153
|
+
#db: Db
|
|
154
|
+
constructor(db: Db) {
|
|
155
|
+
this.#db = db
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async enqueue(
|
|
159
|
+
deliveries: ReadonlyArray<{ subscriber: string; eventAlias: string; payload: unknown }>,
|
|
160
|
+
now = new Date(),
|
|
161
|
+
): Promise<string[]> {
|
|
162
|
+
const ids: string[] = []
|
|
163
|
+
for (const delivery of deliveries) {
|
|
164
|
+
const id = randomUUID()
|
|
165
|
+
await this.#db.exec(
|
|
166
|
+
`INSERT INTO webhook_delivery (${DELIVERY_COLUMNS}, update_date)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
168
|
+
[
|
|
169
|
+
id,
|
|
170
|
+
delivery.subscriber,
|
|
171
|
+
delivery.eventAlias,
|
|
172
|
+
JSON.stringify(delivery.payload),
|
|
173
|
+
'pending',
|
|
174
|
+
0,
|
|
175
|
+
null,
|
|
176
|
+
DbDate.toDb(now),
|
|
177
|
+
DbDate.toDb(now),
|
|
178
|
+
DbDate.toDb(now),
|
|
179
|
+
],
|
|
180
|
+
)
|
|
181
|
+
ids.push(id)
|
|
182
|
+
}
|
|
183
|
+
return ids
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async due(now = new Date(), limit = 25): Promise<WebhookDelivery[]> {
|
|
187
|
+
const rows = await this.#db.query(
|
|
188
|
+
`SELECT ${DELIVERY_COLUMNS} FROM webhook_delivery WHERE state = ? AND run_after <= ?
|
|
189
|
+
ORDER BY run_after, create_date LIMIT ?`,
|
|
190
|
+
['pending', DbDate.toDb(now), limit],
|
|
191
|
+
)
|
|
192
|
+
return rows.map(hydrateDelivery)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Takes a delivery, or finds another node already has. */
|
|
196
|
+
async claim(id: string): Promise<WebhookDelivery | undefined> {
|
|
197
|
+
const rows = await this.#db.query(
|
|
198
|
+
`UPDATE webhook_delivery SET state = ?, attempts = attempts + 1, update_date = ?
|
|
199
|
+
WHERE id = ? AND state = ? RETURNING ${DELIVERY_COLUMNS}`,
|
|
200
|
+
['running', DbDate.toDb(new Date()), id, 'pending'],
|
|
201
|
+
)
|
|
202
|
+
return rows[0] ? hydrateDelivery(rows[0]) : undefined
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async succeed(id: string): Promise<void> {
|
|
206
|
+
await this.#db.exec(
|
|
207
|
+
'UPDATE webhook_delivery SET state = ?, last_error = NULL, update_date = ? WHERE id = ?',
|
|
208
|
+
['done', DbDate.toDb(new Date()), id],
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** `retryAt` puts it back in the queue; without one it has failed for good. */
|
|
213
|
+
async fail(id: string, error: string, retryAt?: Date): Promise<void> {
|
|
214
|
+
await this.#db.exec(
|
|
215
|
+
'UPDATE webhook_delivery SET state = ?, last_error = ?, run_after = ?, update_date = ? WHERE id = ?',
|
|
216
|
+
[
|
|
217
|
+
retryAt ? 'pending' : 'failed',
|
|
218
|
+
error.slice(0, 2000),
|
|
219
|
+
DbDate.toDb(retryAt ?? new Date()),
|
|
220
|
+
DbDate.toDb(new Date()),
|
|
221
|
+
id,
|
|
222
|
+
],
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async byId(id: string): Promise<WebhookDelivery | undefined> {
|
|
227
|
+
const rows = await this.#db.query(
|
|
228
|
+
`SELECT ${DELIVERY_COLUMNS} FROM webhook_delivery WHERE id = ?`,
|
|
229
|
+
[id],
|
|
230
|
+
)
|
|
231
|
+
return rows[0] ? hydrateDelivery(rows[0]) : undefined
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async log(entry: Omit<WebhookLogEntry, 'key' | 'date'>, date = new Date()): Promise<void> {
|
|
235
|
+
await this.#db.exec(
|
|
236
|
+
`INSERT INTO webhook_log (key, delivery_id, subscriber, event_alias, url, status_code, success,
|
|
237
|
+
exception, retry_count, request_headers, request_body, response_headers, response_body, log_date)
|
|
238
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
239
|
+
[
|
|
240
|
+
randomUUID(),
|
|
241
|
+
entry.deliveryId,
|
|
242
|
+
entry.subscriber,
|
|
243
|
+
entry.eventAlias,
|
|
244
|
+
entry.url,
|
|
245
|
+
entry.statusCode,
|
|
246
|
+
this.#db.dialect.boolValue(entry.success),
|
|
247
|
+
this.#db.dialect.boolValue(entry.exception),
|
|
248
|
+
entry.retryCount,
|
|
249
|
+
clip(entry.requestHeaders),
|
|
250
|
+
clip(entry.requestBody),
|
|
251
|
+
clip(entry.responseHeaders),
|
|
252
|
+
clip(entry.responseBody),
|
|
253
|
+
DbDate.toDb(date),
|
|
254
|
+
],
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The log, newest first; one subscriber's when named. */
|
|
259
|
+
async logs(options: {
|
|
260
|
+
subscriber?: string
|
|
261
|
+
skip: number
|
|
262
|
+
take: number
|
|
263
|
+
}): Promise<{ total: number; items: WebhookLogEntry[] }> {
|
|
264
|
+
const where = options.subscriber ? 'WHERE subscriber = ?' : ''
|
|
265
|
+
const params = options.subscriber ? [options.subscriber] : []
|
|
266
|
+
const total = await this.#db.query<{ n: number }>(
|
|
267
|
+
`SELECT COUNT(*) AS n FROM webhook_log ${where}`,
|
|
268
|
+
params,
|
|
269
|
+
)
|
|
270
|
+
const rows = await this.#db.query(
|
|
271
|
+
`SELECT key, delivery_id, subscriber, event_alias, url, status_code, success, exception,
|
|
272
|
+
retry_count, request_headers, request_body, response_headers, response_body, log_date
|
|
273
|
+
FROM webhook_log ${where} ORDER BY log_date DESC, retry_count DESC, key LIMIT ? OFFSET ?`,
|
|
274
|
+
[...params, options.take, options.skip],
|
|
275
|
+
)
|
|
276
|
+
return {
|
|
277
|
+
total: Number(total[0]?.n ?? 0),
|
|
278
|
+
items: rows.map((row) => ({
|
|
279
|
+
key: String(row.key),
|
|
280
|
+
deliveryId: String(row.delivery_id),
|
|
281
|
+
subscriber: String(row.subscriber),
|
|
282
|
+
eventAlias: String(row.event_alias),
|
|
283
|
+
url: String(row.url),
|
|
284
|
+
statusCode:
|
|
285
|
+
row.status_code === null || row.status_code === undefined
|
|
286
|
+
? null
|
|
287
|
+
: Number(row.status_code),
|
|
288
|
+
success: fromDbBool(row.success),
|
|
289
|
+
exception: fromDbBool(row.exception),
|
|
290
|
+
retryCount: Number(row.retry_count),
|
|
291
|
+
requestHeaders: String(row.request_headers),
|
|
292
|
+
requestBody: String(row.request_body),
|
|
293
|
+
responseHeaders: String(row.response_headers),
|
|
294
|
+
responseBody: String(row.response_body),
|
|
295
|
+
date: DbDate.fromDb(row.log_date) ?? new Date(0),
|
|
296
|
+
})),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 038 — approval requests (story 028): a request per page sent for
|
|
3
|
+
* approval, a row per stage it reaches, and its history. The workflow it runs
|
|
4
|
+
* under is copied onto the request, so a workflow changed later does not
|
|
5
|
+
* change one already in flight unless the workflow says so. Also gives the
|
|
6
|
+
* groups that edit content the Workflow section. docs/workflows.md.
|
|
7
|
+
*/
|
|
8
|
+
import type { Db } from '../database.ts'
|
|
9
|
+
import type { Migration } from '../migrations.ts'
|
|
10
|
+
import { STATE_DELIVERY_MEMBER_TOKENS } from './delivery-member-tokens.ts'
|
|
11
|
+
|
|
12
|
+
export const STATE_APPROVALS = '{8e4c0a3d-0038-4b62-9c07-3d5f2b0e6c48}'
|
|
13
|
+
|
|
14
|
+
/** The section the Workflow screens live in, given where content is edited. */
|
|
15
|
+
export const WORKFLOW_SECTION_GROUPS = ['admin', 'editor', 'writer']
|
|
16
|
+
|
|
17
|
+
export const approvalsMigration: Migration = {
|
|
18
|
+
from: STATE_DELIVERY_MEMBER_TOKENS,
|
|
19
|
+
to: STATE_APPROVALS,
|
|
20
|
+
name: 'AddApprovals',
|
|
21
|
+
kind: 'expand',
|
|
22
|
+
release: '0.13.1',
|
|
23
|
+
async up(db: Db) {
|
|
24
|
+
const t = db.dialect.types
|
|
25
|
+
await db.exec(
|
|
26
|
+
`CREATE TABLE approval_request (
|
|
27
|
+
id ${t.identity},
|
|
28
|
+
key ${t.uuid} NOT NULL,
|
|
29
|
+
node_key ${t.uuid} NOT NULL,
|
|
30
|
+
cultures ${t.text},
|
|
31
|
+
action ${t.varchar(16)} NOT NULL,
|
|
32
|
+
event_id ${t.bigint},
|
|
33
|
+
workflow_key ${t.varchar(64)} NOT NULL,
|
|
34
|
+
workflow_version ${t.varchar(32)} NOT NULL,
|
|
35
|
+
workflow ${t.json} NOT NULL,
|
|
36
|
+
state ${t.varchar(16)} NOT NULL,
|
|
37
|
+
requested_by ${t.varchar(64)} NOT NULL,
|
|
38
|
+
requested_at ${t.timestamp} NOT NULL,
|
|
39
|
+
finished_at ${t.timestamp}
|
|
40
|
+
)`,
|
|
41
|
+
)
|
|
42
|
+
await db.exec('CREATE UNIQUE INDEX ux_approval_request_key ON approval_request (key)')
|
|
43
|
+
await db.exec('CREATE INDEX ix_approval_request_node ON approval_request (node_key, state)')
|
|
44
|
+
await db.exec(
|
|
45
|
+
`CREATE TABLE approval_step (
|
|
46
|
+
id ${t.identity},
|
|
47
|
+
request_id ${t.integer} NOT NULL,
|
|
48
|
+
stage ${t.varchar(64)} NOT NULL,
|
|
49
|
+
state ${t.varchar(16)} NOT NULL,
|
|
50
|
+
reached_at ${t.timestamp} NOT NULL,
|
|
51
|
+
done_at ${t.timestamp}
|
|
52
|
+
)`,
|
|
53
|
+
)
|
|
54
|
+
await db.exec('CREATE INDEX ix_approval_step_request ON approval_step (request_id, state)')
|
|
55
|
+
await db.exec(
|
|
56
|
+
`CREATE TABLE approval_history (
|
|
57
|
+
id ${t.identity},
|
|
58
|
+
request_id ${t.integer} NOT NULL,
|
|
59
|
+
step_id ${t.integer},
|
|
60
|
+
stage ${t.varchar(64)},
|
|
61
|
+
kind ${t.varchar(16)} NOT NULL,
|
|
62
|
+
user_key ${t.varchar(64)},
|
|
63
|
+
comment ${t.text},
|
|
64
|
+
create_date ${t.timestamp} NOT NULL
|
|
65
|
+
)`,
|
|
66
|
+
)
|
|
67
|
+
await db.exec('CREATE INDEX ix_approval_history_request ON approval_history (request_id)')
|
|
68
|
+
for (const alias of WORKFLOW_SECTION_GROUPS)
|
|
69
|
+
await db.exec(
|
|
70
|
+
`INSERT INTO user_group_section (user_group_id, app_alias)
|
|
71
|
+
SELECT g.id, 'workflow' FROM user_group g
|
|
72
|
+
WHERE g.alias = ? AND NOT EXISTS (
|
|
73
|
+
SELECT 1 FROM user_group_section s WHERE s.user_group_id = g.id AND s.app_alias = 'workflow')`,
|
|
74
|
+
[alias],
|
|
75
|
+
)
|
|
76
|
+
},
|
|
77
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 037 — the Delivery API's member tokens (story 050):
|
|
3
|
+
* authorization codes, access and refresh tokens, each stored as the SHA-256
|
|
4
|
+
* of what the client holds, never the token itself. docs/delivery-api.md,
|
|
5
|
+
* "Member sign-in".
|
|
6
|
+
*/
|
|
7
|
+
import type { Db } from '../database.ts'
|
|
8
|
+
import type { Migration } from '../migrations.ts'
|
|
9
|
+
import { STATE_TRANSFER_ANSWERS } from './transfer-answers.ts'
|
|
10
|
+
|
|
11
|
+
export const STATE_DELIVERY_MEMBER_TOKENS = '{7d3b9e2c-0037-4a51-b8f6-2c4e1a9d5b37}'
|
|
12
|
+
|
|
13
|
+
export const deliveryMemberTokensMigration: Migration = {
|
|
14
|
+
from: STATE_TRANSFER_ANSWERS,
|
|
15
|
+
to: STATE_DELIVERY_MEMBER_TOKENS,
|
|
16
|
+
name: 'AddDeliveryMemberTokens',
|
|
17
|
+
kind: 'expand',
|
|
18
|
+
release: '0.12.0',
|
|
19
|
+
async up(db: Db) {
|
|
20
|
+
const t = db.dialect.types
|
|
21
|
+
await db.exec(
|
|
22
|
+
`CREATE TABLE delivery_member_token (
|
|
23
|
+
hash ${t.varchar(64)} NOT NULL,
|
|
24
|
+
kind ${t.varchar(16)} NOT NULL,
|
|
25
|
+
member_key ${t.varchar(64)} NOT NULL,
|
|
26
|
+
client_id ${t.varchar(255)} NOT NULL,
|
|
27
|
+
scopes ${t.varchar(255)} NOT NULL,
|
|
28
|
+
redirect_uri ${t.text},
|
|
29
|
+
code_challenge ${t.varchar(128)},
|
|
30
|
+
expires_at ${t.timestamp} NOT NULL,
|
|
31
|
+
redeemed_at ${t.timestamp},
|
|
32
|
+
create_date ${t.timestamp} NOT NULL,
|
|
33
|
+
PRIMARY KEY (hash)
|
|
34
|
+
)`,
|
|
35
|
+
)
|
|
36
|
+
await db.exec(
|
|
37
|
+
'CREATE INDEX ix_delivery_member_token_member ON delivery_member_token (member_key)',
|
|
38
|
+
)
|
|
39
|
+
},
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 034 — diagnostics and choice sources. docs/diagnostics.md, docs/forms.md.
|
|
3
|
+
*
|
|
4
|
+
* `server.crombolo_version` is the Crombolo release each node runs, beside the
|
|
5
|
+
* schema set version `server.version` already holds, so the cluster health check
|
|
6
|
+
* can say when nodes disagree. `form_entry_value.label` is the option label a
|
|
7
|
+
* visitor saw for a choice drawn from a source, so an entry still reads after the
|
|
8
|
+
* node or dictionary item behind it is renamed or removed.
|
|
9
|
+
*/
|
|
10
|
+
import type { Db } from '../database.ts'
|
|
11
|
+
import type { Migration } from '../migrations.ts'
|
|
12
|
+
import { schemaOps } from '../schema-ops.ts'
|
|
13
|
+
import { STATE_AUDIT_LOG } from './audit-log.ts'
|
|
14
|
+
|
|
15
|
+
export const STATE_DIAGNOSTICS = '{6f1d4a8e-0034-4c1a-9e7d-3a5b8c2d4e34}'
|
|
16
|
+
|
|
17
|
+
export const diagnosticsMigration: Migration = {
|
|
18
|
+
from: STATE_AUDIT_LOG,
|
|
19
|
+
to: STATE_DIAGNOSTICS,
|
|
20
|
+
name: 'AddNodeVersionAndChoiceLabel',
|
|
21
|
+
kind: 'expand',
|
|
22
|
+
release: '0.12.0',
|
|
23
|
+
async up(db: Db) {
|
|
24
|
+
const ops = schemaOps(db)
|
|
25
|
+
await ops.addColumn('server', 'crombolo_version', db.dialect.types.varchar(64))
|
|
26
|
+
await ops.addColumn('form_entry_value', 'label', db.dialect.types.text)
|
|
27
|
+
},
|
|
28
|
+
}
|
package/src/schema/plan.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* may only ship in a major (`x.0.0`), because every minor and patch is rolled
|
|
12
12
|
* under live nodes. The first contract will therefore name 1.0.0 at the earliest.
|
|
13
13
|
*/
|
|
14
|
+
|
|
14
15
|
import { MigrationPlan } from '../migrations.ts'
|
|
16
|
+
import { approvalsMigration } from './approvals.ts'
|
|
15
17
|
import { assistantMigration } from './assistant.ts'
|
|
16
18
|
import { auditLogMigration } from './audit-log.ts'
|
|
17
19
|
import { bundleComponentsMigration } from './bundle-components.ts'
|
|
@@ -22,6 +24,8 @@ import { clientIdPrefixMigration } from './client-id-prefix.ts'
|
|
|
22
24
|
import { contentMigration } from './content.ts'
|
|
23
25
|
import { contentEditingMigration } from './content-editing.ts'
|
|
24
26
|
import { createdPackagesMigration } from './created-packages.ts'
|
|
27
|
+
import { deliveryMemberTokensMigration } from './delivery-member-tokens.ts'
|
|
28
|
+
import { diagnosticsMigration } from './diagnostics.ts'
|
|
25
29
|
import { elementsMigration } from './elements.ts'
|
|
26
30
|
import { formPermissionPrefixMigration } from './form-permission-prefix.ts'
|
|
27
31
|
import { formPermissionsMigration } from './form-permissions.ts'
|
|
@@ -40,11 +44,13 @@ import { richTextUiMigration } from './rich-text-ui.ts'
|
|
|
40
44
|
import { searchIndexMigration } from './search-index.ts'
|
|
41
45
|
import { serverRoleMigration } from './server-role.ts'
|
|
42
46
|
import { sinceVersionMigration } from './since-version.ts'
|
|
47
|
+
import { transferAnswersMigration } from './transfer-answers.ts'
|
|
43
48
|
import { transferRunPackageMigration } from './transfer-run-package.ts'
|
|
44
49
|
import { transferRunsMigration } from './transfer-runs.ts'
|
|
45
50
|
import { upgradeReportMigration } from './upgrade-report.ts'
|
|
46
51
|
import { usersLocalisationMigration } from './users-localisation.ts'
|
|
47
52
|
import { valuesMigration } from './values.ts'
|
|
53
|
+
import { webhooksMigration } from './webhooks.ts'
|
|
48
54
|
|
|
49
55
|
export const cromboloPlan = new MigrationPlan([
|
|
50
56
|
identityMigration,
|
|
@@ -80,4 +86,9 @@ export const cromboloPlan = new MigrationPlan([
|
|
|
80
86
|
searchIndexMigration,
|
|
81
87
|
propertySearchableMigration,
|
|
82
88
|
auditLogMigration,
|
|
89
|
+
diagnosticsMigration,
|
|
90
|
+
webhooksMigration,
|
|
91
|
+
transferAnswersMigration,
|
|
92
|
+
deliveryMemberTokensMigration,
|
|
93
|
+
approvalsMigration,
|
|
83
94
|
])
|
package/src/schema/seed.ts
CHANGED
|
@@ -160,6 +160,7 @@ export const BUILT_IN_GROUPS = [
|
|
|
160
160
|
'forms',
|
|
161
161
|
'translation',
|
|
162
162
|
'library',
|
|
163
|
+
'workflow',
|
|
163
164
|
],
|
|
164
165
|
},
|
|
165
166
|
{
|
|
@@ -169,7 +170,7 @@ export const BUILT_IN_GROUPS = [
|
|
|
169
170
|
name: 'Writers',
|
|
170
171
|
icon: 'icon-edit',
|
|
171
172
|
hasAccessToAllLanguages: true,
|
|
172
|
-
sections: ['content', 'library'],
|
|
173
|
+
sections: ['content', 'library', 'workflow'],
|
|
173
174
|
},
|
|
174
175
|
{
|
|
175
176
|
id: 3,
|
|
@@ -178,7 +179,7 @@ export const BUILT_IN_GROUPS = [
|
|
|
178
179
|
name: 'Editors',
|
|
179
180
|
icon: 'icon-tools',
|
|
180
181
|
hasAccessToAllLanguages: true,
|
|
181
|
-
sections: ['content', 'media', 'forms', 'library'],
|
|
182
|
+
sections: ['content', 'media', 'forms', 'library', 'workflow'],
|
|
182
183
|
},
|
|
183
184
|
{
|
|
184
185
|
id: 4,
|
|
@@ -211,7 +212,8 @@ export interface SeedAdmin {
|
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
export interface SeedOptions {
|
|
214
|
-
|
|
215
|
+
/** Omitted, the site is left for its owner to set up from the browser. */
|
|
216
|
+
admin?: SeedAdmin
|
|
215
217
|
hashPassword: (password: string) => Promise<string>
|
|
216
218
|
passwordConfig: string
|
|
217
219
|
}
|
|
@@ -259,6 +261,17 @@ export async function seedIdentity(db: Db, options: SeedOptions): Promise<boolea
|
|
|
259
261
|
if (sync) await db.exec(sync)
|
|
260
262
|
await grantBuiltInPermissions(db)
|
|
261
263
|
|
|
264
|
+
if (options.admin) await insertSuperUser(db, { ...options, admin: options.admin })
|
|
265
|
+
return true
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Creates Umbraco's super user, in Administrators and Sensitive data. */
|
|
269
|
+
export async function insertSuperUser(
|
|
270
|
+
db: Db,
|
|
271
|
+
options: SeedOptions & { admin: SeedAdmin },
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const now = DbDate.toDb(new Date())
|
|
274
|
+
const bool = (value: boolean) => db.dialect.boolValue(value)
|
|
262
275
|
await db.exec(
|
|
263
276
|
`INSERT INTO user_account
|
|
264
277
|
(key, user_name, login, email, password_hash, password_config, security_stamp, language,
|
|
@@ -294,5 +307,4 @@ export async function seedIdentity(db: Db, options: SeedOptions): Promise<boolea
|
|
|
294
307
|
groupId,
|
|
295
308
|
])
|
|
296
309
|
}
|
|
297
|
-
return true
|
|
298
310
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 036 — a pending import and its answers, so a content transfer's
|
|
3
|
+
* conflicts can be answered in the backoffice. docs/content-transfer.md.
|
|
4
|
+
*
|
|
5
|
+
* `transfer_pending` is the last check of each bundle: where it was found and
|
|
6
|
+
* what the check was asked to do, so the dashboard can run the same import.
|
|
7
|
+
* `transfer_answer` holds the choices made there, one per node and property
|
|
8
|
+
* handle, as `resolutions.json` would.
|
|
9
|
+
*/
|
|
10
|
+
import type { Db } from '../database.ts'
|
|
11
|
+
import type { Migration } from '../migrations.ts'
|
|
12
|
+
import { STATE_WEBHOOKS } from './webhooks.ts'
|
|
13
|
+
|
|
14
|
+
export const STATE_TRANSFER_ANSWERS = '{2c8e5b1a-0036-4f3d-8a6e-9d1c4b7a2e36}'
|
|
15
|
+
|
|
16
|
+
export const transferAnswersMigration: Migration = {
|
|
17
|
+
from: STATE_WEBHOOKS,
|
|
18
|
+
to: STATE_TRANSFER_ANSWERS,
|
|
19
|
+
name: 'AddTransferAnswers',
|
|
20
|
+
kind: 'expand',
|
|
21
|
+
release: '0.12.0',
|
|
22
|
+
async up(db: Db) {
|
|
23
|
+
const t = db.dialect.types
|
|
24
|
+
await db.exec(
|
|
25
|
+
`CREATE TABLE transfer_pending (
|
|
26
|
+
bundle_id ${t.varchar(64)} NOT NULL,
|
|
27
|
+
label ${t.varchar(255)},
|
|
28
|
+
target ${t.text} NOT NULL,
|
|
29
|
+
target_kind ${t.varchar(16)} NOT NULL,
|
|
30
|
+
under_target ${t.text},
|
|
31
|
+
publish ${t.boolean} NOT NULL,
|
|
32
|
+
allow_missing_blobs ${t.boolean} NOT NULL,
|
|
33
|
+
checked_at ${t.timestamp} NOT NULL,
|
|
34
|
+
checked_by ${t.varchar(255)},
|
|
35
|
+
PRIMARY KEY (bundle_id)
|
|
36
|
+
)`,
|
|
37
|
+
)
|
|
38
|
+
await db.exec(
|
|
39
|
+
`CREATE TABLE transfer_answer (
|
|
40
|
+
bundle_id ${t.varchar(64)} NOT NULL,
|
|
41
|
+
node_key ${t.varchar(64)} NOT NULL,
|
|
42
|
+
handle ${t.varchar(255)} NOT NULL,
|
|
43
|
+
resolution ${t.varchar(16)} NOT NULL,
|
|
44
|
+
answered_by ${t.varchar(255)},
|
|
45
|
+
answered_at ${t.timestamp} NOT NULL,
|
|
46
|
+
PRIMARY KEY (bundle_id, node_key, handle)
|
|
47
|
+
)`,
|
|
48
|
+
)
|
|
49
|
+
},
|
|
50
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration 035 — webhooks. docs/webhooks.md.
|
|
3
|
+
*
|
|
4
|
+
* `webhook` holds the ones kept in this environment; the ones kept with the
|
|
5
|
+
* site's files are read from `schema/webhooks/`. Their headers are one encrypted
|
|
6
|
+
* blob, because a header is where an API token goes.
|
|
7
|
+
*
|
|
8
|
+
* `webhook_delivery` is the queue, for a webhook's request or a bundle's
|
|
9
|
+
* handler alike: claimed with `UPDATE … WHERE state = 'pending' … RETURNING`, so
|
|
10
|
+
* one node of a cluster delivers each. `webhook_log` keeps every attempt.
|
|
11
|
+
*/
|
|
12
|
+
import type { Db } from '../database.ts'
|
|
13
|
+
import type { Migration } from '../migrations.ts'
|
|
14
|
+
import { STATE_DIAGNOSTICS } from './diagnostics.ts'
|
|
15
|
+
|
|
16
|
+
export const STATE_WEBHOOKS = '{6f1d4a8e-0035-4c1a-9e7d-3a5b8c2d4e35}'
|
|
17
|
+
|
|
18
|
+
export const webhooksMigration: Migration = {
|
|
19
|
+
from: STATE_DIAGNOSTICS,
|
|
20
|
+
to: STATE_WEBHOOKS,
|
|
21
|
+
name: 'AddWebhooks',
|
|
22
|
+
kind: 'expand',
|
|
23
|
+
release: '0.12.0',
|
|
24
|
+
async up(db: Db) {
|
|
25
|
+
const t = db.dialect.types
|
|
26
|
+
await db.exec(
|
|
27
|
+
`CREATE TABLE webhook (
|
|
28
|
+
key ${t.varchar(64)} NOT NULL,
|
|
29
|
+
name ${t.varchar(255)},
|
|
30
|
+
description ${t.text},
|
|
31
|
+
url ${t.text} NOT NULL,
|
|
32
|
+
enabled ${t.boolean} NOT NULL,
|
|
33
|
+
events ${t.text} NOT NULL,
|
|
34
|
+
content_type_keys ${t.text} NOT NULL,
|
|
35
|
+
headers ${t.text},
|
|
36
|
+
create_date ${t.timestamp} NOT NULL,
|
|
37
|
+
update_date ${t.timestamp} NOT NULL,
|
|
38
|
+
PRIMARY KEY (key)
|
|
39
|
+
)`,
|
|
40
|
+
)
|
|
41
|
+
await db.exec(
|
|
42
|
+
`CREATE TABLE webhook_delivery (
|
|
43
|
+
id ${t.varchar(64)} NOT NULL,
|
|
44
|
+
subscriber ${t.varchar(255)} NOT NULL,
|
|
45
|
+
event_alias ${t.varchar(128)} NOT NULL,
|
|
46
|
+
payload ${t.text} NOT NULL,
|
|
47
|
+
state ${t.varchar(16)} NOT NULL,
|
|
48
|
+
attempts ${t.integer} NOT NULL,
|
|
49
|
+
last_error ${t.text},
|
|
50
|
+
run_after ${t.timestamp} NOT NULL,
|
|
51
|
+
create_date ${t.timestamp} NOT NULL,
|
|
52
|
+
update_date ${t.timestamp} NOT NULL,
|
|
53
|
+
PRIMARY KEY (id)
|
|
54
|
+
)`,
|
|
55
|
+
)
|
|
56
|
+
await db.exec('CREATE INDEX ix_webhook_delivery_due ON webhook_delivery (state, run_after)')
|
|
57
|
+
await db.exec(
|
|
58
|
+
`CREATE TABLE webhook_log (
|
|
59
|
+
key ${t.varchar(64)} NOT NULL,
|
|
60
|
+
delivery_id ${t.varchar(64)} NOT NULL,
|
|
61
|
+
subscriber ${t.varchar(255)} NOT NULL,
|
|
62
|
+
event_alias ${t.varchar(128)} NOT NULL,
|
|
63
|
+
url ${t.text} NOT NULL,
|
|
64
|
+
status_code ${t.integer},
|
|
65
|
+
success ${t.boolean} NOT NULL,
|
|
66
|
+
exception ${t.boolean} NOT NULL,
|
|
67
|
+
retry_count ${t.integer} NOT NULL,
|
|
68
|
+
request_headers ${t.text} NOT NULL,
|
|
69
|
+
request_body ${t.text} NOT NULL,
|
|
70
|
+
response_headers ${t.text} NOT NULL,
|
|
71
|
+
response_body ${t.text} NOT NULL,
|
|
72
|
+
log_date ${t.timestamp} NOT NULL,
|
|
73
|
+
PRIMARY KEY (key)
|
|
74
|
+
)`,
|
|
75
|
+
)
|
|
76
|
+
await db.exec('CREATE INDEX ix_webhook_log_subscriber ON webhook_log (subscriber, log_date)')
|
|
77
|
+
},
|
|
78
|
+
}
|