@crombolo/cli 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/bin/crombolo.ts +2108 -0
- package/package.json +42 -4
- package/src/index.ts +7 -0
- package/src/operations/commands.ts +775 -0
- package/src/operations/sections.ts +423 -0
- package/src/templates.ts +309 -0
- package/templates/basic/bundle/bundle.json +45 -0
- package/templates/basic/bundle/nodes/36955f6e-d110-469e-b280-72096eccd148.json +38 -0
- package/templates/basic/files/components/homePage.tsx +22 -0
- package/templates/basic/files/schema/content-types/home-page.toml +28 -0
- package/templates/basic/files/schema/schema.toml +2 -0
- package/templates/basic/template.json +4 -0
- package/templates/demo/harbourstone/CREDITS.md +42 -0
- package/templates/demo/harbourstone/bundle/blobs/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
- package/templates/demo/harbourstone/bundle/blobs/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
- package/templates/demo/harbourstone/bundle/blobs/6be45535/bottle-oloroso-pier.jpg +0 -0
- package/templates/demo/harbourstone/bundle/blobs/6d8d6398/bottle-the-cove.jpg +0 -0
- package/templates/demo/harbourstone/bundle/blobs/7b4713f8/copper-pot-stills.jpg +0 -0
- package/templates/demo/harbourstone/bundle/blobs/c114b7d8/bottle-neap-tide.jpg +0 -0
- package/templates/demo/harbourstone/bundle/blobs/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
- package/templates/demo/harbourstone/bundle/bundle.json +197 -0
- package/templates/demo/harbourstone/bundle/nodes/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +60 -0
- package/templates/demo/harbourstone/bundle/nodes/22144db2-e214-4006-a0bb-53b5bd121fd6.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/26e87610-e2d6-4b47-bf92-3967f7186907.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/2caa099b-3884-472d-9cea-797a0f0c376f.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +42 -0
- package/templates/demo/harbourstone/bundle/nodes/2fe03891-1294-4344-8e24-47e8b212e5b0.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +95 -0
- package/templates/demo/harbourstone/bundle/nodes/3dc30fc1-973a-4217-b26f-24902210be17.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +101 -0
- package/templates/demo/harbourstone/bundle/nodes/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +60 -0
- package/templates/demo/harbourstone/bundle/nodes/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +101 -0
- package/templates/demo/harbourstone/bundle/nodes/6be45535-83e5-4469-acf0-4127016721ae.json +60 -0
- package/templates/demo/harbourstone/bundle/nodes/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +60 -0
- package/templates/demo/harbourstone/bundle/nodes/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +60 -0
- package/templates/demo/harbourstone/bundle/nodes/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/97469df2-107c-4010-8c14-4de88f735529.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +101 -0
- package/templates/demo/harbourstone/bundle/nodes/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/c114b7d8-0138-404b-9d8f-a39d992577a2.json +60 -0
- package/templates/demo/harbourstone/bundle/nodes/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +136 -0
- package/templates/demo/harbourstone/bundle/nodes/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +101 -0
- package/templates/demo/harbourstone/bundle/nodes/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +150 -0
- package/templates/demo/harbourstone/bundle/nodes/d7f13222-d060-4496-b86d-bef21da963c1.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +35 -0
- package/templates/demo/harbourstone/bundle/nodes/fd337567-df67-41ca-ae94-9d84d8d6042f.json +60 -0
- package/templates/demo/harbourstone/files/components/pages/contactPage.tsx +36 -0
- package/templates/demo/harbourstone/files/components/pages/contentPage.tsx +54 -0
- package/templates/demo/harbourstone/files/components/pages/expression.tsx +33 -0
- package/templates/demo/harbourstone/files/components/pages/homePage.tsx +71 -0
- package/templates/demo/harbourstone/files/components/pages/whiskyList.tsx +19 -0
- package/templates/demo/harbourstone/files/components/shared/bottling.tsx +59 -0
- package/templates/demo/harbourstone/files/components/shared/highlights.tsx +39 -0
- package/templates/demo/harbourstone/files/components/shared/layout.tsx +49 -0
- package/templates/demo/harbourstone/files/components/shared/section-intro.tsx +40 -0
- package/templates/demo/harbourstone/files/components/shared/site-footer.tsx +14 -0
- package/templates/demo/harbourstone/files/components/shared/site-header.tsx +57 -0
- package/templates/demo/harbourstone/files/css/site.css +720 -0
- package/templates/demo/harbourstone/files/schema/content-types/contact-page.toml +75 -0
- package/templates/demo/harbourstone/files/schema/content-types/content-page.toml +89 -0
- package/templates/demo/harbourstone/files/schema/content-types/expression.toml +78 -0
- package/templates/demo/harbourstone/files/schema/content-types/highlight.toml +27 -0
- package/templates/demo/harbourstone/files/schema/content-types/home-page.toml +104 -0
- package/templates/demo/harbourstone/files/schema/content-types/tasting-note.toml +26 -0
- package/templates/demo/harbourstone/files/schema/content-types/whisky-list.toml +35 -0
- package/templates/demo/harbourstone/files/schema/data-types/highlight-picker.toml +10 -0
- package/templates/demo/harbourstone/files/schema/data-types/tasting-note-picker.toml +11 -0
- package/templates/demo/harbourstone/files/schema/forms/contact.toml +119 -0
- package/templates/demo/harbourstone/files/schema/schema.toml +2 -0
- package/templates/demo/harbourstone/template.json +10 -0
- package/templates/starter/bundle/bundle.json +49 -0
- package/templates/starter/bundle/nodes/634dd948-cf83-4cad-9b7b-7c938b33da76.json +62 -0
- package/templates/starter/bundle/nodes/64d95359-d91a-4050-b440-3e5124a74999.json +62 -0
- package/templates/starter/bundle/nodes/d2997ebb-36ca-47cb-bbc8-31fefca787c0.json +45 -0
- package/templates/starter/files/components/pages/contentPage.tsx +25 -0
- package/templates/starter/files/components/shared/layout.tsx +24 -0
- package/templates/starter/files/components/shared/site-footer.tsx +9 -0
- package/templates/starter/files/components/shared/site-header.tsx +40 -0
- package/templates/starter/files/css/site.css +117 -0
- package/templates/starter/files/schema/content-types/content-page.toml +51 -0
- package/templates/starter/files/schema/schema.toml +2 -0
- package/templates/starter/template.json +4 -0
- package/README.md +0 -3
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commands an operator runs against an environment, as functions: they take
|
|
3
|
+
* options and return what happened, and never print or exit. The `crombolo`
|
|
4
|
+
* binary renders their results for a terminal; anything else that runs them —
|
|
5
|
+
* a platform's own tooling — gets the same results as data. One implementation,
|
|
6
|
+
* so the two can never disagree about what an import or an upgrade does.
|
|
7
|
+
*/
|
|
8
|
+
import { ObjectTypes, ROOT_ACCESS } from '@crombolo/core'
|
|
9
|
+
import {
|
|
10
|
+
connect,
|
|
11
|
+
cromboloPlan,
|
|
12
|
+
type Db,
|
|
13
|
+
describeRefFailure,
|
|
14
|
+
type NodeRow,
|
|
15
|
+
pauseWrites,
|
|
16
|
+
planUpgrade,
|
|
17
|
+
readWritesPaused,
|
|
18
|
+
recordInLedger,
|
|
19
|
+
resolveNodeRef,
|
|
20
|
+
resumeWrites,
|
|
21
|
+
type WritesPaused,
|
|
22
|
+
writeReport,
|
|
23
|
+
} from '@crombolo/data'
|
|
24
|
+
import { loadSchemaDirectory, loadValueMigrations, runFix, runUpgrade } from '@crombolo/schema'
|
|
25
|
+
import {
|
|
26
|
+
type BackupResult,
|
|
27
|
+
backupBefore,
|
|
28
|
+
bootSchema,
|
|
29
|
+
bootstrapDatabase,
|
|
30
|
+
type CromboloConfig,
|
|
31
|
+
checkSchemaDirectory,
|
|
32
|
+
componentAliasesIn,
|
|
33
|
+
createFileIntake,
|
|
34
|
+
createMediaPort,
|
|
35
|
+
installDatabase,
|
|
36
|
+
MediaFileStore,
|
|
37
|
+
mediaStoreFor,
|
|
38
|
+
placeBlobs,
|
|
39
|
+
readBlobs,
|
|
40
|
+
syncOptionsFor,
|
|
41
|
+
} from '@crombolo/server'
|
|
42
|
+
import {
|
|
43
|
+
type CheckOptions,
|
|
44
|
+
type ContentSet,
|
|
45
|
+
checkBundle,
|
|
46
|
+
exportBundle,
|
|
47
|
+
importBundle,
|
|
48
|
+
loadBundle,
|
|
49
|
+
loadResolutions,
|
|
50
|
+
type Resolution,
|
|
51
|
+
type TransferCheck,
|
|
52
|
+
writeBundle,
|
|
53
|
+
} from '@crombolo/transfer'
|
|
54
|
+
import {
|
|
55
|
+
applySections,
|
|
56
|
+
type CarriedSections,
|
|
57
|
+
carriedSections,
|
|
58
|
+
carriesSections,
|
|
59
|
+
planSections,
|
|
60
|
+
promoteBackups,
|
|
61
|
+
replacementsNeedingPermission,
|
|
62
|
+
type SectionFile,
|
|
63
|
+
type SectionOptions,
|
|
64
|
+
type SectionPlan,
|
|
65
|
+
type SectionResult,
|
|
66
|
+
stageBackups,
|
|
67
|
+
} from './sections.ts'
|
|
68
|
+
|
|
69
|
+
export {
|
|
70
|
+
isResolution,
|
|
71
|
+
RESOLUTIONS,
|
|
72
|
+
type Resolution,
|
|
73
|
+
type TransferCheck,
|
|
74
|
+
} from '@crombolo/transfer'
|
|
75
|
+
// The section half of an install, exported for the same reason the commands
|
|
76
|
+
// are: tooling that applies a bundle without a terminal needs the plan too.
|
|
77
|
+
export {
|
|
78
|
+
applySections,
|
|
79
|
+
type CarriedSections,
|
|
80
|
+
carriedSections,
|
|
81
|
+
carriesSections,
|
|
82
|
+
type FileAction,
|
|
83
|
+
planSections,
|
|
84
|
+
promoteBackups,
|
|
85
|
+
type RestoredFiles,
|
|
86
|
+
RUN_BACKUP_DIR,
|
|
87
|
+
replacementsNeedingPermission,
|
|
88
|
+
restoreSectionFiles,
|
|
89
|
+
type SectionFile,
|
|
90
|
+
type SectionOptions,
|
|
91
|
+
type SectionPlan,
|
|
92
|
+
type SectionResult,
|
|
93
|
+
stageBackups,
|
|
94
|
+
} from './sections.ts'
|
|
95
|
+
export type { BackupResult, WritesPaused }
|
|
96
|
+
|
|
97
|
+
/** The trees a content selector or placement may name. */
|
|
98
|
+
export const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
|
|
99
|
+
|
|
100
|
+
/** A refusal the caller should read: nothing was written. */
|
|
101
|
+
export class CommandError extends Error {
|
|
102
|
+
constructor(
|
|
103
|
+
message: string,
|
|
104
|
+
readonly problems: string[] = [],
|
|
105
|
+
) {
|
|
106
|
+
super(message)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BundleSummary {
|
|
111
|
+
id: string
|
|
112
|
+
from: string
|
|
113
|
+
schemaVersion: string
|
|
114
|
+
schemaRevision: string
|
|
115
|
+
snapshot: string
|
|
116
|
+
nodes: number
|
|
117
|
+
blobsCarried: number
|
|
118
|
+
blobBytes: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The check an install that never reached the content reports.
|
|
123
|
+
*
|
|
124
|
+
* Nothing was examined, so nothing is outstanding: the refusal is in
|
|
125
|
+
* `problems`, and a caller reading `outstanding.length` must not be told there
|
|
126
|
+
* were content findings when there were none.
|
|
127
|
+
*/
|
|
128
|
+
const emptyCheck = (bundleId = ''): TransferCheck => ({
|
|
129
|
+
bundleId,
|
|
130
|
+
findings: [],
|
|
131
|
+
outstanding: [],
|
|
132
|
+
plan: [],
|
|
133
|
+
counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
function summarise(set: ContentSet): BundleSummary {
|
|
137
|
+
const carried = set.manifest.blobs.filter((blob) => blob.included)
|
|
138
|
+
return {
|
|
139
|
+
id: set.manifest.id,
|
|
140
|
+
from: set.manifest.provenance.siteName || '',
|
|
141
|
+
schemaVersion: set.manifest.provenance.schemaVersion,
|
|
142
|
+
schemaRevision: set.manifest.provenance.schemaRevision,
|
|
143
|
+
snapshot: set.manifest.snapshot,
|
|
144
|
+
nodes: set.nodes.length,
|
|
145
|
+
blobsCarried: carried.length,
|
|
146
|
+
blobBytes: carried.reduce((total, blob) => total + (blob.size ?? 0), 0),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Reads a bundle whole, or refuses: a half-copied bundle must never be checked as though complete. */
|
|
151
|
+
export function readBundle(dir: string): {
|
|
152
|
+
set: ContentSet
|
|
153
|
+
blobs: ReadonlyMap<string, string>
|
|
154
|
+
/** The sections it carries beyond its content; empty for a content-only bundle. */
|
|
155
|
+
carried: CarriedSections
|
|
156
|
+
} {
|
|
157
|
+
const loaded = loadBundle(dir)
|
|
158
|
+
const problems = loaded.problems.map((problem) => `${problem.file}: ${problem.message}`)
|
|
159
|
+
if (!loaded.set || problems.length > 0)
|
|
160
|
+
throw new CommandError(
|
|
161
|
+
'The bundle is incomplete or damaged; nothing was read from it.',
|
|
162
|
+
problems,
|
|
163
|
+
)
|
|
164
|
+
return { set: loaded.set, blobs: loaded.blobs, carried: carriedSections(loaded) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface TransferInput extends SectionOptions {
|
|
168
|
+
/** The bundle's directory on this machine. */
|
|
169
|
+
dir: string
|
|
170
|
+
/** Where the bundle's roots go: a path or a key. */
|
|
171
|
+
under?: string
|
|
172
|
+
/** A decision per node key, on top of any the bundle carries. */
|
|
173
|
+
resolutions?: Record<string, Resolution>
|
|
174
|
+
resolveAll?: Resolution
|
|
175
|
+
allowMissingBlobs?: boolean
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The options `check` and `import` share: saved decisions, then the caller's, then placement. */
|
|
179
|
+
export async function transferOptions(
|
|
180
|
+
db: Db,
|
|
181
|
+
config: CromboloConfig,
|
|
182
|
+
set: ContentSet,
|
|
183
|
+
input: TransferInput,
|
|
184
|
+
): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
|
|
185
|
+
const saved = loadResolutions(input.dir, set.manifest.id)
|
|
186
|
+
const resolutions = { ...(saved.file?.byNode ?? {}), ...(input.resolutions ?? {}) }
|
|
187
|
+
const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
|
|
188
|
+
const underRef = input.under ?? saved.file?.under ?? undefined
|
|
189
|
+
let under: NodeRow | undefined
|
|
190
|
+
if (underRef) {
|
|
191
|
+
const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, underRef)
|
|
192
|
+
if (!resolved.ok) throw new CommandError(describeRefFailure(underRef, resolved))
|
|
193
|
+
under = resolved.node
|
|
194
|
+
}
|
|
195
|
+
// Whether this environment has a media file is a question only its store can
|
|
196
|
+
// answer; without it `missing-blob` is a finding the check could never raise.
|
|
197
|
+
const store = await mediaStoreFor(config)
|
|
198
|
+
return {
|
|
199
|
+
options: {
|
|
200
|
+
under,
|
|
201
|
+
resolutions,
|
|
202
|
+
resolveAll,
|
|
203
|
+
componentAliases: componentAliasesIn(config.componentsDir),
|
|
204
|
+
allowMissingBlobs: input.allowMissingBlobs ?? false,
|
|
205
|
+
hasBlob: async (key: string) => Boolean(await store.get(key)),
|
|
206
|
+
backOfficePath: config.backOfficePath,
|
|
207
|
+
},
|
|
208
|
+
under,
|
|
209
|
+
problems: saved.problems,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ContentCheckResult {
|
|
214
|
+
bundle: BundleSummary
|
|
215
|
+
under: string | null
|
|
216
|
+
check: TransferCheck
|
|
217
|
+
/** What the carried sections would do here; undefined for a content-only bundle. */
|
|
218
|
+
sections: SectionPlan | undefined
|
|
219
|
+
/** Nothing outstanding: the bundle would import cleanly. */
|
|
220
|
+
clean: boolean
|
|
221
|
+
/** The decisions this check ran with — the bundle's saved ones under the caller's. */
|
|
222
|
+
decisions: { all: Resolution | null; byNode: Record<string, Resolution> }
|
|
223
|
+
problems: string[]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Read-only: what importing a bundle here would do. Its findings go to the Changes dashboard. */
|
|
227
|
+
export async function contentCheck(
|
|
228
|
+
config: CromboloConfig,
|
|
229
|
+
input: TransferInput,
|
|
230
|
+
): Promise<ContentCheckResult> {
|
|
231
|
+
const { set, carried } = readBundle(input.dir)
|
|
232
|
+
const { db } = await bootstrapDatabase(config)
|
|
233
|
+
try {
|
|
234
|
+
const prepared = await transferOptions(db, config, set, input)
|
|
235
|
+
const check = await checkBundle(db, set, prepared.options)
|
|
236
|
+
// Under this bundle's own scope, so two bundles in flight do not resolve each other's.
|
|
237
|
+
await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
238
|
+
return {
|
|
239
|
+
bundle: summarise(set),
|
|
240
|
+
under: prepared.under ? (prepared.under.text ?? prepared.under.key) : null,
|
|
241
|
+
check,
|
|
242
|
+
sections: carriesSections(carried)
|
|
243
|
+
? await planSections(db, config, carried, input)
|
|
244
|
+
: undefined,
|
|
245
|
+
clean: check.outstanding.length === 0,
|
|
246
|
+
decisions: {
|
|
247
|
+
all: prepared.options.resolveAll ?? null,
|
|
248
|
+
byNode: prepared.options.resolutions ?? {},
|
|
249
|
+
},
|
|
250
|
+
problems: prepared.problems,
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
await db.close()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface ContentImportInput extends TransferInput {
|
|
258
|
+
publish?: boolean
|
|
259
|
+
label?: string
|
|
260
|
+
/** Postgres: the caller has taken a backup itself. */
|
|
261
|
+
backupTaken?: boolean
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ContentImportResult {
|
|
265
|
+
bundle: BundleSummary
|
|
266
|
+
backup: BackupResult
|
|
267
|
+
/**
|
|
268
|
+
* What the carried sections did, when the bundle carried any.
|
|
269
|
+
*
|
|
270
|
+
* Applied before the content and outside the content run's ledger entry: the
|
|
271
|
+
* files are now in `schema/` and the views directory, where the repository
|
|
272
|
+
* owns them — so a revert takes the content back and git takes the files.
|
|
273
|
+
*/
|
|
274
|
+
sections: SectionResult | undefined
|
|
275
|
+
/** Where the originals of any replaced files were kept, for a revert. */
|
|
276
|
+
replacedFilesKept?: string | undefined
|
|
277
|
+
/** Undefined when the import was refused; then nothing was written. */
|
|
278
|
+
runId: string | undefined
|
|
279
|
+
check: TransferCheck
|
|
280
|
+
blobs: { placed: number; bytes: number } | null
|
|
281
|
+
published: number
|
|
282
|
+
publishFailures: { key: string; reason: string }[]
|
|
283
|
+
problems: string[]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Re-check, back up, then apply in one transaction and record the run. */
|
|
287
|
+
export async function contentImport(
|
|
288
|
+
config: CromboloConfig,
|
|
289
|
+
input: ContentImportInput,
|
|
290
|
+
): Promise<ContentImportResult> {
|
|
291
|
+
const { set, blobs, carried } = readBundle(input.dir)
|
|
292
|
+
// The coarse layer, under the fine-grained revert: in-place conversion is not
|
|
293
|
+
// reversible by itself, so the same rule as `upgrade` applies here.
|
|
294
|
+
const backup = await backupBefore(config, {
|
|
295
|
+
reason: 'content-import',
|
|
296
|
+
backupTaken: input.backupTaken,
|
|
297
|
+
})
|
|
298
|
+
const { db } = await bootstrapDatabase(config)
|
|
299
|
+
try {
|
|
300
|
+
const prepared = await transferOptions(db, config, set, input)
|
|
301
|
+
const started = performance.now()
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Structure first, but planned before anything is written.
|
|
305
|
+
*
|
|
306
|
+
* The order is forced: content naming a type this bundle brings cannot pass
|
|
307
|
+
* a check until that type exists, so the schema has to land before the
|
|
308
|
+
* content is checked. Which means a blocking schema change has to be caught
|
|
309
|
+
* *before* the first file is written — otherwise a refused install leaves a
|
|
310
|
+
* half-applied site behind. `planSections` answers that from an overlay
|
|
311
|
+
* copy, so the site is untouched until it is known the schema can apply.
|
|
312
|
+
*/
|
|
313
|
+
const plan = carriesSections(carried)
|
|
314
|
+
? await planSections(db, config, carried, input)
|
|
315
|
+
: undefined
|
|
316
|
+
const refuse = (problems: string[]): ContentImportResult => ({
|
|
317
|
+
bundle: summarise(set),
|
|
318
|
+
backup,
|
|
319
|
+
runId: undefined,
|
|
320
|
+
check: emptyCheck(set.manifest.id),
|
|
321
|
+
blobs: null,
|
|
322
|
+
published: 0,
|
|
323
|
+
publishFailures: [],
|
|
324
|
+
problems,
|
|
325
|
+
sections: undefined,
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
if (plan?.schema?.classification === 'breaking')
|
|
329
|
+
return refuse([
|
|
330
|
+
'the schema this bundle carries cannot be applied here without data work',
|
|
331
|
+
...plan.schema.findings,
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A file this site already has is not replaced without being asked.
|
|
336
|
+
*
|
|
337
|
+
* Every section is editable in the backoffice — a stylesheet, a template, a
|
|
338
|
+
* content type — so an overwrite destroys somebody's work, and the site
|
|
339
|
+
* may have no git to recover it from. Refused by name, like any other
|
|
340
|
+
* finding a person has to answer.
|
|
341
|
+
*/
|
|
342
|
+
const needingPermission = plan ? replacementsNeedingPermission(plan, input) : []
|
|
343
|
+
if (needingPermission.length > 0)
|
|
344
|
+
return refuse([
|
|
345
|
+
`${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
|
|
346
|
+
...needingPermission.map((file: SectionFile) => file.path),
|
|
347
|
+
])
|
|
348
|
+
|
|
349
|
+
// Staged whenever something *will* be replaced — which is decided by the
|
|
350
|
+
// plan, not by the permission check: by this point permission has been
|
|
351
|
+
// given, so the files needing it are exactly the ones to back up.
|
|
352
|
+
const willReplace = (plan?.files ?? []).some((file) => file.action === 'overwrite')
|
|
353
|
+
const staging = plan && willReplace ? stageBackups(config) : undefined
|
|
354
|
+
const sections = plan
|
|
355
|
+
? await applySections(db, config, carried, { ...input, backupDir: staging })
|
|
356
|
+
: undefined
|
|
357
|
+
const result = await importBundle(db, set, {
|
|
358
|
+
...prepared.options,
|
|
359
|
+
publish: input.publish ?? false,
|
|
360
|
+
nodeId: config.nodeId,
|
|
361
|
+
label: input.label,
|
|
362
|
+
})
|
|
363
|
+
await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
364
|
+
const outcome: ContentImportResult = {
|
|
365
|
+
bundle: summarise(set),
|
|
366
|
+
backup,
|
|
367
|
+
runId: result.runId,
|
|
368
|
+
check: result.check,
|
|
369
|
+
blobs: null,
|
|
370
|
+
published: result.published.length,
|
|
371
|
+
publishFailures: result.publishFailures,
|
|
372
|
+
problems: prepared.problems,
|
|
373
|
+
sections,
|
|
374
|
+
}
|
|
375
|
+
if (!result.runId) return outcome
|
|
376
|
+
// The backups become the run's, so `content revert` can find them.
|
|
377
|
+
if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
|
|
378
|
+
if (blobs.size > 0) outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs)
|
|
379
|
+
await recordInLedger(db, {
|
|
380
|
+
name: `content import ${set.manifest.id}`,
|
|
381
|
+
kind: 'transfer',
|
|
382
|
+
durationMs: Math.round(performance.now() - started),
|
|
383
|
+
appliedBy: config.nodeId,
|
|
384
|
+
note: `run ${result.runId}`,
|
|
385
|
+
})
|
|
386
|
+
return outcome
|
|
387
|
+
} finally {
|
|
388
|
+
await db.close()
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface ContentExportInput {
|
|
393
|
+
/** Paths or keys of the subtrees to export. */
|
|
394
|
+
roots: string[]
|
|
395
|
+
only?: boolean
|
|
396
|
+
drafts?: boolean
|
|
397
|
+
withBlobs?: boolean
|
|
398
|
+
withBlueprints?: boolean
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export interface ContentExportResult {
|
|
402
|
+
bundleId: string
|
|
403
|
+
snapshot: string
|
|
404
|
+
counts: Record<string, number>
|
|
405
|
+
expected: { key: string; name: string; why: string }[]
|
|
406
|
+
/** The bundle's files, for the caller to write wherever it is going. */
|
|
407
|
+
files: { path: string; bytes?: Uint8Array; text?: string }[]
|
|
408
|
+
blobs: { carried: number; bytes: number; missing: string[]; notCarried: number }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Writes nothing: returns the bundle's files for the caller to place. */
|
|
412
|
+
export async function contentExport(
|
|
413
|
+
config: CromboloConfig,
|
|
414
|
+
input: ContentExportInput,
|
|
415
|
+
): Promise<ContentExportResult> {
|
|
416
|
+
if (input.roots.length === 0) throw new CommandError('An export needs at least one root.')
|
|
417
|
+
const { db } = await bootstrapDatabase(config)
|
|
418
|
+
try {
|
|
419
|
+
// A path is resolved against the tree it is exported from, and the key is
|
|
420
|
+
// what lands in the bundle, so the artifact is identity-stable either way.
|
|
421
|
+
const roots: NodeRow[] = []
|
|
422
|
+
for (const ref of input.roots) {
|
|
423
|
+
const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, ref)
|
|
424
|
+
if (!resolved.ok) throw new CommandError(describeRefFailure(ref, resolved))
|
|
425
|
+
roots.push(resolved.node)
|
|
426
|
+
}
|
|
427
|
+
const { set, blobKeys } = await exportBundle(db, {
|
|
428
|
+
roots,
|
|
429
|
+
asGiven: input.roots,
|
|
430
|
+
descendants: !input.only,
|
|
431
|
+
snapshot: input.drafts ? 'drafts' : 'published',
|
|
432
|
+
blueprints: input.withBlueprints ?? false,
|
|
433
|
+
withBlobs: input.withBlobs ?? false,
|
|
434
|
+
siteName: config.siteName,
|
|
435
|
+
nodeId: config.nodeId,
|
|
436
|
+
})
|
|
437
|
+
// The bytes are read here rather than in the exporter: where media lives is
|
|
438
|
+
// the store's business, and `@crombolo/transfer` knows nothing about it.
|
|
439
|
+
let carried = new Map<string, Uint8Array>()
|
|
440
|
+
let missing: string[] = []
|
|
441
|
+
if (input.withBlobs && blobKeys.length > 0) {
|
|
442
|
+
const read = await readBlobs(await mediaStoreFor(config), blobKeys)
|
|
443
|
+
carried = read.bytes
|
|
444
|
+
missing = read.missing
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
bundleId: set.manifest.id,
|
|
448
|
+
snapshot: set.manifest.snapshot,
|
|
449
|
+
counts: { ...set.manifest.counts },
|
|
450
|
+
expected: set.manifest.dependencies.expected.map((e) => ({
|
|
451
|
+
key: e.key,
|
|
452
|
+
name: e.name,
|
|
453
|
+
why: e.why,
|
|
454
|
+
})),
|
|
455
|
+
files: writeBundle(set, carried),
|
|
456
|
+
blobs: {
|
|
457
|
+
carried: carried.size,
|
|
458
|
+
bytes: [...carried.values()].reduce((total, file) => total + file.length, 0),
|
|
459
|
+
missing,
|
|
460
|
+
notCarried: input.withBlobs ? 0 : blobKeys.length,
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
} finally {
|
|
464
|
+
await db.close()
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function upgradeContext(config: CromboloConfig, set: Record<string, unknown>) {
|
|
469
|
+
const db = await connect({ file: config.sqliteFile })
|
|
470
|
+
const node = { nodeId: config.nodeId, revision: config.schemaRevision }
|
|
471
|
+
const loaded = () => loadSchemaDirectory(config.schemaDir)
|
|
472
|
+
const syncNode = () => ({ ...node, version: loaded().set.version })
|
|
473
|
+
const checkOptions = async () => ({
|
|
474
|
+
...syncOptionsFor(config),
|
|
475
|
+
set,
|
|
476
|
+
migrations: await loadValueMigrations(config.schemaDir),
|
|
477
|
+
backOfficePath: config.backOfficePath,
|
|
478
|
+
})
|
|
479
|
+
return { db, node, loaded, syncNode, checkOptions }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export interface UpgradePlanResult {
|
|
483
|
+
steps: {
|
|
484
|
+
name: string
|
|
485
|
+
kind: string
|
|
486
|
+
release: string | null
|
|
487
|
+
statements: string[]
|
|
488
|
+
unplannable: string | null
|
|
489
|
+
}[]
|
|
490
|
+
site: { checked: boolean; action: string | null; classification: string | null }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The DDL every pending framework step would run, and what the site's schema would do. Writes nothing. */
|
|
494
|
+
export async function upgradePlan(config: CromboloConfig): Promise<UpgradePlanResult> {
|
|
495
|
+
const { db, node } = await upgradeContext(config, {})
|
|
496
|
+
try {
|
|
497
|
+
const steps = await planUpgrade(db, cromboloPlan)
|
|
498
|
+
const check = await checkSchemaDirectory(db, config, node)
|
|
499
|
+
return {
|
|
500
|
+
steps: steps.map((step) => ({
|
|
501
|
+
name: step.name,
|
|
502
|
+
kind: step.kind,
|
|
503
|
+
release: step.release ?? null,
|
|
504
|
+
statements: (step.statements ?? []).map((sql) => sql.replace(/\s+/g, ' ').trim()),
|
|
505
|
+
unplannable: step.unplannable ?? null,
|
|
506
|
+
})),
|
|
507
|
+
site: {
|
|
508
|
+
checked: check.siteChecked,
|
|
509
|
+
action: check.siteChecked ? check.sync.action : null,
|
|
510
|
+
classification: check.siteChecked ? check.classification : null,
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
} finally {
|
|
514
|
+
await db.close()
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The pre-upgrade report. Its findings go to the Changes dashboard once the site is checkable. */
|
|
519
|
+
export async function upgradeCheck(
|
|
520
|
+
config: CromboloConfig,
|
|
521
|
+
input: { set?: Record<string, unknown> } = {},
|
|
522
|
+
) {
|
|
523
|
+
const { db, node } = await upgradeContext(config, input.set ?? {})
|
|
524
|
+
try {
|
|
525
|
+
const report = await checkSchemaDirectory(db, config, node, { set: input.set ?? {} })
|
|
526
|
+
if (report.siteChecked) await writeReport(db, report.findings, { source: 'upgrade' })
|
|
527
|
+
return {
|
|
528
|
+
siteChecked: report.siteChecked,
|
|
529
|
+
classification: report.classification,
|
|
530
|
+
findings: report.findings,
|
|
531
|
+
outstanding: report.outstanding,
|
|
532
|
+
blocking: report.outstanding.some((finding) => finding.kind === 'blocking'),
|
|
533
|
+
}
|
|
534
|
+
} finally {
|
|
535
|
+
await db.close()
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Back up, then apply the additive part of an upgrade and every conversion, early. */
|
|
540
|
+
export async function upgradeFix(
|
|
541
|
+
config: CromboloConfig,
|
|
542
|
+
input: { set?: Record<string, unknown>; backupTaken?: boolean } = {},
|
|
543
|
+
) {
|
|
544
|
+
const backup = await backupBefore(config, { reason: 'fix', backupTaken: input.backupTaken })
|
|
545
|
+
const { db, loaded, syncNode, checkOptions } = await upgradeContext(config, input.set ?? {})
|
|
546
|
+
try {
|
|
547
|
+
await installDatabase(db, config)
|
|
548
|
+
const fix = await runFix(db, loaded(), syncNode(), {
|
|
549
|
+
...(await checkOptions()),
|
|
550
|
+
by: config.nodeId,
|
|
551
|
+
})
|
|
552
|
+
return {
|
|
553
|
+
backup,
|
|
554
|
+
sync: { action: fix.sync.action, deferred: fix.sync.deferred },
|
|
555
|
+
applied: fix.applied,
|
|
556
|
+
skipped: fix.skipped,
|
|
557
|
+
classification: fix.check.classification,
|
|
558
|
+
findings: fix.check.findings,
|
|
559
|
+
outstanding: fix.check.outstanding,
|
|
560
|
+
}
|
|
561
|
+
} finally {
|
|
562
|
+
await db.close()
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export type UpgradeRunResult =
|
|
567
|
+
| {
|
|
568
|
+
action: 'refused'
|
|
569
|
+
backup: BackupResult
|
|
570
|
+
reasons: { kind: string; message: string; link: string | null }[]
|
|
571
|
+
}
|
|
572
|
+
| { action: 'nothing'; backup: BackupResult }
|
|
573
|
+
| {
|
|
574
|
+
action: 'upgraded'
|
|
575
|
+
backup: BackupResult
|
|
576
|
+
frameworkApplied: string[]
|
|
577
|
+
schema: { action: string; version: string | null; revision: string | null } | null
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Re-check, back up, apply framework steps, cut over, ledger. */
|
|
581
|
+
export async function upgradeRun(
|
|
582
|
+
config: CromboloConfig,
|
|
583
|
+
input: { force?: string; set?: Record<string, unknown>; backupTaken?: boolean } = {},
|
|
584
|
+
): Promise<UpgradeRunResult> {
|
|
585
|
+
const backup = await backupBefore(config, { reason: 'upgrade', backupTaken: input.backupTaken })
|
|
586
|
+
const { db, loaded, syncNode, checkOptions } = await upgradeContext(config, input.set ?? {})
|
|
587
|
+
try {
|
|
588
|
+
await installDatabase(db, config)
|
|
589
|
+
const result = await runUpgrade(db, loaded(), syncNode(), {
|
|
590
|
+
...(await checkOptions()),
|
|
591
|
+
by: config.nodeId,
|
|
592
|
+
policy: config.upgradePolicy,
|
|
593
|
+
force: input.force,
|
|
594
|
+
})
|
|
595
|
+
if (result.action === 'refused')
|
|
596
|
+
return {
|
|
597
|
+
action: 'refused',
|
|
598
|
+
backup,
|
|
599
|
+
reasons: result.reasons.map((f) => ({ kind: f.kind, message: f.message, link: f.link })),
|
|
600
|
+
}
|
|
601
|
+
if (result.action === 'nothing') return { action: 'nothing', backup }
|
|
602
|
+
return {
|
|
603
|
+
action: 'upgraded',
|
|
604
|
+
backup,
|
|
605
|
+
frameworkApplied: result.frameworkApplied,
|
|
606
|
+
schema: result.sync
|
|
607
|
+
? {
|
|
608
|
+
action: result.sync.action,
|
|
609
|
+
version: result.sync.state?.version ?? null,
|
|
610
|
+
revision: result.sync.state?.revision ?? null,
|
|
611
|
+
}
|
|
612
|
+
: null,
|
|
613
|
+
}
|
|
614
|
+
} finally {
|
|
615
|
+
await db.close()
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export interface MediaAddInput {
|
|
620
|
+
/** Where the file already is, in this environment's own media store. */
|
|
621
|
+
key: string
|
|
622
|
+
/** What the item is called. Defaults to the file name in `key`. */
|
|
623
|
+
name?: string
|
|
624
|
+
/** The folder to put it in, by node key. The media root when absent. */
|
|
625
|
+
parent?: string
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export interface MediaAddResult {
|
|
629
|
+
/** The new media item's node key. */
|
|
630
|
+
key: string
|
|
631
|
+
name: string
|
|
632
|
+
/** The media type it was created as, chosen from the file's extension. */
|
|
633
|
+
mediaType: string
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Registers a file that is already in the store as a media item, so it appears
|
|
638
|
+
* in the Media section and can be used in content.
|
|
639
|
+
*
|
|
640
|
+
* For a host offering an upload without opening the backoffice. The caller puts
|
|
641
|
+
* the bytes in the store — a presigned upload, typically — and names the key
|
|
642
|
+
* here, so a large file never travels through whatever asked for this. The
|
|
643
|
+
* media type is chosen from the extension the same way the backoffice chooses
|
|
644
|
+
* it, and the file is placed by the CMS in its own layout rather than by the
|
|
645
|
+
* caller, which is what keeps the store free of files nothing knows about.
|
|
646
|
+
*/
|
|
647
|
+
export async function mediaAdd(
|
|
648
|
+
config: CromboloConfig,
|
|
649
|
+
input: MediaAddInput,
|
|
650
|
+
): Promise<MediaAddResult> {
|
|
651
|
+
const name = (input.name ?? input.key.split('/').at(-1) ?? '').trim()
|
|
652
|
+
if (!name) throw new CommandError('A media item needs a name.')
|
|
653
|
+
|
|
654
|
+
const store = await mediaStoreFor(config)
|
|
655
|
+
const found = await store.get(input.key)
|
|
656
|
+
if (!found) throw new CommandError(`There is nothing at '${input.key}' in the store.`)
|
|
657
|
+
|
|
658
|
+
const files = new MediaFileStore(store)
|
|
659
|
+
const { db } = await bootstrapDatabase(config)
|
|
660
|
+
// The schema the site is actually on. Without it the port falls back to the
|
|
661
|
+
// baseline state and the write gate refuses every write, because a server at
|
|
662
|
+
// an older schema than its database must not write to it.
|
|
663
|
+
const schema = await bootSchema(db, { ...config, syncSchemaAtBoot: false })
|
|
664
|
+
const port = createMediaPort(db, files, {
|
|
665
|
+
nodeState: schema.nodeState,
|
|
666
|
+
nodeId: schema.nodeId,
|
|
667
|
+
valueIntake: createFileIntake(files),
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// The same choice the backoffice makes: a type whose file property allows
|
|
671
|
+
// this extension, falling back to one that allows anything at all.
|
|
672
|
+
const extension = name.includes('.') ? (name.split('.').at(-1) as string) : ''
|
|
673
|
+
const types = await port.typesForExtension(extension)
|
|
674
|
+
const type = types.find((candidate) => candidate.matched) ?? types[0]
|
|
675
|
+
if (!type)
|
|
676
|
+
throw new CommandError(
|
|
677
|
+
`No media type in this site accepts a '${extension}' file, so it cannot be added.`,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
// Through the temporary-file path, so the CMS places the file and records the
|
|
681
|
+
// value exactly as it would for an upload made in the backoffice.
|
|
682
|
+
const temporaryId = crypto.randomUUID()
|
|
683
|
+
// A fresh Uint8Array from the store, handed over as a blob part. `bytes()` is
|
|
684
|
+
// typed over ArrayBufferLike, which a File constructor will not take.
|
|
685
|
+
const bytes = await found.bytes()
|
|
686
|
+
await files.saveTemporary(
|
|
687
|
+
temporaryId,
|
|
688
|
+
new File([new Blob([bytes as BlobPart], { type: found.contentType })], name),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
const created = await port.create(
|
|
692
|
+
{
|
|
693
|
+
key: crypto.randomUUID(),
|
|
694
|
+
contentTypeKey: type.key,
|
|
695
|
+
componentKey: null,
|
|
696
|
+
parentKey: input.parent ?? null,
|
|
697
|
+
values: [
|
|
698
|
+
{
|
|
699
|
+
alias: 'umbracoFile',
|
|
700
|
+
culture: null,
|
|
701
|
+
segment: null,
|
|
702
|
+
value: { temporaryFileId: temporaryId },
|
|
703
|
+
},
|
|
704
|
+
],
|
|
705
|
+
variants: [{ culture: null, segment: null, name }],
|
|
706
|
+
},
|
|
707
|
+
// The platform acting as itself: no user account, every permission, so the
|
|
708
|
+
// item is created rather than refused for an actor the site has never seen.
|
|
709
|
+
// `create` resolves a user id from the key and simply finds none.
|
|
710
|
+
{
|
|
711
|
+
id: '',
|
|
712
|
+
userName: 'operations',
|
|
713
|
+
name: 'operations',
|
|
714
|
+
email: '',
|
|
715
|
+
isAdmin: true,
|
|
716
|
+
languageIsoCode: undefined,
|
|
717
|
+
avatarUrls: [],
|
|
718
|
+
allowedSections: [],
|
|
719
|
+
permissions: [],
|
|
720
|
+
groupKeys: [],
|
|
721
|
+
hasAccessToAllLanguages: true,
|
|
722
|
+
languages: [],
|
|
723
|
+
startNodes: { document: ROOT_ACCESS, media: ROOT_ACCESS, element: ROOT_ACCESS },
|
|
724
|
+
groups: [],
|
|
725
|
+
},
|
|
726
|
+
)
|
|
727
|
+
if (!created.ok) {
|
|
728
|
+
// Nothing was registered, so the staged copy is litter.
|
|
729
|
+
await files.deleteTemporary(temporaryId)
|
|
730
|
+
throw new CommandError(
|
|
731
|
+
`The media item could not be created: ${created.reason}.`,
|
|
732
|
+
(created.errors ?? []).map((error) => `${error.alias}: ${error.messages.join('; ')}`),
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
return { key: created.key, name, mediaType: type.name }
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** Takes the backup a write would take first: a copy of SQLite, or `CROMBOLO_PG_DUMP` on Postgres. */
|
|
739
|
+
export async function backup(
|
|
740
|
+
config: CromboloConfig,
|
|
741
|
+
input: { reason?: string; backupTaken?: boolean } = {},
|
|
742
|
+
): Promise<BackupResult> {
|
|
743
|
+
return backupBefore(config, {
|
|
744
|
+
reason: input.reason ?? 'backup',
|
|
745
|
+
backupTaken: input.backupTaken,
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function withDatabase<T>(config: CromboloConfig, use: (db: Db) => Promise<T>): Promise<T> {
|
|
750
|
+
const db = await connect({ file: config.sqliteFile })
|
|
751
|
+
try {
|
|
752
|
+
return await use(db)
|
|
753
|
+
} finally {
|
|
754
|
+
await db.close()
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Refuses every editor's save, on every node, until resumed; readers are unaffected. */
|
|
759
|
+
export function pauseEditing(
|
|
760
|
+
config: CromboloConfig,
|
|
761
|
+
input: { reason?: string; by?: string } = {},
|
|
762
|
+
): Promise<WritesPaused> {
|
|
763
|
+
return withDatabase(config, (db) =>
|
|
764
|
+
pauseWrites(db, { reason: input.reason ?? '', by: input.by ?? config.nodeId }),
|
|
765
|
+
)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
export function resumeEditing(config: CromboloConfig): Promise<void> {
|
|
769
|
+
return withDatabase(config, resumeWrites)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Whether editing is paused, by whom and why; undefined when it is not. */
|
|
773
|
+
export function editingPaused(config: CromboloConfig): Promise<WritesPaused | undefined> {
|
|
774
|
+
return withDatabase(config, readWritesPaused)
|
|
775
|
+
}
|