@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,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
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The words a stored value contributes to the search index, and the folding
3
+ * both the index and a query go through so they meet. docs/search.md.
4
+ */
5
+
6
+ /**
7
+ * Lowercase words without diacritics: letters and digits only, everything else
8
+ * a separator. The index stores text in this form, so the full-text engines on
9
+ * both dialects only ever split on spaces, and match the same way.
10
+ */
11
+ export function searchWords(text: string): string[] {
12
+ return text
13
+ .normalize('NFKD')
14
+ .replace(/\p{M}+/gu, '')
15
+ .toLowerCase()
16
+ .split(/[^\p{L}\p{N}]+/u)
17
+ .filter((word) => word.length > 0)
18
+ }
19
+
20
+ /** The distinct words of a query, at most a dozen: what every result must hold by prefix. */
21
+ export function searchTerms(query: string): string[] {
22
+ return [...new Set(searchWords(query))].slice(0, 12)
23
+ }
24
+
25
+ const IDENTIFIER =
26
+ /^(?:umb:\/\/\S+|\{?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}?)$/i
27
+
28
+ /**
29
+ * Keys inside an editor's JSON that hold structure rather than words: block
30
+ * and media references, editor aliases, crops. Their values are never text an
31
+ * editor wrote.
32
+ */
33
+ const STRUCTURAL_KEYS = new Set([
34
+ 'alias',
35
+ 'contentKey',
36
+ 'contentTypeKey',
37
+ 'crops',
38
+ 'culture',
39
+ 'editorAlias',
40
+ 'expose',
41
+ 'focalPoint',
42
+ 'icon',
43
+ 'key',
44
+ 'layout',
45
+ 'mediaKey',
46
+ 'mediaTypeAlias',
47
+ 'segment',
48
+ 'settingsKey',
49
+ 'settingsTypeKey',
50
+ 'udi',
51
+ 'unique',
52
+ ])
53
+
54
+ const ENTITIES: Record<string, string> = {
55
+ amp: '&',
56
+ lt: '<',
57
+ gt: '>',
58
+ quot: '"',
59
+ apos: "'",
60
+ nbsp: ' ',
61
+ }
62
+
63
+ function stripMarkup(text: string): string {
64
+ if (!text.includes('<') && !text.includes('&')) return text
65
+ return text
66
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
67
+ .replace(/<[^>]*>/g, ' ')
68
+ .replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (entity, name: string) => {
69
+ if (name.startsWith('#x') || name.startsWith('#X'))
70
+ return String.fromCodePoint(Number.parseInt(name.slice(2), 16))
71
+ if (name.startsWith('#')) return String.fromCodePoint(Number(name.slice(1)))
72
+ return ENTITIES[name.toLowerCase()] ?? entity
73
+ })
74
+ }
75
+
76
+ /** Whether an element type's property is kept out of search: `searchable = false`. */
77
+ export type UnsearchableProperty = (contentTypeKey: string, alias: string) => boolean
78
+
79
+ const NOTHING_UNSEARCHABLE: UnsearchableProperty = () => false
80
+
81
+ function collect(value: unknown, out: string[], unsearchable: UnsearchableProperty): void {
82
+ if (typeof value === 'string') {
83
+ const trimmed = value.trim()
84
+ if (trimmed.length === 0 || IDENTIFIER.test(trimmed)) return
85
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
86
+ try {
87
+ collect(JSON.parse(trimmed), out, unsearchable)
88
+ return
89
+ } catch {
90
+ // Not JSON after all; it is text that happens to start with a bracket.
91
+ }
92
+ }
93
+ const text = stripMarkup(trimmed).replace(/\s+/g, ' ').trim()
94
+ if (text.length > 0) out.push(text)
95
+ return
96
+ }
97
+ if (Array.isArray(value)) {
98
+ for (const item of value) collect(item, out, unsearchable)
99
+ return
100
+ }
101
+ if (!value || typeof value !== 'object') return
102
+ const record = value as Record<string, unknown>
103
+ // A block's element: its values are listed by alias, or (older format) keyed by it.
104
+ const elementType = typeof record.contentTypeKey === 'string' ? record.contentTypeKey : undefined
105
+ for (const [key, item] of Object.entries(record)) {
106
+ if (STRUCTURAL_KEYS.has(key)) continue
107
+ if (elementType && unsearchable(elementType, key)) continue
108
+ if (elementType && key === 'values' && Array.isArray(item)) {
109
+ for (const entry of item) {
110
+ const alias = (entry as { alias?: unknown } | null)?.alias
111
+ if (typeof alias === 'string' && unsearchable(elementType, alias)) continue
112
+ collect(entry, out, unsearchable)
113
+ }
114
+ continue
115
+ }
116
+ collect(item, out, unsearchable)
117
+ }
118
+ }
119
+
120
+ /**
121
+ * The readable text in a stored value: plain strings as they are, markup without
122
+ * its tags, and the strings inside an editor's JSON (blocks, rich text, pickers
123
+ * with captions) without the keys and references that hold it together, or
124
+ * the values of a block's properties marked `searchable = false`.
125
+ */
126
+ export function valueText(
127
+ raw: unknown,
128
+ unsearchable: UnsearchableProperty = NOTHING_UNSEARCHABLE,
129
+ ): string[] {
130
+ const out: string[] = []
131
+ collect(raw, out, unsearchable)
132
+ return out
133
+ }
package/src/search.ts ADDED
@@ -0,0 +1,397 @@
1
+ /**
2
+ * The backoffice search index: what a write queues, how the indexer turns a
3
+ * queued item into rows, and the two indexes the Examine dashboard shows.
4
+ * docs/search.md.
5
+ */
6
+ import { ObjectTypes } from '@crombolo/core'
7
+ import type { Db } from './database.ts'
8
+ import { DbDate, fromDbBool } from './dialect.ts'
9
+ import { searchTerms, searchWords, type UnsearchableProperty, valueText } from './search-text.ts'
10
+
11
+ /** Umbraco's names for the two backoffice indexes, which the dashboard shows. */
12
+ export type SearchIndexName = 'InternalIndex' | 'MembersIndex'
13
+
14
+ export interface SearchIndexDefinition {
15
+ name: SearchIndexName
16
+ searcherName: string
17
+ objectTypes: readonly string[]
18
+ }
19
+
20
+ export const SEARCH_INDEXES: readonly SearchIndexDefinition[] = [
21
+ {
22
+ name: 'InternalIndex',
23
+ searcherName: 'InternalSearcher',
24
+ objectTypes: [ObjectTypes.Document, ObjectTypes.Media],
25
+ },
26
+ { name: 'MembersIndex', searcherName: 'MembersSearcher', objectTypes: [ObjectTypes.Member] },
27
+ ]
28
+
29
+ const INDEXED_TYPES: readonly string[] = SEARCH_INDEXES.flatMap((index) => index.objectTypes)
30
+
31
+ const INDEX_TYPE: Record<string, string> = {
32
+ [ObjectTypes.Document]: 'content',
33
+ [ObjectTypes.Media]: 'media',
34
+ [ObjectTypes.Member]: 'member',
35
+ }
36
+
37
+ const marks = (n: number) => Array.from({ length: n }, () => '?').join(', ')
38
+
39
+ const ENQUEUE = `INSERT INTO search_index_queue (node_id, generation, queued_at)`
40
+ const ON_CONFLICT = `ON CONFLICT (node_id) DO UPDATE
41
+ SET generation = search_index_queue.generation + 1, queued_at = excluded.queued_at`
42
+
43
+ /**
44
+ * Queues items for the indexer. Called inside the write's own transaction, so
45
+ * the queue row commits with the change or not at all. Ids of anything that is
46
+ * not a page, media item or member are ignored.
47
+ */
48
+ export async function queueForSearch(db: Db, nodeIds: Iterable<number>): Promise<void> {
49
+ const ids = [...new Set(nodeIds)]
50
+ if (ids.length === 0) return
51
+ for (let i = 0; i < ids.length; i += 500) {
52
+ const chunk = ids.slice(i, i + 500)
53
+ await db.exec(
54
+ `${ENQUEUE} SELECT id, 1, ${db.dialect.now()} FROM node
55
+ WHERE id IN (${marks(chunk.length)}) AND node_object_type IN (${marks(INDEXED_TYPES.length)})
56
+ ${ON_CONFLICT}`,
57
+ [...chunk, ...INDEXED_TYPES],
58
+ )
59
+ }
60
+ }
61
+
62
+ /** Queues every item of these content types, and of the types composing them. */
63
+ export async function queueContentTypesForSearch(
64
+ db: Db,
65
+ contentTypeNodeIds: Iterable<number>,
66
+ ): Promise<void> {
67
+ const types = new Set(contentTypeNodeIds)
68
+ let frontier = [...types]
69
+ while (frontier.length > 0) {
70
+ const children = await db.query<{ id: number }>(
71
+ `SELECT child_content_type_id AS id FROM content_type_composition
72
+ WHERE parent_content_type_id IN (${marks(frontier.length)})`,
73
+ frontier,
74
+ )
75
+ frontier = children.map((row) => Number(row.id)).filter((id) => !types.has(id))
76
+ for (const id of frontier) types.add(id)
77
+ }
78
+ if (types.size === 0) return
79
+ const ids = [...types]
80
+ await db.exec(
81
+ `${ENQUEUE} SELECT node_id, 1, ${db.dialect.now()} FROM content
82
+ WHERE content_type_id IN (${marks(ids.length)})
83
+ ${ON_CONFLICT}`,
84
+ ids,
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Queues every page, media item and member. An element type's properties are
90
+ * read inside blocks wherever they are used, which no query can narrow.
91
+ */
92
+ export async function queueEverythingForSearch(db: Db): Promise<void> {
93
+ await db.exec(
94
+ `${ENQUEUE} SELECT id, 1, ${db.dialect.now()} FROM node
95
+ WHERE node_object_type IN (${marks(INDEXED_TYPES.length)})
96
+ ${ON_CONFLICT}`,
97
+ [...INDEXED_TYPES],
98
+ )
99
+ }
100
+
101
+ /** One row of `search_document`: an item in one culture, '' when invariant. */
102
+ export interface SearchEntry {
103
+ culture: string
104
+ name: string
105
+ body: string
106
+ fields: Record<string, string[]>
107
+ }
108
+
109
+ export interface SearchIndexStats {
110
+ documentCount: number
111
+ fieldCount: number
112
+ /** Items queued and not yet indexed. */
113
+ pending: number
114
+ }
115
+
116
+ export interface SearchHit {
117
+ key: string
118
+ score: number
119
+ fields: Array<{ name: string; values: string[] }>
120
+ }
121
+
122
+ export class SearchIndex {
123
+ #db: Db
124
+
125
+ constructor(db: Db) {
126
+ this.#db = db
127
+ }
128
+
129
+ static definition(name: string): SearchIndexDefinition | undefined {
130
+ return SEARCH_INDEXES.find((index) => index.name.toLowerCase() === name.toLowerCase())
131
+ }
132
+
133
+ static bySearcher(name: string): SearchIndexDefinition | undefined {
134
+ return SEARCH_INDEXES.find((index) => index.searcherName.toLowerCase() === name.toLowerCase())
135
+ }
136
+
137
+ /**
138
+ * Indexes up to `limit` queued items, oldest first, and returns how many it
139
+ * indexed. Each item is its own transaction, claimed by locking its queue row:
140
+ * a second indexer passes over it, and a write queueing it again bumps the
141
+ * generation, so the row outlives this run and the item is indexed once more.
142
+ */
143
+ async indexQueued(limit = 200): Promise<number> {
144
+ const queued = await this.#db.query<{ node_id: number; generation: number }>(
145
+ 'SELECT node_id, generation FROM search_index_queue ORDER BY queued_at, node_id LIMIT ?',
146
+ [limit],
147
+ )
148
+ let indexed = 0
149
+ for (const row of queued) {
150
+ const nodeId = Number(row.node_id)
151
+ const generation = Number(row.generation)
152
+ const done = await this.#db.transaction(async (tx) => {
153
+ const claimed = await tx.query(
154
+ `SELECT node_id FROM search_index_queue WHERE node_id = ? AND generation = ? ${tx.dialect.forUpdateSkipLocked}`,
155
+ [nodeId, generation],
156
+ )
157
+ if (claimed.length === 0) return false
158
+ await writeEntries(tx, nodeId, await entriesFor(tx, nodeId))
159
+ await tx.exec('DELETE FROM search_index_queue WHERE node_id = ? AND generation = ?', [
160
+ nodeId,
161
+ generation,
162
+ ])
163
+ return true
164
+ })
165
+ if (done) indexed++
166
+ }
167
+ return indexed
168
+ }
169
+
170
+ /** Indexes until nothing is queued. For tests and the command line. */
171
+ async indexAll(): Promise<number> {
172
+ let total = 0
173
+ for (;;) {
174
+ const indexed = await this.indexQueued()
175
+ total += indexed
176
+ if (indexed === 0) return total
177
+ }
178
+ }
179
+
180
+ /** Queues every item the index covers; the index keeps answering meanwhile. */
181
+ async rebuild(index: SearchIndexDefinition): Promise<void> {
182
+ await this.#db.exec(
183
+ `${ENQUEUE} SELECT id, 1, ${this.#db.dialect.now()} FROM node
184
+ WHERE node_object_type IN (${marks(index.objectTypes.length)})
185
+ ${ON_CONFLICT}`,
186
+ [...index.objectTypes],
187
+ )
188
+ }
189
+
190
+ async stats(index: SearchIndexDefinition): Promise<SearchIndexStats> {
191
+ const types = index.objectTypes
192
+ const [documents] = await this.#db.query<{ n: number; fields: number | null }>(
193
+ `SELECT COUNT(DISTINCT sd.node_id) AS n, MAX(sd.field_count) AS fields
194
+ FROM search_document sd JOIN node n ON n.id = sd.node_id
195
+ WHERE n.node_object_type IN (${marks(types.length)})`,
196
+ [...types],
197
+ )
198
+ const [pending] = await this.#db.query<{ n: number }>(
199
+ `SELECT COUNT(*) AS n FROM search_index_queue q JOIN node n ON n.id = q.node_id
200
+ WHERE n.node_object_type IN (${marks(types.length)})`,
201
+ [...types],
202
+ )
203
+ return {
204
+ documentCount: Number(documents?.n ?? 0),
205
+ fieldCount: Number(documents?.fields ?? 0),
206
+ pending: Number(pending?.n ?? 0),
207
+ }
208
+ }
209
+
210
+ /** The Examine dashboard's search: every stored field of each matching item, best first. */
211
+ async query(
212
+ index: SearchIndexDefinition,
213
+ term: string,
214
+ paging: { skip: number; take: number },
215
+ ): Promise<{ total: number; items: SearchHit[] }> {
216
+ const terms = searchTerms(term)
217
+ if (terms.length === 0) return { total: 0, items: [] }
218
+ const types = index.objectTypes
219
+ const from = `FROM (${this.#db.dialect.fullTextHits}) h JOIN node n ON n.id = h.node_id
220
+ WHERE n.node_object_type IN (${marks(types.length)})`
221
+ const params = [this.#db.dialect.fullTextQuery(terms), ...types]
222
+ const [count] = await this.#db.query<{ n: number }>(`SELECT COUNT(*) AS n ${from}`, params)
223
+ const rows = await this.#db.query<{ id: number; key: string; score: number }>(
224
+ `SELECT n.id, n.unique_id AS key, h.score ${from} ORDER BY h.score DESC, n.id LIMIT ? OFFSET ?`,
225
+ [...params, paging.take, paging.skip],
226
+ )
227
+ const items: SearchHit[] = []
228
+ for (const row of rows) {
229
+ const stored = await this.#db.query<{ fields: string }>(
230
+ 'SELECT fields FROM search_document WHERE node_id = ? ORDER BY culture',
231
+ [Number(row.id)],
232
+ )
233
+ const merged = new Map<string, string[]>()
234
+ for (const entry of stored)
235
+ for (const [name, values] of Object.entries(
236
+ JSON.parse(entry.fields) as Record<string, string[]>,
237
+ ))
238
+ merged.set(name, [...new Set([...(merged.get(name) ?? []), ...values])])
239
+ items.push({
240
+ key: String(row.key),
241
+ score: Number(row.score),
242
+ fields: [...merged].map(([name, values]) => ({ name, values })),
243
+ })
244
+ }
245
+ return { total: Number(count?.n ?? 0), items }
246
+ }
247
+ }
248
+
249
+ async function writeEntries(tx: Db, nodeId: number, entries: readonly SearchEntry[]) {
250
+ await tx.exec('DELETE FROM search_document WHERE node_id = ?', [nodeId])
251
+ const now = DbDate.toDb(new Date())
252
+ for (const entry of entries)
253
+ await tx.exec(
254
+ `INSERT INTO search_document (node_id, culture, name, body, fields, field_count, indexed_at)
255
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
256
+ [
257
+ nodeId,
258
+ entry.culture,
259
+ entry.name,
260
+ entry.body,
261
+ JSON.stringify(entry.fields),
262
+ Object.keys(entry.fields).length,
263
+ now,
264
+ ],
265
+ )
266
+ }
267
+
268
+ const folded = (texts: readonly string[]) => texts.flatMap(searchWords).join(' ')
269
+
270
+ /**
271
+ * The element-type properties marked `searchable = false`, by type key and
272
+ * alias, for the blocks inside a value. A property a composition contributes
273
+ * counts on every type composing it, which is the key a block carries.
274
+ */
275
+ async function unsearchableElementProperties(db: Db): Promise<UnsearchableProperty> {
276
+ const rows = await db.query<{ type_id: number; alias: string }>(
277
+ `SELECT p.content_type_id AS type_id, p.alias FROM property_type p
278
+ WHERE p.searchable = ? AND p.retired_at IS NULL`,
279
+ [db.dialect.boolValue(false)],
280
+ )
281
+ if (rows.length === 0) return () => false
282
+ const composedBy = new Map<number, number[]>()
283
+ for (const row of await db.query<{ parent: number; child: number }>(
284
+ 'SELECT parent_content_type_id AS parent, child_content_type_id AS child FROM content_type_composition',
285
+ ))
286
+ composedBy.set(Number(row.parent), [
287
+ ...(composedBy.get(Number(row.parent)) ?? []),
288
+ Number(row.child),
289
+ ])
290
+ const keys = new Map<number, string>()
291
+ for (const row of await db.query<{ id: number; unique_id: string }>(
292
+ `SELECT n.id, n.unique_id FROM node n JOIN content_type ct ON ct.node_id = n.id`,
293
+ ))
294
+ keys.set(Number(row.id), String(row.unique_id).toLowerCase())
295
+ const hidden = new Set<string>()
296
+ for (const row of rows) {
297
+ const seen = new Set<number>()
298
+ const pending = [Number(row.type_id)]
299
+ while (pending.length > 0) {
300
+ const id = pending.pop() as number
301
+ if (seen.has(id)) continue
302
+ seen.add(id)
303
+ hidden.add(`${keys.get(id)}|${row.alias}`)
304
+ pending.push(...(composedBy.get(id) ?? []))
305
+ }
306
+ }
307
+ return (typeKey, alias) => hidden.has(`${typeKey.toLowerCase()}|${alias}`)
308
+ }
309
+
310
+ /**
311
+ * What the index holds for an item, from its current (draft) values: a row per
312
+ * culture it has a name in, each carrying the invariant values too, or one
313
+ * invariant row. Retired properties are left out, and so is sensitive member
314
+ * data, which a search must not reveal to somebody who cannot see it. Trashed
315
+ * items are kept and filtered when searched, so the recycle bin is searchable.
316
+ */
317
+ export async function entriesFor(db: Db, nodeId: number): Promise<SearchEntry[]> {
318
+ const [node] = await db.query<Record<string, unknown>>(
319
+ `SELECT n.id, n.unique_id, n.text, n.path, n.node_object_type, n.trashed, ct.alias AS type_alias
320
+ FROM node n JOIN content c ON c.node_id = n.id JOIN content_type ct ON ct.node_id = c.content_type_id
321
+ WHERE n.id = ?`,
322
+ [nodeId],
323
+ )
324
+ if (!node || !INDEXED_TYPES.includes(String(node.node_object_type))) return []
325
+
326
+ const values = await db.query<Record<string, unknown>>(
327
+ `SELECT p.alias, l.iso_code, pv.varchar_value, pv.text_value
328
+ FROM property_value pv
329
+ JOIN property_type p ON p.id = pv.property_type_id
330
+ LEFT JOIN language l ON l.id = pv.language_id
331
+ LEFT JOIN member_property_type mpt ON mpt.property_type_id = p.id
332
+ WHERE pv.node_id = ? AND pv.is_current = ? AND p.retired_at IS NULL AND p.searchable = ?
333
+ AND (mpt.is_sensitive IS NULL OR mpt.is_sensitive = ?)
334
+ ORDER BY p.sort_order, p.id`,
335
+ [nodeId, db.dialect.boolValue(true), db.dialect.boolValue(true), db.dialect.boolValue(false)],
336
+ )
337
+ const unsearchable = await unsearchableElementProperties(db)
338
+ const names = await db.query<{ iso_code: string; name: string }>(
339
+ `SELECT l.iso_code, cvcv.name FROM content_version cv
340
+ JOIN content_version_culture_variation cvcv ON cvcv.version_id = cv.id
341
+ JOIN language l ON l.id = cvcv.language_id
342
+ WHERE cv.node_id = ? AND cv.current = ?
343
+ ORDER BY l.iso_code`,
344
+ [nodeId, db.dialect.boolValue(true)],
345
+ )
346
+ const extraNames: string[] = []
347
+ const extraFields: Record<string, string[]> = {}
348
+ if (String(node.node_object_type) === ObjectTypes.Member) {
349
+ const [member] = await db.query<{ email: string; login_name: string }>(
350
+ 'SELECT email, login_name FROM member WHERE node_id = ?',
351
+ [nodeId],
352
+ )
353
+ if (member) {
354
+ extraNames.push(String(member.email), String(member.login_name))
355
+ extraFields.email = [String(member.email)]
356
+ extraFields.loginName = [String(member.login_name)]
357
+ }
358
+ }
359
+
360
+ const textOf = (culture: string | null) => {
361
+ const fields: Record<string, string[]> = {}
362
+ for (const row of values) {
363
+ const iso = row.iso_code === null || row.iso_code === undefined ? null : String(row.iso_code)
364
+ if (iso !== null && iso.toLowerCase() !== culture?.toLowerCase()) continue
365
+ const texts = valueText(row.text_value ?? row.varchar_value, unsearchable)
366
+ if (texts.length === 0) continue
367
+ const name = iso ? `${String(row.alias)}_${iso.toLowerCase()}` : String(row.alias)
368
+ fields[name] = [...(fields[name] ?? []), ...texts]
369
+ }
370
+ return fields
371
+ }
372
+
373
+ const base = (culture: string, name: string): SearchEntry => {
374
+ const valueFields = textOf(culture || null)
375
+ const fields: Record<string, string[]> = {
376
+ __NodeId: [String(node.id)],
377
+ __Key: [String(node.unique_id)],
378
+ __IndexType: [INDEX_TYPE[String(node.node_object_type)] ?? 'content'],
379
+ __NodeTypeAlias: [String(node.type_alias)],
380
+ __Path: [String(node.path)],
381
+ ...(fromDbBool(node.trashed) ? { __Trashed: ['true'] } : {}),
382
+ nodeName: [name],
383
+ ...(culture ? { __Culture: [culture] } : {}),
384
+ ...extraFields,
385
+ ...valueFields,
386
+ }
387
+ return {
388
+ culture,
389
+ name: folded([name, ...extraNames]),
390
+ body: folded(Object.values(valueFields).flat()),
391
+ fields,
392
+ }
393
+ }
394
+
395
+ if (names.length === 0) return [base('', String(node.text ?? ''))]
396
+ return names.map((row) => base(String(row.iso_code).toLowerCase(), String(row.name)))
397
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Whether a site still waits for its owner to set it up from the browser. Kept
3
+ * in the database, not in memory, so a restart can never reopen it.
4
+ */
5
+ import type { Db } from './database.ts'
6
+ import { Locks } from './locks.ts'
7
+ import { insertSuperUser, type SeedAdmin, type SeedOptions } from './schema/seed.ts'
8
+
9
+ export const SETUP_STATE_KEY = 'crombolo:setup'
10
+
11
+ /** `none` is every database set up before this existed, or seeded with an administrator. */
12
+ export type SetupState = 'pending' | 'complete' | 'none'
13
+
14
+ export async function readSetupState(db: Db): Promise<SetupState> {
15
+ const rows = await db.query<{ value: string }>('SELECT value FROM key_value WHERE key = ?', [
16
+ SETUP_STATE_KEY,
17
+ ])
18
+ const value = rows[0]?.value
19
+ return value === 'pending' || value === 'complete' ? value : 'none'
20
+ }
21
+
22
+ async function writeSetupState(db: Db, state: 'pending' | 'complete'): Promise<void> {
23
+ const now = db.dialect.now()
24
+ const updated = await db.query<{ key: string }>(
25
+ `UPDATE key_value SET value = ?, update_date = ${now} WHERE key = ? RETURNING key`,
26
+ [state, SETUP_STATE_KEY],
27
+ )
28
+ if (updated.length === 0)
29
+ await db.exec(`INSERT INTO key_value (key, value, update_date) VALUES (?, ?, ${now})`, [
30
+ SETUP_STATE_KEY,
31
+ state,
32
+ ])
33
+ }
34
+
35
+ /** Opens setup on a database that has just been created with no administrator. */
36
+ export function markSetupPending(db: Db): Promise<void> {
37
+ return writeSetupState(db, 'pending')
38
+ }
39
+
40
+ /**
41
+ * Creates the administrator and closes setup for good, or answers `closed` when
42
+ * it was already done. Under a lock, so two attempts at once cannot both win.
43
+ */
44
+ export function completeSetup(
45
+ db: Db,
46
+ admin: SeedAdmin,
47
+ options: Pick<SeedOptions, 'hashPassword' | 'passwordConfig'>,
48
+ ): Promise<'created' | 'closed'> {
49
+ return db.locks.withLock(Locks.KeyValues, () =>
50
+ db.transaction(async (tx) => {
51
+ if ((await readSetupState(tx)) !== 'pending') return 'closed'
52
+ await insertSuperUser(tx, { ...options, admin })
53
+ await writeSetupState(tx, 'complete')
54
+ return 'created'
55
+ }),
56
+ )
57
+ }