caracms 0.2.0 → 0.3.0

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 (50) 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/editor.ts +24 -6
  14. package/src/editor/client/live/publish.tsx +20 -10
  15. package/src/editor/client/pane/parts.tsx +34 -2
  16. package/src/editor/client/state/keeper.ts +51 -6
  17. package/src/editor/client/state/live.ts +45 -6
  18. package/src/editor/middleware.ts +3 -2
  19. package/src/editor/routes/try-again.ts +2 -1
  20. package/src/editor/server/admin-props.ts +2 -1
  21. package/src/editor/server/mode.ts +13 -3
  22. package/src/editor/styles/levels/live.css +1 -1
  23. package/src/editor/types.ts +25 -6
  24. package/src/files-route.ts +9 -4
  25. package/src/forms/endpoint.ts +11 -16
  26. package/src/head.ts +10 -1
  27. package/src/index.ts +53 -7
  28. package/src/live/README.md +4 -2
  29. package/src/live/auth/auth.ts +4 -1
  30. package/src/live/auth/guard.ts +3 -2
  31. package/src/live/auth/index.ts +6 -7
  32. package/src/live/auth/login.ts +17 -8
  33. package/src/live/auth/middleware.ts +7 -3
  34. package/src/live/auth/request.ts +92 -11
  35. package/src/live/auth/views.ts +9 -4
  36. package/src/live/autosaves.ts +59 -16
  37. package/src/live/backend.ts +13 -1
  38. package/src/live/drafts-store.ts +4 -3
  39. package/src/live/editor.ts +92 -67
  40. package/src/live/git.ts +112 -34
  41. package/src/live/github.ts +128 -13
  42. package/src/live/locks.ts +92 -26
  43. package/src/live/mode.ts +131 -18
  44. package/src/live/notices.ts +100 -0
  45. package/src/live/redis.ts +161 -39
  46. package/src/live/runtime.ts +11 -3
  47. package/src/live/status-cache.ts +219 -0
  48. package/src/live/status.ts +14 -2
  49. package/src/live/timings.ts +24 -0
  50. package/src/media.ts +26 -1
@@ -41,10 +41,14 @@
41
41
  * counts while its old file is gone and its new file is there (`movesIn`, git.ts).
42
42
  *
43
43
  * Paths are site-relative (`content/pages/index.json`). Values are JSON text.
44
+ *
45
+ * **The status cache** (status-cache.ts, Redis diet Decision 2): every write here of a
46
+ * key the status poll reads (autosaves, change records, moves) tells it what landed.
44
47
  */
45
48
  import type { AnyDocument } from '../document.ts';
46
49
  import type { Person } from './messages.ts';
47
50
  import type { RedisClient } from './redis.ts';
51
+ import { landed, type StatusCache } from './status-cache.ts';
48
52
  import type { LiveTimings } from './timings.ts';
49
53
 
50
54
  export interface Autosave {
@@ -131,17 +135,34 @@ export class Autosaves {
131
135
  readonly timings: LiveTimings;
132
136
  readonly now: () => number;
133
137
  readonly prefix: string;
138
+ private readonly cache: StatusCache | null;
134
139
 
135
- constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:') {
140
+ constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:', cache: StatusCache | null = null) {
136
141
  this.redis = redis;
137
142
  this.timings = timings;
138
143
  this.now = now;
139
144
  this.prefix = prefix;
145
+ this.cache = cache;
140
146
  }
141
147
 
142
148
  key = (file: string) => `${this.prefix}autosave:${file}`;
149
+ /** What every autosave key starts with (the status cache scans it). */
150
+ get keyPrefix(): string {
151
+ return this.key('');
152
+ }
143
153
  private committedKey = (file: string) => `${this.prefix}committed:${file}`;
144
- private changedKey = (file: string) => `${this.prefix}changed:${file}`;
154
+ readonly changedKey = (file: string) => `${this.prefix}changed:${file}`;
155
+ readonly movesKey = () => movesKey(this.prefix);
156
+
157
+ /** A compare-and-swap (or delete) on an autosave key, and the status cache told what came of it. */
158
+ private async swap(key: string, expected: string | null, raw: string | null): Promise<boolean> {
159
+ const ok = await landed(this.cache, key, () =>
160
+ raw === null ? this.redis.compareAndDelete(key, expected!) : this.redis.compareAndSet(key, expected, raw, { px: this.timings.autosaveTtlMs }),
161
+ );
162
+ if (ok) this.cache?.string(key, raw);
163
+ else this.cache?.drop(key);
164
+ return ok;
165
+ }
145
166
 
146
167
  async get(file: string): Promise<Read<Autosave> | null> {
147
168
  const raw = await this.redis.get(this.key(file));
@@ -151,30 +172,34 @@ export class Autosaves {
151
172
 
152
173
  /** Every autosave: one SCAN and one MGET. */
153
174
  async all(): Promise<Read<Autosave>[]> {
154
- const keys = await this.redis.scan(`${this.prefix}autosave:*`);
175
+ const keys = await this.redis.scan(`${this.keyPrefix}*`);
155
176
  if (keys.length === 0) return [];
156
- const raws = await this.redis.mget(keys);
177
+ return Autosaves.allFrom(await this.redis.mget(keys));
178
+ }
179
+
180
+ /** The autosaves among these values (a scan of `keyPrefix`'s, read). */
181
+ static allFrom(raws: Iterable<string | null>): Read<Autosave>[] {
157
182
  const out: Read<Autosave>[] = [];
158
- raws.forEach((raw) => {
183
+ for (const raw of raws) {
159
184
  const value = parse<Autosave>(raw);
160
185
  if (raw !== null && value) out.push({ raw, value });
161
- });
186
+ }
162
187
  return out;
163
188
  }
164
189
 
165
190
  /** Store it only if the autosave is still what was read (`null`: only if there's none), so a write that read an older one never replaces a newer. */
166
191
  putIf(read: Read<Autosave> | null, autosave: Autosave): Promise<boolean> {
167
- return this.redis.compareAndSet(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave), { px: this.timings.autosaveTtlMs });
192
+ return this.swap(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave));
168
193
  }
169
194
 
170
195
  /** Delete it only if it's still what was read (a newer write wins). */
171
196
  removeIf(read: Read<Autosave>): Promise<boolean> {
172
- return this.redis.compareAndDelete(this.key(read.value.file), read.raw);
197
+ return this.swap(this.key(read.value.file), read.raw, null);
173
198
  }
174
199
 
175
200
  /** Replace it only if it's still what was read. */
176
201
  replaceIf(read: Read<Autosave>, next: Autosave): Promise<boolean> {
177
- return this.redis.compareAndSet(this.key(next.file), read.raw, JSON.stringify(next), { px: this.timings.autosaveTtlMs });
202
+ return this.swap(this.key(next.file), read.raw, JSON.stringify(next));
178
203
  }
179
204
 
180
205
  async committed(file: string): Promise<Committed | null> {
@@ -186,7 +211,12 @@ export class Autosaves {
186
211
  }
187
212
 
188
213
  async changed(file: string): Promise<ChangeRecord | null> {
189
- return parse<ChangeRecord>(await this.redis.get(this.changedKey(file)));
214
+ return Autosaves.changedFrom(await this.redis.get(this.changedKey(file)));
215
+ }
216
+
217
+ /** A change record from its text as read. */
218
+ static changedFrom(raw: string | null): ChangeRecord | null {
219
+ return parse<ChangeRecord>(raw);
190
220
  }
191
221
 
192
222
  /** Several files' records in one MGET (the documents list's "Last edited"), in the order asked; none: null. */
@@ -197,7 +227,9 @@ export class Autosaves {
197
227
 
198
228
  /** No expiry: one small key per file, overwritten on each change, so "who changed it" is never forgotten while Redis keeps its data. */
199
229
  async setChanged(file: string, record: ChangeRecord): Promise<void> {
200
- await this.redis.set(this.changedKey(file), JSON.stringify(record));
230
+ const raw = JSON.stringify(record);
231
+ await landed(this.cache, this.changedKey(file), () => this.redis.set(this.changedKey(file), raw));
232
+ this.cache?.string(this.changedKey(file), raw);
201
233
  }
202
234
 
203
235
  /**
@@ -205,7 +237,8 @@ export class Autosaves {
205
237
  * from `main` leaves `{ by: null }` ("only merges"), and never overwrites an editor's.
206
238
  */
207
239
  async setChangedIfAbsent(file: string, record: Omit<ChangeRecord, 'by'> & { by: Person | null }): Promise<void> {
208
- await this.redis.set(this.changedKey(file), JSON.stringify(record), { nx: true });
240
+ const raw = JSON.stringify(record);
241
+ if (await landed(this.cache, this.changedKey(file), () => this.redis.set(this.changedKey(file), raw, { nx: true }))) this.cache?.string(this.changedKey(file), raw);
209
242
  }
210
243
 
211
244
  /** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
@@ -213,6 +246,16 @@ export class Autosaves {
213
246
  return new Map(Object.entries(await this.redis.hgetall(movesKey(this.prefix))));
214
247
  }
215
248
 
249
+ private async setMove(from: string, to: string): Promise<void> {
250
+ await landed(this.cache, movesKey(this.prefix), () => this.redis.hset(movesKey(this.prefix), { [from]: to }));
251
+ this.cache?.hash(movesKey(this.prefix), { [from]: to });
252
+ }
253
+
254
+ private async dropMoves(...from: string[]): Promise<void> {
255
+ await landed(this.cache, movesKey(this.prefix), () => this.redis.hdel(movesKey(this.prefix), ...from));
256
+ this.cache?.hash(movesKey(this.prefix), {}, from);
257
+ }
258
+
216
259
  /**
217
260
  * A document moved from `from` to `to`. Moved since the last Publish already (a record
218
261
  * ends at `from`): that record now ends at `to`, or goes when `to` is where it started
@@ -223,16 +266,16 @@ export class Autosaves {
223
266
  const moves = await this.moves();
224
267
  const earlier = [...moves].find(([, t]) => t === from)?.[0];
225
268
  if (earlier !== undefined) {
226
- if (earlier === to) await this.redis.hdel(movesKey(this.prefix), earlier);
227
- else await this.redis.hset(movesKey(this.prefix), { [earlier]: to });
228
- } else if (wasLive) await this.redis.hset(movesKey(this.prefix), { [from]: to });
269
+ if (earlier === to) await this.dropMoves(earlier);
270
+ else await this.setMove(earlier, to);
271
+ } else if (wasLive) await this.setMove(from, to);
229
272
  }
230
273
 
231
274
  /** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
232
275
  async forgetMoveTo(file: string): Promise<void> {
233
276
  const moves = await this.moves();
234
277
  const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
235
- if (olds.length) await this.redis.hdel(movesKey(this.prefix), ...olds);
278
+ if (olds.length) await this.dropMoves(...olds);
236
279
  }
237
280
 
238
281
  /** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { createHash } from 'node:crypto';
17
17
  import { gunzipSync } from 'node:zlib';
18
- import { GitHubClient, GraphQLError, HttpError, Tracker } from './github.ts';
18
+ import { GitHubClient, GraphQLError, HttpError, Tracker, type GitHubAccessProblem } from './github.ts';
19
19
 
20
20
  export interface Head {
21
21
  commit: string;
@@ -139,6 +139,8 @@ export interface GitBackend {
139
139
  listDeployments(count: number): Promise<Deployment[]>;
140
140
  /** A deployment's newest status, or null when it has none yet. */
141
141
  deploymentStatus(id: number): Promise<DeploymentStatus | null>;
142
+ /** What's wrong with GitHub access now, from the responses so far (runs-anywhere Decision 5); null when nothing is. */
143
+ accessProblem(): GitHubAccessProblem | null;
142
144
  }
143
145
 
144
146
  /** Is this GraphQL error a stale head (someone moved the ref)? M1 → G6 (a): often it can't tell. */
@@ -188,6 +190,10 @@ export class GitHubBackend implements GitBackend {
188
190
  return this.gh.tracker;
189
191
  }
190
192
 
193
+ accessProblem(): GitHubAccessProblem | null {
194
+ return this.gh.accessProblem();
195
+ }
196
+
191
197
  async readHeads(): Promise<Heads> {
192
198
  const d = await this.gh.graphql<{ repository: { id: string; main: RefNode; drafts: RefNode } }>(
193
199
  HEADS_QUERY,
@@ -411,6 +417,8 @@ export class MemoryBackend implements GitBackend {
411
417
  deployments: MemoryDeployment[] = [];
412
418
  /** Called before each recorded request, with its endpoint: tests inject races here. */
413
419
  beforeRequest: ((endpoint: string) => Promise<void> | void) | null = null;
420
+ /** The GitHub access problem the status reports; tests set it (the GitHub client's own is tested with faked responses). */
421
+ access: GitHubAccessProblem | null = null;
414
422
  private readonly now: () => number;
415
423
  private inHook = false;
416
424
  private blobs = new Map<string, string>();
@@ -541,6 +549,10 @@ export class MemoryBackend implements GitBackend {
541
549
 
542
550
  // GitBackend.
543
551
 
552
+ accessProblem(): GitHubAccessProblem | null {
553
+ return this.access;
554
+ }
555
+
544
556
  async readHeads() {
545
557
  await this.rec('graphql readHeads', false);
546
558
  return { main: this.head('main'), drafts: this.head('drafts') };
@@ -41,7 +41,7 @@ import { parseAnyDocument } from '../document.ts';
41
41
  import type { DraftsStore } from '../editor/server/drafts-store.ts';
42
42
  import { confinePath, fileVersion, WriteRefused, writeText, type WriteContext, type WriteRequest, type WriteResponse } from '../editor/server/write.ts';
43
43
  import type { CreateResponse, DeleteResponse, LastChange, LockRefusal, MoveResponse, ReplaceFileResponse } from '../editor/types.ts';
44
- import type { Autosave, Autosaves, Read } from './autosaves.ts';
44
+ import type { Autosave, Autosaves, ChangeRecord, Read } from './autosaves.ts';
45
45
  import { titleOf, type LiveGit } from './git.ts';
46
46
  import type { LockRecord, Locks } from './locks.ts';
47
47
  import { displayName, type Person } from './messages.ts';
@@ -79,8 +79,9 @@ export function unavailable(error: unknown): WriteRefused {
79
79
  * expire, so a file with none was never changed in the editor or Redis lost its data, and
80
80
  * the server can't tell which: the keeper holds edits on unknown rather than re-send them.
81
81
  */
82
- export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string): Promise<LastChange> {
83
- const record = await parts.autosaves.changed(file);
82
+ /** `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here. */
83
+ export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
84
+ const record = known !== undefined ? known : await parts.autosaves.changed(file);
84
85
  if (record) return { by: record.by, version: record.version, at: record.at };
85
86
  const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
86
87
  return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
@@ -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