@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.
- package/package.json +2 -2
- package/src/ambient.ts +20 -0
- package/src/audit.ts +291 -0
- package/src/busy.ts +74 -0
- package/src/change-report.ts +6 -0
- package/src/cluster.ts +17 -5
- package/src/database.ts +80 -17
- package/src/dialect.ts +24 -0
- package/src/index.ts +20 -0
- package/src/locks.ts +94 -25
- package/src/migrations.ts +42 -16
- package/src/relations.ts +346 -0
- package/src/repositories/content-types.ts +41 -4
- package/src/repositories/data-types.ts +38 -0
- package/src/repositories/delivery-member-tokens.ts +110 -0
- package/src/repositories/dictionary.ts +18 -0
- package/src/repositories/documents.ts +443 -88
- package/src/repositories/dynamic-roots.ts +151 -0
- package/src/repositories/form-entries.ts +32 -11
- package/src/repositories/form-workflows.ts +1 -1
- package/src/repositories/members.ts +26 -1
- package/src/repositories/nodes.ts +43 -7
- package/src/repositories/public-access.ts +30 -0
- package/src/repositories/references.ts +15 -0
- package/src/repositories/transfer-answers.ts +151 -0
- package/src/repositories/webhooks.ts +299 -0
- package/src/schema/audit-log.ts +45 -0
- package/src/schema/delivery-member-tokens.ts +40 -0
- package/src/schema/diagnostics.ts +28 -0
- package/src/schema/plan.ts +14 -0
- package/src/schema/property-searchable.ts +29 -0
- package/src/schema/search-index.ts +92 -0
- package/src/schema/seed.ts +13 -2
- package/src/schema/transfer-answers.ts +50 -0
- package/src/schema/webhooks.ts +78 -0
- package/src/search-text.ts +133 -0
- package/src/search.ts +397 -0
- package/src/setup.ts +57 -0
- package/src/tree-invariants.ts +167 -0
- 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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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'
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 }
|
package/src/relations.ts
ADDED
|
@@ -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
|
+
}
|