@crombolo/data 0.0.0-stage → 0.9.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/change-report.ts +166 -0
  5. package/src/cluster.ts +108 -0
  6. package/src/database.ts +121 -0
  7. package/src/dialect.ts +128 -0
  8. package/src/index.ts +60 -0
  9. package/src/locks.ts +103 -0
  10. package/src/maintenance.ts +51 -0
  11. package/src/migrations.ts +431 -0
  12. package/src/node-ref.ts +107 -0
  13. package/src/pending.ts +25 -0
  14. package/src/repositories/bundles.ts +191 -0
  15. package/src/repositories/content-types.ts +831 -0
  16. package/src/repositories/data-types.ts +222 -0
  17. package/src/repositories/dictionary.ts +259 -0
  18. package/src/repositories/documents.ts +2389 -0
  19. package/src/repositories/domains.ts +108 -0
  20. package/src/repositories/folders.ts +120 -0
  21. package/src/repositories/form-entries.ts +314 -0
  22. package/src/repositories/form-workflows.ts +213 -0
  23. package/src/repositories/languages.ts +108 -0
  24. package/src/repositories/log-searches.ts +33 -0
  25. package/src/repositories/members.ts +427 -0
  26. package/src/repositories/nodes.ts +361 -0
  27. package/src/repositories/notifications.ts +54 -0
  28. package/src/repositories/public-access.ts +187 -0
  29. package/src/repositories/redirects.ts +274 -0
  30. package/src/repositories/references.ts +162 -0
  31. package/src/repositories/tags.ts +93 -0
  32. package/src/repositories/templates.ts +114 -0
  33. package/src/repositories/transfer-runs.ts +190 -0
  34. package/src/repositories/user-data.ts +90 -0
  35. package/src/repositories/users.ts +898 -0
  36. package/src/schema/assistant.ts +74 -0
  37. package/src/schema/built-in-content-types.ts +106 -0
  38. package/src/schema/built-in-media-types.ts +335 -0
  39. package/src/schema/built-in-member-types.ts +61 -0
  40. package/src/schema/bundle-components.ts +49 -0
  41. package/src/schema/bundles-rename.ts +32 -0
  42. package/src/schema/change-report.ts +46 -0
  43. package/src/schema/client-id-prefix.ts +43 -0
  44. package/src/schema/content-editing.ts +50 -0
  45. package/src/schema/content-seed.ts +586 -0
  46. package/src/schema/content.ts +263 -0
  47. package/src/schema/created-packages.ts +56 -0
  48. package/src/schema/elements.ts +62 -0
  49. package/src/schema/form-permission-prefix.ts +50 -0
  50. package/src/schema/form-permissions.ts +26 -0
  51. package/src/schema/form-workflows.ts +54 -0
  52. package/src/schema/forms.ts +63 -0
  53. package/src/schema/group-permissions.ts +20 -0
  54. package/src/schema/identity.ts +176 -0
  55. package/src/schema/log-viewer.ts +68 -0
  56. package/src/schema/member-property-type.ts +30 -0
  57. package/src/schema/members.ts +97 -0
  58. package/src/schema/plan.ts +73 -0
  59. package/src/schema/public-access.ts +56 -0
  60. package/src/schema/recycle-bin.ts +21 -0
  61. package/src/schema/redirects.ts +57 -0
  62. package/src/schema/rich-text-ui.ts +34 -0
  63. package/src/schema/seed.ts +298 -0
  64. package/src/schema/server-role.ts +21 -0
  65. package/src/schema/since-version.ts +26 -0
  66. package/src/schema/transfer-runs.ts +71 -0
  67. package/src/schema/upgrade-report.ts +43 -0
  68. package/src/schema/users-localisation.ts +85 -0
  69. package/src/schema/values.ts +108 -0
  70. package/src/schema-ops.ts +35 -0
  71. package/src/schema-state.ts +196 -0
  72. package/README.md +0 -3
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Redirects: the routes a page used to answer on, and the rules a site declares.
3
+ *
4
+ * Tracking writes a rule per route a publish or a move retired; the site's own
5
+ * `redirects` config is synced into the same table so the backoffice can list
6
+ * everything in force, and those rows are marked `config` and refuse to be
7
+ * deleted through the API — the file owns them. A rule an administrator wrote in
8
+ * the backoffice is `manual`, and is the only kind this repository will let a
9
+ * bundle touch (`server/src/bundles.ts`).
10
+ *
11
+ * A rule is identified by what it matches (kind, pattern, hostname root and
12
+ * culture), so re-registering a route replaces where it points instead of
13
+ * stacking another row behind it, which is how Umbraco's "most recent wins"
14
+ * ends up behaving without the duplicates.
15
+ */
16
+ import { normaliseUuid } from '@crombolo/core'
17
+ import type { Db } from '../database.ts'
18
+ import { DbDate } from '../dialect.ts'
19
+
20
+ export type RedirectMatchKind = 'exact' | 'prefix' | 'regex'
21
+ export type RedirectTargetKind = 'document' | 'path' | 'url'
22
+ /**
23
+ * Where a rule came from, and — because `ORDER` sorts on this column — its
24
+ * precedence: `config` before `manual` before `tracked`, which is alphabetical
25
+ * and so needs no CASE expression. That order is the intent, not a coincidence:
26
+ * a rule in the site's code outranks one an administrator typed, which outranks
27
+ * one a rename recorded by itself.
28
+ */
29
+ export type RedirectSource = 'tracked' | 'config' | 'manual'
30
+
31
+ export interface RedirectRow {
32
+ key: string
33
+ source: RedirectSource
34
+ matchKind: RedirectMatchKind
35
+ pattern: string
36
+ rootKey: string | null
37
+ culture: string | null
38
+ targetKind: RedirectTargetKind
39
+ target: string
40
+ statusCode: number
41
+ sortOrder: number
42
+ createDate: Date
43
+ }
44
+
45
+ /** A rule to store; `key` and `createDate` are assigned when it is new. */
46
+ export type RedirectInput = Omit<RedirectRow, 'key' | 'createDate'>
47
+
48
+ interface Row {
49
+ key: string
50
+ source: string
51
+ match_kind: string
52
+ pattern: string
53
+ root_key: string | null
54
+ culture: string | null
55
+ target_kind: string
56
+ target: string
57
+ status_code: number
58
+ sort_order: number
59
+ create_date: unknown
60
+ }
61
+
62
+ const SELECT = `SELECT key, source, match_kind, pattern, root_key, culture,
63
+ target_kind, target, status_code, sort_order, create_date
64
+ FROM redirect_url`
65
+
66
+ /** By source — see `RedirectSource` — then by the rule's own order, newest first. */
67
+ const ORDER = 'ORDER BY source, sort_order, create_date DESC, id DESC'
68
+
69
+ const hydrate = (row: Row): RedirectRow => ({
70
+ key: normaliseUuid(String(row.key)),
71
+ source: String(row.source) as RedirectSource,
72
+ matchKind: String(row.match_kind) as RedirectMatchKind,
73
+ pattern: String(row.pattern),
74
+ rootKey: row.root_key === null ? null : String(row.root_key),
75
+ culture: row.culture === null ? null : String(row.culture),
76
+ targetKind: String(row.target_kind) as RedirectTargetKind,
77
+ target: String(row.target),
78
+ statusCode: Number(row.status_code),
79
+ sortOrder: Number(row.sort_order),
80
+ createDate: DbDate.fromDb(row.create_date) ?? new Date(0),
81
+ })
82
+
83
+ /** A digest of everything a rule matches on; see migration 015. */
84
+ export function matchHash(input: {
85
+ source: RedirectSource
86
+ matchKind: RedirectMatchKind
87
+ pattern: string
88
+ rootKey: string | null
89
+ culture: string | null
90
+ }): string {
91
+ const parts = [
92
+ input.source,
93
+ input.matchKind,
94
+ input.pattern,
95
+ input.rootKey ?? '',
96
+ (input.culture ?? '').toLowerCase(),
97
+ ]
98
+ return new Bun.CryptoHasher('sha256').update(parts.join('\u0000')).digest('hex')
99
+ }
100
+
101
+ export class RedirectRepository {
102
+ #db: Db
103
+
104
+ constructor(db: Db) {
105
+ this.#db = db
106
+ }
107
+
108
+ /** Every rule, in the order they are matched. */
109
+ async all(): Promise<RedirectRow[]> {
110
+ const rows = await this.#db.query<Row>(`${SELECT} ${ORDER}`)
111
+ return rows.map(hydrate)
112
+ }
113
+
114
+ async byKey(key: string): Promise<RedirectRow | undefined> {
115
+ const rows = await this.#db.query<Row>(`${SELECT} WHERE key = ?`, [normaliseUuid(key)])
116
+ return rows[0] ? hydrate(rows[0]) : undefined
117
+ }
118
+
119
+ /**
120
+ * A page of rules, newest first, optionally filtered on either URL. Umbraco's
121
+ * dashboard searches the original URL; matching the destination too costs
122
+ * nothing and is what someone auditing "where does this page get traffic
123
+ * from" actually wants.
124
+ */
125
+ async list(options: { filter?: string; skip?: number; take?: number } = {}): Promise<{
126
+ total: number
127
+ items: RedirectRow[]
128
+ }> {
129
+ const filter = options.filter?.trim().toLowerCase()
130
+ const where = filter ? 'WHERE LOWER(pattern) LIKE ? OR LOWER(target) LIKE ?' : ''
131
+ const params = filter ? [`%${filter}%`, `%${filter}%`] : []
132
+ const counted = await this.#db.query<{ total: number }>(
133
+ `SELECT COUNT(*) AS total FROM redirect_url ${where}`,
134
+ params,
135
+ )
136
+ const rows = await this.#db.query<Row>(`${SELECT} ${where} ${ORDER} LIMIT ? OFFSET ?`, [
137
+ ...params,
138
+ options.take ?? 100,
139
+ options.skip ?? 0,
140
+ ])
141
+ return { total: Number(counted[0]?.total ?? 0), items: rows.map(hydrate) }
142
+ }
143
+
144
+ /** The rules pointing at one document, newest first. */
145
+ async byDocument(
146
+ documentKey: string,
147
+ options: { skip?: number; take?: number } = {},
148
+ ): Promise<{ total: number; items: RedirectRow[] }> {
149
+ const target = normaliseUuid(documentKey)
150
+ const where = "WHERE target_kind = 'document' AND LOWER(target) = ?"
151
+ const counted = await this.#db.query<{ total: number }>(
152
+ `SELECT COUNT(*) AS total FROM redirect_url ${where}`,
153
+ [target],
154
+ )
155
+ const rows = await this.#db.query<Row>(`${SELECT} ${where} ${ORDER} LIMIT ? OFFSET ?`, [
156
+ target,
157
+ options.take ?? 100,
158
+ options.skip ?? 0,
159
+ ])
160
+ return { total: Number(counted[0]?.total ?? 0), items: rows.map(hydrate) }
161
+ }
162
+
163
+ /**
164
+ * Stores a rule, replacing any existing one matching the same thing. The
165
+ * returned row carries the key, which is a fresh one only when the rule is new.
166
+ */
167
+ async save(input: RedirectInput): Promise<RedirectRow> {
168
+ const hash = matchHash(input)
169
+ const now = new Date()
170
+ return this.#db.transaction(async (tx) => {
171
+ const existing = await tx.query<{ key: string }>(
172
+ 'SELECT key FROM redirect_url WHERE match_hash = ?',
173
+ [hash],
174
+ )
175
+ if (existing[0]) {
176
+ const key = normaliseUuid(String(existing[0].key))
177
+ await tx.exec(
178
+ `UPDATE redirect_url
179
+ SET target_kind = ?, target = ?, status_code = ?, sort_order = ?, create_date = ?
180
+ WHERE key = ?`,
181
+ [
182
+ input.targetKind,
183
+ input.target,
184
+ input.statusCode,
185
+ input.sortOrder,
186
+ DbDate.toDb(now),
187
+ key,
188
+ ],
189
+ )
190
+ return { ...input, key, createDate: now }
191
+ }
192
+ const key = crypto.randomUUID()
193
+ await tx.exec(
194
+ `INSERT INTO redirect_url
195
+ (key, source, match_kind, pattern, root_key, culture,
196
+ target_kind, target, status_code, sort_order, match_hash, create_date)
197
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
198
+ [
199
+ key,
200
+ input.source,
201
+ input.matchKind,
202
+ input.pattern,
203
+ input.rootKey,
204
+ input.culture,
205
+ input.targetKind,
206
+ input.target,
207
+ input.statusCode,
208
+ input.sortOrder,
209
+ hash,
210
+ DbDate.toDb(now),
211
+ ],
212
+ )
213
+ return { ...input, key, createDate: now }
214
+ })
215
+ }
216
+
217
+ /** Deletes one rule. `false` means there was nothing with that key. */
218
+ async delete(key: string): Promise<boolean> {
219
+ const found = await this.byKey(key)
220
+ if (!found) return false
221
+ await this.#db.exec('DELETE FROM redirect_url WHERE key = ?', [normaliseUuid(key)])
222
+ return true
223
+ }
224
+
225
+ /**
226
+ * Drops any rule that matches `path` under `rootKey` and points at this
227
+ * document — the case where a page was renamed and then renamed back, which
228
+ * would otherwise leave a rule redirecting a live URL to itself.
229
+ */
230
+ async removeSelfReferencing(
231
+ documentKey: string,
232
+ rootKey: string | null,
233
+ path: string,
234
+ ): Promise<void> {
235
+ await this.#db.exec(
236
+ `DELETE FROM redirect_url
237
+ WHERE source = 'tracked' AND target_kind = 'document'
238
+ AND LOWER(target) = ? AND match_kind = 'exact' AND LOWER(pattern) = ?
239
+ AND ${rootKey === null ? 'root_key IS NULL' : 'root_key = ?'}`,
240
+ rootKey === null
241
+ ? [normaliseUuid(documentKey), path.toLowerCase()]
242
+ : [normaliseUuid(documentKey), path.toLowerCase(), rootKey],
243
+ )
244
+ }
245
+
246
+ /**
247
+ * Brings the `config` rows into line with the site's configuration: the ones
248
+ * it still declares are stored in order, and any left over from a previous
249
+ * boot are removed. Tracked rows are untouched.
250
+ */
251
+ async syncConfigured(inputs: readonly Omit<RedirectInput, 'source' | 'sortOrder'>[]): Promise<{
252
+ stored: number
253
+ removed: number
254
+ }> {
255
+ const kept = new Set<string>()
256
+ let sortOrder = 0
257
+ for (const input of inputs) {
258
+ const rule: RedirectInput = { ...input, source: 'config', sortOrder: sortOrder++ }
259
+ kept.add(matchHash(rule))
260
+ await this.save(rule)
261
+ }
262
+ const stale = await this.#db.query<{ match_hash: string }>(
263
+ "SELECT match_hash FROM redirect_url WHERE source = 'config'",
264
+ )
265
+ let removed = 0
266
+ for (const row of stale) {
267
+ const hash = String(row.match_hash)
268
+ if (kept.has(hash)) continue
269
+ await this.#db.exec('DELETE FROM redirect_url WHERE match_hash = ?', [hash])
270
+ removed += 1
271
+ }
272
+ return { stored: kept.size, removed }
273
+ }
274
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * What refers to what, derived from the values themselves.
3
+ *
4
+ * Umbraco keeps a `umbracoRelation` row per reference, written when content is
5
+ * saved. Deriving the answer from the current property values instead cannot go
6
+ * stale — a relation table is only as good as the last save that maintained it —
7
+ * and needs no migration. The cost is a scan of `property_value`, which is
8
+ * acceptable for an editor asking "what breaks if I delete this?".
9
+ *
10
+ * A picker stores its target as `umb://document/<hex>` or a bare uuid, so both
11
+ * spellings are matched.
12
+ */
13
+ import { normaliseUuid } from '@crombolo/core'
14
+ import type { Db } from '../database.ts'
15
+
16
+ export interface ReferencingNode {
17
+ key: string
18
+ name: string | null
19
+ objectType: string | null
20
+ published: boolean | null
21
+ contentTypeKey: string | null
22
+ contentTypeAlias: string | null
23
+ contentTypeIcon: string | null
24
+ }
25
+
26
+ /** The two spellings a key appears in inside a stored value. */
27
+ function patterns(key: string): [string, string] {
28
+ const dashed = normaliseUuid(key)
29
+ return [`%${dashed}%`, `%${dashed.replaceAll('-', '')}%`]
30
+ }
31
+
32
+ export class ReferenceRepository {
33
+ #db: Db
34
+
35
+ constructor(db: Db) {
36
+ this.#db = db
37
+ }
38
+
39
+ /** Node ids whose current values mention any of `keys`, excluding `keys` themselves. */
40
+ async #referencingIds(keys: readonly string[]): Promise<Map<string, number[]>> {
41
+ const found = new Map<string, number[]>()
42
+ for (const key of keys) {
43
+ const [dashed, plain] = patterns(key)
44
+ const rows = await this.#db.query<{ id: number }>(
45
+ `SELECT DISTINCT pv.node_id AS id
46
+ FROM property_value pv
47
+ WHERE pv.is_current = ?
48
+ AND ( LOWER(pv.varchar_value) LIKE ? OR LOWER(pv.text_value) LIKE ?
49
+ OR LOWER(pv.varchar_value) LIKE ? OR LOWER(pv.text_value) LIKE ? )`,
50
+ [this.#db.dialect.boolValue(true), dashed, dashed, plain, plain],
51
+ )
52
+ found.set(
53
+ normaliseUuid(key),
54
+ rows.map((row) => Number(row.id)),
55
+ )
56
+ }
57
+ return found
58
+ }
59
+
60
+ async #describe(ids: readonly number[]): Promise<ReferencingNode[]> {
61
+ if (ids.length === 0) return []
62
+ const holes = ids.map(() => '?').join(', ')
63
+ const rows = await this.#db.query<{
64
+ unique_id: string
65
+ text: string | null
66
+ node_object_type: string | null
67
+ published: unknown
68
+ type_key: string | null
69
+ type_alias: string | null
70
+ type_icon: string | null
71
+ }>(
72
+ `SELECT n.unique_id, n.text, n.node_object_type,
73
+ d.published AS published,
74
+ tn.unique_id AS type_key, ct.alias AS type_alias, ct.icon AS type_icon
75
+ FROM node n
76
+ LEFT JOIN content c ON c.node_id = n.id
77
+ LEFT JOIN content_type ct ON ct.node_id = c.content_type_id
78
+ LEFT JOIN node tn ON tn.id = ct.node_id
79
+ LEFT JOIN document d ON d.node_id = n.id
80
+ WHERE n.id IN (${holes})
81
+ ORDER BY n.text`,
82
+ [...ids],
83
+ )
84
+ return rows.map((row) => ({
85
+ key: String(row.unique_id),
86
+ name: row.text,
87
+ objectType: row.node_object_type,
88
+ published: row.published === null || row.published === undefined ? null : !!row.published,
89
+ contentTypeKey: row.type_key ? String(row.type_key) : null,
90
+ contentTypeAlias: row.type_alias,
91
+ contentTypeIcon: row.type_icon,
92
+ }))
93
+ }
94
+
95
+ /** What refers to this one, newest name order, paged. */
96
+ async referencedBy(
97
+ key: string,
98
+ skip: number,
99
+ take: number,
100
+ ): Promise<{ total: number; items: ReferencingNode[] }> {
101
+ const self = await this.#db.query<{ id: number }>('SELECT id FROM node WHERE unique_id = ?', [
102
+ normaliseUuid(key),
103
+ ])
104
+ const selfId = self[0]?.id
105
+ const ids = (await this.#referencingIds([key])).get(normaliseUuid(key)) ?? []
106
+ const others = ids.filter((id) => id !== Number(selfId))
107
+ return { total: others.length, items: await this.#describe(others.slice(skip, skip + take)) }
108
+ }
109
+
110
+ /**
111
+ * Descendants of this node that something else refers to — what an editor
112
+ * needs before deleting a branch.
113
+ */
114
+ async referencedDescendants(
115
+ key: string,
116
+ skip: number,
117
+ take: number,
118
+ ): Promise<{ total: number; items: string[] }> {
119
+ const self = await this.#db.query<{ id: number; path: string }>(
120
+ 'SELECT id, path FROM node WHERE unique_id = ?',
121
+ [normaliseUuid(key)],
122
+ )
123
+ const node = self[0]
124
+ if (!node) return { total: 0, items: [] }
125
+ const descendants = await this.#db.query<{ id: number; unique_id: string }>(
126
+ 'SELECT id, unique_id FROM node WHERE path LIKE ? AND id <> ?',
127
+ [`${node.path},%`, node.id],
128
+ )
129
+ const referenced: string[] = []
130
+ for (const descendant of descendants) {
131
+ const ids = (await this.#referencingIds([String(descendant.unique_id)])).values().next()
132
+ .value as number[] | undefined
133
+ // A reference from inside the branch being deleted does not count.
134
+ const outside = (ids ?? []).filter(
135
+ (id) => !descendants.some((d) => Number(d.id) === id) && id !== Number(node.id),
136
+ )
137
+ if (outside.length > 0) referenced.push(String(descendant.unique_id))
138
+ }
139
+ return { total: referenced.length, items: referenced.slice(skip, skip + take) }
140
+ }
141
+
142
+ /** Of these keys, the ones something refers to. */
143
+ async areReferenced(
144
+ keys: readonly string[],
145
+ skip: number,
146
+ take: number,
147
+ ): Promise<{ total: number; items: string[] }> {
148
+ const wanted = keys.map(normaliseUuid)
149
+ const found = await this.#referencingIds(wanted)
150
+ const selfIds = new Map<string, number>()
151
+ for (const key of wanted) {
152
+ const rows = await this.#db.query<{ id: number }>('SELECT id FROM node WHERE unique_id = ?', [
153
+ key,
154
+ ])
155
+ if (rows[0]) selfIds.set(key, Number(rows[0].id))
156
+ }
157
+ const referenced = wanted.filter((key) =>
158
+ (found.get(key) ?? []).some((id) => id !== selfIds.get(key)),
159
+ )
160
+ return { total: referenced.length, items: referenced.slice(skip, skip + take) }
161
+ }
162
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Tags, read from the current values of Tags properties rather than kept in a
3
+ * table of their own: what the tags editor's suggestions and `GET /tag` list.
4
+ * A tag's group comes from its data type's configuration, as in Umbraco.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+ import type { Db } from '../database.ts'
8
+
9
+ export interface TagRow {
10
+ id: string
11
+ text: string
12
+ group: string
13
+ culture: string | null
14
+ nodeCount: number
15
+ }
16
+
17
+ /** A stable id for a tag: Umbraco's are rows; these are derived from what identifies one. */
18
+ function tagId(group: string, text: string, culture: string | null): string {
19
+ const hex = createHash('sha1')
20
+ .update(`${group}\u0000${text}\u0000${culture ?? ''}`)
21
+ .digest('hex')
22
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${((Number.parseInt(hex.slice(16, 17), 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`
23
+ }
24
+
25
+ function parseTags(raw: unknown): string[] {
26
+ if (typeof raw !== 'string' || raw.trim() === '') return []
27
+ try {
28
+ const parsed = JSON.parse(raw)
29
+ if (Array.isArray(parsed)) return parsed.map(String)
30
+ } catch {
31
+ // CSV storage
32
+ }
33
+ return raw.split(',')
34
+ }
35
+
36
+ export class TagRepository {
37
+ #db: Db
38
+
39
+ constructor(db: Db) {
40
+ this.#db = db
41
+ }
42
+
43
+ async all(
44
+ filter: { query?: string; group?: string; culture?: string | null } = {},
45
+ ): Promise<TagRow[]> {
46
+ const rows = await this.#db.query(
47
+ `SELECT pv.node_id, pv.text_value, pv.varchar_value, d.config, l.iso_code
48
+ FROM property_value pv
49
+ JOIN property_type p ON p.id = pv.property_type_id
50
+ JOIN data_type d ON d.node_id = p.data_type_id
51
+ JOIN node n ON n.id = pv.node_id
52
+ LEFT JOIN language l ON l.id = pv.language_id
53
+ WHERE pv.is_current = ? AND d.editor_alias = ? AND n.trashed = ?`,
54
+ [this.#db.dialect.boolValue(true), 'Umbraco.Tags', this.#db.dialect.boolValue(false)],
55
+ )
56
+ const tags = new Map<string, TagRow & { nodes: Set<number> }>()
57
+ for (const row of rows) {
58
+ let group = 'default'
59
+ try {
60
+ const config = JSON.parse(String(row.config ?? '{}')) as Record<string, unknown>
61
+ if (typeof config.group === 'string' && config.group) group = config.group
62
+ } catch {
63
+ // an unreadable configuration keeps the default group
64
+ }
65
+ const culture = (row.iso_code as string | null) ?? null
66
+ for (const raw of parseTags(row.text_value ?? row.varchar_value)) {
67
+ const text = raw.trim()
68
+ if (!text) continue
69
+ const id = tagId(group, text.toLowerCase(), culture)
70
+ const tag = tags.get(id) ?? {
71
+ id,
72
+ text,
73
+ group,
74
+ culture,
75
+ nodeCount: 0,
76
+ nodes: new Set<number>(),
77
+ }
78
+ tag.nodes.add(Number(row.node_id))
79
+ tag.nodeCount = tag.nodes.size
80
+ tags.set(id, tag)
81
+ }
82
+ }
83
+ const query = filter.query?.trim().toLowerCase()
84
+ return [...tags.values()]
85
+ .filter((t) => !query || t.text.toLowerCase().includes(query))
86
+ .filter((t) => !filter.group || t.group === filter.group)
87
+ .filter(
88
+ (t) => filter.culture === undefined || (t.culture ?? null) === (filter.culture ?? null),
89
+ )
90
+ .map(({ nodes: _nodes, ...tag }) => tag)
91
+ .sort((a, b) => a.text.localeCompare(b.text))
92
+ }
93
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Templates. The database row carries the alias and the tree placement; the view
3
+ * file lives on disk, and the file system is the source of truth for its content.
4
+ */
5
+ import {
6
+ type ComponentModel,
7
+ normaliseUuid,
8
+ ObjectTypes,
9
+ type Page,
10
+ SystemNodes,
11
+ } from '@crombolo/core'
12
+ import type { Db } from '../database.ts'
13
+ import { NodeRepository } from './nodes.ts'
14
+
15
+ export interface ComponentFileStore {
16
+ read(alias: string): Promise<string | undefined>
17
+ write(alias: string, content: string): Promise<void>
18
+ remove(alias: string): Promise<void>
19
+ }
20
+
21
+ export class ComponentRepository {
22
+ #db: Db
23
+ #nodes: NodeRepository
24
+ #files: ComponentFileStore | undefined
25
+
26
+ constructor(db: Db, files?: ComponentFileStore) {
27
+ this.#db = db
28
+ this.#nodes = new NodeRepository(db)
29
+ this.#files = files
30
+ }
31
+
32
+ get nodes(): NodeRepository {
33
+ return this.#nodes
34
+ }
35
+
36
+ async byKey(key: string): Promise<ComponentModel | undefined> {
37
+ const rows = await this.#db.query(
38
+ `SELECT n.unique_id, n.text, t.alias FROM template t
39
+ JOIN node n ON n.id = t.node_id WHERE n.unique_id = ?`,
40
+ [normaliseUuid(key)],
41
+ )
42
+ const row = rows[0]
43
+ if (!row) return undefined
44
+ const alias = String(row.alias)
45
+ return {
46
+ key: normaliseUuid(String(row.unique_id)),
47
+ name: String(row.text ?? ''),
48
+ alias,
49
+ content: (await this.#files?.read(alias)) ?? null,
50
+ }
51
+ }
52
+
53
+ async byAlias(alias: string): Promise<ComponentModel | undefined> {
54
+ const rows = await this.#db.query<{ unique_id: string }>(
55
+ 'SELECT n.unique_id FROM template t JOIN node n ON n.id = t.node_id WHERE t.alias = ?',
56
+ [alias],
57
+ )
58
+ return rows[0] ? this.byKey(String(rows[0].unique_id)) : undefined
59
+ }
60
+
61
+ async all(): Promise<ComponentModel[]> {
62
+ return (await this.list(0, 10_000)).items
63
+ }
64
+
65
+ async list(skip: number, take: number): Promise<Page<ComponentModel>> {
66
+ const page = await this.#nodes.children(SystemNodes.Root, ObjectTypes.Template, skip, take)
67
+ const items: ComponentModel[] = []
68
+ for (const node of page.items) {
69
+ const template = await this.byKey(node.key)
70
+ if (template) items.push(template)
71
+ }
72
+ return { total: page.total, items }
73
+ }
74
+
75
+ async save(model: ComponentModel, userId?: number): Promise<ComponentModel> {
76
+ const saved = await this.#db.transaction(async (tx) => {
77
+ const repo = new ComponentRepository(tx, this.#files)
78
+ const existing = await repo.nodes.byKey(model.key)
79
+ if (existing) {
80
+ await repo.nodes.rename(existing.id, model.name)
81
+ await tx.exec('UPDATE template SET alias = ? WHERE node_id = ?', [model.alias, existing.id])
82
+ } else {
83
+ const node = await repo.nodes.create({
84
+ key: model.key,
85
+ parentId: SystemNodes.Root,
86
+ objectType: ObjectTypes.Template,
87
+ text: model.name,
88
+ userId,
89
+ })
90
+ await tx.exec('INSERT INTO template (node_id, alias) VALUES (?, ?)', [node.id, model.alias])
91
+ }
92
+ return model
93
+ })
94
+ if (model.content !== null && model.content !== undefined) {
95
+ await this.#files?.write(model.alias, model.content)
96
+ }
97
+ const result = await this.byKey(saved.key)
98
+ if (!result) throw new Error('Template could not be read back after saving.')
99
+ return result
100
+ }
101
+
102
+ async delete(key: string): Promise<boolean> {
103
+ const template = await this.byKey(key)
104
+ const node = await this.#nodes.byKey(key)
105
+ if (!template || !node) return false
106
+ await this.#db.transaction(async (tx) => {
107
+ await tx.exec('DELETE FROM content_type_template WHERE template_node_id = ?', [node.id])
108
+ await tx.exec('DELETE FROM template WHERE node_id = ?', [node.id])
109
+ await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
110
+ })
111
+ await this.#files?.remove(template.alias)
112
+ return true
113
+ }
114
+ }