@crombolo/data 0.11.0 → 0.12.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 +14 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -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/references.ts +15 -0
- package/src/repositories/transfer-answers.ts +151 -0
- package/src/repositories/webhooks.ts +299 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +8 -0
- package/src/schema/seed.ts +13 -2
- 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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic roots: a content picker's start node, found from the document being
|
|
3
|
+
* edited by an origin and a list of steps, as Umbraco's are. docs/api-contract.md.
|
|
4
|
+
*/
|
|
5
|
+
import { normaliseUuid, ObjectTypes } from '@crombolo/core'
|
|
6
|
+
import type { Db } from '../database.ts'
|
|
7
|
+
|
|
8
|
+
export interface DynamicRootQuery {
|
|
9
|
+
/** The document being edited; null while it is being created. */
|
|
10
|
+
currentKey: string | null
|
|
11
|
+
/** Its parent; null at the root of the tree. */
|
|
12
|
+
parentKey: string | null
|
|
13
|
+
origin: { alias: string; key?: string | null }
|
|
14
|
+
steps: Array<{ alias: string; documentTypeKeys: string[] }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Node {
|
|
18
|
+
id: number
|
|
19
|
+
key: string
|
|
20
|
+
level: number
|
|
21
|
+
path: string
|
|
22
|
+
typeKey: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The virtual root above the tree: ContentRoot's origin, which descendant steps start from. */
|
|
26
|
+
const CONTENT_ROOT: Node = { id: -1, key: '', level: 0, path: '-1', typeKey: '' }
|
|
27
|
+
|
|
28
|
+
const SELECT = `SELECT n.id, n.unique_id, n.level, n.path, t.unique_id AS type_key
|
|
29
|
+
FROM node n
|
|
30
|
+
JOIN content c ON c.node_id = n.id
|
|
31
|
+
JOIN node t ON t.id = c.content_type_id`
|
|
32
|
+
|
|
33
|
+
export class DynamicRootRepository {
|
|
34
|
+
#db: Db
|
|
35
|
+
|
|
36
|
+
constructor(db: Db) {
|
|
37
|
+
this.#db = db
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The keys the query lands on, nearest first; empty when it finds nothing. */
|
|
41
|
+
async resolve(query: DynamicRootQuery): Promise<string[]> {
|
|
42
|
+
const origin = await this.#origin(query)
|
|
43
|
+
if (!origin) return []
|
|
44
|
+
let found: Node[] = [origin]
|
|
45
|
+
for (const step of query.steps) {
|
|
46
|
+
const types = new Set(step.documentTypeKeys.map((key) => normaliseUuid(key)))
|
|
47
|
+
const next = new Map<number, Node>()
|
|
48
|
+
for (const node of found)
|
|
49
|
+
for (const hit of await this.#step(node, step.alias, types)) next.set(hit.id, hit)
|
|
50
|
+
found = [...next.values()]
|
|
51
|
+
if (found.length === 0) return []
|
|
52
|
+
}
|
|
53
|
+
return found.filter((node) => node.id !== CONTENT_ROOT.id).map((node) => node.key)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async #origin(query: DynamicRootQuery): Promise<Node | undefined> {
|
|
57
|
+
const context = query.currentKey ?? query.parentKey
|
|
58
|
+
switch (query.origin.alias) {
|
|
59
|
+
case 'ContentRoot':
|
|
60
|
+
return CONTENT_ROOT
|
|
61
|
+
case 'ByKey':
|
|
62
|
+
return query.origin.key ? this.#byKey(query.origin.key) : undefined
|
|
63
|
+
case 'Current':
|
|
64
|
+
return query.currentKey ? this.#byKey(query.currentKey) : undefined
|
|
65
|
+
case 'Parent':
|
|
66
|
+
return query.parentKey ? this.#byKey(query.parentKey) : undefined
|
|
67
|
+
case 'Root':
|
|
68
|
+
return context ? this.#root(context) : undefined
|
|
69
|
+
case 'Site':
|
|
70
|
+
return context ? ((await this.#site(context)) ?? this.#root(context)) : undefined
|
|
71
|
+
default:
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async #step(node: Node, alias: string, types: Set<string>): Promise<Node[]> {
|
|
77
|
+
const matches = (candidate: Node) => types.size === 0 || types.has(candidate.typeKey)
|
|
78
|
+
switch (alias) {
|
|
79
|
+
case 'NearestAncestorOrSelf':
|
|
80
|
+
case 'FurthestAncestorOrSelf': {
|
|
81
|
+
const line = (await this.#ancestorsOrSelf(node)).filter(matches)
|
|
82
|
+
const pick = alias === 'NearestAncestorOrSelf' ? line.at(-1) : line[0]
|
|
83
|
+
return pick ? [pick] : []
|
|
84
|
+
}
|
|
85
|
+
case 'NearestDescendantOrSelf':
|
|
86
|
+
case 'FurthestDescendantOrSelf': {
|
|
87
|
+
const below = (await this.#descendantsOrSelf(node)).filter(matches)
|
|
88
|
+
if (below.length === 0) return []
|
|
89
|
+
const levels = below.map((candidate) => candidate.level)
|
|
90
|
+
const level =
|
|
91
|
+
alias === 'NearestDescendantOrSelf' ? Math.min(...levels) : Math.max(...levels)
|
|
92
|
+
return below.filter((candidate) => candidate.level === level)
|
|
93
|
+
}
|
|
94
|
+
default:
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async #query(where: string, params: unknown[]): Promise<Node[]> {
|
|
100
|
+
const rows = await this.#db.query(
|
|
101
|
+
`${SELECT} WHERE n.node_object_type = ? AND n.trashed = ? AND ${where} ORDER BY n.level, n.sort_order`,
|
|
102
|
+
[ObjectTypes.Document, this.#db.dialect.boolValue(false), ...params],
|
|
103
|
+
)
|
|
104
|
+
return rows.map((row) => ({
|
|
105
|
+
id: Number(row.id),
|
|
106
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
107
|
+
level: Number(row.level),
|
|
108
|
+
path: String(row.path),
|
|
109
|
+
typeKey: normaliseUuid(String(row.type_key)),
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async #byKey(key: string): Promise<Node | undefined> {
|
|
114
|
+
return (await this.#query('n.unique_id = ?', [normaliseUuid(key)]))[0]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Top of the tree first, `node` last. */
|
|
118
|
+
async #ancestorsOrSelf(node: Node): Promise<Node[]> {
|
|
119
|
+
if (node.id === CONTENT_ROOT.id) return []
|
|
120
|
+
const ids = node.path
|
|
121
|
+
.split(',')
|
|
122
|
+
.map(Number)
|
|
123
|
+
.filter((id) => id > 0)
|
|
124
|
+
if (ids.length === 0) return []
|
|
125
|
+
return this.#query(`n.id IN (${ids.map(() => '?').join(', ')})`, ids)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async #descendantsOrSelf(node: Node): Promise<Node[]> {
|
|
129
|
+
if (node.id === CONTENT_ROOT.id) return this.#query('n.level >= 1', [])
|
|
130
|
+
return this.#query('(n.id = ? OR n.path LIKE ?)', [node.id, `${node.path},%`])
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async #root(key: string): Promise<Node | undefined> {
|
|
134
|
+
const node = await this.#byKey(key)
|
|
135
|
+
return node ? (await this.#ancestorsOrSelf(node))[0] : undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The nearest ancestor-or-self that a hostname makes a site root. */
|
|
139
|
+
async #site(key: string): Promise<Node | undefined> {
|
|
140
|
+
const node = await this.#byKey(key)
|
|
141
|
+
if (!node) return undefined
|
|
142
|
+
const line = await this.#ancestorsOrSelf(node)
|
|
143
|
+
if (line.length === 0) return undefined
|
|
144
|
+
const rows = await this.#db.query<{ node_id: number }>(
|
|
145
|
+
`SELECT DISTINCT node_id FROM domain WHERE domain_name NOT LIKE '*%' AND node_id IN (${line.map(() => '?').join(', ')})`,
|
|
146
|
+
line.map((candidate) => candidate.id),
|
|
147
|
+
)
|
|
148
|
+
const sites = new Set(rows.map((row) => Number(row.node_id)))
|
|
149
|
+
return line.filter((candidate) => sites.has(candidate.id)).at(-1)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -23,6 +23,11 @@ export interface FormEntryValue {
|
|
|
23
23
|
fieldAlias: string
|
|
24
24
|
/** One row per selection for a multi-value field, in the order submitted. */
|
|
25
25
|
values: string[]
|
|
26
|
+
/**
|
|
27
|
+
* What the visitor saw for each value, when the choice came from a source and
|
|
28
|
+
* the value is a key; present only then.
|
|
29
|
+
*/
|
|
30
|
+
labels?: string[]
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface FormEntry {
|
|
@@ -83,6 +88,7 @@ interface ValueRow {
|
|
|
83
88
|
field_alias: string
|
|
84
89
|
sort_order: number
|
|
85
90
|
value: string | null
|
|
91
|
+
label: string | null
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
const SELECT = `SELECT id, form_key, form_alias, state, culture, page_key, ip_hash,
|
|
@@ -109,19 +115,24 @@ const hydrate = (row: EntryRow, values: FormEntryValue[]): FormEntry => ({
|
|
|
109
115
|
|
|
110
116
|
/** Value rows into one entry per id, each field's selections in order. */
|
|
111
117
|
function group(rows: readonly ValueRow[]): Map<string, FormEntryValue[]> {
|
|
112
|
-
const byEntry = new Map<string, Map<string,
|
|
118
|
+
const byEntry = new Map<string, Map<string, ValueRow[]>>()
|
|
113
119
|
for (const row of rows) {
|
|
114
|
-
const fields = byEntry.get(row.entry_id) ?? new Map<string,
|
|
120
|
+
const fields = byEntry.get(row.entry_id) ?? new Map<string, ValueRow[]>()
|
|
115
121
|
byEntry.set(row.entry_id, fields)
|
|
116
122
|
const values = fields.get(row.field_alias) ?? []
|
|
117
123
|
fields.set(row.field_alias, values)
|
|
118
|
-
values.push(row
|
|
124
|
+
values.push(row)
|
|
119
125
|
}
|
|
120
126
|
const out = new Map<string, FormEntryValue[]>()
|
|
121
127
|
for (const [id, fields] of byEntry)
|
|
122
128
|
out.set(
|
|
123
129
|
id,
|
|
124
|
-
[...fields].map(([fieldAlias,
|
|
130
|
+
[...fields].map(([fieldAlias, list]) => {
|
|
131
|
+
const values = list.map((row) => row.value ?? '')
|
|
132
|
+
return list.some((row) => row.label !== null)
|
|
133
|
+
? { fieldAlias, values, labels: list.map((row, i) => row.label ?? (values[i] as string)) }
|
|
134
|
+
: { fieldAlias, values }
|
|
135
|
+
}),
|
|
125
136
|
)
|
|
126
137
|
return out
|
|
127
138
|
}
|
|
@@ -137,7 +148,7 @@ export class FormEntryRepository {
|
|
|
137
148
|
if (ids.length === 0) return new Map()
|
|
138
149
|
const placeholders = ids.map(() => '?').join(', ')
|
|
139
150
|
const rows = await this.#db.query<ValueRow>(
|
|
140
|
-
`SELECT entry_id, field_alias, sort_order, value
|
|
151
|
+
`SELECT entry_id, field_alias, sort_order, value, label
|
|
141
152
|
FROM form_entry_value
|
|
142
153
|
WHERE entry_id IN (${placeholders})
|
|
143
154
|
ORDER BY field_alias, sort_order`,
|
|
@@ -176,8 +187,10 @@ export class FormEntryRepository {
|
|
|
176
187
|
params.push(this.#db.dialect.boolValue(false))
|
|
177
188
|
}
|
|
178
189
|
if (query.search) {
|
|
179
|
-
where.push(
|
|
180
|
-
|
|
190
|
+
where.push(
|
|
191
|
+
`id IN (SELECT entry_id FROM form_entry_value WHERE LOWER(value) LIKE ? OR LOWER(label) LIKE ?)`,
|
|
192
|
+
)
|
|
193
|
+
params.push(`%${query.search.toLowerCase()}%`, `%${query.search.toLowerCase()}%`)
|
|
181
194
|
}
|
|
182
195
|
const clause = `WHERE ${where.join(' AND ')}`
|
|
183
196
|
|
|
@@ -242,9 +255,9 @@ export class FormEntryRepository {
|
|
|
242
255
|
const list = value.values.length === 0 ? [''] : value.values
|
|
243
256
|
for (const [sort, text] of list.entries())
|
|
244
257
|
await this.#db.exec(
|
|
245
|
-
`INSERT INTO form_entry_value (entry_id, field_alias, sort_order, value)
|
|
246
|
-
VALUES (?, ?, ?, ?)`,
|
|
247
|
-
[id, value.fieldAlias, sort, text],
|
|
258
|
+
`INSERT INTO form_entry_value (entry_id, field_alias, sort_order, value, label)
|
|
259
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
260
|
+
[id, value.fieldAlias, sort, text, value.labels?.[sort] ?? null],
|
|
248
261
|
)
|
|
249
262
|
}
|
|
250
263
|
}
|
|
@@ -280,8 +293,16 @@ export class FormEntryRepository {
|
|
|
280
293
|
async setValues(id: string, values: readonly FormEntryValue[]): Promise<FormEntry | undefined> {
|
|
281
294
|
const existing = await this.byId(id)
|
|
282
295
|
if (!existing) return undefined
|
|
296
|
+
// An edit sends values only; a label still describes a value left as it was.
|
|
297
|
+
const before = new Map(existing.values.map((value) => [value.fieldAlias, value]))
|
|
298
|
+
const kept = values.map((value) => {
|
|
299
|
+
const old = before.get(value.fieldAlias)
|
|
300
|
+
if (value.labels || !old?.labels) return value
|
|
301
|
+
const labels = value.values.map((v) => old.labels?.[old.values.indexOf(v)] ?? v)
|
|
302
|
+
return { ...value, labels }
|
|
303
|
+
})
|
|
283
304
|
await this.#db.exec('DELETE FROM form_entry_value WHERE entry_id = ?', [id])
|
|
284
|
-
await this.#writeValues(id,
|
|
305
|
+
await this.#writeValues(id, kept)
|
|
285
306
|
await this.#db.exec('UPDATE form_entry SET update_date = ? WHERE id = ?', [
|
|
286
307
|
DbDate.toDb(new Date()),
|
|
287
308
|
id,
|
|
@@ -21,7 +21,7 @@ export interface FormWorkflowRun {
|
|
|
21
21
|
/** The entry this came from; absent for a form that stores none. */
|
|
22
22
|
entryId: string | null
|
|
23
23
|
/** The submitted values, as the workflow needs them. */
|
|
24
|
-
payload: { fieldAlias: string; values: string[] }[]
|
|
24
|
+
payload: { fieldAlias: string; values: string[]; labels?: string[] }[]
|
|
25
25
|
state: FormWorkflowState
|
|
26
26
|
attempts: number
|
|
27
27
|
lastError: string | null
|
|
@@ -300,6 +300,17 @@ export class MemberRepository {
|
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
/** Records a successful sign-in, clearing whatever failures came before it. */
|
|
303
|
+
/**
|
|
304
|
+
* Replaces an imported hash with argon2id after a successful sign-in. The
|
|
305
|
+
* password is the same, so the security stamp and change date stand.
|
|
306
|
+
*/
|
|
307
|
+
async rehashPassword(key: string, passwordHash: string, passwordConfig: string): Promise<void> {
|
|
308
|
+
await this.#db.exec(
|
|
309
|
+
'UPDATE member SET password = ?, password_config = ? WHERE node_id = (SELECT id FROM node WHERE unique_id = ?)',
|
|
310
|
+
[passwordHash, passwordConfig, normaliseUuid(key)],
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
303
314
|
async recordLogin(key: string): Promise<void> {
|
|
304
315
|
const node = await this.#nodes.byKey(key)
|
|
305
316
|
if (!node) return
|
|
@@ -284,6 +284,17 @@ export class NodeRepository {
|
|
|
284
284
|
return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
/** The nodes with these ids, in no particular order; one query. */
|
|
288
|
+
async byIds(ids: readonly number[]): Promise<NodeRow[]> {
|
|
289
|
+
if (ids.length === 0) return []
|
|
290
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
291
|
+
const rows = await this.#db.query(
|
|
292
|
+
`SELECT ${NODE_COLUMNS} FROM node WHERE id IN (${placeholders})`,
|
|
293
|
+
[...ids],
|
|
294
|
+
)
|
|
295
|
+
return rows.map(mapNode)
|
|
296
|
+
}
|
|
297
|
+
|
|
287
298
|
/** Ancestors of a node, root first, excluding the node itself. */
|
|
288
299
|
async ancestors(key: string): Promise<NodeRow[]> {
|
|
289
300
|
const node = await this.byKey(key)
|
|
@@ -85,6 +85,36 @@ export class PublicAccessRepository {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Every protected node with who may see it, in two queries: what a request
|
|
90
|
+
* checking many pages at once reads, rather than an entry per page.
|
|
91
|
+
*/
|
|
92
|
+
async allRules(): Promise<
|
|
93
|
+
Array<{ nodeId: number; memberGroupNames: string[]; memberUserNames: string[] }>
|
|
94
|
+
> {
|
|
95
|
+
const entries = await this.#db.query<{ id: number; node_id: number }>(
|
|
96
|
+
'SELECT id, node_id FROM public_access',
|
|
97
|
+
)
|
|
98
|
+
if (entries.length === 0) return []
|
|
99
|
+
const rules = await this.#db.query<{
|
|
100
|
+
public_access_id: number
|
|
101
|
+
rule_type: string
|
|
102
|
+
rule_value: string
|
|
103
|
+
}>('SELECT public_access_id, rule_type, rule_value FROM public_access_rule')
|
|
104
|
+
return entries.map((entry) => {
|
|
105
|
+
const own = rules.filter((r) => Number(r.public_access_id) === Number(entry.id))
|
|
106
|
+
return {
|
|
107
|
+
nodeId: Number(entry.node_id),
|
|
108
|
+
memberGroupNames: own
|
|
109
|
+
.filter((r) => r.rule_type === MEMBER_GROUP_RULE)
|
|
110
|
+
.map((r) => r.rule_value),
|
|
111
|
+
memberUserNames: own
|
|
112
|
+
.filter((r) => r.rule_type === MEMBER_USERNAME_RULE)
|
|
113
|
+
.map((r) => r.rule_value),
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
88
118
|
/** The entry protecting this page itself, if any. */
|
|
89
119
|
async byNodeKey(nodeKey: string): Promise<PublicAccessEntry | undefined> {
|
|
90
120
|
const rows = await this.#db.query<EntryRow>(`${SELECT} WHERE n.unique_id = ?`, [
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { normaliseUuid } from '@crombolo/core'
|
|
14
14
|
import type { Db } from '../database.ts'
|
|
15
|
+
import { RelationRepository } from '../relations.ts'
|
|
15
16
|
|
|
16
17
|
export interface ReferencingNode {
|
|
17
18
|
key: string
|
|
@@ -159,4 +160,18 @@ export class ReferenceRepository {
|
|
|
159
160
|
)
|
|
160
161
|
return { total: referenced.length, items: referenced.slice(skip, skip + take) }
|
|
161
162
|
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* What refers to anything of `objectType` in the recycle bin — what the
|
|
166
|
+
* "empty the bin" dialog warns about — in name order, paged.
|
|
167
|
+
*/
|
|
168
|
+
async referencingRecycleBin(
|
|
169
|
+
objectType: string,
|
|
170
|
+
skip: number,
|
|
171
|
+
take: number,
|
|
172
|
+
): Promise<{ total: number; items: ReferencingNode[] }> {
|
|
173
|
+
const ids = await new RelationRepository(this.#db).referencingRecycleBin(objectType)
|
|
174
|
+
const described = await this.#describe(ids)
|
|
175
|
+
return { total: described.length, items: described.slice(skip, skip + take) }
|
|
176
|
+
}
|
|
162
177
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending content imports and the answers given to their conflicts in the
|
|
3
|
+
* backoffice. The answers are laid over a bundle's `resolutions.json` at import.
|
|
4
|
+
*/
|
|
5
|
+
import type { Db } from '../database.ts'
|
|
6
|
+
import { DbDate, fromDbBool } from '../dialect.ts'
|
|
7
|
+
|
|
8
|
+
export interface PendingTransfer {
|
|
9
|
+
bundleId: string
|
|
10
|
+
label: string | null
|
|
11
|
+
/** A package name the site depends on, or a directory on this server. */
|
|
12
|
+
target: string
|
|
13
|
+
targetKind: 'package' | 'directory'
|
|
14
|
+
/** Where the check placed the bundle's roots, as given. */
|
|
15
|
+
under: string | null
|
|
16
|
+
publish: boolean
|
|
17
|
+
allowMissingBlobs: boolean
|
|
18
|
+
checkedAt: Date
|
|
19
|
+
checkedBy: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TransferAnswer {
|
|
23
|
+
nodeKey: string
|
|
24
|
+
/** `"*"` for the whole node, else `alias` or `alias@culture`. */
|
|
25
|
+
handle: string
|
|
26
|
+
resolution: string
|
|
27
|
+
answeredBy: string | null
|
|
28
|
+
answeredAt: Date
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Answers as `byNode`, the shape `resolutions.json` and the import take. Pure. */
|
|
32
|
+
export function answersByNode(
|
|
33
|
+
answers: readonly Pick<TransferAnswer, 'nodeKey' | 'handle' | 'resolution'>[],
|
|
34
|
+
): Record<string, Record<string, string>> {
|
|
35
|
+
const byNode: Record<string, Record<string, string>> = {}
|
|
36
|
+
for (const answer of answers)
|
|
37
|
+
byNode[answer.nodeKey] = {
|
|
38
|
+
...(byNode[answer.nodeKey] ?? {}),
|
|
39
|
+
[answer.handle]: answer.resolution,
|
|
40
|
+
}
|
|
41
|
+
return byNode
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const pendingFrom = (row: Record<string, unknown>): PendingTransfer => ({
|
|
45
|
+
bundleId: row.bundle_id as string,
|
|
46
|
+
label: (row.label as string | null) ?? null,
|
|
47
|
+
target: row.target as string,
|
|
48
|
+
targetKind: row.target_kind === 'directory' ? 'directory' : 'package',
|
|
49
|
+
under: (row.under_target as string | null) ?? null,
|
|
50
|
+
publish: fromDbBool(row.publish),
|
|
51
|
+
allowMissingBlobs: fromDbBool(row.allow_missing_blobs),
|
|
52
|
+
checkedAt: DbDate.fromDb(row.checked_at) ?? new Date(0),
|
|
53
|
+
checkedBy: (row.checked_by as string | null) ?? null,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export class TransferAnswerRepository {
|
|
57
|
+
#db: Db
|
|
58
|
+
constructor(db: Db) {
|
|
59
|
+
this.#db = db
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Records the latest check of a bundle; its answers stand. */
|
|
63
|
+
async recordCheck(pending: PendingTransfer): Promise<void> {
|
|
64
|
+
const bool = (value: boolean) => this.#db.dialect.boolValue(value)
|
|
65
|
+
await this.#db.transaction(async (tx) => {
|
|
66
|
+
await tx.exec('DELETE FROM transfer_pending WHERE bundle_id = ?', [pending.bundleId])
|
|
67
|
+
await tx.exec(
|
|
68
|
+
`INSERT INTO transfer_pending
|
|
69
|
+
(bundle_id, label, target, target_kind, under_target, publish, allow_missing_blobs, checked_at, checked_by)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
71
|
+
[
|
|
72
|
+
pending.bundleId,
|
|
73
|
+
pending.label,
|
|
74
|
+
pending.target,
|
|
75
|
+
pending.targetKind,
|
|
76
|
+
pending.under,
|
|
77
|
+
bool(pending.publish),
|
|
78
|
+
bool(pending.allowMissingBlobs),
|
|
79
|
+
DbDate.toDb(pending.checkedAt),
|
|
80
|
+
pending.checkedBy,
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async pending(bundleId: string): Promise<PendingTransfer | undefined> {
|
|
87
|
+
const rows = await this.#db.query('SELECT * FROM transfer_pending WHERE bundle_id = ?', [
|
|
88
|
+
bundleId,
|
|
89
|
+
])
|
|
90
|
+
return rows[0] ? pendingFrom(rows[0]) : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async allPending(): Promise<PendingTransfer[]> {
|
|
94
|
+
return (await this.#db.query('SELECT * FROM transfer_pending ORDER BY checked_at DESC')).map(
|
|
95
|
+
pendingFrom,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async setAllowMissingBlobs(bundleId: string, allow: boolean): Promise<void> {
|
|
100
|
+
await this.#db.exec('UPDATE transfer_pending SET allow_missing_blobs = ? WHERE bundle_id = ?', [
|
|
101
|
+
this.#db.dialect.boolValue(allow),
|
|
102
|
+
bundleId,
|
|
103
|
+
])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async answers(bundleId: string): Promise<TransferAnswer[]> {
|
|
107
|
+
const rows = await this.#db.query(
|
|
108
|
+
'SELECT * FROM transfer_answer WHERE bundle_id = ? ORDER BY node_key, handle',
|
|
109
|
+
[bundleId],
|
|
110
|
+
)
|
|
111
|
+
return rows.map((row) => ({
|
|
112
|
+
nodeKey: row.node_key as string,
|
|
113
|
+
handle: row.handle as string,
|
|
114
|
+
resolution: row.resolution as string,
|
|
115
|
+
answeredBy: (row.answered_by as string | null) ?? null,
|
|
116
|
+
answeredAt: DbDate.fromDb(row.answered_at) ?? new Date(0),
|
|
117
|
+
}))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Sets one answer, or clears it when `resolution` is null. */
|
|
121
|
+
async answer(
|
|
122
|
+
bundleId: string,
|
|
123
|
+
answer: { nodeKey: string; handle: string; resolution: string | null; by: string | null },
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
await this.#db.transaction(async (tx) => {
|
|
126
|
+
await tx.exec(
|
|
127
|
+
'DELETE FROM transfer_answer WHERE bundle_id = ? AND node_key = ? AND handle = ?',
|
|
128
|
+
[bundleId, answer.nodeKey, answer.handle],
|
|
129
|
+
)
|
|
130
|
+
if (answer.resolution === null) return
|
|
131
|
+
await tx.exec(
|
|
132
|
+
`INSERT INTO transfer_answer (bundle_id, node_key, handle, resolution, answered_by, answered_at)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
134
|
+
[
|
|
135
|
+
bundleId,
|
|
136
|
+
answer.nodeKey,
|
|
137
|
+
answer.handle,
|
|
138
|
+
answer.resolution,
|
|
139
|
+
answer.by,
|
|
140
|
+
DbDate.toDb(new Date()),
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Once the import is applied there is nothing pending, and nothing left to answer. */
|
|
147
|
+
async clear(bundleId: string): Promise<void> {
|
|
148
|
+
await this.#db.exec('DELETE FROM transfer_answer WHERE bundle_id = ?', [bundleId])
|
|
149
|
+
await this.#db.exec('DELETE FROM transfer_pending WHERE bundle_id = ?', [bundleId])
|
|
150
|
+
}
|
|
151
|
+
}
|