@crombolo/server 0.13.2 → 0.14.4

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.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * A whole site in one file (story 104): every content node, media file, schema
3
+ * file, component, stylesheet, script and dictionary item, as one private npm
4
+ * package tarball — the bundle format with `scope: 'site'`. `docs/bundles.md`.
5
+ *
6
+ * Written as it goes. The media files stream from the store into the gzip, and
7
+ * the integrity hash is computed as each file passes, in the path order
8
+ * `bundleIntegrity` defines; only the small files are held. The tarball is
9
+ * written beside its destination and renamed into place once complete, so a
10
+ * failed export never leaves a file that looks whole.
11
+ */
12
+ import { rename, rm } from 'node:fs/promises'
13
+ import { discoverBundles } from '@crombolo/backoffice-host'
14
+ import { type Db, NodeRepository, type NodeRow, ObjectTypes } from '@crombolo/data'
15
+ import {
16
+ BUNDLE_SECTIONS,
17
+ type BundleFile,
18
+ type BundlePackageInfo,
19
+ blobPath,
20
+ bundleIdFor,
21
+ bundlePackageJson,
22
+ exportBundle,
23
+ exportPeople,
24
+ MANIFEST_FILE,
25
+ newSeal,
26
+ PAYLOAD_DIR,
27
+ packageNameFor,
28
+ planStreamedBundle,
29
+ sanitise,
30
+ sealBytes,
31
+ writeManifest,
32
+ } from '@crombolo/transfer'
33
+ import { createFileSystemPort } from './adapters/file-system.ts'
34
+ import { createTemplateFileStore } from './adapters/template-files.ts'
35
+ import { type CromboloConfig, VERSION } from './config.ts'
36
+ import { mediaStoreFor } from './media-files.ts'
37
+ import { type StoredFile, streamOf } from './media-store.ts'
38
+ import { siteSections } from './site-sections.ts'
39
+ import { TarballWriter } from './tar.ts'
40
+
41
+ export interface SiteExportOptions {
42
+ /** The `.tgz` to write. */
43
+ out: string
44
+ /**
45
+ * False for `--no-media`: the media nodes travel, their files do not.
46
+ * `referenced`: likewise, for a remote and its CLI to move the files one at a
47
+ * time (story 107).
48
+ */
49
+ media?: boolean | 'referenced'
50
+ /** Carry the latest drafts rather than what is published. */
51
+ drafts?: boolean
52
+ withMembers?: boolean
53
+ withUsers?: boolean
54
+ /** Replace every personal value before anything is written (`sanitise`). */
55
+ anonymise?: boolean
56
+ /**
57
+ * Seal the content data — property values and media files — as well as any
58
+ * people, under `passphrase`: the bundle rule, metadata readable and content
59
+ * not (story 107). Each media file is sealed whole, one at a time.
60
+ */
61
+ sealContent?: boolean
62
+ /** Seals the people, and the content with `sealContent`; needed when either travels sealed. */
63
+ passphrase?: string
64
+ package?: { name?: string; version?: string }
65
+ }
66
+
67
+ export interface SiteExportResult {
68
+ out: string
69
+ bundleId: string
70
+ package: { name: string; version: string }
71
+ counts: Record<string, number>
72
+ sections: Partial<Record<keyof typeof BUNDLE_SECTIONS, number>>
73
+ media: { carried: number; bytes: number; missing: string[]; omitted: boolean }
74
+ people: { members: number; users: number }
75
+ /** How many distinct email addresses `--anonymise` replaced. */
76
+ anonymised?: number
77
+ }
78
+
79
+ /** The content kinds a whole site carries, each from its tree's top. */
80
+ const SITE_TREES = [
81
+ ObjectTypes.Document,
82
+ ObjectTypes.Media,
83
+ ObjectTypes.Element,
84
+ ObjectTypes.DocumentBlueprint,
85
+ ]
86
+
87
+ async function topLevelNodes(db: Db): Promise<NodeRow[]> {
88
+ const nodes = new NodeRepository(db)
89
+ const roots: NodeRow[] = []
90
+ for (const tree of SITE_TREES) {
91
+ for (let skip = 0; ; skip += 500) {
92
+ const page = await nodes.roots(tree, skip, 500)
93
+ roots.push(...page.items)
94
+ if (skip + 500 >= page.total) break
95
+ }
96
+ }
97
+ return roots
98
+ }
99
+
100
+ /** The server bundles the site wires, by package name and version. */
101
+ function requiredPackages(config: CromboloConfig): Record<string, string> {
102
+ if (config.bundles.length === 0) return {}
103
+ const wired = new Set(config.bundles.map((bundle) => bundle.id))
104
+ const required: Record<string, string> = {}
105
+ for (const found of discoverBundles(config.siteDir).bundles)
106
+ if (wired.has(found.id)) required[found.packageName] = found.version
107
+ return required
108
+ }
109
+
110
+ export async function exportSite(
111
+ db: Db,
112
+ config: CromboloConfig,
113
+ options: SiteExportOptions,
114
+ ): Promise<SiteExportResult> {
115
+ const withMedia = options.media !== false && options.media !== 'referenced'
116
+ const carryMembers = Boolean(options.withMembers)
117
+ // Anonymised users are removed, so there are none to carry.
118
+ const carryUsers = Boolean(options.withUsers) && !options.anonymise
119
+ if ((carryMembers || carryUsers) && !options.passphrase)
120
+ throw new Error('Members and users travel sealed: give a passphrase.')
121
+ if (options.sealContent && !options.passphrase)
122
+ throw new Error('Sealed content needs a passphrase.')
123
+
124
+ const exported = await exportBundle(db, {
125
+ roots: await topLevelNodes(db),
126
+ asGiven: [],
127
+ descendants: true,
128
+ snapshot: options.drafts ? 'drafts' : 'published',
129
+ blueprints: true,
130
+ withBlobs: withMedia,
131
+ siteName: config.siteName,
132
+ nodeId: config.nodeId,
133
+ })
134
+
135
+ let sections: BundleFile[] = await siteSections(
136
+ {
137
+ db,
138
+ componentFiles: createTemplateFileStore(config.componentsDir),
139
+ components: createFileSystemPort(config.componentsDir, {
140
+ extension: '.tsx',
141
+ rewrites: ['.cshtml'],
142
+ }),
143
+ stylesheets: createFileSystemPort(config.stylesheetsDir, { extension: '.css' }),
144
+ scripts: createFileSystemPort(config.scriptsDir, { extension: '.js' }),
145
+ schemaDir: config.schemaDir,
146
+ },
147
+ 'all',
148
+ )
149
+
150
+ let set = exported.set
151
+ let people =
152
+ carryMembers || carryUsers
153
+ ? await exportPeople(db, { members: carryMembers, users: carryUsers })
154
+ : undefined
155
+ let anonymised: number | undefined
156
+ if (options.anonymise) {
157
+ const dictionaryPath = `${BUNDLE_SECTIONS.dictionary}.udt`
158
+ const dictionary = sections.find((file) => file.path === dictionaryPath)
159
+ const clean = sanitise({ set, people, dictionary: dictionary?.text })
160
+ set = clean.set
161
+ people = clean.people
162
+ anonymised = clean.emails
163
+ if (dictionary && clean.dictionary !== undefined)
164
+ sections = sections.map((file) =>
165
+ file.path === dictionaryPath ? { ...file, text: clean.dictionary } : file,
166
+ )
167
+ }
168
+
169
+ // Each media file is looked up once: its size goes in the manifest now, its
170
+ // bytes stream into the tarball later.
171
+ const store = await mediaStoreFor(config)
172
+ const stored = new Map<string, StoredFile>()
173
+ const missing: string[] = []
174
+ // Referenced, each file is named with its size and etag, so the far side can
175
+ // fetch it — or skip it, holding one with the same etag already.
176
+ const described = new Map<string, { size: number; etag: string }>()
177
+ if (withMedia || options.media === 'referenced')
178
+ for (const key of exported.blobKeys) {
179
+ const file = await store.get(key)
180
+ if (!file) missing.push(key)
181
+ else if (withMedia) stored.set(key, file)
182
+ else described.set(key, { size: file.size, etag: file.etag })
183
+ }
184
+
185
+ const site = {
186
+ ...set,
187
+ manifest: {
188
+ ...set.manifest,
189
+ blobs: set.manifest.blobs.map((blob) => {
190
+ const found = described.get(blob.key)
191
+ return found ? { ...blob, size: found.size, etag: found.etag } : blob
192
+ }),
193
+ label: `${config.siteName} (whole site)`,
194
+ provenance: { ...set.manifest.provenance, cmsVersion: VERSION },
195
+ scope: 'site' as const,
196
+ ...(withMedia
197
+ ? {}
198
+ : {
199
+ media: options.media === 'referenced' ? ('referenced' as const) : ('omitted' as const),
200
+ }),
201
+ requires: requiredPackages(config),
202
+ },
203
+ }
204
+ const seal =
205
+ (people || options.sealContent) && options.passphrase ? newSeal(options.passphrase) : undefined
206
+ const planned = planStreamedBundle(
207
+ site,
208
+ new Map([...stored].map(([key, file]) => [key, file.size])),
209
+ sections,
210
+ { people, seal, content: options.sealContent ? 'sealed' : 'open' },
211
+ )
212
+
213
+ const name = options.package?.name ?? packageNameFor(`${config.siteName}-site`)
214
+ const info: BundlePackageInfo = {
215
+ name,
216
+ version: options.package?.version ?? '1.0.0',
217
+ id: bundleIdFor(name.replace(/^@[^/]+\//, '')),
218
+ label: site.manifest.label,
219
+ private: true,
220
+ peerRange: `>=${VERSION}`,
221
+ }
222
+
223
+ const partial = `${options.out}.partial`
224
+ const writer = TarballWriter.create(partial)
225
+ const encoder = new TextEncoder()
226
+ const hasher = new Bun.CryptoHasher('sha256')
227
+ let bytes = 0
228
+ try {
229
+ const small = new Map(planned.files.map((file) => [file.path, file]))
230
+ const blobs = new Map(planned.blobs.map((key) => [blobPath(key), key]))
231
+ const paths = [...small.keys(), ...blobs.keys()].sort((a, b) => a.localeCompare(b))
232
+ for (const path of paths) {
233
+ hasher.update(path)
234
+ hasher.update('\0')
235
+ const file = small.get(path)
236
+ if (file) {
237
+ const body = file.bytes ?? encoder.encode(file.text ?? '')
238
+ hasher.update(body)
239
+ await writer.add(`${PAYLOAD_DIR}/${path}`, body)
240
+ } else {
241
+ const media = stored.get(blobs.get(path) as string) as StoredFile
242
+ if (planned.sealing && seal) {
243
+ const sealed = sealBytes(seal.key, path, await media.bytes())
244
+ hasher.update(sealed)
245
+ hasher.update('\n')
246
+ await writer.add(`${PAYLOAD_DIR}/${path}`, sealed)
247
+ bytes += media.size
248
+ continue
249
+ }
250
+ async function* hashed() {
251
+ for await (const chunk of streamOf(media)) {
252
+ hasher.update(chunk)
253
+ yield chunk
254
+ }
255
+ }
256
+ await writer.addStream(`${PAYLOAD_DIR}/${path}`, media.size, hashed())
257
+ bytes += media.size
258
+ }
259
+ hasher.update('\n')
260
+ }
261
+ const manifest = { ...planned.manifest, integrity: `sha256-${hasher.digest('hex')}` }
262
+ await writer.add(`${PAYLOAD_DIR}/${MANIFEST_FILE}`, encoder.encode(writeManifest(manifest)))
263
+ await writer.add(
264
+ 'package.json',
265
+ encoder.encode(`${JSON.stringify(bundlePackageJson(info), null, 2)}\n`),
266
+ )
267
+ await writer.close()
268
+ await rename(partial, options.out)
269
+ } catch (error) {
270
+ await writer.abort()
271
+ await rm(partial, { force: true })
272
+ throw error
273
+ }
274
+
275
+ const counted: SiteExportResult['sections'] = {}
276
+ for (const file of sections) {
277
+ const section = (Object.keys(BUNDLE_SECTIONS) as (keyof typeof BUNDLE_SECTIONS)[]).find(
278
+ (s) => file.path === `${BUNDLE_SECTIONS[s]}.udt` || file.path.startsWith(`${s}/`),
279
+ )
280
+ if (section) counted[section] = (counted[section] ?? 0) + 1
281
+ }
282
+ return {
283
+ out: options.out,
284
+ bundleId: site.manifest.id,
285
+ package: { name: info.name, version: info.version },
286
+ counts: { ...site.manifest.counts },
287
+ sections: counted,
288
+ media: { carried: stored.size, bytes, missing, omitted: !withMedia },
289
+ people: { members: people?.members.length ?? 0, users: people?.users.length ?? 0 },
290
+ ...(anonymised !== undefined ? { anonymised } : {}),
291
+ }
292
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Replacing a site with a whole-site file (story 105): what has to be true
3
+ * before it starts, and what the site has that the file does not.
4
+ *
5
+ * `--replace` recycles content and media the file does not carry, so a revert
6
+ * brings them back, and keeps the site's people. `--replace-all` deletes them,
7
+ * and the members and users the file does not carry too.
8
+ */
9
+ import { existsSync } from 'node:fs'
10
+ import { join, sep } from 'node:path'
11
+ import { discoverBundles } from '@crombolo/backoffice-host'
12
+ import {
13
+ currentSchemaState,
14
+ type Db,
15
+ DocumentRepository,
16
+ MemberRepository,
17
+ ObjectTypes,
18
+ TransferRunRepository,
19
+ UserRepository,
20
+ } from '@crombolo/data'
21
+ import type { BundleManifest, BundlePeople, ContentSet } from '@crombolo/transfer'
22
+
23
+ export type ReplaceMode = 'replace' | 'replace-all'
24
+
25
+ /**
26
+ * The server bundles a file needs and this site lacks, or has older, each
27
+ * named with the version wanted.
28
+ */
29
+ export function requiresProblems(manifest: BundleManifest, siteDir: string): string[] {
30
+ const required = Object.entries(manifest.requires ?? {})
31
+ if (required.length === 0) return []
32
+ const installed = new Map(
33
+ discoverBundles(siteDir).bundles.map((found) => [found.packageName, found.version]),
34
+ )
35
+ const problems: string[] = []
36
+ for (const [name, version] of required) {
37
+ const here = installed.get(name)
38
+ if (!here) problems.push(`the file needs ${name}@${version}, which this site does not have`)
39
+ else if (Bun.semver.order(here, version) < 0)
40
+ problems.push(`the file needs ${name}@${version}, and this site has ${here}`)
41
+ }
42
+ return problems
43
+ }
44
+
45
+ /** Whether the file's people include someone who can administer the back office. */
46
+ export function carriesAdministrator(people: BundlePeople | undefined): boolean {
47
+ return (people?.users ?? []).some((user) => !user.disabled && user.groupAliases.includes('admin'))
48
+ }
49
+
50
+ const PRUNED_KINDS = [
51
+ { objectType: ObjectTypes.Document, kind: 'document' },
52
+ { objectType: ObjectTypes.Media, kind: 'media' },
53
+ { objectType: ObjectTypes.Element, kind: 'element' },
54
+ { objectType: ObjectTypes.DocumentBlueprint, kind: 'blueprint' },
55
+ ] as const
56
+
57
+ export interface PrunedNode {
58
+ key: string
59
+ kind: (typeof PRUNED_KINDS)[number]['kind']
60
+ head: number | undefined
61
+ published: number | undefined
62
+ }
63
+
64
+ /**
65
+ * Takes out what the site has and the file does not: the topmost of each such
66
+ * branch, recycled or deleted, inside the caller's transaction. Answers what
67
+ * was recycled, for the run to record so its revert can bring it back.
68
+ */
69
+ export async function pruneSite(
70
+ tx: Db,
71
+ set: ContentSet,
72
+ mode: ReplaceMode,
73
+ nodeId: string,
74
+ ): Promise<PrunedNode[]> {
75
+ const inFile = new Set(set.nodes.map((node) => node.key.toLowerCase()))
76
+ const state = await currentSchemaState(tx)
77
+ const nodeState = { version: state.version, revision: state.revision }
78
+ const pruned: PrunedNode[] = []
79
+ for (const { objectType, kind } of PRUNED_KINDS) {
80
+ const rows = await tx.query<{ id: number; unique_id: string; parent_id: number }>(
81
+ 'SELECT id, unique_id, parent_id FROM node WHERE node_object_type = ? AND trashed = ?',
82
+ [objectType, tx.dialect.boolValue(false)],
83
+ )
84
+ const leaving = rows.filter((row) => !inFile.has(String(row.unique_id).toLowerCase()))
85
+ const leavingIds = new Set(leaving.map((row) => Number(row.id)))
86
+ // The top of each branch: recycling or deleting it takes its children too.
87
+ const tops = leaving.filter((row) => !leavingIds.has(Number(row.parent_id)))
88
+ const repo = new DocumentRepository(tx, { kind, nodeState, nodeId, inTreeLock: true })
89
+ for (const row of tops) {
90
+ const key = String(row.unique_id)
91
+ if (mode === 'replace-all') {
92
+ await repo.delete(key)
93
+ continue
94
+ }
95
+ const markers = await repo.eventMarkers(key)
96
+ await repo.moveToRecycleBin(key)
97
+ pruned.push({ key, kind, head: markers?.head, published: markers?.published })
98
+ }
99
+ }
100
+ return pruned
101
+ }
102
+
103
+ /** Members and users the file does not carry, deleted: `--replace-all` only. */
104
+ export async function prunePeople(tx: Db, people: BundlePeople | undefined): Promise<void> {
105
+ const members = new Set((people?.members ?? []).map((m) => m.key.toLowerCase()))
106
+ const users = new Set((people?.users ?? []).map((u) => u.key.toLowerCase()))
107
+ const state = await currentSchemaState(tx)
108
+ const memberRepo = new MemberRepository(tx, {
109
+ nodeState: { version: state.version, revision: state.revision },
110
+ })
111
+ const memberRows = await tx.query<{ unique_id: string }>(
112
+ 'SELECT unique_id FROM node WHERE node_object_type = ?',
113
+ [ObjectTypes.Member],
114
+ )
115
+ for (const row of memberRows)
116
+ if (!members.has(String(row.unique_id).toLowerCase()))
117
+ await memberRepo.delete(String(row.unique_id))
118
+ const userRepo = new UserRepository(tx)
119
+ for (const row of await tx.query<{ key: string }>('SELECT key FROM user_account'))
120
+ if (!users.has(String(row.key).toLowerCase())) await userRepo.delete(String(row.key))
121
+ }
122
+
123
+ /** Records what a replace recycled on its run, so `content revert` brings it back. */
124
+ export async function recordPruned(tx: Db, runId: string, pruned: PrunedNode[]): Promise<void> {
125
+ const runs = new TransferRunRepository(tx)
126
+ for (const node of pruned)
127
+ await runs.record({
128
+ runId,
129
+ nodeKey: node.key,
130
+ kind: node.kind,
131
+ action: 'recycle',
132
+ beforeEventId: node.head ?? null,
133
+ beforePublishedEventId: node.published ?? null,
134
+ eventId: null,
135
+ })
136
+ }
137
+
138
+ export interface ReplaceSummary {
139
+ /** What the site has that the file does not, by kind: recycled, or deleted. */
140
+ leaving: Record<string, number>
141
+ /** Files in the site's folders that the file does not carry. */
142
+ filesRemoved: string[]
143
+ /** Members and users `--replace-all` would delete. */
144
+ people: { members: number; users: number }
145
+ }
146
+
147
+ /** Read-only: what replacing this site with the file would take out. */
148
+ export async function replaceSummary(
149
+ db: Db,
150
+ set: ContentSet,
151
+ carriedPaths: ReadonlySet<string>,
152
+ folders: ReadonlyArray<{ section: string; dir: string }>,
153
+ mode: ReplaceMode,
154
+ ): Promise<ReplaceSummary> {
155
+ const inFile = new Set(set.nodes.map((node) => node.key.toLowerCase()))
156
+ const leaving: Record<string, number> = {}
157
+ for (const { objectType, kind } of PRUNED_KINDS) {
158
+ const rows = await db.query<{ unique_id: string }>(
159
+ 'SELECT unique_id FROM node WHERE node_object_type = ? AND trashed = ?',
160
+ [objectType, db.dialect.boolValue(false)],
161
+ )
162
+ const count = rows.filter((row) => !inFile.has(String(row.unique_id).toLowerCase())).length
163
+ if (count > 0) leaving[kind] = count
164
+ }
165
+ const filesRemoved: string[] = []
166
+ for (const { section, dir } of folders) {
167
+ if (!existsSync(dir)) continue
168
+ for (const path of new Bun.Glob('**/*').scanSync({ cwd: dir, onlyFiles: true })) {
169
+ const bundlePath = `${section}/${path.split(sep).join('/')}`
170
+ if (!carriedPaths.has(bundlePath)) filesRemoved.push(join(dir, path))
171
+ }
172
+ }
173
+ let people = { members: 0, users: 0 }
174
+ if (mode === 'replace-all') {
175
+ const members = new Set((set.people?.members ?? []).map((m) => m.key.toLowerCase()))
176
+ const users = new Set((set.people?.users ?? []).map((u) => u.key.toLowerCase()))
177
+ const memberRows = await db.query<{ unique_id: string }>(
178
+ 'SELECT unique_id FROM node WHERE node_object_type = ?',
179
+ [ObjectTypes.Member],
180
+ )
181
+ const userRows = await db.query<{ key: string }>('SELECT key FROM user_account')
182
+ people = {
183
+ members: memberRows.filter((r) => !members.has(String(r.unique_id).toLowerCase())).length,
184
+ users: userRows.filter((r) => !users.has(String(r.key).toLowerCase())).length,
185
+ }
186
+ }
187
+ return { leaving, filesRemoved, people }
188
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Site search for a render (story 140): the published-only index, narrowed to
3
+ * what the person asking may open, each hit read back from the published cache
4
+ * so a page unpublished since it was indexed is never shown. docs/search.md.
5
+ */
6
+ import { branchAccess } from '@crombolo/core'
7
+ import { type Db, PublicAccessRepository, SiteSearch } from '@crombolo/data'
8
+ import {
9
+ excerpt,
10
+ type PublishedCache,
11
+ type RequestMember,
12
+ type SearchResult,
13
+ type SiteSearchPort,
14
+ } from '@crombolo/render'
15
+
16
+ /** The search `search()` calls during one render, for `member` or for nobody signed in. */
17
+ export function siteSearchFor(
18
+ db: Db,
19
+ cache: PublishedCache,
20
+ member: RequestMember | undefined,
21
+ ): SiteSearchPort {
22
+ return async (query, options) => {
23
+ const rules = await new PublicAccessRepository(db).allRules()
24
+ const access = branchAccess(
25
+ rules,
26
+ member ? { username: member.username, groups: member.roles } : undefined,
27
+ )
28
+ const culture = options.culture ?? (await cache.defaultCulture())
29
+ const hits = await new SiteSearch(db).query(query, {
30
+ culture: culture ?? '',
31
+ types: options.types,
32
+ skip: options.skip,
33
+ take: options.take,
34
+ access,
35
+ })
36
+ const nav = await cache.navigation(culture)
37
+ const items: SearchResult[] = []
38
+ for (const hit of hits.items) {
39
+ const content = nav.byKey(hit.key)
40
+ if (!content) continue
41
+ items.push({
42
+ content,
43
+ url: content.url,
44
+ name: content.name,
45
+ score: hit.score,
46
+ excerpt: excerpt(hit.text, query),
47
+ })
48
+ }
49
+ return { total: hits.total, items }
50
+ }
51
+ }