caracms 0.8.0 → 0.8.2
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 +23 -2
- package/package.json +1 -1
- package/skills/caracms-hosting/SKILL.md +1 -1
- package/src/body.ts +55 -0
- package/src/cli/validate.ts +1 -1
- package/src/cloud-middleware.ts +39 -3
- package/src/content.ts +15 -6
- package/src/document-problems.ts +48 -0
- package/src/editor/client/collections/CollectionGrid.tsx +2 -2
- package/src/editor/client/collections/CollectionTable.tsx +3 -3
- package/src/editor/client/documents/DocMenu.tsx +56 -1
- package/src/editor/client/documents/actions.ts +30 -1
- package/src/editor/client/documents/dialogs.tsx +61 -3
- package/src/editor/client/editor.ts +98 -2
- package/src/editor/client/fields/LockupField.tsx +4 -4
- package/src/editor/client/inbox/InboxStage.tsx +25 -1
- package/src/editor/client/live/notices.tsx +19 -0
- package/src/editor/client/live/publish.tsx +58 -2
- package/src/editor/client/nav/DocRow.tsx +4 -4
- package/src/editor/client/pane/levels/CollectionLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentLevel.tsx +2 -1
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +2 -2
- package/src/editor/client/pane/levels/FormLevel.tsx +33 -6
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +19 -2
- package/src/editor/client/pane/parts.tsx +67 -4
- package/src/editor/client/pane/switcher.tsx +4 -4
- package/src/editor/client/shell/Bar.tsx +11 -0
- package/src/editor/client/state/keeper.ts +28 -4
- package/src/editor/client/state/live.ts +18 -1
- package/src/editor/routes/_live.ts +1 -1
- package/src/editor/routes/admin.astro +9 -0
- package/src/editor/routes/discard.ts +11 -0
- package/src/editor/server/admin-props.ts +18 -2
- package/src/editor/server/arrange.ts +4 -4
- package/src/editor/server/create.ts +4 -4
- package/src/editor/server/drafts-store.ts +2 -2
- package/src/editor/server/empty-site.ts +23 -0
- package/src/editor/server/media/cloud-mux.ts +4 -1
- package/src/editor/server/media/upload.ts +2 -41
- package/src/editor/server/move.ts +32 -4
- package/src/editor/server/write.ts +4 -4
- package/src/editor/styles/levels/live.css +2 -0
- package/src/editor/types.ts +92 -4
- package/src/forms/cloud-store.ts +4 -1
- package/src/forms/inbox.ts +10 -5
- package/src/forms/store.ts +14 -2
- package/src/index.ts +23 -8
- package/src/live/auth/login.ts +4 -3
- package/src/live/auth/views.ts +8 -1
- package/src/live/autosaves.ts +94 -6
- package/src/live/cloud.ts +64 -20
- package/src/live/drafts-store.ts +17 -6
- package/src/live/editor.ts +232 -16
- package/src/live/git.ts +492 -22
- package/src/live/github.ts +84 -17
- package/src/live/locks.ts +22 -0
- package/src/live/messages.ts +49 -2
- package/src/live/notices.ts +5 -1
- package/src/live/runtime.ts +2 -1
- package/src/live/status.ts +5 -0
- package/src/live/sweep.ts +44 -1
- package/src/live/webhook.ts +8 -3
package/src/live/cloud.ts
CHANGED
|
@@ -12,11 +12,18 @@
|
|
|
12
12
|
* - `cloudSettings()`: the site's repo from those settings (`github.repo`; null is
|
|
13
13
|
* `no_repo`, with the cloud's own sentence). Only the editor needs a repo.
|
|
14
14
|
* - `cloudToken()`: `POST /v1/github/token`, a fresh installation token on the site's repo,
|
|
15
|
-
* about an hour long. `GitHubClient` calls it and refreshes five minutes early.
|
|
15
|
+
* about an hour long. `GitHubClient` calls it and refreshes five minutes early. It waits
|
|
16
|
+
* 35 s (`CLOUD_TOKEN_TIMEOUT_MS`), every other call 20 s (`CLOUD_TIMEOUT_MS`).
|
|
17
|
+
* - `confirmCloudRepo()`: a token answer naming another repo is a rename or transfer on
|
|
18
|
+
* GitHub (the cloud's M2b: "A `full_name` change GitHub reports updates the site's repo").
|
|
19
|
+
* The settings are read again; when they name it too, the held copy takes the new repo
|
|
20
|
+
* and `GitHubClient` carries on under it.
|
|
16
21
|
* - **A refusal** (any answer that isn't 2xx, carrying `{ error, message }`) throws a
|
|
17
22
|
* `CloudRefusal` with the cloud's `message` as it came, and `installUrl` when there is
|
|
18
23
|
* one. A cloud that can't be reached, or answers with something else, throws a
|
|
19
|
-
* `CloudRefusal` whose `error` is `unreachable`, with the package's own sentence
|
|
24
|
+
* `CloudRefusal` whose `error` is `unreachable`, with the package's own sentence and a
|
|
25
|
+
* `detail` for logs (timed out, the connection failed, or the status of an answer that
|
|
26
|
+
* wasn't the API's).
|
|
20
27
|
*
|
|
21
28
|
* - `cloudUnavailable()`: the sign-in guard's 503 for a refusal at start (Decision 14),
|
|
22
29
|
* the sentence escaped and the install link a link.
|
|
@@ -33,19 +40,37 @@ export const CLOUD_KEY_SHAPE = /^cck_[A-Za-z0-9_-]{43}$/;
|
|
|
33
40
|
export const CLOUD_UNREACHABLE = "CaraCMS Cloud couldn't be reached, so the editor can't start. Try again in a minute.";
|
|
34
41
|
/** M3 Decision 20: the build's own sentence for a cloud it can't reach. */
|
|
35
42
|
export const CLOUD_UNREACHABLE_BUILD = "CaraCMS Cloud couldn't be reached, so the site can't be built. Try again in a minute.";
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The cloud's own `no_repo` sentence, word for word (caracms-cloud `convex/http.ts`, the token
|
|
45
|
+
* route), for `github.repo: null` in the settings, which carry no sentence of their own. The
|
|
46
|
+
* token route's `no_repo` refusal is shown as it came (`cloudCall`); this is only the copy for
|
|
47
|
+
* the settings (caracms-cloud adversarial review, seam note: it was M1's "Ask CaraCMS to set one").
|
|
48
|
+
*/
|
|
49
|
+
export const CLOUD_NO_REPO = 'This site has no GitHub repo in CaraCMS Cloud yet. A team owner can pick one in app.caracms.com.';
|
|
50
|
+
/**
|
|
51
|
+
* A token answer named another repo than the one the server is on, and the settings, read
|
|
52
|
+
* again, don't say so yet (or couldn't be read): this refresh fails, and the next one asks
|
|
53
|
+
* again (caracms-cloud adversarial review, finding 7; once the settings agree, the new name
|
|
54
|
+
* is taken, github.ts).
|
|
55
|
+
*/
|
|
56
|
+
export const CLOUD_REPO_CHANGED = (repo: string) =>
|
|
57
|
+
`This site's repo is now ${repo} on GitHub, but CaraCMS Cloud's settings don't say so yet, so changes can't be saved to GitHub for now. The editor tries again on its own; if this stays, ask CaraCMS to check the site.`;
|
|
40
58
|
|
|
41
59
|
/** How long a call to the cloud may take before it counts as unreachable. */
|
|
42
60
|
export const CLOUD_TIMEOUT_MS = 20_000;
|
|
61
|
+
/**
|
|
62
|
+
* The token call's own, longer timeout (caracms-cloud adversarial review, seam note): the
|
|
63
|
+
* cloud's worst case, an id mint refused and then the installation found and a token minted
|
|
64
|
+
* by name (10 s to GitHub each), can pass 20 s, which would read as `unreachable`.
|
|
65
|
+
*/
|
|
66
|
+
export const CLOUD_TOKEN_TIMEOUT_MS = 35_000;
|
|
43
67
|
|
|
44
68
|
export interface CloudConnection {
|
|
45
69
|
key: string;
|
|
46
70
|
url: string;
|
|
47
71
|
/** For tests. Default: global fetch. */
|
|
48
72
|
fetch?: typeof fetch;
|
|
73
|
+
/** Default: `CLOUD_TIMEOUT_MS`, or `CLOUD_TOKEN_TIMEOUT_MS` for `cloudToken()`. */
|
|
49
74
|
timeoutMs?: number;
|
|
50
75
|
}
|
|
51
76
|
|
|
@@ -66,27 +91,30 @@ export function isCloudRefusal(value: unknown): value is CloudRefusal {
|
|
|
66
91
|
/** The cloud said no (or couldn't be reached). `message` is safe to show and to log. */
|
|
67
92
|
export class CloudRefusal extends Error {
|
|
68
93
|
readonly [REFUSAL_BRAND] = true;
|
|
69
|
-
/** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed
|
|
94
|
+
/** The cloud's `error` (`no_repo`, `app_not_installed`, `unknown_key`…), or the package's `unreachable` / `repo_changed` (finding 7: a rename the settings don't confirm yet). */
|
|
70
95
|
readonly error: string;
|
|
71
96
|
/** An `https://` link the message names (the App's install page), or null. */
|
|
72
97
|
readonly installUrl: string | null;
|
|
73
|
-
|
|
98
|
+
/** For `unreachable`, what happened, for a log line (never a secret): `timed out`, `answered HTTP 502`… */
|
|
99
|
+
readonly detail: string | null;
|
|
100
|
+
constructor(error: string, message: string, installUrl: string | null = null, detail: string | null = null) {
|
|
74
101
|
super(message);
|
|
75
102
|
this.name = 'CloudRefusal';
|
|
76
103
|
this.error = error;
|
|
77
104
|
this.installUrl = installUrl;
|
|
105
|
+
this.detail = detail;
|
|
78
106
|
}
|
|
79
107
|
}
|
|
80
108
|
|
|
81
|
-
const unreachable = () => new CloudRefusal('unreachable', CLOUD_UNREACHABLE);
|
|
109
|
+
const unreachable = (detail: string | null = null) => new CloudRefusal('unreachable', CLOUD_UNREACHABLE, null, detail);
|
|
82
110
|
|
|
83
111
|
/**
|
|
84
112
|
* One call to the API: `path` with its query, a JSON `body` for a POST. The answer's JSON
|
|
85
113
|
* object, or a `CloudRefusal` (the cloud's own, or `unreachable`). The media bucket
|
|
86
114
|
* (editor/server/media/cloud-bucket.ts) calls it too.
|
|
87
115
|
*/
|
|
88
|
-
export async function cloudCall(connection: CloudConnection, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
89
|
-
let res: Response;
|
|
116
|
+
export async function cloudCall(connection: CloudConnection, method: 'GET' | 'POST', path: string, body?: unknown, defaultTimeoutMs = CLOUD_TIMEOUT_MS): Promise<Record<string, unknown>> {
|
|
117
|
+
let res: Response | undefined;
|
|
90
118
|
let answer: unknown;
|
|
91
119
|
try {
|
|
92
120
|
res = await (connection.fetch ?? fetch)(`${connection.url}${path}`, {
|
|
@@ -98,17 +126,18 @@ export async function cloudCall(connection: CloudConnection, method: 'GET' | 'PO
|
|
|
98
126
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
99
127
|
},
|
|
100
128
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
101
|
-
signal: AbortSignal.timeout(connection.timeoutMs ??
|
|
129
|
+
signal: AbortSignal.timeout(connection.timeoutMs ?? defaultTimeoutMs),
|
|
102
130
|
});
|
|
103
131
|
answer = await res.json();
|
|
104
|
-
} catch {
|
|
105
|
-
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const timedOut = (error as { name?: unknown } | null)?.name === 'TimeoutError';
|
|
134
|
+
throw unreachable(timedOut ? 'timed out' : res ? `answered HTTP ${res.status}, not with JSON` : 'the connection failed');
|
|
106
135
|
}
|
|
107
136
|
const object = answer && typeof answer === 'object' && !Array.isArray(answer) ? (answer as Record<string, unknown>) : null;
|
|
108
|
-
if (!object) throw unreachable();
|
|
137
|
+
if (!object) throw unreachable(`answered HTTP ${res.status}, not with an object`);
|
|
109
138
|
if (!res.ok) {
|
|
110
139
|
const { error, message, installUrl } = object;
|
|
111
|
-
if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable();
|
|
140
|
+
if (typeof error !== 'string' || typeof message !== 'string' || !message.trim()) throw unreachable(`answered HTTP ${res.status} without a reason`);
|
|
112
141
|
throw new CloudRefusal(error, message, typeof installUrl === 'string' && /^https:\/\//i.test(installUrl) ? installUrl : null);
|
|
113
142
|
}
|
|
114
143
|
return object;
|
|
@@ -147,9 +176,9 @@ export async function fetchCloudSettings(connection: CloudConnection): Promise<C
|
|
|
147
176
|
const github = answer.github as { repo?: unknown } | undefined;
|
|
148
177
|
const media = answer.media as { bucket?: unknown; imageHost?: unknown } | undefined;
|
|
149
178
|
const pair = answer.imgproxy as { key?: unknown; salt?: unknown } | undefined;
|
|
150
|
-
if (!github || typeof github !== 'object') throw unreachable();
|
|
179
|
+
if (!github || typeof github !== 'object') throw unreachable('answered settings without github');
|
|
151
180
|
const m = typeof github.repo === 'string' ? REPO.exec(github.repo) : null;
|
|
152
|
-
if (github.repo !== null && !m) throw unreachable();
|
|
181
|
+
if (github.repo !== null && !m) throw unreachable('answered settings with a malformed repo');
|
|
153
182
|
const folder = str(answer.folder);
|
|
154
183
|
const bucket = str(media && typeof media === 'object' ? media.bucket : null);
|
|
155
184
|
const imageHost = str(media && typeof media === 'object' ? media.imageHost : null);
|
|
@@ -221,14 +250,29 @@ export function heldCloudSettings(connection: Pick<CloudConnection, 'key' | 'url
|
|
|
221
250
|
return h.failure ? { kind: 'failed', refusal: h.failure } : null;
|
|
222
251
|
}
|
|
223
252
|
|
|
253
|
+
/**
|
|
254
|
+
* caracms-cloud adversarial review, finding 7: a token answer named `fullName`, not the repo
|
|
255
|
+
* the server is on. `GET /v1/settings` afresh: when it names `fullName` too, that repo is
|
|
256
|
+
* returned and the held copy takes it (a start later in this process begins on it; the media
|
|
257
|
+
* are left as held). Null when the settings name another repo, or none, or can't be read.
|
|
258
|
+
*/
|
|
259
|
+
export async function confirmCloudRepo(connection: CloudConnection, fullName: string): Promise<{ owner: string; repo: string } | null> {
|
|
260
|
+
const settings = await fetchCloudSettings(connection).catch(() => null);
|
|
261
|
+
const repo = settings?.repo ?? null;
|
|
262
|
+
if (!repo || `${repo.owner}/${repo.repo}`.toLowerCase() !== fullName.toLowerCase()) return null;
|
|
263
|
+
const h = held()[HELD];
|
|
264
|
+
if (h?.value && h.key === connection.key && h.url === connection.url) h.value = { ...h.value, repo };
|
|
265
|
+
return repo;
|
|
266
|
+
}
|
|
267
|
+
|
|
224
268
|
/** Forget the held settings (tests). */
|
|
225
269
|
export function resetCloudSettings(): void {
|
|
226
270
|
delete held()[HELD];
|
|
227
271
|
}
|
|
228
272
|
|
|
229
|
-
/** A fresh installation token on the site's repo, from `POST /v1/github/token
|
|
273
|
+
/** A fresh installation token on the site's repo, from `POST /v1/github/token` (35 s unless `timeoutMs` says otherwise). */
|
|
230
274
|
export async function cloudToken(connection: CloudConnection): Promise<{ token: string; expiresAt: number; repo: string }> {
|
|
231
|
-
const answer = await cloudCall(connection, 'POST', '/v1/github/token');
|
|
275
|
+
const answer = await cloudCall(connection, 'POST', '/v1/github/token', undefined, CLOUD_TOKEN_TIMEOUT_MS);
|
|
232
276
|
const { token, expiresAt, repo } = answer;
|
|
233
277
|
const at = typeof expiresAt === 'string' ? Date.parse(expiresAt) : Number.NaN;
|
|
234
278
|
if (typeof token !== 'string' || !token || Number.isNaN(at) || typeof repo !== 'string') throw unreachable();
|
package/src/live/drafts-store.ts
CHANGED
|
@@ -80,12 +80,16 @@ export function unavailable(error: unknown): WriteRefused {
|
|
|
80
80
|
* expire, so a file with none was never changed in the editor or Redis lost its data, and
|
|
81
81
|
* the server can't tell which: the keeper holds edits on unknown rather than re-send them.
|
|
82
82
|
*/
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* `known`: the file's change record as the caller already read it (the status poll's cache); otherwise read here.
|
|
85
|
+
* Discard changes: its `discard` is `discarded`, so a browser drops edits held from before it.
|
|
86
|
+
*/
|
|
84
87
|
export async function lastChangeOf(parts: Pick<LiveStoreParts, 'git' | 'autosaves'>, file: string, known?: ChangeRecord | null): Promise<LastChange> {
|
|
85
88
|
const record = known !== undefined ? known : await parts.autosaves.changed(file);
|
|
86
|
-
|
|
89
|
+
const discarded = record?.discard ? { discarded: { by: record.discard.by, at: record.discard.at } } : {};
|
|
90
|
+
if (record) return { by: record.by, version: record.version, at: record.at, ...discarded };
|
|
87
91
|
const editor = parts.git.waiting().find((w) => w.file === file)?.editors[0];
|
|
88
|
-
return editor ? { by: editor.person, version: null, at: editor.at } : { by: null, version: null, at: null, known: false };
|
|
92
|
+
return editor ? { by: editor.person, version: null, at: editor.at, ...discarded } : { by: null, version: null, at: null, known: false, ...discarded };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
@@ -400,7 +404,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
400
404
|
guard(async () => {
|
|
401
405
|
const file = posix(relative(site.root, resolveDocumentFile(site, collection, path)));
|
|
402
406
|
const save = (await autosaves.get(file))?.value ?? null;
|
|
403
|
-
const read = await readDocument(site, collection, path, overlay(save ? [save] : []));
|
|
407
|
+
const read = await readDocument(site, collection, path, overlay(save ? [save] : []), { untitled: true });
|
|
404
408
|
return { ...read, mtimeMs: save?.updatedAt ?? 0, version: fileVersion(read.text), lastChange: await lastChangeOf(parts, file) };
|
|
405
409
|
}),
|
|
406
410
|
|
|
@@ -415,8 +419,15 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
415
419
|
const current = git.read(request.file);
|
|
416
420
|
// It exists: in the copy, or as a new document's autosave (M5a); a "gone" autosave is a deletion.
|
|
417
421
|
const missing = () => new WriteRefused(404, `${site.collections[target.collection]!.label}: no document "${target.path}".`);
|
|
418
|
-
const saved = await autosaves.
|
|
422
|
+
const [saved, changed] = await autosaves.getWithChange(request.file);
|
|
419
423
|
if (saved?.value.gone || (current === null && !saved)) throw missing();
|
|
424
|
+
// Discard changes: a tab that had the document when someone put it back to what's live
|
|
425
|
+
// can't write until it has let go of its copy (it's stale: its edits would come back).
|
|
426
|
+
const discard = changed?.discard;
|
|
427
|
+
if (discard && discard.tabs.includes(tab)) {
|
|
428
|
+
const title = saved?.value.text ? titleOf(request.file, saved.value.text) : titleOf(request.file, current);
|
|
429
|
+
throw new WriteRefused(409, `${displayName(discard.by)} discarded the unpublished changes to ${title}, so these edits weren't kept.`, { code: 'discarded', by: discard.by, at: discard.at });
|
|
430
|
+
}
|
|
420
431
|
|
|
421
432
|
const text = writeText(site, target.collection, request);
|
|
422
433
|
|
|
@@ -494,7 +505,7 @@ export function liveDraftsStore(parts: LiveStoreParts): DraftsStore {
|
|
|
494
505
|
if (await autosaves.putIf(existing, next)) break;
|
|
495
506
|
}
|
|
496
507
|
const version = fileVersion(text);
|
|
497
|
-
await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString() });
|
|
508
|
+
await autosaves.setChanged(request.file, { by: person, version, at: new Date(t).toISOString(), ...autosaves.carryDiscard(changed) });
|
|
498
509
|
if (context?.release) await locks.release(request.file, tab, person);
|
|
499
510
|
sweep.poke();
|
|
500
511
|
return { ok: true, file: request.file, changed: text !== (existing?.value.text ?? current), mtimeMs: t, version };
|
package/src/live/editor.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Live mode's editor server (M4 Phase 2): the live drafts store, the sweep, locks and
|
|
3
3
|
* presence, and the handlers behind `/_caracms/status`, `lock`, `publish`, `history`,
|
|
4
|
-
* `try-again` and `
|
|
4
|
+
* `try-again`, `notice` and `discard` (the routes in ../editor/routes/ are one line each).
|
|
5
|
+
*
|
|
6
|
+
* **Discard changes** (`discard`): one waiting document back to what's live. Its autosave,
|
|
7
|
+
* committed record and move records go and its files on `drafts` are committed as `main` has
|
|
8
|
+
* them (git.ts → `restoreDrafts`), all under the sweep's locks (sweep.ts → `exclusively`), so
|
|
9
|
+
* no tick commits the edit meanwhile. A moved document first moves back through the move
|
|
10
|
+
* engine (its links and redirects too), unredirected. Every other tab that had it open or
|
|
11
|
+
* was editing it is listed in its discard record (autosaves.ts): their writes are refused
|
|
12
|
+
* until they let go (`{ file, seen: true }`), and each other person who edited it or had it
|
|
13
|
+
* open gets a "discarded" notice. Refused while someone else is editing it now (a fresh
|
|
14
|
+
* lock), while a Publish runs, and for nothing waiting.
|
|
5
15
|
*
|
|
6
16
|
* One per process, beside the runtime (`liveEditor`), or made by tests
|
|
7
17
|
* (`createLiveEditor`) on a runtime from `createLiveRuntime` with the in-memory
|
|
@@ -32,8 +42,11 @@ import type { DraftsStore } from '../editor/server/drafts-store.ts';
|
|
|
32
42
|
import { handleCreate, handleDelete } from '../editor/server/create.ts';
|
|
33
43
|
import { checkOrigin, confinePath, fileVersion, handleWrite, tabOf, WriteRefused } from '../editor/server/write.ts';
|
|
34
44
|
import { missingAltIn } from '../editor/server/media/alt.ts';
|
|
45
|
+
import { settingsWithRedirectsBack } from '../editor/server/move.ts';
|
|
46
|
+
import { parseAnyDocument } from '../document.ts';
|
|
35
47
|
import type {
|
|
36
48
|
ApiError,
|
|
49
|
+
DiscardResponse,
|
|
37
50
|
HistoryResponse,
|
|
38
51
|
LockRefusal,
|
|
39
52
|
LockResponse,
|
|
@@ -47,7 +60,7 @@ import type {
|
|
|
47
60
|
} from '../editor/types.ts';
|
|
48
61
|
import { Autosaves, splitMoves } from './autosaves.ts';
|
|
49
62
|
import { lastChangeOf, liveDraftsStore, unavailable } from './drafts-store.ts';
|
|
50
|
-
import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf } from './git.ts';
|
|
63
|
+
import { LiveGit, movesIn, NO_REDEPLOYER_REASON, titleOf, type DraftsLost } from './git.ts';
|
|
51
64
|
import { accessProblemMessage, type GitHubAccessProblem } from './github.ts';
|
|
52
65
|
import { parseRedirects, type Redirect } from '../redirects.ts';
|
|
53
66
|
import { Locks } from './locks.ts';
|
|
@@ -56,7 +69,9 @@ import type { LiveSettings } from './mode.ts';
|
|
|
56
69
|
import { Notices, type StoodNotice } from './notices.ts';
|
|
57
70
|
import { RedisUnavailableError } from './redis.ts';
|
|
58
71
|
import { liveRuntime, type LiveRuntime } from './runtime.ts';
|
|
59
|
-
import { Sweep, type Schedule } from './sweep.ts';
|
|
72
|
+
import { Sweep, SweepBusyError, type Schedule } from './sweep.ts';
|
|
73
|
+
import { readCappedText } from '../body.ts';
|
|
74
|
+
import { textProblems } from '../document-problems.ts';
|
|
60
75
|
|
|
61
76
|
type Handler = (request: Request, person: Person | null) => Promise<Response>;
|
|
62
77
|
|
|
@@ -67,8 +82,33 @@ const ACCESS_TITLE: Record<GitHubAccessProblem['kind'], string> = {
|
|
|
67
82
|
permission: 'GitHub refused a change',
|
|
68
83
|
expiring: "This site's GitHub token expires soon",
|
|
69
84
|
cloud: 'No GitHub access through CaraCMS Cloud',
|
|
85
|
+
// caracms-cloud adversarial review, finding 7: information, not a problem.
|
|
86
|
+
renamed: "This site's repo has a new name",
|
|
70
87
|
};
|
|
71
88
|
|
|
89
|
+
/** Finding 4: the save bar's sentence while draft commits keep failing. */
|
|
90
|
+
export const COMMIT_TROUBLE_MESSAGE = "Saved here, but not reaching GitHub. Ask your developer to check the site's GitHub access.";
|
|
91
|
+
|
|
92
|
+
/** Finding 5: the note's sentence once `drafts` had to be made again from `main`. */
|
|
93
|
+
export function draftsLostMessage(lost: DraftsLost): string {
|
|
94
|
+
const lastCommit = lost.commit ? `its last commit (${lost.commit.slice(0, 7)}) couldn't be found` : 'no record of its last commit was kept';
|
|
95
|
+
return `The drafts branch went missing on GitHub and ${lastCommit}, so it was made again from the live site. Changes saved since the last Publish are gone from the editor. Ask your developer to protect the drafts branch from deletion.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Force-push note (caracms-cloud adversarial review): `main` was rolled back past the last Publish. */
|
|
99
|
+
export const MAIN_ROLLED_BACK_TITLE = 'The live site was rolled back on GitHub';
|
|
100
|
+
export const MAIN_ROLLED_BACK_MESSAGE =
|
|
101
|
+
"The changes waiting here include what was rolled back, and publishing will put it back. Discard what you don't want first: each waiting document has Discard… in the Publish list.";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A Redis outage before a Publish's refs move (finding 6): nothing went out, and nothing is
|
|
105
|
+
* held in the browser either (that's the write route's sentence, `unavailable`).
|
|
106
|
+
*/
|
|
107
|
+
export function publishUnavailable(error: unknown): WriteRefused {
|
|
108
|
+
const what = error instanceof Error ? error.message : String(error);
|
|
109
|
+
return new WriteRefused(503, `The server can't reach its store right now, so it couldn't publish. Your changes are still waiting: try again in a minute (${what}).`, { code: 'unavailable' });
|
|
110
|
+
}
|
|
111
|
+
|
|
72
112
|
export interface LiveEditor {
|
|
73
113
|
readonly runtime: Pick<LiveRuntime, 'site' | 'settings' | 'git' | 'redis' | 'timings' | 'now'>;
|
|
74
114
|
readonly autosaves: Autosaves;
|
|
@@ -85,6 +125,8 @@ export interface LiveEditor {
|
|
|
85
125
|
history: Handler;
|
|
86
126
|
tryAgain: Handler;
|
|
87
127
|
notice: Handler;
|
|
128
|
+
/** Discard changes: POST /_caracms/discard `{ file }` (types.ts → DiscardRequest). */
|
|
129
|
+
discard: Handler;
|
|
88
130
|
}
|
|
89
131
|
|
|
90
132
|
export interface LiveEditorOptions {
|
|
@@ -99,8 +141,8 @@ const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Cont
|
|
|
99
141
|
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: HEADERS });
|
|
100
142
|
const sameEmail = (a: Person, b: Person) => a.email.toLowerCase() === b.email.toLowerCase();
|
|
101
143
|
|
|
102
|
-
/** A notice id: `kept:<file>:<commit>` or `
|
|
103
|
-
const NOTICE_ID = /^(kept|stood):[^\s\p{C}]+$/u;
|
|
144
|
+
/** A notice id: `kept:<file>:<commit>`, `stood:<commit>:<file>:<email>` or `discarded:<at>:<file>:<email>`, no spaces or controls, capped. */
|
|
145
|
+
const NOTICE_ID = /^(kept|stood|discarded):[^\s\p{C}]+$/u;
|
|
104
146
|
const NOTICE_ID_MAX = 512;
|
|
105
147
|
|
|
106
148
|
/** Map a site file to its document (collection and path), or nulls. */
|
|
@@ -152,9 +194,9 @@ export async function createLiveEditor(
|
|
|
152
194
|
// hook above in place, so a merge from main commits every pending autosave first.
|
|
153
195
|
await git.check({ force: true }).catch((error: unknown) => log(`check at startup: ${error instanceof Error ? error.message : String(error)}`));
|
|
154
196
|
|
|
155
|
-
/** Common gate: a person, the same origin for anything but GET, Redis outages as 503. */
|
|
197
|
+
/** Common gate: a person, the same origin for anything but GET, Redis outages as 503 (`outage`: that route's sentence for one). */
|
|
156
198
|
const handler =
|
|
157
|
-
(methods: string[], fn: (request: Request, person: Person) => Promise<Response
|
|
199
|
+
(methods: string[], fn: (request: Request, person: Person) => Promise<Response>, outage: (error: unknown) => WriteRefused = unavailable): Handler =>
|
|
158
200
|
async (request, person) => {
|
|
159
201
|
try {
|
|
160
202
|
if (!methods.includes(request.method)) {
|
|
@@ -167,7 +209,7 @@ export async function createLiveEditor(
|
|
|
167
209
|
if (request.method !== 'GET') checkOrigin(request);
|
|
168
210
|
return await fn(request, person);
|
|
169
211
|
} catch (error) {
|
|
170
|
-
const refused = error instanceof RedisUnavailableError ?
|
|
212
|
+
const refused = error instanceof RedisUnavailableError ? outage(error) : error;
|
|
171
213
|
if (refused instanceof WriteRefused) return json(refused.status, { error: refused.message, ...refused.body });
|
|
172
214
|
log(`${new URL(request.url).pathname} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
173
215
|
return json(502, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` } satisfies ApiError);
|
|
@@ -175,8 +217,9 @@ export async function createLiveEditor(
|
|
|
175
217
|
};
|
|
176
218
|
|
|
177
219
|
const readBody = async (request: Request): Promise<Record<string, unknown>> => {
|
|
178
|
-
|
|
179
|
-
|
|
220
|
+
// caracms-cloud adversarial review, finding 10: 16 KB counted as it's read, never the whole body first.
|
|
221
|
+
const raw = await readCappedText(request, 16_384);
|
|
222
|
+
if (raw === null) throw new WriteRefused(413, 'That body is too large.');
|
|
180
223
|
try {
|
|
181
224
|
const body = JSON.parse(raw || '{}') as unknown;
|
|
182
225
|
if (body && typeof body === 'object' && !Array.isArray(body)) return body as Record<string, unknown>;
|
|
@@ -320,7 +363,11 @@ export async function createLiveEditor(
|
|
|
320
363
|
// autosave's text, else the copy's). A deleted document has none.
|
|
321
364
|
const textNow = new Map(saves.map((a) => [a.file, a.text]));
|
|
322
365
|
for (const w of waiting.values()) {
|
|
323
|
-
|
|
366
|
+
const text = textNow.has(w.file) ? (textNow.get(w.file) ?? null) : git.read(w.file);
|
|
367
|
+
w.missingAlt = w.change === 'deleted' ? [] : missingAltOf(w.file, w.collection, text);
|
|
368
|
+
// caracms-cloud adversarial review, finding 15: what Publish will leave waiting, by the same rule (git.ts → heldBack).
|
|
369
|
+
const [problem] = w.change !== 'deleted' && w.collection !== null && text !== null ? textProblems(text) : [];
|
|
370
|
+
if (problem) w.heldBack = problem.reason;
|
|
324
371
|
}
|
|
325
372
|
// Pre-M7 item 8: a moved document is one entry, on its new file, with its old address.
|
|
326
373
|
// Read from the moves as recorded, not Site Settings' redirects: removing the redirect leaves it a move.
|
|
@@ -359,9 +406,16 @@ export async function createLiveEditor(
|
|
|
359
406
|
if (save ? save.gone : git.read(file) === null) {
|
|
360
407
|
deleted = record?.gone ? { by: record.by, at: record.at } : { by: null, at: null };
|
|
361
408
|
}
|
|
362
|
-
|
|
409
|
+
// Discard changes: this tab had it when someone put it back to what's live (its copy is stale).
|
|
410
|
+
const discard = record?.discard;
|
|
411
|
+
const discarded = discard && tab && discard.tabs.includes(tab) ? { by: discard.by, at: discard.at } : null;
|
|
412
|
+
open = { file, state: Locks.state(lock), lock, lastChange: await lastChangeOf({ git, autosaves }, file, record), deleted, discarded };
|
|
363
413
|
}
|
|
364
414
|
const access = git.backend.accessProblem();
|
|
415
|
+
// Finding 4: only while something is waiting to reach GitHub.
|
|
416
|
+
const trouble = saves.length ? git.commitTrouble() : null;
|
|
417
|
+
const lost = git.draftsLost();
|
|
418
|
+
const rolledBack = git.mainRolledBack();
|
|
365
419
|
const body: StatusResponse = {
|
|
366
420
|
mode: 'live',
|
|
367
421
|
you: person,
|
|
@@ -373,11 +427,14 @@ export async function createLiveEditor(
|
|
|
373
427
|
locks: lockViews,
|
|
374
428
|
presence,
|
|
375
429
|
notices: Notices.forPerson(person, git.keptDraftNotices(), titleFor, snap.set(noticeStore.dismissedKey), snap.hash(noticeStore.noticesKey).values()),
|
|
376
|
-
publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: LiveGit.publishRecordFrom(snap.string(git.publishRecordKey)) }),
|
|
430
|
+
publish: await publishView({ running: snap.string(git.publishLockKey) !== null, record: git.publishRecordOr(LiveGit.publishRecordFrom(snap.string(git.publishRecordKey))) }),
|
|
377
431
|
// Runs-anywhere Decision 5: while it holds, not dismissable.
|
|
378
432
|
githubAccess: access
|
|
379
433
|
? { kind: access.kind, title: ACCESS_TITLE[access.kind], message: accessProblemMessage(access), link: access.kind === 'cloud' ? access.installUrl : null }
|
|
380
434
|
: null,
|
|
435
|
+
commitTrouble: trouble ? { since: new Date(trouble.since).toISOString(), failures: trouble.failures, message: COMMIT_TROUBLE_MESSAGE } : null,
|
|
436
|
+
draftsLost: lost ? { title: 'Unpublished changes were lost', message: draftsLostMessage(lost), commit: lost.commit, at: lost.at } : null,
|
|
437
|
+
mainRolledBack: rolledBack ? { title: MAIN_ROLLED_BACK_TITLE, message: MAIN_ROLLED_BACK_MESSAGE, publish: rolledBack.publish, main: rolledBack.main, at: rolledBack.at } : null,
|
|
381
438
|
// M5a Decision 10: from the committed copy, never an autosave.
|
|
382
439
|
brand: editorBrand(brandFrom(git.read(SITE_SETTINGS_FILE), site)),
|
|
383
440
|
// Arrange pages (Decision 18): from the autosaves already in the snapshot, else the copy; no Redis command of its own.
|
|
@@ -428,7 +485,10 @@ export async function createLiveEditor(
|
|
|
428
485
|
const publish = handler(['POST'], async (_request, person) => {
|
|
429
486
|
const before = await locks.presence();
|
|
430
487
|
const work = git.publish(person).then(async (result) => {
|
|
431
|
-
|
|
488
|
+
// Finding 6: the notices are kept in Redis, so with Redis gone they can't be sent; the Publish happened all the same.
|
|
489
|
+
await stoodNotices(result, before, person).catch((error: unknown) => {
|
|
490
|
+
log(`publish: the "published as it stood" notices weren't sent (${error instanceof Error ? error.message : String(error)})`);
|
|
491
|
+
});
|
|
432
492
|
return result;
|
|
433
493
|
});
|
|
434
494
|
// Runs-anywhere (Watch-outs): Cloudflare gives up on an answer after 100 s, and a
|
|
@@ -444,8 +504,17 @@ export async function createLiveEditor(
|
|
|
444
504
|
work.catch((error: unknown) => log(`publish (answered early): ${error instanceof Error ? error.message : String(error)}`));
|
|
445
505
|
return json(200, { result: 'joined', publish: await publishView() } satisfies PublishResponse);
|
|
446
506
|
}
|
|
507
|
+
if (result.kind === 'published') {
|
|
508
|
+
// Finding 6: the record as made, not as Redis has it (it may not have taken it); the lock read is the only Redis call, and its failure isn't the Publish's.
|
|
509
|
+
const running = await redis.get(git.publishLockKey).then(
|
|
510
|
+
(v) => v !== null,
|
|
511
|
+
() => false,
|
|
512
|
+
);
|
|
513
|
+
return json(200, { result: result.kind, publish: await publishView({ running, record: result.record }), held: result.held } satisfies PublishResponse);
|
|
514
|
+
}
|
|
515
|
+
if (result.kind === 'held-back') return json(200, { result: result.kind, publish: await publishView(), held: result.held } satisfies PublishResponse);
|
|
447
516
|
return json(200, { result: result.kind, publish: await publishView() } satisfies PublishResponse);
|
|
448
|
-
});
|
|
517
|
+
}, publishUnavailable);
|
|
449
518
|
|
|
450
519
|
/** "Aoife published Home as it stood at 14:20" for whoever had a published document open (Decision 10 step 1). */
|
|
451
520
|
const stoodNotices = async (result: Awaited<ReturnType<typeof git.publish>>, before: Awaited<ReturnType<typeof locks.presence>>, person: Person) => {
|
|
@@ -493,7 +562,154 @@ export async function createLiveEditor(
|
|
|
493
562
|
return json(200, { ok: true });
|
|
494
563
|
});
|
|
495
564
|
|
|
496
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Discard changes (see the header). `{ file }`: the document's file now (a moved one's new
|
|
567
|
+
* file). `{ file, seen: true }`: this tab has let go of its copy of a discarded document.
|
|
568
|
+
*/
|
|
569
|
+
const discard = handler(['POST'], async (request, person) => {
|
|
570
|
+
const tab = tabOf(request);
|
|
571
|
+
if (!tab) throw new WriteRefused(400, 'Discard requests name their tab (X-Caracms-Tab).', { code: 'no-tab' });
|
|
572
|
+
const body = await readBody(request);
|
|
573
|
+
const extra = Object.keys(body).filter((k) => k !== 'file' && k !== 'seen');
|
|
574
|
+
if (extra.length || typeof body.file !== 'string' || (body.seen !== undefined && body.seen !== true)) {
|
|
575
|
+
throw new WriteRefused(400, 'Malformed body: send { file } (or { file, seen: true }).', { code: 'bad-request' });
|
|
576
|
+
}
|
|
577
|
+
const file = body.file;
|
|
578
|
+
confinePath(site, file);
|
|
579
|
+
if (body.seen === true) {
|
|
580
|
+
await autosaves.ackDiscard(file, tab);
|
|
581
|
+
return json(200, { ok: true });
|
|
582
|
+
}
|
|
583
|
+
if ((await redis.get(git.publishLockKey)) !== null) throw new WriteRefused(409, "A Publish is running. Try again once it's done.", { code: 'publishing' });
|
|
584
|
+
|
|
585
|
+
// What's waiting for it: an autosave, a commit on drafts that main hasn't got, or a move to it.
|
|
586
|
+
const save = (await autosaves.get(file))?.value ?? null;
|
|
587
|
+
const textNow = save ? save.text : git.read(file);
|
|
588
|
+
const fromGit = git.waiting().find((w) => w.file === file) ?? null;
|
|
589
|
+
const moves = await autosaves.moves();
|
|
590
|
+
const from = [...moves].find(([, to]) => to === file)?.[0] ?? null;
|
|
591
|
+
const change = git.changeOf(file, textNow);
|
|
592
|
+
const title = save?.gone ? save.gone.title : textNow !== null ? titleOf(file, textNow) : git.titleFor(file);
|
|
593
|
+
if (!change || (!save && !fromGit && !from)) {
|
|
594
|
+
if (!from && (await autosaves.addresses()).has(file)) {
|
|
595
|
+
throw new WriteRefused(409, `${title} moved with the page that holds it. Discard that page's changes to put it back.`, { code: 'held-move' });
|
|
596
|
+
}
|
|
597
|
+
throw new WriteRefused(404, `${title} has no unpublished changes.`, { code: 'not-waiting' });
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Someone else editing it right now (a fresh lock, not one held only until their autosave is committed).
|
|
601
|
+
const own = [file, ...(from ? [from] : [])];
|
|
602
|
+
for (const f of own) {
|
|
603
|
+
const held = await locks.read(f);
|
|
604
|
+
if (held && !sameEmail(held.lock.person, person) && held.lock.releasing === null && now() - held.lock.beat < timings.lockLapseMs) {
|
|
605
|
+
throw new WriteRefused(409, `${displayName(held.lock.person)} is editing ${title} right now, so its changes can't be discarded yet.`, { code: 'locked' });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const t0 = now();
|
|
610
|
+
let movedBack: DiscardResponse['movedBack'] = null;
|
|
611
|
+
if (from && git.mainHas(from)) {
|
|
612
|
+
// Back to its old address first, through the move engine (its links, the redirects, what
|
|
613
|
+
// moved with it), with no redirect. A swap (it took a live document's place) isn't undone here.
|
|
614
|
+
const there = docOf(site, file);
|
|
615
|
+
const back = docOf(site, from);
|
|
616
|
+
if (git.mainHas(file)) throw new WriteRefused(409, `${title} took the place of a page that's live, so it can't be discarded on its own.`, { code: 'swap' });
|
|
617
|
+
if (!there.collection || !there.path || !back.path || back.collection !== there.collection) {
|
|
618
|
+
throw new WriteRefused(409, `${title} can't go back to its old address from here.`, { code: 'swap' });
|
|
619
|
+
}
|
|
620
|
+
// Any lock someone left on it (only held until their autosave is committed) goes, so the move can take it.
|
|
621
|
+
for (const f of own) {
|
|
622
|
+
const held = await locks.read(f);
|
|
623
|
+
if (held && held.lock.tab !== tab) await locks.drop(held, f);
|
|
624
|
+
}
|
|
625
|
+
const context = { person, tab, release: false, seq: null };
|
|
626
|
+
const moved = await store.move({ collection: there.collection, path: there.path, to: back.path, redirect: false }, context);
|
|
627
|
+
// A redirect the move took out of Site Settings (from the address it took) comes back, as it is live.
|
|
628
|
+
const vacated = moved.moved.filter((m) => m.fromUrl !== m.toUrl).map((m) => m.fromUrl);
|
|
629
|
+
const current = (await autosaves.get(SITE_SETTINGS_FILE))?.value.text ?? git.read(SITE_SETTINGS_FILE);
|
|
630
|
+
const restored = settingsWithRedirectsBack(site, current, (await git.readMain([SITE_SETTINGS_FILE])).get(SITE_SETTINGS_FILE) ?? null, vacated);
|
|
631
|
+
if (restored !== null && current !== null) {
|
|
632
|
+
const settings = site.collections.settings ?? Object.values(site.collections).find((c) => c.kind === 'settings');
|
|
633
|
+
if (settings) {
|
|
634
|
+
await store.write({ file: SITE_SETTINGS_FILE, base: current, document: parseAnyDocument(JSON.parse(restored), site, settings) }, context);
|
|
635
|
+
await locks.release(SITE_SETTINGS_FILE, tab, person);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
await locks.release(from, tab, person);
|
|
639
|
+
movedBack = { collection: back.collection, path: back.path, file: from };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const result = await sweep
|
|
643
|
+
.exclusively(async () => {
|
|
644
|
+
// The document's files, and any file the move back left exactly as main has it (a link put back).
|
|
645
|
+
const files = new Set(own);
|
|
646
|
+
if (movedBack) {
|
|
647
|
+
const mine = (await autosaves.all()).map((r) => r.value).filter((a) => a.tab === tab && sameEmail(a.editor, person) && a.updatedAt >= t0 && !files.has(a.file));
|
|
648
|
+
const main = await git.readMain(mine.map((a) => a.file));
|
|
649
|
+
for (const a of mine) if (a.text === (main.get(a.file) ?? null)) files.add(a.file);
|
|
650
|
+
}
|
|
651
|
+
const list = [...files];
|
|
652
|
+
// Every tab that has it or had edits in it, but this one: their copies are stale from now.
|
|
653
|
+
const tabs = new Set<string>();
|
|
654
|
+
const people = new Map<string, Person>();
|
|
655
|
+
const note = (p: Person) => {
|
|
656
|
+
if (!sameEmail(p, person)) people.set(p.email.toLowerCase(), p);
|
|
657
|
+
};
|
|
658
|
+
for (const e of fromGit?.editors ?? []) note(e.person);
|
|
659
|
+
for (const f of list) {
|
|
660
|
+
const a = (await autosaves.get(f))?.value;
|
|
661
|
+
if (a) {
|
|
662
|
+
tabs.add(a.tab);
|
|
663
|
+
note(a.editor);
|
|
664
|
+
}
|
|
665
|
+
const c = await autosaves.committed(f);
|
|
666
|
+
if (c) tabs.add(c.tab);
|
|
667
|
+
const l = await locks.read(f);
|
|
668
|
+
if (l) tabs.add(l.lock.tab);
|
|
669
|
+
for (const p of await locks.tabsOn(f)) {
|
|
670
|
+
tabs.add(p.tab);
|
|
671
|
+
note(p.person);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
tabs.delete(tab);
|
|
675
|
+
const at = new Date(now()).toISOString();
|
|
676
|
+
const titles = [title];
|
|
677
|
+
const restored = await git.restoreDrafts(list, titles, person);
|
|
678
|
+
// Who changed it last, as it reads now, with the discard and the tabs that had it.
|
|
679
|
+
const changeOf = (f: string) => {
|
|
680
|
+
const text = restored.texts.get(f) ?? null;
|
|
681
|
+
const discard = { by: person, at, tabs: [...tabs] };
|
|
682
|
+
return text === null ? { by: person, version: '', at, gone: true, discard } : { by: person, version: fileVersion(text), at, discard };
|
|
683
|
+
};
|
|
684
|
+
for (const f of list) await autosaves.setChanged(f, changeOf(f));
|
|
685
|
+
for (const f of list) {
|
|
686
|
+
for (const gone of await autosaves.removeAll(f)) if (gone.tab !== tab) tabs.add(gone.tab);
|
|
687
|
+
await autosaves.forgetCommitted(f);
|
|
688
|
+
}
|
|
689
|
+
await autosaves.forgetMovesOf(list);
|
|
690
|
+
for (const f of list) {
|
|
691
|
+
await autosaves.setChanged(f, changeOf(f)); // any tab whose autosave was removed just now too
|
|
692
|
+
// This tab's lock lets go; one left by a tab that's gone is dropped; a live one (your other tab) stays, and that tab is told.
|
|
693
|
+
const l = await locks.read(f);
|
|
694
|
+
const fresh = !!l && l.lock.releasing === null && now() - l.lock.beat < timings.lockLapseMs;
|
|
695
|
+
if (l && l.lock.tab === tab) await locks.release(f, tab, person);
|
|
696
|
+
else if (l && !fresh) await locks.drop(l, f);
|
|
697
|
+
}
|
|
698
|
+
// "Aoife discarded the unpublished changes to About", for everyone else who had it.
|
|
699
|
+
await noticeStore
|
|
700
|
+
.addStood([...people.values()].map((p) => ({ id: `discarded:${at}:${file}:${p.email.toLowerCase()}`, for: p.email.toLowerCase(), file: movedBack?.file ?? file, title, by: person, at, kind: 'discarded' as const })))
|
|
701
|
+
.catch((error: unknown) => log(`discard: the notices weren't left (${error instanceof Error ? error.message : String(error)})`));
|
|
702
|
+
log(`${person.email} discarded the unpublished changes to ${list.join(', ')}`);
|
|
703
|
+
return { files: list };
|
|
704
|
+
})
|
|
705
|
+
.catch((error: unknown) => {
|
|
706
|
+
if (error instanceof SweepBusyError) throw new WriteRefused(409, 'The server is busy saving changes. Try again in a moment.', { code: 'busy' });
|
|
707
|
+
throw error;
|
|
708
|
+
});
|
|
709
|
+
return json(200, { ok: true, file, change, files: result.files, movedBack } satisfies DiscardResponse);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
return { runtime, autosaves, locks, sweep, store, write, create, delete: remove, status, lock, publish, history, tryAgain, notice, discard };
|
|
497
713
|
}
|
|
498
714
|
|
|
499
715
|
const SHARED = Symbol.for('caracms.liveEditor');
|