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.
- 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/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/autosaves.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
175
|
+
const keys = await this.redis.scan(`${this.keyPrefix}*`);
|
|
155
176
|
if (keys.length === 0) return [];
|
|
156
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
227
|
-
else await this.
|
|
228
|
-
} else if (wasLive) await this.
|
|
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.
|
|
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`. */
|
package/src/live/backend.ts
CHANGED
|
@@ -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') };
|
package/src/live/drafts-store.ts
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
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 };
|
package/src/live/editor.ts
CHANGED
|
@@ -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
|
-
|
|
124
|
-
const
|
|
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
|
-
|
|
208
|
-
|
|
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,
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
271
|
-
const lockViews = [...(
|
|
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,
|
|
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
|
-
|
|
334
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
414
|
-
await redis.hset(keys.notices, fields);
|
|
415
|
-
await redis.pexpire(keys.notices, timings.autosaveTtlMs);
|
|
416
|
-
}
|
|
443
|
+
await noticeStore.addStood(stood);
|
|
417
444
|
}
|
|
418
|
-
|
|
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:
|
|
453
|
+
if (!settings.deployHook && !settings.railway) return json(409, { ok: false, error: NO_REDEPLOYER_REASON } satisfies TryAgainResponse);
|
|
428
454
|
const st = await publishView();
|
|
429
|
-
|
|
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
|
-
|
|
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
|
|