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/git.ts
CHANGED
|
@@ -30,6 +30,40 @@
|
|
|
30
30
|
* new, deleted or changed (`changeOf`), and a file made and removed again since the
|
|
31
31
|
* last Publish isn't waiting at all.
|
|
32
32
|
*
|
|
33
|
+
* **Losing `drafts`** (caracms-cloud adversarial review, finding 5). Every head of
|
|
34
|
+
* `drafts` this server makes or finds at start is recorded in Redis (`drafts:head`, no
|
|
35
|
+
* expiry). A start that finds `drafts` gone (a branch tidy-up, a stale-branch bot) makes
|
|
36
|
+
* it again from that commit while GitHub still has it; only when it can't does it fall
|
|
37
|
+
* back to `main`, with a loud log line and a note every editor sees for a week
|
|
38
|
+
* (`drafts:lost`, `draftsLost()`), cleared by the next Publish.
|
|
39
|
+
*
|
|
40
|
+
* **`main` rolled back by a force-push** (caracms-cloud adversarial review, force-push
|
|
41
|
+
* note). A developer resetting `main` to an older commit and force-pushing leaves `drafts`
|
|
42
|
+
* holding what was rolled away, so it all reads as waiting and the next Publish puts it
|
|
43
|
+
* back. Each check asks whether `main` still has the last Publish's commit (one compare per
|
|
44
|
+
* pair of commits): a push on top, a merge or a `git revert` all keep it, so only history
|
|
45
|
+
* being rewritten finds it gone. Then a log line, and a note every editor sees
|
|
46
|
+
* (`main:rolled-back`, `mainRolledBack()`). Nothing works out which waiting changes were
|
|
47
|
+
* rolled back. The note goes with the next Publish, once nothing is waiting (everything
|
|
48
|
+
* discarded: it stays gone for that rollback), or when `main` has the commit again.
|
|
49
|
+
*
|
|
50
|
+
* **Commits that keep failing** (finding 4): `commitTrouble()` says so once
|
|
51
|
+
* `COMMIT_TROUBLE_AFTER` draft commits in a row have failed, until one succeeds; the
|
|
52
|
+
* status poll turns it into the save bar's warning. In-process: the sweep's ticks on this
|
|
53
|
+
* server are what fail, and a restart learns it again within a few ticks.
|
|
54
|
+
*
|
|
55
|
+
* **Holding a document back** (caracms-cloud adversarial review, finding 15). Publish
|
|
56
|
+
* checks every waiting document as the reader will (document-problems.ts): one the reader
|
|
57
|
+
* would refuse (no title) isn't published. `main` gets `drafts`' tree with those files as
|
|
58
|
+
* `main` has them (a new one left out), and `drafts` gets one more commit on top, headed
|
|
59
|
+
* `Held back:`, putting them back as they were, so they're still waiting, with who changed
|
|
60
|
+
* them. A moved document's old file is held with its new one. Nothing publishable: nothing
|
|
61
|
+
* moves (`held-back`).
|
|
62
|
+
*
|
|
63
|
+
* **Discarding a document's changes** (`restoreDrafts`): its files are committed to
|
|
64
|
+
* `drafts` as `main` has them now (a file `main` lacks is removed), headed `Discarded:`,
|
|
65
|
+
* so what's waiting no longer lists them. One commit, compare-and-swap like any other.
|
|
66
|
+
*
|
|
33
67
|
* In-process state here is caches of git and GitHub, and throttles; none of it is
|
|
34
68
|
* the only record of anything.
|
|
35
69
|
*/
|
|
@@ -45,7 +79,8 @@ import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, ty
|
|
|
45
79
|
import { HttpError } from './github.ts';
|
|
46
80
|
import { runningCommitFrom } from './mode.ts';
|
|
47
81
|
import { mergeFile } from './merge.ts';
|
|
48
|
-
import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
|
|
82
|
+
import { discardMessage, draftMessage, heldMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
|
|
83
|
+
import { textProblems } from '../document-problems.ts';
|
|
49
84
|
import type { RedisClient } from './redis.ts';
|
|
50
85
|
import { landed, StatusCache } from './status-cache.ts';
|
|
51
86
|
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
@@ -79,10 +114,22 @@ export type CheckResult =
|
|
|
79
114
|
| { kind: 'fast-forward'; to: string; fromMain: string[] }
|
|
80
115
|
| { kind: 'merged'; commit: string; fromMain: string[]; keptDraft: string[] };
|
|
81
116
|
|
|
117
|
+
/** A waiting document Publish left waiting (finding 15): its file, its title and why ("its title is empty"). */
|
|
118
|
+
export interface HeldBack {
|
|
119
|
+
file: string;
|
|
120
|
+
title: string;
|
|
121
|
+
reason: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
82
124
|
export type PublishResult =
|
|
83
125
|
| { kind: 'nothing-waiting' }
|
|
84
|
-
/**
|
|
85
|
-
|
|
126
|
+
/**
|
|
127
|
+
* `fileTitles`: each of `record.files`' own title, in that order (`record.titles` is the headline's items).
|
|
128
|
+
* `held`: the documents left waiting (finding 15), none of them in `record.files`.
|
|
129
|
+
*/
|
|
130
|
+
| { kind: 'published'; record: PublishRecord; attempts: number; fileTitles: string[]; held: HeldBack[] }
|
|
131
|
+
/** Finding 15: everything waiting would break the reader, so nothing moved. */
|
|
132
|
+
| { kind: 'held-back'; held: HeldBack[] }
|
|
86
133
|
/** A Publish was already running; this press joined it. */
|
|
87
134
|
| { kind: 'joined'; record: PublishRecord | null };
|
|
88
135
|
|
|
@@ -140,6 +187,35 @@ export interface LiveGitOptions {
|
|
|
140
187
|
statusCache?: StatusCache;
|
|
141
188
|
}
|
|
142
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Draft commits failing in a row before the editor is told (finding 4): three of the
|
|
192
|
+
* sweep's 30-second ticks, about a minute of failing, so one GitHub blip never shows.
|
|
193
|
+
*/
|
|
194
|
+
export const COMMIT_TROUBLE_AFTER = 3;
|
|
195
|
+
|
|
196
|
+
/** Draft commits failing in a row (finding 4): since when, and how many. */
|
|
197
|
+
export interface CommitTrouble {
|
|
198
|
+
since: number;
|
|
199
|
+
failures: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** `drafts` was made again from `main` and what it held is gone (finding 5): its last recorded head (null: none was), and when. */
|
|
203
|
+
export interface DraftsLost {
|
|
204
|
+
commit: string | null;
|
|
205
|
+
at: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* `main` no longer has the last Publish's commit (force-push note): that commit, `main`'s head
|
|
210
|
+
* when it was found, and when. `cleared`: nothing was waiting since, so the note is done with.
|
|
211
|
+
*/
|
|
212
|
+
export interface MainRolledBack {
|
|
213
|
+
publish: string;
|
|
214
|
+
main: string;
|
|
215
|
+
at: string;
|
|
216
|
+
cleared?: boolean;
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
/** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
|
|
144
220
|
export { SITE_SETTINGS_FILE };
|
|
145
221
|
|
|
@@ -150,6 +226,17 @@ const RAILWAY_API = 'https://backboard.railway.com/graphql/v2';
|
|
|
150
226
|
|
|
151
227
|
const posix = (p: string) => p.split(sep).join('/');
|
|
152
228
|
|
|
229
|
+
/** The rollback note as kept in Redis; null when there's none or it doesn't read. */
|
|
230
|
+
function parseRolledBack(raw: string | null): MainRolledBack | null {
|
|
231
|
+
if (!raw) return null;
|
|
232
|
+
try {
|
|
233
|
+
const v = JSON.parse(raw) as MainRolledBack;
|
|
234
|
+
return typeof v.publish === 'string' && typeof v.main === 'string' ? v : null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
153
240
|
/** What `drafts` holds beyond `main`: from one compare, extended by this server's own commits. */
|
|
154
241
|
interface SinceMain {
|
|
155
242
|
main: string;
|
|
@@ -204,6 +291,21 @@ export class LiveGit {
|
|
|
204
291
|
private deploymentsDenied = false;
|
|
205
292
|
/** A deleted file's title (M5a): the waiting list's name for it once `drafts` has no copy. Learned when this process deleted it, else from `main`'s copy at a check (`learnDeletedTitles`), so a restart keeps it. */
|
|
206
293
|
private deletedTitles = new Map<string, string>();
|
|
294
|
+
/** Finding 5: the `drafts` head last written to Redis from here, so the same one isn't written twice. */
|
|
295
|
+
private recordedHead: string | null = null;
|
|
296
|
+
/** Finding 5: `drafts` was made again from `main` (read at start; a Publish clears it). */
|
|
297
|
+
private lost: DraftsLost | null = null;
|
|
298
|
+
/** Force-push note: `main` lost the last Publish's commit (read at each check; a Publish clears it). */
|
|
299
|
+
private rolledBack: MainRolledBack | null = null;
|
|
300
|
+
/** Force-push note: the last (publish commit, `main`) pair checked, and an unknown commit already logged. */
|
|
301
|
+
private rollbackPair: string | null = null;
|
|
302
|
+
private rollbackUnknown: string | null = null;
|
|
303
|
+
/** Its own cache, not `includesCache`: that one reads a commit GitHub lacks as "not included". */
|
|
304
|
+
private onMainCache = new Map<string, boolean>();
|
|
305
|
+
/** Finding 4: draft commits failing in a row; null after a success. */
|
|
306
|
+
private failing: CommitTrouble | null = null;
|
|
307
|
+
/** Finding 6: a publish whose refs moved but whose record couldn't be written to Redis. */
|
|
308
|
+
private unkeptRecord: PublishRecord | null = null;
|
|
207
309
|
|
|
208
310
|
constructor(options: LiveGitOptions) {
|
|
209
311
|
this.backend = options.backend;
|
|
@@ -270,10 +372,62 @@ export class LiveGit {
|
|
|
270
372
|
return this.key('publish:last');
|
|
271
373
|
}
|
|
272
374
|
|
|
375
|
+
/** Finding 5: the newest head of `drafts` this server made or found, and the note left when it was lost. */
|
|
376
|
+
get draftsHeadKey(): string {
|
|
377
|
+
return this.key('drafts:head');
|
|
378
|
+
}
|
|
379
|
+
get draftsLostKey(): string {
|
|
380
|
+
return this.key('drafts:lost');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Force-push note: `main` was found without the last Publish's commit. */
|
|
384
|
+
get mainRolledBackKey(): string {
|
|
385
|
+
return this.key('main:rolled-back');
|
|
386
|
+
}
|
|
387
|
+
|
|
273
388
|
private async setPublishRecord(record: PublishRecord): Promise<void> {
|
|
274
389
|
const raw = JSON.stringify(record);
|
|
275
390
|
await landed(this.statusCache, this.publishRecordKey, () => this.redis.set(this.publishRecordKey, raw));
|
|
276
391
|
this.statusCache.string(this.publishRecordKey, raw);
|
|
392
|
+
this.unkeptRecord = null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Finding 6: the newest publish as the status poll read it, unless this server made a
|
|
397
|
+
* newer one whose record Redis didn't take: that one.
|
|
398
|
+
*/
|
|
399
|
+
publishRecordOr(record: PublishRecord | null): PublishRecord | null {
|
|
400
|
+
const unkept = this.unkeptRecord;
|
|
401
|
+
return unkept && (!record || record.at < unkept.at) ? unkept : record;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Finding 5: record `drafts`' head in Redis (no expiry), so a start that finds the
|
|
406
|
+
* branch deleted can make it again from here. Never fails what it follows.
|
|
407
|
+
*/
|
|
408
|
+
private async recordDraftsHead(commit: string): Promise<void> {
|
|
409
|
+
if (this.recordedHead === commit) return;
|
|
410
|
+
try {
|
|
411
|
+
await this.redis.set(this.draftsHeadKey, commit);
|
|
412
|
+
this.recordedHead = commit;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.log(`recording drafts' head (${commit.slice(0, 7)}): ${error instanceof Error ? error.message : String(error)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Finding 5: `drafts` was made again from `main` at a start, losing what it held (for a week, or until the next Publish). */
|
|
419
|
+
draftsLost(): DraftsLost | null {
|
|
420
|
+
return this.lost;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Force-push note: `main` was rolled back past the last Publish, and something is still waiting. */
|
|
424
|
+
mainRolledBack(): MainRolledBack | null {
|
|
425
|
+
return this.rolledBack && !this.rolledBack.cleared ? this.rolledBack : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Finding 4: draft commits have failed `COMMIT_TROUBLE_AFTER` times in a row or more, and none has succeeded since. */
|
|
429
|
+
commitTrouble(): CommitTrouble | null {
|
|
430
|
+
return this.failing && this.failing.failures >= COMMIT_TROUBLE_AFTER ? this.failing : null;
|
|
277
431
|
}
|
|
278
432
|
|
|
279
433
|
/** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
|
|
@@ -291,6 +445,13 @@ export class LiveGit {
|
|
|
291
445
|
* can run it once `commitPending` is in place, Decision 8).
|
|
292
446
|
*/
|
|
293
447
|
async start(options: { check?: boolean } = {}): Promise<void> {
|
|
448
|
+
// Finding 5: a note left by an earlier start that lost `drafts` (it expires after a week).
|
|
449
|
+
try {
|
|
450
|
+
const raw = await this.redis.get(this.draftsLostKey);
|
|
451
|
+
this.lost = raw ? (JSON.parse(raw) as DraftsLost) : null;
|
|
452
|
+
} catch (error) {
|
|
453
|
+
this.log(`reading the lost-drafts note: ${error instanceof Error ? error.message : String(error)}`);
|
|
454
|
+
}
|
|
294
455
|
await this.backend.tracker.within('start', () =>
|
|
295
456
|
this.exclusive(async () => {
|
|
296
457
|
let heads: Heads;
|
|
@@ -298,19 +459,69 @@ export class LiveGit {
|
|
|
298
459
|
heads = await this.backend.readHeads();
|
|
299
460
|
} catch (error) {
|
|
300
461
|
if (!(error instanceof MissingBranchError) || error.branch !== 'drafts') throw error;
|
|
301
|
-
|
|
302
|
-
this.log(`no drafts branch yet: creating it from main (${main.slice(0, 7)})`);
|
|
303
|
-
await this.backend.createBranch('drafts', main).catch((e: unknown) => {
|
|
304
|
-
if (!(e instanceof HttpError && e.status === 422)) throw e; // another server made it first
|
|
305
|
-
});
|
|
306
|
-
heads = await this.backend.readHeads();
|
|
462
|
+
heads = await this.remakeDrafts();
|
|
307
463
|
}
|
|
308
464
|
await this.download(heads.drafts);
|
|
465
|
+
await this.recordDraftsHead(heads.drafts.commit);
|
|
309
466
|
}),
|
|
310
467
|
);
|
|
311
468
|
if (options.check !== false) await this.check({ force: true });
|
|
312
469
|
}
|
|
313
470
|
|
|
471
|
+
/**
|
|
472
|
+
* `drafts` is missing (finding 5): made again from its last recorded head when GitHub
|
|
473
|
+
* still has that commit, so nothing unpublished is lost. Otherwise from `main`, as on a
|
|
474
|
+
* site's first start; with a recorded head, or a publish on record (so it isn't a first
|
|
475
|
+
* start), that loses every change saved since the last Publish: a loud log line, and the
|
|
476
|
+
* editor's note (`drafts:lost`). Redis out: this start fails rather than make `drafts`
|
|
477
|
+
* from `main` and lose what the next start could put back.
|
|
478
|
+
*/
|
|
479
|
+
private async remakeDrafts(): Promise<Heads> {
|
|
480
|
+
const main = await this.mainOnly();
|
|
481
|
+
const recorded = await this.redis.get(this.draftsHeadKey);
|
|
482
|
+
if (recorded) {
|
|
483
|
+
const heads = await this.createDrafts(recorded);
|
|
484
|
+
if (heads) {
|
|
485
|
+
this.log(`the drafts branch was missing: made it again from its last recorded head (${recorded}), so nothing unpublished was lost`);
|
|
486
|
+
return heads;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const published = recorded === null ? (await this.lastPublish()) !== null : true;
|
|
490
|
+
const heads = await this.createDrafts(main);
|
|
491
|
+
if (!heads) throw new Error(`caracms: couldn't make the drafts branch from main (${main.slice(0, 7)}).`);
|
|
492
|
+
if (!published) {
|
|
493
|
+
this.log(`no drafts branch yet: created it from main (${main.slice(0, 7)})`);
|
|
494
|
+
return heads;
|
|
495
|
+
}
|
|
496
|
+
this.log(
|
|
497
|
+
recorded
|
|
498
|
+
? `UNPUBLISHED CHANGES LOST: the drafts branch was missing on GitHub and its last recorded head (${recorded}) isn't there any more, so drafts was made again from main (${main.slice(0, 7)}). Every change saved since the last Publish, up to that commit, is gone from the editor.`
|
|
499
|
+
: `UNPUBLISHED CHANGES LOST: the drafts branch was missing on GitHub and no head of it was recorded, so drafts was made again from main (${main.slice(0, 7)}). Any change saved since the last Publish is gone from the editor.`,
|
|
500
|
+
);
|
|
501
|
+
this.lost = { commit: recorded, at: new Date(this.now()).toISOString() };
|
|
502
|
+
await this.redis.set(this.draftsLostKey, JSON.stringify(this.lost), { px: this.timings.autosaveTtlMs }).catch((error: unknown) => {
|
|
503
|
+
this.log(`keeping the lost-drafts note: ${error instanceof Error ? error.message : String(error)}`);
|
|
504
|
+
});
|
|
505
|
+
return heads;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Make `drafts` at `commit`; null when GitHub doesn't have that commit. Another server making it first is a success. */
|
|
509
|
+
private async createDrafts(commit: string): Promise<Heads | null> {
|
|
510
|
+
try {
|
|
511
|
+
await this.backend.createBranch('drafts', commit);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
const heads = await this.backend.readHeads().catch((e: unknown) => {
|
|
514
|
+
if (e instanceof MissingBranchError && e.branch === 'drafts') return null;
|
|
515
|
+
throw e;
|
|
516
|
+
});
|
|
517
|
+
if (heads) return heads; // another server made it first
|
|
518
|
+
// GitHub answers 422 for a sha it doesn't have (the memory backend, 404). Anything else fails the start.
|
|
519
|
+
if (error instanceof HttpError && (error.status === 422 || error.status === 404)) return null;
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
return this.backend.readHeads();
|
|
523
|
+
}
|
|
524
|
+
|
|
314
525
|
/** `main`'s head when `drafts` is missing (readHeads throws then). */
|
|
315
526
|
private async mainOnly(): Promise<string> {
|
|
316
527
|
// listCommits reads main alone; its first entry is the head.
|
|
@@ -379,6 +590,20 @@ export class LiveGit {
|
|
|
379
590
|
* and merges again.
|
|
380
591
|
*/
|
|
381
592
|
async commitDrafts(entries: DraftEntry[]): Promise<DraftCommitResult> {
|
|
593
|
+
// Finding 4: count the failures in a row (the save bar's warning), and record the new head.
|
|
594
|
+
try {
|
|
595
|
+
const r = await this.commitDraftsOnce(entries);
|
|
596
|
+
this.failing = null;
|
|
597
|
+
if (r.commit) await this.recordDraftsHead(r.commit);
|
|
598
|
+
return r;
|
|
599
|
+
} catch (error) {
|
|
600
|
+
this.failing = { since: this.failing?.since ?? this.now(), failures: (this.failing?.failures ?? 0) + 1 };
|
|
601
|
+
if (this.failing.failures === COMMIT_TROUBLE_AFTER) this.log(`draft commits have failed ${COMMIT_TROUBLE_AFTER} times in a row: the editor now says they aren't reaching GitHub`);
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
private async commitDraftsOnce(entries: DraftEntry[]): Promise<DraftCommitResult> {
|
|
382
607
|
return this.backend.tracker.within('draft', () =>
|
|
383
608
|
this.exclusive(async () => {
|
|
384
609
|
for (let attempt = 1; ; attempt++) {
|
|
@@ -448,6 +673,80 @@ export class LiveGit {
|
|
|
448
673
|
);
|
|
449
674
|
}
|
|
450
675
|
|
|
676
|
+
// ── Discard changes ─────────────────────────────────────────────────────────
|
|
677
|
+
|
|
678
|
+
/** These files as `main` has them now (null: it hasn't), in one read of the heads and one of the files. */
|
|
679
|
+
async readMain(files: readonly string[]): Promise<Map<string, string | null>> {
|
|
680
|
+
if (files.length === 0) return new Map();
|
|
681
|
+
const heads = await this.backend.readHeads();
|
|
682
|
+
const read = await this.backend.readFiles(files.map((f) => ({ commit: heads.main.commit, path: this.toRepo(f) })));
|
|
683
|
+
return new Map(files.map((f, i) => [f, read[i] ?? null]));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Put these files on `drafts` back to what `main` has now (removed where `main` has
|
|
688
|
+
* none), in one commit headed `Discarded: <titles> — by <person>`. Nothing to change:
|
|
689
|
+
* no commit. The files leave what's waiting (`since`). A lost race refreshes the copy and
|
|
690
|
+
* goes again. Returns the commit (null when `drafts` already matched) and each file's
|
|
691
|
+
* text as it now reads (null: gone).
|
|
692
|
+
*/
|
|
693
|
+
async restoreDrafts(files: readonly string[], titles: readonly string[], by: Person): Promise<{ commit: string | null; texts: Map<string, string | null> }> {
|
|
694
|
+
const result = await this.backend.tracker.within('draft', () =>
|
|
695
|
+
this.exclusive(async () => {
|
|
696
|
+
for (let attempt = 1; ; attempt++) {
|
|
697
|
+
const heads = await this.reread();
|
|
698
|
+
const head = this.head();
|
|
699
|
+
const read = await this.backend.readFiles(files.map((f) => ({ commit: heads.main.commit, path: this.toRepo(f) })));
|
|
700
|
+
const texts = new Map(files.map((f, i) => [f, read[i] ?? null] as [string, string | null]));
|
|
701
|
+
const changed = [...texts].filter(([file, text]) => this.read(file) !== text);
|
|
702
|
+
const settle = (commit: string) => {
|
|
703
|
+
// Every one of them matches main now: none is waiting. Only while the compare is of this main.
|
|
704
|
+
if (this.since && this.since.main === heads.main.commit && this.since.drafts === commit) {
|
|
705
|
+
for (const file of files) {
|
|
706
|
+
this.since.files.delete(this.toRepo(file));
|
|
707
|
+
this.since.absent.delete(this.toRepo(file));
|
|
708
|
+
}
|
|
709
|
+
} else this.since = null;
|
|
710
|
+
};
|
|
711
|
+
if (changed.length === 0) {
|
|
712
|
+
settle(head.commit);
|
|
713
|
+
return { commit: null, texts };
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const removed = changed.filter(([, text]) => text === null).map(([f]) => this.toRepo(f));
|
|
717
|
+
const writes = changed.filter(([, text]) => text !== null).map(([file, contents]) => ({ path: this.toRepo(file), contents: contents! }));
|
|
718
|
+
const message = discardMessage([...titles], [...files], by);
|
|
719
|
+
const [headline, ...body] = message.split('\n');
|
|
720
|
+
const next = await this.backend.commitOnBranch('drafts', head.commit, writes, removed, headline!, body.join('\n').replace(/^\n/, ''));
|
|
721
|
+
for (const [file, text] of changed) {
|
|
722
|
+
if (text === null) this.copyFiles.delete(file);
|
|
723
|
+
else this.copyFiles.set(file, text);
|
|
724
|
+
}
|
|
725
|
+
this.copyHead = next;
|
|
726
|
+
if (this.since?.drafts === head.commit) {
|
|
727
|
+
this.since.drafts = next.commit;
|
|
728
|
+
this.since.commits.push({ sha: next.commit, parents: [head.commit], message, date: new Date(this.now()).toISOString() });
|
|
729
|
+
}
|
|
730
|
+
settle(next.commit);
|
|
731
|
+
this.historyCache = null;
|
|
732
|
+
this.log(`discarded ${next.commit.slice(0, 7)}: ${files.join(', ')}`);
|
|
733
|
+
return { commit: next.commit, texts };
|
|
734
|
+
} catch (error) {
|
|
735
|
+
const now = await this.backend.readHeads().catch(() => null);
|
|
736
|
+
const moved = now !== null && now.drafts.commit !== head.commit;
|
|
737
|
+
if (!(error instanceof StaleHeadError) && !moved) throw error;
|
|
738
|
+
if (attempt >= MAX_ATTEMPTS) throw new Error(`caracms: drafts kept moving; gave up discarding after ${attempt} tries.`);
|
|
739
|
+
this.log('discard: drafts moved under us; refreshing the copy and trying again');
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
744
|
+
if (result.commit) await this.recordDraftsHead(result.commit);
|
|
745
|
+
// Force-push note: everything rolled back may have just been discarded.
|
|
746
|
+
await this.exclusive(() => this.rollbackSettled());
|
|
747
|
+
return result;
|
|
748
|
+
}
|
|
749
|
+
|
|
451
750
|
// ── Keeping drafts up to date with main (Decision 8) ────────────────────────
|
|
452
751
|
|
|
453
752
|
/**
|
|
@@ -467,6 +766,7 @@ export class LiveGit {
|
|
|
467
766
|
const r = await this.exclusive(() => this.checkOnce(pendingCommitted));
|
|
468
767
|
if (r !== 'needs-pending') {
|
|
469
768
|
await this.exclusive(() => this.learnDeletedTitles());
|
|
769
|
+
await this.exclusive(() => this.checkRollback());
|
|
470
770
|
return r;
|
|
471
771
|
}
|
|
472
772
|
await this.commitPending();
|
|
@@ -533,6 +833,7 @@ export class LiveGit {
|
|
|
533
833
|
this.since = { main: heads.main.commit, drafts: heads.main.commit, commits: [], files: new Set(), absent: new Set() };
|
|
534
834
|
const fromMain = (cmp.files ?? []).filter((f) => this.inScope(f.path)).map((f) => this.toSite(f.path));
|
|
535
835
|
this.log(`drafts fast-forwarded to main (${heads.main.commit.slice(0, 7)})`);
|
|
836
|
+
await this.recordDraftsHead(heads.main.commit);
|
|
536
837
|
await this.told(fromMain);
|
|
537
838
|
return { kind: 'fast-forward', to: heads.main.commit, fromMain };
|
|
538
839
|
}
|
|
@@ -616,6 +917,7 @@ export class LiveGit {
|
|
|
616
917
|
absent: new Set(absent),
|
|
617
918
|
};
|
|
618
919
|
this.log(`merged main into drafts (${commit.slice(0, 7)}): from main [${fromMain.join(', ')}], draft kept [${keptDraft.join(', ')}]`);
|
|
920
|
+
await this.recordDraftsHead(commit);
|
|
619
921
|
await this.told([...copyAfter.keys()]);
|
|
620
922
|
return { kind: 'merged', commit, fromMain, keptDraft };
|
|
621
923
|
}
|
|
@@ -718,11 +1020,18 @@ export class LiveGit {
|
|
|
718
1020
|
return null;
|
|
719
1021
|
}
|
|
720
1022
|
const repoFiles = (cmp.files ?? this.diffCopyFallback().map((path) => ({ path }))).map((f) => f.path);
|
|
721
|
-
const
|
|
1023
|
+
const waiting = repoFiles.filter((p) => p.startsWith(this.prefix)).map(this.toSite);
|
|
1024
|
+
const { records, addresses } = splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
|
|
1025
|
+
// Finding 15: a document the reader would refuse stays waiting; the rest go.
|
|
1026
|
+
const held = this.heldBack(waiting, records);
|
|
1027
|
+
const files = waiting.filter((f) => !held.has(f));
|
|
1028
|
+
if (!files.length) {
|
|
1029
|
+
this.log(`publish: nothing publishable; held back ${[...held.keys()].join(', ')}`);
|
|
1030
|
+
return { kind: 'held-back', held: [...held.values()] };
|
|
1031
|
+
}
|
|
722
1032
|
// A file drafts no longer has is deleted: "Contact (deleted)", its title from main's copy first (M5a Decision 12).
|
|
723
1033
|
const gone = files.filter((f) => this.read(f) === null);
|
|
724
1034
|
const goneTitles = await this.titlesBeforeRemoval(gone, heads.main.commit);
|
|
725
|
-
const { records, addresses } = splitMoves(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
|
|
726
1035
|
// Collections under pages: a held document's addresses come from its holder's (only `holds` is read, so no blocks are needed).
|
|
727
1036
|
const bases = await heldBasesOf({ ...this.site, blocks: {} }, this.contentSource());
|
|
728
1037
|
const moves = this.movesIn(files, records, bases, addresses);
|
|
@@ -744,18 +1053,104 @@ export class LiveGit {
|
|
|
744
1053
|
...readdressed.map((f) => `${titleOf(f, this.read(f))} (moved from ${moves.from.get(f)})`),
|
|
745
1054
|
...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!)),
|
|
746
1055
|
];
|
|
747
|
-
|
|
1056
|
+
// Finding 15: main's tree is drafts' with the held files as main has them, and drafts
|
|
1057
|
+
// gets them back in a commit of its own on top, so they stay waiting.
|
|
1058
|
+
const heldFiles = [...held.keys()];
|
|
1059
|
+
const onMain = heldFiles.length ? await this.backend.readFiles(heldFiles.map((f) => ({ commit: heads.main.commit, path: this.toRepo(f) }))) : [];
|
|
1060
|
+
const tree = heldFiles.length
|
|
1061
|
+
? await this.backend.createTree(
|
|
1062
|
+
heads.drafts.tree,
|
|
1063
|
+
heldFiles.flatMap((f, i) => (onMain[i] === null ? [{ path: this.toRepo(f), mode: '100644', sha: null }] : [])),
|
|
1064
|
+
heldFiles.flatMap((f, i) => (onMain[i] === null ? [] : [{ path: this.toRepo(f), contents: onMain[i]! }])),
|
|
1065
|
+
)
|
|
1066
|
+
: heads.drafts.tree;
|
|
1067
|
+
const commit = await this.backend.createCommit(tree, [heads.main.commit], publishMessage(titles, files, by));
|
|
1068
|
+
const heldBackMessage = heldFiles.length ? heldMessage([...held.values()].map((h) => h.title), heldFiles, waitingFromCommits(heldFiles, cmp.commits)) : null;
|
|
1069
|
+
const draftsHead = heldBackMessage ? await this.backend.createCommit(heads.drafts.tree, [commit], heldBackMessage) : commit;
|
|
748
1070
|
const moved = await this.moveRefs([
|
|
749
1071
|
// main: a fast-forward onto the squash commit. drafts: not a fast-forward, so
|
|
750
1072
|
// `force`, but still compare-and-swap on `before` (M1 → G2).
|
|
751
1073
|
{ branch: 'main', before: heads.main.commit, after: commit, force: false },
|
|
752
|
-
{ branch: 'drafts', before: heads.drafts.commit, after:
|
|
1074
|
+
{ branch: 'drafts', before: heads.drafts.commit, after: draftsHead, force: true },
|
|
753
1075
|
]);
|
|
754
1076
|
if (!moved) return null; // the squash commit is left orphaned; nothing points at it
|
|
755
|
-
this.copyHead = { commit, tree: heads.drafts.tree };
|
|
756
|
-
this.since =
|
|
1077
|
+
this.copyHead = { commit: draftsHead, tree: heads.drafts.tree };
|
|
1078
|
+
this.since = heldBackMessage
|
|
1079
|
+
? {
|
|
1080
|
+
main: commit,
|
|
1081
|
+
drafts: draftsHead,
|
|
1082
|
+
commits: [{ sha: draftsHead, parents: [commit], message: heldBackMessage, date: new Date(this.now()).toISOString() }],
|
|
1083
|
+
files: new Set(heldFiles.map(this.toRepo)),
|
|
1084
|
+
absent: new Set(heldFiles.filter((_, i) => onMain[i] === null).map(this.toRepo)),
|
|
1085
|
+
}
|
|
1086
|
+
: { main: commit, drafts: commit, commits: [], files: new Set(), absent: new Set() };
|
|
757
1087
|
this.deletedTitles.clear();
|
|
758
1088
|
this.historyCache = null;
|
|
1089
|
+
const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
|
|
1090
|
+
// The refs moved: the Publish has happened, and it's deploying. Nothing after this may
|
|
1091
|
+
// fail it (caracms-cloud adversarial review, finding 6): Redis dropping out now is logged,
|
|
1092
|
+
// and the answer is `published` with the record made here.
|
|
1093
|
+
try {
|
|
1094
|
+
await this.afterPublish(record, draftsHead, records, addresses, bases, held);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
this.unkeptRecord = record;
|
|
1097
|
+
this.log(`published ${commit.slice(0, 7)}, but what follows a publish in Redis didn't all happen (${error instanceof Error ? error.message : String(error)}): the status shows this publish from this server's memory until the next one`);
|
|
1098
|
+
}
|
|
1099
|
+
this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}${heldFiles.length ? `; held back ${heldFiles.join(', ')}` : ''}`);
|
|
1100
|
+
return { kind: 'published', record, attempts: attempt, fileTitles, held: [...held.values()] };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Finding 15: the waiting files Publish leaves waiting, by file: each document the reader
|
|
1105
|
+
* would refuse (document-problems.ts), and a moved one's old file with it (its record, old
|
|
1106
|
+
* file → new), so its live page stays where it is. A deletion is never held back.
|
|
1107
|
+
*/
|
|
1108
|
+
private heldBack(files: readonly string[], records: ReadonlyMap<string, string>): Map<string, HeldBack> {
|
|
1109
|
+
const held = new Map<string, HeldBack>();
|
|
1110
|
+
for (const file of files) {
|
|
1111
|
+
const text = this.read(file);
|
|
1112
|
+
if (text === null || !this.isDocument(file)) continue;
|
|
1113
|
+
const [problem] = textProblems(text);
|
|
1114
|
+
if (problem) held.set(file, { file, title: titleOf(file, text), reason: problem.reason });
|
|
1115
|
+
}
|
|
1116
|
+
for (const [old, now] of records) {
|
|
1117
|
+
const why = held.get(now);
|
|
1118
|
+
if (why && files.includes(old) && !held.has(old)) held.set(old, { ...why, file: old });
|
|
1119
|
+
}
|
|
1120
|
+
return held;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Is this site file a document of a declared collection (Site Settings and shared blocks included)? */
|
|
1124
|
+
private isDocument(file: string): boolean {
|
|
1125
|
+
try {
|
|
1126
|
+
confinePath(this.site as ResolvedSite, file);
|
|
1127
|
+
return true;
|
|
1128
|
+
} catch {
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/** Publish's Redis work once its refs have moved (finding 6: its caller catches what fails). `draftsHead`: where it left `drafts` (finding 5). */
|
|
1134
|
+
private async afterPublish(
|
|
1135
|
+
record: PublishRecord,
|
|
1136
|
+
draftsHead: string,
|
|
1137
|
+
records: ReadonlyMap<string, string>,
|
|
1138
|
+
addresses: ReadonlyMap<string, string>,
|
|
1139
|
+
bases: Readonly<Record<string, HeldBase>>,
|
|
1140
|
+
held: ReadonlyMap<string, HeldBack>,
|
|
1141
|
+
): Promise<void> {
|
|
1142
|
+
const { files } = record;
|
|
1143
|
+
await this.recordDraftsHead(draftsHead);
|
|
1144
|
+
if (this.lost) {
|
|
1145
|
+
// Finding 5: a Publish since `drafts` was lost is the team carrying on; the note goes.
|
|
1146
|
+
this.lost = null;
|
|
1147
|
+
await this.redis.del(this.draftsLostKey).catch(() => 0);
|
|
1148
|
+
}
|
|
1149
|
+
if (this.rolledBack) {
|
|
1150
|
+
// Force-push note: what was waiting has been published, rolled-back content and all, by choice.
|
|
1151
|
+
this.rolledBack = null;
|
|
1152
|
+
await this.redis.del(this.mainRolledBackKey).catch(() => 0);
|
|
1153
|
+
}
|
|
759
1154
|
// The moves this publish took live are done with (a move made since stays waiting).
|
|
760
1155
|
const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
|
|
761
1156
|
if (published.length) {
|
|
@@ -771,7 +1166,8 @@ export class LiveGit {
|
|
|
771
1166
|
// changed since (still its live address in what was published) stays waiting.
|
|
772
1167
|
const live = withHeldBases(this.site, bases);
|
|
773
1168
|
const done = [...addresses].filter(([file, was]) => {
|
|
774
|
-
|
|
1169
|
+
// Finding 15: a held-back document's live address is still the one recorded.
|
|
1170
|
+
if (this.read(file) === null || held.has(file)) return false;
|
|
775
1171
|
const url = this.urlOf(live, file);
|
|
776
1172
|
return url !== null && url !== was;
|
|
777
1173
|
}).map(([file]) => `${ADDRESS_FIELD}${file}`);
|
|
@@ -784,15 +1180,12 @@ export class LiveGit {
|
|
|
784
1180
|
if (cleared) this.statusCache.hash(key, {}, done);
|
|
785
1181
|
else this.statusCache.drop(key);
|
|
786
1182
|
}
|
|
787
|
-
const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
|
|
788
1183
|
await this.setPublishRecord(record);
|
|
789
|
-
this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
|
|
790
|
-
return { kind: 'published', record, attempts: attempt, fileTitles };
|
|
791
1184
|
}
|
|
792
1185
|
|
|
793
|
-
/** The newest publish, from Redis. */
|
|
1186
|
+
/** The newest publish, from Redis (or this server's own newer one Redis didn't take, finding 6). */
|
|
794
1187
|
async lastPublish(): Promise<PublishRecord | null> {
|
|
795
|
-
return LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey));
|
|
1188
|
+
return this.publishRecordOr(LiveGit.publishRecordFrom(await this.redis.get(this.publishRecordKey)));
|
|
796
1189
|
}
|
|
797
1190
|
|
|
798
1191
|
/** A publish record from its text as read (the status cache's, on a poll). */
|
|
@@ -917,7 +1310,11 @@ export class LiveGit {
|
|
|
917
1310
|
if (!this.since) return [];
|
|
918
1311
|
const waiting = new Set([...this.since.files].filter((p) => this.inScope(p)).map(this.toSite));
|
|
919
1312
|
const out = new Map<string, string>();
|
|
920
|
-
for (const c of this.since.commits)
|
|
1313
|
+
for (const c of this.since.commits) {
|
|
1314
|
+
const parsed = parseMessage(c.message);
|
|
1315
|
+
for (const file of parsed.discardedFiles) out.delete(file); // a discard ends the notice's reason
|
|
1316
|
+
for (const file of parsed.keptDraft) if (waiting.has(file)) out.set(file, c.sha);
|
|
1317
|
+
}
|
|
921
1318
|
return [...out].map(([file, commit]) => ({ file, commit }));
|
|
922
1319
|
}
|
|
923
1320
|
|
|
@@ -946,6 +1343,79 @@ export class LiveGit {
|
|
|
946
1343
|
});
|
|
947
1344
|
}
|
|
948
1345
|
|
|
1346
|
+
// ── `main` rolled back (force-push note) ─────────────────────────────────────
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* After a check: does `main` still have the last Publish's commit? Only rewritten history
|
|
1350
|
+
* loses it (a push on top, a merge or a revert keeps it). One compare per pair; never
|
|
1351
|
+
* fails the check.
|
|
1352
|
+
*/
|
|
1353
|
+
private async checkRollback(): Promise<void> {
|
|
1354
|
+
try {
|
|
1355
|
+
const record = await this.lastPublish();
|
|
1356
|
+
if (!record) {
|
|
1357
|
+
this.rolledBack = null;
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
const main = this.since?.main ?? (await this.backend.readHeads()).main.commit;
|
|
1361
|
+
const pair = `${record.commit}:${main}`;
|
|
1362
|
+
if (pair !== this.rollbackPair) {
|
|
1363
|
+
const has = await this.mainHasPublish(record.commit, main);
|
|
1364
|
+
if (has === null) {
|
|
1365
|
+
// GitHub doesn't know the commit (another repo's record, or long gone): can't tell, so no note.
|
|
1366
|
+
if (this.rollbackUnknown !== record.commit) this.log(`the last Publish's commit (${record.commit.slice(0, 7)}) isn't on GitHub: can't tell whether main was rolled back`);
|
|
1367
|
+
this.rollbackUnknown = record.commit;
|
|
1368
|
+
this.rolledBack = null;
|
|
1369
|
+
} else if (has) {
|
|
1370
|
+
if (this.rolledBack || this.rollbackPair === null) await this.redis.del(this.mainRolledBackKey);
|
|
1371
|
+
this.rolledBack = null;
|
|
1372
|
+
} else {
|
|
1373
|
+
const kept = this.rolledBack?.publish === record.commit ? this.rolledBack : parseRolledBack(await this.redis.get(this.mainRolledBackKey));
|
|
1374
|
+
if (kept && kept.publish === record.commit) this.rolledBack = kept;
|
|
1375
|
+
else {
|
|
1376
|
+
this.rolledBack = { publish: record.commit, main, at: new Date(this.now()).toISOString() };
|
|
1377
|
+
await this.redis.set(this.mainRolledBackKey, JSON.stringify(this.rolledBack));
|
|
1378
|
+
this.log(
|
|
1379
|
+
`MAIN WAS ROLLED BACK: main (${main.slice(0, 7)}) no longer has the last Publish's commit (${record.commit.slice(0, 7)}), so its history was rewritten (a force-push). What was rolled back is waiting in the editor again, and the next Publish puts it back; editors are told to discard what they don't want first.`,
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
this.rollbackPair = pair;
|
|
1384
|
+
}
|
|
1385
|
+
await this.rollbackSettled();
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
this.log(`checking whether main was rolled back: ${error instanceof Error ? error.message : String(error)}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** Force-push note: with nothing waiting (all of it discarded), the note is done with for this rollback, on every server. */
|
|
1392
|
+
private async rollbackSettled(): Promise<void> {
|
|
1393
|
+
const note = this.rolledBack;
|
|
1394
|
+
if (!note || note.cleared || !this.since || this.waiting().length > 0) return;
|
|
1395
|
+
this.rolledBack = { ...note, cleared: true };
|
|
1396
|
+
this.log(`main's rollback: nothing is waiting any more, so the note is done with`);
|
|
1397
|
+
await this.redis.set(this.mainRolledBackKey, JSON.stringify(this.rolledBack)).catch((error: unknown) => {
|
|
1398
|
+
this.log(`keeping the rollback note's end: ${error instanceof Error ? error.message : String(error)}`);
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** Is the Publish's `commit` in `main`? Null when GitHub doesn't have it. One compare per pair, per server. */
|
|
1403
|
+
private async mainHasPublish(commit: string, main: string): Promise<boolean | null> {
|
|
1404
|
+
if (commit === main) return true;
|
|
1405
|
+
const key = `${commit}:${main}`;
|
|
1406
|
+
const known = this.onMainCache.get(key);
|
|
1407
|
+
if (known !== undefined) return known;
|
|
1408
|
+
try {
|
|
1409
|
+
const cmp = await this.backend.compare(commit, main);
|
|
1410
|
+
const yes = cmp.status === 'ahead' || cmp.status === 'identical';
|
|
1411
|
+
this.onMainCache.set(key, yes); // ancestry never changes
|
|
1412
|
+
return yes;
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
if (error instanceof HttpError && (error.status === 404 || error.status === 422)) return null;
|
|
1415
|
+
throw error;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
949
1419
|
/** Is `commit` in `running` (the same, or an ancestor)? One compare per pair, per server. */
|
|
950
1420
|
private async includes(commit: string, running: string): Promise<boolean> {
|
|
951
1421
|
const key = `${commit}:${running}`;
|