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.
- package/README.md +247 -3
- package/bin/caracms.mjs +32 -16
- package/bin/cloudflare-ips.mjs +38 -0
- package/bin/gate.mjs +277 -4
- package/bin/init.mjs +64 -20
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +5 -3
- package/skills/caracms-adopt/look-same.mjs +8 -4
- package/skills/caracms-blocks/SKILL.md +20 -2
- package/skills/caracms-tina/SKILL.md +14 -10
- package/src/components/Head.astro +3 -2
- package/src/content.ts +2 -0
- package/src/editor/client/documents/actions.ts +6 -4
- package/src/editor/client/documents/dialogs.tsx +2 -1
- package/src/editor/client/editor.ts +24 -6
- package/src/editor/client/live/publish.tsx +20 -10
- package/src/editor/client/pane/parts.tsx +34 -2
- package/src/editor/client/state/keeper.ts +51 -6
- package/src/editor/client/state/live.ts +45 -6
- package/src/editor/middleware.ts +3 -2
- package/src/editor/routes/try-again.ts +2 -1
- package/src/editor/server/admin-props.ts +2 -1
- package/src/editor/server/mode.ts +13 -3
- package/src/editor/styles/levels/live.css +1 -1
- package/src/editor/types.ts +25 -6
- package/src/files-route.ts +9 -4
- package/src/forms/endpoint.ts +11 -16
- package/src/head.ts +10 -1
- package/src/index.ts +53 -7
- package/src/live/README.md +4 -2
- package/src/live/auth/auth.ts +4 -1
- package/src/live/auth/guard.ts +3 -2
- package/src/live/auth/index.ts +6 -7
- package/src/live/auth/login.ts +17 -8
- package/src/live/auth/middleware.ts +7 -3
- package/src/live/auth/request.ts +92 -11
- package/src/live/auth/views.ts +9 -4
- package/src/live/autosaves.ts +59 -16
- package/src/live/backend.ts +13 -1
- package/src/live/drafts-store.ts +4 -3
- package/src/live/editor.ts +92 -67
- package/src/live/git.ts +112 -34
- package/src/live/github.ts +128 -13
- package/src/live/locks.ts +92 -26
- package/src/live/mode.ts +131 -18
- package/src/live/notices.ts +100 -0
- package/src/live/redis.ts +161 -39
- package/src/live/runtime.ts +11 -3
- package/src/live/status-cache.ts +219 -0
- package/src/live/status.ts +14 -2
- package/src/live/timings.ts +24 -0
- package/src/media.ts +26 -1
package/src/live/github.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub as the site's App (M4 Phase 1
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
105
|
-
|
|
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.
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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)))
|
|
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
|
-
/**
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
262
|
-
|
|
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)
|
|
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
|
}
|