@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
package/src/dialect.ts CHANGED
@@ -36,6 +36,16 @@ export interface Dialect {
36
36
  /** Appended to a SELECT that must block a concurrent state change; empty where the engine is single-writer. */
37
37
  readonly forShare: string
38
38
  readonly forUpdate: string
39
+ /** Appended to a SELECT that claims rows, passing over any another connection holds. */
40
+ readonly forUpdateSkipLocked: string
41
+ /**
42
+ * The full-text match over `search_document`: a subquery of `node_id` and
43
+ * `score`, higher being better, for each item holding every term as a word
44
+ * prefix. Its one parameter is `fullTextQuery(terms)`. docs/search.md.
45
+ */
46
+ readonly fullTextHits: string
47
+ /** Terms are already folded words: letters and digits only. */
48
+ fullTextQuery(terms: readonly string[]): string
39
49
  /**
40
50
  * SQL that moves a table's identity past rows inserted with explicit ids, or
41
51
  * undefined where the engine does that itself.
@@ -71,6 +81,15 @@ export const sqliteDialect: Dialect = {
71
81
  boolValue: (value) => (value ? 1 : 0),
72
82
  forShare: '',
73
83
  forUpdate: '',
84
+ forUpdateSkipLocked: '',
85
+ // Name hits weigh ten times body hits; bm25 is lower-is-better, so negated.
86
+ // bm25 cannot be called under an aggregate, and `LIMIT -1` (no limit) keeps
87
+ // SQLite from flattening the inner query into the outer one, which would.
88
+ fullTextHits: `SELECT sd.node_id AS node_id, MAX(f.score) AS score
89
+ FROM (SELECT rowid AS id, -bm25(search_document_fts, 10.0, 1.0) AS score
90
+ FROM search_document_fts WHERE search_document_fts MATCH ? LIMIT -1) f
91
+ JOIN search_document sd ON sd.id = f.id GROUP BY sd.node_id`,
92
+ fullTextQuery: (terms) => terms.map((term) => `"${term}"*`).join(' AND '),
74
93
  syncIdentity: () => undefined,
75
94
  }
76
95
 
@@ -102,6 +121,11 @@ export const postgresDialect: Dialect = {
102
121
  boolValue: (value) => value,
103
122
  forShare: 'FOR SHARE',
104
123
  forUpdate: 'FOR UPDATE',
124
+ forUpdateSkipLocked: 'FOR UPDATE SKIP LOCKED',
125
+ fullTextHits: `SELECT sd.node_id AS node_id, MAX(ts_rank(sd.terms, q)) AS score
126
+ FROM search_document sd, to_tsquery('simple', ?) q
127
+ WHERE sd.terms @@ q GROUP BY sd.node_id`,
128
+ fullTextQuery: (terms) => terms.map((term) => `${term}:*`).join(' & '),
105
129
  syncIdentity: (table) =>
106
130
  `SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${table}))`,
107
131
  }
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
+ export { insideTransaction } from './ambient.ts'
2
+ export * from './audit.ts'
1
3
  export * from './bundle-tables/index.ts'
4
+ export * from './busy.ts'
2
5
  export * from './change-report.ts'
3
6
  export * from './cluster.ts'
4
7
  export * from './database.ts'
@@ -8,12 +11,15 @@ export * from './maintenance.ts'
8
11
  export * from './migrations.ts'
9
12
  export * from './node-ref.ts'
10
13
  export * from './pending.ts'
14
+ export * from './relations.ts'
11
15
  export * from './repositories/bundles.ts'
12
16
  export * from './repositories/content-types.ts'
13
17
  export * from './repositories/data-types.ts'
18
+ export * from './repositories/delivery-member-tokens.ts'
14
19
  export * from './repositories/dictionary.ts'
15
20
  export * from './repositories/documents.ts'
16
21
  export * from './repositories/domains.ts'
22
+ export * from './repositories/dynamic-roots.ts'
17
23
  export * from './repositories/folders.ts'
18
24
  export * from './repositories/form-entries.ts'
19
25
  export * from './repositories/form-workflows.ts'
@@ -27,10 +33,13 @@ export * from './repositories/redirects.ts'
27
33
  export * from './repositories/references.ts'
28
34
  export * from './repositories/tags.ts'
29
35
  export * from './repositories/templates.ts'
36
+ export * from './repositories/transfer-answers.ts'
30
37
  export * from './repositories/transfer-runs.ts'
31
38
  export * from './repositories/user-data.ts'
32
39
  export * from './repositories/users.ts'
40
+ export * from './repositories/webhooks.ts'
33
41
  export * from './schema/assistant.ts'
42
+ export * from './schema/audit-log.ts'
34
43
  export * from './schema/built-in-content-types.ts'
35
44
  export * from './schema/built-in-media-types.ts'
36
45
  export * from './schema/built-in-member-types.ts'
@@ -41,6 +50,8 @@ export * from './schema/client-id-prefix.ts'
41
50
  export * from './schema/content.ts'
42
51
  export * from './schema/content-seed.ts'
43
52
  export * from './schema/created-packages.ts'
53
+ export * from './schema/delivery-member-tokens.ts'
54
+ export * from './schema/diagnostics.ts'
44
55
  export * from './schema/elements.ts'
45
56
  export * from './schema/form-permission-prefix.ts'
46
57
  export * from './schema/group-permissions.ts'
@@ -49,15 +60,24 @@ export * from './schema/log-viewer.ts'
49
60
  export * from './schema/member-property-type.ts'
50
61
  export * from './schema/members.ts'
51
62
  export * from './schema/plan.ts'
63
+ export * from './schema/property-searchable.ts'
52
64
  export * from './schema/public-access.ts'
53
65
  export * from './schema/recycle-bin.ts'
54
66
  export * from './schema/rich-text-ui.ts'
67
+ export * from './schema/search-index.ts'
55
68
  export * from './schema/seed.ts'
56
69
  export * from './schema/since-version.ts'
70
+ export * from './schema/transfer-answers.ts'
57
71
  export * from './schema/transfer-run-package.ts'
58
72
  export * from './schema/transfer-runs.ts'
59
73
  export * from './schema/upgrade-report.ts'
60
74
  export * from './schema/users-localisation.ts'
61
75
  export * from './schema/values.ts'
76
+ export * from './schema/webhooks.ts'
62
77
  export * from './schema-ops.ts'
63
78
  export * from './schema-state.ts'
79
+ export * from './search.ts'
80
+ export * from './search-text.ts'
81
+ export * from './setup.ts'
82
+ export * from './tree-invariants.ts'
83
+ export * from './url-options.ts'
package/src/locks.ts CHANGED
@@ -4,8 +4,12 @@
4
4
  * and necessary, while Postgres uses advisory locks.
5
5
  */
6
6
  import { AsyncLocalStorage } from 'node:async_hooks'
7
+ import { insideTransaction } from './ambient.ts'
8
+ import { ContentBusyError, DEFAULT_RETRY, type RetryPolicy, retryContention } from './busy.ts'
7
9
  import type { Db } from './database.ts'
8
10
 
11
+ export const DEFAULT_LOCK_TIMEOUT_MS = 15_000
12
+
9
13
  /** Mirrors Umbraco's Constants.Locks. */
10
14
  export const Locks = {
11
15
  Servers: -331,
@@ -55,49 +59,114 @@ async function reentrant<T>(
55
59
  })
56
60
  }
57
61
 
58
- class SqliteLockManager implements LockManager {
59
- #queues = new Map<number, Promise<unknown>>()
62
+ export interface LockOptions {
63
+ /** How long to wait for the lock before answering busy. */
64
+ timeoutMs?: number
65
+ retry?: RetryPolicy
66
+ }
60
67
 
61
- withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
62
- return reentrant(id, fn, (run) => this.#acquire(id, run))
63
- }
68
+ /**
69
+ * An in-process FIFO per lock id with a bounded wait. Ownership passes straight
70
+ * to the next waiter on release, and a waiter that times out leaves the line.
71
+ */
72
+ export class LockQueue {
73
+ #lines = new Map<number, Array<() => void>>()
64
74
 
65
- async #acquire<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
66
- const previous = this.#queues.get(id) ?? Promise.resolve()
67
- let release!: () => void
68
- const gate = new Promise<void>((resolve) => {
69
- release = resolve
70
- })
71
- this.#queues.set(
72
- id,
73
- previous.then(() => gate),
74
- )
75
- await previous
75
+ async hold<T>(id: number, timeoutMs: number, fn: () => Promise<T>): Promise<T> {
76
+ await this.#acquire(id, timeoutMs)
76
77
  try {
77
78
  return await fn()
78
79
  } finally {
79
- release()
80
- if (this.#queues.get(id) === gate) this.#queues.delete(id)
80
+ this.#release(id)
81
+ }
82
+ }
83
+
84
+ #acquire(id: number, timeoutMs: number): Promise<void> {
85
+ const line = this.#lines.get(id)
86
+ if (!line) {
87
+ this.#lines.set(id, [])
88
+ return Promise.resolve()
81
89
  }
90
+ return new Promise<void>((resolve, reject) => {
91
+ const grant = () => {
92
+ clearTimeout(timer)
93
+ resolve()
94
+ }
95
+ const timer = setTimeout(() => {
96
+ const at = line.indexOf(grant)
97
+ if (at >= 0) line.splice(at, 1)
98
+ reject(new ContentBusyError('lockTimeout'))
99
+ }, timeoutMs)
100
+ line.push(grant)
101
+ })
102
+ }
103
+
104
+ #release(id: number): void {
105
+ const next = this.#lines.get(id)?.shift()
106
+ if (next) next()
107
+ else this.#lines.delete(id)
82
108
  }
83
109
  }
84
110
 
111
+ /** Retries only from outside any transaction: one the database aborted cannot be resumed. */
112
+ function guarded<T>(fn: () => Promise<T>, retry: RetryPolicy): Promise<T> {
113
+ return retryContention(fn, insideTransaction() ? { ...retry, attempts: 1 } : retry)
114
+ }
115
+
116
+ class SqliteLockManager implements LockManager {
117
+ #queue = new LockQueue()
118
+ #timeoutMs: number
119
+ #retry: RetryPolicy
120
+
121
+ constructor(options: LockOptions) {
122
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
123
+ this.#retry = options.retry ?? DEFAULT_RETRY
124
+ }
125
+
126
+ withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
127
+ return reentrant(id, fn, (run) =>
128
+ guarded(() => this.#queue.hold(id, this.#timeoutMs, run), this.#retry),
129
+ )
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The in-process queue first, then the advisory lock, which orders the nodes.
135
+ * Without the queue every waiting request held a pooled connection blocked on
136
+ * the advisory lock, and a pool full of waiters left the holder none to work on.
137
+ */
85
138
  class PostgresLockManager implements LockManager {
86
139
  #db: Db
87
- constructor(db: Db) {
140
+ #queue = new LockQueue()
141
+ #timeoutMs: number
142
+ #retry: RetryPolicy
143
+
144
+ constructor(db: Db, options: LockOptions) {
88
145
  this.#db = db
146
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
147
+ this.#retry = options.retry ?? DEFAULT_RETRY
89
148
  }
90
149
 
91
150
  withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
151
+ const timeout = Math.max(1, Math.round(this.#timeoutMs))
92
152
  return reentrant(id, fn, (run) =>
93
- this.#db.transaction(async (tx) => {
94
- await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
95
- return run()
96
- }),
153
+ guarded(
154
+ () =>
155
+ this.#queue.hold(id, this.#timeoutMs, () =>
156
+ this.#db.transaction(async (tx) => {
157
+ await tx.exec(`SET LOCAL lock_timeout = ${timeout}`)
158
+ await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
159
+ return run()
160
+ }),
161
+ ),
162
+ this.#retry,
163
+ ),
97
164
  )
98
165
  }
99
166
  }
100
167
 
101
- export function createLockManager(db: Db): LockManager {
102
- return db.dialect.name === 'postgres' ? new PostgresLockManager(db) : new SqliteLockManager()
168
+ export function createLockManager(db: Db, options: LockOptions = {}): LockManager {
169
+ return db.dialect.name === 'postgres'
170
+ ? new PostgresLockManager(db, options)
171
+ : new SqliteLockManager(options)
103
172
  }
package/src/migrations.ts CHANGED
@@ -349,6 +349,26 @@ export async function readLedger(db: Db): Promise<LedgerRow[]> {
349
349
  }))
350
350
  }
351
351
 
352
+ /**
353
+ * A step failed and was rolled back; every step before it is applied. Says which,
354
+ * and that running the upgrade again carries on from there.
355
+ */
356
+ export class MigrationStepError extends Error {
357
+ constructor(
358
+ readonly step: string,
359
+ readonly applied: readonly string[],
360
+ readonly bundle: string | undefined,
361
+ override readonly cause: unknown,
362
+ ) {
363
+ const reason = cause instanceof Error ? cause.message : String(cause)
364
+ super(
365
+ `The upgrade stopped at ${bundle ? `the bundle "${bundle}"'s step` : 'step'} ${step}: ${reason}. ` +
366
+ `Nothing from that step was kept${applied.length > 0 ? `; the ${applied.length} step(s) before it are applied` : ''}. ` +
367
+ 'Fix the cause and run the upgrade again: it carries on from this step.',
368
+ )
369
+ }
370
+ }
371
+
352
372
  /** The database is at a state this code's plan does not know: newer code wrote it. */
353
373
  export class UpgradeStateError extends Error {
354
374
  constructor(
@@ -440,23 +460,29 @@ export async function migrate(
440
460
  await verifyApplied(db, plan, state)
441
461
  for (const next of pending) {
442
462
  const started = performance.now()
443
- await db.transaction(async (tx) => {
444
- await next.up(tx)
445
- await next.backfill?.(tx)
446
- })
463
+ // The state and the ledger row commit with the step, so an interrupted
464
+ // run resumes at the first step that did not land and never repeats one.
465
+ await db
466
+ .transaction(async (tx) => {
467
+ await next.up(tx)
468
+ await next.backfill?.(tx)
469
+ await writeState(tx, next.to, plan.stateKey)
470
+ await recordInLedger(tx, {
471
+ name: next.name,
472
+ kind: next.kind ?? 'expand',
473
+ fromState: next.from,
474
+ toState: next.to,
475
+ durationMs: Math.round(performance.now() - started),
476
+ checksum: next.checksum ?? next.name,
477
+ appliedBy: options.by ?? null,
478
+ note: next.release ? `release ${next.release}` : null,
479
+ bundle: plan.bundle ?? null,
480
+ })
481
+ })
482
+ .catch((error: unknown) => {
483
+ throw new MigrationStepError(next.name, applied, plan.bundle, error)
484
+ })
447
485
  state = next.to
448
- await writeState(db, state, plan.stateKey)
449
- await recordInLedger(db, {
450
- name: next.name,
451
- kind: next.kind ?? 'expand',
452
- fromState: next.from,
453
- toState: next.to,
454
- durationMs: Math.round(performance.now() - started),
455
- checksum: next.checksum ?? next.name,
456
- appliedBy: options.by ?? null,
457
- note: next.release ? `release ${next.release}` : null,
458
- bundle: plan.bundle ?? null,
459
- })
460
486
  applied.push(next.name)
461
487
  }
462
488
  return { from, to: state, applied }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Umbraco's built-in relation types, and their relations derived rather than
3
+ * stored. "Related …" comes from the current values, as references do (story
4
+ * 041). "Relate parent … on delete" comes from the original parent a trashed
5
+ * item remembers. Copies record no origin, so "Relate Document On Copy" has none.
6
+ * docs/data-model.md.
7
+ */
8
+ import { createHash } from 'node:crypto'
9
+ import { normaliseUuid, ObjectTypes } from '@crombolo/core'
10
+ import type { Db } from './database.ts'
11
+ import { DbDate, fromDbBool } from './dialect.ts'
12
+
13
+ /** RFC 4122's URL namespace, which Umbraco's `string.ToGuid()` hashes in. */
14
+ const URL_NAMESPACE = '6ba7b811-9dad-11d1-80b4-00c04fd430c8'
15
+
16
+ /** A name-based (version 3, MD5) UUID: the same input always gives the same key. */
17
+ export function nameBasedUuid(name: string, namespace = URL_NAMESPACE): string {
18
+ const bytes = createHash('md5')
19
+ .update(Buffer.from(namespace.replaceAll('-', ''), 'hex'))
20
+ .update(name, 'utf8')
21
+ .digest()
22
+ bytes[6] = ((bytes[6] as number) & 0x0f) | 0x30
23
+ bytes[8] = ((bytes[8] as number) & 0x3f) | 0x80
24
+ const hex = bytes.subarray(0, 16).toString('hex')
25
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
26
+ }
27
+
28
+ export type RelationDerivation = 'reference' | 'original-parent' | 'none'
29
+
30
+ export interface RelationType {
31
+ key: string
32
+ alias: string
33
+ name: string
34
+ parentObjectType: string | null
35
+ childObjectType: string | null
36
+ isBidirectional: boolean
37
+ isDependency: boolean
38
+ derivation: RelationDerivation
39
+ /** For a reference type, what is referred to; for an original-parent type, what was trashed. */
40
+ target: string | null
41
+ }
42
+
43
+ const define = (
44
+ alias: string,
45
+ name: string,
46
+ parentObjectType: string | null,
47
+ childObjectType: string | null,
48
+ isBidirectional: boolean,
49
+ isDependency: boolean,
50
+ derivation: RelationDerivation,
51
+ target: string | null,
52
+ ): RelationType => ({
53
+ // Umbraco's `CreateUniqueRelationTypeId`, so the keys match a real install's.
54
+ key: nameBasedUuid(`${alias}____${name}`),
55
+ alias,
56
+ name,
57
+ parentObjectType,
58
+ childObjectType,
59
+ isBidirectional,
60
+ isDependency,
61
+ derivation,
62
+ target,
63
+ })
64
+
65
+ /** In Umbraco's install order. */
66
+ export const RELATION_TYPES: readonly RelationType[] = [
67
+ define(
68
+ 'relateDocumentOnCopy',
69
+ 'Relate Document On Copy',
70
+ ObjectTypes.Document,
71
+ ObjectTypes.Document,
72
+ true,
73
+ false,
74
+ 'none',
75
+ null,
76
+ ),
77
+ define(
78
+ 'relateParentDocumentOnDelete',
79
+ 'Relate Parent Document On Delete',
80
+ ObjectTypes.Document,
81
+ ObjectTypes.Document,
82
+ false,
83
+ false,
84
+ 'original-parent',
85
+ ObjectTypes.Document,
86
+ ),
87
+ define(
88
+ 'relateParentMediaFolderOnDelete',
89
+ 'Relate Parent Media Folder On Delete',
90
+ ObjectTypes.Media,
91
+ ObjectTypes.Media,
92
+ false,
93
+ false,
94
+ 'original-parent',
95
+ ObjectTypes.Media,
96
+ ),
97
+ define('umbMedia', 'Related Media', null, null, false, true, 'reference', ObjectTypes.Media),
98
+ define(
99
+ 'umbDocument',
100
+ 'Related Document',
101
+ null,
102
+ null,
103
+ false,
104
+ true,
105
+ 'reference',
106
+ ObjectTypes.Document,
107
+ ),
108
+ define('umbMember', 'Related Member', null, null, false, true, 'reference', ObjectTypes.Member),
109
+ define(
110
+ 'umbElement',
111
+ 'Related Element',
112
+ null,
113
+ null,
114
+ false,
115
+ true,
116
+ 'reference',
117
+ ObjectTypes.Element,
118
+ ),
119
+ define(
120
+ 'relateParentElementContainerOnElementDelete',
121
+ 'Relate Parent Element Container On Element Delete',
122
+ ObjectTypes.ElementContainer,
123
+ ObjectTypes.Element,
124
+ false,
125
+ false,
126
+ 'original-parent',
127
+ ObjectTypes.Element,
128
+ ),
129
+ define(
130
+ 'relateParentElementContainerOnContainerDelete',
131
+ 'Relate Parent Element Container On Container Delete',
132
+ ObjectTypes.ElementContainer,
133
+ ObjectTypes.ElementContainer,
134
+ false,
135
+ false,
136
+ 'none',
137
+ null,
138
+ ),
139
+ ]
140
+
141
+ export function relationTypeByKey(key: string): RelationType | undefined {
142
+ const wanted = normaliseUuid(key)
143
+ return RELATION_TYPES.find((type) => type.key === wanted)
144
+ }
145
+
146
+ const UDI = /umb:\/\/[a-z-]+\/([0-9a-f]{32})/gi
147
+ const UUID = /\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi
148
+
149
+ /** Every key a stored value mentions, as a picker writes it: `umb://<type>/<hex>` or a bare uuid. */
150
+ export function referencedKeysIn(text: string): string[] {
151
+ const keys = new Set<string>()
152
+ for (const match of text.matchAll(UDI)) {
153
+ const hex = (match[1] as string).toLowerCase()
154
+ keys.add(
155
+ `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
156
+ )
157
+ }
158
+ for (const match of text.matchAll(UUID)) keys.add(normaliseUuid(match[1] as string))
159
+ return [...keys]
160
+ }
161
+
162
+ export interface ValueText {
163
+ nodeId: number
164
+ text: string
165
+ }
166
+
167
+ export interface KnownNode {
168
+ id: number
169
+ key: string
170
+ objectType: string
171
+ }
172
+
173
+ export interface ReferenceEdge {
174
+ fromId: number
175
+ toId: number
176
+ toObjectType: string
177
+ }
178
+
179
+ /** Who refers to whom, once per pair, never a node to itself. Pure. */
180
+ export function deriveReferences(
181
+ values: readonly ValueText[],
182
+ nodesByKey: ReadonlyMap<string, KnownNode>,
183
+ ): ReferenceEdge[] {
184
+ const seen = new Set<string>()
185
+ const edges: ReferenceEdge[] = []
186
+ for (const value of values) {
187
+ for (const key of referencedKeysIn(value.text)) {
188
+ const target = nodesByKey.get(key)
189
+ if (!target || target.id === value.nodeId) continue
190
+ const pair = `${value.nodeId}>${target.id}`
191
+ if (seen.has(pair)) continue
192
+ seen.add(pair)
193
+ edges.push({ fromId: value.nodeId, toId: target.id, toObjectType: target.objectType })
194
+ }
195
+ }
196
+ return edges
197
+ }
198
+
199
+ /** Every reference the current values make. A scan, which is the price of never going stale. */
200
+ export async function loadReferences(db: Db): Promise<ReferenceEdge[]> {
201
+ const values = await db.query<{ node_id: number; v: string | null; t: string | null }>(
202
+ `SELECT node_id, varchar_value AS v, text_value AS t FROM property_value
203
+ WHERE is_current = ? AND (varchar_value LIKE '%-%-%-%-%' OR text_value LIKE '%-%-%-%-%'
204
+ OR varchar_value LIKE '%umb://%' OR text_value LIKE '%umb://%')`,
205
+ [db.dialect.boolValue(true)],
206
+ )
207
+ const texts: ValueText[] = []
208
+ for (const row of values) {
209
+ const text = `${row.v ?? ''} ${row.t ?? ''}`
210
+ texts.push({ nodeId: Number(row.node_id), text })
211
+ }
212
+ const wanted = new Set(texts.flatMap((value) => referencedKeysIn(value.text)))
213
+ if (wanted.size === 0) return []
214
+ const nodes = await db.query<{ id: number; unique_id: string; node_object_type: string }>(
215
+ 'SELECT id, unique_id, node_object_type FROM node',
216
+ )
217
+ const byKey = new Map<string, KnownNode>()
218
+ for (const node of nodes) {
219
+ const key = normaliseUuid(String(node.unique_id))
220
+ if (wanted.has(key))
221
+ byKey.set(key, {
222
+ id: Number(node.id),
223
+ key,
224
+ objectType: String(node.node_object_type).toLowerCase(),
225
+ })
226
+ }
227
+ return deriveReferences(texts, byKey)
228
+ }
229
+
230
+ export interface Relation {
231
+ key: string
232
+ typeKey: string
233
+ parent: { key: string; name: string | null }
234
+ child: { key: string; name: string | null }
235
+ createDate: Date
236
+ }
237
+
238
+ interface NodeFacts {
239
+ key: string
240
+ name: string | null
241
+ date: Date
242
+ }
243
+
244
+ async function describeNodes(db: Db, ids: readonly number[]): Promise<Map<number, NodeFacts>> {
245
+ const facts = new Map<number, NodeFacts>()
246
+ if (ids.length === 0) return facts
247
+ const holes = ids.map(() => '?').join(', ')
248
+ const rows = await db.query<{ id: number; unique_id: string; text: string | null; d: unknown }>(
249
+ `SELECT n.id, n.unique_id, n.text,
250
+ COALESCE((SELECT MAX(cv.version_date) FROM content_version cv WHERE cv.node_id = n.id), n.create_date) AS d
251
+ FROM node n WHERE n.id IN (${holes})`,
252
+ [...ids],
253
+ )
254
+ for (const row of rows)
255
+ facts.set(Number(row.id), {
256
+ key: normaliseUuid(String(row.unique_id)),
257
+ name: row.text,
258
+ date: DbDate.fromDb(row.d) ?? new Date(0),
259
+ })
260
+ return facts
261
+ }
262
+
263
+ /** A stable key for a derived relation: its type and its two ends. */
264
+ export const relationKey = (type: RelationType, parentKey: string, childKey: string) =>
265
+ nameBasedUuid(`${type.alias}|${parentKey}|${childKey}`)
266
+
267
+ export class RelationRepository {
268
+ #db: Db
269
+
270
+ constructor(db: Db) {
271
+ this.#db = db
272
+ }
273
+
274
+ /** The relations of one type, ordered by the parent's name then the child's, paged. */
275
+ async relations(
276
+ type: RelationType,
277
+ skip: number,
278
+ take: number,
279
+ ): Promise<{ total: number; items: Relation[] }> {
280
+ const pairs = await this.#pairs(type)
281
+ const facts = await describeNodes(this.#db, [...new Set(pairs.flat())])
282
+ const relations: Relation[] = []
283
+ for (const [parentId, childId] of pairs) {
284
+ const parent = facts.get(parentId)
285
+ const child = facts.get(childId)
286
+ if (!parent || !child) continue
287
+ relations.push({
288
+ key: relationKey(type, parent.key, child.key),
289
+ typeKey: type.key,
290
+ parent: { key: parent.key, name: parent.name },
291
+ child: { key: child.key, name: child.name },
292
+ createDate: type.derivation === 'reference' ? parent.date : child.date,
293
+ })
294
+ }
295
+ relations.sort(
296
+ (a, b) =>
297
+ (a.parent.name ?? '').localeCompare(b.parent.name ?? '') ||
298
+ (a.child.name ?? '').localeCompare(b.child.name ?? ''),
299
+ )
300
+ return { total: relations.length, items: relations.slice(skip, skip + take) }
301
+ }
302
+
303
+ async #pairs(type: RelationType): Promise<Array<[number, number]>> {
304
+ if (type.derivation === 'reference')
305
+ return (await loadReferences(this.#db))
306
+ .filter((edge) => edge.toObjectType === type.target)
307
+ .map((edge) => [edge.fromId, edge.toId])
308
+ if (type.derivation === 'original-parent') {
309
+ const rows = await this.#db.query<{ id: number; parent: number; parent_type: string }>(
310
+ `SELECT n.id, d.original_parent_id AS parent, p.node_object_type AS parent_type
311
+ FROM node n
312
+ JOIN document d ON d.node_id = n.id
313
+ JOIN node p ON p.id = d.original_parent_id
314
+ WHERE n.trashed = ? AND n.node_object_type = ?`,
315
+ [this.#db.dialect.boolValue(true), type.target],
316
+ )
317
+ return rows
318
+ .filter(
319
+ (row) =>
320
+ !type.parentObjectType ||
321
+ String(row.parent_type).toLowerCase() === type.parentObjectType,
322
+ )
323
+ .map((row) => [Number(row.parent), Number(row.id)])
324
+ }
325
+ return []
326
+ }
327
+
328
+ /** Nodes that refer to anything of `objectType` in the recycle bin: what emptying it breaks. */
329
+ async referencingRecycleBin(objectType: string): Promise<number[]> {
330
+ const trashed = new Set(
331
+ (
332
+ await this.#db.query<{ id: number; trashed: unknown }>(
333
+ 'SELECT id, trashed FROM node WHERE node_object_type = ? AND trashed = ?',
334
+ [objectType, this.#db.dialect.boolValue(true)],
335
+ )
336
+ )
337
+ .filter((row) => fromDbBool(row.trashed))
338
+ .map((row) => Number(row.id)),
339
+ )
340
+ if (trashed.size === 0) return []
341
+ const from = new Set<number>()
342
+ for (const edge of await loadReferences(this.#db))
343
+ if (trashed.has(edge.toId)) from.add(edge.fromId)
344
+ return [...from]
345
+ }
346
+ }