@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,256 @@
1
+ /**
2
+ * Content types as Umbraco's `.udt` XML, the format its Export and Import
3
+ * buttons exchange — so a type exported from an Umbraco site imports here and
4
+ * the other way round.
5
+ *
6
+ * The element names are Umbraco's `EntityXmlSerializer`: `Info`, `Structure`,
7
+ * `GenericProperties` and `Tabs`, with the root naming the kind. Aliases are
8
+ * the currency throughout — a composition, an allowed child and a template are
9
+ * all referenced by alias, because keys differ between sites.
10
+ */
11
+ import type { ContentTypeAggregate } from '@crombolo/core'
12
+ import type { ContentTypeKind } from '@crombolo/data'
13
+ import { escapeText, parseXml, type XmlElement } from './udt.ts'
14
+
15
+ const ROOT = {
16
+ document: 'DocumentType',
17
+ media: 'MediaType',
18
+ member: 'MemberType',
19
+ } as const satisfies Record<ContentTypeKind, string>
20
+
21
+ const KIND_OF: Record<string, ContentTypeKind> = {
22
+ DocumentType: 'document',
23
+ MediaType: 'media',
24
+ MemberType: 'member',
25
+ }
26
+
27
+ /** Umbraco writes .NET's `True`/`False` and its `ContentVariation` names. */
28
+ const bool = (value: boolean) => (value ? 'True' : 'False')
29
+
30
+ function variations(aggregate: { variesByCulture: boolean; variesBySegment: boolean }): string {
31
+ if (aggregate.variesByCulture && aggregate.variesBySegment) return 'CultureAndSegment'
32
+ if (aggregate.variesByCulture) return 'Culture'
33
+ if (aggregate.variesBySegment) return 'Segment'
34
+ return 'Nothing'
35
+ }
36
+
37
+ const tag = (name: string, value: string | null | undefined, indent: string) =>
38
+ value === null || value === undefined ? '' : `${indent}<${name}>${escapeText(value)}</${name}>\n`
39
+
40
+ export interface UdtContext {
41
+ /** Alias of a content type by key, for compositions and allowed children. */
42
+ aliasOf(key: string): string | undefined
43
+ /** Alias of a template by key, for the allowed and default templates. */
44
+ templateAliasOf(key: string): string | undefined
45
+ }
46
+
47
+ export function writeContentTypeUdt(
48
+ aggregate: ContentTypeAggregate,
49
+ kind: ContentTypeKind,
50
+ context: UdtContext,
51
+ ): string {
52
+ const root = ROOT[kind]
53
+ const groupOf = new Map(aggregate.containers.map((container) => [container.key, container]))
54
+
55
+ let info = ''
56
+ info += tag('Name', aggregate.name, ' ')
57
+ info += tag('Alias', aggregate.alias, ' ')
58
+ info += tag('Key', aggregate.key, ' ')
59
+ info += tag('Icon', aggregate.icon, ' ')
60
+ info += tag('Thumbnail', '', ' ')
61
+ info += tag('Description', aggregate.description ?? '', ' ')
62
+ info += tag('AllowAtRoot', bool(aggregate.allowedAsRoot), ' ')
63
+ info += tag('IsListView', bool(aggregate.collectionKey !== null), ' ')
64
+ info += tag('IsElement', bool(aggregate.isElement), ' ')
65
+ info += tag('AllowedInLibrary', bool(aggregate.allowedInLibrary), ' ')
66
+ info += tag('Variations', variations(aggregate), ' ')
67
+ if (kind === 'document') {
68
+ const templates = aggregate.allowedComponentKeys
69
+ .map((key) => context.templateAliasOf(key))
70
+ .filter((alias): alias is string => !!alias)
71
+ info += ` <AllowedTemplates>\n${templates.map((a) => tag('Template', a, ' ')).join('')} </AllowedTemplates>\n`
72
+ info += tag(
73
+ 'DefaultTemplate',
74
+ (aggregate.defaultComponentKey && context.templateAliasOf(aggregate.defaultComponentKey)) ||
75
+ '',
76
+ ' ',
77
+ )
78
+ }
79
+
80
+ const compositions = aggregate.compositions
81
+ .map((composition) => context.aliasOf(composition.contentTypeKey))
82
+ .filter((alias): alias is string => !!alias)
83
+ const structure = aggregate.allowedContentTypes
84
+ .map((allowed) => context.aliasOf(allowed.contentTypeKey))
85
+ .filter((alias): alias is string => !!alias)
86
+
87
+ const properties = [...aggregate.properties]
88
+ .sort((a, b) => a.sortOrder - b.sortOrder)
89
+ .map((property) => {
90
+ const group = property.containerKey ? groupOf.get(property.containerKey) : undefined
91
+ let body = ''
92
+ body += tag('Name', property.name, ' ')
93
+ body += tag('Alias', property.alias, ' ')
94
+ body += tag('Key', property.key, ' ')
95
+ body += tag('Definition', property.dataTypeKey, ' ')
96
+ if (group)
97
+ body += ` <Tab Alias="${escapeText(group.alias ?? '')}">${escapeText(group.name ?? '')}</Tab>\n`
98
+ body += tag('SortOrder', String(property.sortOrder), ' ')
99
+ body += tag('Mandatory', bool(property.mandatory), ' ')
100
+ body += tag('LabelOnTop', bool(property.labelOnTop), ' ')
101
+ if (property.mandatoryMessage)
102
+ body += tag('MandatoryMessage', property.mandatoryMessage, ' ')
103
+ if (property.regEx) body += tag('Validation', property.regEx, ' ')
104
+ if (property.regExMessage)
105
+ body += tag('ValidationRegExpMessage', property.regExMessage, ' ')
106
+ if (property.description) body += tag('Description', property.description, ' ')
107
+ body += tag('Variations', variations(property), ' ')
108
+ return ` <GenericProperty>\n${body} </GenericProperty>\n`
109
+ })
110
+ .join('')
111
+
112
+ const tabs = [...aggregate.containers]
113
+ .sort((a, b) => a.sortOrder - b.sortOrder)
114
+ .map((container) => {
115
+ let body = ''
116
+ body += tag('Key', container.key, ' ')
117
+ body += tag('Type', container.type, ' ')
118
+ body += tag('Caption', container.name ?? '', ' ')
119
+ body += tag('Alias', container.alias ?? '', ' ')
120
+ body += tag('SortOrder', String(container.sortOrder), ' ')
121
+ return ` <Tab>\n${body} </Tab>\n`
122
+ })
123
+ .join('')
124
+
125
+ return `<?xml version="1.0" encoding="utf-8"?>
126
+ <${root}>
127
+ <Info>
128
+ ${info} </Info>
129
+ <Compositions>
130
+ ${compositions.map((a) => tag('Composition', a, ' ')).join('')} </Compositions>
131
+ <Structure>
132
+ ${structure.map((a) => tag(root, a, ' ')).join('')} </Structure>
133
+ <GenericProperties>
134
+ ${properties} </GenericProperties>
135
+ <Tabs>
136
+ ${tabs} </Tabs>
137
+ </${root}>
138
+ `
139
+ }
140
+
141
+ const child = (element: XmlElement | undefined, name: string): XmlElement | undefined =>
142
+ element?.children.find((c) => c.name.toLowerCase() === name.toLowerCase())
143
+
144
+ const childText = (element: XmlElement | undefined, name: string): string | undefined => {
145
+ if (!element) return undefined
146
+ const found = child(element, name)
147
+ return found ? found.text.trim() : undefined
148
+ }
149
+
150
+ const childrenNamed = (element: XmlElement | undefined, name: string): XmlElement[] =>
151
+ element ? element.children.filter((c) => c.name.toLowerCase() === name.toLowerCase()) : []
152
+
153
+ export interface UdtContentType {
154
+ kind: ContentTypeKind
155
+ key: string | undefined
156
+ alias: string
157
+ name: string
158
+ icon: string | null
159
+ description: string | null
160
+ allowedAsRoot: boolean
161
+ isElement: boolean
162
+ allowedInLibrary: boolean
163
+ variesByCulture: boolean
164
+ variesBySegment: boolean
165
+ compositionAliases: string[]
166
+ allowedAliases: string[]
167
+ componentAliases: string[]
168
+ defaultTemplateAlias: string | null
169
+ containers: Array<{ key?: string; name: string; alias: string; type: string; sortOrder: number }>
170
+ properties: Array<{
171
+ key?: string
172
+ alias: string
173
+ name: string
174
+ dataTypeKey: string | undefined
175
+ tabAlias: string | null
176
+ sortOrder: number
177
+ mandatory: boolean
178
+ labelOnTop: boolean
179
+ mandatoryMessage: string | null
180
+ regEx: string | null
181
+ regExMessage: string | null
182
+ description: string | null
183
+ variesByCulture: boolean
184
+ variesBySegment: boolean
185
+ }>
186
+ }
187
+
188
+ const isTrue = (value: string | undefined) => (value ?? '').toLowerCase() === 'true'
189
+
190
+ /** Reads a `.udt` document; undefined when it is not one we understand. */
191
+ export function readContentTypeUdt(source: string): UdtContentType | undefined {
192
+ const document = parseXml(source)
193
+ // parseXml yields the root element itself; a document wrapper only appears
194
+ // when something precedes it.
195
+ const root =
196
+ document && KIND_OF[document.name] ? document : document?.children.find((c) => KIND_OF[c.name])
197
+ if (!root) return undefined
198
+ const kind = KIND_OF[root.name] as ContentTypeKind
199
+ const info = child(root, 'Info')
200
+ const alias = childText(info, 'Alias')
201
+ const name = childText(info, 'Name')
202
+ if (!alias || !name) return undefined
203
+ const variation = (element: XmlElement | undefined) => {
204
+ const value = (childText(element, 'Variations') ?? 'Nothing').toLowerCase()
205
+ return {
206
+ variesByCulture: value.includes('culture'),
207
+ variesBySegment: value.includes('segment'),
208
+ }
209
+ }
210
+ return {
211
+ kind,
212
+ key: childText(info, 'Key') || undefined,
213
+ alias,
214
+ name,
215
+ icon: childText(info, 'Icon') || null,
216
+ description: childText(info, 'Description') || null,
217
+ allowedAsRoot: isTrue(childText(info, 'AllowAtRoot')),
218
+ isElement: isTrue(childText(info, 'IsElement')),
219
+ allowedInLibrary: isTrue(childText(info, 'AllowedInLibrary')),
220
+ ...variation(info),
221
+ compositionAliases: childrenNamed(child(root, 'Compositions'), 'Composition')
222
+ .map((c) => c.text.trim())
223
+ .filter(Boolean),
224
+ allowedAliases: childrenNamed(child(root, 'Structure'), ROOT[kind])
225
+ .map((c) => c.text.trim())
226
+ .filter(Boolean),
227
+ componentAliases: childrenNamed(child(info, 'AllowedTemplates'), 'Template')
228
+ .map((c) => c.text.trim())
229
+ .filter(Boolean),
230
+ defaultTemplateAlias: childText(info, 'DefaultTemplate') || null,
231
+ containers: childrenNamed(child(root, 'Tabs'), 'Tab').map((tab) => ({
232
+ key: childText(tab, 'Key') || undefined,
233
+ name: childText(tab, 'Caption') ?? '',
234
+ alias: childText(tab, 'Alias') ?? '',
235
+ type: childText(tab, 'Type') ?? 'Tab',
236
+ sortOrder: Number(childText(tab, 'SortOrder') ?? 0),
237
+ })),
238
+ properties: childrenNamed(child(root, 'GenericProperties'), 'GenericProperty').map(
239
+ (property) => ({
240
+ key: childText(property, 'Key') || undefined,
241
+ alias: childText(property, 'Alias') ?? '',
242
+ name: childText(property, 'Name') ?? '',
243
+ dataTypeKey: childText(property, 'Definition') || undefined,
244
+ tabAlias: child(property, 'Tab')?.attributes.Alias ?? null,
245
+ sortOrder: Number(childText(property, 'SortOrder') ?? 0),
246
+ mandatory: isTrue(childText(property, 'Mandatory')),
247
+ labelOnTop: isTrue(childText(property, 'LabelOnTop')),
248
+ mandatoryMessage: childText(property, 'MandatoryMessage') || null,
249
+ regEx: childText(property, 'Validation') || null,
250
+ regExMessage: childText(property, 'ValidationRegExpMessage') || null,
251
+ description: childText(property, 'Description') || null,
252
+ ...variation(property),
253
+ }),
254
+ ),
255
+ }
256
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Database bootstrap: connect, migrate, seed.
3
+ *
4
+ * Migration and seeding run at startup rather than through the install wizard;
5
+ * the wizard is story 080. Both are idempotent, so restarting is safe.
6
+ */
7
+ import { hashPassword, passwordConfigJson } from '@crombolo/auth'
8
+ import {
9
+ connect,
10
+ cromboloPlan,
11
+ type Db,
12
+ ensureBuiltInDataTypes,
13
+ ensureSystemContentTypes,
14
+ ensureSystemMediaTypes,
15
+ ensureSystemMemberTypes,
16
+ INITIAL_STATE,
17
+ migrate,
18
+ pendingMigrations,
19
+ readState,
20
+ seedContent,
21
+ seedIdentity,
22
+ } from '@crombolo/data'
23
+ import { type BundleChain, migrateBundles, pendingBundleSteps } from './bundle-tables.ts'
24
+ import type { CromboloConfig } from './config.ts'
25
+
26
+ /** Production never auto-upgrades: an existing database with steps pending refuses to boot. */
27
+ export class UpgradePendingError extends Error {
28
+ constructor(
29
+ readonly steps: string[],
30
+ /** Each bundle with steps pending, and those steps. */
31
+ readonly bundles: { id: string; steps: string[] }[] = [],
32
+ ) {
33
+ const parts: string[] = []
34
+ if (steps.length > 0)
35
+ parts.push(
36
+ `${steps.length} framework migration(s) pending on this database: ${steps.join(', ')}.`,
37
+ )
38
+ for (const bundle of bundles)
39
+ parts.push(
40
+ `The bundle "${bundle.id}" has ${bundle.steps.length} step(s) pending on this database: ${bundle.steps.join(', ')}.`,
41
+ )
42
+ super(
43
+ `${parts.join(' ')} ` +
44
+ 'Production does not upgrade at boot; run `crombolo upgrade check`, then `crombolo upgrade`.',
45
+ )
46
+ }
47
+ }
48
+
49
+ export interface Bootstrap {
50
+ db: Db
51
+ /** Present only when this run created the admin account. */
52
+ seededAdminPassword: string | undefined
53
+ }
54
+
55
+ /** A `web` node boots against a database it does not own; `api` has to have been there first. */
56
+ export class DatabaseNotReadyError extends Error {
57
+ constructor(readonly steps: string[]) {
58
+ super(
59
+ `This node runs as 'web' and does not migrate, but the database has ${steps.length} ` +
60
+ 'migration(s) pending. Start the `api` node first, or run `crombolo upgrade`.',
61
+ )
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Connects without migrating or seeding, for a node whose role is to read.
67
+ *
68
+ * The refusal matters more than the connection: a read-only node that quietly
69
+ * migrated would race the node that owns the schema, and one that quietly
70
+ * served an unmigrated database would answer queries against missing tables.
71
+ */
72
+ export async function attachDatabase(config: CromboloConfig): Promise<Bootstrap> {
73
+ const db = await connect({ file: config.sqliteFile })
74
+ const pending = await pendingMigrations(db, cromboloPlan)
75
+ if (pending.length > 0) {
76
+ await db.close()
77
+ throw new DatabaseNotReadyError(pending.map((m) => m.name))
78
+ }
79
+ return { db, seededAdminPassword: undefined }
80
+ }
81
+
82
+ /**
83
+ * `chains` are the wired bundles' tables, which only a node that mounts bundles
84
+ * passes: a bundle's steps pending in production refuse the boot just as
85
+ * core's do, and a database ahead of a bundle's code refuses it by name.
86
+ */
87
+ export async function bootstrapDatabase(
88
+ config: CromboloConfig,
89
+ chains: readonly BundleChain[] = [],
90
+ ): Promise<Bootstrap> {
91
+ const db = await connect({ file: config.sqliteFile })
92
+ try {
93
+ // pendingMigrations throws when the database is newer than this code.
94
+ const pending = await pendingMigrations(db, cromboloPlan)
95
+ const fresh = (await readState(db)) === INITIAL_STATE
96
+ const bundles = fresh ? [] : await pendingBundleSteps(db, chains)
97
+ const bundlesPending = bundles.filter((bundle) => bundle.pending.length > 0)
98
+ if ((pending.length > 0 || bundlesPending.length > 0) && !fresh && !config.development)
99
+ throw new UpgradePendingError(
100
+ pending.map((m) => m.name),
101
+ bundlesPending.map((bundle) => ({
102
+ id: bundle.id,
103
+ steps: bundle.pending.map((m) => m.name),
104
+ })),
105
+ )
106
+ } catch (error) {
107
+ await db.close()
108
+ throw error
109
+ }
110
+ return { db, seededAdminPassword: await installDatabase(db, config, chains) }
111
+ }
112
+
113
+ /**
114
+ * Migrates and seeds — idempotent. Boot calls it after the production check
115
+ * above; the upgrade CLI calls it directly, since applying the framework's
116
+ * steps is exactly its job. Returns the admin password when this run created
117
+ * the account and none was configured.
118
+ */
119
+ export async function installDatabase(
120
+ db: Db,
121
+ config: CromboloConfig,
122
+ chains: readonly BundleChain[] = [],
123
+ ): Promise<string | undefined> {
124
+ await migrate(db, cromboloPlan, { by: config.nodeId })
125
+ // Core first, so a bundle step always runs against a core it declared itself compatible with.
126
+ await migrateBundles(db, chains, { by: config.nodeId })
127
+
128
+ const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
129
+ const seeded = await seedIdentity(db, {
130
+ admin: {
131
+ name: 'Administrator',
132
+ login: config.adminLogin,
133
+ email: config.adminLogin,
134
+ password,
135
+ },
136
+ hashPassword,
137
+ passwordConfig: passwordConfigJson(),
138
+ })
139
+
140
+ await seedContent(db)
141
+ // A database from an older version gains the built-ins it lacks.
142
+ await ensureBuiltInDataTypes(db)
143
+ await ensureSystemMediaTypes(db)
144
+ await ensureSystemMemberTypes(db)
145
+ await ensureSystemContentTypes(db)
146
+
147
+ return seeded && !config.adminPassword ? password : undefined
148
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Runtime diagnostics for a live node: a trace of every request, and a regular
3
+ * report of the heap, memory and in-flight work. Off unless configured; the dev
4
+ * stack turns both on (compose.yaml).
5
+ *
6
+ * The report counts in-flight requests twice, and the difference is the point.
7
+ * Bun's `pendingRequests` holds a request until its response has been written;
8
+ * ours releases it when the handler returns a `Response`. Bun's count staying
9
+ * high while ours is idle means responses are being returned and never finished
10
+ * — a stall in the transport, not in any handler.
11
+ */
12
+ import { heapStats } from 'bun:jsc'
13
+ import { logger } from './logging.ts'
14
+
15
+ export interface DiagnosticsOptions {
16
+ /** Log every request as it arrives and as its handler returns. */
17
+ traceRequests: boolean
18
+ /** Seconds between reports; 0 turns them off. */
19
+ heapIntervalSeconds: number
20
+ /** A handler still running after this long is named in the report. */
21
+ slowRequestMs?: number
22
+ }
23
+
24
+ /** The part of Bun's server the report reads. */
25
+ export interface ServerCounts {
26
+ readonly pendingRequests: number
27
+ readonly pendingWebSockets: number
28
+ }
29
+
30
+ export interface DiagnosticsSample {
31
+ /** What the JS heap has reserved. */
32
+ heapMb: number
33
+ /** What was live at the last collection, so it can read low just after one. */
34
+ liveMb: number
35
+ /** Memory held outside the heap by heap objects: buffers, file bodies. */
36
+ externalMb: number
37
+ rssMb: number
38
+ /** Requests Bun has not finished, response bodies included. */
39
+ pendingInBun: number | undefined
40
+ pendingWebSockets: number | undefined
41
+ /** Requests whose handler has not yet returned. */
42
+ inHandlers: number
43
+ slow: { method: string; path: string; ms: number }[]
44
+ }
45
+
46
+ type Fetch<S> = (request: Request, server: S) => Promise<Response | undefined>
47
+
48
+ export interface Diagnostics {
49
+ /** `fetch`, traced and counted. Returns it unchanged when tracing and reports are both off. */
50
+ wrap<S extends ServerCounts>(fetch: Fetch<S>): Fetch<S>
51
+ sample(): DiagnosticsSample
52
+ stop(): void
53
+ }
54
+
55
+ const mb = (bytes: number) => Math.round((bytes / 1024 / 1024) * 10) / 10
56
+
57
+ export function createDiagnostics(options: DiagnosticsOptions): Diagnostics {
58
+ const http = logger('http')
59
+ const log = logger('diagnostics')
60
+ const slowMs = options.slowRequestMs ?? 5_000
61
+ const inFlight = new Map<number, { method: string; path: string; started: number }>()
62
+ let next = 0
63
+ let server: ServerCounts | undefined
64
+
65
+ const sample = (): DiagnosticsSample => {
66
+ const heap = heapStats()
67
+ const now = performance.now()
68
+ return {
69
+ heapMb: mb(heap.heapCapacity),
70
+ liveMb: mb(heap.heapSize),
71
+ externalMb: mb(heap.extraMemorySize),
72
+ rssMb: mb(process.memoryUsage().rss),
73
+ pendingInBun: server?.pendingRequests,
74
+ pendingWebSockets: server?.pendingWebSockets,
75
+ inHandlers: inFlight.size,
76
+ slow: [...inFlight.values()]
77
+ .map((request) => ({
78
+ method: request.method,
79
+ path: request.path,
80
+ ms: Math.round(now - request.started),
81
+ }))
82
+ .filter((request) => request.ms >= slowMs)
83
+ .sort((a, b) => b.ms - a.ms)
84
+ .slice(0, 10),
85
+ }
86
+ }
87
+
88
+ const timer =
89
+ options.heapIntervalSeconds > 0
90
+ ? setInterval(() => {
91
+ const s = sample()
92
+ log.info(
93
+ 'Heap {heapMb} MB ({liveMb} MB live), external {externalMb} MB, RSS {rssMb} MB; requests pending in Bun {pendingInBun}, in handlers {inHandlers}; websockets {pendingWebSockets}',
94
+ { ...s, slow: undefined },
95
+ )
96
+ for (const request of s.slow) {
97
+ log.warning('Still in its handler after {ms} ms: {method} {path}', request)
98
+ }
99
+ }, options.heapIntervalSeconds * 1_000)
100
+ : undefined
101
+ timer?.unref()
102
+
103
+ return {
104
+ wrap(fetch) {
105
+ if (!options.traceRequests && !timer) return fetch
106
+ return async (request, bunServer) => {
107
+ server = bunServer
108
+ const id = next++
109
+ const url = new URL(request.url)
110
+ const path = `${url.pathname}${url.search}`
111
+ const started = performance.now()
112
+ inFlight.set(id, { method: request.method, path, started })
113
+ if (options.traceRequests) http.debug('→ {method} {path}', { method: request.method, path })
114
+ try {
115
+ const response = await fetch(request, bunServer)
116
+ if (options.traceRequests) {
117
+ http.debug('← {status} {method} {path} {ms} ms', {
118
+ status: response?.status ?? 'upgrade',
119
+ method: request.method,
120
+ path,
121
+ ms: Math.round(performance.now() - started),
122
+ })
123
+ }
124
+ return response
125
+ } catch (error) {
126
+ http.error('✗ {method} {path} threw after {ms} ms', {
127
+ method: request.method,
128
+ path,
129
+ ms: Math.round(performance.now() - started),
130
+ error,
131
+ })
132
+ throw error
133
+ } finally {
134
+ inFlight.delete(id)
135
+ }
136
+ }
137
+ },
138
+ sample,
139
+ stop() {
140
+ if (timer) clearInterval(timer)
141
+ },
142
+ }
143
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Dictionary items as a `.udt` file, for seeding one environment from another.
3
+ *
4
+ * `schema-as-code.md` leaves the dictionary in the database rather than in
5
+ * `schema/`, because translators work in production where the schema files are
6
+ * read-only — and promises this pair of commands as the way a lower environment
7
+ * gets the translations.
8
+ *
9
+ * The logic was already in the Management API adapter, tied to a temporary
10
+ * upload. It lives here so the endpoint and the CLI share one implementation:
11
+ * the same `.udt` format Umbraco uses, the same upsert by key, and the same rule
12
+ * that translations for languages this site does not have are skipped.
13
+ */
14
+ import { type Db, DictionaryRepository } from '@crombolo/data'
15
+ import { readUdt, type UdtItem, writeUdtAll } from './udt.ts'
16
+
17
+ export interface DictionaryImportOutcome {
18
+ ok: boolean
19
+ reason?: string
20
+ /** Items written, counting nested children. */
21
+ imported: number
22
+ /** Translations dropped because this environment has no such language. */
23
+ skipped: string[]
24
+ }
25
+
26
+ /**
27
+ * One item and its children as `.udt`, or the whole dictionary when no key is
28
+ * given. `children()` returns a lighter row than the item itself, so each one
29
+ * is read back for its translations.
30
+ */
31
+ export async function dictionaryToUdt(
32
+ repo: DictionaryRepository,
33
+ key?: string,
34
+ ): Promise<UdtItem[]> {
35
+ const toUdt = async (itemKey: string): Promise<UdtItem | undefined> => {
36
+ const item = await repo.get(itemKey)
37
+ if (!item) return undefined
38
+ const children: UdtItem[] = []
39
+ for (const child of await repo.children(item.key)) {
40
+ const nested = await toUdt(child.key)
41
+ if (nested) children.push(nested)
42
+ }
43
+ return { key: item.key, name: item.name, translations: item.translations, children }
44
+ }
45
+ if (key) {
46
+ const item = await toUdt(key)
47
+ return item ? [item] : []
48
+ }
49
+ const roots: UdtItem[] = []
50
+ for (const root of await repo.children(null)) {
51
+ const item = await toUdt(root.key)
52
+ if (item) roots.push(item)
53
+ }
54
+ return roots
55
+ }
56
+
57
+ export const writeDictionaryUdt = writeUdtAll
58
+
59
+ /**
60
+ * Applies a `.udt` file. Items are upserted by key, so running it twice is a
61
+ * no-op and an item translated here keeps any language the file does not carry.
62
+ */
63
+ export async function importDictionaryUdt(
64
+ db: Db,
65
+ text: string,
66
+ parentKey: string | null = null,
67
+ ): Promise<DictionaryImportOutcome> {
68
+ const repo = new DictionaryRepository(db)
69
+ const items = readUdt(text)
70
+ if (!items) return { ok: false, reason: 'not a dictionary export', imported: 0, skipped: [] }
71
+ if (parentKey && !(await repo.get(parentKey)))
72
+ return { ok: false, reason: 'the parent item does not exist', imported: 0, skipped: [] }
73
+
74
+ const languages = new Set(
75
+ (await db.query<{ iso_code: string }>('SELECT iso_code FROM language')).map((row) =>
76
+ String(row.iso_code).toLowerCase(),
77
+ ),
78
+ )
79
+ const skipped = new Set<string>()
80
+ // As Umbraco does: a translation for a language this site has not got is
81
+ // dropped rather than refused, so a partial language set still imports.
82
+ const known = (item: UdtItem) =>
83
+ item.translations.filter((t) => {
84
+ if (languages.has(t.isoCode.toLowerCase())) return true
85
+ skipped.add(t.isoCode)
86
+ return false
87
+ })
88
+
89
+ let imported = 0
90
+ const save = async (item: UdtItem, parent: string | null): Promise<void> => {
91
+ const existing = await repo.get(item.key)
92
+ const mine = known(item)
93
+ const result = existing
94
+ ? await repo.update(item.key, {
95
+ name: existing.name,
96
+ translations: [
97
+ ...existing.translations.filter(
98
+ (t) => !mine.some((n) => n.isoCode.toLowerCase() === t.isoCode.toLowerCase()),
99
+ ),
100
+ ...mine,
101
+ ],
102
+ })
103
+ : await repo.create({
104
+ key: item.key,
105
+ name: item.name,
106
+ parentKey: parent,
107
+ translations: mine,
108
+ })
109
+ if (result !== 'Success') return
110
+ imported += 1
111
+ for (const child of item.children) await save(child, item.key)
112
+ }
113
+
114
+ for (const item of items) await save(item, parentKey)
115
+ return imported > 0
116
+ ? { ok: true, imported, skipped: [...skipped] }
117
+ : {
118
+ ok: false,
119
+ reason: 'nothing in the file could be imported: a name is already in use',
120
+ imported: 0,
121
+ skipped: [...skipped],
122
+ }
123
+ }