@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.
- package/package.json +2 -2
- package/src/audit.ts +291 -0
- package/src/bundle-tables/builder.ts +264 -0
- package/src/bundle-tables/capability.ts +377 -0
- package/src/bundle-tables/chain.ts +105 -0
- package/src/bundle-tables/ddl.ts +124 -0
- package/src/bundle-tables/guard.ts +116 -0
- package/src/bundle-tables/index.ts +7 -0
- package/src/bundle-tables/names.ts +58 -0
- package/src/bundle-tables/schema.ts +485 -0
- package/src/cluster.ts +1 -1
- package/src/database.ts +39 -9
- package/src/dialect.ts +32 -0
- package/src/index.ts +9 -0
- package/src/migrations.ts +147 -31
- package/src/pending.ts +1 -1
- package/src/repositories/bundles.ts +36 -4
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/documents.ts +13 -3
- package/src/repositories/form-entries.ts +1 -1
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +15 -1
- package/src/repositories/nodes.ts +32 -7
- package/src/repositories/transfer-runs.ts +20 -2
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/bundle-components.ts +1 -1
- package/src/schema/bundle-package.ts +27 -0
- package/src/schema/bundles-rename.ts +1 -1
- package/src/schema/content.ts +2 -2
- package/src/schema/created-packages.ts +1 -1
- package/src/schema/form-permissions.ts +1 -1
- package/src/schema/form-workflows.ts +1 -1
- package/src/schema/forms.ts +1 -1
- package/src/schema/identity.ts +1 -1
- package/src/schema/plan.ts +10 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/transfer-run-package.ts +27 -0
- package/src/schema/upgrade-report.ts +1 -1
- package/src/schema-state.ts +1 -1
- package/src/search-text.ts +133 -0
- 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
|
-
[
|
|
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/
|
|
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, {
|
|
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)
|
|
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
|
|
|
@@ -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
|
-
/**
|
|
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
|
}
|
|
@@ -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/
|
|
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/
|
|
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
|
package/src/schema/content.ts
CHANGED
|
@@ -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/
|
|
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 (
|
|
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/
|
|
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/
|
|
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/
|
|
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,
|
package/src/schema/forms.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Migration 023 — form entries. `docs/
|
|
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
|
package/src/schema/identity.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Migration 001 — back-office identity.
|
|
3
3
|
*
|
|
4
|
-
* Table and column names follow docs/
|
|
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
|
*/
|
package/src/schema/plan.ts
CHANGED
|
@@ -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
|
+
}
|