@crombolo/server 0.13.2 → 0.14.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 -12
- package/src/adapters/bundles.ts +20 -139
- package/src/adapters/members.ts +22 -10
- package/src/adapters/schema-proposals.ts +10 -2
- package/src/adapters/users.ts +23 -1
- package/src/backoffice-external.ts +99 -0
- package/src/bundle-install.ts +261 -77
- package/src/bundle-sections.ts +160 -0
- package/src/bundles.ts +4 -0
- package/src/config.ts +15 -1
- package/src/delivery-members.ts +11 -2
- package/src/external-sign-in.ts +270 -0
- package/src/identity-provider-overrides.ts +283 -0
- package/src/identity-providers.ts +185 -0
- package/src/index.ts +8 -0
- package/src/media-store-azure.ts +6 -0
- package/src/media-store-s3.ts +1 -0
- package/src/media-store.ts +21 -0
- package/src/member-auth.ts +232 -15
- package/src/ports.ts +55 -1
- package/src/schema-store.ts +16 -0
- package/src/schema.ts +5 -7
- package/src/server.ts +112 -7
- package/src/site-file.ts +258 -0
- package/src/site-replace.ts +188 -0
- package/src/site-search.ts +51 -0
- package/src/site-sections.ts +234 -0
- package/src/tar.ts +155 -0
- package/src/two-factor.ts +138 -0
package/src/bundle-install.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* with the connection it already has: the CLI's `content check` and
|
|
13
13
|
* `content import` open a database and call these.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from 'node:fs'
|
|
15
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
16
16
|
import { join, resolve } from 'node:path'
|
|
17
17
|
import {
|
|
18
18
|
type BundlePackage,
|
|
@@ -27,7 +27,9 @@ import { ObjectTypes } from '@crombolo/core'
|
|
|
27
27
|
import {
|
|
28
28
|
answersByNode,
|
|
29
29
|
type Db,
|
|
30
|
+
DictionaryRepository,
|
|
30
31
|
describeRefFailure,
|
|
32
|
+
Locks,
|
|
31
33
|
type NodeRow,
|
|
32
34
|
recordInLedger,
|
|
33
35
|
resolveNodeRef,
|
|
@@ -35,9 +37,11 @@ import {
|
|
|
35
37
|
writeReport,
|
|
36
38
|
} from '@crombolo/data'
|
|
37
39
|
import {
|
|
40
|
+
BUNDLE_SECTIONS,
|
|
38
41
|
type CheckOptions,
|
|
39
42
|
type ContentSet,
|
|
40
43
|
checkBundle,
|
|
44
|
+
type ImportResult,
|
|
41
45
|
importBundle,
|
|
42
46
|
isPackageName,
|
|
43
47
|
loadBundle,
|
|
@@ -54,22 +58,40 @@ import {
|
|
|
54
58
|
} from '@crombolo/transfer'
|
|
55
59
|
import { type BackupResult, backupBefore } from './backup.ts'
|
|
56
60
|
import {
|
|
57
|
-
applySections,
|
|
58
61
|
type CarriedSections,
|
|
59
62
|
carriedSections,
|
|
60
63
|
carriesSections,
|
|
64
|
+
keepDictionary,
|
|
65
|
+
keptDictionary,
|
|
61
66
|
planSections,
|
|
62
|
-
|
|
67
|
+
promoteStaged,
|
|
68
|
+
type RestoredFiles,
|
|
63
69
|
replacementsNeedingPermission,
|
|
70
|
+
restoreSectionFiles,
|
|
64
71
|
type SectionFile,
|
|
65
72
|
type SectionOptions,
|
|
66
73
|
type SectionPlan,
|
|
67
74
|
type SectionResult,
|
|
68
75
|
stageBackups,
|
|
76
|
+
stagedConfig,
|
|
77
|
+
stageSections,
|
|
78
|
+
swappedSections,
|
|
79
|
+
swapSections,
|
|
69
80
|
} from './bundle-sections.ts'
|
|
70
81
|
import { type CromboloConfig, VERSION } from './config.ts'
|
|
82
|
+
import { dictionaryToUdt, importDictionaryUdt, writeDictionaryUdt } from './dictionary-transfer.ts'
|
|
83
|
+
import { syncDomainsFile } from './domains.ts'
|
|
71
84
|
import { mediaStoreFor, placeBlobs } from './media-files.ts'
|
|
72
|
-
import { componentAliasesIn } from './schema.ts'
|
|
85
|
+
import { componentAliasesIn, syncSchemaDirectory } from './schema.ts'
|
|
86
|
+
import { publishSchema } from './schema-store.ts'
|
|
87
|
+
import {
|
|
88
|
+
carriesAdministrator,
|
|
89
|
+
prunePeople,
|
|
90
|
+
pruneSite,
|
|
91
|
+
type ReplaceMode,
|
|
92
|
+
recordPruned,
|
|
93
|
+
requiresProblems,
|
|
94
|
+
} from './site-replace.ts'
|
|
73
95
|
|
|
74
96
|
/** The trees a content selector or placement may name. */
|
|
75
97
|
export const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
|
|
@@ -364,6 +386,12 @@ export async function checkBundleContent(
|
|
|
364
386
|
}
|
|
365
387
|
|
|
366
388
|
export interface ContentImportInput extends TransferInput {
|
|
389
|
+
/**
|
|
390
|
+
* Make the site the file's (story 105). `replace` recycles content and media
|
|
391
|
+
* the file lacks and keeps the site's people; `replace-all` deletes them, and
|
|
392
|
+
* the people the file lacks.
|
|
393
|
+
*/
|
|
394
|
+
replace?: ReplaceMode
|
|
367
395
|
publish?: boolean
|
|
368
396
|
label?: string
|
|
369
397
|
/** Postgres: the caller has taken a backup itself. */
|
|
@@ -438,7 +466,28 @@ export async function pendingResolutionsFile(
|
|
|
438
466
|
})
|
|
439
467
|
}
|
|
440
468
|
|
|
441
|
-
/**
|
|
469
|
+
/** Thrown inside the import's transaction to roll everything back, carrying why. */
|
|
470
|
+
class InstallRefused extends Error {
|
|
471
|
+
constructor(
|
|
472
|
+
readonly problems: string[],
|
|
473
|
+
readonly result?: ImportResult,
|
|
474
|
+
) {
|
|
475
|
+
super(problems[0] ?? 'the import was refused')
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Back up, re-check, then apply all or nothing and record the run (story 105).
|
|
481
|
+
*
|
|
482
|
+
* Three phases. **Stage**: every carried section is written into a copy under
|
|
483
|
+
* the run's directory, and the media files are placed under their own keys.
|
|
484
|
+
* **One transaction**: the prune a replace asks for, the schema sync against
|
|
485
|
+
* the staged copy, the dictionary and the content — so a refusal anywhere
|
|
486
|
+
* leaves the database as it was. **Swap**: once it commits, the staged folders
|
|
487
|
+
* move into place and what they replace is kept for the run's revert. A
|
|
488
|
+
* failure before the swap leaves the site's files untouched and removes the
|
|
489
|
+
* media files it placed.
|
|
490
|
+
*/
|
|
442
491
|
export async function installBundleContent(
|
|
443
492
|
db: Db,
|
|
444
493
|
config: CromboloConfig,
|
|
@@ -452,31 +501,13 @@ export async function installBundleContent(
|
|
|
452
501
|
throw new PassphraseError(
|
|
453
502
|
'This bundle is encrypted: give the passphrase it was exported with. Nothing was written.',
|
|
454
503
|
)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const
|
|
458
|
-
reason: 'content-import',
|
|
459
|
-
backupTaken: input.backupTaken,
|
|
460
|
-
})
|
|
461
|
-
const prepared = await transferOptions(db, config, bundle, input)
|
|
462
|
-
const started = performance.now()
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Structure first, but planned before anything is written.
|
|
466
|
-
*
|
|
467
|
-
* The order is forced: content naming a type this bundle brings cannot pass
|
|
468
|
-
* a check until that type exists, so the schema has to land before the
|
|
469
|
-
* content is checked. Which means a blocking schema change has to be caught
|
|
470
|
-
* *before* the first file is written — otherwise a refused install leaves a
|
|
471
|
-
* half-applied site behind. `planSections` answers that from an overlay
|
|
472
|
-
* copy, so the site is untouched until it is known the schema can apply.
|
|
473
|
-
*/
|
|
474
|
-
const plan = carriesSections(carried) ? await planSections(db, config, carried, input) : undefined
|
|
475
|
-
const refuse = (problems: string[]): ContentImportResult => ({
|
|
504
|
+
const manifest = set.manifest
|
|
505
|
+
const replace = input.replace
|
|
506
|
+
const refuse = (problems: string[], backup?: BackupResult): ContentImportResult => ({
|
|
476
507
|
bundle: summarise(bundle),
|
|
477
|
-
backup,
|
|
508
|
+
backup: backup ?? { kind: 'none' },
|
|
478
509
|
runId: undefined,
|
|
479
|
-
check: emptyCheck(
|
|
510
|
+
check: emptyCheck(manifest.id),
|
|
480
511
|
blobs: null,
|
|
481
512
|
published: 0,
|
|
482
513
|
publishFailures: [],
|
|
@@ -484,69 +515,222 @@ export async function installBundleContent(
|
|
|
484
515
|
sections: undefined,
|
|
485
516
|
})
|
|
486
517
|
|
|
487
|
-
|
|
518
|
+
// What has to be true before anything is backed up or written.
|
|
519
|
+
if (replace && manifest.scope !== 'site')
|
|
520
|
+
return refuse(['only a whole-site file (crombolo export --site) can replace a site'])
|
|
521
|
+
const missing = requiresProblems(manifest, config.siteDir)
|
|
522
|
+
if (missing.length > 0) return refuse(missing)
|
|
523
|
+
if (manifest.media === 'omitted' && !input.allowMissingBlobs)
|
|
488
524
|
return refuse([
|
|
489
|
-
'
|
|
490
|
-
...plan.schema.findings,
|
|
525
|
+
'this file was written without its media files (--no-media); pass --allow-missing-media to import the media items without them',
|
|
491
526
|
])
|
|
527
|
+
if (replace === 'replace-all' && !carriesAdministrator(set.people))
|
|
528
|
+
return refuse([
|
|
529
|
+
'this file carries no back-office administrator, so replacing everything would leave nobody able to sign in; export it with --with-users, or use --replace',
|
|
530
|
+
])
|
|
531
|
+
|
|
532
|
+
// A replace takes the file's side of every difference, and replaces files.
|
|
533
|
+
const effective: ContentImportInput = replace
|
|
534
|
+
? { ...input, resolveAll: 'take-bundle', replaceFiles: true, publish: input.publish ?? true }
|
|
535
|
+
: input
|
|
536
|
+
|
|
537
|
+
// The coarse layer, under the fine-grained revert: in-place conversion is not
|
|
538
|
+
// reversible by itself, so the same rule as `upgrade` applies here.
|
|
539
|
+
const backup = await backupBefore(config, {
|
|
540
|
+
reason: 'content-import',
|
|
541
|
+
backupTaken: input.backupTaken,
|
|
542
|
+
})
|
|
543
|
+
const prepared = await transferOptions(db, config, bundle, effective)
|
|
544
|
+
const started = performance.now()
|
|
492
545
|
|
|
493
546
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* content type — so an overwrite destroys somebody's work, and the site
|
|
498
|
-
* may have no git to recover it from. Refused by name, like any other
|
|
499
|
-
* finding a person has to answer.
|
|
547
|
+
* Structure first, but planned before anything is written: a blocking
|
|
548
|
+
* schema change is caught from an overlay copy, and a file an editor may
|
|
549
|
+
* have changed is not replaced without being asked.
|
|
500
550
|
*/
|
|
501
|
-
const
|
|
551
|
+
const plan = carriesSections(carried)
|
|
552
|
+
? await planSections(db, config, carried, effective)
|
|
553
|
+
: undefined
|
|
554
|
+
if (plan?.schema?.classification === 'breaking')
|
|
555
|
+
return refuse(
|
|
556
|
+
[
|
|
557
|
+
'the schema this bundle carries cannot be applied here without data work',
|
|
558
|
+
...plan.schema.findings,
|
|
559
|
+
],
|
|
560
|
+
backup,
|
|
561
|
+
)
|
|
562
|
+
const needingPermission = plan ? replacementsNeedingPermission(plan, effective) : []
|
|
502
563
|
if (needingPermission.length > 0)
|
|
503
|
-
return refuse(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
564
|
+
return refuse(
|
|
565
|
+
[
|
|
566
|
+
`${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
|
|
567
|
+
...needingPermission.map((file: SectionFile) => file.path),
|
|
568
|
+
],
|
|
569
|
+
backup,
|
|
570
|
+
)
|
|
507
571
|
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
572
|
+
// ---- 1. Stage
|
|
573
|
+
const runDir = stageBackups(config)
|
|
574
|
+
const staged =
|
|
575
|
+
plan || replace
|
|
576
|
+
? stageSections(config, carried, { ...effective, replace: Boolean(replace) }, runDir)
|
|
577
|
+
: undefined
|
|
578
|
+
const store = blobs.size > 0 ? await mediaStoreFor(config) : undefined
|
|
579
|
+
const placedHere: string[] = []
|
|
580
|
+
let placed: ContentImportResult['blobs'] = null
|
|
581
|
+
if (store) {
|
|
582
|
+
for (const key of blobs.keys()) if (!(await store.get(key))) placedHere.push(key)
|
|
583
|
+
placed = await placeBlobs(store, blobs, bundle.openBlob)
|
|
584
|
+
}
|
|
585
|
+
const undoStage = async () => {
|
|
586
|
+
for (const key of placedHere) await store?.delete(key).catch(() => {})
|
|
587
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---- 2. One transaction
|
|
591
|
+
let schemaAction: string | undefined
|
|
592
|
+
let dictionary: SectionResult['dictionary']
|
|
593
|
+
let result: ImportResult
|
|
594
|
+
try {
|
|
595
|
+
result = await db.locks.withLock(Locks.Schema, () =>
|
|
596
|
+
db.locks.withLock(Locks.ContentTree, () =>
|
|
597
|
+
db.transaction(async (tx) => {
|
|
598
|
+
const pruned = replace ? await pruneSite(tx, set, replace, config.nodeId) : []
|
|
599
|
+
if (replace === 'replace-all') await prunePeople(tx, set.people)
|
|
600
|
+
if (staged?.next.schema) {
|
|
601
|
+
const report = await syncSchemaDirectory(
|
|
602
|
+
tx,
|
|
603
|
+
stagedConfig(config, staged),
|
|
604
|
+
{ nodeId: config.nodeId, revision: config.schemaRevision },
|
|
605
|
+
{ forceRetireTypes: Boolean(replace) },
|
|
606
|
+
)
|
|
607
|
+
if (report.action === 'refused' || report.action === 'skipped-older')
|
|
608
|
+
throw new InstallRefused([
|
|
609
|
+
`the schema this bundle carries was not applied: ${report.reason ?? report.action}`,
|
|
610
|
+
])
|
|
611
|
+
schemaAction = report.action
|
|
612
|
+
}
|
|
613
|
+
const udtPath = `${BUNDLE_SECTIONS.dictionary}.udt`
|
|
614
|
+
const carriesDictionary = (carried.carries.dictionary ?? []).includes(udtPath)
|
|
615
|
+
if (carriesDictionary || replace) {
|
|
616
|
+
const repo = new DictionaryRepository(tx)
|
|
617
|
+
keepDictionary(runDir, writeDictionaryUdt(await dictionaryToUdt(repo)))
|
|
618
|
+
if (replace) for (const root of await repo.children(null)) await repo.delete(root.key)
|
|
619
|
+
for (const path of carried.carries.dictionary ?? []) {
|
|
620
|
+
const file = carried.files.get(path)
|
|
621
|
+
if (!file) continue
|
|
622
|
+
const outcome = await importDictionaryUdt(tx, readFileSync(file, 'utf8'))
|
|
623
|
+
dictionary = {
|
|
624
|
+
imported: (dictionary?.imported ?? 0) + outcome.imported,
|
|
625
|
+
skipped: [...(dictionary?.skipped ?? []), ...outcome.skipped],
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const imported = await importBundle(tx, set, {
|
|
630
|
+
...prepared.options,
|
|
631
|
+
publish: effective.publish ?? false,
|
|
632
|
+
nodeId: config.nodeId,
|
|
633
|
+
label: input.label,
|
|
634
|
+
package: { name: bundle.package.packageName, version: bundle.package.version },
|
|
635
|
+
exact: Boolean(replace),
|
|
636
|
+
})
|
|
637
|
+
if (!imported.runId) throw new InstallRefused([], imported)
|
|
638
|
+
await recordPruned(tx, imported.runId, pruned)
|
|
639
|
+
return imported
|
|
640
|
+
}),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
} catch (error) {
|
|
644
|
+
await undoStage()
|
|
645
|
+
if (!(error instanceof InstallRefused)) throw error
|
|
646
|
+
if (!error.result) return refuse(error.problems, backup)
|
|
647
|
+
await writeReport(db, error.result.check.findings, { source: 'transfer', scope: manifest.id })
|
|
648
|
+
await recordPending(db, bundle, input, prepared.options.allowMissingBlobs ?? false)
|
|
649
|
+
return {
|
|
650
|
+
...refuse(prepared.problems, backup),
|
|
651
|
+
check: error.result.check,
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const runId = result.runId as string
|
|
655
|
+
|
|
656
|
+
// ---- 3. Swap
|
|
657
|
+
if (staged) {
|
|
658
|
+
swapSections(config, staged)
|
|
659
|
+
if (staged.next.schema && config.schemaStore)
|
|
660
|
+
await publishSchema(config.schemaStore, config.schemaDir)
|
|
661
|
+
}
|
|
662
|
+
const kept = promoteStaged(config, runDir, runId)
|
|
663
|
+
// Domains belong to where the site runs, never to the file: rebound by the
|
|
664
|
+
// site's own `domains.toml` onto the pages now in its tree.
|
|
665
|
+
if (replace) await syncDomainsFile(db, config.siteDir)
|
|
666
|
+
await writeReport(db, result.check.findings, { source: 'transfer', scope: manifest.id })
|
|
667
|
+
await new TransferAnswerRepository(db).clear(manifest.id)
|
|
668
|
+
await recordInLedger(db, {
|
|
669
|
+
name: `content import ${manifest.id}`,
|
|
670
|
+
kind: 'transfer',
|
|
671
|
+
durationMs: Math.round(performance.now() - started),
|
|
672
|
+
appliedBy: config.nodeId,
|
|
673
|
+
note: `run ${runId}, ${bundle.package.packageName}@${bundle.package.version}${replace ? `, --${replace}` : ''}`,
|
|
522
674
|
})
|
|
523
|
-
|
|
524
|
-
const outcome: ContentImportResult = {
|
|
675
|
+
return {
|
|
525
676
|
bundle: summarise(bundle),
|
|
526
677
|
backup,
|
|
527
|
-
runId
|
|
678
|
+
runId,
|
|
528
679
|
check: result.check,
|
|
529
|
-
blobs:
|
|
680
|
+
blobs: placed,
|
|
530
681
|
published: result.published.length,
|
|
531
682
|
publishFailures: result.publishFailures,
|
|
532
683
|
problems: prepared.problems,
|
|
533
|
-
sections
|
|
684
|
+
sections: staged
|
|
685
|
+
? {
|
|
686
|
+
written: staged.written,
|
|
687
|
+
replaced: [],
|
|
688
|
+
schema: schemaAction ? { action: schemaAction } : undefined,
|
|
689
|
+
dictionary,
|
|
690
|
+
}
|
|
691
|
+
: undefined,
|
|
692
|
+
replacedFilesKept: staged ? kept : undefined,
|
|
534
693
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export interface RevertedSections extends RestoredFiles {
|
|
697
|
+
/** The schema sync after the swap back, when the run swapped schema in. */
|
|
698
|
+
schema: string | undefined
|
|
699
|
+
/** Whether the dictionary was put back as the run found it. */
|
|
700
|
+
dictionary: boolean
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* What an install did beyond its content, undone once `revertRun` has put the
|
|
705
|
+
* content back (story 105): the folders swapped back, the schema re-synced to
|
|
706
|
+
* them, and the dictionary restored as the run found it.
|
|
707
|
+
*/
|
|
708
|
+
export async function revertSections(
|
|
709
|
+
db: Db,
|
|
710
|
+
config: CromboloConfig,
|
|
711
|
+
runId: string,
|
|
712
|
+
): Promise<RevertedSections> {
|
|
713
|
+
const swapped = swappedSections(config, runId)
|
|
714
|
+
const files = restoreSectionFiles(config, runId)
|
|
715
|
+
let schema: string | undefined
|
|
716
|
+
if (swapped.includes('schema')) {
|
|
717
|
+
// A type the install brought has content only in the bin by now, which is
|
|
718
|
+
// what retiring it here is for.
|
|
719
|
+
const report = await syncSchemaDirectory(
|
|
720
|
+
db,
|
|
721
|
+
config,
|
|
722
|
+
{ nodeId: config.nodeId, revision: config.schemaRevision },
|
|
723
|
+
{ forceRetireTypes: true },
|
|
724
|
+
)
|
|
725
|
+
schema = report.action
|
|
726
|
+
if (config.schemaStore) await publishSchema(config.schemaStore, config.schemaDir)
|
|
538
727
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
durationMs: Math.round(performance.now() - started),
|
|
548
|
-
appliedBy: config.nodeId,
|
|
549
|
-
note: `run ${result.runId}, ${bundle.package.packageName}@${bundle.package.version}`,
|
|
550
|
-
})
|
|
551
|
-
return outcome
|
|
728
|
+
const kept = keptDictionary(config, runId)
|
|
729
|
+
if (kept !== undefined)
|
|
730
|
+
await db.transaction(async (tx) => {
|
|
731
|
+
const repo = new DictionaryRepository(tx)
|
|
732
|
+
for (const root of await repo.children(null)) await repo.delete(root.key)
|
|
733
|
+
await importDictionaryUdt(tx, kept)
|
|
734
|
+
})
|
|
735
|
+
return { ...files, schema, dictionary: kept !== undefined }
|
|
552
736
|
}
|
package/src/bundle-sections.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
existsSync,
|
|
21
21
|
mkdirSync,
|
|
22
22
|
mkdtempSync,
|
|
23
|
+
readdirSync,
|
|
23
24
|
readFileSync,
|
|
24
25
|
renameSync,
|
|
25
26
|
rmSync,
|
|
@@ -33,6 +34,7 @@ import { BUNDLE_SECTIONS } from '@crombolo/transfer'
|
|
|
33
34
|
import type { CromboloConfig } from './config.ts'
|
|
34
35
|
import { importDictionaryUdt } from './dictionary-transfer.ts'
|
|
35
36
|
import { checkSchemaDirectory, syncSchemaDirectory } from './schema.ts'
|
|
37
|
+
import { publishSchema } from './schema-store.ts'
|
|
36
38
|
|
|
37
39
|
/** What applying one carried file would do to the destination. */
|
|
38
40
|
export type FileAction = 'create' | 'overwrite' | 'unchanged'
|
|
@@ -277,6 +279,10 @@ export async function applySections(
|
|
|
277
279
|
revision: config.schemaRevision,
|
|
278
280
|
})
|
|
279
281
|
schema = { action: report.action }
|
|
282
|
+
// The store is the truth where there is one, as for a backoffice edit: the
|
|
283
|
+
// next boot materialises it over this directory (story 106).
|
|
284
|
+
if (config.schemaStore && (report.action === 'applied' || report.action === 'skipped-same'))
|
|
285
|
+
await publishSchema(config.schemaStore, config.schemaDir)
|
|
280
286
|
}
|
|
281
287
|
|
|
282
288
|
if (!options.withoutFiles) {
|
|
@@ -377,6 +383,23 @@ export interface RestoredFiles {
|
|
|
377
383
|
*/
|
|
378
384
|
export function restoreSectionFiles(config: CromboloConfig, runId: string): RestoredFiles {
|
|
379
385
|
const dir = join(config.siteDir, RUN_BACKUP_DIR, runId)
|
|
386
|
+
// A staged install swapped whole sections in; reverting swaps them back, and
|
|
387
|
+
// keeps what it takes out beside them so the revert can itself be reverted.
|
|
388
|
+
const swapped = swappedSections(config, runId)
|
|
389
|
+
if (swapped.length > 0) {
|
|
390
|
+
const restored: string[] = []
|
|
391
|
+
for (const section of swapped) {
|
|
392
|
+
const destination = destinationOf(config, section)
|
|
393
|
+
if (!destination) continue
|
|
394
|
+
const previous = join(dir, PREVIOUS_DIR, section)
|
|
395
|
+
const reverted = join(dir, 'reverted', section)
|
|
396
|
+
rmSync(reverted, { recursive: true, force: true })
|
|
397
|
+
moveContents(destination, reverted)
|
|
398
|
+
moveContents(previous, destination)
|
|
399
|
+
restored.push(destination)
|
|
400
|
+
}
|
|
401
|
+
return { restored, removed: [] }
|
|
402
|
+
}
|
|
380
403
|
const record = join(dir, RECORD_FILE)
|
|
381
404
|
if (!existsSync(record)) return { restored: [], removed: [] }
|
|
382
405
|
|
|
@@ -422,3 +445,140 @@ function sectionFor(path: string): BundleSection | undefined {
|
|
|
422
445
|
if (top === dir) return section as BundleSection
|
|
423
446
|
return undefined
|
|
424
447
|
}
|
|
448
|
+
|
|
449
|
+
/** Which sections an install staged, and where each one's current files went. */
|
|
450
|
+
interface SwapRecord {
|
|
451
|
+
sections: BundleSection[]
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const SWAP_FILE = 'swap.json'
|
|
455
|
+
const NEXT_DIR = 'next'
|
|
456
|
+
const PREVIOUS_DIR = 'previous'
|
|
457
|
+
const DICTIONARY_BEFORE = 'dictionary-before.udt'
|
|
458
|
+
|
|
459
|
+
export interface StagedSections {
|
|
460
|
+
/** The run's directory: staged files, then the swapped-out originals. */
|
|
461
|
+
dir: string
|
|
462
|
+
/** Each staged section's directory under `dir`. */
|
|
463
|
+
next: Partial<Record<BundleSection, string>>
|
|
464
|
+
/** The files the bundle brought, by where they will land. */
|
|
465
|
+
written: string[]
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Moves what a directory holds, not the directory: a site's folders can be
|
|
470
|
+
* mount points, which cannot be renamed. A move across file systems copies.
|
|
471
|
+
*/
|
|
472
|
+
function moveContents(from: string, to: string): void {
|
|
473
|
+
if (!existsSync(from)) return
|
|
474
|
+
mkdirSync(to, { recursive: true })
|
|
475
|
+
for (const entry of readdirSync(from)) {
|
|
476
|
+
const source = join(from, entry)
|
|
477
|
+
const target = join(to, entry)
|
|
478
|
+
rmSync(target, { recursive: true, force: true })
|
|
479
|
+
try {
|
|
480
|
+
renameSync(source, target)
|
|
481
|
+
} catch (error) {
|
|
482
|
+
if ((error as NodeJS.ErrnoException).code !== 'EXDEV') throw error
|
|
483
|
+
cpSync(source, target, { recursive: true })
|
|
484
|
+
rmSync(source, { recursive: true, force: true })
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Stage, the first of an install's three phases (story 105): every carried
|
|
491
|
+
* section written into a copy under the run's directory, the site untouched.
|
|
492
|
+
*
|
|
493
|
+
* Overlaid, a staged section is the site's directory with the bundle's files
|
|
494
|
+
* laid over it, and only a section the bundle carries is staged. Replacing, a
|
|
495
|
+
* staged section is the bundle's files alone, and every section is staged — so
|
|
496
|
+
* what the file does not have is gone once the copies swap in.
|
|
497
|
+
*/
|
|
498
|
+
export function stageSections(
|
|
499
|
+
config: CromboloConfig,
|
|
500
|
+
carried: CarriedSections,
|
|
501
|
+
options: SectionOptions & { replace?: boolean },
|
|
502
|
+
dir: string,
|
|
503
|
+
): StagedSections {
|
|
504
|
+
const staged: StagedSections = { dir, next: {}, written: [] }
|
|
505
|
+
for (const section of ['schema', ...FILE_SECTIONS] as BundleSection[]) {
|
|
506
|
+
if (section === 'schema' ? options.withoutSchema : options.withoutFiles) continue
|
|
507
|
+
const destination = destinationOf(config, section)
|
|
508
|
+
if (!destination) continue
|
|
509
|
+
const paths = carried.carries[section] ?? []
|
|
510
|
+
if (!options.replace && paths.length === 0) continue
|
|
511
|
+
const next = join(dir, NEXT_DIR, section)
|
|
512
|
+
mkdirSync(next, { recursive: true })
|
|
513
|
+
if (!options.replace && existsSync(destination)) cpSync(destination, next, { recursive: true })
|
|
514
|
+
for (const path of paths) {
|
|
515
|
+
const inside = withinSection(path, section)
|
|
516
|
+
const target = join(next, inside)
|
|
517
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
518
|
+
writeFileSync(target, read(carried, path))
|
|
519
|
+
staged.written.push(join(destination, inside))
|
|
520
|
+
}
|
|
521
|
+
staged.next[section] = next
|
|
522
|
+
}
|
|
523
|
+
return staged
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** A config whose section directories are the staged copies, for the sync inside the transaction. */
|
|
527
|
+
export function stagedConfig(config: CromboloConfig, staged: StagedSections): CromboloConfig {
|
|
528
|
+
return {
|
|
529
|
+
...config,
|
|
530
|
+
schemaDir: staged.next.schema ?? config.schemaDir,
|
|
531
|
+
componentsDir: staged.next.components ?? config.componentsDir,
|
|
532
|
+
stylesheetsDir: staged.next.styles ?? config.stylesheetsDir,
|
|
533
|
+
scriptsDir: staged.next.scripts ?? config.scriptsDir,
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Swap, the third phase, once the transaction has committed: each staged
|
|
539
|
+
* section moves into place and what it replaces is kept under the run, for its
|
|
540
|
+
* revert.
|
|
541
|
+
*/
|
|
542
|
+
export function swapSections(config: CromboloConfig, staged: StagedSections): void {
|
|
543
|
+
const sections = Object.keys(staged.next) as BundleSection[]
|
|
544
|
+
for (const section of sections) {
|
|
545
|
+
const destination = destinationOf(config, section) as string
|
|
546
|
+
moveContents(destination, join(staged.dir, PREVIOUS_DIR, section))
|
|
547
|
+
moveContents(staged.next[section] as string, destination)
|
|
548
|
+
}
|
|
549
|
+
rmSync(join(staged.dir, NEXT_DIR), { recursive: true, force: true })
|
|
550
|
+
const record: SwapRecord = { sections }
|
|
551
|
+
writeFileSync(join(staged.dir, SWAP_FILE), `${JSON.stringify(record, null, 2)}\n`)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Keeps the dictionary as it was, for a revert to put back. */
|
|
555
|
+
export function keepDictionary(dir: string, udt: string): void {
|
|
556
|
+
mkdirSync(dir, { recursive: true })
|
|
557
|
+
writeFileSync(join(dir, DICTIONARY_BEFORE), udt)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The dictionary a run kept, when it kept one. */
|
|
561
|
+
export function keptDictionary(config: CromboloConfig, runId: string): string | undefined {
|
|
562
|
+
const file = join(config.siteDir, RUN_BACKUP_DIR, runId, DICTIONARY_BEFORE)
|
|
563
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : undefined
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Whether a run swapped whole sections, so that reverting it swaps them back. */
|
|
567
|
+
export function swappedSections(config: CromboloConfig, runId: string): BundleSection[] {
|
|
568
|
+
const file = join(config.siteDir, RUN_BACKUP_DIR, runId, SWAP_FILE)
|
|
569
|
+
if (!existsSync(file)) return []
|
|
570
|
+
try {
|
|
571
|
+
return (JSON.parse(readFileSync(file, 'utf8')) as SwapRecord).sections
|
|
572
|
+
} catch {
|
|
573
|
+
return []
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Moves a staging directory to be the run's, once the run exists. */
|
|
578
|
+
export function promoteStaged(config: CromboloConfig, dir: string, runId: string): string {
|
|
579
|
+
const target = join(config.siteDir, RUN_BACKUP_DIR, runId)
|
|
580
|
+
rmSync(target, { recursive: true, force: true })
|
|
581
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
582
|
+
renameSync(dir, target)
|
|
583
|
+
return target
|
|
584
|
+
}
|
package/src/bundles.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from '@crombolo/data'
|
|
63
63
|
import { normaliseRedirectPath, type PublishedCache } from '@crombolo/render'
|
|
64
64
|
import type { BundleForms } from './form-types.ts'
|
|
65
|
+
import type { BundleIdentityProviders } from './identity-provider-overrides.ts'
|
|
65
66
|
import type { BundleEvents } from './webhooks.ts'
|
|
66
67
|
|
|
67
68
|
/** The segment every bundle's routes hang below, inside the plugin API. */
|
|
@@ -142,6 +143,8 @@ export interface BundleHost {
|
|
|
142
143
|
redirects: BundleRedirects
|
|
143
144
|
documents: BundleDocuments
|
|
144
145
|
log: BundleLog
|
|
146
|
+
/** The identity providers the config declares, and an administrator's overrides of their settings (story 033). */
|
|
147
|
+
identityProviders: BundleIdentityProviders
|
|
145
148
|
/** The bundle's own tables, and only for a bundle that declares some. */
|
|
146
149
|
tables: BundleTablesCapability
|
|
147
150
|
}
|
|
@@ -182,6 +185,7 @@ export const BUNDLE_CAPABILITIES: readonly BundleCapability[] = [
|
|
|
182
185
|
'documents',
|
|
183
186
|
'log',
|
|
184
187
|
'tables',
|
|
188
|
+
'identityProviders',
|
|
185
189
|
]
|
|
186
190
|
|
|
187
191
|
/** What a handler is handed: the declared capabilities, with `tables` typed from the declaration. */
|