@crombolo/server 0.9.1 → 0.11.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.
- package/package.json +11 -11
- package/src/adapters/audit.ts +186 -0
- package/src/adapters/bundles.ts +84 -9
- package/src/adapters/schema-files.ts +1 -1
- package/src/adapters/search-index.ts +31 -0
- package/src/backup.ts +1 -1
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +139 -38
- package/src/config.ts +24 -3
- package/src/database.ts +50 -11
- package/src/dictionary-transfer.ts +1 -1
- package/src/email.ts +1 -1
- package/src/extensions-notice.ts +2 -2
- package/src/form-files.ts +2 -2
- package/src/form-submit.ts +1 -1
- package/src/form-workflows.ts +2 -2
- package/src/forms.ts +1 -1
- package/src/index.ts +4 -0
- package/src/jobs.ts +74 -10
- package/src/marketplace.ts +117 -32
- package/src/no-nodes.ts +1 -1
- package/src/ports.ts +7 -1
- package/src/schema.ts +1 -1
- package/src/server.ts +146 -12
- package/src/status.ts +29 -0
- package/src/tar.ts +85 -0
- package/src/zip.ts +1 -1
package/src/jobs.ts
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Background jobs, as Umbraco runs them: scheduled publishing every minute and
|
|
3
|
-
* version cleanup every hour when it is enabled
|
|
3
|
+
* version cleanup every hour when it is enabled, plus the search indexer
|
|
4
|
+
* draining its queue and the audit log ageing out old entries hourly. Each run can also be called directly, which is
|
|
4
5
|
* how tests drive them. On a multi-node Postgres site every node runs them;
|
|
5
|
-
* each schedule is claimed atomically, so it runs once.
|
|
6
|
+
* each schedule and queued item is claimed atomically, so it runs once.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { NodeSchemaState } from '@crombolo/data'
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
AuditLog,
|
|
12
|
+
type Db,
|
|
13
|
+
DocumentRepository,
|
|
14
|
+
PublishBlockedError,
|
|
15
|
+
SearchIndex,
|
|
16
|
+
} from '@crombolo/data'
|
|
10
17
|
import type { PublishedCache } from '@crombolo/render'
|
|
18
|
+
import { auditRetainedSince, recordAudit } from './adapters/audit.ts'
|
|
11
19
|
import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
|
|
12
20
|
import { logger } from './logging.ts'
|
|
13
21
|
import type { MediaFileStore } from './media-files.ts'
|
|
@@ -33,6 +41,10 @@ export interface JobsOptions {
|
|
|
33
41
|
*/
|
|
34
42
|
formWorkflows?: WorkflowRunner
|
|
35
43
|
formWorkflowsMs?: number
|
|
44
|
+
/** How often the search indexer looks for queued items; a write here also starts it at once. */
|
|
45
|
+
searchIndexingMs?: number
|
|
46
|
+
/** Days the audit log keeps an entry; 0 or absent keeps them forever. */
|
|
47
|
+
auditRetentionDays?: number
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export interface ScheduledRun {
|
|
@@ -45,6 +57,12 @@ export interface BackgroundJobs {
|
|
|
45
57
|
runScheduledPublishing(now?: Date): Promise<ScheduledRun>
|
|
46
58
|
/** Runs the form workflows that are due; a no-op when none are configured. */
|
|
47
59
|
runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
|
|
60
|
+
/** Indexes every queued item, and returns how many. docs/search.md. */
|
|
61
|
+
runSearchIndexing(): Promise<number>
|
|
62
|
+
/** Something was queued here: index it shortly, rather than at the next tick. */
|
|
63
|
+
kickSearchIndexing(): void
|
|
64
|
+
/** Deletes audit entries older than the retention period, and returns how many. */
|
|
65
|
+
runAuditRetention(now?: Date): Promise<number>
|
|
48
66
|
/** Runs whether or not the hourly job is enabled; the policy decides what goes. */
|
|
49
67
|
runVersionCleanup(now?: Date): Promise<{ nodes: number; versionsDeleted: number }>
|
|
50
68
|
start(): void
|
|
@@ -60,6 +78,9 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
60
78
|
})
|
|
61
79
|
const timers: Array<ReturnType<typeof setInterval>> = []
|
|
62
80
|
let running = false
|
|
81
|
+
const search = new SearchIndex(options.db)
|
|
82
|
+
let indexing: Promise<number> | undefined
|
|
83
|
+
let kicked: ReturnType<typeof setTimeout> | undefined
|
|
63
84
|
|
|
64
85
|
async function runScheduledPublishing(now = new Date()): Promise<ScheduledRun> {
|
|
65
86
|
const run: ScheduledRun = { published: [], unpublished: [], failed: [] }
|
|
@@ -67,13 +88,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
67
88
|
if (!(await documents.claimSchedule(due.id))) continue
|
|
68
89
|
const cultures = due.culture ? [due.culture] : null
|
|
69
90
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
91
|
+
const release = due.action === 'Release'
|
|
92
|
+
const changed = release
|
|
93
|
+
? await documents.publish(due.key, cultures)
|
|
94
|
+
: await documents.unpublish(due.key, cultures)
|
|
95
|
+
;(release ? run.published : run.unpublished).push(due.key)
|
|
96
|
+
await recordAudit(options.db, {
|
|
97
|
+
category: 'content',
|
|
98
|
+
action: release ? 'Publish' : 'Unpublish',
|
|
99
|
+
source: 'job',
|
|
100
|
+
entityType: 'Document',
|
|
101
|
+
entityKey: due.key,
|
|
102
|
+
entityName: changed?.variants[0]?.name ?? null,
|
|
103
|
+
detail: `Scheduled${due.culture ? ` (${due.culture})` : ''}`,
|
|
104
|
+
})
|
|
77
105
|
options.onDocumentChanged?.(due.key)
|
|
78
106
|
} catch (error) {
|
|
79
107
|
if (!(error instanceof PublishBlockedError)) throw error
|
|
@@ -84,6 +112,11 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
84
112
|
return run
|
|
85
113
|
}
|
|
86
114
|
|
|
115
|
+
async function runAuditRetention(now = new Date()): Promise<number> {
|
|
116
|
+
const before = auditRetainedSince(options.auditRetentionDays ?? 0, now)
|
|
117
|
+
return before ? new AuditLog(options.db).prune(before) : 0
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
async function runVersionCleanup(now = new Date()) {
|
|
88
121
|
return documents.cleanupVersions(now, options.versionCleanup)
|
|
89
122
|
}
|
|
@@ -99,6 +132,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
99
132
|
)
|
|
100
133
|
}
|
|
101
134
|
|
|
135
|
+
// Its own guard rather than `guarded`: indexing a big rebuild must not hold up
|
|
136
|
+
// a scheduled publish, and a kick while it runs joins the run in progress.
|
|
137
|
+
function runSearchIndexing(): Promise<number> {
|
|
138
|
+
indexing ??= search.indexAll().finally(() => {
|
|
139
|
+
indexing = undefined
|
|
140
|
+
})
|
|
141
|
+
return indexing
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const indexQuietly = () =>
|
|
145
|
+
runSearchIndexing().catch((error: unknown) => {
|
|
146
|
+
logger('jobs').error('Search indexing failed', { error })
|
|
147
|
+
})
|
|
148
|
+
|
|
102
149
|
const guarded = (job: () => Promise<unknown>) => async () => {
|
|
103
150
|
if (running) return
|
|
104
151
|
running = true
|
|
@@ -120,18 +167,35 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
|
|
|
120
167
|
return {
|
|
121
168
|
runScheduledPublishing,
|
|
122
169
|
runVersionCleanup,
|
|
170
|
+
runAuditRetention,
|
|
123
171
|
runFormWorkflows,
|
|
172
|
+
runSearchIndexing,
|
|
173
|
+
kickSearchIndexing() {
|
|
174
|
+
if (timers.length === 0 || kicked) return
|
|
175
|
+
kicked = setTimeout(() => {
|
|
176
|
+
kicked = undefined
|
|
177
|
+
void indexQuietly()
|
|
178
|
+
}, 250)
|
|
179
|
+
kicked.unref()
|
|
180
|
+
},
|
|
124
181
|
start() {
|
|
125
182
|
every(options.scheduledPublishingMs ?? 60_000, runScheduledPublishing)
|
|
183
|
+
// The queue is shared, so this also picks up what other nodes wrote.
|
|
184
|
+
const searchTimer = setInterval(indexQuietly, options.searchIndexingMs ?? 5_000)
|
|
185
|
+
searchTimer.unref()
|
|
186
|
+
timers.push(searchTimer)
|
|
126
187
|
// More often than the other jobs: somebody waiting on a form notification
|
|
127
188
|
// notices a minute, and a queue row is cheap to look for.
|
|
128
189
|
if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
|
|
129
190
|
if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
|
|
191
|
+
if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
|
|
130
192
|
const files = options.mediaFiles
|
|
131
193
|
if (files) every(60 * 60_000, () => files.cleanupExpired())
|
|
132
194
|
},
|
|
133
195
|
stop() {
|
|
134
196
|
for (const timer of timers.splice(0)) clearInterval(timer)
|
|
197
|
+
if (kicked) clearTimeout(kicked)
|
|
198
|
+
kicked = undefined
|
|
135
199
|
},
|
|
136
200
|
started() {
|
|
137
201
|
return timers.length > 0
|
package/src/marketplace.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Bundles section's marketplace, over npm. `docs/
|
|
2
|
+
* The Bundles section's marketplace, over npm. `docs/bundles.md`.
|
|
3
3
|
*
|
|
4
4
|
* Discovery and declaration are two different things, because npm indexes only
|
|
5
5
|
* one of them: a bundle is *found* by the `crombolo-bundle` keyword, which is
|
|
6
6
|
* the only thing registry search will match, and it *declares* itself with a
|
|
7
7
|
* `crombolo` field in its `package.json`. Search for the keyword, then read each
|
|
8
8
|
* hit's packument — custom fields survive there — and offer only the packages
|
|
9
|
-
*
|
|
9
|
+
* whose manifest is a valid bundle. A keyword squatter never reaches the list.
|
|
10
|
+
*
|
|
11
|
+
* An install is `bun add`, and then — unless the site or the request says not
|
|
12
|
+
* to — the content the bundle carries, through the same installer as
|
|
13
|
+
* `crombolo bundle install`. A bundle's server half is never loaded by an
|
|
14
|
+
* install: that still takes an edit to `crombolo.config.ts` and a deploy.
|
|
10
15
|
*
|
|
11
16
|
* The search is proxied rather than done in the browser: it works where the
|
|
12
17
|
* browser cannot reach npm, and the backoffice keeps making same-origin
|
|
13
18
|
* requests only.
|
|
14
19
|
*/
|
|
15
20
|
import { join } from 'node:path'
|
|
16
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
type BundlePackage,
|
|
23
|
+
EXTENSION_KEYWORD,
|
|
24
|
+
type ExtensionRegistry,
|
|
25
|
+
type PackageJson,
|
|
26
|
+
validateBundlePackage,
|
|
27
|
+
} from '@crombolo/backoffice-host'
|
|
17
28
|
import { logger } from './logging.ts'
|
|
18
29
|
|
|
19
30
|
const log = logger('packages')
|
|
@@ -27,12 +38,12 @@ export interface MarketplaceListing {
|
|
|
27
38
|
description: string
|
|
28
39
|
publisher: string | null
|
|
29
40
|
links: { npm: string; homepage: string | null; repository: string | null }
|
|
30
|
-
/**
|
|
31
|
-
|
|
41
|
+
/** What the latest version carries; only a valid bundle is listed at all. */
|
|
42
|
+
carries: BundlePackage['carries']
|
|
32
43
|
installedVersion: string | null
|
|
33
44
|
}
|
|
34
45
|
|
|
35
|
-
/**
|
|
46
|
+
/** A bundle the site depends on, as the Installed view lists it. */
|
|
36
47
|
export interface InstalledListing {
|
|
37
48
|
/** The npm package name, which is what an uninstall names. */
|
|
38
49
|
packageName: string
|
|
@@ -41,6 +52,19 @@ export interface InstalledListing {
|
|
|
41
52
|
id: string
|
|
42
53
|
version: string
|
|
43
54
|
extensionCount: number
|
|
55
|
+
carries: BundlePackage['carries']
|
|
56
|
+
/** Whether its server half is imported into `crombolo.config.ts`; only meaningful when it carries one. */
|
|
57
|
+
wired: boolean
|
|
58
|
+
/** The version whose content was applied here, or null when none has been. */
|
|
59
|
+
contentApplied: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What became of the content half of an install. */
|
|
63
|
+
export interface ContentOutcome {
|
|
64
|
+
applied: boolean
|
|
65
|
+
/** Set when it was applied: the transfer run that can revert it. */
|
|
66
|
+
runId?: string
|
|
67
|
+
message: string
|
|
44
68
|
}
|
|
45
69
|
|
|
46
70
|
export interface MarketplaceOptions {
|
|
@@ -53,6 +77,17 @@ export interface MarketplaceOptions {
|
|
|
53
77
|
siteDir: string
|
|
54
78
|
/** Overridden by tests; the real one shells out to Bun. */
|
|
55
79
|
install?: PackageInstaller
|
|
80
|
+
/** The ids of the bundles whose server half this node has mounted. */
|
|
81
|
+
wired?: () => readonly string[]
|
|
82
|
+
/** The version of a package whose content has been applied here. */
|
|
83
|
+
contentApplied?: (packageName: string) => Promise<string | null>
|
|
84
|
+
/**
|
|
85
|
+
* Installs the content half of a bundle the site now depends on. Absent where
|
|
86
|
+
* content cannot be applied, which declines it for every install.
|
|
87
|
+
*/
|
|
88
|
+
applyContent?: (packageName: string) => Promise<ContentOutcome>
|
|
89
|
+
/** The site's `applyBundleContentOnInstall`. */
|
|
90
|
+
applyContentOnInstall?: boolean
|
|
56
91
|
}
|
|
57
92
|
|
|
58
93
|
export interface InstallOutcome {
|
|
@@ -64,6 +99,8 @@ export interface InstallOutcome {
|
|
|
64
99
|
*/
|
|
65
100
|
dependency?: { name: string; range: string; version: string }
|
|
66
101
|
message: string
|
|
102
|
+
/** What happened to the content the bundle carries; absent when it carries none. */
|
|
103
|
+
content?: ContentOutcome
|
|
67
104
|
}
|
|
68
105
|
|
|
69
106
|
export type PackageInstaller = (
|
|
@@ -102,8 +139,12 @@ export interface Marketplace {
|
|
|
102
139
|
/** Where someone browsing should be sent, when they want more than this list. */
|
|
103
140
|
url(): string
|
|
104
141
|
search(query: string | undefined): Promise<MarketplaceListing[]>
|
|
105
|
-
installed(): InstalledListing[]
|
|
106
|
-
install(
|
|
142
|
+
installed(): Promise<InstalledListing[]>
|
|
143
|
+
install(
|
|
144
|
+
name: string,
|
|
145
|
+
version: string | undefined,
|
|
146
|
+
options?: { applyContent?: boolean },
|
|
147
|
+
): Promise<InstallOutcome>
|
|
107
148
|
uninstall(name: string): Promise<InstallOutcome>
|
|
108
149
|
}
|
|
109
150
|
|
|
@@ -156,24 +197,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
156
197
|
let cached: { at: number; query: string; listings: MarketplaceListing[] } | undefined
|
|
157
198
|
|
|
158
199
|
const installedVersion = (name: string): string | null =>
|
|
159
|
-
options.extensions.
|
|
200
|
+
options.extensions.bundles().bundles.find((bundle) => bundle.packageName === name)?.version ??
|
|
201
|
+
null
|
|
160
202
|
|
|
161
|
-
/**
|
|
162
|
-
const declares = async (name: string): Promise<
|
|
203
|
+
/** What the registry says this package's latest version carries, when it is a valid bundle. */
|
|
204
|
+
const declares = async (name: string): Promise<BundlePackage['carries'] | undefined> => {
|
|
163
205
|
try {
|
|
164
206
|
const response = await call(`${options.registry}/${encodeURIComponent(name)}`, {
|
|
165
207
|
headers: { accept: 'application/json' },
|
|
166
208
|
})
|
|
167
|
-
if (!response.ok) return
|
|
209
|
+
if (!response.ok) return undefined
|
|
168
210
|
const packument = (await response.json()) as {
|
|
169
211
|
'dist-tags'?: { latest?: string }
|
|
170
|
-
versions?: Record<string,
|
|
212
|
+
versions?: Record<string, PackageJson>
|
|
171
213
|
}
|
|
172
214
|
const latest = packument['dist-tags']?.latest
|
|
173
215
|
const version = latest ? packument.versions?.[latest] : undefined
|
|
174
|
-
|
|
216
|
+
if (!version || validateBundlePackage(version).length > 0) return undefined
|
|
217
|
+
const field = version.crombolo as {
|
|
218
|
+
extensions?: unknown[]
|
|
219
|
+
content?: string
|
|
220
|
+
server?: string
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
extensions: Array.isArray(field.extensions) && field.extensions.length > 0,
|
|
224
|
+
content: field.content !== undefined,
|
|
225
|
+
server: field.server !== undefined,
|
|
226
|
+
}
|
|
175
227
|
} catch {
|
|
176
|
-
return
|
|
228
|
+
return undefined
|
|
177
229
|
}
|
|
178
230
|
}
|
|
179
231
|
|
|
@@ -213,8 +265,10 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
213
265
|
.filter((pkg): pkg is NonNullable<typeof pkg> =>
|
|
214
266
|
Boolean(pkg?.name && isValidPackageName(pkg.name)),
|
|
215
267
|
)
|
|
216
|
-
.map(async (pkg): Promise<MarketplaceListing> => {
|
|
268
|
+
.map(async (pkg): Promise<MarketplaceListing | undefined> => {
|
|
217
269
|
const name = pkg.name as string
|
|
270
|
+
const carries = await declares(name)
|
|
271
|
+
if (!carries) return undefined
|
|
218
272
|
return {
|
|
219
273
|
name,
|
|
220
274
|
version: pkg.version ?? '0.0.0',
|
|
@@ -225,29 +279,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
225
279
|
homepage: pkg.links?.homepage ?? null,
|
|
226
280
|
repository: pkg.links?.repository ?? null,
|
|
227
281
|
},
|
|
228
|
-
|
|
282
|
+
carries,
|
|
229
283
|
installedVersion: installedVersion(name),
|
|
230
284
|
}
|
|
231
285
|
}),
|
|
232
286
|
)
|
|
233
|
-
// Only what really
|
|
234
|
-
//
|
|
235
|
-
const offered = listings.filter((listing) => listing
|
|
287
|
+
// Only what really is a bundle: the keyword is a claim, the manifest is
|
|
288
|
+
// the thing itself.
|
|
289
|
+
const offered = listings.filter((listing): listing is MarketplaceListing => Boolean(listing))
|
|
236
290
|
cached = { at: Date.now(), query: text, listings: offered }
|
|
237
291
|
return offered
|
|
238
292
|
},
|
|
239
293
|
|
|
240
|
-
installed() {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
294
|
+
async installed() {
|
|
295
|
+
const wired = new Set(options.wired?.() ?? [])
|
|
296
|
+
return Promise.all(
|
|
297
|
+
options.extensions.bundles().bundles.map(async (bundle) => ({
|
|
298
|
+
packageName: bundle.packageName,
|
|
299
|
+
name: bundle.name,
|
|
300
|
+
id: bundle.id,
|
|
301
|
+
version: bundle.version,
|
|
302
|
+
extensionCount: bundle.field.extensions?.length ?? 0,
|
|
303
|
+
carries: bundle.carries,
|
|
304
|
+
wired: wired.has(bundle.id),
|
|
305
|
+
contentApplied: (await options.contentApplied?.(bundle.packageName)) ?? null,
|
|
306
|
+
})),
|
|
307
|
+
)
|
|
248
308
|
},
|
|
249
309
|
|
|
250
|
-
async install(name, version) {
|
|
310
|
+
async install(name, version, request = {}) {
|
|
251
311
|
if (!isValidPackageName(name))
|
|
252
312
|
return { ok: false, message: `'${name}' is not a package name npm would accept.` }
|
|
253
313
|
if (version !== undefined && !isValidVersion(version))
|
|
@@ -263,19 +323,44 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
|
|
|
263
323
|
}
|
|
264
324
|
|
|
265
325
|
const range = (await rangeFor(name)) ?? version ?? 'latest'
|
|
266
|
-
const found = options.extensions
|
|
326
|
+
const found = options.extensions
|
|
327
|
+
.bundles()
|
|
328
|
+
.bundles.find((bundle) => bundle.packageName === name)
|
|
267
329
|
if (!found)
|
|
268
330
|
return {
|
|
269
331
|
ok: true,
|
|
270
332
|
dependency: { name, range, version: version ?? range },
|
|
271
|
-
message: `${name} was installed, but it
|
|
333
|
+
message: `${name} was installed, but it is not a valid bundle, so nothing was added to the backoffice.`,
|
|
272
334
|
}
|
|
335
|
+
|
|
336
|
+
// The content half lands after the dependency, so a refused check leaves
|
|
337
|
+
// the package installed and says why, rather than undoing an install that
|
|
338
|
+
// succeeded.
|
|
339
|
+
let content: ContentOutcome | undefined
|
|
340
|
+
if (found.carries.content) {
|
|
341
|
+
const declined = request.applyContent === false || options.applyContentOnInstall === false
|
|
342
|
+
content =
|
|
343
|
+
declined || !options.applyContent
|
|
344
|
+
? {
|
|
345
|
+
applied: false,
|
|
346
|
+
message: `Its content was not applied. Run \`crombolo bundle install ${name}\` to apply it.`,
|
|
347
|
+
}
|
|
348
|
+
: await options.applyContent(name).catch((error: unknown) => ({
|
|
349
|
+
applied: false,
|
|
350
|
+
message: `Its content could not be applied: ${error instanceof Error ? error.message : String(error)}`,
|
|
351
|
+
}))
|
|
352
|
+
}
|
|
273
353
|
return {
|
|
274
354
|
ok: true,
|
|
275
355
|
dependency: { name, range, version: found.version },
|
|
276
356
|
// The install lives in this node's node_modules until the dependency is
|
|
277
357
|
// committed; saying so is the difference between a surprise and a plan.
|
|
278
|
-
message: `${name}@${found.version} is installed
|
|
358
|
+
message: `${name}@${found.version} is installed. Commit the changed package.json and bun.lock to keep it across deployments.${
|
|
359
|
+
found.carries.server
|
|
360
|
+
? ' Its server half runs only once crombolo.config.ts imports it and the site is deployed.'
|
|
361
|
+
: ''
|
|
362
|
+
}`,
|
|
363
|
+
...(content ? { content } : {}),
|
|
279
364
|
}
|
|
280
365
|
},
|
|
281
366
|
|
package/src/no-nodes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The holding page for a site with nothing published — Umbraco's "no nodes"
|
|
3
|
-
* screen, which `docs/
|
|
3
|
+
* screen, which `docs/features.md` asks for.
|
|
4
4
|
*
|
|
5
5
|
* It answers 404, not 200: a brand-new site is not a working one, and anything
|
|
6
6
|
* watching the URL should keep saying so until a page is published. It is only
|
package/src/ports.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from '@crombolo/data'
|
|
36
36
|
import type { PublishedCache } from '@crombolo/render'
|
|
37
37
|
import { createNodeLookup } from './access.ts'
|
|
38
|
+
import { createAuditHistory } from './adapters/audit.ts'
|
|
38
39
|
import { createBlueprintPort } from './adapters/blueprints.ts'
|
|
39
40
|
import { createPackagePort } from './adapters/bundles.ts'
|
|
40
41
|
import {
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
type SchemaFiles,
|
|
59
60
|
type SchemaFileWriter,
|
|
60
61
|
} from './adapters/schema-files.ts'
|
|
62
|
+
import { createSearchIndexPort } from './adapters/search-index.ts'
|
|
61
63
|
import { createTemplateFileStore } from './adapters/template-files.ts'
|
|
62
64
|
import { createTemplateQueryPort } from './adapters/template-query.ts'
|
|
63
65
|
import { avatarUrls, createUserPorts } from './adapters/users.ts'
|
|
@@ -89,7 +91,7 @@ const CULTURES = [
|
|
|
89
91
|
|
|
90
92
|
function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): ServerPort {
|
|
91
93
|
return {
|
|
92
|
-
//
|
|
94
|
+
// Story 080 derives this from migration state; until then the site is always up.
|
|
93
95
|
status: () => ({ serverStatus: 'Run' }),
|
|
94
96
|
configuration: () => ({
|
|
95
97
|
// Reported false when no link could be delivered, so the client does not
|
|
@@ -374,6 +376,8 @@ export interface DepsOptions {
|
|
|
374
376
|
* tells the other nodes to look; without it they keep the view they imported.
|
|
375
377
|
*/
|
|
376
378
|
onViewsChange?: (alias: string) => void | Promise<void>
|
|
379
|
+
/** Items were queued for the search index; the indexer can start on them now. */
|
|
380
|
+
onSearchQueued?: () => void
|
|
377
381
|
}
|
|
378
382
|
|
|
379
383
|
function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
|
|
@@ -514,12 +518,14 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
|
|
|
514
518
|
stylesheets: deps.stylesheets,
|
|
515
519
|
scripts: deps.scripts,
|
|
516
520
|
})
|
|
521
|
+
deps.auditHistory = createAuditHistory(options.db, options.config.auditRetentionDays)
|
|
517
522
|
if (options.cache) {
|
|
518
523
|
const cache = options.cache
|
|
519
524
|
deps.templateQuery = createTemplateQueryPort({
|
|
520
525
|
cache,
|
|
521
526
|
types: new ContentTypeRepository(options.db),
|
|
522
527
|
})
|
|
528
|
+
deps.searchIndex = createSearchIndexPort(options.db, options.onSearchQueued)
|
|
523
529
|
deps.publishedCache = {
|
|
524
530
|
reload: () => cache.invalidate(),
|
|
525
531
|
rebuild: async () => {
|
package/src/schema.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema-as-code at runtime: the boot sync, the development watcher that
|
|
3
3
|
* re-syncs on file changes, and the cache-instruction poll that keeps this
|
|
4
|
-
* node's caches coherent with the others. docs/
|
|
4
|
+
* node's caches coherent with the others. docs/schema-as-code.md.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, watch } from 'node:fs'
|