@crombolo/data 0.11.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.
@@ -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
+ }
@@ -121,6 +121,44 @@ export class DataTypeRepository {
121
121
  return { total: items.length, items: items.slice(paging.skip, paging.skip + paging.take) }
122
122
  }
123
123
 
124
+ /** Whether any current value is saved through this data type; undefined when there is no such type. */
125
+ async isUsed(key: string): Promise<boolean | undefined> {
126
+ const node = await this.#nodes.byKey(key)
127
+ if (!node || node.objectType !== ObjectTypes.DataType) return undefined
128
+ return this.#hasValues('p.data_type_id = ?', [node.id])
129
+ }
130
+
131
+ /**
132
+ * Whether any current value is saved for the property `alias` of a content type;
133
+ * undefined when the type or the property does not exist.
134
+ */
135
+ async isPropertyUsed(contentTypeKey: string, alias: string): Promise<boolean | undefined> {
136
+ const rows = await this.#db.query<{ id: number }>(
137
+ `SELECT p.id FROM property_type p
138
+ JOIN node ctn ON ctn.id = p.content_type_id
139
+ WHERE ctn.unique_id = ? AND p.alias = ? AND p.retired_at IS NULL`,
140
+ [normaliseUuid(contentTypeKey), alias],
141
+ )
142
+ const property = rows[0]
143
+ if (!property) return undefined
144
+ return this.#hasValues('p.id = ?', [Number(property.id)])
145
+ }
146
+
147
+ /** A current value that holds something: an empty string or an all-null row is not a use. */
148
+ async #hasValues(where: string, params: unknown[]): Promise<boolean> {
149
+ const rows = await this.#db.query(
150
+ `SELECT 1 AS used FROM property_value v
151
+ JOIN property_type p ON p.id = v.property_type_id
152
+ WHERE ${where} AND v.is_current = ? AND p.retired_at IS NULL
153
+ AND (v.int_value IS NOT NULL OR v.decimal_value IS NOT NULL OR v.date_value IS NOT NULL
154
+ OR (v.varchar_value IS NOT NULL AND v.varchar_value <> '')
155
+ OR (v.text_value IS NOT NULL AND v.text_value <> ''))
156
+ LIMIT 1`,
157
+ [...params, this.#db.dialect.boolValue(true)],
158
+ )
159
+ return rows.length > 0
160
+ }
161
+
124
162
  /** Property types using this data type, with the type each belongs to. */
125
163
  async referencedBy(key: string): Promise<
126
164
  Array<{
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The Delivery API's member tokens. A token is random and opaque; only its
3
+ * SHA-256 is kept, so the table cannot be replayed from a backup.
4
+ */
5
+ import { createHash, randomBytes } from 'node:crypto'
6
+ import type { Db } from '../database.ts'
7
+ import { DbDate } from '../dialect.ts'
8
+
9
+ export type MemberTokenKind = 'code' | 'access' | 'refresh'
10
+
11
+ export interface MemberToken {
12
+ kind: MemberTokenKind
13
+ memberKey: string
14
+ clientId: string
15
+ scopes: string[]
16
+ redirectUri: string | null
17
+ codeChallenge: string | null
18
+ expiresAt: Date
19
+ redeemedAt: Date | null
20
+ }
21
+
22
+ /** A new token for a client to hold, and the hash it is stored under. Pure apart from the randomness. */
23
+ export function newToken(): { token: string; hash: string } {
24
+ const token = randomBytes(32).toString('base64url')
25
+ return { token, hash: hashToken(token) }
26
+ }
27
+
28
+ export const hashToken = (token: string): string => createHash('sha256').update(token).digest('hex')
29
+
30
+ /** PKCE S256: the verifier's SHA-256, base64url, against the challenge. Pure. */
31
+ export const pkceMatches = (verifier: string, challenge: string): boolean =>
32
+ createHash('sha256').update(verifier).digest('base64url') === challenge
33
+
34
+ export class DeliveryMemberTokenRepository {
35
+ #db: Db
36
+ constructor(db: Db) {
37
+ this.#db = db
38
+ }
39
+
40
+ async issue(
41
+ token: Omit<MemberToken, 'redeemedAt' | 'expiresAt'> & { lifetimeMs: number },
42
+ ): Promise<{ token: string; expiresAt: Date }> {
43
+ const { token: value, hash } = newToken()
44
+ const now = new Date()
45
+ const expiresAt = new Date(now.getTime() + token.lifetimeMs)
46
+ await this.#db.exec(
47
+ `INSERT INTO delivery_member_token
48
+ (hash, kind, member_key, client_id, scopes, redirect_uri, code_challenge, expires_at, create_date)
49
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
50
+ [
51
+ hash,
52
+ token.kind,
53
+ token.memberKey,
54
+ token.clientId,
55
+ token.scopes.join(' '),
56
+ token.redirectUri,
57
+ token.codeChallenge,
58
+ DbDate.toDb(expiresAt),
59
+ DbDate.toDb(now),
60
+ ],
61
+ )
62
+ return { token: value, expiresAt }
63
+ }
64
+
65
+ /** The token a client presents, whatever its state; the caller judges it. */
66
+ async find(token: string): Promise<MemberToken | undefined> {
67
+ const rows = await this.#db.query('SELECT * FROM delivery_member_token WHERE hash = ?', [
68
+ hashToken(token),
69
+ ])
70
+ const row = rows[0]
71
+ if (!row) return undefined
72
+ return {
73
+ kind: String(row.kind) as MemberTokenKind,
74
+ memberKey: String(row.member_key),
75
+ clientId: String(row.client_id),
76
+ scopes: String(row.scopes ?? '')
77
+ .split(' ')
78
+ .filter(Boolean),
79
+ redirectUri: (row.redirect_uri as string | null) ?? null,
80
+ codeChallenge: (row.code_challenge as string | null) ?? null,
81
+ expiresAt: DbDate.fromDb(row.expires_at) ?? new Date(0),
82
+ redeemedAt: DbDate.fromDb(row.redeemed_at) ?? null,
83
+ }
84
+ }
85
+
86
+ /** Marks a code or refresh token used; false when it already was, so only one redemption wins. */
87
+ async redeem(token: string): Promise<boolean> {
88
+ const rows = await this.#db.query<{ hash: string }>(
89
+ 'UPDATE delivery_member_token SET redeemed_at = ? WHERE hash = ? AND redeemed_at IS NULL RETURNING hash',
90
+ [DbDate.toDb(new Date()), hashToken(token)],
91
+ )
92
+ return rows.length > 0
93
+ }
94
+
95
+ async revoke(token: string): Promise<void> {
96
+ await this.#db.exec('DELETE FROM delivery_member_token WHERE hash = ?', [hashToken(token)])
97
+ }
98
+
99
+ /** Every token a member holds, as signing out ends them. */
100
+ async revokeMember(memberKey: string): Promise<void> {
101
+ await this.#db.exec('DELETE FROM delivery_member_token WHERE member_key = ?', [memberKey])
102
+ }
103
+
104
+ /** Expired tokens, which nothing will accept again. */
105
+ async purgeExpired(): Promise<void> {
106
+ await this.#db.exec('DELETE FROM delivery_member_token WHERE expires_at < ?', [
107
+ DbDate.toDb(new Date()),
108
+ ])
109
+ }
110
+ }
@@ -240,6 +240,24 @@ export class DictionaryRepository {
240
240
  })
241
241
  }
242
242
 
243
+ /** Every item's key, name and parent's name, for a choice list drawn from the dictionary. */
244
+ async outline(): Promise<Array<{ key: string; name: string; parentName: string | null }>> {
245
+ const rows = await this.#db.query<{
246
+ key: string
247
+ item_key: string
248
+ parent_name: string | null
249
+ }>(
250
+ `SELECT d.key, d.item_key, p.item_key AS parent_name FROM dictionary_item d
251
+ LEFT JOIN dictionary_item p ON p.key = d.parent_key
252
+ ORDER BY d.item_key`,
253
+ )
254
+ return rows.map((row) => ({
255
+ key: normaliseUuid(String(row.key)),
256
+ name: String(row.item_key),
257
+ parentName: row.parent_name === null ? null : String(row.parent_name),
258
+ }))
259
+ }
260
+
243
261
  /** Every translation, keyed by item name then iso code, for rendering. */
244
262
  async translations(): Promise<Map<string, Map<string, string>>> {
245
263
  const rows = await this.#db.query<{ item_key: string; iso_code: string; value: string }>(