@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.
- package/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/audit.ts +291 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +80 -17
- package/src/dialect.ts +24 -0
- package/src/index.ts +20 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/content-types.ts +41 -4
- 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 +443 -88
- 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 +26 -1
- package/src/repositories/nodes.ts +43 -7
- 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/audit-log.ts +45 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +14 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -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/search-text.ts +133 -0
- package/src/search.ts +397 -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
|
|
@@ -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
|
-
/**
|
|
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: {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|