@crombolo/data 0.10.0 → 0.11.0

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.
@@ -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,30 +232,54 @@ 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
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Migration 033 — the audit log. docs/audit-log.md.
3
+ *
4
+ * `audit_entry` records who did what, when and from where: every backoffice
5
+ * change, every sign-in and failed sign-in, and what the command line and the
6
+ * background jobs changed. Names are copied in when an entry is written, so an
7
+ * entry still reads after its user or item is gone. It starts empty: history from
8
+ * before this migration stays in the content versions it came from.
9
+ */
10
+ import type { Db } from '../database.ts'
11
+ import type { Migration } from '../migrations.ts'
12
+ import { STATE_PROPERTY_SEARCHABLE } from './property-searchable.ts'
13
+
14
+ export const STATE_AUDIT_LOG = '{6f1d4a8e-0033-4c1a-9e7d-3a5b8c2d4e33}'
15
+
16
+ export const auditLogMigration: Migration = {
17
+ from: STATE_PROPERTY_SEARCHABLE,
18
+ to: STATE_AUDIT_LOG,
19
+ name: 'AddAuditLog',
20
+ kind: 'expand',
21
+ release: '0.10.0',
22
+ async up(db: Db) {
23
+ const t = db.dialect.types
24
+ await db.exec(
25
+ `CREATE TABLE audit_entry (
26
+ id ${t.identity},
27
+ occurred_at ${t.timestamp} NOT NULL,
28
+ category ${t.varchar(32)} NOT NULL,
29
+ action ${t.varchar(64)} NOT NULL,
30
+ user_key ${t.uuid},
31
+ user_name ${t.varchar(255)},
32
+ entity_type ${t.varchar(64)},
33
+ entity_key ${t.varchar(255)},
34
+ entity_name ${t.varchar(255)},
35
+ source ${t.varchar(16)} NOT NULL,
36
+ ip_address ${t.varchar(64)},
37
+ forwarded_for ${t.varchar(255)},
38
+ detail ${t.text}
39
+ )`,
40
+ )
41
+ await db.exec('CREATE INDEX ix_audit_entry_occurred ON audit_entry (occurred_at)')
42
+ await db.exec('CREATE INDEX ix_audit_entry_entity ON audit_entry (entity_key, occurred_at)')
43
+ await db.exec('CREATE INDEX ix_audit_entry_user ON audit_entry (user_key, occurred_at)')
44
+ },
45
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { MigrationPlan } from '../migrations.ts'
15
15
  import { assistantMigration } from './assistant.ts'
16
+ import { auditLogMigration } from './audit-log.ts'
16
17
  import { bundleComponentsMigration } from './bundle-components.ts'
17
18
  import { bundlePackageMigration } from './bundle-package.ts'
18
19
  import { bundlesRenameMigration } from './bundles-rename.ts'
@@ -31,10 +32,12 @@ import { identityMigration } from './identity.ts'
31
32
  import { logViewerMigration } from './log-viewer.ts'
32
33
  import { memberPropertyTypeMigration } from './member-property-type.ts'
33
34
  import { membersMigration } from './members.ts'
35
+ import { propertySearchableMigration } from './property-searchable.ts'
34
36
  import { publicAccessMigration } from './public-access.ts'
35
37
  import { recycleBinMigration } from './recycle-bin.ts'
36
38
  import { redirectsMigration } from './redirects.ts'
37
39
  import { richTextUiMigration } from './rich-text-ui.ts'
40
+ import { searchIndexMigration } from './search-index.ts'
38
41
  import { serverRoleMigration } from './server-role.ts'
39
42
  import { sinceVersionMigration } from './since-version.ts'
40
43
  import { transferRunPackageMigration } from './transfer-run-package.ts'
@@ -74,4 +77,7 @@ export const cromboloPlan = new MigrationPlan([
74
77
  bundleComponentsMigration,
75
78
  bundlePackageMigration,
76
79
  transferRunPackageMigration,
80
+ searchIndexMigration,
81
+ propertySearchableMigration,
82
+ auditLogMigration,
77
83
  ])
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Migration 032 — a property can be kept out of backoffice search.
3
+ *
4
+ * `searchable = false` on a property in the schema files. Stored on the
5
+ * property type, because the backoffice writes those files back from the
6
+ * database and its save carries no such flag, so the column is what a save
7
+ * preserves. Every existing property stays searchable. docs/search.md.
8
+ */
9
+ import type { Db } from '../database.ts'
10
+ import type { Migration } from '../migrations.ts'
11
+ import { schemaOps } from '../schema-ops.ts'
12
+ import { STATE_SEARCH_INDEX } from './search-index.ts'
13
+
14
+ export const STATE_PROPERTY_SEARCHABLE = '{6f1d4a8e-0032-4b7e-8a2c-5d9f1e3b6c32}'
15
+
16
+ export const propertySearchableMigration: Migration = {
17
+ from: STATE_SEARCH_INDEX,
18
+ to: STATE_PROPERTY_SEARCHABLE,
19
+ name: 'AddPropertySearchable',
20
+ kind: 'expand',
21
+ release: '0.10.0',
22
+ async up(db: Db) {
23
+ await schemaOps(db).addColumn(
24
+ 'property_type',
25
+ 'searchable',
26
+ `${db.dialect.types.boolean} NOT NULL DEFAULT ${db.dialect.name === 'postgres' ? 'TRUE' : '1'}`,
27
+ )
28
+ },
29
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Migration 031 — the backoffice search index. docs/search.md.
3
+ *
4
+ * `search_document` holds one row per item and culture: its name and the text of
5
+ * its current values, folded to lowercase without diacritics. SQLite matches it
6
+ * through an FTS5 table kept in step by triggers; Postgres through a generated
7
+ * `tsvector` column. Both use language-neutral word splitting, so the two
8
+ * dialects find the same things.
9
+ *
10
+ * `search_index_queue` is what a write leaves for the indexer: a node id and a
11
+ * generation, bumped each time the node is queued again, so a row re-queued
12
+ * while it was being indexed survives the indexer's delete. Every existing
13
+ * page, media item and member is queued here, which is the backfill.
14
+ */
15
+ import { ObjectTypes } from '@crombolo/core'
16
+ import type { Db } from '../database.ts'
17
+ import type { Migration } from '../migrations.ts'
18
+ import { STATE_TRANSFER_RUN_PACKAGE } from './transfer-run-package.ts'
19
+
20
+ export const STATE_SEARCH_INDEX = '{6f1d4a8e-0031-4f3a-9c6d-2b8e5a1f7d31}'
21
+
22
+ export const searchIndexMigration: Migration = {
23
+ from: STATE_TRANSFER_RUN_PACKAGE,
24
+ to: STATE_SEARCH_INDEX,
25
+ name: 'AddSearchIndex',
26
+ kind: 'expand',
27
+ release: '0.10.0',
28
+ async up(db: Db) {
29
+ const t = db.dialect.types
30
+ const postgres = db.dialect.name === 'postgres'
31
+ await db.exec(
32
+ `CREATE TABLE search_document (
33
+ id ${t.identity},
34
+ node_id ${t.integer} NOT NULL REFERENCES node (id) ON DELETE CASCADE,
35
+ culture ${t.varchar(14)} NOT NULL,
36
+ name ${t.text} NOT NULL,
37
+ body ${t.text} NOT NULL,
38
+ fields ${t.json} NOT NULL,
39
+ field_count ${t.integer} NOT NULL,
40
+ indexed_at ${t.timestamp} NOT NULL${
41
+ postgres
42
+ ? `,
43
+ terms tsvector GENERATED ALWAYS AS (
44
+ setweight(to_tsvector('simple', name), 'A') || setweight(to_tsvector('simple', body), 'B')
45
+ ) STORED`
46
+ : ''
47
+ },
48
+ UNIQUE (node_id, culture)
49
+ )`,
50
+ )
51
+ if (postgres) {
52
+ await db.exec('CREATE INDEX ix_search_document_terms ON search_document USING GIN (terms)')
53
+ } else {
54
+ await db.exec(
55
+ `CREATE VIRTUAL TABLE search_document_fts USING fts5(
56
+ name, body, content='search_document', content_rowid='id',
57
+ tokenize='unicode61 remove_diacritics 2'
58
+ )`,
59
+ )
60
+ await db.exec(
61
+ `CREATE TRIGGER search_document_ai AFTER INSERT ON search_document BEGIN
62
+ INSERT INTO search_document_fts (rowid, name, body) VALUES (new.id, new.name, new.body);
63
+ END`,
64
+ )
65
+ await db.exec(
66
+ `CREATE TRIGGER search_document_ad AFTER DELETE ON search_document BEGIN
67
+ INSERT INTO search_document_fts (search_document_fts, rowid, name, body)
68
+ VALUES ('delete', old.id, old.name, old.body);
69
+ END`,
70
+ )
71
+ await db.exec(
72
+ `CREATE TRIGGER search_document_au AFTER UPDATE ON search_document BEGIN
73
+ INSERT INTO search_document_fts (search_document_fts, rowid, name, body)
74
+ VALUES ('delete', old.id, old.name, old.body);
75
+ INSERT INTO search_document_fts (rowid, name, body) VALUES (new.id, new.name, new.body);
76
+ END`,
77
+ )
78
+ }
79
+ await db.exec(
80
+ `CREATE TABLE search_index_queue (
81
+ node_id ${t.integer} PRIMARY KEY REFERENCES node (id) ON DELETE CASCADE,
82
+ generation ${t.integer} NOT NULL,
83
+ queued_at ${t.timestamp} NOT NULL
84
+ )`,
85
+ )
86
+ await db.exec(
87
+ `INSERT INTO search_index_queue (node_id, generation, queued_at)
88
+ SELECT id, 1, ${db.dialect.now()} FROM node WHERE node_object_type IN (?, ?, ?)`,
89
+ [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Member],
90
+ )
91
+ },
92
+ }
@@ -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
+ }