@crombolo/server 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 (81) 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 +432 -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/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Bundles: the definitions, and the zip a download builds from one.
3
+ * `docs/17-bundles.md`.
4
+ *
5
+ * Nothing new is serialized here. Schema travels as the canonical TOML
6
+ * `@crombolo/schema` already writes, content and media as the bundle
7
+ * `@crombolo/transfer` already exports, files as themselves, and dictionary
8
+ * items as the `.udt` the dictionary already exports. The build is composition,
9
+ * which is why a bundle can carry everything the builder UI offers.
10
+ */
11
+ import { join } from 'node:path'
12
+ import {
13
+ type BuiltPackage,
14
+ type FileSystemPort,
15
+ type PackageDefinition,
16
+ type PackageDefinitionInput,
17
+ type PackagePort,
18
+ type PackageWriteResult,
19
+ packageFileName,
20
+ } from '@crombolo/api-management'
21
+ import { ObjectTypes, type Page } from '@crombolo/core'
22
+ import {
23
+ type Bundle,
24
+ BundleRepository,
25
+ type ComponentFileStore,
26
+ ComponentRepository,
27
+ currentSchemaState,
28
+ type Db,
29
+ DictionaryRepository,
30
+ type NodeRow,
31
+ resolveNodeRef,
32
+ } from '@crombolo/data'
33
+ import {
34
+ exportSchemaSet,
35
+ fileNameFor,
36
+ type SchemaSet,
37
+ writeContentType,
38
+ writeDataType,
39
+ writeLanguages,
40
+ writeMediaType,
41
+ writeSchemaVersion,
42
+ } from '@crombolo/schema'
43
+ import {
44
+ BUNDLE_SECTIONS,
45
+ type BundleBlob,
46
+ type BundleDependency,
47
+ type BundleFile,
48
+ type BundleKind,
49
+ type BundleNode,
50
+ type BundleSection,
51
+ type ContentSet,
52
+ exportBundle,
53
+ writeBundle,
54
+ } from '@crombolo/transfer'
55
+ import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
56
+ import { readBlobs } from '../media-files.ts'
57
+ import type { MediaStore } from '../media-store.ts'
58
+ import { writeZip, type ZipEntry } from '../zip.ts'
59
+
60
+ /** The content kinds a bundle's picks can name. */
61
+ const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
62
+
63
+ export interface PackagePortOptions {
64
+ siteName: string
65
+ nodeId: string
66
+ marketplaceUrl: string
67
+ /** Reads a template's view, which lives on disk rather than in the row. */
68
+ componentFiles: ComponentFileStore
69
+ mediaStore: MediaStore
70
+ components?: FileSystemPort
71
+ stylesheets?: FileSystemPort
72
+ scripts?: FileSystemPort
73
+ }
74
+
75
+ const definition = (row: Bundle): PackageDefinition => ({
76
+ id: row.id,
77
+ name: row.name,
78
+ contentNodeId: row.contentNodeId,
79
+ contentLoadChildNodes: row.contentLoadChildNodes,
80
+ mediaIds: row.mediaIds,
81
+ mediaLoadChildNodes: row.mediaLoadChildNodes,
82
+ elementIds: row.elementIds,
83
+ contentTypes: row.contentTypes,
84
+ mediaTypes: row.mediaTypes,
85
+ dataTypes: row.dataTypes,
86
+ // The wire format is Umbraco's, and it still has two fields. There is one
87
+ // list on this side, so it answers as `templates` and `partialViews` is
88
+ // always empty — the split is a property of the contract, not of the data.
89
+ templates: row.components,
90
+ partialViews: [],
91
+ stylesheets: row.stylesheets,
92
+ scripts: row.scripts,
93
+ languages: row.languages,
94
+ dictionaryItems: row.dictionaryItems,
95
+ })
96
+
97
+ /**
98
+ * The contract's two lists, stored as the one this side has.
99
+ *
100
+ * `templates` and `partialViews` are both components; which tree the backoffice
101
+ * picked one from is not something worth keeping.
102
+ */
103
+ const stored = <T extends PackageDefinitionInput>(input: T) => ({
104
+ ...input,
105
+ components: [...input.templates, ...input.partialViews],
106
+ })
107
+
108
+ /** Matches a selection against a type's key or its alias, since the UI sends either. */
109
+ const picks = (selected: readonly string[]) => {
110
+ const wanted = new Set(selected.map((value) => value.trim().toLowerCase()))
111
+ return (type: { key?: string; alias: string }): boolean =>
112
+ wanted.has(type.alias.toLowerCase()) ||
113
+ (type.key !== undefined && wanted.has(type.key.toLowerCase()))
114
+ }
115
+
116
+ /** The selected slice of the site's schema, as the files `schema/` would hold. */
117
+ function schemaEntries(set: SchemaSet, from: PackageDefinition): BundleFile[] {
118
+ const entries: BundleFile[] = []
119
+ const put = (path: string, text: string) =>
120
+ entries.push({ path: `${BUNDLE_SECTIONS.schema}/${path}`, text })
121
+
122
+ const contentTypes = set.contentTypes.filter(picks(from.contentTypes))
123
+ const mediaTypes = (set.mediaTypes ?? []).filter(picks(from.mediaTypes))
124
+ // Member types have no picker in the builder, and the contract's definition
125
+ // has no field for them, so a bundle never carries one.
126
+ const dataTypes = set.dataTypes.filter(picks(from.dataTypes))
127
+ const languages = set.languages.filter((language) =>
128
+ from.languages.some((pick) => pick.toLowerCase() === language.iso.toLowerCase()),
129
+ )
130
+
131
+ if (
132
+ contentTypes.length === 0 &&
133
+ mediaTypes.length === 0 &&
134
+ dataTypes.length === 0 &&
135
+ languages.length === 0
136
+ )
137
+ return entries
138
+
139
+ put('schema.toml', writeSchemaVersion(set.version))
140
+ for (const type of contentTypes)
141
+ put(join('content-types', fileNameFor(type.alias)), writeContentType(type))
142
+ for (const type of mediaTypes)
143
+ put(join('media-types', fileNameFor(type.alias)), writeMediaType(type))
144
+ for (const type of dataTypes)
145
+ put(join('data-types', fileNameFor(type.alias)), writeDataType(type))
146
+ if (languages.length > 0) put('languages.toml', writeLanguages(languages))
147
+ return entries
148
+ }
149
+
150
+ /**
151
+ * Several exports as one bundle: the nodes deduped by key, the counts summed,
152
+ * and the dependencies and selector unioned, so the manifest describes what the
153
+ * bundle actually holds rather than what one of the exports held. `writeBundle`
154
+ * recomputes the integrity hash over the files it writes.
155
+ */
156
+ function mergeContentSets(sets: readonly ContentSet[]): ContentSet | undefined {
157
+ const [first, ...rest] = sets
158
+ if (!first) return undefined
159
+ if (rest.length === 0) return first
160
+
161
+ const nodes = new Map<string, BundleNode>()
162
+ for (const set of sets) for (const node of set.nodes) nodes.set(node.key, node)
163
+
164
+ const counts: Partial<Record<BundleKind, number>> = {}
165
+ for (const node of nodes.values()) counts[node.kind] = (counts[node.kind] ?? 0) + 1
166
+
167
+ const expected = new Map<string, BundleDependency>()
168
+ const blobs = new Map<string, BundleBlob>()
169
+ const carried = new Set<string>()
170
+ const contentTypes = new Map<string, { key: string; alias: string }>()
171
+ const components = new Set<string>()
172
+ const languages = new Set<string>()
173
+ const roots: string[] = []
174
+ const asGiven: string[] = []
175
+ for (const set of sets) {
176
+ for (const dependency of set.manifest.dependencies.expected)
177
+ expected.set(dependency.key, dependency)
178
+ for (const key of set.manifest.dependencies.carried) carried.add(key)
179
+ for (const type of set.manifest.dependencies.schema.contentTypes)
180
+ contentTypes.set(type.key, type)
181
+ for (const alias of set.manifest.dependencies.schema.components) components.add(alias)
182
+ for (const iso of set.manifest.dependencies.schema.languages) languages.add(iso)
183
+ for (const blob of set.manifest.blobs) blobs.set(blob.key, blob)
184
+ roots.push(...set.manifest.selector.roots)
185
+ asGiven.push(...set.manifest.selector.asGiven)
186
+ }
187
+
188
+ return {
189
+ nodes: [...nodes.values()],
190
+ manifest: {
191
+ ...first.manifest,
192
+ counts,
193
+ // True of the merged set: a group that asked for descendants is already
194
+ // expanded into its nodes, so the flag has done its work.
195
+ selector: { roots, asGiven, descendants: sets.some((s) => s.manifest.selector.descendants) },
196
+ dependencies: {
197
+ carried: [...carried],
198
+ expected: [...expected.values()],
199
+ schema: {
200
+ contentTypes: [...contentTypes.values()],
201
+ components: [...components],
202
+ languages: [...languages],
203
+ },
204
+ },
205
+ blobs: [...blobs.values()],
206
+ },
207
+ }
208
+ }
209
+
210
+ export function createPackagePort(db: Db, options: PackagePortOptions): PackagePort {
211
+ const bundles = new BundleRepository(db)
212
+
213
+ const validate = (input: PackageDefinitionInput): PackageWriteResult | undefined =>
214
+ input.name.length === 0 ? { ok: false, status: 'InvalidName' } : undefined
215
+
216
+ /**
217
+ * The files a selection of file-system paths resolves to, under one section.
218
+ *
219
+ * The section is a logical name — `styles`, not `css` — because a site holds
220
+ * these wherever it is configured to, and the importer is what maps a section
221
+ * onto a directory.
222
+ */
223
+ const fileEntries = async (
224
+ port: FileSystemPort | undefined,
225
+ paths: readonly string[],
226
+ section: BundleSection,
227
+ ): Promise<BundleFile[]> => {
228
+ if (!port) return []
229
+ const files: BundleFile[] = []
230
+ for (const path of paths) {
231
+ const file = await port.read(path)
232
+ if (!file) continue
233
+ const relative = file.path.replace(/^\/+/, '')
234
+ files.push({ path: `${BUNDLE_SECTIONS[section]}/${relative}`, text: file.content })
235
+ }
236
+ return files
237
+ }
238
+
239
+ const componentEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
240
+ if (keys.length === 0) return []
241
+ const templates = new ComponentRepository(db, options.componentFiles)
242
+ const all = await templates.all()
243
+ const wanted = new Set(keys.map((key) => key.trim().toLowerCase()))
244
+ const files: BundleFile[] = []
245
+ for (const template of all) {
246
+ if (!wanted.has(template.key.toLowerCase()) && !wanted.has(template.alias.toLowerCase()))
247
+ continue
248
+ files.push({
249
+ path: `${BUNDLE_SECTIONS.components}/${template.alias}.tsx`,
250
+ text: template.content ?? '',
251
+ })
252
+ }
253
+ return files
254
+ }
255
+
256
+ /**
257
+ * The components a bundle carries, from one list that holds two kinds of
258
+ * name.
259
+ *
260
+ * The contract has `templates` and `partialViews`; this side has one list, so
261
+ * a picked component arrives either as a template alias — a row in
262
+ * `template`, whose file is read through the alias — or as a path in the
263
+ * components tree. Both are tried and the results are keyed by path, so a
264
+ * component picked both ways is carried once.
265
+ */
266
+ const componentSection = async (picked: readonly string[]): Promise<BundleFile[]> => {
267
+ const byPath = new Map<string, BundleFile>()
268
+ for (const file of await componentEntries(picked)) byPath.set(file.path, file)
269
+ for (const file of await fileEntries(options.components, picked, 'components'))
270
+ byPath.set(file.path, file)
271
+ return [...byPath.values()]
272
+ }
273
+
274
+ const dictionaryEntries = async (keys: readonly string[]): Promise<BundleFile[]> => {
275
+ if (keys.length === 0) return []
276
+ const repo = new DictionaryRepository(db)
277
+ const items = []
278
+ for (const key of keys) items.push(...(await dictionaryToUdt(repo, key)))
279
+ if (items.length === 0) return []
280
+ return [{ path: `${BUNDLE_SECTIONS.dictionary}.udt`, text: writeDictionaryUdt(items) }]
281
+ }
282
+
283
+ /**
284
+ * The content, media and element picks as one transfer bundle, blobs included.
285
+ *
286
+ * Three groups, not one: the contract carries `contentLoadChildNodes` and
287
+ * `mediaLoadChildNodes` as separate booleans, and elements have no such flag
288
+ * at all. `descendants` is per export, so a definition whose two flags
289
+ * disagree needs an export each — otherwise picking a media folder's children
290
+ * would quietly drag a content page's children in too. The sets are merged
291
+ * back into one bundle, which is what the destination reads.
292
+ */
293
+ const contentEntries = async (
294
+ from: PackageDefinition,
295
+ ): Promise<{ set: ContentSet | undefined; blobs: ReadonlyMap<string, Uint8Array> }> => {
296
+ const groups: Array<{ refs: string[]; descendants: boolean }> = [
297
+ {
298
+ refs: from.contentNodeId ? [from.contentNodeId] : [],
299
+ descendants: from.contentLoadChildNodes,
300
+ },
301
+ { refs: from.mediaIds, descendants: from.mediaLoadChildNodes },
302
+ { refs: from.elementIds ?? [], descendants: false },
303
+ ]
304
+
305
+ const sets: ContentSet[] = []
306
+ const blobKeys = new Set<string>()
307
+ for (const group of groups) {
308
+ if (group.refs.length === 0) continue
309
+ const roots: NodeRow[] = []
310
+ for (const ref of group.refs) {
311
+ const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, ref)
312
+ // A pick that has since been deleted is dropped rather than failing the
313
+ // download: the definition outlives the content it names.
314
+ if (resolved.ok) roots.push(resolved.node)
315
+ }
316
+ if (roots.length === 0) continue
317
+ const exported = await exportBundle(db, {
318
+ roots,
319
+ asGiven: group.refs,
320
+ descendants: group.descendants,
321
+ snapshot: 'published',
322
+ blueprints: false,
323
+ withBlobs: true,
324
+ siteName: options.siteName,
325
+ nodeId: options.nodeId,
326
+ })
327
+ sets.push(exported.set)
328
+ for (const key of exported.blobKeys) blobKeys.add(key)
329
+ }
330
+
331
+ const merged = mergeContentSets(sets)
332
+ if (!merged) return { set: undefined, blobs: new Map() }
333
+
334
+ const { bytes } =
335
+ blobKeys.size > 0
336
+ ? await readBlobs(options.mediaStore, [...blobKeys])
337
+ : { bytes: new Map<string, Uint8Array>() }
338
+ return { set: merged, blobs: bytes }
339
+ }
340
+
341
+ return {
342
+ marketplaceUrl: () => options.marketplaceUrl,
343
+
344
+ async list(paging): Promise<Page<PackageDefinition>> {
345
+ const result = await bundles.list(paging)
346
+ return { total: result.total, items: result.items.map(definition) }
347
+ },
348
+
349
+ async byId(id) {
350
+ const row = await bundles.byId(id)
351
+ return row ? definition(row) : undefined
352
+ },
353
+
354
+ async create(input, id) {
355
+ const invalid = validate(input)
356
+ if (invalid) return invalid
357
+ if (await bundles.byName(input.name)) return { ok: false, status: 'DuplicateName' }
358
+ const row = await bundles.create(stored(input), id)
359
+ return { ok: true, id: row.id }
360
+ },
361
+
362
+ async update(id, input) {
363
+ const invalid = validate(input)
364
+ if (invalid) return invalid
365
+ if (!(await bundles.byId(id))) return { ok: false, status: 'NotFound' }
366
+ const clash = await bundles.byName(input.name)
367
+ if (clash && clash.id !== id) return { ok: false, status: 'DuplicateName' }
368
+ await bundles.update(id, stored(input))
369
+ return { ok: true, id }
370
+ },
371
+
372
+ async remove(id) {
373
+ if (!(await bundles.byId(id))) return 'notFound'
374
+ await bundles.delete(id)
375
+ return 'deleted'
376
+ },
377
+
378
+ /**
379
+ * The definition as one bundle, zipped.
380
+ *
381
+ * Everything goes through `writeBundle` so there is a single manifest over
382
+ * the whole artifact: it declares the sections, carries the hash that covers
383
+ * them, and names the package — a zip of loose directories would have no
384
+ * record of what it was or whether it arrived whole.
385
+ */
386
+ async build(id): Promise<BuiltPackage | undefined> {
387
+ const row = await bundles.byId(id)
388
+ if (!row) return undefined
389
+ const from = definition(row)
390
+
391
+ // The schema version the site is actually at, so the files a package
392
+ // carries say where they came from rather than claiming version 0.
393
+ const state = await currentSchemaState(db).catch(() => undefined)
394
+ const schema = await exportSchemaSet(db, state?.version ?? '0')
395
+
396
+ const sections: BundleFile[] = [
397
+ ...schemaEntries(schema, from),
398
+ ...(await componentSection([...from.templates, ...from.partialViews])),
399
+ ...(await fileEntries(options.stylesheets, from.stylesheets, 'styles')),
400
+ ...(await fileEntries(options.scripts, from.scripts, 'scripts')),
401
+ ...(await dictionaryEntries(from.dictionaryItems)),
402
+ ]
403
+
404
+ const content = await contentEntries(from)
405
+ // A package with nothing picked is still a bundle: an empty selection
406
+ // produces an empty set rather than throwing, which is what gives a
407
+ // schema-only package a manifest of its own.
408
+ const set =
409
+ content.set ??
410
+ (
411
+ await exportBundle(db, {
412
+ roots: [],
413
+ asGiven: [],
414
+ descendants: false,
415
+ snapshot: 'published',
416
+ blueprints: false,
417
+ withBlobs: false,
418
+ siteName: options.siteName,
419
+ nodeId: options.nodeId,
420
+ })
421
+ ).set
422
+
423
+ const labelled: ContentSet = { ...set, manifest: { ...set.manifest, label: from.name } }
424
+ const encoder = new TextEncoder()
425
+ const entries: ZipEntry[] = writeBundle(labelled, content.blobs, sections).map((file) => ({
426
+ path: file.path,
427
+ bytes: file.bytes ?? encoder.encode(file.text ?? ''),
428
+ }))
429
+ return { fileName: packageFileName(from.name), bytes: writeZip(entries) }
430
+ },
431
+ }
432
+ }