@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,361 @@
1
+ /**
2
+ * Shared access to `node`, the universal tree.
3
+ *
4
+ * Every tree in the backoffice — documents, content types, data types, templates —
5
+ * is the same query with a different object type, so it lives here once.
6
+ */
7
+ import { normaliseUuid, ObjectTypes, type Page, SystemNodes } from '@crombolo/core'
8
+ import type { Db } from '../database.ts'
9
+ import { DbDate, fromDbBool } from '../dialect.ts'
10
+
11
+ export interface NodeRow {
12
+ id: number
13
+ key: string
14
+ parentId: number
15
+ level: number
16
+ path: string
17
+ sortOrder: number
18
+ trashed: boolean
19
+ text: string | null
20
+ objectType: string | null
21
+ createDate: Date | undefined
22
+ }
23
+
24
+ export function mapNode(row: Record<string, unknown>): NodeRow {
25
+ return {
26
+ id: Number(row.id),
27
+ key: normaliseUuid(String(row.unique_id)),
28
+ parentId: Number(row.parent_id),
29
+ level: Number(row.level),
30
+ path: String(row.path),
31
+ sortOrder: Number(row.sort_order),
32
+ trashed: fromDbBool(row.trashed),
33
+ text: (row.text as string | null) ?? null,
34
+ objectType: row.node_object_type ? normaliseUuid(String(row.node_object_type)) : null,
35
+ createDate: DbDate.fromDb(row.create_date),
36
+ }
37
+ }
38
+
39
+ const NODE_COLUMNS =
40
+ 'id, unique_id, parent_id, level, path, sort_order, trashed, text, node_object_type, create_date'
41
+
42
+ export interface CreateNodeInit {
43
+ key?: string
44
+ parentId: number
45
+ objectType: string
46
+ text: string
47
+ userId?: number
48
+ sortOrder?: number
49
+ }
50
+
51
+ export class NodeRepository {
52
+ #db: Db
53
+
54
+ constructor(db: Db) {
55
+ this.#db = db
56
+ }
57
+
58
+ async byKey(key: string): Promise<NodeRow | undefined> {
59
+ const rows = await this.#db.query(`SELECT ${NODE_COLUMNS} FROM node WHERE unique_id = ?`, [
60
+ normaliseUuid(key),
61
+ ])
62
+ return rows[0] ? mapNode(rows[0]) : undefined
63
+ }
64
+
65
+ async byId(id: number): Promise<NodeRow | undefined> {
66
+ const rows = await this.#db.query(`SELECT ${NODE_COLUMNS} FROM node WHERE id = ?`, [id])
67
+ return rows[0] ? mapNode(rows[0]) : undefined
68
+ }
69
+
70
+ async byKeys(keys: readonly string[]): Promise<NodeRow[]> {
71
+ if (keys.length === 0) return []
72
+ const placeholders = keys.map(() => '?').join(', ')
73
+ const rows = await this.#db.query(
74
+ `SELECT ${NODE_COLUMNS} FROM node WHERE unique_id IN (${placeholders})`,
75
+ keys.map(normaliseUuid),
76
+ )
77
+ return rows.map(mapNode)
78
+ }
79
+
80
+ /**
81
+ * Creates a node, computing `level` and the materialised `path` from the
82
+ * parent. The path is what makes descendant queries a prefix match.
83
+ */
84
+ async create(init: CreateNodeInit): Promise<NodeRow> {
85
+ const key = normaliseUuid(init.key ?? crypto.randomUUID())
86
+ const parent = init.parentId === SystemNodes.Root ? undefined : await this.byId(init.parentId)
87
+ const level = parent ? parent.level + 1 : 1
88
+ const sortOrder = init.sortOrder ?? (await this.#nextSortOrder(init.parentId, init.objectType))
89
+ const now = DbDate.toDb(new Date())
90
+
91
+ await this.#db.exec(
92
+ `INSERT INTO node
93
+ (unique_id, parent_id, level, path, sort_order, trashed, node_user, text, node_object_type, create_date)
94
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
95
+ [
96
+ key,
97
+ init.parentId,
98
+ level,
99
+ '',
100
+ sortOrder,
101
+ this.#db.dialect.boolValue(false),
102
+ init.userId ?? null,
103
+ init.text,
104
+ init.objectType,
105
+ now,
106
+ ],
107
+ )
108
+ const created = await this.byKey(key)
109
+ if (!created) throw new Error('Node insert did not produce a row.')
110
+
111
+ const path = parent ? `${parent.path},${created.id}` : `${SystemNodes.Root},${created.id}`
112
+ await this.#db.exec('UPDATE node SET path = ? WHERE id = ?', [path, created.id])
113
+ return { ...created, path }
114
+ }
115
+
116
+ async rename(id: number, text: string): Promise<void> {
117
+ await this.#db.exec('UPDATE node SET text = ? WHERE id = ?', [text, id])
118
+ }
119
+
120
+ async delete(id: number): Promise<void> {
121
+ await this.#db.exec('DELETE FROM node WHERE id = ?', [id])
122
+ }
123
+
124
+ /** Root-level items of a type, i.e. those parented to the system root. */
125
+ async roots(objectType: string, skip: number, take: number): Promise<Page<NodeRow>> {
126
+ return this.children(SystemNodes.Root, objectType, skip, take)
127
+ }
128
+
129
+ async children(
130
+ parentId: number,
131
+ objectType: string,
132
+ skip: number,
133
+ take: number,
134
+ trashed = false,
135
+ ): Promise<Page<NodeRow>> {
136
+ const totals = await this.#db.query<{ n: number }>(
137
+ 'SELECT COUNT(*) AS n FROM node WHERE parent_id = ? AND node_object_type = ? AND trashed = ?',
138
+ [parentId, objectType, this.#db.dialect.boolValue(trashed)],
139
+ )
140
+ const rows = await this.#db.query(
141
+ `SELECT ${NODE_COLUMNS} FROM node
142
+ WHERE parent_id = ? AND node_object_type = ? AND trashed = ?
143
+ ORDER BY sort_order, id
144
+ LIMIT ? OFFSET ?`,
145
+ [parentId, objectType, this.#db.dialect.boolValue(trashed), take, skip],
146
+ )
147
+ return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
148
+ }
149
+
150
+ /** Children of several object types at once: a settings tree with folders first, then by name. */
151
+ async childrenOf(
152
+ parentId: number,
153
+ objectTypes: readonly string[],
154
+ skip: number,
155
+ take: number,
156
+ ): Promise<Page<NodeRow>> {
157
+ if (objectTypes.length === 0) return { total: 0, items: [] }
158
+ const marks = objectTypes.map(() => '?').join(', ')
159
+ const f = this.#db.dialect.boolValue(false)
160
+ const totals = await this.#db.query<{ n: number }>(
161
+ `SELECT COUNT(*) AS n FROM node WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?`,
162
+ [parentId, ...objectTypes, f],
163
+ )
164
+ const rows = await this.#db.query(
165
+ `SELECT ${NODE_COLUMNS} FROM node
166
+ WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
167
+ ORDER BY CASE WHEN node_object_type = ? THEN 0 ELSE 1 END, text, id
168
+ LIMIT ? OFFSET ?`,
169
+ [parentId, ...objectTypes, f, objectTypes[0], take, skip],
170
+ )
171
+ return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
172
+ }
173
+
174
+ /** Re-parents a node and its descendants, keeping levels and paths true. */
175
+ async move(id: number, newParentId: number): Promise<void> {
176
+ const node = await this.byId(id)
177
+ if (!node) return
178
+ const parent = newParentId === SystemNodes.Root ? undefined : await this.byId(newParentId)
179
+ const newPath = parent ? `${parent.path},${node.id}` : `${SystemNodes.Root},${node.id}`
180
+ const newLevel = parent ? parent.level + 1 : 1
181
+ const delta = newLevel - node.level
182
+ const oldPath = node.path
183
+ await this.#db.exec('UPDATE node SET parent_id = ?, level = ?, path = ? WHERE id = ?', [
184
+ newParentId,
185
+ newLevel,
186
+ newPath,
187
+ node.id,
188
+ ])
189
+ const descendants = await this.#db.query<{ id: number; path: string; level: number }>(
190
+ 'SELECT id, path, level FROM node WHERE path LIKE ?',
191
+ [`${oldPath},%`],
192
+ )
193
+ for (const d of descendants) {
194
+ await this.#db.exec('UPDATE node SET path = ?, level = ? WHERE id = ?', [
195
+ `${newPath}${String(d.path).slice(oldPath.length)}`,
196
+ Number(d.level) + delta,
197
+ Number(d.id),
198
+ ])
199
+ }
200
+ }
201
+
202
+ /** A window of a node's siblings around it: `before` above, `after` below, the node itself included. */
203
+ async siblings(
204
+ key: string,
205
+ objectType: string | readonly string[],
206
+ before: number,
207
+ after: number,
208
+ ): Promise<{ items: NodeRow[]; totalBefore: number; totalAfter: number } | undefined> {
209
+ const target = await this.byKey(key)
210
+ if (!target) return undefined
211
+ const types = typeof objectType === 'string' ? [objectType] : [...objectType]
212
+ const marks = types.map(() => '?').join(', ')
213
+ const order =
214
+ types.length > 1
215
+ ? 'CASE WHEN node_object_type = ? THEN 0 ELSE 1 END, text, id'
216
+ : 'sort_order, id'
217
+ const all = await this.#db.query(
218
+ `SELECT ${NODE_COLUMNS} FROM node WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
219
+ ORDER BY ${order}`,
220
+ [
221
+ target.parentId,
222
+ ...types,
223
+ this.#db.dialect.boolValue(target.trashed),
224
+ ...(types.length > 1 ? [types[0]] : []),
225
+ ],
226
+ )
227
+ const rows = all.map(mapNode)
228
+ const index = rows.findIndex((row) => row.id === target.id)
229
+ const start = Math.max(0, index - before)
230
+ const end = Math.min(rows.length, index + after + 1)
231
+ return { items: rows.slice(start, end), totalBefore: start, totalAfter: rows.length - end }
232
+ }
233
+
234
+ /** Name search, case-insensitive, optionally under a parent. */
235
+ async search(
236
+ objectType: string,
237
+ query: string,
238
+ options: { trashed?: boolean; parentId?: number; skip: number; take: number },
239
+ ): Promise<Page<NodeRow>> {
240
+ const conditions = ['node_object_type = ?', 'trashed = ?', 'LOWER(text) LIKE ?']
241
+ const params: unknown[] = [
242
+ objectType,
243
+ this.#db.dialect.boolValue(options.trashed ?? false),
244
+ `%${query.toLowerCase().replaceAll('%', '').replaceAll('_', '')}%`,
245
+ ]
246
+ if (options.parentId !== undefined) {
247
+ conditions.push('parent_id = ?')
248
+ params.push(options.parentId)
249
+ }
250
+ const where = conditions.join(' AND ')
251
+ const totals = await this.#db.query<{ n: number }>(
252
+ `SELECT COUNT(*) AS n FROM node WHERE ${where}`,
253
+ params,
254
+ )
255
+ const rows = await this.#db.query(
256
+ `SELECT ${NODE_COLUMNS} FROM node WHERE ${where} ORDER BY text, id LIMIT ? OFFSET ?`,
257
+ [...params, options.take, options.skip],
258
+ )
259
+ return { total: Number(totals[0]?.n ?? 0), items: rows.map(mapNode) }
260
+ }
261
+
262
+ /** Ancestors of a node, root first, excluding the node itself. */
263
+ async ancestors(key: string): Promise<NodeRow[]> {
264
+ const node = await this.byKey(key)
265
+ if (!node) return []
266
+ const ids = node.path
267
+ .split(',')
268
+ .map((part) => Number(part))
269
+ .filter((id) => id > 0 && id !== node.id)
270
+ if (ids.length === 0) return []
271
+ const placeholders = ids.map(() => '?').join(', ')
272
+ const rows = await this.#db.query(
273
+ `SELECT ${NODE_COLUMNS} FROM node WHERE id IN (${placeholders}) ORDER BY level`,
274
+ ids,
275
+ )
276
+ return rows.map(mapNode)
277
+ }
278
+
279
+ /**
280
+ * Every descendant of a node, parents before children. The materialised
281
+ * `path` makes this one prefix query rather than a walk — the same trick
282
+ * `move` uses to re-parent a branch.
283
+ *
284
+ * Trashed nodes are excluded: a bundle of a subtree should not carry what
285
+ * somebody deleted out of it.
286
+ */
287
+ async descendants(node: NodeRow, objectTypes: readonly string[]): Promise<NodeRow[]> {
288
+ if (objectTypes.length === 0) return []
289
+ const marks = objectTypes.map(() => '?').join(', ')
290
+ const rows = await this.#db.query(
291
+ `SELECT ${NODE_COLUMNS} FROM node
292
+ WHERE path LIKE ? AND node_object_type IN (${marks}) AND trashed = ?
293
+ ORDER BY level, sort_order, id`,
294
+ [`${node.path},%`, ...objectTypes, this.#db.dialect.boolValue(false)],
295
+ )
296
+ return rows.map(mapNode)
297
+ }
298
+
299
+ /** The children of a node with a given name, for resolving a content path a segment at a time. */
300
+ async childrenNamed(
301
+ parentId: number,
302
+ objectTypes: readonly string[],
303
+ name: string,
304
+ ): Promise<NodeRow[]> {
305
+ if (objectTypes.length === 0) return []
306
+ const marks = objectTypes.map(() => '?').join(', ')
307
+ const rows = await this.#db.query(
308
+ `SELECT ${NODE_COLUMNS} FROM node
309
+ WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
310
+ AND LOWER(text) = LOWER(?)
311
+ ORDER BY sort_order, id`,
312
+ [parentId, ...objectTypes, this.#db.dialect.boolValue(false), name],
313
+ )
314
+ return rows.map(mapNode)
315
+ }
316
+
317
+ /** The names under a node, so a failed path resolution can say what was there. */
318
+ async childNames(parentId: number, objectTypes: readonly string[]): Promise<string[]> {
319
+ if (objectTypes.length === 0) return []
320
+ const marks = objectTypes.map(() => '?').join(', ')
321
+ const rows = await this.#db.query<{ text: string | null }>(
322
+ `SELECT text FROM node
323
+ WHERE parent_id = ? AND node_object_type IN (${marks}) AND trashed = ?
324
+ ORDER BY sort_order, id`,
325
+ [parentId, ...objectTypes, this.#db.dialect.boolValue(false)],
326
+ )
327
+ return rows.flatMap((row) => (row.text ? [String(row.text)] : []))
328
+ }
329
+
330
+ async hasChildren(id: number, objectType: string): Promise<boolean> {
331
+ const rows = await this.#db.query<{ n: number }>(
332
+ 'SELECT COUNT(*) AS n FROM node WHERE parent_id = ? AND node_object_type = ?',
333
+ [id, objectType],
334
+ )
335
+ return Number(rows[0]?.n ?? 0) > 0
336
+ }
337
+
338
+ /** Bulk `hasChildren`, so a tree page costs one query instead of one per row. */
339
+ async childCounts(ids: readonly number[], objectType: string): Promise<Map<number, number>> {
340
+ if (ids.length === 0) return new Map()
341
+ const placeholders = ids.map(() => '?').join(', ')
342
+ const rows = await this.#db.query<{ parent_id: number; n: number }>(
343
+ `SELECT parent_id, COUNT(*) AS n FROM node
344
+ WHERE parent_id IN (${placeholders}) AND node_object_type = ?
345
+ GROUP BY parent_id`,
346
+ [...ids, objectType],
347
+ )
348
+ return new Map(rows.map((row) => [Number(row.parent_id), Number(row.n)]))
349
+ }
350
+
351
+ async #nextSortOrder(parentId: number, objectType: string): Promise<number> {
352
+ const rows = await this.#db.query<{ next: number | null }>(
353
+ 'SELECT MAX(sort_order) AS next FROM node WHERE parent_id = ? AND node_object_type = ?',
354
+ [parentId, objectType],
355
+ )
356
+ const current = rows[0]?.next
357
+ return current === null || current === undefined ? 0 : Number(current) + 1
358
+ }
359
+ }
360
+
361
+ export { ObjectTypes, SystemNodes }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Which actions on a node a user asked to be notified of (Umbraco's
3
+ * `umbracoUser2NodeNotify`). Sending the notification e-mails needs e-mail,
4
+ * which the site does not have yet; the subscriptions are kept regardless.
5
+ */
6
+ import type { Db } from '../database.ts'
7
+
8
+ /** The actions Umbraco offers in the notifications dialog, by letter and alias. */
9
+ export const NOTIFIABLE_ACTIONS: ReadonlyArray<{ actionId: string; alias: string }> = [
10
+ { actionId: 'Umb.Document.Duplicate', alias: 'copy' },
11
+ { actionId: 'Umb.Document.Delete', alias: 'delete' },
12
+ { actionId: 'Umb.Document.Move', alias: 'move' },
13
+ { actionId: 'Umb.Document.Create', alias: 'create' },
14
+ { actionId: 'Umb.Document.PublicAccess', alias: 'protect' },
15
+ { actionId: 'Umb.Document.Publish', alias: 'publish' },
16
+ { actionId: 'Umb.DocumentRecycleBin.Restore', alias: 'restore' },
17
+ { actionId: 'Umb.Document.Permissions', alias: 'rights' },
18
+ { actionId: 'Umb.Document.Rollback', alias: 'rollback' },
19
+ { actionId: 'Umb.Document.Sort', alias: 'sort' },
20
+ { actionId: 'Umb.Document.Update', alias: 'update' },
21
+ ]
22
+
23
+ export class NotificationRepository {
24
+ #db: Db
25
+
26
+ constructor(db: Db) {
27
+ this.#db = db
28
+ }
29
+
30
+ async subscribed(userId: number, nodeId: number): Promise<string[]> {
31
+ const rows = await this.#db.query<{ action: string }>(
32
+ 'SELECT action FROM user_notification WHERE user_id = ? AND node_id = ?',
33
+ [userId, nodeId],
34
+ )
35
+ return rows.map((row) => String(row.action))
36
+ }
37
+
38
+ /** Replaces the user's subscriptions on a node; unknown action ids are ignored. */
39
+ async set(userId: number, nodeId: number, actionIds: readonly string[]): Promise<void> {
40
+ const known = new Set(NOTIFIABLE_ACTIONS.map((a) => a.actionId))
41
+ await this.#db.transaction(async (tx) => {
42
+ await tx.exec('DELETE FROM user_notification WHERE user_id = ? AND node_id = ?', [
43
+ userId,
44
+ nodeId,
45
+ ])
46
+ for (const action of new Set(actionIds))
47
+ if (known.has(action))
48
+ await tx.exec(
49
+ 'INSERT INTO user_notification (user_id, node_id, action) VALUES (?, ?, ?)',
50
+ [userId, nodeId, action],
51
+ )
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Which pages are protected, and by what.
3
+ *
4
+ * An entry protects the page it names *and everything below it*, so the render
5
+ * path asks "is anything on this node's ancestor path protected?" — which is
6
+ * `entryFor`, resolved against `node.path` rather than by walking parents.
7
+ *
8
+ * Rules are names, not keys: a member group's name or a member's username, as
9
+ * Umbraco stores them, because that is what a signed-in member carries.
10
+ */
11
+ import { normaliseUuid } from '@crombolo/core'
12
+ import type { Db } from '../database.ts'
13
+ import { DbDate } from '../dialect.ts'
14
+ import { NodeRepository } from './nodes.ts'
15
+
16
+ export const MEMBER_GROUP_RULE = 'MemberRole'
17
+ export const MEMBER_USERNAME_RULE = 'MemberUsername'
18
+
19
+ export interface PublicAccessEntry {
20
+ key: string
21
+ /** The protected page. */
22
+ nodeKey: string
23
+ loginNodeKey: string
24
+ errorNodeKey: string
25
+ memberGroupNames: string[]
26
+ memberUserNames: string[]
27
+ }
28
+
29
+ export interface PublicAccessInput {
30
+ nodeKey: string
31
+ loginNodeKey: string
32
+ errorNodeKey: string
33
+ memberGroupNames: readonly string[]
34
+ memberUserNames: readonly string[]
35
+ }
36
+
37
+ interface EntryRow {
38
+ id: number
39
+ key: string
40
+ node_key: string
41
+ login_key: string
42
+ error_key: string
43
+ }
44
+
45
+ const SELECT = `SELECT a.id, a.key,
46
+ n.unique_id AS node_key, l.unique_id AS login_key, e.unique_id AS error_key
47
+ FROM public_access a
48
+ JOIN node n ON n.id = a.node_id
49
+ JOIN node l ON l.id = a.login_node_id
50
+ JOIN node e ON e.id = a.error_node_id`
51
+
52
+ export class PublicAccessRepository {
53
+ #db: Db
54
+ #nodes: NodeRepository
55
+
56
+ constructor(db: Db) {
57
+ this.#db = db
58
+ this.#nodes = new NodeRepository(db)
59
+ }
60
+
61
+ async #rules(id: number): Promise<{ groups: string[]; users: string[] }> {
62
+ const rows = await this.#db.query<{ rule_type: string; rule_value: string }>(
63
+ 'SELECT rule_type, rule_value FROM public_access_rule WHERE public_access_id = ? ORDER BY rule_value',
64
+ [id],
65
+ )
66
+ return {
67
+ groups: rows
68
+ .filter((r) => r.rule_type === MEMBER_GROUP_RULE)
69
+ .map((r) => String(r.rule_value)),
70
+ users: rows
71
+ .filter((r) => r.rule_type === MEMBER_USERNAME_RULE)
72
+ .map((r) => String(r.rule_value)),
73
+ }
74
+ }
75
+
76
+ async #hydrate(row: EntryRow): Promise<PublicAccessEntry> {
77
+ const { groups, users } = await this.#rules(Number(row.id))
78
+ return {
79
+ key: normaliseUuid(String(row.key)),
80
+ nodeKey: normaliseUuid(String(row.node_key)),
81
+ loginNodeKey: normaliseUuid(String(row.login_key)),
82
+ errorNodeKey: normaliseUuid(String(row.error_key)),
83
+ memberGroupNames: groups,
84
+ memberUserNames: users,
85
+ }
86
+ }
87
+
88
+ /** The entry protecting this page itself, if any. */
89
+ async byNodeKey(nodeKey: string): Promise<PublicAccessEntry | undefined> {
90
+ const rows = await this.#db.query<EntryRow>(`${SELECT} WHERE n.unique_id = ?`, [
91
+ normaliseUuid(nodeKey),
92
+ ])
93
+ return rows[0] ? this.#hydrate(rows[0]) : undefined
94
+ }
95
+
96
+ /**
97
+ * The entry that governs a page: its own, else the nearest protected
98
+ * ancestor's. `undefined` means the page is open to everyone.
99
+ */
100
+ async entryFor(nodeKey: string): Promise<PublicAccessEntry | undefined> {
101
+ const node = await this.#nodes.byKey(nodeKey)
102
+ if (!node) return undefined
103
+ // `path` is "-1,1050,1063": the ids from the root down. The nearest
104
+ // protected ancestor governs, so the deepest match wins.
105
+ const ids = node.path
106
+ .split(',')
107
+ .map((part) => Number(part))
108
+ .filter((id) => Number.isFinite(id))
109
+ if (ids.length === 0) return undefined
110
+ const holes = ids.map(() => '?').join(', ')
111
+ const rows = await this.#db.query<EntryRow>(
112
+ `${SELECT} WHERE a.node_id IN (${holes}) ORDER BY n.level DESC LIMIT 1`,
113
+ [...ids],
114
+ )
115
+ return rows[0] ? this.#hydrate(rows[0]) : undefined
116
+ }
117
+
118
+ /** Whether an ancestor above this page is protected, which the editor shows. */
119
+ async isProtectedByAncestor(nodeKey: string): Promise<boolean> {
120
+ const entry = await this.entryFor(nodeKey)
121
+ return entry !== undefined && entry.nodeKey !== normaliseUuid(nodeKey)
122
+ }
123
+
124
+ /**
125
+ * Creates or replaces the entry for a page. `undefined` means one of the
126
+ * three nodes could not be found.
127
+ */
128
+ async save(input: PublicAccessInput): Promise<PublicAccessEntry | undefined> {
129
+ const node = await this.#nodes.byKey(input.nodeKey)
130
+ const login = await this.#nodes.byKey(input.loginNodeKey)
131
+ const error = await this.#nodes.byKey(input.errorNodeKey)
132
+ if (!node || !login || !error) return undefined
133
+ const now = DbDate.toDb(new Date())
134
+ await this.#db.transaction(async (tx) => {
135
+ const existing = await tx.query<{ id: number }>(
136
+ 'SELECT id FROM public_access WHERE node_id = ?',
137
+ [node.id],
138
+ )
139
+ let id = existing[0] ? Number(existing[0].id) : undefined
140
+ if (id === undefined) {
141
+ const key = crypto.randomUUID()
142
+ await tx.exec(
143
+ `INSERT INTO public_access (key, node_id, login_node_id, error_node_id, create_date, update_date)
144
+ VALUES (?, ?, ?, ?, ?, ?)`,
145
+ [key, node.id, login.id, error.id, now, now],
146
+ )
147
+ const created = await tx.query<{ id: number }>(
148
+ 'SELECT id FROM public_access WHERE node_id = ?',
149
+ [node.id],
150
+ )
151
+ id = Number(created[0]?.id)
152
+ } else {
153
+ await tx.exec(
154
+ 'UPDATE public_access SET login_node_id = ?, error_node_id = ?, update_date = ? WHERE id = ?',
155
+ [login.id, error.id, now, id],
156
+ )
157
+ await tx.exec('DELETE FROM public_access_rule WHERE public_access_id = ?', [id])
158
+ }
159
+ for (const [type, names] of [
160
+ [MEMBER_GROUP_RULE, input.memberGroupNames],
161
+ [MEMBER_USERNAME_RULE, input.memberUserNames],
162
+ ] as const) {
163
+ for (const name of new Set(names.filter((value) => value.trim() !== ''))) {
164
+ await tx.exec(
165
+ 'INSERT INTO public_access_rule (public_access_id, rule_type, rule_value, create_date) VALUES (?, ?, ?, ?)',
166
+ [id, type, name, now],
167
+ )
168
+ }
169
+ }
170
+ })
171
+ return this.byNodeKey(input.nodeKey)
172
+ }
173
+
174
+ async remove(nodeKey: string): Promise<boolean> {
175
+ const node = await this.#nodes.byKey(nodeKey)
176
+ if (!node) return false
177
+ const rows = await this.#db.query<{ id: number }>(
178
+ 'SELECT id FROM public_access WHERE node_id = ?',
179
+ [node.id],
180
+ )
181
+ const id = rows[0]?.id
182
+ if (id === undefined) return false
183
+ await this.#db.exec('DELETE FROM public_access_rule WHERE public_access_id = ?', [id])
184
+ await this.#db.exec('DELETE FROM public_access WHERE id = ?', [id])
185
+ return true
186
+ }
187
+ }