@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.
@@ -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
+ }
@@ -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
  ])
@@ -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
- admin: SeedAdmin
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
+ }