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
@@ -1,6 +1,9 @@
1
1
  /**
2
- * GitHub as the site's App (M4 Phase 1; lifted from M1's spike, `spikes/git/src/github.ts`).
2
+ * GitHub as the site's App or a fine-grained token (M4 Phase 1, lifted from M1's spike,
3
+ * `spikes/git/src/github.ts`; the token from runs-anywhere Decision 3).
3
4
  *
5
+ * - **A fine-grained token** (`CARACMS_GITHUB_TOKEN`) is the bearer as it is. GitHub sends
6
+ * its expiry on every response, which is kept for the editor (Decision 5).
4
7
  * - **The App's JWT**: RS256, signed with the App's private key, `iat` backdated a
5
8
  * minute for clock drift, nine minutes long (GitHub's limit is ten).
6
9
  * - **An installation token scoped to the one repo**: the installation is found with
@@ -14,16 +17,18 @@
14
17
  * that stops answering fails the operation instead of holding it (and Publish's or
15
18
  * the check's callers) forever.
16
19
  *
17
- * The private key is never logged.
20
+ * - **Access problems** (Decision 5): every response is watched for a refused credential
21
+ * (401), a write refused for lack of a permission (403 "not accessible"), and, for a
22
+ * token, an expiry within 14 days. `accessProblem()` says which holds now; a later
23
+ * success clears the first two.
24
+ *
25
+ * The private key and the token are never logged.
18
26
  */
19
27
  import { createSign } from 'node:crypto';
20
28
 
21
29
  const API = 'https://api.github.com';
22
30
 
23
- export interface AppConnection {
24
- appId: string;
25
- /** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
26
- privateKey: string;
31
+ interface ConnectionBase {
27
32
  owner: string;
28
33
  repo: string;
29
34
  /** For tests. Default: global fetch. */
@@ -34,6 +39,63 @@ export interface AppConnection {
34
39
  timeoutMs?: number;
35
40
  }
36
41
 
42
+ export interface AppConnection extends ConnectionBase {
43
+ appId: string;
44
+ /** The PEM. Railway variables often carry it with literal `\n`s; those are turned back into newlines. */
45
+ privateKey: string;
46
+ }
47
+
48
+ /** A fine-grained personal access token on the one repo (Decision 3). */
49
+ export interface TokenConnection extends ConnectionBase {
50
+ token: string;
51
+ }
52
+
53
+ export type GitHubConnection = AppConnection | TokenConnection;
54
+
55
+ /**
56
+ * What's wrong with GitHub access right now (Decision 5), shown in the editor while it holds.
57
+ * `credential` says which one the site uses, so the sentence names the right fix.
58
+ */
59
+ export type GitHubAccessProblem =
60
+ | { kind: 'refused'; credential: 'app' | 'token' }
61
+ | { kind: 'permission'; credential: 'app' | 'token'; permission: string }
62
+ | { kind: 'expiring'; credential: 'token'; expires: string };
63
+
64
+ /** A token expiring within this long shows in the editor (Decision 5). */
65
+ export const TOKEN_EXPIRY_WARNING_MS = 14 * 24 * 60 * 60_000;
66
+
67
+ /** The permission every write needs; Deployments read is optional and never shown (Decision 10). */
68
+ const CONTENTS_PERMISSION = 'Contents: Read and write';
69
+
70
+ /**
71
+ * `github-authentication-token-expiration`: "2026-11-03 10:00:00 UTC" or "… -0700".
72
+ * Null when it doesn't parse.
73
+ */
74
+ export function parseTokenExpiry(value: string | null): number | null {
75
+ const m = value && /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)\s*(UTC|Z|[+-]\d{2}:?\d{2})?$/i.exec(value.trim());
76
+ if (!m) return null;
77
+ const zone = !m[3] || /^(utc|z)$/i.test(m[3]) ? 'Z' : m[3].replace(/^([+-]\d{2}):?(\d{2})$/, '$1:$2');
78
+ const at = Date.parse(`${m[1]}T${m[2]}${zone}`);
79
+ return Number.isNaN(at) ? null : at;
80
+ }
81
+
82
+ /** The sentence the editor shows for an access problem, for the developer it's forwarded to. */
83
+ export function accessProblemMessage(problem: GitHubAccessProblem): string {
84
+ const what = problem.credential === 'token' ? 'GitHub token (CARACMS_GITHUB_TOKEN)' : 'GitHub App';
85
+ switch (problem.kind) {
86
+ case 'refused':
87
+ return problem.credential === 'token'
88
+ ? `GitHub refused this site's ${what}: it may have expired or been revoked. Changes can't be saved to GitHub until a developer makes a new fine-grained token and sets it.`
89
+ : `GitHub refused this site's ${what}: it may have been uninstalled from the repo or suspended. Changes can't be saved to GitHub until a developer reinstalls it.`;
90
+ case 'permission':
91
+ return `GitHub refused a change because this site's ${what} lacks the "${problem.permission}" permission. A developer can add it in the ${problem.credential === 'token' ? "token's settings" : "App's settings"}.`;
92
+ case 'expiring': {
93
+ const date = new Date(problem.expires).toLocaleDateString('en-IE', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
94
+ return `This site's ${what} expires on ${date}. After that, changes can't be saved to GitHub: a developer can regenerate it, or make a new one, and set it before then.`;
95
+ }
96
+ }
97
+ }
98
+
37
99
  /** A GitHub request that takes longer than this fails (its caller treats it as any failure). */
38
100
  export const GITHUB_TIMEOUT_MS = 20_000;
39
101
 
@@ -143,6 +205,8 @@ export class GitHubClient {
143
205
  readonly tracker: Tracker;
144
206
  readonly owner: string;
145
207
  readonly repo: string;
208
+ /** Which credential this client signs in with. */
209
+ readonly credential: 'app' | 'token';
146
210
  private readonly appId: string;
147
211
  private readonly key: string;
148
212
  private readonly fetcher: typeof fetch;
@@ -151,10 +215,23 @@ export class GitHubClient {
151
215
  private token = '';
152
216
  private tokenExpires = 0;
153
217
  private signingIn: Promise<void> | null = null;
218
+ /** Decision 5: what the last responses said about access. */
219
+ private refused = false;
220
+ private missingPermission: string | null = null;
221
+ private personalTokenExpires: number | null = null;
154
222
 
155
- constructor(connection: AppConnection, tracker: Tracker) {
156
- this.appId = connection.appId;
157
- this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
223
+ constructor(connection: GitHubConnection, tracker: Tracker) {
224
+ if ('token' in connection) {
225
+ this.credential = 'token';
226
+ this.appId = '';
227
+ this.key = '';
228
+ this.token = connection.token;
229
+ this.tokenExpires = Number.POSITIVE_INFINITY;
230
+ } else {
231
+ this.credential = 'app';
232
+ this.appId = connection.appId;
233
+ this.key = connection.privateKey.includes('\\n') ? connection.privateKey.replace(/\\n/g, '\n') : connection.privateKey;
234
+ }
158
235
  this.owner = connection.owner;
159
236
  this.repo = connection.repo;
160
237
  this.fetcher = connection.fetch ?? fetch;
@@ -163,6 +240,37 @@ export class GitHubClient {
163
240
  this.tracker = tracker;
164
241
  }
165
242
 
243
+ /** What's wrong with GitHub access now, the most serious first; null when nothing is (Decision 5). */
244
+ accessProblem(): GitHubAccessProblem | null {
245
+ if (this.refused) return { kind: 'refused', credential: this.credential };
246
+ if (this.missingPermission) return { kind: 'permission', credential: this.credential, permission: this.missingPermission };
247
+ const expires = this.personalTokenExpires;
248
+ if (this.credential === 'token' && expires !== null && expires - this.now() < TOKEN_EXPIRY_WARNING_MS) {
249
+ return { kind: 'expiring', credential: 'token', expires: new Date(expires).toISOString() };
250
+ }
251
+ return null;
252
+ }
253
+
254
+ /**
255
+ * Keep what a response says about access. A 401 is a refused credential. A write
256
+ * refused for lack of a permission (`notPermitted`: a 403 or a GraphQL error saying
257
+ * "not accessible") is a missing permission, except on Deployments (optional,
258
+ * Decision 10); a rate limit's 403 isn't one. Any success clears a refusal; a
259
+ * successful write clears a missing permission.
260
+ */
261
+ private observe(res: Response, path: string, write: boolean, notPermitted: boolean, hadErrors = false): void {
262
+ if (this.credential === 'token') {
263
+ const expires = parseTokenExpiry(res.headers.get('github-authentication-token-expiration'));
264
+ if (expires !== null) this.personalTokenExpires = expires;
265
+ }
266
+ if (res.status === 401) this.refused = true;
267
+ else if (res.ok && !notPermitted) {
268
+ this.refused = false;
269
+ if (write && !hadErrors) this.missingPermission = null; // a GraphQL 200 carrying errors didn't land
270
+ }
271
+ if (notPermitted && write && !/\/deployments/.test(path)) this.missingPermission = CONTENTS_PERMISSION;
272
+ }
273
+
166
274
  get nameWithOwner() {
167
275
  return `${this.owner}/${this.repo}`;
168
276
  }
@@ -176,6 +284,7 @@ export class GitHubClient {
176
284
  const jwt = appJwt(this.appId, this.key, Math.floor(this.now() / 1000));
177
285
  const install = (await this.request('GET', this.repoPath('/installation'), undefined, jwt).catch((error: unknown) => {
178
286
  if (error instanceof HttpError && error.status === 404) {
287
+ this.refused = true;
179
288
  throw new Error(`The GitHub App (id ${this.appId}) isn't installed on ${this.nameWithOwner}. Install it on that repo.`);
180
289
  }
181
290
  throw error;
@@ -188,8 +297,9 @@ export class GitHubClient {
188
297
  this.tokenExpires = Date.parse(tok.expires_at);
189
298
  }
190
299
 
191
- /** The installation token, refreshed five minutes before it expires. */
300
+ /** The installation token, refreshed five minutes before it expires; a personal token as it is. */
192
301
  private async auth(): Promise<string> {
302
+ if (this.credential === 'token') return this.token;
193
303
  if (!this.token || this.now() > this.tokenExpires - 5 * 60_000) {
194
304
  this.signingIn ??= this.signIn().finally(() => {
195
305
  this.signingIn = null;
@@ -207,6 +317,7 @@ export class GitHubClient {
207
317
  async bytes(path: string): Promise<Buffer> {
208
318
  const res = await this.fetcher(`${API}${path}`, { headers: this.headers(await this.auth()), redirect: 'follow', signal: this.signal() });
209
319
  this.tracker.record({ endpoint: `GET ${shorten(path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, ''))}`, status: res.status, creating: false });
320
+ this.observe(res, path, false, false);
210
321
  if (!res.ok) throw new HttpError(res.status, `GET ${path} -> ${res.status}`, await res.text().catch(() => ''));
211
322
  return Buffer.from(await res.arrayBuffer());
212
323
  }
@@ -234,7 +345,8 @@ export class GitHubClient {
234
345
  signal: this.signal(),
235
346
  });
236
347
  const endpointPath = path.replace(/^\/repos\/[^/]+\/[^/]+/, '').replace(/\?.*$/, '');
237
- this.tracker.record({ endpoint: `${method} ${shorten(endpointPath)}`, status: res.status, creating: !notCreating && isContentCreating(method, path, false) });
348
+ const creating = !notCreating && isContentCreating(method, path, false);
349
+ this.tracker.record({ endpoint: `${method} ${shorten(endpointPath)}`, status: res.status, creating });
238
350
  const text = await res.text();
239
351
  let json: unknown = null;
240
352
  try {
@@ -242,14 +354,15 @@ export class GitHubClient {
242
354
  } catch {
243
355
  json = text;
244
356
  }
357
+ const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
358
+ this.observe(res, path, creating, res.status === 403 && /not accessible/i.test(message));
245
359
  if (!res.ok) {
246
- const message = (json as { message?: string } | null)?.message ?? text.slice(0, 200);
247
360
  throw new HttpError(res.status, `${method} ${endpointPath} -> ${res.status}: ${message}`, json);
248
361
  }
249
362
  return json;
250
363
  }
251
364
 
252
- /** GraphQL as the installation. Throws GraphQLError when the answer has `errors`. */
365
+ /** GraphQL as the installation (or the token). Throws GraphQLError when the answer has `errors`. */
253
366
  async graphql<T = unknown>(query: string, variables: Record<string, unknown>, name: string): Promise<T> {
254
367
  const isMutation = /^\s*mutation\b/.test(query);
255
368
  const res = await this.fetcher(`${API}/graphql`, {
@@ -264,6 +377,8 @@ export class GitHubClient {
264
377
  if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) throw error; // timed out mid-body
265
378
  return {};
266
379
  })) as { data?: T; errors?: { type?: string; message?: string }[] };
380
+ const notPermitted = (json.errors ?? []).some((e) => e.type === 'FORBIDDEN' || /not accessible/i.test(e.message ?? ''));
381
+ this.observe(res, '/graphql', isMutation, notPermitted, (json.errors?.length ?? 0) > 0);
267
382
  if (!res.ok && !json.errors) throw new HttpError(res.status, `graphql ${name} -> ${res.status}`, json);
268
383
  if (json.errors?.length) throw new GraphQLError(json.errors);
269
384
  return json.data as T;
package/src/live/locks.ts CHANGED
@@ -28,11 +28,16 @@
28
28
  *
29
29
  * **Presence** (`caracms:presence`, a hash by tab): each poll records the person and the
30
30
  * document their tab has open; someone is "on" a document while a tab of theirs
31
- * polled with it in the last `presenceMs`.
31
+ * polled with it within that record's hold: twice the tab's poll interval plus 3 s
32
+ * (Redis diet Decision 9), so a slow tab stays on and a closed fast one goes quickly.
33
+ *
34
+ * **The status cache** (status-cache.ts, Redis diet Decision 2): every write here tells
35
+ * it what landed, so the status poll can read locks and presence from memory.
32
36
  */
33
37
  import type { LockState, LockView, PresenceView } from '../editor/types.ts';
34
38
  import type { Person } from './messages.ts';
35
39
  import type { RedisClient } from './redis.ts';
40
+ import { landed, type StatusCache } from './status-cache.ts';
36
41
  import type { LiveTimings } from './timings.ts';
37
42
 
38
43
  export interface LockRecord {
@@ -48,7 +53,7 @@ export interface LockRecord {
48
53
  deleted?: true;
49
54
  }
50
55
 
51
- interface ReadLock {
56
+ export interface ReadLock {
52
57
  raw: string;
53
58
  lock: LockRecord;
54
59
  }
@@ -57,11 +62,16 @@ interface PresenceRecord {
57
62
  person: Person;
58
63
  file: string | null;
59
64
  at: number;
65
+ /** ms the record holds after `at` (Redis diet Decision 9). Absent (written before it was added): `presenceMs`. */
66
+ hold?: number;
60
67
  }
61
68
 
62
69
  /** How long a releasing lock with no autosave yet waits for the tab's last write. */
63
70
  export const RELEASE_GRACE_MS = 5_000;
64
71
 
72
+ /** Redis diet Decision 9: a presence record holds for twice the tab's poll interval plus this. */
73
+ export const PRESENCE_SLACK_MS = 3_000;
74
+
65
75
  const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
66
76
 
67
77
  export type AcquireResult = { ok: true; lock: LockRecord } | { ok: false; lock: LockRecord };
@@ -73,17 +83,38 @@ export class Locks {
73
83
  readonly prefix: string;
74
84
  /** Whether the document has an autosave not yet committed. */
75
85
  private readonly pending: (file: string) => Promise<boolean>;
86
+ private readonly cache: StatusCache | null;
76
87
 
77
- constructor(redis: RedisClient, timings: LiveTimings, now: () => number, pending: (file: string) => Promise<boolean>, prefix = 'caracms:') {
88
+ constructor(
89
+ redis: RedisClient,
90
+ timings: LiveTimings,
91
+ now: () => number,
92
+ pending: (file: string) => Promise<boolean>,
93
+ prefix = 'caracms:',
94
+ cache: StatusCache | null = null,
95
+ ) {
78
96
  this.redis = redis;
79
97
  this.timings = timings;
80
98
  this.now = now;
81
99
  this.pending = pending;
82
100
  this.prefix = prefix;
101
+ this.cache = cache;
83
102
  }
84
103
 
85
104
  key = (file: string) => `${this.prefix}lock:${file}`;
86
- private presenceKey = () => `${this.prefix}presence`;
105
+ /** What every lock key starts with (the status cache scans it). */
106
+ get keyPrefix(): string {
107
+ return this.key('');
108
+ }
109
+ readonly presenceKey = () => `${this.prefix}presence`;
110
+
111
+ /** A compare-and-swap on a lock key, and the status cache told what came of it. */
112
+ private async swap(file: string, expected: string | null, raw: string): Promise<boolean> {
113
+ const ok = await landed(this.cache, this.key(file), () => this.redis.compareAndSet(this.key(file), expected, raw, { px: this.timings.autosaveTtlMs }));
114
+ if (ok) this.cache?.string(this.key(file), raw);
115
+ else this.cache?.drop(this.key(file));
116
+ return ok;
117
+ }
87
118
 
88
119
  private parse(raw: string | null): ReadLock | null {
89
120
  if (raw === null) return null;
@@ -101,14 +132,19 @@ export class Locks {
101
132
 
102
133
  /** Every lock key: one SCAN and one MGET. */
103
134
  async all(): Promise<Map<string, ReadLock>> {
104
- const keys = await this.redis.scan(`${this.prefix}lock:*`);
105
- const out = new Map<string, ReadLock>();
106
- if (keys.length === 0) return out;
135
+ const keys = await this.redis.scan(`${this.keyPrefix}*`);
136
+ if (keys.length === 0) return new Map();
107
137
  const raws = await this.redis.mget(keys);
108
- keys.forEach((k, i) => {
109
- const read = this.parse(raws[i] ?? null);
110
- if (read) out.set(k.slice(this.key('').length), read);
111
- });
138
+ return this.allFrom(keys.map((k, i) => [k, raws[i] ?? null]));
139
+ }
140
+
141
+ /** The locks among these keys and values (a scan of `keyPrefix`), by file. */
142
+ allFrom(entries: Iterable<readonly [string, string | null]>): Map<string, ReadLock> {
143
+ const out = new Map<string, ReadLock>();
144
+ for (const [k, raw] of entries) {
145
+ const read = this.parse(raw);
146
+ if (read) out.set(k.slice(this.keyPrefix.length), read);
147
+ }
112
148
  return out;
113
149
  }
114
150
 
@@ -125,7 +161,7 @@ export class Locks {
125
161
  }
126
162
 
127
163
  private put(file: string, expected: string | null, lock: LockRecord): Promise<boolean> {
128
- return this.redis.compareAndSet(this.key(file), expected, JSON.stringify(lock), { px: this.timings.autosaveTtlMs });
164
+ return this.swap(file, expected, JSON.stringify(lock));
129
165
  }
130
166
 
131
167
  private static mine(lock: LockRecord, tab: string, person: Person) {
@@ -161,18 +197,32 @@ export class Locks {
161
197
  * is still here), except for a lock the tab's own delete released. A lapsed lock that still names this tab is re-taken (a tab back
162
198
  * from hidden). Nothing is taken that the tab never held.
163
199
  */
164
- async beat(file: string, tab: string, person: Person): Promise<ReadLock | null> {
200
+ async beat(file: string, tab: string, person: Person, known?: ReadLock | null): Promise<ReadLock | null> {
165
201
  for (let attempt = 0; attempt < 3; attempt++) {
166
- const cur = await this.read(file);
202
+ // `known`: the lock as the status cache has it, the first try's expected value (Redis diet Decision 6).
203
+ const cur = attempt === 0 && known !== undefined ? known : await this.read(file);
167
204
  if (!cur || !Locks.mine(cur.lock, tab, person)) return cur;
168
205
  // A poll in flight when the tab deleted the document must not un-release its lock (M5a).
169
206
  const next = { ...cur.lock, beat: this.now(), releasing: cur.lock.deleted ? cur.lock.releasing : null };
170
207
  const raw = JSON.stringify(next);
171
- if (await this.redis.compareAndSet(this.key(file), cur.raw, raw, { px: this.timings.autosaveTtlMs })) return { raw, lock: next };
208
+ if (await this.swap(file, cur.raw, raw)) return { raw, lock: next };
172
209
  }
173
210
  return this.read(file);
174
211
  }
175
212
 
213
+ /**
214
+ * Redis diet Decision 6: does the status poll need to beat for this tab? Only when the
215
+ * lock (as the cache has it) is the tab's own and its last beat is `beatEveryMs` old
216
+ * (a lapsed one is older still: it's taken back), or it's marked releasing and not
217
+ * deleted (a tab back from `pagehide`: the beat clears it). A lock that isn't the
218
+ * tab's, or none, needs no beat: the beat takes nothing the tab never held.
219
+ */
220
+ needsBeat(cached: ReadLock | null, tab: string, person: Person): boolean {
221
+ if (!cached || !Locks.mine(cached.lock, tab, person)) return false;
222
+ if (cached.lock.releasing !== null && !cached.lock.deleted) return true;
223
+ return this.now() - cached.lock.beat >= this.timings.beatEveryMs;
224
+ }
225
+
176
226
  /** Edit here: move your own lock to this tab at once, or take a free one. Someone else's refuses. */
177
227
  async take(file: string, tab: string, person: Person): Promise<AcquireResult> {
178
228
  for (let attempt = 0; attempt < 5; attempt++) {
@@ -218,7 +268,10 @@ export class Locks {
218
268
  /** Delete a lock that no longer holds (the sweep, after a commit). */
219
269
  async dropIfFree(file: string): Promise<void> {
220
270
  const cur = await this.read(file);
221
- if (cur && !(await this.live(cur, file))) await this.redis.compareAndDelete(this.key(file), cur.raw);
271
+ if (cur && !(await this.live(cur, file))) {
272
+ if (await landed(this.cache, this.key(file), () => this.redis.compareAndDelete(this.key(file), cur.raw))) this.cache?.string(this.key(file), null);
273
+ else this.cache?.drop(this.key(file));
274
+ }
222
275
  }
223
276
 
224
277
  view(file: string, lock: LockRecord, tab: string | null, person: Person): LockView {
@@ -238,19 +291,28 @@ export class Locks {
238
291
 
239
292
  // ── Presence ────────────────────────────────────────────────────────────────
240
293
 
241
- /** Record a poll: this tab, this person, this document (or none). */
242
- async present(tab: string, person: Person, file: string | null): Promise<void> {
243
- const record: PresenceRecord = { person, file, at: this.now() };
244
- await this.redis.hset(this.presenceKey(), { [tab]: JSON.stringify(record) });
294
+ /**
295
+ * Record a poll: this tab, this person, this document (or none). `intervalMs`: how long
296
+ * until the tab's next poll; the record holds for twice that plus 3 s (Decision 9).
297
+ */
298
+ async present(tab: string, person: Person, file: string | null, intervalMs = this.timings.pollMs): Promise<void> {
299
+ const record: PresenceRecord = { person, file, at: this.now(), hold: 2 * intervalMs + PRESENCE_SLACK_MS };
300
+ const fields = { [tab]: JSON.stringify(record) };
301
+ await landed(this.cache, this.presenceKey(), () => this.redis.hset(this.presenceKey(), fields));
302
+ this.cache?.hash(this.presenceKey(), fields);
245
303
  }
246
304
 
247
- /** Who is on which document now (polled with it in the last `presenceMs`). Long-gone tabs are pruned. */
305
+ /** Who is on which document now (polled with it within the record's hold). Long-gone tabs are pruned. */
248
306
  async presence(): Promise<PresenceView[]> {
249
- const all = await this.redis.hgetall(this.presenceKey());
307
+ return this.presenceFrom(Object.entries(await this.redis.hgetall(this.presenceKey())));
308
+ }
309
+
310
+ /** `presence()` over the hash's fields as read (the status cache's, on a poll). */
311
+ async presenceFrom(fields: Iterable<readonly [string, string]>): Promise<PresenceView[]> {
250
312
  const now = this.now();
251
313
  const byFile = new Map<string, Person[]>();
252
314
  const gone: string[] = [];
253
- for (const [tab, raw] of Object.entries(all)) {
315
+ for (const [tab, raw] of fields) {
254
316
  let r: PresenceRecord;
255
317
  try {
256
318
  r = JSON.parse(raw) as PresenceRecord;
@@ -258,8 +320,9 @@ export class Locks {
258
320
  gone.push(tab);
259
321
  continue;
260
322
  }
261
- if (now - r.at > this.timings.presenceMs) {
262
- if (now - r.at > this.timings.lockLapseMs) gone.push(tab);
323
+ const hold = typeof r.hold === 'number' ? r.hold : this.timings.presenceMs;
324
+ if (now - r.at > hold) {
325
+ if (now - r.at > Math.max(hold, this.timings.lockLapseMs)) gone.push(tab);
263
326
  continue;
264
327
  }
265
328
  if (!r.file) continue;
@@ -267,7 +330,10 @@ export class Locks {
267
330
  if (!people.some((p) => sameEmail(p, r.person))) people.push(r.person);
268
331
  byFile.set(r.file, people);
269
332
  }
270
- if (gone.length) await this.redis.hdel(this.presenceKey(), ...gone);
333
+ if (gone.length) {
334
+ await landed(this.cache, this.presenceKey(), () => this.redis.hdel(this.presenceKey(), ...gone));
335
+ this.cache?.hash(this.presenceKey(), {}, gone);
336
+ }
271
337
  return [...byFile].map(([file, people]) => ({ file, people }));
272
338
  }
273
339
  }