@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.
@@ -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
- promoteBackups,
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
- /** Back up, re-check, then apply in one transaction and record the run. */
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
- // The coarse layer, under the fine-grained revert: in-place conversion is not
456
- // reversible by itself, so the same rule as `upgrade` applies here.
457
- const backup = await backupBefore(config, {
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(set.manifest.id),
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
- if (plan?.schema?.classification === 'breaking')
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
- 'the schema this bundle carries cannot be applied here without data work',
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
- * A file this site already has is not replaced without being asked.
495
- *
496
- * Every section is editable in the backoffice — a stylesheet, a template, a
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 needingPermission = plan ? replacementsNeedingPermission(plan, input) : []
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
- `${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
505
- ...needingPermission.map((file: SectionFile) => file.path),
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
- // Staged whenever something *will* be replaced — which is decided by the
509
- // plan, not by the permission check: by this point permission has been
510
- // given, so the files needing it are exactly the ones to back up.
511
- const willReplace = (plan?.files ?? []).some((file) => file.action === 'overwrite')
512
- const staging = plan && willReplace ? stageBackups(config) : undefined
513
- const sections = plan
514
- ? await applySections(db, config, carried, { ...input, backupDir: staging })
515
- : undefined
516
- const result = await importBundle(db, set, {
517
- ...prepared.options,
518
- publish: input.publish ?? false,
519
- nodeId: config.nodeId,
520
- label: input.label,
521
- package: { name: bundle.package.packageName, version: bundle.package.version },
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
- await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
524
- const outcome: ContentImportResult = {
675
+ return {
525
676
  bundle: summarise(bundle),
526
677
  backup,
527
- runId: result.runId,
678
+ runId,
528
679
  check: result.check,
529
- blobs: null,
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
- if (!result.runId) {
536
- await recordPending(db, bundle, input, prepared.options.allowMissingBlobs ?? false)
537
- return outcome
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
- await new TransferAnswerRepository(db).clear(set.manifest.id)
540
- // The backups become the run's, so `content revert` can find them.
541
- if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
542
- if (blobs.size > 0)
543
- outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs, bundle.openBlob)
544
- await recordInLedger(db, {
545
- name: `content import ${set.manifest.id}`,
546
- kind: 'transfer',
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
  }
@@ -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. */