caracms 0.8.0 → 0.8.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.
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 +18 -5
  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
@@ -80,12 +80,16 @@ export function unavailable(error: unknown): WriteRefused {
80
80
  * expire, so a file with none was never changed in the editor or Redis lost its data, and
81
81
  * the server can't tell which: the keeper holds edits on unknown rather than re-send them.
82
82
  */
83
- /** `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here. */
83
+ /**
84
+ * `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here.
85
+ * Discard changes: its `discard` is `discarded`, so a browser drops edits held from before it.
86
+ */
84
87
  export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
85
88
  const record = known !== undefined ? known : await parts.autosaves.changed(file);
86
- if (record) return { by: record.by, version: record.version, at: record.at };
89
+ const discarded = record?.discard ? { discarded: { by: record.discard.by, at: record.discard.at } } : {};
90
+ if (record) return { by: record.by, version: record.version, at: record.at, ...discarded };
87
91
  const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
88
- return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
92
+ return editor ? { by: editor.person, version: null, at: editor.at, ...discarded } : { by: null, version: null, at: null, known: false, ...discarded };
89
93
  }
90
94
 
91
95
  export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
@@ -400,7 +404,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
400
404
  guard(async () => {
401
405
  const file = posix(relative(site.root, resolveDocumentFile(site, collection, path)));
402
406
  const save = (await autosaves.get(file))?.value ?? null;
403
- const read = await readDocument(site, collection, path, overlay(save ? [save] : []));
407
+ const read = await readDocument(site, collection, path, overlay(save ? [save] : []), { untitled: true });
404
408
  return { ...read, mtimeMs: save?.updatedAt ?? 0, version: fileVersion(read.text), lastChange: await lastChangeOf(parts, file) };
405
409
  }),
406
410
 
@@ -415,8 +419,15 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
415
419
  const current = git.read(request.file);
416
420
  // It exists: in the copy, or as a new document's autosave (M5a); a "gone" autosave is a deletion.
417
421
  const missing = () => new WriteRefused(404, `${site.collections[target.collection]!.label}: no document "${target.path}".`);
418
- const saved = await autosaves.get(request.file);
422
+ const [saved, changed] = await autosaves.getWithChange(request.file);
419
423
  if (saved?.value.gone || (current === null && !saved)) throw missing();
424
+ // Discard changes: a tab that had the document when someone put it back to what's live
425
+ // can't write until it has let go of its copy (it's stale: its edits would come back).
426
+ const discard = changed?.discard;
427
+ if (discard && discard.tabs.includes(tab)) {
428
+ const title = saved?.value.text ? titleOf(request.file, saved.value.text) : titleOf(request.file, current);
429
+ throw new WriteRefused(409, `${displayName(discard.by)} discarded the unpublished changes to ${title}, so these edits weren't kept.`, { code: 'discarded', by: discard.by, at: discard.at });
430
+ }
420
431
 
421
432
  const text = writeText(site, target.collection, request);
422
433
 
@@ -494,7 +505,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
494
505
  if (await autosaves.putIf(existing, next)) break;
495
506
  }
496
507
  const version = fileVersion(text);
497
- await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString() });
508
+ await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString(), ...autosaves.carryDiscard(changed) });
498
509
  if (context?.release) await locks.release(request.file, tab, person);
499
510
  sweep.poke();
500
511
  return { ok: true, file: request.file, changed: text !== (existing?.value.text ?? current), mtimeMs: t, version };
@@ -1,7 +1,17 @@
1
1
  /**
2
2
  * Live mode's editor server (M4 Phase 2): the live drafts store, the sweep, locks and
3
3
  * presence, and the handlers behind `/_caracms/status`, `lock`, `publish`, `history`,
4
- * `try-again` and `notice` (the routes in ../editor/routes/ are one line each).
4
+ * `try-again`, `notice` and `discard` (the routes in ../editor/routes/ are one line each).
5
+ *
6
+ * **Discard changes** (`discard`): one waiting document back to what's live. Its autosave,
7
+ * committed record and move records go and its files on `drafts` are committed as `main` has
8
+ * them (git.ts → `restoreDrafts`), all under the sweep's locks (sweep.ts → `exclusively`), so
9
+ * no tick commits the edit meanwhile. A moved document first moves back through the move
10
+ * engine (its links and redirects too), unredirected. Every other tab that had it open or
11
+ * was editing it is listed in its discard record (autosaves.ts): their writes are refused
12
+ * until they let go (`{ file, seen: true }`), and each other person who edited it or had it
13
+ * open gets a "discarded" notice. Refused while someone else is editing it now (a fresh
14
+ * lock), while a Publish runs, and for nothing waiting.
5
15
  *
6
16
  * One per process, beside the runtime (`liveEditor`), or made by tests
7
17
  * (`createLiveEditor`) on a runtime from `createLiveRuntime` with the in-memory
@@ -32,8 +42,11 @@ import type { DraftsStore } from '../editor/server/drafts-store.ts';
32
42
  import { handleCreate, handleDelete } from '../editor/server/create.ts';
33
43
  import { checkOrigin, confinePath, fileVersion, handleWrite, tabOf, WriteRefused } from '../editor/server/write.ts';
34
44
  import { missingAltIn } from '../editor/server/media/alt.ts';
45
+ import { settingsWithRedirectsBack } from '../editor/server/move.ts';
46
+ import { parseAnyDocument } from '../document.ts';
35
47
  import type {
36
48
  ApiError,
49
+ DiscardResponse,
37
50
  HistoryResponse,
38
51
  LockRefusal,
39
52
  LockResponse,
@@ -47,7 +60,7 @@ import type {
47
60
  } from '../editor/types.ts';
48
61
  import { Autosaves, splitMoves } from './autosaves.ts';
49
62
  import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
50
- import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
63
+ import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf, type DraftsLost } from './git.ts';
51
64
  import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
52
65
  import { parseRedirects, type Redirect } from '../redirects.ts';
53
66
  import { Locks } from './locks.ts';
@@ -56,7 +69,9 @@ import type { LiveSettings } from './mode.ts';
56
69
  import { Notices, type StoodNotice } from './notices.ts';
57
70
  import { RedisUnavailableError } from './redis.ts';
58
71
  import { liveRuntime, type LiveRuntime } from './runtime.ts';
59
- import { Sweep, type Schedule } from './sweep.ts';
72
+ import { Sweep, SweepBusyError, type Schedule } from './sweep.ts';
73
+ import { readCappedText } from '../body.ts';
74
+ import { textProblems } from '../document-problems.ts';
60
75
 
61
76
  type Handler = (request: Request, person: Person | null) => Promise<Response>;
62
77
 
@@ -67,8 +82,33 @@ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
67
82
  permission: 'GitHub refused a change',
68
83
  expiring: "This site's GitHub token expires soon",
69
84
  cloud: 'No GitHub access through CaraCMS Cloud',
85
+ // caracms-cloud adversarial review, finding 7: information, not a problem.
86
+ renamed: "This site's repo has a new name",
70
87
  };
71
88
 
89
+ /** Finding 4: the save bar's sentence while draft commits keep failing. */
90
+ export const COMMIT_TROUBLE_MESSAGE = "Saved here, but not reaching GitHub. Ask your developer to check the site's GitHub access.";
91
+
92
+ /** Finding 5: the note's sentence once `drafts` had to be made again from `main`. */
93
+ export function draftsLostMessage(lost: DraftsLost): string {
94
+ const lastCommit = lost.commit ? `its last commit (${lost.commit.slice(0, 7)}) couldn't be found` : 'no record of its last commit was kept';
95
+ return `The drafts branch went missing on GitHub and ${lastCommit}, so it was made again from the live site. Changes saved since the last Publish are gone from the editor. Ask your developer to protect the drafts branch from deletion.`;
96
+ }
97
+
98
+ /** Force-push note (caracms-cloud adversarial review): `main` was rolled back past the last Publish. */
99
+ export const MAIN_ROLLED_BACK_TITLE = 'The live site was rolled back on GitHub';
100
+ export const MAIN_ROLLED_BACK_MESSAGE =
101
+ "The changes waiting here include what was rolled back, and publishing will put it back. Discard what you don't want first: each waiting document has Discard… in the Publish list.";
102
+
103
+ /**
104
+ * A Redis outage before a Publish's refs move (finding 6): nothing went out, and nothing is
105
+ * held in the browser either (that's the write route's sentence, `unavailable`).
106
+ */
107
+ export function publishUnavailable(error: unknown): WriteRefused {
108
+ const what = error instanceof Error ? error.message : String(error);
109
+ return new WriteRefused(503, `The server can't reach its store right now, so it couldn't publish. Your changes are still waiting: try again in a minute (${what}).`, { code: 'unavailable' });
110
+ }
111
+
72
112
  export interface LiveEditor {
73
113
  readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
74
114
  readonly autosaves: Autosaves;
@@ -85,6 +125,8 @@ export interface LiveEditor {
85
125
  history: Handler;
86
126
  tryAgain: Handler;
87
127
  notice: Handler;
128
+ /** Discard changes: POST /_caracms/discard `{ file }` (types.ts → DiscardRequest). */
129
+ discard: Handler;
88
130
  }
89
131
 
90
132
  export interface LiveEditorOptions {
@@ -99,8 +141,8 @@ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Cont
99
141
  const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: HEADERS });
100
142
  const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
101
143
 
102
- /** A notice id: `kept:<file>:<commit>` or `stood:<commit>:<file>:<email>`, no spaces or controls, capped. */
103
- const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
144
+ /** A notice id: `kept:<file>:<commit>`, `stood:<commit>:<file>:<email>` or `discarded:<at>:<file>:<email>`, no spaces or controls, capped. */
145
+ const NOTICE_ID = /^(kept|stood|discarded):[^\s\p{C}]+$/u;
104
146
  const NOTICE_ID_MAX = 512;
105
147
 
106
148
  /** Map a site file to its document (collection and path), or nulls. */
@@ -152,9 +194,9 @@ export async function createLiveEditor(
152
194
  // hook above in place, so a merge from main commits every pending autosave first.
153
195
  await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
154
196
 
155
- /** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
197
+ /** Common gate: a person, the same origin for anything but GET, Redis outages as 503 (`outage`: that route's sentence for one). */
156
198
  const handler =
157
- (methods: string[], fn: (request: Request, person: Person) => Promise<Response>): Handler =>
199
+ (methods: string[], fn: (request: Request, person: Person) => Promise<Response>, outage: (error: unknown) => WriteRefused = unavailable): Handler =>
158
200
  async (request, person) => {
159
201
  try {
160
202
  if (!methods.includes(request.method)) {
@@ -167,7 +209,7 @@ export async function createLiveEditor(
167
209
  if (request.method !== 'GET') checkOrigin(request);
168
210
  return await fn(request, person);
169
211
  } catch (error) {
170
- const refused = error instanceof RedisUnavailableError ? unavailable(error) : error;
212
+ const refused = error instanceof RedisUnavailableError ? outage(error) : error;
171
213
  if (refused instanceof WriteRefused) return json(refused.status, { error: refused.message, ...refused.body });
172
214
  log(`${new URL(request.url).pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
173
215
  return json(502, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` } satisfies ApiError);
@@ -175,8 +217,9 @@ export async function createLiveEditor(
175
217
  };
176
218
 
177
219
  const readBody = async (request: Request): Promise<Record<string, unknown>> => {
178
- const raw = await request.text();
179
- if (raw.length > 16_384) throw new WriteRefused(413, 'That body is too large.');
220
+ // caracms-cloud adversarial review, finding 10: 16 KB counted as it's read, never the whole body first.
221
+ const raw = await readCappedText(request, 16_384);
222
+ if (raw === null) throw new WriteRefused(413, 'That body is too large.');
180
223
  try {
181
224
  const body = JSON.parse(raw || '{}') as unknown;
182
225
  if (body && typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>;
@@ -320,7 +363,11 @@ export async function createLiveEditor(
320
363
  // autosave's text, else the copy's). A deleted document has none.
321
364
  const textNow = new Map(saves.map((a) => [a.file, a.text]));
322
365
  for (const w of waiting.values()) {
323
- w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file));
366
+ const text = textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file);
367
+ w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, text);
368
+ // caracms-cloud adversarial review, finding 15: what Publish will leave waiting, by the same rule (git.ts → heldBack).
369
+ const [problem] = w.change !== 'deleted' && w.collection !== null && text !== null ? textProblems(text) : [];
370
+ if (problem) w.heldBack = problem.reason;
324
371
  }
325
372
  // Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
326
373
  // Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
@@ -359,9 +406,16 @@ export async function createLiveEditor(
359
406
  if (save ? save.gone : git.read(file) === null) {
360
407
  deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
361
408
  }
362
- open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted };
409
+ // Discard changes: this tab had it when someone put it back to what's live (its copy is stale).
410
+ const discard = record?.discard;
411
+ const discarded = discard && tab && discard.tabs.includes(tab) ? { by: discard.by, at: discard.at } : null;
412
+ open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted, discarded };
363
413
  }
364
414
  const access = git.backend.accessProblem();
415
+ // Finding 4: only while something is waiting to reach GitHub.
416
+ const trouble = saves.length ? git.commitTrouble() : null;
417
+ const lost = git.draftsLost();
418
+ const rolledBack = git.mainRolledBack();
365
419
  const body: StatusResponse = {
366
420
  mode: 'live',
367
421
  you: person,
@@ -373,11 +427,14 @@ export async function createLiveEditor(
373
427
  locks: lockViews,
374
428
  presence,
375
429
  notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
376
- publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
430
+ publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: git.publishRecordOr(LiveGit.publishRecordFrom(snap.string(git.publishRecordKey))) }),
377
431
  // Runs-anywhere Decision 5: while it holds, not dismissable.
378
432
  githubAccess: access
379
433
  ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access), link: access.kind === 'cloud' ? access.installUrl : null }
380
434
  : null,
435
+ commitTrouble: trouble ? { since: new Date(trouble.since).toISOString(), failures: trouble.failures, message: COMMIT_TROUBLE_MESSAGE } : null,
436
+ draftsLost: lost ? { title: 'Unpublished changes were lost', message: draftsLostMessage(lost), commit: lost.commit, at: lost.at } : null,
437
+ mainRolledBack: rolledBack ? { title: MAIN_ROLLED_BACK_TITLE, message: MAIN_ROLLED_BACK_MESSAGE, publish: rolledBack.publish, main: rolledBack.main, at: rolledBack.at } : null,
381
438
  // M5a Decision 10: from the committed copy, never an autosave.
382
439
  brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
383
440
  // Arrange pages (Decision 18): from the autosaves already in the snapshot, else the copy; no Redis command of its own.
@@ -428,7 +485,10 @@ export async function createLiveEditor(
428
485
  const publish = handler(['POST'], async (_request, person) => {
429
486
  const before = await locks.presence();
430
487
  const work = git.publish(person).then(async (result) => {
431
- await stoodNotices(result, before, person);
488
+ // Finding 6: the notices are kept in Redis, so with Redis gone they can't be sent; the Publish happened all the same.
489
+ await stoodNotices(result, before, person).catch((error: unknown) => {
490
+ log(`publish: the "published as it stood" notices weren't sent (${error instanceof Error ? error.message : String(error)})`);
491
+ });
432
492
  return result;
433
493
  });
434
494
  // Runs-anywhere (Watch-outs): Cloudflare gives up on an answer after 100 s, and a
@@ -444,8 +504,17 @@ export async function createLiveEditor(
444
504
  work.catch((error: unknown) => log(`publish (answered early): ${error instanceof Error ? error.message : String(error)}`));
445
505
  return json(200, { result: 'joined', publish: await publishView() } satisfies PublishResponse);
446
506
  }
507
+ if (result.kind === 'published') {
508
+ // Finding 6: the record as made, not as Redis has it (it may not have taken it); the lock read is the only Redis call, and its failure isn't the Publish's.
509
+ const running = await redis.get(git.publishLockKey).then(
510
+ (v) => v !== null,
511
+ () => false,
512
+ );
513
+ return json(200, { result: result.kind, publish: await publishView({ running, record: result.record }), held: result.held } satisfies PublishResponse);
514
+ }
515
+ if (result.kind === 'held-back') return json(200, { result: result.kind, publish: await publishView(), held: result.held } satisfies PublishResponse);
447
516
  return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
448
- });
517
+ }, publishUnavailable);
449
518
 
450
519
  /** "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1). */
451
520
  const stoodNotices = async (result: Awaited<ReturnType<typeof git.publish>>, before: Awaited<ReturnType<typeof locks.presence>>, person: Person) => {
@@ -493,7 +562,154 @@ export async function createLiveEditor(
493
562
  return json(200, { ok: true });
494
563
  });
495
564
 
496
- return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice };
565
+ /**
566
+ * Discard changes (see the header). `{ file }`: the document's file now (a moved one's new
567
+ * file). `{ file, seen: true }`: this tab has let go of its copy of a discarded document.
568
+ */
569
+ const discard = handler(['POST'], async (request, person) => {
570
+ const tab = tabOf(request);
571
+ if (!tab) throw new WriteRefused(400, 'Discard requests name their tab (X-Caracms-Tab).', { code: 'no-tab' });
572
+ const body = await readBody(request);
573
+ const extra = Object.keys(body).filter((k) => k !== 'file' && k !== 'seen');
574
+ if (extra.length || typeof body.file !== 'string' || (body.seen !== undefined && body.seen !== true)) {
575
+ throw new WriteRefused(400, 'Malformed body: send { file } (or { file, seen: true }).', { code: 'bad-request' });
576
+ }
577
+ const file = body.file;
578
+ confinePath(site, file);
579
+ if (body.seen === true) {
580
+ await autosaves.ackDiscard(file, tab);
581
+ return json(200, { ok: true });
582
+ }
583
+ if ((await redis.get(git.publishLockKey)) !== null) throw new WriteRefused(409, "A Publish is running. Try again once it's done.", { code: 'publishing' });
584
+
585
+ // What's waiting for it: an autosave, a commit on drafts that main hasn't got, or a move to it.
586
+ const save = (await autosaves.get(file))?.value ?? null;
587
+ const textNow = save ? save.text : git.read(file);
588
+ const fromGit = git.waiting().find((w) => w.file === file) ?? null;
589
+ const moves = await autosaves.moves();
590
+ const from = [...moves].find(([, to]) => to === file)?.[0] ?? null;
591
+ const change = git.changeOf(file, textNow);
592
+ const title = save?.gone ? save.gone.title : textNow !== null ? titleOf(file, textNow) : git.titleFor(file);
593
+ if (!change || (!save && !fromGit && !from)) {
594
+ if (!from && (await autosaves.addresses()).has(file)) {
595
+ throw new WriteRefused(409, `${title} moved with the page that holds it. Discard that page's changes to put it back.`, { code: 'held-move' });
596
+ }
597
+ throw new WriteRefused(404, `${title} has no unpublished changes.`, { code: 'not-waiting' });
598
+ }
599
+
600
+ // Someone else editing it right now (a fresh lock, not one held only until their autosave is committed).
601
+ const own = [file, ...(from ? [from] : [])];
602
+ for (const f of own) {
603
+ const held = await locks.read(f);
604
+ if (held && !sameEmail(held.lock.person, person) && held.lock.releasing === null && now() - held.lock.beat < timings.lockLapseMs) {
605
+ throw new WriteRefused(409, `${displayName(held.lock.person)} is editing ${title} right now, so its changes can't be discarded yet.`, { code: 'locked' });
606
+ }
607
+ }
608
+
609
+ const t0 = now();
610
+ let movedBack: DiscardResponse['movedBack'] = null;
611
+ if (from && git.mainHas(from)) {
612
+ // Back to its old address first, through the move engine (its links, the redirects, what
613
+ // moved with it), with no redirect. A swap (it took a live document's place) isn't undone here.
614
+ const there = docOf(site, file);
615
+ const back = docOf(site, from);
616
+ if (git.mainHas(file)) throw new WriteRefused(409, `${title} took the place of a page that's live, so it can't be discarded on its own.`, { code: 'swap' });
617
+ if (!there.collection || !there.path || !back.path || back.collection !== there.collection) {
618
+ throw new WriteRefused(409, `${title} can't go back to its old address from here.`, { code: 'swap' });
619
+ }
620
+ // Any lock someone left on it (only held until their autosave is committed) goes, so the move can take it.
621
+ for (const f of own) {
622
+ const held = await locks.read(f);
623
+ if (held && held.lock.tab !== tab) await locks.drop(held, f);
624
+ }
625
+ const context = { person, tab, release: false, seq: null };
626
+ const moved = await store.move({ collection: there.collection, path: there.path, to: back.path, redirect: false }, context);
627
+ // A redirect the move took out of Site Settings (from the address it took) comes back, as it is live.
628
+ const vacated = moved.moved.filter((m) => m.fromUrl !== m.toUrl).map((m) => m.fromUrl);
629
+ const current = (await autosaves.get(SITE_SETTINGS_FILE))?.value.text ?? git.read(SITE_SETTINGS_FILE);
630
+ const restored = settingsWithRedirectsBack(site, current, (await git.readMain([SITE_SETTINGS_FILE])).get(SITE_SETTINGS_FILE) ?? null, vacated);
631
+ if (restored !== null && current !== null) {
632
+ const settings = site.collections.settings ?? Object.values(site.collections).find((c) => c.kind === 'settings');
633
+ if (settings) {
634
+ await store.write({ file: SITE_SETTINGS_FILE, base: current, document: parseAnyDocument(JSON.parse(restored), site, settings) }, context);
635
+ await locks.release(SITE_SETTINGS_FILE, tab, person);
636
+ }
637
+ }
638
+ await locks.release(from, tab, person);
639
+ movedBack = { collection: back.collection, path: back.path, file: from };
640
+ }
641
+
642
+ const result = await sweep
643
+ .exclusively(async () => {
644
+ // The document's files, and any file the move back left exactly as main has it (a link put back).
645
+ const files = new Set(own);
646
+ if (movedBack) {
647
+ const mine = (await autosaves.all()).map((r) => r.value).filter((a) => a.tab === tab && sameEmail(a.editor, person) && a.updatedAt >= t0 && !files.has(a.file));
648
+ const main = await git.readMain(mine.map((a) => a.file));
649
+ for (const a of mine) if (a.text === (main.get(a.file) ?? null)) files.add(a.file);
650
+ }
651
+ const list = [...files];
652
+ // Every tab that has it or had edits in it, but this one: their copies are stale from now.
653
+ const tabs = new Set<string>();
654
+ const people = new Map<string, Person>();
655
+ const note = (p: Person) => {
656
+ if (!sameEmail(p, person)) people.set(p.email.toLowerCase(), p);
657
+ };
658
+ for (const e of fromGit?.editors ?? []) note(e.person);
659
+ for (const f of list) {
660
+ const a = (await autosaves.get(f))?.value;
661
+ if (a) {
662
+ tabs.add(a.tab);
663
+ note(a.editor);
664
+ }
665
+ const c = await autosaves.committed(f);
666
+ if (c) tabs.add(c.tab);
667
+ const l = await locks.read(f);
668
+ if (l) tabs.add(l.lock.tab);
669
+ for (const p of await locks.tabsOn(f)) {
670
+ tabs.add(p.tab);
671
+ note(p.person);
672
+ }
673
+ }
674
+ tabs.delete(tab);
675
+ const at = new Date(now()).toISOString();
676
+ const titles = [title];
677
+ const restored = await git.restoreDrafts(list, titles, person);
678
+ // Who changed it last, as it reads now, with the discard and the tabs that had it.
679
+ const changeOf = (f: string) => {
680
+ const text = restored.texts.get(f) ?? null;
681
+ const discard = { by: person, at, tabs: [...tabs] };
682
+ return text === null ? { by: person, version: '', at, gone: true, discard } : { by: person, version: fileVersion(text), at, discard };
683
+ };
684
+ for (const f of list) await autosaves.setChanged(f, changeOf(f));
685
+ for (const f of list) {
686
+ for (const gone of await autosaves.removeAll(f)) if (gone.tab !== tab) tabs.add(gone.tab);
687
+ await autosaves.forgetCommitted(f);
688
+ }
689
+ await autosaves.forgetMovesOf(list);
690
+ for (const f of list) {
691
+ await autosaves.setChanged(f, changeOf(f)); // any tab whose autosave was removed just now too
692
+ // This tab's lock lets go; one left by a tab that's gone is dropped; a live one (your other tab) stays, and that tab is told.
693
+ const l = await locks.read(f);
694
+ const fresh = !!l && l.lock.releasing === null && now() - l.lock.beat < timings.lockLapseMs;
695
+ if (l && l.lock.tab === tab) await locks.release(f, tab, person);
696
+ else if (l && !fresh) await locks.drop(l, f);
697
+ }
698
+ // "Aoife discarded the unpublished changes to About", for everyone else who had it.
699
+ await noticeStore
700
+ .addStood([...people.values()].map((p) => ({ id: `discarded:${at}:${file}:${p.email.toLowerCase()}`, for: p.email.toLowerCase(), file: movedBack?.file ?? file, title, by: person, at, kind: 'discarded' as const })))
701
+ .catch((error: unknown) => log(`discard: the notices weren't left (${error instanceof Error ? error.message : String(error)})`));
702
+ log(`${person.email} discarded the unpublished changes to ${list.join(', ')}`);
703
+ return { files: list };
704
+ })
705
+ .catch((error: unknown) => {
706
+ if (error instanceof SweepBusyError) throw new WriteRefused(409, 'The server is busy saving changes. Try again in a moment.', { code: 'busy' });
707
+ throw error;
708
+ });
709
+ return json(200, { ok: true, file, change, files: result.files, movedBack } satisfies DiscardResponse);
710
+ });
711
+
712
+ return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice, discard };
497
713
  }
498
714
 
499
715
  const SHARED = Symbol.for('caracms.liveEditor');