caracms 0.2.0 → 0.3.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 (52) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +64 -20
  6. package/package.json +1 -1
  7. package/skills/caracms-adopt/SKILL.md +5 -3
  8. package/skills/caracms-adopt/look-same.mjs +8 -4
  9. package/skills/caracms-blocks/SKILL.md +20 -2
  10. package/skills/caracms-tina/SKILL.md +14 -10
  11. package/src/components/Head.astro +3 -2
  12. package/src/content.ts +2 -0
  13. package/src/editor/client/documents/actions.ts +6 -4
  14. package/src/editor/client/documents/dialogs.tsx +2 -1
  15. package/src/editor/client/editor.ts +24 -6
  16. package/src/editor/client/live/publish.tsx +20 -10
  17. package/src/editor/client/pane/parts.tsx +34 -2
  18. package/src/editor/client/state/keeper.ts +51 -6
  19. package/src/editor/client/state/live.ts +45 -6
  20. package/src/editor/middleware.ts +3 -2
  21. package/src/editor/routes/try-again.ts +2 -1
  22. package/src/editor/server/admin-props.ts +2 -1
  23. package/src/editor/server/mode.ts +13 -3
  24. package/src/editor/styles/levels/live.css +1 -1
  25. package/src/editor/types.ts +25 -6
  26. package/src/files-route.ts +9 -4
  27. package/src/forms/endpoint.ts +11 -16
  28. package/src/head.ts +10 -1
  29. package/src/index.ts +53 -7
  30. package/src/live/README.md +4 -2
  31. package/src/live/auth/auth.ts +4 -1
  32. package/src/live/auth/guard.ts +3 -2
  33. package/src/live/auth/index.ts +6 -7
  34. package/src/live/auth/login.ts +17 -8
  35. package/src/live/auth/middleware.ts +7 -3
  36. package/src/live/auth/request.ts +92 -11
  37. package/src/live/auth/views.ts +9 -4
  38. package/src/live/autosaves.ts +59 -16
  39. package/src/live/backend.ts +13 -1
  40. package/src/live/drafts-store.ts +4 -3
  41. package/src/live/editor.ts +92 -67
  42. package/src/live/git.ts +112 -34
  43. package/src/live/github.ts +128 -13
  44. package/src/live/locks.ts +92 -26
  45. package/src/live/mode.ts +131 -18
  46. package/src/live/notices.ts +100 -0
  47. package/src/live/redis.ts +161 -39
  48. package/src/live/runtime.ts +11 -3
  49. package/src/live/status-cache.ts +219 -0
  50. package/src/live/status.ts +14 -2
  51. package/src/live/timings.ts +24 -0
  52. package/src/media.ts +26 -1
@@ -15,6 +15,11 @@
15
15
  * **A poll asks GitHub for nothing** beyond `git.check()` (once a minute) and the
16
16
  * deployment reads while a publish is in flight (git.ts, every 10 s at most).
17
17
  *
18
+ * **A poll asks Redis for little** (Redis diet): its reads come from the status cache
19
+ * (status-cache.ts, `git.statusCache`), refreshed from Redis once 30 s old; it writes
20
+ * only its presence, and a heartbeat when the lock needs one. The other handlers read
21
+ * Redis directly (Decision 3).
22
+ *
18
23
  * State lives in Redis and git (two servers run during a deploy); what's held here
19
24
  * is the sweep's "am I ticking" and git.ts's caches.
20
25
  */
@@ -39,17 +44,27 @@ import type {
39
44
  } from '../editor/types.ts';
40
45
  import { Autosaves } from './autosaves.ts';
41
46
  import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
42
- import { movesIn, titleOf } from './git.ts';
47
+ import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
48
+ import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
43
49
  import { parseRedirects, type Redirect } from '../redirects.ts';
44
50
  import { Locks } from './locks.ts';
45
51
  import { displayName, type Person } from './messages.ts';
46
52
  import type { LiveSettings } from './mode.ts';
53
+ import { Notices, type StoodNotice } from './notices.ts';
47
54
  import { RedisUnavailableError } from './redis.ts';
48
55
  import { liveRuntime, type LiveRuntime } from './runtime.ts';
49
56
  import { Sweep, type Schedule } from './sweep.ts';
50
57
 
51
58
  type Handler = (request: Request, person: Person | null) => Promise<Response>;
52
59
 
60
+
61
+ /** The heading of the editor's GitHub access note (runs-anywhere Decision 5); the sentence says what to fix. */
62
+ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
63
+ refused: 'GitHub refused this site',
64
+ permission: 'GitHub refused a change',
65
+ expiring: "This site's GitHub token expires soon",
66
+ };
67
+
53
68
  export interface LiveEditor {
54
69
  readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
55
70
  readonly autosaves: Autosaves;
@@ -84,15 +99,6 @@ const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.to
84
99
  const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
85
100
  const NOTICE_ID_MAX = 512;
86
101
 
87
- interface StoodNotice {
88
- id: string;
89
- for: string;
90
- file: string;
91
- title: string;
92
- by: Person;
93
- at: string;
94
- }
95
-
96
102
  /** Map a site file to its document (collection and path), or nulls. */
97
103
  /** Site Settings' redirects, from its text (none when it's missing or unreadable). */
98
104
  function redirectsIn(text: string | null): Redirect[] {
@@ -120,8 +126,11 @@ export async function createLiveEditor(
120
126
  const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
121
127
  const prefix = options.prefix ?? 'caracms:';
122
128
  const log = options.log ?? ((line: string) => console.log(`[caracms] ${line}`));
123
- const autosaves = new Autosaves(redis, timings, now, prefix);
124
- const locks = new Locks(redis, timings, now, async (file) => (await autosaves.get(file)) !== null, prefix);
129
+ // Redis diet Decision 2: every module that writes what the status poll reads tells its cache.
130
+ const cache = git.statusCache;
131
+ const autosaves = new Autosaves(redis, timings, now, prefix, cache);
132
+ const locks = new Locks(redis, timings, now, async (file) => (await autosaves.get(file)) !== null, prefix, cache);
133
+ const noticeStore = new Notices(redis, timings, prefix, cache);
125
134
  const sweep = new Sweep({ redis, git, autosaves, locks, timings, now, schedule: options.schedule, sleep: options.sleep, log, prefix });
126
135
  const store = liveDraftsStore({ site, git, autosaves, locks, sweep, now });
127
136
  // Publish's step 1 and Decision 8's "commit every pending autosave first".
@@ -139,12 +148,6 @@ export async function createLiveEditor(
139
148
  // hook above in place, so a merge from main commits every pending autosave first.
140
149
  await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
141
150
 
142
- const keys = {
143
- publishLock: `${prefix}publish:lock`,
144
- dismissed: `${prefix}dismissed`,
145
- notices: `${prefix}notices`,
146
- };
147
-
148
151
  /** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
149
152
  const handler =
150
153
  (methods: string[], fn: (request: Request, person: Person) => Promise<Response>): Handler =>
@@ -204,36 +207,22 @@ export async function createLiveEditor(
204
207
  return list;
205
208
  };
206
209
 
207
- const publishView = async (): Promise<PublishView> => {
208
- const running = (await redis.get(keys.publishLock)) !== null;
210
+ /** `read`: the Publish lock and record as the status poll's cache has them; otherwise read from Redis. */
211
+ const publishView = async (read?: { running: boolean; record: PublishView['record'] }): Promise<PublishView> => {
212
+ const running = read ? read.running : (await redis.get(git.publishLockKey)) !== null;
209
213
  let st;
210
214
  try {
211
- st = await git.publishStatus();
215
+ st = await git.publishStatus(read ? { record: read.record } : {});
212
216
  } catch (error) {
213
217
  log(`publish status: ${error instanceof Error ? error.message : String(error)}`);
214
- const record = await git.lastPublish();
218
+ const record = read ? read.record : await git.lastPublish();
215
219
  st = { state: record ? ('publishing' as const) : ('none' as const), record, assumed: false, canTryAgain: false };
216
220
  }
217
- return { state: st.state, running, record: st.record, assumed: st.assumed, canTryAgain: st.canTryAgain, hasToken: settings.railway !== null };
221
+ return { state: st.state, running, record: st.record, assumed: st.assumed, canTryAgain: st.canTryAgain, hasRedeployer: Boolean(settings.deployHook) || settings.railway !== null };
218
222
  };
219
223
 
220
- const notices = async (person: Person): Promise<NoticeView[]> => {
221
- const dismissed = new Set(await redis.smembers(keys.dismissed));
222
- const out: NoticeView[] = [];
223
- for (const n of git.keptDraftNotices()) {
224
- const id = `kept:${n.file}:${n.commit}`;
225
- if (!dismissed.has(id)) out.push({ id, kind: 'draft-kept', file: n.file, title: git.titleFor(n.file) });
226
- }
227
- for (const raw of Object.values(await redis.hgetall(keys.notices))) {
228
- try {
229
- const n = JSON.parse(raw) as StoodNotice;
230
- if (n.for === person.email.toLowerCase()) out.push({ id: n.id, kind: 'published-as-it-stood', file: n.file, title: n.title, by: n.by, at: n.at });
231
- } catch {
232
- /* skip */
233
- }
234
- }
235
- return out;
236
- };
224
+ const titleFor = (file: string) => git.titleFor(file);
225
+ const notices = (person: Person): Promise<NoticeView[]> => noticeStore.of(person, git.keptDraftNotices(), titleFor);
237
226
 
238
227
  // The write route: M3's handler over the live store, which answers 401 with no person.
239
228
  const write: Handler = (request, person) => handleWrite(request, store, { person });
@@ -256,19 +245,36 @@ export async function createLiveEditor(
256
245
  // so it lapses by itself (Take over, 2026-10-15). A tab that doesn't say is treated as active.
257
246
  const input = Number(url.searchParams.get('input'));
258
247
  const active = !url.searchParams.has('input') || !(input >= timings.lockIdleMs);
248
+ // Redis diet Contracts: `interval` is how long until the tab's next poll, which its
249
+ // presence holds by (Decision 9). None, or not a number: a 5-second tab (an old page).
250
+ const every = Number(url.searchParams.get('interval'));
251
+ const interval = Number.isFinite(every) && every > 0 ? Math.min(every, Math.max(timings.pollMs, timings.slowPollMs)) : timings.pollMs;
252
+
253
+ // Every read from the status cache (Decision 1): memory, or Redis once 30 s old.
254
+ const snap = await cache.read({
255
+ scans: [autosaves.keyPrefix, locks.keyPrefix],
256
+ hashes: [locks.presenceKey(), autosaves.movesKey(), noticeStore.noticesKey],
257
+ sets: [noticeStore.dismissedKey],
258
+ strings: [git.publishLockKey, git.publishRecordKey, ...(file ? [autosaves.changedKey(file)] : [])],
259
+ });
259
260
  if (tab) {
260
- await locks.present(tab, person, file);
261
- if (file && active) await locks.beat(file, tab, person);
261
+ await locks.present(tab, person, file, interval);
262
+ // Decision 6: a beat only when the lock (as cached) is this tab's and needs one.
263
+ if (file && active) {
264
+ const cached = locks.allFrom(snap.scan(locks.keyPrefix)).get(file) ?? null;
265
+ if (locks.needsBeat(cached, tab, person)) await locks.beat(file, tab, person, cached);
266
+ }
262
267
  }
263
268
  // Decision 8's check, throttled to once a minute and shared inside git.ts. Started, not
264
269
  // awaited: the poll answers with what's known, and never waits on GitHub.
265
270
  void git.check().catch((error: unknown) => log(`check: ${error instanceof Error ? error.message : String(error)}`));
266
271
 
267
- const saves = (await autosaves.all()).map((r) => r.value);
272
+ // From the snapshot after this poll's own writes landed in it (its presence, its beat).
273
+ const saves = Autosaves.allFrom(snap.scan(autosaves.keyPrefix).values()).map((r) => r.value);
268
274
  if (saves.length) sweep.poke(); // they may have come in on the other server during a deploy
269
275
  const pending = new Set(saves.map((a) => a.file));
270
- const presence = await locks.presence();
271
- const lockViews = [...(await locks.all())]
276
+ const presence = await locks.presenceFrom(snap.hash(locks.presenceKey()));
277
+ const lockViews = [...locks.allFrom(snap.scan(locks.keyPrefix))]
272
278
  .filter(([f, r]) => locks.holds(r.lock, pending.has(f)))
273
279
  .map(([f, r]) => locks.view(f, r.lock, tab, person));
274
280
 
@@ -315,7 +321,7 @@ export async function createLiveEditor(
315
321
  // Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
316
322
  // Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
317
323
  const readNow = (f: string) => (textNow.has(f) ? (textNow.get(f) ?? null) : git.read(f));
318
- const moves = movesIn(site, [...waiting.keys()], readNow, await autosaves.moves());
324
+ const moves = movesIn(site, [...waiting.keys()], readNow, new Map(snap.hash(autosaves.movesKey())));
319
325
  const redirects = moves.from.size ? redirectsIn(readNow(SITE_SETTINGS_FILE)) : [];
320
326
  for (const [file, from] of moves.from) {
321
327
  const w = waiting.get(file)!;
@@ -330,12 +336,14 @@ export async function createLiveEditor(
330
336
  const lock = lockViews.find((l) => l.file === file) ?? null;
331
337
  // M5a Decision 12: a viewer learns the document was deleted, and by whom, on this poll.
332
338
  let deleted: NonNullable<StatusResponse['open']>['deleted'] = null;
333
- if (!(await exists(file))) {
334
- const record = await autosaves.changed(file);
339
+ const save = saves.find((a) => a.file === file);
340
+ const record = Autosaves.changedFrom(snap.string(autosaves.changedKey(file)));
341
+ if (save ? save.gone : git.read(file) === null) {
335
342
  deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
336
343
  }
337
- open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file), deleted };
344
+ open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted };
338
345
  }
346
+ const access = git.backend.accessProblem();
339
347
  const body: StatusResponse = {
340
348
  mode: 'live',
341
349
  you: person,
@@ -346,8 +354,10 @@ export async function createLiveEditor(
346
354
  waiting: list,
347
355
  locks: lockViews,
348
356
  presence,
349
- notices: await notices(person),
350
- publish: await publishView(),
357
+ notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
358
+ publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
359
+ // Runs-anywhere Decision 5: while it holds, not dismissable.
360
+ githubAccess: access ? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access) } : null,
351
361
  // M5a Decision 10: from the committed copy, never an autosave.
352
362
  brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
353
363
  };
@@ -395,28 +405,44 @@ export async function createLiveEditor(
395
405
 
396
406
  const publish = handler(['POST'], async (_request, person) => {
397
407
  const before = await locks.presence();
398
- const result = await git.publish(person);
408
+ const work = git.publish(person).then(async (result) => {
409
+ await stoodNotices(result, before, person);
410
+ return result;
411
+ });
412
+ // Runs-anywhere (Watch-outs): Cloudflare gives up on an answer after 100 s, and a
413
+ // Publish whose GitHub calls are slow can take longer. Past `publishAnswerMs` the
414
+ // request answers "joined" with the status (the Publish lock shows it running) and
415
+ // the Publish carries on; the panel follows it on the poll.
416
+ let timer: ReturnType<typeof setTimeout> | undefined;
417
+ const late = new Promise<null>((resolve) => {
418
+ timer = setTimeout(() => resolve(null), timings.publishAnswerMs);
419
+ });
420
+ const result = await Promise.race([work, late]).finally(() => clearTimeout(timer));
421
+ if (result === null) {
422
+ work.catch((error: unknown) => log(`publish (answered early): ${error instanceof Error ? error.message : String(error)}`));
423
+ return json(200, { result: 'joined', publish: await publishView() } satisfies PublishResponse);
424
+ }
425
+ return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
426
+ });
427
+
428
+ /** "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1). */
429
+ const stoodNotices = async (result: Awaited<ReturnType<typeof git.publish>>, before: Awaited<ReturnType<typeof locks.presence>>, person: Person) => {
399
430
  if (result.kind === 'published') {
400
431
  // "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1).
401
432
  const { record } = result;
402
- const fields: Record<string, string> = {};
433
+ const stood: StoodNotice[] = [];
403
434
  for (const p of before) {
404
435
  const i = record.files.indexOf(p.file);
405
436
  if (i === -1) continue;
406
437
  for (const someone of p.people) {
407
438
  if (sameEmail(someone, person)) continue;
408
439
  const id = `stood:${record.commit}:${p.file}:${someone.email.toLowerCase()}`;
409
- const notice: StoodNotice = { id, for: someone.email.toLowerCase(), file: p.file, title: result.fileTitles[i] ?? titleOf(p.file, null), by: person, at: record.at };
410
- fields[id] = JSON.stringify(notice);
440
+ stood.push({ id, for: someone.email.toLowerCase(), file: p.file, title: result.fileTitles[i] ?? titleOf(p.file, null), by: person, at: record.at });
411
441
  }
412
442
  }
413
- if (Object.keys(fields).length) {
414
- await redis.hset(keys.notices, fields);
415
- await redis.pexpire(keys.notices, timings.autosaveTtlMs);
416
- }
443
+ await noticeStore.addStood(stood);
417
444
  }
418
- return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
419
- });
445
+ };
420
446
 
421
447
  const history = handler(['GET'], async () => {
422
448
  const entries = await git.history({ refresh: true });
@@ -424,9 +450,10 @@ export async function createLiveEditor(
424
450
  });
425
451
 
426
452
  const tryAgain = handler(['POST'], async () => {
427
- if (!settings.railway) return json(409, { ok: false, error: 'No CARACMS_RAILWAY_TOKEN: a developer can redeploy from Railway.' } satisfies TryAgainResponse);
453
+ if (!settings.deployHook && !settings.railway) return json(409, { ok: false, error: NO_REDEPLOYER_REASON } satisfies TryAgainResponse);
428
454
  const st = await publishView();
429
- if (st.state !== 'failed') return json(409, { ok: false, error: 'The last publish hasn\'t failed, so there\'s nothing to try again.' } satisfies TryAgainResponse);
455
+ // Runs-anywhere Decision 10: taking longer than usual offers it too. One click, one call (the editor's button waits for the answer).
456
+ if (st.state !== 'failed' && st.state !== 'slow') return json(409, { ok: false, error: 'The last publish hasn\'t failed, so there\'s nothing to try again.' } satisfies TryAgainResponse);
430
457
  const r = await git.tryAgain();
431
458
  return r.ok ? json(200, { ok: true } satisfies TryAgainResponse) : json(502, { ok: false, error: r.reason } satisfies TryAgainResponse);
432
459
  });
@@ -439,10 +466,8 @@ export async function createLiveEditor(
439
466
  if (typeof id !== 'string' || id.length > NOTICE_ID_MAX || !NOTICE_ID.test(id)) throw new WriteRefused(400, '"id" must be a notice\'s id.');
440
467
  const shown = (await notices(person)).find((n) => n.id === id);
441
468
  if (!shown) throw new WriteRefused(404, 'There\'s no such notice for you to dismiss.');
442
- if (shown.kind === 'draft-kept') {
443
- await redis.sadd(keys.dismissed, id);
444
- await redis.pexpire(keys.dismissed, timings.autosaveTtlMs);
445
- } else await redis.hdel(keys.notices, id);
469
+ if (shown.kind === 'draft-kept') await noticeStore.dismiss(id);
470
+ else await noticeStore.remove(id);
446
471
  return json(200, { ok: true });
447
472
  });
448
473
 
package/src/live/git.ts CHANGED
@@ -42,9 +42,11 @@ import { movesKey } from './autosaves.ts';
42
42
  import type { WaitingChange } from '../editor/types.ts';
43
43
  import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, type Comparison, type GitBackend, type Head, type Heads, type TreeChange } from './backend.ts';
44
44
  import { HttpError } from './github.ts';
45
+ import { runningCommitFrom } from './mode.ts';
45
46
  import { mergeFile } from './merge.ts';
46
47
  import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
47
48
  import type { RedisClient } from './redis.ts';
49
+ import { landed, StatusCache } from './status-cache.ts';
48
50
  import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
49
51
  import { publishState, waitingFromCommits, type DeploymentFact, type PublishRecord, type PublishState, type WaitingFile } from './status.ts';
50
52
 
@@ -103,14 +105,20 @@ export interface HistoryEntry {
103
105
  state: PublishState;
104
106
  }
105
107
 
106
- export interface RailwayConfig {
107
- /** CARACMS_RAILWAY_TOKEN: a project token (Decision 12). */
108
- token: string;
109
- serviceId: string;
110
- environmentId: string;
111
- /** For tests. Default: global fetch. */
112
- fetch?: typeof fetch;
113
- }
108
+ /**
109
+ * Try again's redeployer (runs-anywhere Decision 11): Railway's API with a project token
110
+ * (`CARACMS_RAILWAY_TOKEN`, M4 Decision 12), or a deploy hook (`CARACMS_DEPLOY_HOOK_URL`):
111
+ * one bare POST, any 2xx a success. The hook's URL is a secret: never logged or sent on.
112
+ */
113
+ export type Redeployer =
114
+ | { kind: 'railway'; token: string; serviceId: string; environmentId: string; fetch?: typeof fetch }
115
+ | { kind: 'hook'; url: string; fetch?: typeof fetch; timeoutMs?: number };
116
+
117
+ /** A deploy hook that hasn't answered in this long has failed (it's one POST; nothing retries by itself). */
118
+ export const DEPLOY_HOOK_TIMEOUT_MS = 20_000;
119
+
120
+ /** Try again's answer when no redeployer is set: no host's name (Decision 11). */
121
+ export const NO_REDEPLOYER_REASON = "Try again isn't set up on this site: a developer can redeploy it.";
114
122
 
115
123
  export interface LiveGitOptions {
116
124
  backend: GitBackend;
@@ -119,12 +127,16 @@ export interface LiveGitOptions {
119
127
  now?: () => number;
120
128
  sleep?: (ms: number) => Promise<void>;
121
129
  log?: (line: string) => void;
122
- /** The running server's commit, read on every status (tests move it). Default: CARACMS_RUNNING_COMMIT or RAILWAY_GIT_COMMIT_SHA. */
130
+ /** The running server's commit, read on every status (tests move it). Default: mode.ts → `runningCommitFrom` over the environment and `bakedCommit`. */
123
131
  runningCommit?: () => string | null;
132
+ /** The commit baked in at build (runs-anywhere Decision 9), for the default `runningCommit`. */
133
+ bakedCommit?: string | null;
124
134
  timings?: Partial<LiveTimings>;
125
- railway?: RailwayConfig | null;
135
+ redeployer?: Redeployer | null;
126
136
  /** Redis key prefix. Default `caracms:`. */
127
137
  keyPrefix?: string;
138
+ /** The status poll's cache (Redis diet), told of the publish lock, the publish record and the moves Publish clears. Default: one of its own. */
139
+ statusCache?: StatusCache;
128
140
  }
129
141
 
130
142
  /** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
@@ -153,6 +165,12 @@ export class LiveGit {
153
165
  readonly redis: RedisClient;
154
166
  readonly timings: LiveTimings;
155
167
  readonly now: () => number;
168
+ /**
169
+ * The status poll's write-through cache (status-cache.ts, Redis diet Decisions 1–5),
170
+ * one per process: kept here because this module writes some of what it holds; the
171
+ * live editor's autosaves, locks and notices write through the same one.
172
+ */
173
+ readonly statusCache: StatusCache;
156
174
  /** Phase 2 fills this: commit every pending autosave (Decision 10 step 1; Decision 8 before a merge). */
157
175
  commitPending: () => Promise<void> = async () => {};
158
176
  /**
@@ -168,7 +186,7 @@ export class LiveGit {
168
186
  private readonly sleep: (ms: number) => Promise<void>;
169
187
  private readonly log: (line: string) => void;
170
188
  private readonly runningCommit: () => string | null;
171
- private readonly railway: RailwayConfig | null;
189
+ private readonly redeployer: Redeployer | null;
172
190
  private readonly keyPrefix: string;
173
191
 
174
192
  private copyHead: Head | null = null;
@@ -194,9 +212,11 @@ export class LiveGit {
194
212
  this.now = options.now ?? Date.now;
195
213
  this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
196
214
  this.log = options.log ?? ((line) => console.log(`[caracms] ${line}`));
197
- this.runningCommit = options.runningCommit ?? (() => process.env.CARACMS_RUNNING_COMMIT || process.env.RAILWAY_GIT_COMMIT_SHA || null);
198
- this.railway = options.railway ?? null;
215
+ const baked = options.bakedCommit ?? null;
216
+ this.runningCommit = options.runningCommit ?? (() => runningCommitFrom(process.env, baked));
217
+ this.redeployer = options.redeployer ?? null;
199
218
  this.keyPrefix = options.keyPrefix ?? 'caracms:';
219
+ this.statusCache = options.statusCache ?? new StatusCache({ redis: this.redis, maxAgeMs: this.timings.statusCacheMs, now: this.now });
200
220
  this.prefix = repoPrefix(options.site);
201
221
  // Every collection's folder, shared blocks' included (M5a Decision 8); Site Settings is its one file (inScope).
202
222
  this.folders = Object.values(options.site.collections)
@@ -241,6 +261,20 @@ export class LiveGit {
241
261
  return `${this.keyPrefix}${name}`;
242
262
  }
243
263
 
264
+ /** The Publish lock's key (one Publish at a time) and the newest publish's record's (Decision 10, 12). */
265
+ get publishLockKey(): string {
266
+ return this.key('publish:lock');
267
+ }
268
+ get publishRecordKey(): string {
269
+ return this.key('publish:last');
270
+ }
271
+
272
+ private async setPublishRecord(record: PublishRecord): Promise<void> {
273
+ const raw = JSON.stringify(record);
274
+ await landed(this.statusCache, this.publishRecordKey, () => this.redis.set(this.publishRecordKey, raw));
275
+ this.statusCache.string(this.publishRecordKey, raw);
276
+ }
277
+
244
278
  /** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
245
279
  private exclusive<T>(fn: () => Promise<T>): Promise<T> {
246
280
  const run = this.queue.then(fn, fn);
@@ -642,9 +676,11 @@ export class LiveGit {
642
676
  * Redis lock); a second press while one runs joins it.
643
677
  */
644
678
  async publish(by: Person): Promise<PublishResult> {
645
- const lock = this.key('publish:lock');
679
+ const lock = this.publishLockKey;
646
680
  const token = randomBytes(12).toString('base64url');
647
- if (!(await this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }))) {
681
+ const taken = await landed(this.statusCache, lock, () => this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }));
682
+ if (taken) this.statusCache.string(lock, token);
683
+ else {
648
684
  const until = this.now() + this.timings.publishLockMs;
649
685
  while (this.now() < until && (await this.redis.get(lock)) !== null) await this.sleep(500);
650
686
  return { kind: 'joined', record: await this.lastPublish() };
@@ -659,7 +695,10 @@ export class LiveGit {
659
695
  throw new Error(`caracms: publish gave up after ${MAX_ATTEMPTS} tries (main or drafts kept moving).`);
660
696
  });
661
697
  } finally {
662
- await this.redis.compareAndDelete(lock, token).catch(() => {});
698
+ // Let go: gone when it was still ours; otherwise (it expired and someone else took it, or the call failed) not known here.
699
+ const gone = await this.redis.compareAndDelete(lock, token).catch(() => false);
700
+ if (gone) this.statusCache.string(lock, null);
701
+ else this.statusCache.drop(lock);
663
702
  }
664
703
  }
665
704
 
@@ -710,16 +749,28 @@ export class LiveGit {
710
749
  this.historyCache = null;
711
750
  // The moves this publish took live are done with (a move made since stays waiting).
712
751
  const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
713
- if (published.length) await this.redis.hdel(movesKey(this.keyPrefix), ...published).catch(() => 0);
752
+ if (published.length) {
753
+ const moves = movesKey(this.keyPrefix);
754
+ const cleared = await this.redis.hdel(moves, ...published).then(
755
+ () => true,
756
+ () => false,
757
+ );
758
+ if (cleared) this.statusCache.hash(moves, {}, published);
759
+ else this.statusCache.drop(moves);
760
+ }
714
761
  const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
715
- await this.redis.set(this.key('publish:last'), JSON.stringify(record));
762
+ await this.setPublishRecord(record);
716
763
  this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
717
764
  return { kind: 'published', record, attempts: attempt, fileTitles };
718
765
  }
719
766
 
720
767
  /** The newest publish, from Redis. */
721
768
  async lastPublish(): Promise<PublishRecord | null> {
722
- const raw = await this.redis.get(this.key('publish:last'));
769
+ return LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey));
770
+ }
771
+
772
+ /** A publish record from its text as read (the status cache's, on a poll). */
773
+ static publishRecordFrom(raw: string | null): PublishRecord | null {
723
774
  if (!raw) return null;
724
775
  try {
725
776
  return JSON.parse(raw) as PublishRecord;
@@ -842,14 +893,15 @@ export class LiveGit {
842
893
  // ── Publish status (Decision 12) ────────────────────────────────────────────
843
894
 
844
895
  /** The newest publish's state. Asks GitHub at most every `deploymentReadMs`, and only while it isn't live. */
845
- async publishStatus(): Promise<PublishStatus> {
846
- const record = await this.lastPublish();
896
+ async publishStatus(options: { record?: PublishRecord | null } = {}): Promise<PublishStatus> {
897
+ // `record`: the newest publish as the caller read it (the status poll's cache); otherwise read here.
898
+ const record = options.record !== undefined ? options.record : await this.lastPublish();
847
899
  if (!record) return { state: 'none', record: null, assumed: false, canTryAgain: false };
848
900
  const running = this.runningCommit();
849
901
  if (running === null) {
850
902
  if (!this.assumedLogged) {
851
903
  this.assumedLogged = true;
852
- this.log('no RAILWAY_GIT_COMMIT_SHA or CARACMS_RUNNING_COMMIT: no deploy pipeline, so a publish reads as live once its refs moved');
904
+ this.log('no running commit known (CARACMS_RUNNING_COMMIT, a host\'s commit variable, or one baked in at build): a publish reads as live once its refs moved');
853
905
  }
854
906
  return { state: 'live', record, assumed: true, canTryAgain: false };
855
907
  }
@@ -858,8 +910,8 @@ export class LiveGit {
858
910
  let facts: DeploymentFact[] = [];
859
911
  let later = new Set<string>();
860
912
  if (!includes) ({ facts, later } = await this.deploymentFacts(record));
861
- const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts });
862
- return { state, record, assumed: false, canTryAgain: state === 'failed' && this.railway !== null };
913
+ const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts, now: this.now(), slowAfterMs: this.timings.publishSlowMs });
914
+ return { state, record, assumed: false, canTryAgain: (state === 'failed' || state === 'slow') && this.redeployer !== null };
863
915
  });
864
916
  }
865
917
 
@@ -904,7 +956,7 @@ export class LiveGit {
904
956
  } catch (error) {
905
957
  if (error instanceof HttpError && (error.status === 403 || error.status === 404)) {
906
958
  this.deploymentsDenied = true;
907
- this.log(`can't read GitHub Deployments (${error.status}): the App needs "Deployments: read". A publish will show as publishing until the server runs it.`);
959
+ this.log(`can't read GitHub Deployments (${error.status}): the site's GitHub token or App needs "Deployments: read" to tell a failed publish. Without it, a publish shows as publishing until the server runs it, then as taking longer than usual.`);
908
960
  return { facts: [], later: new Set() };
909
961
  }
910
962
  throw error;
@@ -930,7 +982,7 @@ export class LiveGit {
930
982
  const parsed = parseMessage(c.message);
931
983
  if (parsed.kind !== 'publish') return;
932
984
  const live = running === null || (runningAt !== -1 && i >= runningAt) || (status.record?.commit === c.sha && status.state === 'live');
933
- const pending: PublishState = status.state === 'failed' ? 'failed' : 'publishing';
985
+ const pending: PublishState = status.state === 'failed' || status.state === 'slow' ? status.state : 'publishing';
934
986
  entries.push({ commit: c.sha, by: parsed.publishedBy, at: c.date, titles: parsed.titles, files: parsed.publishedFiles, state: live ? 'live' : pending });
935
987
  });
936
988
  this.historyCache = { at: this.now(), entries };
@@ -940,13 +992,26 @@ export class LiveGit {
940
992
 
941
993
  // ── Try again (Decision 12) ─────────────────────────────────────────────────
942
994
 
943
- /** Redeploy through Railway's API with the project token. Never publishes again. */
995
+ /**
996
+ * Redeploy through the redeployer (runs-anywhere Decision 11): Railway's API with the
997
+ * project token, or the deploy hook's one POST. Never publishes again, and never
998
+ * retries by itself. A success restarts the "taking longer than usual" clock.
999
+ */
944
1000
  async tryAgain(): Promise<{ ok: true } | { ok: false; reason: string }> {
945
- if (!this.railway) return { ok: false, reason: 'No CARACMS_RAILWAY_TOKEN: a developer can redeploy from Railway.' };
1001
+ if (!this.redeployer) return { ok: false, reason: NO_REDEPLOYER_REASON };
946
1002
  const record = await this.lastPublish();
947
1003
  if (!record) return { ok: false, reason: 'Nothing has been published.' };
948
- const { token, serviceId, environmentId } = this.railway;
949
- const res = await (this.railway.fetch ?? fetch)(RAILWAY_API, {
1004
+ const r = this.redeployer.kind === 'hook' ? await this.callHook(this.redeployer) : await this.redeployOnRailway(this.redeployer);
1005
+ if (!r.ok) return r;
1006
+ await this.setPublishRecord({ ...record, tryAgainAt: new Date(this.now()).toISOString() });
1007
+ this.deploymentsCache = null;
1008
+ this.log(`try again: ${this.redeployer.kind === 'hook' ? 'the deploy hook took it' : 'Railway is redeploying'} (publish ${record.commit.slice(0, 7)})`);
1009
+ return { ok: true };
1010
+ }
1011
+
1012
+ private async redeployOnRailway(railway: Extract<Redeployer, { kind: 'railway' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
1013
+ const { token, serviceId, environmentId } = railway;
1014
+ const res = await (railway.fetch ?? fetch)(RAILWAY_API, {
950
1015
  method: 'POST',
951
1016
  headers: { 'content-type': 'application/json', 'project-access-token': token },
952
1017
  body: JSON.stringify({
@@ -956,15 +1021,28 @@ export class LiveGit {
956
1021
  });
957
1022
  const body = (await res.json().catch(() => ({}))) as { data?: { serviceInstanceRedeploy?: boolean }; errors?: { message: string }[] };
958
1023
  if (!res.ok || body.errors?.length || !body.data?.serviceInstanceRedeploy) {
959
- const reason = body.errors?.map((e) => e.message).join('; ') || `Railway answered ${res.status}`;
1024
+ const reason = body.errors?.map((e) => e.message).join('; ') || `the host answered ${res.status}`;
960
1025
  this.log(`try again: Railway refused the redeploy (${reason})`);
961
1026
  return { ok: false, reason };
962
1027
  }
963
- await this.redis.set(this.key('publish:last'), JSON.stringify({ ...record, tryAgainAt: new Date(this.now()).toISOString() }));
964
- this.deploymentsCache = null;
965
- this.log(`try again: Railway is redeploying (publish ${record.commit.slice(0, 7)})`);
966
1028
  return { ok: true };
967
1029
  }
1030
+
1031
+ /** The deploy hook: one POST, no body, no extra headers; any 2xx is success. The URL is never logged or returned. */
1032
+ private async callHook(hook: Extract<Redeployer, { kind: 'hook' }>): Promise<{ ok: true } | { ok: false; reason: string }> {
1033
+ let reason: string;
1034
+ try {
1035
+ const res = await (hook.fetch ?? fetch)(hook.url, { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(hook.timeoutMs ?? DEPLOY_HOOK_TIMEOUT_MS) });
1036
+ await res.body?.cancel().catch(() => {});
1037
+ if (res.status >= 200 && res.status < 300) return { ok: true };
1038
+ reason = `the deploy hook answered ${res.status}`;
1039
+ } catch (error) {
1040
+ const name = error instanceof Error ? error.name : '';
1041
+ reason = name === 'TimeoutError' || name === 'AbortError' ? "the deploy hook didn't answer in time" : "the deploy hook couldn't be reached";
1042
+ }
1043
+ this.log(`try again: ${reason}`);
1044
+ return { ok: false, reason };
1045
+ }
968
1046
  }
969
1047
 
970
1048
  export interface Moves {