caracms 0.8.0 → 0.8.2

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.
Files changed (62) hide show
  1. package/README.md +23 -2
  2. package/package.json +1 -1
  3. package/skills/caracms-hosting/SKILL.md +1 -1
  4. package/src/body.ts +55 -0
  5. package/src/cli/validate.ts +1 -1
  6. package/src/cloud-middleware.ts +39 -3
  7. package/src/content.ts +15 -6
  8. package/src/document-problems.ts +48 -0
  9. package/src/editor/client/collections/CollectionGrid.tsx +2 -2
  10. package/src/editor/client/collections/CollectionTable.tsx +3 -3
  11. package/src/editor/client/documents/DocMenu.tsx +56 -1
  12. package/src/editor/client/documents/actions.ts +30 -1
  13. package/src/editor/client/documents/dialogs.tsx +61 -3
  14. package/src/editor/client/editor.ts +98 -2
  15. package/src/editor/client/fields/LockupField.tsx +4 -4
  16. package/src/editor/client/inbox/InboxStage.tsx +25 -1
  17. package/src/editor/client/live/notices.tsx +19 -0
  18. package/src/editor/client/live/publish.tsx +58 -2
  19. package/src/editor/client/nav/DocRow.tsx +4 -4
  20. package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
  21. package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
  22. package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
  23. package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
  24. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
  25. package/src/editor/client/pane/parts.tsx +67 -4
  26. package/src/editor/client/pane/switcher.tsx +4 -4
  27. package/src/editor/client/shell/Bar.tsx +11 -0
  28. package/src/editor/client/state/keeper.ts +28 -4
  29. package/src/editor/client/state/live.ts +18 -1
  30. package/src/editor/routes/_live.ts +1 -1
  31. package/src/editor/routes/admin.astro +9 -0
  32. package/src/editor/routes/discard.ts +11 -0
  33. package/src/editor/server/admin-props.ts +18 -2
  34. package/src/editor/server/arrange.ts +4 -4
  35. package/src/editor/server/create.ts +4 -4
  36. package/src/editor/server/drafts-store.ts +2 -2
  37. package/src/editor/server/empty-site.ts +23 -0
  38. package/src/editor/server/media/cloud-mux.ts +4 -1
  39. package/src/editor/server/media/upload.ts +2 -41
  40. package/src/editor/server/move.ts +32 -4
  41. package/src/editor/server/write.ts +4 -4
  42. package/src/editor/styles/levels/live.css +2 -0
  43. package/src/editor/types.ts +92 -4
  44. package/src/forms/cloud-store.ts +4 -1
  45. package/src/forms/inbox.ts +10 -5
  46. package/src/forms/store.ts +14 -2
  47. package/src/index.ts +23 -8
  48. package/src/live/auth/login.ts +4 -3
  49. package/src/live/auth/views.ts +8 -1
  50. package/src/live/autosaves.ts +94 -6
  51. package/src/live/cloud.ts +64 -20
  52. package/src/live/drafts-store.ts +17 -6
  53. package/src/live/editor.ts +232 -16
  54. package/src/live/git.ts +492 -22
  55. package/src/live/github.ts +84 -17
  56. package/src/live/locks.ts +22 -0
  57. package/src/live/messages.ts +49 -2
  58. package/src/live/notices.ts +5 -1
  59. package/src/live/runtime.ts +2 -1
  60. package/src/live/status.ts +5 -0
  61. package/src/live/sweep.ts +44 -1
  62. package/src/live/webhook.ts +8 -3
package/src/live/git.ts CHANGED
@@ -30,6 +30,40 @@
30
30
  * new, deleted or changed (`changeOf`), and a file made and removed again since the
31
31
  * last Publish isn't waiting at all.
32
32
  *
33
+ * **Losing `drafts`** (caracms-cloud adversarial review, finding 5). Every head of
34
+ * `drafts` this server makes or finds at start is recorded in Redis (`drafts:head`, no
35
+ * expiry). A start that finds `drafts` gone (a branch tidy-up, a stale-branch bot) makes
36
+ * it again from that commit while GitHub still has it; only when it can't does it fall
37
+ * back to `main`, with a loud log line and a note every editor sees for a week
38
+ * (`drafts:lost`, `draftsLost()`), cleared by the next Publish.
39
+ *
40
+ * **`main` rolled back by a force-push** (caracms-cloud adversarial review, force-push
41
+ * note). A developer resetting `main` to an older commit and force-pushing leaves `drafts`
42
+ * holding what was rolled away, so it all reads as waiting and the next Publish puts it
43
+ * back. Each check asks whether `main` still has the last Publish's commit (one compare per
44
+ * pair of commits): a push on top, a merge or a `git revert` all keep it, so only history
45
+ * being rewritten finds it gone. Then a log line, and a note every editor sees
46
+ * (`main:rolled-back`, `mainRolledBack()`). Nothing works out which waiting changes were
47
+ * rolled back. The note goes with the next Publish, once nothing is waiting (everything
48
+ * discarded: it stays gone for that rollback), or when `main` has the commit again.
49
+ *
50
+ * **Commits that keep failing** (finding 4): `commitTrouble()` says so once
51
+ * `COMMIT_TROUBLE_AFTER` draft commits in a row have failed, until one succeeds; the
52
+ * status poll turns it into the save bar's warning. In-process: the sweep's ticks on this
53
+ * server are what fail, and a restart learns it again within a few ticks.
54
+ *
55
+ * **Holding a document back** (caracms-cloud adversarial review, finding 15). Publish
56
+ * checks every waiting document as the reader will (document-problems.ts): one the reader
57
+ * would refuse (no title) isn't published. `main` gets `drafts`' tree with those files as
58
+ * `main` has them (a new one left out), and `drafts` gets one more commit on top, headed
59
+ * `Held back:`, putting them back as they were, so they're still waiting, with who changed
60
+ * them. A moved document's old file is held with its new one. Nothing publishable: nothing
61
+ * moves (`held-back`).
62
+ *
63
+ * **Discarding a document's changes** (`restoreDrafts`): its files are committed to
64
+ * `drafts` as `main` has them now (a file `main` lacks is removed), headed `Discarded:`,
65
+ * so what's waiting no longer lists them. One commit, compare-and-swap like any other.
66
+ *
33
67
  * In-process state here is caches of git and GitHub, and throttles; none of it is
34
68
  * the only record of anything.
35
69
  */
@@ -45,7 +79,8 @@ import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, ty
45
79
  import { HttpError } from './github.ts';
46
80
  import { runningCommitFrom } from './mode.ts';
47
81
  import { mergeFile } from './merge.ts';
48
- import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
82
+ import { discardMessage, draftMessage, heldMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
83
+ import { textProblems } from '../document-problems.ts';
49
84
  import type { RedisClient } from './redis.ts';
50
85
  import { landed, StatusCache } from './status-cache.ts';
51
86
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
@@ -79,10 +114,22 @@ export type CheckResult =
79
114
  | { kind: 'fast-forward'; to: string; fromMain: string[] }
80
115
  | { kind: 'merged'; commit: string; fromMain: string[]; keptDraft: string[] };
81
116
 
117
+ /** A waiting document Publish left waiting (finding 15): its file, its title and why ("its title is empty"). */
118
+ export interface HeldBack {
119
+ file: string;
120
+ title: string;
121
+ reason: string;
122
+ }
123
+
82
124
  export type PublishResult =
83
125
  | { kind: 'nothing-waiting' }
84
- /** `fileTitles`: each of `record.files`' own title, in that order (`record.titles` is the headline's items). */
85
- | { kind: 'published'; record: PublishRecord; attempts: number; fileTitles: string[] }
126
+ /**
127
+ * `fileTitles`: each of `record.files`' own title, in that order (`record.titles` is the headline's items).
128
+ * `held`: the documents left waiting (finding 15), none of them in `record.files`.
129
+ */
130
+ | { kind: 'published'; record: PublishRecord; attempts: number; fileTitles: string[]; held: HeldBack[] }
131
+ /** Finding 15: everything waiting would break the reader, so nothing moved. */
132
+ | { kind: 'held-back'; held: HeldBack[] }
86
133
  /** A Publish was already running; this press joined it. */
87
134
  | { kind: 'joined'; record: PublishRecord | null };
88
135
 
@@ -140,6 +187,35 @@ export interface LiveGitOptions {
140
187
  statusCache?: StatusCache;
141
188
  }
142
189
 
190
+ /**
191
+ * Draft commits failing in a row before the editor is told (finding 4): three of the
192
+ * sweep's 30-second ticks, about a minute of failing, so one GitHub blip never shows.
193
+ */
194
+ export const COMMIT_TROUBLE_AFTER = 3;
195
+
196
+ /** Draft commits failing in a row (finding 4): since when, and how many. */
197
+ export interface CommitTrouble {
198
+ since: number;
199
+ failures: number;
200
+ }
201
+
202
+ /** `drafts` was made again from `main` and what it held is gone (finding 5): its last recorded head (null: none was), and when. */
203
+ export interface DraftsLost {
204
+ commit: string | null;
205
+ at: string;
206
+ }
207
+
208
+ /**
209
+ * `main` no longer has the last Publish's commit (force-push note): that commit, `main`'s head
210
+ * when it was found, and when. `cleared`: nothing was waiting since, so the note is done with.
211
+ */
212
+ export interface MainRolledBack {
213
+ publish: string;
214
+ main: string;
215
+ at: string;
216
+ cleared?: boolean;
217
+ }
218
+
143
219
  /** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
144
220
  export { SITE_SETTINGS_FILE };
145
221
 
@@ -150,6 +226,17 @@ const RAILWAY_API = 'https://backboard.railway.com/graphql/v2';
150
226
 
151
227
  const posix = (p: string) => p.split(sep).join('/');
152
228
 
229
+ /** The rollback note as kept in Redis; null when there's none or it doesn't read. */
230
+ function parseRolledBack(raw: string | null): MainRolledBack | null {
231
+ if (!raw) return null;
232
+ try {
233
+ const v = JSON.parse(raw) as MainRolledBack;
234
+ return typeof v.publish === 'string' && typeof v.main === 'string' ? v : null;
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
153
240
  /** What `drafts` holds beyond `main`: from one compare, extended by this server's own commits. */
154
241
  interface SinceMain {
155
242
  main: string;
@@ -204,6 +291,21 @@ export class LiveGit {
204
291
  private deploymentsDenied = false;
205
292
  /** A deleted file's title (M5a): the waiting list's name for it once `drafts` has no copy. Learned when this process deleted it, else from `main`'s copy at a check (`learnDeletedTitles`), so a restart keeps it. */
206
293
  private deletedTitles = new Map<string, string>();
294
+ /** Finding 5: the `drafts` head last written to Redis from here, so the same one isn't written twice. */
295
+ private recordedHead: string | null = null;
296
+ /** Finding 5: `drafts` was made again from `main` (read at start; a Publish clears it). */
297
+ private lost: DraftsLost | null = null;
298
+ /** Force-push note: `main` lost the last Publish's commit (read at each check; a Publish clears it). */
299
+ private rolledBack: MainRolledBack | null = null;
300
+ /** Force-push note: the last (publish commit, `main`) pair checked, and an unknown commit already logged. */
301
+ private rollbackPair: string | null = null;
302
+ private rollbackUnknown: string | null = null;
303
+ /** Its own cache, not `includesCache`: that one reads a commit GitHub lacks as "not included". */
304
+ private onMainCache = new Map<string, boolean>();
305
+ /** Finding 4: draft commits failing in a row; null after a success. */
306
+ private failing: CommitTrouble | null = null;
307
+ /** Finding 6: a publish whose refs moved but whose record couldn't be written to Redis. */
308
+ private unkeptRecord: PublishRecord | null = null;
207
309
 
208
310
  constructor(options: LiveGitOptions) {
209
311
  this.backend = options.backend;
@@ -270,10 +372,62 @@ export class LiveGit {
270
372
  return this.key('publish:last');
271
373
  }
272
374
 
375
+ /** Finding 5: the newest head of `drafts` this server made or found, and the note left when it was lost. */
376
+ get draftsHeadKey(): string {
377
+ return this.key('drafts:head');
378
+ }
379
+ get draftsLostKey(): string {
380
+ return this.key('drafts:lost');
381
+ }
382
+
383
+ /** Force-push note: `main` was found without the last Publish's commit. */
384
+ get mainRolledBackKey(): string {
385
+ return this.key('main:rolled-back');
386
+ }
387
+
273
388
  private async setPublishRecord(record: PublishRecord): Promise<void> {
274
389
  const raw = JSON.stringify(record);
275
390
  await landed(this.statusCache, this.publishRecordKey, () => this.redis.set(this.publishRecordKey, raw));
276
391
  this.statusCache.string(this.publishRecordKey, raw);
392
+ this.unkeptRecord = null;
393
+ }
394
+
395
+ /**
396
+ * Finding 6: the newest publish as the status poll read it, unless this server made a
397
+ * newer one whose record Redis didn't take: that one.
398
+ */
399
+ publishRecordOr(record: PublishRecord | null): PublishRecord | null {
400
+ const unkept = this.unkeptRecord;
401
+ return unkept && (!record || record.at < unkept.at) ? unkept : record;
402
+ }
403
+
404
+ /**
405
+ * Finding 5: record `drafts`' head in Redis (no expiry), so a start that finds the
406
+ * branch deleted can make it again from here. Never fails what it follows.
407
+ */
408
+ private async recordDraftsHead(commit: string): Promise<void> {
409
+ if (this.recordedHead === commit) return;
410
+ try {
411
+ await this.redis.set(this.draftsHeadKey, commit);
412
+ this.recordedHead = commit;
413
+ } catch (error) {
414
+ this.log(`recording drafts' head (${commit.slice(0, 7)}): ${error instanceof Error ? error.message : String(error)}`);
415
+ }
416
+ }
417
+
418
+ /** Finding 5: `drafts` was made again from `main` at a start, losing what it held (for a week, or until the next Publish). */
419
+ draftsLost(): DraftsLost | null {
420
+ return this.lost;
421
+ }
422
+
423
+ /** Force-push note: `main` was rolled back past the last Publish, and something is still waiting. */
424
+ mainRolledBack(): MainRolledBack | null {
425
+ return this.rolledBack && !this.rolledBack.cleared ? this.rolledBack : null;
426
+ }
427
+
428
+ /** Finding 4: draft commits have failed `COMMIT_TROUBLE_AFTER` times in a row or more, and none has succeeded since. */
429
+ commitTrouble(): CommitTrouble | null {
430
+ return this.failing && this.failing.failures >= COMMIT_TROUBLE_AFTER ? this.failing : null;
277
431
  }
278
432
 
279
433
  /** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
@@ -291,6 +445,13 @@ export class LiveGit {
291
445
  * can run it once `commitPending` is in place, Decision 8).
292
446
  */
293
447
  async start(options: { check?: boolean } = {}): Promise<void> {
448
+ // Finding 5: a note left by an earlier start that lost `drafts` (it expires after a week).
449
+ try {
450
+ const raw = await this.redis.get(this.draftsLostKey);
451
+ this.lost = raw ? (JSON.parse(raw) as DraftsLost) : null;
452
+ } catch (error) {
453
+ this.log(`reading the lost-drafts note: ${error instanceof Error ? error.message : String(error)}`);
454
+ }
294
455
  await this.backend.tracker.within('start', () =>
295
456
  this.exclusive(async () => {
296
457
  let heads: Heads;
@@ -298,19 +459,69 @@ export class LiveGit {
298
459
  heads = await this.backend.readHeads();
299
460
  } catch (error) {
300
461
  if (!(error instanceof MissingBranchError) || error.branch !== 'drafts') throw error;
301
- const main = await this.mainOnly();
302
- this.log(`no drafts branch yet: creating it from main (${main.slice(0, 7)})`);
303
- await this.backend.createBranch('drafts', main).catch((e: unknown) => {
304
- if (!(e instanceof HttpError && e.status === 422)) throw e; // another server made it first
305
- });
306
- heads = await this.backend.readHeads();
462
+ heads = await this.remakeDrafts();
307
463
  }
308
464
  await this.download(heads.drafts);
465
+ await this.recordDraftsHead(heads.drafts.commit);
309
466
  }),
310
467
  );
311
468
  if (options.check !== false) await this.check({ force: true });
312
469
  }
313
470
 
471
+ /**
472
+ * `drafts` is missing (finding 5): made again from its last recorded head when GitHub
473
+ * still has that commit, so nothing unpublished is lost. Otherwise from `main`, as on a
474
+ * site's first start; with a recorded head, or a publish on record (so it isn't a first
475
+ * start), that loses every change saved since the last Publish: a loud log line, and the
476
+ * editor's note (`drafts:lost`). Redis out: this start fails rather than make `drafts`
477
+ * from `main` and lose what the next start could put back.
478
+ */
479
+ private async remakeDrafts(): Promise<Heads> {
480
+ const main = await this.mainOnly();
481
+ const recorded = await this.redis.get(this.draftsHeadKey);
482
+ if (recorded) {
483
+ const heads = await this.createDrafts(recorded);
484
+ if (heads) {
485
+ this.log(`the drafts branch was missing: made it again from its last recorded head (${recorded}), so nothing unpublished was lost`);
486
+ return heads;
487
+ }
488
+ }
489
+ const published = recorded === null ? (await this.lastPublish()) !== null : true;
490
+ const heads = await this.createDrafts(main);
491
+ if (!heads) throw new Error(`caracms: couldn't make the drafts branch from main (${main.slice(0, 7)}).`);
492
+ if (!published) {
493
+ this.log(`no drafts branch yet: created it from main (${main.slice(0, 7)})`);
494
+ return heads;
495
+ }
496
+ this.log(
497
+ recorded
498
+ ? `UNPUBLISHED CHANGES LOST: the drafts branch was missing on GitHub and its last recorded head (${recorded}) isn't there any more, so drafts was made again from main (${main.slice(0, 7)}). Every change saved since the last Publish, up to that commit, is gone from the editor.`
499
+ : `UNPUBLISHED CHANGES LOST: the drafts branch was missing on GitHub and no head of it was recorded, so drafts was made again from main (${main.slice(0, 7)}). Any change saved since the last Publish is gone from the editor.`,
500
+ );
501
+ this.lost = { commit: recorded, at: new Date(this.now()).toISOString() };
502
+ await this.redis.set(this.draftsLostKey, JSON.stringify(this.lost), { px: this.timings.autosaveTtlMs }).catch((error: unknown) => {
503
+ this.log(`keeping the lost-drafts note: ${error instanceof Error ? error.message : String(error)}`);
504
+ });
505
+ return heads;
506
+ }
507
+
508
+ /** Make `drafts` at `commit`; null when GitHub doesn't have that commit. Another server making it first is a success. */
509
+ private async createDrafts(commit: string): Promise<Heads | null> {
510
+ try {
511
+ await this.backend.createBranch('drafts', commit);
512
+ } catch (error) {
513
+ const heads = await this.backend.readHeads().catch((e: unknown) => {
514
+ if (e instanceof MissingBranchError && e.branch === 'drafts') return null;
515
+ throw e;
516
+ });
517
+ if (heads) return heads; // another server made it first
518
+ // GitHub answers 422 for a sha it doesn't have (the memory backend, 404). Anything else fails the start.
519
+ if (error instanceof HttpError && (error.status === 422 || error.status === 404)) return null;
520
+ throw error;
521
+ }
522
+ return this.backend.readHeads();
523
+ }
524
+
314
525
  /** `main`'s head when `drafts` is missing (readHeads throws then). */
315
526
  private async mainOnly(): Promise<string> {
316
527
  // listCommits reads main alone; its first entry is the head.
@@ -379,6 +590,20 @@ export class LiveGit {
379
590
  * and merges again.
380
591
  */
381
592
  async commitDrafts(entries: DraftEntry[]): Promise<DraftCommitResult> {
593
+ // Finding 4: count the failures in a row (the save bar's warning), and record the new head.
594
+ try {
595
+ const r = await this.commitDraftsOnce(entries);
596
+ this.failing = null;
597
+ if (r.commit) await this.recordDraftsHead(r.commit);
598
+ return r;
599
+ } catch (error) {
600
+ this.failing = { since: this.failing?.since ?? this.now(), failures: (this.failing?.failures ?? 0) + 1 };
601
+ if (this.failing.failures === COMMIT_TROUBLE_AFTER) this.log(`draft commits have failed ${COMMIT_TROUBLE_AFTER} times in a row: the editor now says they aren't reaching GitHub`);
602
+ throw error;
603
+ }
604
+ }
605
+
606
+ private async commitDraftsOnce(entries: DraftEntry[]): Promise<DraftCommitResult> {
382
607
  return this.backend.tracker.within('draft', () =>
383
608
  this.exclusive(async () => {
384
609
  for (let attempt = 1; ; attempt++) {
@@ -448,6 +673,80 @@ export class LiveGit {
448
673
  );
449
674
  }
450
675
 
676
+ // ── Discard changes ─────────────────────────────────────────────────────────
677
+
678
+ /** These files as `main` has them now (null: it hasn't), in one read of the heads and one of the files. */
679
+ async readMain(files: readonly string[]): Promise<Map<string, string | null>> {
680
+ if (files.length === 0) return new Map();
681
+ const heads = await this.backend.readHeads();
682
+ const read = await this.backend.readFiles(files.map((f) => ({ commit: heads.main.commit, path: this.toRepo(f) })));
683
+ return new Map(files.map((f, i) => [f, read[i] ?? null]));
684
+ }
685
+
686
+ /**
687
+ * Put these files on `drafts` back to what `main` has now (removed where `main` has
688
+ * none), in one commit headed `Discarded: <titles> — by <person>`. Nothing to change:
689
+ * no commit. The files leave what's waiting (`since`). A lost race refreshes the copy and
690
+ * goes again. Returns the commit (null when `drafts` already matched) and each file's
691
+ * text as it now reads (null: gone).
692
+ */
693
+ async restoreDrafts(files: readonly string[], titles: readonly string[], by: Person): Promise<{ commit: string | null; texts: Map<string, string | null> }> {
694
+ const result = await this.backend.tracker.within('draft', () =>
695
+ this.exclusive(async () => {
696
+ for (let attempt = 1; ; attempt++) {
697
+ const heads = await this.reread();
698
+ const head = this.head();
699
+ const read = await this.backend.readFiles(files.map((f) => ({ commit: heads.main.commit, path: this.toRepo(f) })));
700
+ const texts = new Map(files.map((f, i) => [f, read[i] ?? null] as [string, string | null]));
701
+ const changed = [...texts].filter(([file, text]) => this.read(file) !== text);
702
+ const settle = (commit: string) => {
703
+ // Every one of them matches main now: none is waiting. Only while the compare is of this main.
704
+ if (this.since && this.since.main === heads.main.commit && this.since.drafts === commit) {
705
+ for (const file of files) {
706
+ this.since.files.delete(this.toRepo(file));
707
+ this.since.absent.delete(this.toRepo(file));
708
+ }
709
+ } else this.since = null;
710
+ };
711
+ if (changed.length === 0) {
712
+ settle(head.commit);
713
+ return { commit: null, texts };
714
+ }
715
+ try {
716
+ const removed = changed.filter(([, text]) => text === null).map(([f]) => this.toRepo(f));
717
+ const writes = changed.filter(([, text]) => text !== null).map(([file, contents]) => ({ path: this.toRepo(file), contents: contents! }));
718
+ const message = discardMessage([...titles], [...files], by);
719
+ const [headline, ...body] = message.split('\n');
720
+ const next = await this.backend.commitOnBranch('drafts', head.commit, writes, removed, headline!, body.join('\n').replace(/^\n/, ''));
721
+ for (const [file, text] of changed) {
722
+ if (text === null) this.copyFiles.delete(file);
723
+ else this.copyFiles.set(file, text);
724
+ }
725
+ this.copyHead = next;
726
+ if (this.since?.drafts === head.commit) {
727
+ this.since.drafts = next.commit;
728
+ this.since.commits.push({ sha: next.commit, parents: [head.commit], message, date: new Date(this.now()).toISOString() });
729
+ }
730
+ settle(next.commit);
731
+ this.historyCache = null;
732
+ this.log(`discarded ${next.commit.slice(0, 7)}: ${files.join(', ')}`);
733
+ return { commit: next.commit, texts };
734
+ } catch (error) {
735
+ const now = await this.backend.readHeads().catch(() => null);
736
+ const moved = now !== null && now.drafts.commit !== head.commit;
737
+ if (!(error instanceof StaleHeadError) && !moved) throw error;
738
+ if (attempt >= MAX_ATTEMPTS) throw new Error(`caracms: drafts kept moving; gave up discarding after ${attempt} tries.`);
739
+ this.log('discard: drafts moved under us; refreshing the copy and trying again');
740
+ }
741
+ }
742
+ }),
743
+ );
744
+ if (result.commit) await this.recordDraftsHead(result.commit);
745
+ // Force-push note: everything rolled back may have just been discarded.
746
+ await this.exclusive(() => this.rollbackSettled());
747
+ return result;
748
+ }
749
+
451
750
  // ── Keeping drafts up to date with main (Decision 8) ────────────────────────
452
751
 
453
752
  /**
@@ -467,6 +766,7 @@ export class LiveGit {
467
766
  const r = await this.exclusive(() => this.checkOnce(pendingCommitted));
468
767
  if (r !== 'needs-pending') {
469
768
  await this.exclusive(() => this.learnDeletedTitles());
769
+ await this.exclusive(() => this.checkRollback());
470
770
  return r;
471
771
  }
472
772
  await this.commitPending();
@@ -533,6 +833,7 @@ export class LiveGit {
533
833
  this.since = { main: heads.main.commit, drafts: heads.main.commit, commits: [], files: new Set(), absent: new Set() };
534
834
  const fromMain = (cmp.files ?? []).filter((f) => this.inScope(f.path)).map((f) => this.toSite(f.path));
535
835
  this.log(`drafts fast-forwarded to main (${heads.main.commit.slice(0, 7)})`);
836
+ await this.recordDraftsHead(heads.main.commit);
536
837
  await this.told(fromMain);
537
838
  return { kind: 'fast-forward', to: heads.main.commit, fromMain };
538
839
  }
@@ -616,6 +917,7 @@ export class LiveGit {
616
917
  absent: new Set(absent),
617
918
  };
618
919
  this.log(`merged main into drafts (${commit.slice(0, 7)}): from main [${fromMain.join(', ')}], draft kept [${keptDraft.join(', ')}]`);
920
+ await this.recordDraftsHead(commit);
619
921
  await this.told([...copyAfter.keys()]);
620
922
  return { kind: 'merged', commit, fromMain, keptDraft };
621
923
  }
@@ -718,11 +1020,18 @@ export class LiveGit {
718
1020
  return null;
719
1021
  }
720
1022
  const repoFiles = (cmp.files ?? this.diffCopyFallback().map((path) => ({ path }))).map((f) => f.path);
721
- const files = repoFiles.filter((p) => p.startsWith(this.prefix)).map(this.toSite);
1023
+ const waiting = repoFiles.filter((p) => p.startsWith(this.prefix)).map(this.toSite);
1024
+ const { records, addresses } = splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
1025
+ // Finding 15: a document the reader would refuse stays waiting; the rest go.
1026
+ const held = this.heldBack(waiting, records);
1027
+ const files = waiting.filter((f) => !held.has(f));
1028
+ if (!files.length) {
1029
+ this.log(`publish: nothing publishable; held back ${[...held.keys()].join(', ')}`);
1030
+ return { kind: 'held-back', held: [...held.values()] };
1031
+ }
722
1032
  // A file drafts no longer has is deleted: "Contact (deleted)", its title from main's copy first (M5a Decision 12).
723
1033
  const gone = files.filter((f) => this.read(f) === null);
724
1034
  const goneTitles = await this.titlesBeforeRemoval(gone, heads.main.commit);
725
- const { records, addresses } = splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
726
1035
  // Collections under pages: a held document's addresses come from its holder's (only `holds` is read, so no blocks are needed).
727
1036
  const bases = await heldBasesOf({ ...this.site, blocks: {} }, this.contentSource());
728
1037
  const moves = this.movesIn(files, records, bases, addresses);
@@ -744,18 +1053,104 @@ export class LiveGit {
744
1053
  ...readdressed.map((f) => `${titleOf(f, this.read(f))} (moved from ${moves.from.get(f)})`),
745
1054
  ...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!)),
746
1055
  ];
747
- const commit = await this.backend.createCommit(heads.drafts.tree, [heads.main.commit], publishMessage(titles, files, by));
1056
+ // Finding 15: main's tree is drafts' with the held files as main has them, and drafts
1057
+ // gets them back in a commit of its own on top, so they stay waiting.
1058
+ const heldFiles = [...held.keys()];
1059
+ const onMain = heldFiles.length ? await this.backend.readFiles(heldFiles.map((f) => ({ commit: heads.main.commit, path: this.toRepo(f) }))) : [];
1060
+ const tree = heldFiles.length
1061
+ ? await this.backend.createTree(
1062
+ heads.drafts.tree,
1063
+ heldFiles.flatMap((f, i) => (onMain[i] === null ? [{ path: this.toRepo(f), mode: '100644', sha: null }] : [])),
1064
+ heldFiles.flatMap((f, i) => (onMain[i] === null ? [] : [{ path: this.toRepo(f), contents: onMain[i]! }])),
1065
+ )
1066
+ : heads.drafts.tree;
1067
+ const commit = await this.backend.createCommit(tree, [heads.main.commit], publishMessage(titles, files, by));
1068
+ const heldBackMessage = heldFiles.length ? heldMessage([...held.values()].map((h) => h.title), heldFiles, waitingFromCommits(heldFiles, cmp.commits)) : null;
1069
+ const draftsHead = heldBackMessage ? await this.backend.createCommit(heads.drafts.tree, [commit], heldBackMessage) : commit;
748
1070
  const moved = await this.moveRefs([
749
1071
  // main: a fast-forward onto the squash commit. drafts: not a fast-forward, so
750
1072
  // `force`, but still compare-and-swap on `before` (M1 → G2).
751
1073
  { branch: 'main', before: heads.main.commit, after: commit, force: false },
752
- { branch: 'drafts', before: heads.drafts.commit, after: commit, force: true },
1074
+ { branch: 'drafts', before: heads.drafts.commit, after: draftsHead, force: true },
753
1075
  ]);
754
1076
  if (!moved) return null; // the squash commit is left orphaned; nothing points at it
755
- this.copyHead = { commit, tree: heads.drafts.tree };
756
- this.since = { main: commit, drafts: commit, commits: [], files: new Set(), absent: new Set() };
1077
+ this.copyHead = { commit: draftsHead, tree: heads.drafts.tree };
1078
+ this.since = heldBackMessage
1079
+ ? {
1080
+ main: commit,
1081
+ drafts: draftsHead,
1082
+ commits: [{ sha: draftsHead, parents: [commit], message: heldBackMessage, date: new Date(this.now()).toISOString() }],
1083
+ files: new Set(heldFiles.map(this.toRepo)),
1084
+ absent: new Set(heldFiles.filter((_, i) => onMain[i] === null).map(this.toRepo)),
1085
+ }
1086
+ : { main: commit, drafts: commit, commits: [], files: new Set(), absent: new Set() };
757
1087
  this.deletedTitles.clear();
758
1088
  this.historyCache = null;
1089
+ const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
1090
+ // The refs moved: the Publish has happened, and it's deploying. Nothing after this may
1091
+ // fail it (caracms-cloud adversarial review, finding 6): Redis dropping out now is logged,
1092
+ // and the answer is `published` with the record made here.
1093
+ try {
1094
+ await this.afterPublish(record, draftsHead, records, addresses, bases, held);
1095
+ } catch (error) {
1096
+ this.unkeptRecord = record;
1097
+ this.log(`published ${commit.slice(0, 7)}, but what follows a publish in Redis didn't all happen (${error instanceof Error ? error.message : String(error)}): the status shows this publish from this server's memory until the next one`);
1098
+ }
1099
+ this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}${heldFiles.length ? `; held back ${heldFiles.join(', ')}` : ''}`);
1100
+ return { kind: 'published', record, attempts: attempt, fileTitles, held: [...held.values()] };
1101
+ }
1102
+
1103
+ /**
1104
+ * Finding 15: the waiting files Publish leaves waiting, by file: each document the reader
1105
+ * would refuse (document-problems.ts), and a moved one's old file with it (its record, old
1106
+ * file → new), so its live page stays where it is. A deletion is never held back.
1107
+ */
1108
+ private heldBack(files: readonly string[], records: ReadonlyMap<string, string>): Map<string, HeldBack> {
1109
+ const held = new Map<string, HeldBack>();
1110
+ for (const file of files) {
1111
+ const text = this.read(file);
1112
+ if (text === null || !this.isDocument(file)) continue;
1113
+ const [problem] = textProblems(text);
1114
+ if (problem) held.set(file, { file, title: titleOf(file, text), reason: problem.reason });
1115
+ }
1116
+ for (const [old, now] of records) {
1117
+ const why = held.get(now);
1118
+ if (why && files.includes(old) && !held.has(old)) held.set(old, { ...why, file: old });
1119
+ }
1120
+ return held;
1121
+ }
1122
+
1123
+ /** Is this site file a document of a declared collection (Site Settings and shared blocks included)? */
1124
+ private isDocument(file: string): boolean {
1125
+ try {
1126
+ confinePath(this.site as ResolvedSite, file);
1127
+ return true;
1128
+ } catch {
1129
+ return false;
1130
+ }
1131
+ }
1132
+
1133
+ /** Publish's Redis work once its refs have moved (finding 6: its caller catches what fails). `draftsHead`: where it left `drafts` (finding 5). */
1134
+ private async afterPublish(
1135
+ record: PublishRecord,
1136
+ draftsHead: string,
1137
+ records: ReadonlyMap<string, string>,
1138
+ addresses: ReadonlyMap<string, string>,
1139
+ bases: Readonly<Record<string, HeldBase>>,
1140
+ held: ReadonlyMap<string, HeldBack>,
1141
+ ): Promise<void> {
1142
+ const { files } = record;
1143
+ await this.recordDraftsHead(draftsHead);
1144
+ if (this.lost) {
1145
+ // Finding 5: a Publish since `drafts` was lost is the team carrying on; the note goes.
1146
+ this.lost = null;
1147
+ await this.redis.del(this.draftsLostKey).catch(() => 0);
1148
+ }
1149
+ if (this.rolledBack) {
1150
+ // Force-push note: what was waiting has been published, rolled-back content and all, by choice.
1151
+ this.rolledBack = null;
1152
+ await this.redis.del(this.mainRolledBackKey).catch(() => 0);
1153
+ }
759
1154
  // The moves this publish took live are done with (a move made since stays waiting).
760
1155
  const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
761
1156
  if (published.length) {
@@ -771,7 +1166,8 @@ export class LiveGit {
771
1166
  // changed since (still its live address in what was published) stays waiting.
772
1167
  const live = withHeldBases(this.site, bases);
773
1168
  const done = [...addresses].filter(([file, was]) => {
774
- if (this.read(file) === null) return false;
1169
+ // Finding 15: a held-back document's live address is still the one recorded.
1170
+ if (this.read(file) === null || held.has(file)) return false;
775
1171
  const url = this.urlOf(live, file);
776
1172
  return url !== null && url !== was;
777
1173
  }).map(([file]) => `${ADDRESS_FIELD}${file}`);
@@ -784,15 +1180,12 @@ export class LiveGit {
784
1180
  if (cleared) this.statusCache.hash(key, {}, done);
785
1181
  else this.statusCache.drop(key);
786
1182
  }
787
- const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
788
1183
  await this.setPublishRecord(record);
789
- this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
790
- return { kind: 'published', record, attempts: attempt, fileTitles };
791
1184
  }
792
1185
 
793
- /** The newest publish, from Redis. */
1186
+ /** The newest publish, from Redis (or this server's own newer one Redis didn't take, finding 6). */
794
1187
  async lastPublish(): Promise<PublishRecord | null> {
795
- return LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey));
1188
+ return this.publishRecordOr(LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey)));
796
1189
  }
797
1190
 
798
1191
  /** A publish record from its text as read (the status cache's, on a poll). */
@@ -917,7 +1310,11 @@ export class LiveGit {
917
1310
  if (!this.since) return [];
918
1311
  const waiting = new Set([...this.since.files].filter((p) => this.inScope(p)).map(this.toSite));
919
1312
  const out = new Map<string, string>();
920
- for (const c of this.since.commits) for (const file of parseMessage(c.message).keptDraft) if (waiting.has(file)) out.set(file, c.sha);
1313
+ for (const c of this.since.commits) {
1314
+ const parsed = parseMessage(c.message);
1315
+ for (const file of parsed.discardedFiles) out.delete(file); // a discard ends the notice's reason
1316
+ for (const file of parsed.keptDraft) if (waiting.has(file)) out.set(file, c.sha);
1317
+ }
921
1318
  return [...out].map(([file, commit]) => ({ file, commit }));
922
1319
  }
923
1320
 
@@ -946,6 +1343,79 @@ export class LiveGit {
946
1343
  });
947
1344
  }
948
1345
 
1346
+ // ── `main` rolled back (force-push note) ─────────────────────────────────────
1347
+
1348
+ /**
1349
+ * After a check: does `main` still have the last Publish's commit? Only rewritten history
1350
+ * loses it (a push on top, a merge or a revert keeps it). One compare per pair; never
1351
+ * fails the check.
1352
+ */
1353
+ private async checkRollback(): Promise<void> {
1354
+ try {
1355
+ const record = await this.lastPublish();
1356
+ if (!record) {
1357
+ this.rolledBack = null;
1358
+ return;
1359
+ }
1360
+ const main = this.since?.main ?? (await this.backend.readHeads()).main.commit;
1361
+ const pair = `${record.commit}:${main}`;
1362
+ if (pair !== this.rollbackPair) {
1363
+ const has = await this.mainHasPublish(record.commit, main);
1364
+ if (has === null) {
1365
+ // GitHub doesn't know the commit (another repo's record, or long gone): can't tell, so no note.
1366
+ if (this.rollbackUnknown !== record.commit) this.log(`the last Publish's commit (${record.commit.slice(0, 7)}) isn't on GitHub: can't tell whether main was rolled back`);
1367
+ this.rollbackUnknown = record.commit;
1368
+ this.rolledBack = null;
1369
+ } else if (has) {
1370
+ if (this.rolledBack || this.rollbackPair === null) await this.redis.del(this.mainRolledBackKey);
1371
+ this.rolledBack = null;
1372
+ } else {
1373
+ const kept = this.rolledBack?.publish === record.commit ? this.rolledBack : parseRolledBack(await this.redis.get(this.mainRolledBackKey));
1374
+ if (kept && kept.publish === record.commit) this.rolledBack = kept;
1375
+ else {
1376
+ this.rolledBack = { publish: record.commit, main, at: new Date(this.now()).toISOString() };
1377
+ await this.redis.set(this.mainRolledBackKey, JSON.stringify(this.rolledBack));
1378
+ this.log(
1379
+ `MAIN WAS ROLLED BACK: main (${main.slice(0, 7)}) no longer has the last Publish's commit (${record.commit.slice(0, 7)}), so its history was rewritten (a force-push). What was rolled back is waiting in the editor again, and the next Publish puts it back; editors are told to discard what they don't want first.`,
1380
+ );
1381
+ }
1382
+ }
1383
+ this.rollbackPair = pair;
1384
+ }
1385
+ await this.rollbackSettled();
1386
+ } catch (error) {
1387
+ this.log(`checking whether main was rolled back: ${error instanceof Error ? error.message : String(error)}`);
1388
+ }
1389
+ }
1390
+
1391
+ /** Force-push note: with nothing waiting (all of it discarded), the note is done with for this rollback, on every server. */
1392
+ private async rollbackSettled(): Promise<void> {
1393
+ const note = this.rolledBack;
1394
+ if (!note || note.cleared || !this.since || this.waiting().length > 0) return;
1395
+ this.rolledBack = { ...note, cleared: true };
1396
+ this.log(`main's rollback: nothing is waiting any more, so the note is done with`);
1397
+ await this.redis.set(this.mainRolledBackKey, JSON.stringify(this.rolledBack)).catch((error: unknown) => {
1398
+ this.log(`keeping the rollback note's end: ${error instanceof Error ? error.message : String(error)}`);
1399
+ });
1400
+ }
1401
+
1402
+ /** Is the Publish's `commit` in `main`? Null when GitHub doesn't have it. One compare per pair, per server. */
1403
+ private async mainHasPublish(commit: string, main: string): Promise<boolean | null> {
1404
+ if (commit === main) return true;
1405
+ const key = `${commit}:${main}`;
1406
+ const known = this.onMainCache.get(key);
1407
+ if (known !== undefined) return known;
1408
+ try {
1409
+ const cmp = await this.backend.compare(commit, main);
1410
+ const yes = cmp.status === 'ahead' || cmp.status === 'identical';
1411
+ this.onMainCache.set(key, yes); // ancestry never changes
1412
+ return yes;
1413
+ } catch (error) {
1414
+ if (error instanceof HttpError && (error.status === 404 || error.status === 422)) return null;
1415
+ throw error;
1416
+ }
1417
+ }
1418
+
949
1419
  /** Is `commit` in `running` (the same, or an ancestor)? One compare per pair, per server. */
950
1420
  private async includes(commit: string, running: string): Promise<boolean> {
951
1421
  const key = `${commit}:${running}`;