@crombolo/data 0.9.1 → 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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/audit.ts +291 -0
  3. package/src/bundle-tables/builder.ts +264 -0
  4. package/src/bundle-tables/capability.ts +377 -0
  5. package/src/bundle-tables/chain.ts +105 -0
  6. package/src/bundle-tables/ddl.ts +124 -0
  7. package/src/bundle-tables/guard.ts +116 -0
  8. package/src/bundle-tables/index.ts +7 -0
  9. package/src/bundle-tables/names.ts +58 -0
  10. package/src/bundle-tables/schema.ts +485 -0
  11. package/src/cluster.ts +1 -1
  12. package/src/database.ts +39 -9
  13. package/src/dialect.ts +32 -0
  14. package/src/index.ts +9 -0
  15. package/src/migrations.ts +147 -31
  16. package/src/pending.ts +1 -1
  17. package/src/repositories/bundles.ts +36 -4
  18. package/src/repositories/content-types.ts +41 -4
  19. package/src/repositories/documents.ts +13 -3
  20. package/src/repositories/form-entries.ts +1 -1
  21. package/src/repositories/form-workflows.ts +1 -1
  22. package/src/repositories/members.ts +15 -1
  23. package/src/repositories/nodes.ts +32 -7
  24. package/src/repositories/transfer-runs.ts +20 -2
  25. package/src/schema/audit-log.ts +45 -0
  26. package/src/schema/bundle-components.ts +1 -1
  27. package/src/schema/bundle-package.ts +27 -0
  28. package/src/schema/bundles-rename.ts +1 -1
  29. package/src/schema/content.ts +2 -2
  30. package/src/schema/created-packages.ts +1 -1
  31. package/src/schema/form-permissions.ts +1 -1
  32. package/src/schema/form-workflows.ts +1 -1
  33. package/src/schema/forms.ts +1 -1
  34. package/src/schema/identity.ts +1 -1
  35. package/src/schema/plan.ts +10 -0
  36. package/src/schema/property-searchable.ts +29 -0
  37. package/src/schema/search-index.ts +92 -0
  38. package/src/schema/transfer-run-package.ts +27 -0
  39. package/src/schema/upgrade-report.ts +1 -1
  40. package/src/schema-state.ts +1 -1
  41. package/src/search-text.ts +133 -0
  42. package/src/search.ts +397 -0
@@ -27,10 +27,26 @@ import type { Db } from '../database.ts'
27
27
  import { DbDate, fromDbBool } from '../dialect.ts'
28
28
  import { pendingUntil } from '../pending.ts'
29
29
  import type { NodeSchemaState } from '../schema-state.ts'
30
+ import { queueContentTypesForSearch, queueEverythingForSearch } from '../search.ts'
30
31
  import { NodeRepository } from './nodes.ts'
31
32
 
32
33
  const GROUP_TYPE_NAMES = ['Group', 'Tab'] as const
33
34
 
35
+ /** The live properties of a type, by alias, variation and storage: what its items are indexed by. */
36
+ async function propertySignature(db: Db, contentTypeNodeId: number): Promise<string> {
37
+ const rows = await db.query<Record<string, unknown>>(
38
+ `SELECT p.id, p.alias, p.variations, p.data_type_id, p.searchable FROM property_type p
39
+ WHERE p.content_type_id = ? AND p.retired_at IS NULL ORDER BY p.id`,
40
+ [contentTypeNodeId],
41
+ )
42
+ return rows
43
+ .map(
44
+ (row) =>
45
+ `${row.id}:${row.alias}:${row.variations}:${row.data_type_id}:${fromDbBool(row.searchable)}`,
46
+ )
47
+ .join('|')
48
+ }
49
+
34
50
  export interface SaveContentTypeOptions {
35
51
  userId?: number
36
52
  /** The schema state that introduced anything created by this save. */
@@ -228,6 +244,9 @@ export class ContentTypeRepository {
228
244
  ? ((await repo.nodes.byKey(aggregate.parentKey))?.id ?? SystemNodes.Root)
229
245
  : SystemNodes.Root
230
246
 
247
+ // What the search index holds depends on which properties are live and what
248
+ // they are called, so a change to either has the type's items re-indexed.
249
+ const propertiesBefore = existing ? await propertySignature(tx, existing.id) : ''
231
250
  let nodeId: number
232
251
  if (existing) {
233
252
  nodeId = existing.id
@@ -361,13 +380,25 @@ export class ContentTypeRepository {
361
380
  WHERE id = ?`,
362
381
  [...columns, Number(found[0].id)],
363
382
  )
383
+ // Only a schema sync says; a backoffice save leaves it as it was.
384
+ if (property.searchable !== undefined)
385
+ await tx.exec('UPDATE property_type SET searchable = ? WHERE id = ?', [
386
+ bool(property.searchable),
387
+ Number(found[0].id),
388
+ ])
364
389
  } else {
365
390
  await tx.exec(
366
391
  `INSERT INTO property_type
367
392
  (unique_id, content_type_id, data_type_id, property_type_group_id, alias, name, description, sort_order,
368
- mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id)
369
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
370
- [key, nodeId, ...columns, options.sinceStateId ?? null],
393
+ mandatory, mandatory_message, validation_reg_exp, validation_reg_exp_message, label_on_top, variations, since_version, since_state_id, searchable)
394
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
395
+ [
396
+ key,
397
+ nodeId,
398
+ ...columns,
399
+ options.sinceStateId ?? null,
400
+ bool(property.searchable !== false),
401
+ ],
371
402
  )
372
403
  }
373
404
  }
@@ -451,6 +482,11 @@ export class ContentTypeRepository {
451
482
  )
452
483
  }
453
484
 
485
+ if (existing && propertiesBefore !== (await propertySignature(tx, nodeId))) {
486
+ if (aggregate.isElement) await queueEverythingForSearch(tx)
487
+ else await queueContentTypesForSearch(tx, [nodeId])
488
+ }
489
+
454
490
  const saved = await repo.byKey(aggregate.key)
455
491
  if (!saved) throw new Error('Content type could not be read back after saving.')
456
492
  return saved
@@ -739,7 +775,7 @@ export class ContentTypeRepository {
739
775
  p.validation_reg_exp, p.validation_reg_exp_message, p.label_on_top, p.variations,
740
776
  dtn.unique_id AS data_type_key, g.unique_id AS container_key,
741
777
  p.since_version, ss.version AS since_v, ss.revision AS since_r,
742
- m.member_can_edit, m.member_can_view, m.is_sensitive
778
+ m.member_can_edit, m.member_can_view, m.is_sensitive, p.searchable
743
779
  FROM property_type p
744
780
  JOIN node dtn ON dtn.id = p.data_type_id
745
781
  LEFT JOIN property_type_group g ON g.id = p.property_type_group_id
@@ -766,6 +802,7 @@ export class ContentTypeRepository {
766
802
  labelOnTop: fromDbBool(row.label_on_top),
767
803
  sinceVersion: (row.since_version as string | null) ?? null,
768
804
  pending: this.#pending(row),
805
+ ...(fromDbBool(row.searchable) ? {} : { searchable: false }),
769
806
  ...(this.#kind === 'member'
770
807
  ? {
771
808
  memberCanEdit: fromDbBool(row.member_can_edit),
@@ -5,7 +5,7 @@
5
5
  * tagged with the event that wrote it and the schema state the writer was at.
6
6
  * `content_version` rows are events (save | publish | rollback | migrate).
7
7
  * Publish is an event and a pointer, not a copy; rollback appends the old
8
- * values under a new event. See docs/02-data-model.md.
8
+ * values under a new event. See docs/data-model.md.
9
9
  */
10
10
  import {
11
11
  type DocumentAggregate,
@@ -36,6 +36,7 @@ import {
36
36
  type SchemaStateRow,
37
37
  visibleStateIdFor,
38
38
  } from '../schema-state.ts'
39
+ import { queueForSearch } from '../search.ts'
39
40
  import { NodeRepository, type NodeRow } from './nodes.ts'
40
41
 
41
42
  export type EventKind = 'save' | 'publish' | 'rollback' | 'migrate'
@@ -334,7 +335,11 @@ export class DocumentRepository {
334
335
  ? (await this.#nodes.byKey(options.parentKey))?.id
335
336
  : undefined
336
337
  if (options.parentKey && parentId === undefined) return { total: 0, items: [] }
337
- return this.#nodes.search(this.objectType, query, { ...options, parentId })
338
+ return this.#nodes.search(this.objectType, query, {
339
+ ...options,
340
+ parentId,
341
+ fullText: this.kind === 'document' || this.kind === 'media',
342
+ })
338
343
  }
339
344
 
340
345
  async versions(key: string): Promise<DocumentVersionSummary[]> {
@@ -594,7 +599,10 @@ export class DocumentRepository {
594
599
  )
595
600
  written = true
596
601
  }
597
- if (written) await repo.#recomputeEdited(node.id)
602
+ if (written) {
603
+ await repo.#recomputeEdited(node.id)
604
+ await queueForSearch(tx, [node.id])
605
+ }
598
606
  return written
599
607
  })
600
608
  }
@@ -2053,6 +2061,8 @@ export class DocumentRepository {
2053
2061
  'INSERT INTO document_version (id, template_id, published) VALUES (?, ?, ?)',
2054
2062
  [eventId, templateId, this.#db.dialect.boolValue(published)],
2055
2063
  )
2064
+ // Every change to what an item says is an event, so this is where the search index hears of it.
2065
+ await queueForSearch(this.#db, [nodeId])
2056
2066
  return eventId
2057
2067
  }
2058
2068
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Form entries: what visitors submitted. `docs/18-forms.md`.
2
+ * Form entries: what visitors submitted. `docs/forms.md`.
3
3
  *
4
4
  * Keyed by the form's UUID rather than a foreign key, because the definition is
5
5
  * a file. An entry outlives its definition on purpose.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The form workflow queue. `docs/18-forms.md`.
2
+ * The form workflow queue. `docs/forms.md`.
3
3
  *
4
4
  * Claimed with `UPDATE … WHERE state = 'pending' … RETURNING`, which is atomic
5
5
  * on both dialects: on a multi-node Postgres site every node polls, and exactly
@@ -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,6 +294,7 @@ 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
  }
@@ -365,9 +370,18 @@ export class MemberRepository {
365
370
  }
366
371
  const text = criteria.filter?.trim()
367
372
  if (text) {
368
- wheres.push('(LOWER(m.email) LIKE ? OR LOWER(m.login_name) LIKE ? OR LOWER(n.text) LIKE ?)')
369
373
  const like = `%${text.toLowerCase()}%`
374
+ const terms = searchTerms(text)
375
+ // The index adds what a member's properties say to the name, username and e-mail.
376
+ const indexed =
377
+ terms.length > 0
378
+ ? ` OR m.node_id IN (SELECT node_id FROM (${this.#db.dialect.fullTextHits}) h)`
379
+ : ''
380
+ wheres.push(
381
+ `(LOWER(m.email) LIKE ? OR LOWER(m.login_name) LIKE ? OR LOWER(n.text) LIKE ?${indexed})`,
382
+ )
370
383
  values.push(like, like, like)
384
+ if (indexed) values.push(this.#db.dialect.fullTextQuery(terms))
371
385
  }
372
386
  const where = wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : ''
373
387
  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,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
  }
@@ -24,6 +24,9 @@ export interface TransferRun {
24
24
  id: string
25
25
  bundleId: string
26
26
  bundleLabel: string | null
27
+ /** The npm package the content arrived in, and its version. */
28
+ packageName: string | null
29
+ packageVersion: string | null
27
30
  direction: TransferDirection
28
31
  revertsRunId: string | null
29
32
  schemaStateId: number
@@ -57,12 +60,14 @@ export class TransferRunRepository {
57
60
  async start(run: Omit<TransferRun, 'finishedAt' | 'status'>): Promise<void> {
58
61
  await this.#db.exec(
59
62
  `INSERT INTO content_transfer_run
60
- (id, bundle_id, bundle_label, direction, reverts_run_id, schema_state_id, started_at, applied_by, node_count, status)
61
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'applied')`,
63
+ (id, bundle_id, bundle_label, package_name, package_version, direction, reverts_run_id, schema_state_id, started_at, applied_by, node_count, status)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'applied')`,
62
65
  [
63
66
  run.id,
64
67
  run.bundleId,
65
68
  run.bundleLabel,
69
+ run.packageName,
70
+ run.packageVersion,
66
71
  run.direction,
67
72
  run.revertsRunId,
68
73
  run.schemaStateId,
@@ -121,6 +126,17 @@ export class TransferRunRepository {
121
126
  return rows[0] ? mapRun(rows[0]) : undefined
122
127
  }
123
128
 
129
+ /** The latest standing import of a package's content, whichever version brought it. */
130
+ async appliedForPackage(packageName: string): Promise<TransferRun | undefined> {
131
+ const rows = await this.#db.query(
132
+ `SELECT * FROM content_transfer_run
133
+ WHERE package_name = ? AND direction = 'import' AND status = 'applied'
134
+ ORDER BY started_at DESC, id DESC LIMIT 1`,
135
+ [packageName],
136
+ )
137
+ return rows[0] ? mapRun(rows[0]) : undefined
138
+ }
139
+
124
140
  /** Runs, newest first. */
125
141
  async recent(limit = 20): Promise<TransferRun[]> {
126
142
  const rows = await this.#db.query(
@@ -164,6 +180,8 @@ function mapRun(row: Record<string, unknown>): TransferRun {
164
180
  id: String(row.id),
165
181
  bundleId: String(row.bundle_id),
166
182
  bundleLabel: (row.bundle_label as string | null) ?? null,
183
+ packageName: (row.package_name as string | null) ?? null,
184
+ packageVersion: (row.package_version as string | null) ?? null,
167
185
  direction: String(row.direction) as TransferDirection,
168
186
  revertsRunId: (row.reverts_run_id as string | null) ?? null,
169
187
  schemaStateId: Number(row.schema_state_id),
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Migration 028 — a bundle's `templates` and `partial_views` become `components`.
3
3
  *
4
- * There is one root and one kind of file on disk now (`docs/05-rendering.md`),
4
+ * There is one root and one kind of file on disk now (`docs/rendering.md`),
5
5
  * so a bundle definition that still kept two lists was recording a distinction
6
6
  * the rest of the system no longer makes. The two are merged rather than one
7
7
  * dropped: both held component aliases, and which tree the backoffice picked
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Migration 029 — a created bundle records the npm package it is written as.
3
+ *
4
+ * Every bundle is an npm package (`docs/bundles.md`), so the Created view's
5
+ * download needs a package name and version. Umbraco's package definition has
6
+ * neither, so they live here, beside the columns the contract does name. Both
7
+ * are nullable: an unset name or version is derived when the bundle is built.
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_BUNDLE_COMPONENTS } from './bundle-components.ts'
13
+
14
+ export const STATE_BUNDLE_PACKAGE = '{6f1d4a8e-0029-4c5e-9a17-3d8b2e6f4c29}'
15
+
16
+ export const bundlePackageMigration: Migration = {
17
+ from: STATE_BUNDLE_COMPONENTS,
18
+ to: STATE_BUNDLE_PACKAGE,
19
+ name: 'AddBundlePackageNameAndVersion',
20
+ kind: 'expand',
21
+ release: '0.10.0',
22
+ async up(db: Db) {
23
+ const ops = schemaOps(db)
24
+ await ops.addColumn('bundle', 'package_name', db.dialect.types.varchar(214))
25
+ await ops.addColumn('bundle', 'package_version', db.dialect.types.varchar(64))
26
+ },
27
+ }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The table was named after Umbraco's `umbracoCreatedPackageSchema`, which it
5
5
  * mirrors column for column. What it holds is a bundle definition
6
- * (`docs/17-bundles.md`), and the backoffice now says so, so the table does too.
6
+ * (`docs/bundles.md`), and the backoffice now says so, so the table does too.
7
7
  *
8
8
  * A rename rather than an edit to migration 022: 022 has already run on every
9
9
  * database that exists, and rewriting it would leave those sites with a table
@@ -2,7 +2,7 @@
2
2
  * Migration 002 — the content schema.
3
3
  *
4
4
  * `node` is the universal polymorphic tree; every other table here is a facet of
5
- * it. See docs/02-data-model.md for the model and why it is kept.
5
+ * it. See docs/data-model.md for the model and why it is kept.
6
6
  */
7
7
  import type { Db } from '../database.ts'
8
8
  import type { Migration } from '../migrations.ts'
@@ -164,7 +164,7 @@ export const contentMigration: Migration = {
164
164
  )`,
165
165
  )
166
166
 
167
- // ---- documents and versions (Phase 4 uses these) ----
167
+ // ---- documents and versions (story 005 uses these) ----
168
168
 
169
169
  await db.exec(
170
170
  `CREATE TABLE content (
@@ -2,7 +2,7 @@
2
2
  * Migration 022 — the created-package definitions the Packages section builds.
3
3
  *
4
4
  * A definition, never an artifact: the zip is built when it is downloaded
5
- * (`docs/17-bundles.md`), so there are no bytes here to go stale when a
5
+ * (`docs/bundles.md`), so there are no bytes here to go stale when a
6
6
  * content type the package carries is edited afterwards.
7
7
  *
8
8
  * The selection lists are JSON text rather than a row each. They are read and
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Migration 025 — the form permission verbs, for databases seeded before they
3
- * existed. `docs/18-forms.md`.
3
+ * existed. `docs/forms.md`.
4
4
  *
5
5
  * The same shape as migration 007: granting what the seed now grants, so an
6
6
  * upgraded site does not have a Forms section nobody can open. Idempotent, and
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Migration 024 — the queue a form's workflows run from. `docs/18-forms.md`.
2
+ * Migration 024 — the queue a form's workflows run from. `docs/forms.md`.
3
3
  *
4
4
  * A submission is stored and *then* its workflows run, so a mail server being
5
5
  * down never loses an entry. That means a row per workflow per submission,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Migration 023 — form entries. `docs/18-forms.md`.
2
+ * Migration 023 — form entries. `docs/forms.md`.
3
3
  *
4
4
  * The definition is a file, so there is no form table and nothing to point at:
5
5
  * an entry carries `form_key`, the UUID from `schema/forms/*.toml`, and keeps
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Migration 001 — back-office identity.
3
3
  *
4
- * Table and column names follow docs/02-data-model.md: Umbraco's relational
4
+ * Table and column names follow docs/data-model.md: Umbraco's relational
5
5
  * model with snake_case names. `user` is reserved in Postgres, hence
6
6
  * `user_account`.
7
7
  */
@@ -13,7 +13,9 @@
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'
18
+ import { bundlePackageMigration } from './bundle-package.ts'
17
19
  import { bundlesRenameMigration } from './bundles-rename.ts'
18
20
  import { changeReportMigration } from './change-report.ts'
19
21
  import { clientIdPrefixMigration } from './client-id-prefix.ts'
@@ -30,12 +32,15 @@ import { identityMigration } from './identity.ts'
30
32
  import { logViewerMigration } from './log-viewer.ts'
31
33
  import { memberPropertyTypeMigration } from './member-property-type.ts'
32
34
  import { membersMigration } from './members.ts'
35
+ import { propertySearchableMigration } from './property-searchable.ts'
33
36
  import { publicAccessMigration } from './public-access.ts'
34
37
  import { recycleBinMigration } from './recycle-bin.ts'
35
38
  import { redirectsMigration } from './redirects.ts'
36
39
  import { richTextUiMigration } from './rich-text-ui.ts'
40
+ import { searchIndexMigration } from './search-index.ts'
37
41
  import { serverRoleMigration } from './server-role.ts'
38
42
  import { sinceVersionMigration } from './since-version.ts'
43
+ import { transferRunPackageMigration } from './transfer-run-package.ts'
39
44
  import { transferRunsMigration } from './transfer-runs.ts'
40
45
  import { upgradeReportMigration } from './upgrade-report.ts'
41
46
  import { usersLocalisationMigration } from './users-localisation.ts'
@@ -70,4 +75,9 @@ export const cromboloPlan = new MigrationPlan([
70
75
  formPermissionPrefixMigration,
71
76
  bundlesRenameMigration,
72
77
  bundleComponentsMigration,
78
+ bundlePackageMigration,
79
+ transferRunPackageMigration,
80
+ searchIndexMigration,
81
+ propertySearchableMigration,
82
+ auditLogMigration,
73
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
+ }