@crombolo/server 0.0.0-stage → 0.10.0

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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,76 @@
1
+ /** The dictionary port over the repository, with `.udt` import and export. */
2
+ import type { DictionaryPort } from '@crombolo/api-management'
3
+ import { type Db, DictionaryRepository } from '@crombolo/data'
4
+ import { importDictionaryUdt } from '../dictionary-transfer.ts'
5
+ import type { MediaFileStore } from '../media-files.ts'
6
+ import { readUdt, type UdtItem, writeUdt } from '../udt.ts'
7
+
8
+ export function createDictionaryPort(
9
+ db: Db,
10
+ files: MediaFileStore,
11
+ options: { onChange?: () => void } = {},
12
+ ): DictionaryPort {
13
+ const repo = new DictionaryRepository(db)
14
+ const changed = <T>(result: T): T => {
15
+ if (result === 'Success') options.onChange?.()
16
+ return result
17
+ }
18
+
19
+ async function toUdt(key: string, includeChildren: boolean): Promise<UdtItem | undefined> {
20
+ const item = await repo.get(key)
21
+ if (!item) return undefined
22
+ const children: UdtItem[] = []
23
+ if (includeChildren)
24
+ for (const child of await repo.children(item.key)) {
25
+ const nested = await toUdt(child.key, true)
26
+ if (nested) children.push(nested)
27
+ }
28
+ return { key: item.key, name: item.name, translations: item.translations, children }
29
+ }
30
+
31
+ return {
32
+ all: (filter) => repo.all(filter),
33
+ get: (key) => repo.get(key),
34
+ items: (keys) => repo.many(keys),
35
+ children: (parentKey) => repo.children(parentKey),
36
+ ancestry: (key) => repo.ancestry(key),
37
+ create: async (item) => changed(await repo.create(item)),
38
+ update: async (key, change) => changed(await repo.update(key, change)),
39
+ move: async (key, parentKey) => changed(await repo.move(key, parentKey)),
40
+ delete: async (key) => changed(await repo.delete(key)),
41
+
42
+ async export(key, includeChildren) {
43
+ const item = await toUdt(key, includeChildren)
44
+ if (!item) return undefined
45
+ return { fileName: `${item.name}.udt`, content: writeUdt(item) }
46
+ },
47
+
48
+ async import(temporaryFileId, parentKey) {
49
+ const upload = await files.readTemporary(temporaryFileId)
50
+ if (!upload)
51
+ return { ok: false, status: 'NotFound', reason: 'The uploaded file has expired.' }
52
+ if (!upload.fileName.toLowerCase().endsWith('.udt'))
53
+ return { ok: false, status: 'Invalid', reason: 'Only .udt files can be imported.' }
54
+ const text = new TextDecoder().decode(upload.bytes)
55
+ // The reader is consulted first only so a malformed file and an absent
56
+ // parent keep the distinct statuses the contract names for them.
57
+ const items = readUdt(text)
58
+ if (!items)
59
+ return { ok: false, status: 'Invalid', reason: 'The file is not a dictionary export.' }
60
+ if (parentKey && !(await repo.get(parentKey)))
61
+ return { ok: false, status: 'ParentNotFound', reason: 'The parent item does not exist.' }
62
+
63
+ // The same implementation `crombolo dictionary import` uses.
64
+ const outcome = await importDictionaryUdt(db, text, parentKey ?? null)
65
+ await files.deleteTemporary(temporaryFileId)
66
+ if (!outcome.ok)
67
+ return {
68
+ ok: false,
69
+ status: 'Invalid',
70
+ reason: 'No item in the file could be imported: its name is already in use.',
71
+ }
72
+ options.onChange?.()
73
+ return { ok: true, key: items[0]?.key as string }
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,637 @@
1
+ /** Adapters binding the document repository to the API port and the renderer. */
2
+ import type {
3
+ DocumentPort,
4
+ PortFailure,
5
+ Principal,
6
+ SaveDocument,
7
+ SkipTake,
8
+ } from '@crombolo/api-management'
9
+ import {
10
+ type DocumentAggregate,
11
+ normaliseUuid,
12
+ ObjectTypes,
13
+ type Page,
14
+ type TreeItem,
15
+ toPublishedValue,
16
+ } from '@crombolo/core'
17
+ import {
18
+ appendCacheInstruction,
19
+ ContentTypeRepository,
20
+ type Db,
21
+ DictionaryRepository,
22
+ DocumentRepository,
23
+ type DocumentRepositoryOptions,
24
+ DomainRepository,
25
+ LanguageRepository,
26
+ NOTIFIABLE_ACTIONS,
27
+ type NodeRow,
28
+ NotificationRepository,
29
+ PublishBlockedError,
30
+ RedirectRepository,
31
+ WriteRejectedError,
32
+ } from '@crombolo/data'
33
+ import type { PublishedCache, PublishedContentSource, PublishedNode } from '@crombolo/render'
34
+ import {
35
+ DOMAINS_FILE,
36
+ nodeReferenceFor,
37
+ readDomainsFile,
38
+ replaceNodeDeclarations,
39
+ writeDomains,
40
+ } from '../domains.ts'
41
+ import { TemporaryFileMissingError } from '../file-intake.ts'
42
+ import { NO_REDIRECT_TRACKING, type RedirectTracker } from '../redirects.ts'
43
+
44
+ async function toTreeItems(
45
+ repo: DocumentRepository,
46
+ rows: readonly NodeRow[],
47
+ ): Promise<TreeItem[]> {
48
+ const counts = await repo.nodes.childCounts(
49
+ rows.map((row) => row.id),
50
+ ObjectTypes.Document,
51
+ )
52
+ const items: TreeItem[] = []
53
+ for (const row of rows) {
54
+ const parent = row.parentId > 0 ? await repo.nodes.byId(row.parentId) : undefined
55
+ const aggregate = await repo.load(row)
56
+ items.push({
57
+ key: row.key,
58
+ name: row.text ?? '',
59
+ hasChildren: (counts.get(row.id) ?? 0) > 0,
60
+ parentKey: parent && parent.objectType === ObjectTypes.Document ? parent.key : null,
61
+ icon: aggregate?.contentTypeIcon ?? 'icon-document',
62
+ isFolder: false,
63
+ })
64
+ }
65
+ return items
66
+ }
67
+
68
+ export function createDocumentPort(
69
+ db: Db,
70
+ cache: PublishedCache,
71
+ options: DocumentRepositoryOptions = {},
72
+ /** Without one, a rename or a move records no redirect. */
73
+ tracker: RedirectTracker = NO_REDIRECT_TRACKING,
74
+ /** The site's own directory: where `domains.toml` is, when the site keeps one. */
75
+ siteDir?: string,
76
+ ): DocumentPort {
77
+ const repo = new DocumentRepository(db, options)
78
+ const domains = new DomainRepository(db)
79
+ const notifications = new NotificationRepository(db)
80
+
81
+ /** The signed-in user's row id, which versions record as their author. */
82
+ const userIdOf = async (principal: Principal | undefined): Promise<number | undefined> => {
83
+ if (!principal) return undefined
84
+ const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
85
+ principal.id,
86
+ ])
87
+ return rows[0] ? Number(rows[0].id) : undefined
88
+ }
89
+
90
+ /** A node behind the database's schema state may read but not write: 409. */
91
+ const failure = (error: unknown) => {
92
+ if (error instanceof WriteRejectedError)
93
+ return { ok: false as const, reason: error.message, status: 409 }
94
+ if (error instanceof PublishBlockedError) return { ok: false as const, reason: error.message }
95
+ if (error instanceof TemporaryFileMissingError)
96
+ return { ok: false as const, reason: error.message, status: 400 }
97
+ throw error
98
+ }
99
+
100
+ /** Publishing changes what is reachable, so the cache must be dropped. */
101
+ const invalidate = () => cache.invalidate()
102
+
103
+ const validationFailure = (errors: NonNullable<PortFailure['errors']>): PortFailure => ({
104
+ ok: false,
105
+ reason: 'One or more properties did not pass validation',
106
+ status: 400,
107
+ errors,
108
+ })
109
+
110
+ return {
111
+ byKey: (key) => repo.byKey(key),
112
+ byKeyPublished: (key) => repo.byKeyPublished(key),
113
+ validate: (input, cultures) => repo.validate(input, cultures),
114
+
115
+ async createAndPublish(input: SaveDocument, cultures: string[] | null, principal: Principal) {
116
+ try {
117
+ const userId = await userIdOf(principal)
118
+ const result = await repo.createAndPublish({ ...input, userId }, cultures)
119
+ if (!result.ok) return validationFailure(result.errors)
120
+ invalidate()
121
+ return { ok: true as const, key: result.document.key }
122
+ } catch (error) {
123
+ return failure(error)
124
+ }
125
+ },
126
+
127
+ async updateAndPublish(
128
+ key: string,
129
+ input: SaveDocument,
130
+ cultures: string[] | null,
131
+ principal: Principal,
132
+ ) {
133
+ try {
134
+ const userId = await userIdOf(principal)
135
+ const before = await tracker.capture(key)
136
+ const result = await repo.updateAndPublish({ ...input, key, userId }, cultures)
137
+ if (!result) return { ok: false as const, reason: 'The document could not be found.' }
138
+ if (!result.ok) return validationFailure(result.errors)
139
+ invalidate()
140
+ await tracker.commit(before)
141
+ return { ok: true as const }
142
+ } catch (error) {
143
+ return failure(error)
144
+ }
145
+ },
146
+
147
+ async treeRoot(paging: SkipTake) {
148
+ const page = await repo.children(null, paging.skip, paging.take)
149
+ return { total: page.total, items: await repo.treeItems(page.items) }
150
+ },
151
+ async treeChildren(parentKey: string, paging: SkipTake) {
152
+ const page = await repo.children(parentKey, paging.skip, paging.take)
153
+ return { total: page.total, items: await repo.treeItems(page.items) }
154
+ },
155
+ treeAncestors: async (descendantKey: string) =>
156
+ repo.treeItems(
157
+ (await repo.nodes.ancestors(descendantKey)).filter(
158
+ (row) => row.objectType === ObjectTypes.Document,
159
+ ),
160
+ ),
161
+ async documentItems(keys: readonly string[]) {
162
+ const rows = await Promise.all(keys.map((key) => repo.nodes.byKey(key)))
163
+ return repo.treeItems(
164
+ rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.Document),
165
+ )
166
+ },
167
+ async treeSiblings(target: string, before: number, after: number) {
168
+ const window = await repo.siblings(target, before, after)
169
+ if (!window) return undefined
170
+ return { ...window, items: await repo.treeItems(window.items) }
171
+ },
172
+ async search(query, options) {
173
+ const page = await repo.search(query, options)
174
+ return { total: page.total, items: await repo.treeItems(page.items) }
175
+ },
176
+ async recycleBin(parentKey: string | null, paging: SkipTake) {
177
+ const page = await repo.trashed(parentKey, paging.skip, paging.take)
178
+ return { total: page.total, items: await repo.treeItems(page.items) }
179
+ },
180
+ originalParent: (key) => repo.originalParent(key),
181
+ async recycleBinSiblings(target: string, before: number, after: number) {
182
+ const window = await repo.siblings(target, before, after)
183
+ if (!window) return undefined
184
+ return { ...window, items: await repo.treeItems(window.items) }
185
+ },
186
+
187
+ async create(input: SaveDocument, principal: Principal) {
188
+ try {
189
+ const created = await repo.create({ ...input, userId: await userIdOf(principal) })
190
+ return { ok: true as const, key: created.key }
191
+ } catch (error) {
192
+ return failure(error)
193
+ }
194
+ },
195
+
196
+ async update(key: string, input: SaveDocument, principal: Principal) {
197
+ try {
198
+ const updated = await repo.update({ ...input, key, userId: await userIdOf(principal) })
199
+ if (!updated) return { ok: false as const, reason: 'The document could not be found.' }
200
+ invalidate()
201
+ return { ok: true as const }
202
+ } catch (error) {
203
+ return failure(error)
204
+ }
205
+ },
206
+
207
+ async publish(key: string, cultures: string[] | null) {
208
+ try {
209
+ const before = await tracker.capture(key)
210
+ const published = await repo.publish(key, cultures)
211
+ if (!published) return { ok: false as const, reason: 'The document could not be found.' }
212
+ invalidate()
213
+ await tracker.commit(before)
214
+ return { ok: true as const }
215
+ } catch (error) {
216
+ return failure(error)
217
+ }
218
+ },
219
+
220
+ async unpublish(key: string, cultures: string[] | null) {
221
+ try {
222
+ const result = await repo.unpublish(key, cultures)
223
+ if (!result) return { ok: false as const, reason: 'The document could not be found.' }
224
+ invalidate()
225
+ return { ok: true as const }
226
+ } catch (error) {
227
+ return failure(error)
228
+ }
229
+ },
230
+
231
+ async moveToRecycleBin(key: string) {
232
+ const moved = await repo.moveToRecycleBin(key)
233
+ if (moved) invalidate()
234
+ return moved
235
+ },
236
+
237
+ async remove(key: string) {
238
+ const removed = await repo.delete(key)
239
+ if (removed) invalidate()
240
+ return removed
241
+ },
242
+
243
+ async move(key, targetKey) {
244
+ try {
245
+ const before = await tracker.capture(key)
246
+ const result = await repo.move(key, targetKey)
247
+ if (result === 'notFound')
248
+ return {
249
+ ok: false as const,
250
+ reason: 'The document or target could not be found.',
251
+ status: 404,
252
+ }
253
+ if (result === 'invalid')
254
+ return {
255
+ ok: false as const,
256
+ reason: 'A document cannot be moved below itself or into the recycle bin.',
257
+ status: 400,
258
+ }
259
+ invalidate()
260
+ await tracker.commit(before)
261
+ return { ok: true as const }
262
+ } catch (error) {
263
+ return failure(error)
264
+ }
265
+ },
266
+
267
+ async copy(key, targetKey, options, principal) {
268
+ try {
269
+ const copied = await repo.copy(key, targetKey, {
270
+ includeDescendants: options.includeDescendants,
271
+ userId: await userIdOf(principal),
272
+ })
273
+ if (!copied)
274
+ return {
275
+ ok: false as const,
276
+ reason: 'The document or target could not be found.',
277
+ status: 404,
278
+ }
279
+ return { ok: true as const, key: copied }
280
+ } catch (error) {
281
+ return failure(error)
282
+ }
283
+ },
284
+
285
+ async sort(parentKey, sorting) {
286
+ try {
287
+ const before = await tracker.captureChildren(parentKey)
288
+ const result = await repo.sort(parentKey, sorting)
289
+ if (result === 'notFound')
290
+ return {
291
+ ok: false as const,
292
+ reason: 'The parent, or one of the items, could not be found under it.',
293
+ status: 404,
294
+ }
295
+ invalidate()
296
+ await tracker.commit(before)
297
+ return { ok: true as const }
298
+ } catch (error) {
299
+ return failure(error)
300
+ }
301
+ },
302
+
303
+ async sortChildrenBy(parentKey, field, direction) {
304
+ try {
305
+ const before = await tracker.captureChildren(parentKey)
306
+ const result = await repo.sortChildrenBy(parentKey, field, direction)
307
+ if (result === 'notFound')
308
+ return { ok: false as const, reason: 'The document could not be found.', status: 404 }
309
+ invalidate()
310
+ await tracker.commit(before)
311
+ return { ok: true as const }
312
+ } catch (error) {
313
+ return failure(error)
314
+ }
315
+ },
316
+
317
+ async restore(key, targetKey) {
318
+ try {
319
+ const result = await repo.restore(key, targetKey)
320
+ if (result === 'notFound')
321
+ return {
322
+ ok: false as const,
323
+ reason: 'The document is not in the recycle bin.',
324
+ status: 404,
325
+ }
326
+ if (result === 'invalid')
327
+ return {
328
+ ok: false as const,
329
+ reason: 'The restore target does not exist or is in the recycle bin.',
330
+ status: 400,
331
+ }
332
+ invalidate()
333
+ return { ok: true as const }
334
+ } catch (error) {
335
+ return failure(error)
336
+ }
337
+ },
338
+
339
+ async emptyRecycleBin() {
340
+ for (const node of await repo.trashedRoots()) await repo.delete(node.key)
341
+ invalidate()
342
+ },
343
+
344
+ async publishBranch(key, cultures, includeUnpublished) {
345
+ try {
346
+ const before = await tracker.capture(key)
347
+ const result = await repo.publishBranch(key, cultures, includeUnpublished)
348
+ if (!result)
349
+ return { ok: false as const, reason: 'The document could not be found.', status: 404 }
350
+ invalidate()
351
+ await tracker.commit(before)
352
+ return { ok: true as const, ...result }
353
+ } catch (error) {
354
+ return failure(error)
355
+ }
356
+ },
357
+
358
+ atVersion: (versionId) => repo.atVersion(versionId),
359
+ schedule: (key, entries) => repo.setSchedule(key, entries),
360
+
361
+ async domains(key) {
362
+ const node = await repo.nodes.byKey(key)
363
+ if (!node || node.objectType !== repo.objectType) return undefined
364
+ return domains.forNode(node.id)
365
+ },
366
+ async setDomains(key, assignment) {
367
+ const node = await repo.nodes.byKey(key)
368
+ if (!node || node.objectType !== repo.objectType) return { status: 'notFound' as const }
369
+ const result = await domains.setForNode(node.id, assignment)
370
+ if (result.status !== 'ok') return result
371
+ await appendCacheInstruction(db, {
372
+ kind: 'content',
373
+ payload: { key: node.key },
374
+ by: options.nodeId,
375
+ })
376
+ invalidate()
377
+
378
+ // A site that keeps `domains.toml` keeps it in the file: the next boot
379
+ // converges onto it, so a change saved only here would be undone. Only
380
+ // this page's entries are rewritten, so `${VAR}` in the others survives.
381
+ const declarations = siteDir ? readDomainsFile(siteDir) : undefined
382
+ if (!declarations) return result
383
+ await writeDomains(
384
+ siteDir as string,
385
+ replaceNodeDeclarations(
386
+ declarations.declarations,
387
+ await nodeReferenceFor(db, declarations.declarations, node),
388
+ assignment.domains.map((domain) => ({
389
+ host: domain.domainName,
390
+ culture: domain.isoCode || undefined,
391
+ })),
392
+ assignment.defaultIsoCode ?? undefined,
393
+ ),
394
+ )
395
+ return { status: 'ok' as const, file: DOMAINS_FILE }
396
+ },
397
+
398
+ async notifications(key, principal) {
399
+ const node = await repo.nodes.byKey(key)
400
+ const userId = await userIdOf(principal)
401
+ if (!node || node.objectType !== repo.objectType || userId === undefined) return undefined
402
+ const subscribed = new Set(await notifications.subscribed(userId, node.id))
403
+ return NOTIFIABLE_ACTIONS.map((action) => ({
404
+ ...action,
405
+ subscribed: subscribed.has(action.actionId),
406
+ }))
407
+ },
408
+ async setNotifications(key, principal, actionIds) {
409
+ const node = await repo.nodes.byKey(key)
410
+ const userId = await userIdOf(principal)
411
+ if (!node || node.objectType !== repo.objectType || userId === undefined) return false
412
+ await notifications.set(userId, node.id, actionIds)
413
+ return true
414
+ },
415
+
416
+ versions: (key) => repo.versions(key),
417
+ async systemUserKey() {
418
+ const rows = await db.query<{ key: string }>(
419
+ 'SELECT key FROM user_account ORDER BY id LIMIT 1',
420
+ )
421
+ return rows[0] ? normaliseUuid(String(rows[0].key)) : null
422
+ },
423
+
424
+ async rollback(versionId: string) {
425
+ const result = await repo.rollback(versionId)
426
+ if (result) invalidate()
427
+ return result !== undefined
428
+ },
429
+
430
+ setPreventCleanup: (versionId, prevent) => repo.setPreventCleanup(versionId, prevent),
431
+
432
+ // An unpublished document, or culture, has no URL, which the editor renders as such.
433
+ urls: (key: string) => cache.urls(key),
434
+
435
+ async root(paging: SkipTake): Promise<Page<TreeItem>> {
436
+ const page = await repo.children(null, paging.skip, paging.take)
437
+ return { total: page.total, items: await toTreeItems(repo, page.items) }
438
+ },
439
+
440
+ async children(parentKey: string, paging: SkipTake): Promise<Page<TreeItem>> {
441
+ const page = await repo.children(parentKey, paging.skip, paging.take)
442
+ return { total: page.total, items: await toTreeItems(repo, page.items) }
443
+ },
444
+
445
+ async ancestors(descendantKey: string): Promise<TreeItem[]> {
446
+ return toTreeItems(repo, await repo.nodes.ancestors(descendantKey))
447
+ },
448
+
449
+ async items(keys: readonly string[]): Promise<TreeItem[]> {
450
+ return toTreeItems(repo, await repo.nodes.byKeys(keys))
451
+ },
452
+
453
+ async collection(parentKey: string | null, paging: SkipTake): Promise<Page<DocumentAggregate>> {
454
+ const page = await repo.children(parentKey, paging.skip, paging.take)
455
+ const items: DocumentAggregate[] = []
456
+ for (const row of page.items) {
457
+ const aggregate = await repo.load(row)
458
+ if (aggregate) items.push(aggregate)
459
+ }
460
+ return { total: page.total, items }
461
+ },
462
+ }
463
+ }
464
+
465
+ export function createPublishedContentSource(
466
+ db: Db,
467
+ options: DocumentRepositoryOptions = {},
468
+ ): PublishedContentSource {
469
+ const repo = new DocumentRepository(db, options)
470
+ return {
471
+ async loadPublished(): Promise<PublishedNode[]> {
472
+ const rows = await repo.loadPublished()
473
+ return rows.map((row) => ({
474
+ key: row.key,
475
+ id: row.id,
476
+ parentId: row.parentId,
477
+ level: row.level,
478
+ path: row.path,
479
+ sortOrder: row.sortOrder,
480
+ name: row.name,
481
+ contentTypeAlias: row.contentTypeAlias,
482
+ componentAlias: row.componentAlias,
483
+ createDate: row.createDate,
484
+ updateDate: row.updateDate,
485
+ cultures: row.cultures,
486
+ names: row.names,
487
+ properties: row.values.map((value) => ({
488
+ alias: value.alias,
489
+ editorAlias: value.editorAlias ?? '',
490
+ culture: value.culture,
491
+ segment: value.segment,
492
+ value: toPublishedValue(value.editorAlias, value.value),
493
+ config: value.config,
494
+ })),
495
+ }))
496
+ },
497
+ async loadMedia() {
498
+ const media = new DocumentRepository(db, { ...options, kind: 'media' })
499
+ const rows = await db.query<{ unique_id: string }>(
500
+ 'SELECT unique_id FROM node WHERE node_object_type = ? AND trashed = ?',
501
+ [ObjectTypes.Media, db.dialect.boolValue(false)],
502
+ )
503
+ const nodes = []
504
+ for (const row of rows) {
505
+ const item = await media.byKey(normaliseUuid(String(row.unique_id)))
506
+ if (!item) continue
507
+ nodes.push({
508
+ key: item.key,
509
+ id: (await media.nodes.byKey(item.key))?.id ?? 0,
510
+ name: item.variants[0]?.name ?? '',
511
+ mediaTypeAlias: item.contentTypeAlias,
512
+ properties: item.values.map((value) => ({
513
+ alias: value.alias,
514
+ editorAlias: value.editorAlias ?? '',
515
+ culture: value.culture,
516
+ segment: value.segment,
517
+ value: toPublishedValue(value.editorAlias, value.value),
518
+ config: value.config,
519
+ })),
520
+ })
521
+ }
522
+ return nodes
523
+ },
524
+ async loadLanguages() {
525
+ return (await new LanguageRepository(db).all()).map((l) => ({
526
+ isoCode: l.isoCode,
527
+ isDefault: l.isDefault,
528
+ fallbackIsoCode: l.fallbackIsoCode,
529
+ }))
530
+ },
531
+ loadDictionary: () => new DictionaryRepository(db).translations(),
532
+ async loadDomains() {
533
+ return (await new DomainRepository(db).all()).map((row) => ({
534
+ nodeId: row.nodeId,
535
+ domainName: row.domainName,
536
+ isoCode: row.isoCode,
537
+ isWildcard: row.isWildcard,
538
+ }))
539
+ },
540
+ /**
541
+ * Published library elements. `loadPublished` is keyed on the repository's own
542
+ * object type, so the same query that builds the content cache builds this one
543
+ * with `kind: 'element'`.
544
+ */
545
+ async loadElements() {
546
+ const elements = new DocumentRepository(db, { ...options, kind: 'element' })
547
+ const types = new ContentTypeRepository(db)
548
+ const keyOfType = new Map<string, string>()
549
+ const rows = await elements.loadPublished()
550
+ const out = []
551
+ for (const row of rows) {
552
+ if (!keyOfType.has(row.contentTypeAlias))
553
+ keyOfType.set(
554
+ row.contentTypeAlias,
555
+ (await types.byAlias(row.contentTypeAlias))?.key ?? '',
556
+ )
557
+ out.push({
558
+ key: row.key,
559
+ contentTypeKey: keyOfType.get(row.contentTypeAlias) as string,
560
+ contentTypeAlias: row.contentTypeAlias,
561
+ cultures: row.cultures,
562
+ properties: row.values.map((value) => ({
563
+ alias: value.alias,
564
+ editorAlias: value.editorAlias ?? '',
565
+ culture: value.culture,
566
+ segment: value.segment,
567
+ value: toPublishedValue(value.editorAlias, value.value),
568
+ config: value.config,
569
+ })),
570
+ })
571
+ }
572
+ return out
573
+ },
574
+ loadContentTypeAliases: () => new ContentTypeRepository(db).aliasesByKey(),
575
+
576
+ async loadRedirects() {
577
+ return (await new RedirectRepository(db).all()).map((row) => ({
578
+ key: row.key,
579
+ matchKind: row.matchKind,
580
+ pattern: row.pattern,
581
+ rootKey: row.rootKey,
582
+ culture: row.culture,
583
+ targetKind: row.targetKind,
584
+ target: row.target,
585
+ statusCode: row.statusCode,
586
+ sortOrder: row.sortOrder,
587
+ source: row.source,
588
+ }))
589
+ },
590
+ }
591
+ }
592
+
593
+ /** A document's current draft in the shape the renderer takes, for preview. */
594
+ export async function loadDraftNode(
595
+ db: Db,
596
+ key: string,
597
+ options: DocumentRepositoryOptions = {},
598
+ ): Promise<PublishedNode | undefined> {
599
+ const repo = new DocumentRepository(db, options)
600
+ const node = await repo.nodes.byKey(key)
601
+ if (!node || node.objectType !== repo.objectType) return undefined
602
+ const draft = await repo.load(node)
603
+ if (!draft) return undefined
604
+ const template = draft.componentKey ? await repo.nodes.byKey(draft.componentKey) : undefined
605
+ const componentAlias = template
606
+ ? ((
607
+ await db.query<{ alias: string }>('SELECT alias FROM template WHERE node_id = ?', [
608
+ template.id,
609
+ ])
610
+ )[0]?.alias ?? null)
611
+ : null
612
+ const invariant = draft.variants.find((v) => v.culture === null) ?? draft.variants[0]
613
+ return {
614
+ key: node.key,
615
+ id: node.id,
616
+ parentId: node.parentId,
617
+ level: node.level,
618
+ path: node.path,
619
+ sortOrder: node.sortOrder,
620
+ name: invariant?.name ?? node.text ?? '',
621
+ contentTypeAlias: draft.contentTypeAlias,
622
+ componentAlias: componentAlias ? String(componentAlias) : null,
623
+ createDate: node.createDate ?? new Date(),
624
+ updateDate: invariant?.updateDate ?? new Date(),
625
+ cultures: draft.variants.flatMap((v) => (v.culture ? [v.culture] : [])),
626
+ names: Object.fromEntries(
627
+ draft.variants.flatMap((v) => (v.culture ? [[v.culture, v.name]] : [])),
628
+ ),
629
+ properties: draft.values.map((value) => ({
630
+ alias: value.alias,
631
+ editorAlias: value.editorAlias ?? '',
632
+ culture: value.culture,
633
+ segment: value.segment,
634
+ value: toPublishedValue(value.editorAlias, value.value),
635
+ })),
636
+ }
637
+ }