@crombolo/server 0.10.0 → 0.12.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/package.json +12 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/content.ts +2 -0
- package/src/adapters/documents.ts +28 -25
- package/src/adapters/elements.ts +3 -0
- package/src/adapters/health.ts +333 -0
- package/src/adapters/media.ts +3 -0
- package/src/adapters/members.ts +10 -2
- package/src/adapters/references.ts +13 -0
- package/src/adapters/relations.ts +48 -0
- package/src/adapters/schema-files.ts +41 -11
- package/src/adapters/schema-proposals.ts +13 -6
- package/src/adapters/search-index.ts +31 -0
- package/src/adapters/webhooks.ts +76 -0
- package/src/boot-failure.ts +147 -0
- package/src/bundle-install.ts +148 -13
- package/src/bundles.ts +14 -0
- package/src/busy.ts +13 -0
- package/src/config.ts +46 -2
- package/src/database.ts +9 -7
- package/src/delivery-members.ts +387 -0
- package/src/editor-types.ts +7 -2
- package/src/extensions-notice.ts +3 -2
- package/src/form-files.ts +38 -3
- package/src/form-submit.ts +62 -5
- package/src/form-types.ts +187 -0
- package/src/form-workflows.ts +66 -6
- package/src/forms.ts +15 -7
- package/src/index.ts +12 -0
- package/src/jobs.ts +91 -10
- package/src/media-files.ts +3 -1
- package/src/member-auth.ts +17 -1
- package/src/models-builder.ts +12 -8
- package/src/ports.ts +57 -2
- package/src/schema.ts +58 -11
- package/src/secrets.ts +36 -0
- package/src/server.ts +388 -29
- package/src/setup.ts +288 -0
- package/src/status.ts +5 -2
- package/src/transfer-dashboard.ts +219 -0
- package/src/upgrade-summary.ts +43 -0
- package/src/url-settings.ts +91 -0
- package/src/webhook-files.ts +138 -0
- package/src/webhooks.ts +544 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostics port: health checks for what actually goes wrong on a site,
|
|
3
|
+
* the troubleshooting report and the upgrade check. docs/diagnostics.md.
|
|
4
|
+
*/
|
|
5
|
+
import type { DiagnosticsPort, HealthCheckGroup, HealthCheckResult } from '@crombolo/api-management'
|
|
6
|
+
import {
|
|
7
|
+
compareStates,
|
|
8
|
+
cromboloPlan,
|
|
9
|
+
currentSchemaState,
|
|
10
|
+
type Db,
|
|
11
|
+
liveNodes,
|
|
12
|
+
type NodeSchemaState,
|
|
13
|
+
pendingMigrations,
|
|
14
|
+
} from '@crombolo/data'
|
|
15
|
+
import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from '../bundle-tables.ts'
|
|
16
|
+
import { type CromboloConfig, describeDatabase, VERSION } from '../config.ts'
|
|
17
|
+
import { type EmailPort, emailAvailability } from '../email.ts'
|
|
18
|
+
import type { MediaStore } from '../media-store.ts'
|
|
19
|
+
import { backofficeVersion } from '../status.ts'
|
|
20
|
+
|
|
21
|
+
export const UPGRADE_PACKAGE = '@crombolo/crombolo'
|
|
22
|
+
|
|
23
|
+
/** How recently a node must have polled to count as part of the cluster. */
|
|
24
|
+
const LIVE_WITHIN_MS = 60_000
|
|
25
|
+
|
|
26
|
+
const CHECKS = {
|
|
27
|
+
database: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000001', name: 'Database connection' },
|
|
28
|
+
migrations: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000002', name: 'Database upgrades' },
|
|
29
|
+
media: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000003', name: 'Media storage' },
|
|
30
|
+
email: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000004', name: 'E-mail delivery' },
|
|
31
|
+
nodes: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000005', name: 'Servers' },
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
const GROUPS: HealthCheckGroup[] = [
|
|
35
|
+
{
|
|
36
|
+
name: 'Database',
|
|
37
|
+
checks: [
|
|
38
|
+
{ ...CHECKS.database, description: 'Whether the database answers, and which one it is.' },
|
|
39
|
+
{
|
|
40
|
+
...CHECKS.migrations,
|
|
41
|
+
description: 'Whether the database is up to date with this release and its schema files.',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'Media',
|
|
47
|
+
checks: [{ ...CHECKS.media, description: 'Whether uploaded files can be written and read.' }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'E-mail',
|
|
51
|
+
checks: [{ ...CHECKS.email, description: 'Whether this site can send e-mail.' }],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'Servers',
|
|
55
|
+
checks: [
|
|
56
|
+
{
|
|
57
|
+
...CHECKS.nodes,
|
|
58
|
+
description: 'The servers running this site, and whether they run the same release.',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
export interface HealthCheckOptions {
|
|
65
|
+
config: CromboloConfig
|
|
66
|
+
db: Db
|
|
67
|
+
media: MediaStore
|
|
68
|
+
email?: EmailPort
|
|
69
|
+
/** What this node deploys at; read from the database's own state when omitted. */
|
|
70
|
+
nodeState?: NodeSchemaState
|
|
71
|
+
fetch?: typeof fetch
|
|
72
|
+
now?: () => Date
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = (
|
|
76
|
+
resultType: HealthCheckResult['resultType'],
|
|
77
|
+
message: string,
|
|
78
|
+
): HealthCheckResult => ({ resultType, message })
|
|
79
|
+
|
|
80
|
+
const failure = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
81
|
+
|
|
82
|
+
/** `0.13.0` against `0.12.0`: which part moved, or None when the first is not newer. */
|
|
83
|
+
export function upgradeType(latest: string, current: string): 'None' | 'Patch' | 'Minor' | 'Major' {
|
|
84
|
+
const parts = (value: string) =>
|
|
85
|
+
(value.split('-')[0] ?? '').split('.').map((part) => Number(part) || 0)
|
|
86
|
+
const [a, b] = [parts(latest), parts(current)]
|
|
87
|
+
for (const [index, type] of (['Major', 'Minor', 'Patch'] as const).entries()) {
|
|
88
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0)
|
|
89
|
+
if (difference > 0) return type
|
|
90
|
+
if (difference < 0) return 'None'
|
|
91
|
+
}
|
|
92
|
+
return 'None'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ago(then: Date, now: Date): string {
|
|
96
|
+
const seconds = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000))
|
|
97
|
+
return seconds < 2 ? 'just now' : `${seconds} seconds ago`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createDiagnosticsPort(options: HealthCheckOptions): DiagnosticsPort {
|
|
101
|
+
const { config, db, media } = options
|
|
102
|
+
const now = options.now ?? (() => new Date())
|
|
103
|
+
const call = options.fetch ?? fetch
|
|
104
|
+
let checked:
|
|
105
|
+
| { at: number; answer: Awaited<ReturnType<DiagnosticsPort['upgradeCheck']>> }
|
|
106
|
+
| undefined
|
|
107
|
+
|
|
108
|
+
async function database(): Promise<HealthCheckResult[]> {
|
|
109
|
+
const dialect = db.dialect.name === 'postgres' ? 'PostgreSQL' : 'SQLite'
|
|
110
|
+
try {
|
|
111
|
+
await db.query('SELECT 1 AS ok')
|
|
112
|
+
return [result('Success', `${dialect} is answering (${describeDatabase(config)}).`)]
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return [result('Error', `${dialect} is not answering: ${failure(error)}`)]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function migrations(): Promise<HealthCheckResult[]> {
|
|
119
|
+
const results: HealthCheckResult[] = []
|
|
120
|
+
try {
|
|
121
|
+
const pending = await pendingMigrations(db, cromboloPlan)
|
|
122
|
+
results.push(
|
|
123
|
+
pending.length === 0
|
|
124
|
+
? result('Success', `The database is up to date with Crombolo ${VERSION}.`)
|
|
125
|
+
: result(
|
|
126
|
+
'Error',
|
|
127
|
+
`${pending.length} database upgrade(s) for Crombolo ${VERSION} have not run yet: ${pending.map((m) => m.name).join(', ')}. Run \`crombolo upgrade\`.`,
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
for (const bundle of await pendingBundleSteps(db, bundleChainsFor(config)))
|
|
131
|
+
if (bundle.pending.length > 0)
|
|
132
|
+
results.push(
|
|
133
|
+
result(
|
|
134
|
+
'Error',
|
|
135
|
+
`The bundle "${bundle.name}" has ${bundle.pending.length} upgrade step(s) that have not run yet. Run \`crombolo upgrade\`.`,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
if (options.nodeState) {
|
|
139
|
+
const state = await currentSchemaState(db)
|
|
140
|
+
const difference = compareStates(options.nodeState, state)
|
|
141
|
+
if (difference < 0)
|
|
142
|
+
results.push(
|
|
143
|
+
result(
|
|
144
|
+
'Warning',
|
|
145
|
+
`The database's schema is ${state.version} (revision ${state.revision}), newer than this server's ${options.nodeState.version} (revision ${options.nodeState.revision}): this server reads but refuses changes until it is replaced.`,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
else if (difference > 0)
|
|
149
|
+
results.push(
|
|
150
|
+
result(
|
|
151
|
+
'Warning',
|
|
152
|
+
`This server's schema files are ${options.nodeState.version} (revision ${options.nodeState.revision}), ahead of the database's ${state.version} (revision ${state.revision}).`,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
results.push(
|
|
158
|
+
result(
|
|
159
|
+
'Error',
|
|
160
|
+
error instanceof BundleConfigurationError
|
|
161
|
+
? error.problems.join(' ')
|
|
162
|
+
: `The upgrade state could not be read: ${failure(error)}`,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
return results
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function mediaStorage(): Promise<HealthCheckResult[]> {
|
|
170
|
+
const key = `health-check/${crypto.randomUUID()}.txt`
|
|
171
|
+
const written = new TextEncoder().encode('ok')
|
|
172
|
+
try {
|
|
173
|
+
await media.put(key, written, { contentType: 'text/plain' })
|
|
174
|
+
const read = await media.get(key)
|
|
175
|
+
const bytes = read ? await read.bytes() : undefined
|
|
176
|
+
await media.delete(key)
|
|
177
|
+
if (!bytes || new TextDecoder().decode(bytes) !== 'ok')
|
|
178
|
+
return [result('Error', `A file written to ${media.description} could not be read back.`)]
|
|
179
|
+
return [result('Success', `Files can be written to and read from ${media.description}.`)]
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return [result('Error', `${media.description} could not be used: ${failure(error)}`)]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function email(): HealthCheckResult[] {
|
|
186
|
+
const availability = emailAvailability(options.email)
|
|
187
|
+
if (availability.available)
|
|
188
|
+
return [result('Success', `E-mail is sent through ${availability.description}.`)]
|
|
189
|
+
const affects =
|
|
190
|
+
availability.affects.length > 0
|
|
191
|
+
? ` Unavailable until it is: ${availability.affects.join(', ')}.`
|
|
192
|
+
: ''
|
|
193
|
+
return [
|
|
194
|
+
result(
|
|
195
|
+
availability.provider === 'log' ? 'Info' : 'Warning',
|
|
196
|
+
`${availability.reason}${affects}`,
|
|
197
|
+
),
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function nodes(): Promise<HealthCheckResult[]> {
|
|
202
|
+
const live = await liveNodes(db, { seenWithinMs: LIVE_WITHIN_MS })
|
|
203
|
+
if (live.length === 0) return [result('Warning', 'No server has reported in the last minute.')]
|
|
204
|
+
const at = now()
|
|
205
|
+
const results = live.map((node) =>
|
|
206
|
+
result(
|
|
207
|
+
'Info',
|
|
208
|
+
`${node.nodeId}${node.role ? ` (${node.role})` : ''} runs Crombolo ${node.cromboloVersion ?? 'of an unknown release'} with schema ${node.version ?? '?'} revision ${node.revision ?? '?'}, last seen ${ago(node.lastSeen, at)}.`,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
const releases = new Set(live.map((node) => node.cromboloVersion ?? 'unknown'))
|
|
212
|
+
const schemas = new Set(live.map((node) => `${node.version}/${node.revision}`))
|
|
213
|
+
if (releases.size > 1)
|
|
214
|
+
results.unshift(
|
|
215
|
+
result(
|
|
216
|
+
'Warning',
|
|
217
|
+
`The servers run different releases (${[...releases].join(', ')}). That is expected only while an upgrade rolls out.`,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
else if (schemas.size > 1)
|
|
221
|
+
results.unshift(
|
|
222
|
+
result(
|
|
223
|
+
'Warning',
|
|
224
|
+
'The servers deploy different schema files. That is expected only while a deploy rolls out.',
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
else
|
|
228
|
+
results.unshift(
|
|
229
|
+
result('Success', `${live.length} server(s) running Crombolo ${[...releases][0]}.`),
|
|
230
|
+
)
|
|
231
|
+
return results
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const runners: Record<string, () => HealthCheckResult[] | Promise<HealthCheckResult[]>> = {
|
|
235
|
+
[CHECKS.database.id]: database,
|
|
236
|
+
[CHECKS.migrations.id]: migrations,
|
|
237
|
+
[CHECKS.media.id]: mediaStorage,
|
|
238
|
+
[CHECKS.email.id]: email,
|
|
239
|
+
[CHECKS.nodes.id]: nodes,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
groups: () => GROUPS,
|
|
244
|
+
|
|
245
|
+
async run(name) {
|
|
246
|
+
const group = GROUPS.find((candidate) => candidate.name === name)
|
|
247
|
+
if (!group) return undefined
|
|
248
|
+
return Promise.all(
|
|
249
|
+
group.checks.map(async (check) => {
|
|
250
|
+
try {
|
|
251
|
+
return {
|
|
252
|
+
id: check.id,
|
|
253
|
+
results: await (runners[check.id] as () => Promise<HealthCheckResult[]>)(),
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
id: check.id,
|
|
258
|
+
results: [result('Error', `The check failed: ${failure(error)}`)],
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async troubleshooting() {
|
|
266
|
+
const availability = emailAvailability(options.email)
|
|
267
|
+
const state = options.nodeState
|
|
268
|
+
const items: Array<[string, string | undefined]> = [
|
|
269
|
+
['Crombolo version', VERSION],
|
|
270
|
+
['Backoffice version', backofficeVersion()],
|
|
271
|
+
['Runtime', `Bun ${Bun.version}`],
|
|
272
|
+
['Operating system', `${process.platform} ${process.arch}`],
|
|
273
|
+
['Mode', config.development ? 'Development' : 'Production'],
|
|
274
|
+
['Server', `${config.nodeId} (${config.role})`],
|
|
275
|
+
['Database', db.dialect.name === 'postgres' ? 'PostgreSQL' : 'SQLite'],
|
|
276
|
+
['Database location', describeDatabase(config)],
|
|
277
|
+
['Schema', state ? `${state.version} revision ${state.revision}` : undefined],
|
|
278
|
+
['Upgrade policy', config.upgradePolicy],
|
|
279
|
+
['Media storage', media.description],
|
|
280
|
+
[
|
|
281
|
+
'E-mail',
|
|
282
|
+
availability.available ? (availability.description ?? undefined) : 'Not configured',
|
|
283
|
+
],
|
|
284
|
+
['Log level', config.logLevel],
|
|
285
|
+
['Time zone', Intl.DateTimeFormat().resolvedOptions().timeZone],
|
|
286
|
+
['Server time', now().toISOString()],
|
|
287
|
+
]
|
|
288
|
+
return {
|
|
289
|
+
items: items
|
|
290
|
+
.filter((item): item is [string, string] => item[1] !== undefined)
|
|
291
|
+
.map(([name, data]) => ({ name, data })),
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async upgradeCheck() {
|
|
296
|
+
if (config.versionCheckPeriod <= 0)
|
|
297
|
+
return { type: 'None', comment: 'Checking for new releases is turned off.', url: '' }
|
|
298
|
+
const periodMs = config.versionCheckPeriod * 86_400_000
|
|
299
|
+
if (checked && now().getTime() - checked.at < periodMs) return checked.answer
|
|
300
|
+
let answer: Awaited<ReturnType<DiagnosticsPort['upgradeCheck']>>
|
|
301
|
+
try {
|
|
302
|
+
const response = await call(
|
|
303
|
+
`${config.npmRegistry.replace(/\/$/, '')}/${encodeURIComponent(UPGRADE_PACKAGE)}`,
|
|
304
|
+
{ headers: { accept: 'application/json' } },
|
|
305
|
+
)
|
|
306
|
+
if (!response.ok) throw new Error(`the registry answered ${response.status}`)
|
|
307
|
+
const latest = ((await response.json()) as { 'dist-tags'?: { latest?: string } })[
|
|
308
|
+
'dist-tags'
|
|
309
|
+
]?.latest
|
|
310
|
+
if (!latest) throw new Error('the registry named no latest release')
|
|
311
|
+
const type = upgradeType(latest, VERSION)
|
|
312
|
+
answer =
|
|
313
|
+
type === 'None'
|
|
314
|
+
? { type, comment: `Crombolo ${VERSION} is the latest release.`, url: '' }
|
|
315
|
+
: {
|
|
316
|
+
type,
|
|
317
|
+
comment: `Crombolo ${latest} is available; this site runs ${VERSION}.`,
|
|
318
|
+
url: `https://www.npmjs.com/package/${UPGRADE_PACKAGE}/v/${latest}`,
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// Not cached: an air-gapped site asks again next time rather than
|
|
322
|
+
// being told nothing for a week.
|
|
323
|
+
return {
|
|
324
|
+
type: 'None',
|
|
325
|
+
comment: `The package registry could not be reached to check for a newer release (${failure(error)}).`,
|
|
326
|
+
url: '',
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
checked = { at: now().getTime(), answer }
|
|
330
|
+
return answer
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
}
|
package/src/adapters/media.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
DataTypeRepository,
|
|
20
20
|
type Db,
|
|
21
21
|
DocumentRepository,
|
|
22
|
+
InvalidParentError,
|
|
22
23
|
type NodeRow,
|
|
23
24
|
type NodeSchemaState,
|
|
24
25
|
type ValueIntake,
|
|
@@ -65,6 +66,8 @@ export function createMediaPort(
|
|
|
65
66
|
const failure = (error: unknown) => {
|
|
66
67
|
if (error instanceof WriteRejectedError)
|
|
67
68
|
return { ok: false as const, reason: error.message, status: 409 }
|
|
69
|
+
if (error instanceof InvalidParentError)
|
|
70
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
68
71
|
if (error instanceof TemporaryFileMissingError)
|
|
69
72
|
return { ok: false as const, reason: error.message, status: 400 }
|
|
70
73
|
throw error
|
package/src/adapters/members.ts
CHANGED
|
@@ -45,6 +45,11 @@ export interface MemberPortOptions extends DocumentRepositoryOptions {
|
|
|
45
45
|
valueIntake?: ValueIntake
|
|
46
46
|
/** How many failed sign-ins lock an account out; Umbraco's default is 5. */
|
|
47
47
|
maxFailedPasswordAttempts?: number
|
|
48
|
+
/**
|
|
49
|
+
* The members the Delivery API's client credentials sign in as: Umbraco shows
|
|
50
|
+
* them as kind `Api`, derived from config rather than stored.
|
|
51
|
+
*/
|
|
52
|
+
apiMemberUserNames?: readonly string[]
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
const notFound = () => ({
|
|
@@ -54,6 +59,9 @@ const notFound = () => ({
|
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
|
|
62
|
+
const apiMembers = new Set((options.apiMemberUserNames ?? []).map((n) => n.toLowerCase()))
|
|
63
|
+
const kindOf = (userName: string): 'Api' | 'Default' =>
|
|
64
|
+
apiMembers.has(userName.toLowerCase()) ? 'Api' : 'Default'
|
|
57
65
|
const members = new MemberRepository(db, options)
|
|
58
66
|
const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
|
|
59
67
|
|
|
@@ -110,7 +118,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
110
118
|
lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
|
|
111
119
|
lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
|
|
112
120
|
groups: await members.groupKeys(member.key),
|
|
113
|
-
kind:
|
|
121
|
+
kind: kindOf(member.username),
|
|
114
122
|
profileData: null,
|
|
115
123
|
flags: [],
|
|
116
124
|
values: content.values
|
|
@@ -136,7 +144,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
|
|
|
136
144
|
id: member.key,
|
|
137
145
|
memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
|
|
138
146
|
variants: [{ name: member.name, culture: null }],
|
|
139
|
-
kind:
|
|
147
|
+
kind: kindOf(member.username),
|
|
140
148
|
flags: [],
|
|
141
149
|
})
|
|
142
150
|
|
|
@@ -50,7 +50,20 @@ function toReference(node: ReferencingNode): unknown {
|
|
|
50
50
|
|
|
51
51
|
export function createReferencePort(db: Db): ReferencePort {
|
|
52
52
|
const references = new ReferenceRepository(db)
|
|
53
|
+
const binTypes = {
|
|
54
|
+
Document: ObjectTypes.Document,
|
|
55
|
+
Media: ObjectTypes.Media,
|
|
56
|
+
Element: ObjectTypes.Element,
|
|
57
|
+
} as const
|
|
53
58
|
return {
|
|
59
|
+
async recycleBinReferencedBy(area, paging) {
|
|
60
|
+
const result = await references.referencingRecycleBin(
|
|
61
|
+
binTypes[area],
|
|
62
|
+
paging.skip,
|
|
63
|
+
paging.take,
|
|
64
|
+
)
|
|
65
|
+
return { total: result.total, items: result.items.map(toReference) }
|
|
66
|
+
},
|
|
54
67
|
async referencedBy(key, paging) {
|
|
55
68
|
const result = await references.referencedBy(key, paging.skip, paging.take)
|
|
56
69
|
return { total: result.total, items: result.items.map(toReference) }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Relation types and their derived relations, shaped for the wire. */
|
|
2
|
+
import type { RelationPort, RelationTypeView } from '@crombolo/api-management'
|
|
3
|
+
import { ObjectTypes } from '@crombolo/core'
|
|
4
|
+
import { type Db, RELATION_TYPES, RelationRepository, relationTypeByKey } from '@crombolo/data'
|
|
5
|
+
|
|
6
|
+
/** Umbraco's friendly names for the object types a relation type joins. */
|
|
7
|
+
const OBJECT_TYPE_NAMES: Record<string, string> = {
|
|
8
|
+
[ObjectTypes.Document]: 'Document',
|
|
9
|
+
[ObjectTypes.Media]: 'Media',
|
|
10
|
+
[ObjectTypes.Member]: 'Member',
|
|
11
|
+
[ObjectTypes.Element]: 'Element',
|
|
12
|
+
[ObjectTypes.ElementContainer]: 'Element Container',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const objectType = (id: string | null) => (id ? { id, name: OBJECT_TYPE_NAMES[id] ?? id } : null)
|
|
16
|
+
|
|
17
|
+
const VIEWS: readonly RelationTypeView[] = RELATION_TYPES.map((type) => ({
|
|
18
|
+
id: type.key,
|
|
19
|
+
alias: type.alias,
|
|
20
|
+
name: type.name,
|
|
21
|
+
parentObject: objectType(type.parentObjectType),
|
|
22
|
+
childObject: objectType(type.childObjectType),
|
|
23
|
+
isBidirectional: type.isBidirectional,
|
|
24
|
+
isDependency: type.isDependency,
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
export function createRelationPort(db: Db): RelationPort {
|
|
28
|
+
const relations = new RelationRepository(db)
|
|
29
|
+
return {
|
|
30
|
+
types: () => VIEWS,
|
|
31
|
+
async relations(typeKey, paging) {
|
|
32
|
+
const type = relationTypeByKey(typeKey)
|
|
33
|
+
if (!type) return undefined
|
|
34
|
+
const result = await relations.relations(type, paging.skip, paging.take)
|
|
35
|
+
return {
|
|
36
|
+
total: result.total,
|
|
37
|
+
items: result.items.map((relation) => ({
|
|
38
|
+
id: relation.key,
|
|
39
|
+
relationType: { id: relation.typeKey },
|
|
40
|
+
parent: { id: relation.parent.key, name: relation.parent.name },
|
|
41
|
+
child: { id: relation.child.key, name: relation.child.name },
|
|
42
|
+
createDate: relation.createDate.toISOString(),
|
|
43
|
+
comment: null,
|
|
44
|
+
})),
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { join, relative } from 'node:path'
|
|
10
10
|
import type { PortFailure } from '@crombolo/api-management'
|
|
11
|
-
import type { ContentTypeAggregate, DataTypeModel } from '@crombolo/core'
|
|
11
|
+
import type { ContentTypeAggregate, DataTypeModel, FormTypes } from '@crombolo/core'
|
|
12
12
|
import { currentSchemaState, type Db, updateSchemaStateHash } from '@crombolo/data'
|
|
13
13
|
import {
|
|
14
14
|
type ChangeClass,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
loadSchemaDirectory,
|
|
19
19
|
nextSchemaVersion,
|
|
20
20
|
type SchemaTypeKind,
|
|
21
|
+
type SchemaUrls,
|
|
21
22
|
TYPE_KIND_FILES,
|
|
22
23
|
validateSchemaSet,
|
|
23
24
|
writeContentType,
|
|
@@ -32,6 +33,8 @@ export interface SchemaFiles {
|
|
|
32
33
|
schemaDir: string
|
|
33
34
|
writable: boolean
|
|
34
35
|
componentAliases(): ReadonlySet<string>
|
|
36
|
+
/** The form types the site's wired bundles add. */
|
|
37
|
+
formTypes?: FormTypes
|
|
35
38
|
/**
|
|
36
39
|
* Called after any write, so a shared schema store outlives this container.
|
|
37
40
|
* Absent when the schema is a directory on disk, which needs no publishing.
|
|
@@ -54,6 +57,8 @@ export interface SchemaFileWriter {
|
|
|
54
57
|
writeDataType(model: DataTypeModel): Promise<void>
|
|
55
58
|
removeDataType(alias: string): Promise<void>
|
|
56
59
|
writeLanguages(): Promise<void>
|
|
60
|
+
/** `[urls]` in `schema.toml`, the version left as it is. */
|
|
61
|
+
writeUrlOptions(urls: SchemaUrls): Promise<void>
|
|
57
62
|
/** After a move or a folder change: every file, from the database, canonically. */
|
|
58
63
|
rewriteAll(): Promise<void>
|
|
59
64
|
}
|
|
@@ -82,7 +87,7 @@ export function createSchemaFileWriter(
|
|
|
82
87
|
await updateSchemaStateHash(
|
|
83
88
|
db,
|
|
84
89
|
state.id,
|
|
85
|
-
hashSchemaSet(loadSchemaDirectory(files.schemaDir).set),
|
|
90
|
+
hashSchemaSet(loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set),
|
|
86
91
|
)
|
|
87
92
|
await files.onChanged?.()
|
|
88
93
|
}
|
|
@@ -100,10 +105,10 @@ export function createSchemaFileWriter(
|
|
|
100
105
|
|
|
101
106
|
async bumpVersion(classification) {
|
|
102
107
|
if (!managed() || !files.writable || classification === 'none') return
|
|
103
|
-
const loaded = loadSchemaDirectory(files.schemaDir)
|
|
108
|
+
const loaded = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes })
|
|
104
109
|
const next = nextSchemaVersion(loaded.set.version, classification)
|
|
105
110
|
if (next === loaded.set.version) return
|
|
106
|
-
writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next))
|
|
111
|
+
writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next, loaded.set.urls))
|
|
107
112
|
logger('schema').info('Schema version {from} → {to} ({classification})', {
|
|
108
113
|
from: loaded.set.version,
|
|
109
114
|
to: next,
|
|
@@ -113,7 +118,10 @@ export function createSchemaFileWriter(
|
|
|
113
118
|
|
|
114
119
|
async write(aggregate, previousAlias) {
|
|
115
120
|
if (!managed() || !files.writable) return
|
|
116
|
-
const set = await exportSchemaSet(
|
|
121
|
+
const set = await exportSchemaSet(
|
|
122
|
+
db,
|
|
123
|
+
loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
|
|
124
|
+
)
|
|
117
125
|
const list =
|
|
118
126
|
kind === 'document'
|
|
119
127
|
? set.contentTypes
|
|
@@ -122,7 +130,10 @@ export function createSchemaFileWriter(
|
|
|
122
130
|
: (set.memberTypes ?? [])
|
|
123
131
|
const type = list.find((t) => t.key === aggregate.key)
|
|
124
132
|
if (!type) return
|
|
125
|
-
const problems = validateSchemaSet(set, {
|
|
133
|
+
const problems = validateSchemaSet(set, {
|
|
134
|
+
componentAliases: files.componentAliases(),
|
|
135
|
+
types: files.formTypes,
|
|
136
|
+
})
|
|
126
137
|
if (problems.length > 0) {
|
|
127
138
|
const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
|
|
128
139
|
logger('schema').error('Not writing {file}: {detail}', {
|
|
@@ -146,7 +157,10 @@ export function createSchemaFileWriter(
|
|
|
146
157
|
|
|
147
158
|
async writeDataType(model) {
|
|
148
159
|
if (!managed() || !files.writable || !model.alias) return
|
|
149
|
-
const set = await exportSchemaSet(
|
|
160
|
+
const set = await exportSchemaSet(
|
|
161
|
+
db,
|
|
162
|
+
loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
|
|
163
|
+
)
|
|
150
164
|
const dataType = set.dataTypes.find((d) => d.key === model.key)
|
|
151
165
|
// An unchanged built-in has no file; a changed one does.
|
|
152
166
|
if (!dataType) {
|
|
@@ -157,7 +171,10 @@ export function createSchemaFileWriter(
|
|
|
157
171
|
// The same guard `write` applies to a content type: a set that does not
|
|
158
172
|
// validate never reaches the disk, because the file is what every other
|
|
159
173
|
// node reads and what a deploy boots from.
|
|
160
|
-
const problems = validateSchemaSet(set, {
|
|
174
|
+
const problems = validateSchemaSet(set, {
|
|
175
|
+
componentAliases: files.componentAliases(),
|
|
176
|
+
types: files.formTypes,
|
|
177
|
+
})
|
|
161
178
|
if (problems.length > 0) {
|
|
162
179
|
const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
|
|
163
180
|
logger('schema').error('Not writing {file}: {detail}', {
|
|
@@ -182,16 +199,29 @@ export function createSchemaFileWriter(
|
|
|
182
199
|
|
|
183
200
|
async writeLanguages() {
|
|
184
201
|
if (!managed() || !files.writable) return
|
|
185
|
-
const set = await exportSchemaSet(
|
|
202
|
+
const set = await exportSchemaSet(
|
|
203
|
+
db,
|
|
204
|
+
loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
|
|
205
|
+
)
|
|
186
206
|
writeFileSync(join(files.schemaDir, 'languages.toml'), writeLanguages(set.languages))
|
|
187
207
|
await settle()
|
|
188
208
|
},
|
|
189
209
|
|
|
210
|
+
async writeUrlOptions(urls) {
|
|
211
|
+
if (!managed() || !files.writable) return
|
|
212
|
+
const { version } = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set
|
|
213
|
+
writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(version, urls))
|
|
214
|
+
await settle()
|
|
215
|
+
},
|
|
216
|
+
|
|
190
217
|
async rewriteAll() {
|
|
191
218
|
if (!managed() || !files.writable) return
|
|
192
|
-
const loaded = loadSchemaDirectory(files.schemaDir)
|
|
219
|
+
const loaded = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes })
|
|
193
220
|
const set = await exportSchemaSet(db, loaded.set.version)
|
|
194
|
-
const problems = validateSchemaSet(set, {
|
|
221
|
+
const problems = validateSchemaSet(set, {
|
|
222
|
+
componentAliases: files.componentAliases(),
|
|
223
|
+
types: files.formTypes,
|
|
224
|
+
})
|
|
195
225
|
if (problems.length > 0) {
|
|
196
226
|
const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
|
|
197
227
|
logger('schema').error('Not rewriting the schema files: {detail}', { detail })
|
|
@@ -18,6 +18,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
|
18
18
|
import { dirname, join } from 'node:path'
|
|
19
19
|
import type { SchemaProposalPort } from '@crombolo/assistant'
|
|
20
20
|
import type { Db } from '@crombolo/data'
|
|
21
|
+
import type { SyncReport } from '@crombolo/schema'
|
|
21
22
|
import {
|
|
22
23
|
fileNameFor,
|
|
23
24
|
loadSchemaDirectory,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
writeSchemaVersion,
|
|
33
34
|
} from '@crombolo/schema'
|
|
34
35
|
import type { CromboloConfig } from '../config.ts'
|
|
36
|
+
import { formTypesFor } from '../form-types.ts'
|
|
35
37
|
import { logger } from '../logging.ts'
|
|
36
38
|
import { componentAliasesIn, importSchema } from '../schema.ts'
|
|
37
39
|
|
|
@@ -48,6 +50,8 @@ export interface SchemaProposalOptions {
|
|
|
48
50
|
config: CromboloConfig
|
|
49
51
|
/** The node's identity and state, refreshed in place by an import. */
|
|
50
52
|
boot: { nodeId: string; nodeState: { version: string; revision: string } }
|
|
53
|
+
/** After an import applies: what a change to how URLs are built needs. */
|
|
54
|
+
afterSync?(report: SyncReport | undefined): Promise<void>
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
export function createSchemaProposals(options: SchemaProposalOptions): SchemaProposalPort {
|
|
@@ -70,12 +74,12 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
70
74
|
? parsed.problems
|
|
71
75
|
: [{ file, path: '', message: 'the file defines no data type' }]
|
|
72
76
|
}
|
|
73
|
-
const set = loadSchemaDirectory(config.schemaDir).set
|
|
77
|
+
const set = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set
|
|
74
78
|
const dataTypes = [
|
|
75
79
|
...set.dataTypes.filter((existing) => existing.alias !== dataType.alias),
|
|
76
80
|
dataType,
|
|
77
81
|
]
|
|
78
|
-
return validateSchemaSet({ ...set, dataTypes })
|
|
82
|
+
return validateSchemaSet({ ...set, dataTypes }, { types: formTypesFor(config) })
|
|
79
83
|
}
|
|
80
84
|
|
|
81
85
|
const parsed = parseContentType(file, toml)
|
|
@@ -85,14 +89,14 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
85
89
|
? parsed.problems
|
|
86
90
|
: [{ file, path: '', message: 'the file defines no content type' }]
|
|
87
91
|
}
|
|
88
|
-
const set = loadSchemaDirectory(config.schemaDir).set
|
|
92
|
+
const set = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set
|
|
89
93
|
const contentTypes = [
|
|
90
94
|
...set.contentTypes.filter((existing) => existing.alias !== contentType.alias),
|
|
91
95
|
contentType,
|
|
92
96
|
]
|
|
93
97
|
return validateSchemaSet(
|
|
94
98
|
{ ...set, contentTypes },
|
|
95
|
-
{ componentAliases: componentAliasesIn(config.componentsDir) },
|
|
99
|
+
{ componentAliases: componentAliasesIn(config.componentsDir), types: formTypesFor(config) },
|
|
96
100
|
)
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -140,9 +144,11 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
140
144
|
// import lands at a version the other nodes already have and they never
|
|
141
145
|
// learn they are behind.
|
|
142
146
|
const ahead = await importSchema(options.db, config, options.boot, { dryRun: true })
|
|
143
|
-
const version = loadSchemaDirectory(config.schemaDir
|
|
147
|
+
const { version, urls } = loadSchemaDirectory(config.schemaDir, {
|
|
148
|
+
formTypes: formTypesFor(config),
|
|
149
|
+
}).set
|
|
144
150
|
const next = nextSchemaVersion(version, ahead.classification)
|
|
145
|
-
if (next !== version) await writeFile(versionFile, writeSchemaVersion(next), 'utf8')
|
|
151
|
+
if (next !== version) await writeFile(versionFile, writeSchemaVersion(next, urls), 'utf8')
|
|
146
152
|
|
|
147
153
|
const result = await importSchema(options.db, config, options.boot)
|
|
148
154
|
if (result.report && result.report.action !== 'applied') {
|
|
@@ -154,6 +160,7 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
|
|
|
154
160
|
error: result.report.reason ?? `the import was ${result.report.action}`,
|
|
155
161
|
}
|
|
156
162
|
}
|
|
163
|
+
await options.afterSync?.(result.report)
|
|
157
164
|
log.info('{alias} applied from a proposal: {classification}, schema at {version}', {
|
|
158
165
|
alias,
|
|
159
166
|
classification: ahead.classification,
|