@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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
|
+
}
|
package/src/database.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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 Phase 6. 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 { CromboloConfig } from './config.ts'
|
|
24
|
+
|
|
25
|
+
/** Production never auto-upgrades: an existing database with steps pending refuses to boot. */
|
|
26
|
+
export class UpgradePendingError extends Error {
|
|
27
|
+
constructor(readonly steps: string[]) {
|
|
28
|
+
super(
|
|
29
|
+
`${steps.length} framework migration(s) pending on this database: ${steps.join(', ')}. ` +
|
|
30
|
+
'Production does not upgrade at boot; run `crombolo upgrade check`, then `crombolo upgrade`.',
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Bootstrap {
|
|
36
|
+
db: Db
|
|
37
|
+
/** Present only when this run created the admin account. */
|
|
38
|
+
seededAdminPassword: string | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A `web` node boots against a database it does not own; `api` has to have been there first. */
|
|
42
|
+
export class DatabaseNotReadyError extends Error {
|
|
43
|
+
constructor(readonly steps: string[]) {
|
|
44
|
+
super(
|
|
45
|
+
`This node runs as 'web' and does not migrate, but the database has ${steps.length} ` +
|
|
46
|
+
'migration(s) pending. Start the `api` node first, or run `crombolo upgrade`.',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Connects without migrating or seeding, for a node whose role is to read.
|
|
53
|
+
*
|
|
54
|
+
* The refusal matters more than the connection: a read-only node that quietly
|
|
55
|
+
* migrated would race the node that owns the schema, and one that quietly
|
|
56
|
+
* served an unmigrated database would answer queries against missing tables.
|
|
57
|
+
*/
|
|
58
|
+
export async function attachDatabase(config: CromboloConfig): Promise<Bootstrap> {
|
|
59
|
+
const db = await connect({ file: config.sqliteFile })
|
|
60
|
+
const pending = await pendingMigrations(db, cromboloPlan)
|
|
61
|
+
if (pending.length > 0) {
|
|
62
|
+
await db.close()
|
|
63
|
+
throw new DatabaseNotReadyError(pending.map((m) => m.name))
|
|
64
|
+
}
|
|
65
|
+
return { db, seededAdminPassword: undefined }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function bootstrapDatabase(config: CromboloConfig): Promise<Bootstrap> {
|
|
69
|
+
const db = await connect({ file: config.sqliteFile })
|
|
70
|
+
// pendingMigrations throws when the database is newer than this code.
|
|
71
|
+
const pending = await pendingMigrations(db, cromboloPlan)
|
|
72
|
+
const fresh = (await readState(db)) === INITIAL_STATE
|
|
73
|
+
if (pending.length > 0 && !fresh && !config.development) {
|
|
74
|
+
await db.close()
|
|
75
|
+
throw new UpgradePendingError(pending.map((m) => m.name))
|
|
76
|
+
}
|
|
77
|
+
return { db, seededAdminPassword: await installDatabase(db, config) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Migrates and seeds — idempotent. Boot calls it after the production check
|
|
82
|
+
* above; the upgrade CLI calls it directly, since applying the framework's
|
|
83
|
+
* steps is exactly its job. Returns the admin password when this run created
|
|
84
|
+
* the account and none was configured.
|
|
85
|
+
*/
|
|
86
|
+
export async function installDatabase(db: Db, config: CromboloConfig): Promise<string | undefined> {
|
|
87
|
+
await migrate(db, cromboloPlan, { by: config.nodeId })
|
|
88
|
+
|
|
89
|
+
const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
|
|
90
|
+
const seeded = await seedIdentity(db, {
|
|
91
|
+
admin: {
|
|
92
|
+
name: 'Administrator',
|
|
93
|
+
login: config.adminLogin,
|
|
94
|
+
email: config.adminLogin,
|
|
95
|
+
password,
|
|
96
|
+
},
|
|
97
|
+
hashPassword,
|
|
98
|
+
passwordConfig: passwordConfigJson(),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await seedContent(db)
|
|
102
|
+
// A database from an older version gains the built-ins it lacks.
|
|
103
|
+
await ensureBuiltInDataTypes(db)
|
|
104
|
+
await ensureSystemMediaTypes(db)
|
|
105
|
+
await ensureSystemMemberTypes(db)
|
|
106
|
+
await ensureSystemContentTypes(db)
|
|
107
|
+
|
|
108
|
+
return seeded && !config.adminPassword ? password : undefined
|
|
109
|
+
}
|
|
@@ -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
|
+
* `09-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
|
+
}
|