@crombolo/data 0.10.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.
Files changed (40) hide show
  1. package/package.json +2 -2
  2. package/src/ambient.ts +20 -0
  3. package/src/audit.ts +291 -0
  4. package/src/busy.ts +74 -0
  5. package/src/change-report.ts +6 -0
  6. package/src/cluster.ts +17 -5
  7. package/src/database.ts +80 -17
  8. package/src/dialect.ts +24 -0
  9. package/src/index.ts +20 -0
  10. package/src/locks.ts +94 -25
  11. package/src/migrations.ts +42 -16
  12. package/src/relations.ts +346 -0
  13. package/src/repositories/content-types.ts +41 -4
  14. package/src/repositories/data-types.ts +38 -0
  15. package/src/repositories/delivery-member-tokens.ts +110 -0
  16. package/src/repositories/dictionary.ts +18 -0
  17. package/src/repositories/documents.ts +443 -88
  18. package/src/repositories/dynamic-roots.ts +151 -0
  19. package/src/repositories/form-entries.ts +32 -11
  20. package/src/repositories/form-workflows.ts +1 -1
  21. package/src/repositories/members.ts +26 -1
  22. package/src/repositories/nodes.ts +43 -7
  23. package/src/repositories/public-access.ts +30 -0
  24. package/src/repositories/references.ts +15 -0
  25. package/src/repositories/transfer-answers.ts +151 -0
  26. package/src/repositories/webhooks.ts +299 -0
  27. package/src/schema/audit-log.ts +45 -0
  28. package/src/schema/delivery-member-tokens.ts +40 -0
  29. package/src/schema/diagnostics.ts +28 -0
  30. package/src/schema/plan.ts +14 -0
  31. package/src/schema/property-searchable.ts +29 -0
  32. package/src/schema/search-index.ts +92 -0
  33. package/src/schema/seed.ts +13 -2
  34. package/src/schema/transfer-answers.ts +50 -0
  35. package/src/schema/webhooks.ts +78 -0
  36. package/src/search-text.ts +133 -0
  37. package/src/search.ts +397 -0
  38. package/src/setup.ts +57 -0
  39. package/src/tree-invariants.ts +167 -0
  40. 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, 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, 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.value ?? '')
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, values]) => ({ fieldAlias, values })),
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(`id IN (SELECT entry_id FROM form_entry_value WHERE LOWER(value) LIKE ?)`)
180
- params.push(`%${query.search.toLowerCase()}%`)
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, values)
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
@@ -14,6 +14,8 @@
14
14
  import { normaliseUuid, ObjectTypes } from '@crombolo/core'
15
15
  import type { Db } from '../database.ts'
16
16
  import { DbDate, fromDbBool } from '../dialect.ts'
17
+ import { queueForSearch } from '../search.ts'
18
+ import { searchTerms } from '../search-text.ts'
17
19
  import {
18
20
  DocumentRepository,
19
21
  type DocumentRepositoryOptions,
@@ -242,6 +244,8 @@ export class MemberRepository {
242
244
  ],
243
245
  )
244
246
  await this.#writeGroups(node.id, identity.groupKeys ?? [])
247
+ // Again, now the e-mail and username it is found by are written.
248
+ await queueForSearch(this.#db, [node.id])
245
249
  const member = await this.byKey(created.key)
246
250
  if (!member) throw new Error(`the member ${created.key} could not be read back`)
247
251
  return member
@@ -290,11 +294,23 @@ export class MemberRepository {
290
294
  ...values,
291
295
  node.id,
292
296
  ])
297
+ await queueForSearch(this.#db, [node.id])
293
298
  if (identity.groupKeys) await this.#writeGroups(node.id, identity.groupKeys)
294
299
  return this.byKey(key)
295
300
  }
296
301
 
297
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
+
298
314
  async recordLogin(key: string): Promise<void> {
299
315
  const node = await this.#nodes.byKey(key)
300
316
  if (!node) return
@@ -365,9 +381,18 @@ export class MemberRepository {
365
381
  }
366
382
  const text = criteria.filter?.trim()
367
383
  if (text) {
368
- wheres.push('(LOWER(m.email) LIKE ? OR LOWER(m.login_name) LIKE ? OR LOWER(n.text) LIKE ?)')
369
384
  const like = `%${text.toLowerCase()}%`
385
+ const terms = searchTerms(text)
386
+ // The index adds what a member's properties say to the name, username and e-mail.
387
+ const indexed =
388
+ terms.length > 0
389
+ ? ` OR m.node_id IN (SELECT node_id FROM (${this.#db.dialect.fullTextHits}) h)`
390
+ : ''
391
+ wheres.push(
392
+ `(LOWER(m.email) LIKE ? OR LOWER(m.login_name) LIKE ? OR LOWER(n.text) LIKE ?${indexed})`,
393
+ )
370
394
  values.push(like, like, like)
395
+ if (indexed) values.push(this.#db.dialect.fullTextQuery(terms))
371
396
  }
372
397
  const where = wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : ''
373
398
  const order = ORDER_COLUMNS[(criteria.orderBy ?? '').toLowerCase()] ?? ORDER_COLUMNS.username
@@ -7,6 +7,7 @@
7
7
  import { normaliseUuid, ObjectTypes, type Page, SystemNodes } from '@crombolo/core'
8
8
  import type { Db } from '../database.ts'
9
9
  import { DbDate, fromDbBool } from '../dialect.ts'
10
+ import { searchTerms } from '../search-text.ts'
10
11
 
11
12
  export interface NodeRow {
12
13
  id: number
@@ -231,34 +232,69 @@ export class NodeRepository {
231
232
  return { items: rows.slice(start, end), totalBefore: start, totalAfter: rows.length - end }
232
233
  }
233
234
 
234
- /** Name search, case-insensitive, optionally under a parent. */
235
+ /**
236
+ * Name search, case-insensitive, optionally under a parent. With `fullText`,
237
+ * an item also matches when the search index holds every word of the query by
238
+ * prefix; items whose name matches come first, then the best index matches.
239
+ */
235
240
  async search(
236
241
  objectType: string,
237
242
  query: string,
238
- options: { trashed?: boolean; parentId?: number; skip: number; take: number },
243
+ options: {
244
+ trashed?: boolean
245
+ parentId?: number
246
+ fullText?: boolean
247
+ skip: number
248
+ take: number
249
+ },
239
250
  ): Promise<Page<NodeRow>> {
240
- const conditions = ['node_object_type = ?', 'trashed = ?', 'LOWER(text) LIKE ?']
251
+ const like = `%${query.toLowerCase().replaceAll('%', '').replaceAll('_', '')}%`
252
+ const terms = options.fullText ? searchTerms(query) : []
253
+ const indexed = terms.length > 0
254
+ const conditions = [
255
+ 'node_object_type = ?',
256
+ 'trashed = ?',
257
+ indexed ? '(LOWER(text) LIKE ? OR h.node_id IS NOT NULL)' : 'LOWER(text) LIKE ?',
258
+ ]
241
259
  const params: unknown[] = [
260
+ ...(indexed ? [this.#db.dialect.fullTextQuery(terms)] : []),
242
261
  objectType,
243
262
  this.#db.dialect.boolValue(options.trashed ?? false),
244
- `%${query.toLowerCase().replaceAll('%', '').replaceAll('_', '')}%`,
263
+ like,
245
264
  ]
246
265
  if (options.parentId !== undefined) {
247
266
  conditions.push('parent_id = ?')
248
267
  params.push(options.parentId)
249
268
  }
269
+ const from = indexed
270
+ ? `node LEFT JOIN (${this.#db.dialect.fullTextHits}) h ON h.node_id = node.id`
271
+ : 'node'
250
272
  const where = conditions.join(' AND ')
251
273
  const totals = await this.#db.query<{ n: number }>(
252
- `SELECT COUNT(*) AS n FROM node WHERE ${where}`,
274
+ `SELECT COUNT(*) AS n FROM ${from} WHERE ${where}`,
253
275
  params,
254
276
  )
277
+ const order = indexed
278
+ ? 'CASE WHEN LOWER(text) LIKE ? THEN 0 ELSE 1 END, COALESCE(h.score, 0) DESC, text, id'
279
+ : 'text, id'
255
280
  const rows = await this.#db.query(
256
- `SELECT ${NODE_COLUMNS} FROM node WHERE ${where} ORDER BY text, id LIMIT ? OFFSET ?`,
257
- [...params, options.take, options.skip],
281
+ `SELECT ${NODE_COLUMNS} FROM ${from} WHERE ${where} ORDER BY ${order} LIMIT ? OFFSET ?`,
282
+ [...params, ...(indexed ? [like] : []), options.take, options.skip],
258
283
  )
259
284
  return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
260
285
  }
261
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
+
262
298
  /** Ancestors of a node, root first, excluding the node itself. */
263
299
  async ancestors(key: string): Promise<NodeRow[]> {
264
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
+ }