caracms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
package/src/live/git.ts
ADDED
|
@@ -0,0 +1,1021 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The git layer of live mode (M4 Phase 1): the in-memory `drafts` copy, draft
|
|
3
|
+
* commits, keeping `drafts` up to date with `main`, Publish, what's waiting, publish
|
|
4
|
+
* status, history and Try again. No UI and no routes; Phase 2's live drafts store,
|
|
5
|
+
* sweep and status route sit on top of it.
|
|
6
|
+
*
|
|
7
|
+
* **The copy** (Spec → How it maps to git). The server holds `drafts`' site files in
|
|
8
|
+
* memory: downloaded once at startup as an archive, keeping only the files under
|
|
9
|
+
* the declared collection folders plus Site Settings (Decision 3), then kept current
|
|
10
|
+
* by the server's own commits and merges. When `drafts` moved under it (another
|
|
11
|
+
* server, during a deploy), it's refreshed before anything retries.
|
|
12
|
+
*
|
|
13
|
+
* **Two servers at once.** Railway runs the old and new servers side by side during
|
|
14
|
+
* a deploy. Nothing here assumes it's alone: every ref move is compare-and-swap
|
|
15
|
+
* (`expectedHeadOid`, `beforeOid`), a lost race re-reads and refreshes the copy and
|
|
16
|
+
* goes round again, Publish takes a Redis lock, and the newest publish is kept in
|
|
17
|
+
* Redis, so a status read on the new server knows about a publish the old one made.
|
|
18
|
+
*
|
|
19
|
+
* **Rate limits** (M1 → G4: 80 content-creating requests a minute, 500 an hour).
|
|
20
|
+
* One commit per batch; `drafts`' head comes from the copy, so a draft commit is
|
|
21
|
+
* one request; status reads ask GitHub at most once a minute (Decision 8) and the
|
|
22
|
+
* deployments at most every 10 seconds while a publish is in flight (Decision 13).
|
|
23
|
+
*
|
|
24
|
+
* **Deletions** (M5a Decisions 12): a draft entry with `text` null removes the file
|
|
25
|
+
* (`commitDrafts`), merged like any change with the draft winning (a file the editor
|
|
26
|
+
* deleted that `main` changed stays deleted, with `Draft-kept:`). A deleted document
|
|
27
|
+
* is named "Contact (deleted)" in the draft and publish headlines, its title from
|
|
28
|
+
* `main`'s copy, else `drafts`' copy before the removal, else its path. What's
|
|
29
|
+
* waiting knows which files `main` lacks (`SinceMain.absent`), so each waiting file is
|
|
30
|
+
* new, deleted or changed (`changeOf`), and a file made and removed again since the
|
|
31
|
+
* last Publish isn't waiting at all.
|
|
32
|
+
*
|
|
33
|
+
* In-process state here is caches of git and GitHub, and throttles; none of it is
|
|
34
|
+
* the only record of anything.
|
|
35
|
+
*/
|
|
36
|
+
import { randomBytes } from 'node:crypto';
|
|
37
|
+
import { relative, sep } from 'node:path';
|
|
38
|
+
import { repoPrefix, SITE_SETTINGS_FILE, type ResolvedSite } from '../config.ts';
|
|
39
|
+
import { documentUrl, filesSource, type ContentSource } from '../content.ts';
|
|
40
|
+
import { confinePath } from '../editor/server/write.ts';
|
|
41
|
+
import { movesKey } from './autosaves.ts';
|
|
42
|
+
import type { WaitingChange } from '../editor/types.ts';
|
|
43
|
+
import { MissingBranchError, StaleHeadError, type Branch, type CommitSummary, type Comparison, type GitBackend, type Head, type Heads, type TreeChange } from './backend.ts';
|
|
44
|
+
import { HttpError } from './github.ts';
|
|
45
|
+
import { mergeFile } from './merge.ts';
|
|
46
|
+
import { draftMessage, mergeMessage, parseMessage, publishMessage, type Person } from './messages.ts';
|
|
47
|
+
import type { RedisClient } from './redis.ts';
|
|
48
|
+
import { DEFAULT_TIMINGS, type LiveTimings } from './timings.ts';
|
|
49
|
+
import { publishState, waitingFromCommits, type DeploymentFact, type PublishRecord, type PublishState, type WaitingFile } from './status.ts';
|
|
50
|
+
|
|
51
|
+
/** One autosave to commit (Decision 6): `text` is `applyEdits(base, document)`. */
|
|
52
|
+
export interface DraftEntry {
|
|
53
|
+
/** Site-relative path, `content/pages/index.json`. */
|
|
54
|
+
file: string;
|
|
55
|
+
/** The text the edits were made against: the merge's ancestor. Null for a file that didn't exist. */
|
|
56
|
+
base: string | null;
|
|
57
|
+
/** The autosave as text: the merge's draft side, which wins conflicts. Null: the editor deleted the file (M5a). */
|
|
58
|
+
text: string | null;
|
|
59
|
+
editor: Person;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DraftCommitResult {
|
|
63
|
+
/** Null when nothing changed (every entry already matched `drafts`). */
|
|
64
|
+
commit: string | null;
|
|
65
|
+
/** Site-relative files the commit changed. */
|
|
66
|
+
files: string[];
|
|
67
|
+
/** Files where a key or the whole file was decided for the autosave (the notice). */
|
|
68
|
+
keptDraft: string[];
|
|
69
|
+
/** Each entry's file as committed, by file (null: removed). */
|
|
70
|
+
texts: Map<string, string | null>;
|
|
71
|
+
attempts: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type CheckResult =
|
|
75
|
+
| { kind: 'current' }
|
|
76
|
+
| { kind: 'fast-forward'; to: string; fromMain: string[] }
|
|
77
|
+
| { kind: 'merged'; commit: string; fromMain: string[]; keptDraft: string[] };
|
|
78
|
+
|
|
79
|
+
export type PublishResult =
|
|
80
|
+
| { kind: 'nothing-waiting' }
|
|
81
|
+
/** `fileTitles`: each of `record.files`' own title, in that order (`record.titles` is the headline's items). */
|
|
82
|
+
| { kind: 'published'; record: PublishRecord; attempts: number; fileTitles: string[] }
|
|
83
|
+
/** A Publish was already running; this press joined it. */
|
|
84
|
+
| { kind: 'joined'; record: PublishRecord | null };
|
|
85
|
+
|
|
86
|
+
export interface PublishStatus {
|
|
87
|
+
/** `none`: nothing has been published since the record began. */
|
|
88
|
+
state: PublishState | 'none';
|
|
89
|
+
record: PublishRecord | null;
|
|
90
|
+
/** Live only because there's no deploy pipeline to ask (Decision 12; logged). */
|
|
91
|
+
assumed: boolean;
|
|
92
|
+
/** Try again is possible (a Railway token is set and the publish failed). */
|
|
93
|
+
canTryAgain: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HistoryEntry {
|
|
97
|
+
commit: string;
|
|
98
|
+
by: Person | null;
|
|
99
|
+
/** ISO; the editor shows it in the site's time zone (Decision 16). */
|
|
100
|
+
at: string;
|
|
101
|
+
titles: string[];
|
|
102
|
+
files: string[];
|
|
103
|
+
state: PublishState;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RailwayConfig {
|
|
107
|
+
/** CARACMS_RAILWAY_TOKEN: a project token (Decision 12). */
|
|
108
|
+
token: string;
|
|
109
|
+
serviceId: string;
|
|
110
|
+
environmentId: string;
|
|
111
|
+
/** For tests. Default: global fetch. */
|
|
112
|
+
fetch?: typeof fetch;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface LiveGitOptions {
|
|
116
|
+
backend: GitBackend;
|
|
117
|
+
site: Pick<ResolvedSite, 'root' | 'collections' | 'repoPath'>;
|
|
118
|
+
redis: RedisClient;
|
|
119
|
+
now?: () => number;
|
|
120
|
+
sleep?: (ms: number) => Promise<void>;
|
|
121
|
+
log?: (line: string) => void;
|
|
122
|
+
/** The running server's commit, read on every status (tests move it). Default: CARACMS_RUNNING_COMMIT or RAILWAY_GIT_COMMIT_SHA. */
|
|
123
|
+
runningCommit?: () => string | null;
|
|
124
|
+
timings?: Partial<LiveTimings>;
|
|
125
|
+
railway?: RailwayConfig | null;
|
|
126
|
+
/** Redis key prefix. Default `caracms:`. */
|
|
127
|
+
keyPrefix?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The site file that holds Site Settings, relative to the site (Decision 15). Defined in config.ts (M5a: the reserved `settings` collection). */
|
|
131
|
+
export { SITE_SETTINGS_FILE };
|
|
132
|
+
|
|
133
|
+
const MAX_ATTEMPTS = 4;
|
|
134
|
+
/** archiveDiff's mark for "changed, sha not known". */
|
|
135
|
+
const CHANGED = '(changed)';
|
|
136
|
+
const RAILWAY_API = 'https://backboard.railway.com/graphql/v2';
|
|
137
|
+
|
|
138
|
+
const posix = (p: string) => p.split(sep).join('/');
|
|
139
|
+
|
|
140
|
+
/** What `drafts` holds beyond `main`: from one compare, extended by this server's own commits. */
|
|
141
|
+
interface SinceMain {
|
|
142
|
+
main: string;
|
|
143
|
+
drafts: string;
|
|
144
|
+
commits: CommitSummary[];
|
|
145
|
+
/** Repo paths that differ (a superset: a file changed and changed back stays listed). */
|
|
146
|
+
files: Set<string>;
|
|
147
|
+
/** M5a: those of `files` that `main` doesn't have (new on `drafts`, or made and removed again). */
|
|
148
|
+
absent: Set<string>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class LiveGit {
|
|
152
|
+
readonly backend: GitBackend;
|
|
153
|
+
readonly redis: RedisClient;
|
|
154
|
+
readonly timings: LiveTimings;
|
|
155
|
+
readonly now: () => number;
|
|
156
|
+
/** Phase 2 fills this: commit every pending autosave (Decision 10 step 1; Decision 8 before a merge). */
|
|
157
|
+
commitPending: () => Promise<void> = async () => {};
|
|
158
|
+
/**
|
|
159
|
+
* Told the site files (and their new text) a fast-forward or merge from `main` just
|
|
160
|
+
* changed in the copy. Phase 2's editor records "only merges" for any file with no
|
|
161
|
+
* record of who changed it yet (Decision 11). Its failure never fails the check.
|
|
162
|
+
*/
|
|
163
|
+
tookIn: (files: { file: string; text: string }[]) => Promise<void> = async () => {};
|
|
164
|
+
|
|
165
|
+
private readonly site: LiveGitOptions['site'];
|
|
166
|
+
private readonly prefix: string;
|
|
167
|
+
private readonly folders: string[];
|
|
168
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
169
|
+
private readonly log: (line: string) => void;
|
|
170
|
+
private readonly runningCommit: () => string | null;
|
|
171
|
+
private readonly railway: RailwayConfig | null;
|
|
172
|
+
private readonly keyPrefix: string;
|
|
173
|
+
|
|
174
|
+
private copyHead: Head | null = null;
|
|
175
|
+
private copyFiles = new Map<string, string>();
|
|
176
|
+
private since: SinceMain | null = null;
|
|
177
|
+
private lastCheck = -Infinity;
|
|
178
|
+
private checking: Promise<CheckResult> | null = null;
|
|
179
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
180
|
+
private assumedLogged = false;
|
|
181
|
+
private deploymentsCache: { key: string; at: number; facts: DeploymentFact[]; later: Set<string> } | null = null;
|
|
182
|
+
private includesCache = new Map<string, boolean>();
|
|
183
|
+
private laterCache: { key: string; later: Set<string> } | null = null;
|
|
184
|
+
private historyCache: { at: number; entries: HistoryEntry[] } | null = null;
|
|
185
|
+
private deploymentsDenied = false;
|
|
186
|
+
/** 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. */
|
|
187
|
+
private deletedTitles = new Map<string, string>();
|
|
188
|
+
|
|
189
|
+
constructor(options: LiveGitOptions) {
|
|
190
|
+
this.backend = options.backend;
|
|
191
|
+
this.redis = options.redis;
|
|
192
|
+
this.site = options.site;
|
|
193
|
+
this.timings = { ...DEFAULT_TIMINGS, ...options.timings };
|
|
194
|
+
this.now = options.now ?? Date.now;
|
|
195
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
196
|
+
this.log = options.log ?? ((line) => console.log(`[caracms] ${line}`));
|
|
197
|
+
this.runningCommit = options.runningCommit ?? (() => process.env.CARACMS_RUNNING_COMMIT || process.env.RAILWAY_GIT_COMMIT_SHA || null);
|
|
198
|
+
this.railway = options.railway ?? null;
|
|
199
|
+
this.keyPrefix = options.keyPrefix ?? 'caracms:';
|
|
200
|
+
this.prefix = repoPrefix(options.site);
|
|
201
|
+
// Every collection's folder, shared blocks' included (M5a Decision 8); Site Settings is its one file (inScope).
|
|
202
|
+
this.folders = Object.values(options.site.collections)
|
|
203
|
+
.filter((c) => c.kind !== 'settings')
|
|
204
|
+
.map((c) => posix(relative(options.site.root, c.dir)));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Paths and the copy ──────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/** Is this repo path one the copy keeps? Collection folders' `.json` files (shared blocks' too), and Site Settings (Decision 3). */
|
|
210
|
+
inScope(repoPath: string): boolean {
|
|
211
|
+
if (!repoPath.startsWith(this.prefix)) return false;
|
|
212
|
+
const rel = repoPath.slice(this.prefix.length);
|
|
213
|
+
if (rel === SITE_SETTINGS_FILE) return true;
|
|
214
|
+
return rel.endsWith('.json') && this.folders.some((f) => rel.startsWith(`${f}/`));
|
|
215
|
+
}
|
|
216
|
+
toSite = (repoPath: string) => repoPath.slice(this.prefix.length);
|
|
217
|
+
toRepo = (sitePath: string) => `${this.prefix}${sitePath}`;
|
|
218
|
+
|
|
219
|
+
/** The copy's files, by site-relative path. Phase 3 reads `content/site-settings.json` here. */
|
|
220
|
+
files(): ReadonlyMap<string, string> {
|
|
221
|
+
return this.copyFiles;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** One file from the copy, or null. */
|
|
225
|
+
read(sitePath: string): string | null {
|
|
226
|
+
return this.copyFiles.get(sitePath) ?? null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The copy as a content.ts source: the reader's rules over `drafts` (Current state). */
|
|
230
|
+
contentSource(): ContentSource {
|
|
231
|
+
return filesSource(this.site.root, () => this.copyFiles);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `drafts`' head as the copy has it. */
|
|
235
|
+
head(): Head {
|
|
236
|
+
if (!this.copyHead) throw new Error('caracms: the drafts copy isn\'t loaded yet (call start()).');
|
|
237
|
+
return this.copyHead;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private key(name: string) {
|
|
241
|
+
return `${this.keyPrefix}${name}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Run `fn` alone among this server's git operations, so the copy is never changed by two at once. */
|
|
245
|
+
private exclusive<T>(fn: () => Promise<T>): Promise<T> {
|
|
246
|
+
const run = this.queue.then(fn, fn);
|
|
247
|
+
this.queue = run.catch(() => {});
|
|
248
|
+
return run;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Startup ─────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Create `drafts` from `main` if it's missing, download the copy, and check `main` once
|
|
255
|
+
* (`check: false` leaves the check to the caller: the runtime does, so the live editor
|
|
256
|
+
* can run it once `commitPending` is in place, Decision 8).
|
|
257
|
+
*/
|
|
258
|
+
async start(options: { check?: boolean } = {}): Promise<void> {
|
|
259
|
+
await this.backend.tracker.within('start', () =>
|
|
260
|
+
this.exclusive(async () => {
|
|
261
|
+
let heads: Heads;
|
|
262
|
+
try {
|
|
263
|
+
heads = await this.backend.readHeads();
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (!(error instanceof MissingBranchError) || error.branch !== 'drafts') throw error;
|
|
266
|
+
const main = await this.mainOnly();
|
|
267
|
+
this.log(`no drafts branch yet: creating it from main (${main.slice(0, 7)})`);
|
|
268
|
+
await this.backend.createBranch('drafts', main).catch((e: unknown) => {
|
|
269
|
+
if (!(e instanceof HttpError && e.status === 422)) throw e; // another server made it first
|
|
270
|
+
});
|
|
271
|
+
heads = await this.backend.readHeads();
|
|
272
|
+
}
|
|
273
|
+
await this.download(heads.drafts);
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
if (options.check !== false) await this.check({ force: true });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `main`'s head when `drafts` is missing (readHeads throws then). */
|
|
280
|
+
private async mainOnly(): Promise<string> {
|
|
281
|
+
// listCommits reads main alone; its first entry is the head.
|
|
282
|
+
const [head] = await this.backend.listCommits('main', 1);
|
|
283
|
+
if (!head) throw new MissingBranchError('main');
|
|
284
|
+
return head.sha;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private async download(head: Head) {
|
|
288
|
+
const files = await this.backend.readArchive(head.commit, (p) => this.inScope(p));
|
|
289
|
+
this.copyFiles = new Map([...files].map(([p, t]) => [this.toSite(p), t]));
|
|
290
|
+
this.copyHead = head;
|
|
291
|
+
this.since = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Bring the copy up to `drafts` as GitHub has it: the commits since, or a fresh download. */
|
|
295
|
+
private async refresh(heads: Heads) {
|
|
296
|
+
const copy = this.head();
|
|
297
|
+
if (heads.drafts.commit === copy.commit) return;
|
|
298
|
+
const cmp = await this.backend.compare(copy.commit, heads.drafts.commit);
|
|
299
|
+
if (cmp.status !== 'ahead' || cmp.files === null) {
|
|
300
|
+
this.log(`drafts moved to ${heads.drafts.commit.slice(0, 7)} (not on from the copy): downloading it again`);
|
|
301
|
+
await this.download(heads.drafts);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// A file new to the waiting list was the same on main as in the copy, so main has it if the copy did.
|
|
305
|
+
const hadIt = new Map(cmp.files.map((f) => [f.path, this.copyFiles.has(this.toSite(f.path))]));
|
|
306
|
+
await this.applyChanges(heads.drafts, cmp);
|
|
307
|
+
if (this.since && this.since.main === heads.main.commit) {
|
|
308
|
+
this.since.drafts = heads.drafts.commit;
|
|
309
|
+
this.since.commits.push(...cmp.commits);
|
|
310
|
+
for (const f of cmp.files) {
|
|
311
|
+
if (!this.since.files.has(f.path) && !hadIt.get(f.path)) this.since.absent.add(f.path);
|
|
312
|
+
this.since.files.add(f.path);
|
|
313
|
+
}
|
|
314
|
+
} else this.since = null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Lay a comparison's in-scope files (at `to`) onto the copy. */
|
|
318
|
+
private async applyChanges(to: Head, cmp: Comparison) {
|
|
319
|
+
const changed = (cmp.files ?? []).filter((f) => this.inScope(f.path));
|
|
320
|
+
const reads = changed.filter((f) => f.sha !== null);
|
|
321
|
+
const texts = reads.length ? await this.backend.readFiles(reads.map((f) => ({ commit: to.commit, path: f.path }))) : [];
|
|
322
|
+
reads.forEach((f, i) => {
|
|
323
|
+
const text = texts[i];
|
|
324
|
+
if (text === null || text === undefined) this.copyFiles.delete(this.toSite(f.path));
|
|
325
|
+
else this.copyFiles.set(this.toSite(f.path), text);
|
|
326
|
+
});
|
|
327
|
+
for (const f of changed) if (f.sha === null) this.copyFiles.delete(this.toSite(f.path));
|
|
328
|
+
this.copyHead = to;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Re-read both heads and refresh the copy: after any failed ref move (M1 → G6 (a)). */
|
|
332
|
+
private async reread(): Promise<Heads> {
|
|
333
|
+
const heads = await this.backend.readHeads();
|
|
334
|
+
await this.refresh(heads);
|
|
335
|
+
return heads;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ── Draft commits (Decisions 5 and 6) ───────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Commit a batch of autosaves to `drafts` in **one** commit, each merged with the
|
|
342
|
+
* file in `drafts` now (Decision 6), with who changed what in the message
|
|
343
|
+
* (Decision 5). Entries for one file apply in order. A lost race refreshes the copy
|
|
344
|
+
* and merges again.
|
|
345
|
+
*/
|
|
346
|
+
async commitDrafts(entries: DraftEntry[]): Promise<DraftCommitResult> {
|
|
347
|
+
return this.backend.tracker.within('draft', () =>
|
|
348
|
+
this.exclusive(async () => {
|
|
349
|
+
for (let attempt = 1; ; attempt++) {
|
|
350
|
+
const head = this.head();
|
|
351
|
+
const texts = new Map<string, string | null>();
|
|
352
|
+
const kept = new Set<string>();
|
|
353
|
+
const edits: { file: string; title: string; editor: Person }[] = [];
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const perKey = entry.file.endsWith('.json');
|
|
356
|
+
const current = texts.has(entry.file) ? texts.get(entry.file)! : this.read(entry.file);
|
|
357
|
+
const merged = mergeFile({ base: entry.base, draft: entry.text, other: current, perKey });
|
|
358
|
+
// The notice is for changes made outside this batch: a later entry for the same file is checked against drafts.
|
|
359
|
+
const outside = texts.has(entry.file) ? mergeFile({ base: entry.base, draft: entry.text, other: this.read(entry.file), perKey }) : merged;
|
|
360
|
+
if (outside.keptDraft) kept.add(entry.file);
|
|
361
|
+
// An editor's autosave never deletes: a null here means "deleted on the other side", and the draft
|
|
362
|
+
// wins it. A deletion (M5a: `text` null) merges like any change, so a removal the draft wins removes it.
|
|
363
|
+
texts.set(entry.file, entry.text === null ? merged.text : (merged.text ?? entry.text));
|
|
364
|
+
edits.push({ file: entry.file, title: '', editor: entry.editor });
|
|
365
|
+
}
|
|
366
|
+
const changed = [...texts].filter(([file, text]) => this.read(file) !== text);
|
|
367
|
+
if (changed.length === 0) return { commit: null, files: [], keptDraft: [], texts, attempts: attempt };
|
|
368
|
+
const changedFiles = new Set(changed.map(([f]) => f));
|
|
369
|
+
const removed = changed.filter(([, text]) => text === null).map(([f]) => f);
|
|
370
|
+
const deletedTitles = await this.titlesBeforeRemoval(removed);
|
|
371
|
+
const titled = edits
|
|
372
|
+
.filter((e) => changedFiles.has(e.file))
|
|
373
|
+
.map((e) => ({ ...e, title: texts.get(e.file) === null ? `${deletedTitles.get(e.file)!} (deleted)` : titleOf(e.file, texts.get(e.file)!) }));
|
|
374
|
+
const { headline, body } = draftMessage(titled, [...kept].filter((f) => changedFiles.has(f)));
|
|
375
|
+
try {
|
|
376
|
+
const next = await this.backend.commitOnBranch(
|
|
377
|
+
'drafts',
|
|
378
|
+
head.commit,
|
|
379
|
+
changed.filter(([, text]) => text !== null).map(([file, contents]) => ({ path: this.toRepo(file), contents: contents! })),
|
|
380
|
+
removed.map(this.toRepo),
|
|
381
|
+
headline,
|
|
382
|
+
body,
|
|
383
|
+
);
|
|
384
|
+
// Before the copy changes: a file new to the waiting list is on main exactly when drafts had it.
|
|
385
|
+
const absentOnMain = changed.filter(([file]) => this.read(file) === null).map(([file]) => this.toRepo(file));
|
|
386
|
+
for (const [file, text] of changed) {
|
|
387
|
+
if (text === null) this.copyFiles.delete(file);
|
|
388
|
+
else this.copyFiles.set(file, text);
|
|
389
|
+
}
|
|
390
|
+
for (const [file, title] of deletedTitles) this.deletedTitles.set(file, title);
|
|
391
|
+
this.copyHead = next;
|
|
392
|
+
if (this.since?.drafts === head.commit) {
|
|
393
|
+
this.since.drafts = next.commit;
|
|
394
|
+
this.since.commits.push({ sha: next.commit, parents: [head.commit], message: `${headline}\n\n${body}`, date: new Date(this.now()).toISOString() });
|
|
395
|
+
for (const [file] of changed) {
|
|
396
|
+
const repo = this.toRepo(file);
|
|
397
|
+
if (!this.since.files.has(repo) && absentOnMain.includes(repo)) this.since.absent.add(repo);
|
|
398
|
+
this.since.files.add(repo);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
this.log(`draft ${next.commit.slice(0, 7)}: ${[...changedFiles].join(', ')}`);
|
|
402
|
+
return { commit: next.commit, files: [...changedFiles], keptDraft: [...kept].filter((f) => changedFiles.has(f)), texts, attempts: attempt };
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const heads = await this.backend.readHeads().catch(() => null);
|
|
405
|
+
const moved = heads !== null && heads.drafts.commit !== head.commit;
|
|
406
|
+
if (!(error instanceof StaleHeadError) && !moved) throw error;
|
|
407
|
+
if (attempt >= MAX_ATTEMPTS) throw new Error(`caracms: drafts kept moving; gave up committing after ${attempt} tries.`);
|
|
408
|
+
this.log(`draft: drafts moved under us; refreshing the copy and merging again`);
|
|
409
|
+
await this.refresh(heads ?? (await this.backend.readHeads()));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Keeping drafts up to date with main (Decision 8) ────────────────────────
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Is `main` in `drafts`? If `drafts` is only behind, fast-forward it; if they've
|
|
420
|
+
* diverged, commit every pending autosave and merge per key. Throttled to once a
|
|
421
|
+
* minute unless `force` (startup, the webhook, Publish). Concurrent calls share one run.
|
|
422
|
+
*/
|
|
423
|
+
async check(options: { force?: boolean } = {}): Promise<CheckResult> {
|
|
424
|
+
// Not throttled while the cached compare is missing (after a refresh), so waiting() comes back soon.
|
|
425
|
+
if (!options.force && this.since && this.now() - this.lastCheck < this.timings.mainCheckMs) return { kind: 'current' };
|
|
426
|
+
if (this.checking) return this.checking;
|
|
427
|
+
this.lastCheck = this.now();
|
|
428
|
+
this.checking = this.backend.tracker
|
|
429
|
+
.within('check', async () => {
|
|
430
|
+
let pendingCommitted = false;
|
|
431
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS + 1; attempt++) {
|
|
432
|
+
const r = await this.exclusive(() => this.checkOnce(pendingCommitted));
|
|
433
|
+
if (r !== 'needs-pending') {
|
|
434
|
+
await this.exclusive(() => this.learnDeletedTitles());
|
|
435
|
+
return r;
|
|
436
|
+
}
|
|
437
|
+
await this.commitPending();
|
|
438
|
+
pendingCommitted = true;
|
|
439
|
+
}
|
|
440
|
+
throw new Error('caracms: main and drafts kept moving; gave up keeping drafts up to date.');
|
|
441
|
+
})
|
|
442
|
+
.finally(() => {
|
|
443
|
+
this.checking = null;
|
|
444
|
+
});
|
|
445
|
+
return this.checking;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private async checkOnce(pendingCommitted: boolean): Promise<CheckResult | 'needs-pending'> {
|
|
449
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
450
|
+
const heads = await this.reread();
|
|
451
|
+
if (this.since && this.since.main === heads.main.commit && this.since.drafts === heads.drafts.commit) return { kind: 'current' };
|
|
452
|
+
const cmp = await this.backend.compare(heads.drafts.commit, heads.main.commit); // main relative to drafts
|
|
453
|
+
if (cmp.status === 'identical' || cmp.status === 'behind') {
|
|
454
|
+
// main is in drafts: just learn what's waiting.
|
|
455
|
+
await this.learnSince(heads);
|
|
456
|
+
return { kind: 'current' };
|
|
457
|
+
}
|
|
458
|
+
if (cmp.status === 'ahead') {
|
|
459
|
+
const done = await this.fastForward(heads, cmp);
|
|
460
|
+
if (done) return done;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (!pendingCommitted) return 'needs-pending';
|
|
464
|
+
const merged = await this.merge(heads, cmp);
|
|
465
|
+
if (merged) return merged;
|
|
466
|
+
}
|
|
467
|
+
throw new Error('caracms: drafts kept moving; gave up merging main into it.');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** What `drafts` holds beyond `main` (Decision 8's cached compare). */
|
|
471
|
+
private async learnSince(heads: Heads) {
|
|
472
|
+
if (heads.main.commit === heads.drafts.commit) {
|
|
473
|
+
this.since = { main: heads.main.commit, drafts: heads.drafts.commit, commits: [], files: new Set(), absent: new Set() };
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const cmp = await this.backend.compare(heads.main.commit, heads.drafts.commit);
|
|
477
|
+
this.since = {
|
|
478
|
+
main: heads.main.commit,
|
|
479
|
+
drafts: heads.drafts.commit,
|
|
480
|
+
commits: cmp.commits,
|
|
481
|
+
files: new Set((cmp.files ?? this.diffCopyFallback()).map((f) => (typeof f === 'string' ? f : f.path))),
|
|
482
|
+
// Added on drafts: main doesn't have it. (With too many files to list, none is known to be missing.)
|
|
483
|
+
absent: new Set((cmp.files ?? []).filter((f) => f.status === 'added').map((f) => f.path)),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** With too many files for GitHub's compare: every in-scope file counts as possibly waiting. */
|
|
488
|
+
private diffCopyFallback(): string[] {
|
|
489
|
+
this.log('compare listed too many files: treating every content file as possibly waiting');
|
|
490
|
+
return [...this.copyFiles.keys()].map(this.toRepo);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** `drafts` only behind `main`: move it up, no merge commit (Decision 8). Null when it lost a race. */
|
|
494
|
+
private async fastForward(heads: Heads, cmp: Comparison): Promise<CheckResult | null> {
|
|
495
|
+
if (!(await this.moveRefs([{ branch: 'drafts', before: heads.drafts.commit, after: heads.main.commit, force: false }]))) return null;
|
|
496
|
+
if (cmp.files === null) await this.download(heads.main);
|
|
497
|
+
else await this.applyChanges(heads.main, cmp);
|
|
498
|
+
this.since = { main: heads.main.commit, drafts: heads.main.commit, commits: [], files: new Set(), absent: new Set() };
|
|
499
|
+
const fromMain = (cmp.files ?? []).filter((f) => this.inScope(f.path)).map((f) => this.toSite(f.path));
|
|
500
|
+
this.log(`drafts fast-forwarded to main (${heads.main.commit.slice(0, 7)})`);
|
|
501
|
+
await this.told(fromMain);
|
|
502
|
+
return { kind: 'fast-forward', to: heads.main.commit, fromMain };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* `main` and `drafts` diverged: a merge commit (parents drafts, main) whose tree is
|
|
507
|
+
* `main`'s with `drafts`' changes laid over it, and per-key merges (merge.ts) for
|
|
508
|
+
* content files changed on both sides. Null when it lost a race.
|
|
509
|
+
*/
|
|
510
|
+
private async merge(heads: Heads, mainSide: Comparison): Promise<CheckResult | null> {
|
|
511
|
+
const draftsSide = await this.backend.compare(heads.main.commit, heads.drafts.commit);
|
|
512
|
+
const base = mainSide.mergeBase.commit;
|
|
513
|
+
|
|
514
|
+
// Main's in-scope changes since the merge base. Out-of-scope ones come with main's tree.
|
|
515
|
+
let mainChanged: Map<string, string | null>;
|
|
516
|
+
if (mainSide.files) mainChanged = new Map(mainSide.files.filter((f) => this.inScope(f.path)).map((f) => [f.path, f.sha]));
|
|
517
|
+
else mainChanged = await this.archiveDiff(base, heads.main.commit);
|
|
518
|
+
let draftsChanged: Map<string, string | null>;
|
|
519
|
+
if (draftsSide.files) draftsChanged = new Map(draftsSide.files.map((f) => [f.path, f.sha]));
|
|
520
|
+
else {
|
|
521
|
+
this.log('merge: too many drafts-side files to list; only content files are carried over');
|
|
522
|
+
draftsChanged = await this.archiveDiff(base, heads.drafts.commit);
|
|
523
|
+
}
|
|
524
|
+
// archiveDiff knows texts, not blob shas: drafts' side is then written from the copy.
|
|
525
|
+
const fromCopy = (path: string) => draftsChanged.get(path) === CHANGED;
|
|
526
|
+
|
|
527
|
+
const both = [...draftsChanged.keys()].filter((p) => mainChanged.has(p));
|
|
528
|
+
const both3 = both.filter((p) => this.inScope(p));
|
|
529
|
+
const mainOnly = [...mainChanged].filter(([p, sha]) => !draftsChanged.has(p) && sha !== null).map(([p]) => p);
|
|
530
|
+
const reads = [
|
|
531
|
+
...both3.flatMap((p) => [
|
|
532
|
+
{ commit: base, path: p },
|
|
533
|
+
{ commit: heads.main.commit, path: p },
|
|
534
|
+
]),
|
|
535
|
+
...mainOnly.map((p) => ({ commit: heads.main.commit, path: p })),
|
|
536
|
+
];
|
|
537
|
+
const texts = reads.length ? await this.backend.readFiles(reads) : [];
|
|
538
|
+
|
|
539
|
+
const changes: TreeChange[] = [];
|
|
540
|
+
const writes: { path: string; contents: string }[] = [];
|
|
541
|
+
const copyAfter = new Map<string, string | null>();
|
|
542
|
+
const keptDraft: string[] = [];
|
|
543
|
+
// Changed only on drafts' side: drafts' version over main's tree. (Content files are plain files.)
|
|
544
|
+
for (const [path, sha] of draftsChanged) {
|
|
545
|
+
if (mainChanged.has(path)) continue;
|
|
546
|
+
if (fromCopy(path)) writes.push({ path, contents: this.read(this.toSite(path))! });
|
|
547
|
+
else changes.push({ path, mode: '100644', sha });
|
|
548
|
+
}
|
|
549
|
+
both3.forEach((path, i) => {
|
|
550
|
+
const site = this.toSite(path);
|
|
551
|
+
const result = mergeFile({ base: texts[i * 2] ?? null, other: texts[i * 2 + 1] ?? null, draft: this.read(site), perKey: path.endsWith('.json') });
|
|
552
|
+
if (result.keptDraft) keptDraft.push(site);
|
|
553
|
+
if (result.text === null) changes.push({ path, mode: '100644', sha: null });
|
|
554
|
+
else writes.push({ path, contents: result.text });
|
|
555
|
+
copyAfter.set(site, result.text);
|
|
556
|
+
});
|
|
557
|
+
// Changed on both sides outside the copy (code pushed to drafts): the draft's version, whole.
|
|
558
|
+
for (const path of both.filter((p) => !this.inScope(p))) changes.push({ path, mode: '100644', sha: draftsChanged.get(path)! });
|
|
559
|
+
mainOnly.forEach((path, i) => copyAfter.set(this.toSite(path), texts[both3.length * 2 + i] ?? null));
|
|
560
|
+
for (const [path, sha] of mainChanged) if (sha === null && !draftsChanged.has(path)) copyAfter.set(this.toSite(path), null);
|
|
561
|
+
|
|
562
|
+
const tree = changes.length || writes.length ? await this.backend.createTree(heads.main.tree, changes, writes) : heads.main.tree;
|
|
563
|
+
const fromMain = [...mainChanged.keys()].filter((p) => !draftsChanged.has(p)).map(this.toSite);
|
|
564
|
+
const message = mergeMessage(fromMain, keptDraft);
|
|
565
|
+
const commit = await this.backend.createCommit(tree, [heads.drafts.commit, heads.main.commit], message);
|
|
566
|
+
if (!(await this.moveRefs([{ branch: 'drafts', before: heads.drafts.commit, after: commit, force: false }]))) return null;
|
|
567
|
+
|
|
568
|
+
for (const [site, text] of copyAfter) {
|
|
569
|
+
if (text === null) this.copyFiles.delete(site);
|
|
570
|
+
else this.copyFiles.set(site, text);
|
|
571
|
+
}
|
|
572
|
+
this.copyHead = { commit, tree };
|
|
573
|
+
// Which of drafts' files main lacks now: one main deleted (the draft kept it), or one drafts added that main didn't.
|
|
574
|
+
const addedOnDrafts = new Set((draftsSide.files ?? []).filter((f) => f.status === 'added').map((f) => f.path));
|
|
575
|
+
const absent = [...draftsChanged.keys()].filter((p) => (mainChanged.has(p) ? mainChanged.get(p) === null : addedOnDrafts.has(p)));
|
|
576
|
+
this.since = {
|
|
577
|
+
main: heads.main.commit,
|
|
578
|
+
drafts: commit,
|
|
579
|
+
commits: [...draftsSide.commits, { sha: commit, parents: [heads.drafts.commit, heads.main.commit], message, date: new Date(this.now()).toISOString() }],
|
|
580
|
+
files: new Set(draftsChanged.keys()),
|
|
581
|
+
absent: new Set(absent),
|
|
582
|
+
};
|
|
583
|
+
this.log(`merged main into drafts (${commit.slice(0, 7)}): from main [${fromMain.join(', ')}], draft kept [${keptDraft.join(', ')}]`);
|
|
584
|
+
await this.told([...copyAfter.keys()]);
|
|
585
|
+
return { kind: 'merged', commit, fromMain, keptDraft };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Hand `tookIn` the files a check just changed in the copy (deleted ones left out). */
|
|
589
|
+
private async told(files: string[]) {
|
|
590
|
+
const changed = [...new Set(files)].flatMap((file) => {
|
|
591
|
+
const text = this.read(file);
|
|
592
|
+
return text === null ? [] : [{ file, text }];
|
|
593
|
+
});
|
|
594
|
+
if (!changed.length) return;
|
|
595
|
+
await this.tookIn(changed).catch((error: unknown) => this.log(`recording what main changed: ${error instanceof Error ? error.message : String(error)}`));
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** In-scope files that differ between two commits, from their archives (when compare can't list them). */
|
|
599
|
+
private async archiveDiff(from: string, to: string): Promise<Map<string, string | null>> {
|
|
600
|
+
const [a, b] = await Promise.all([this.backend.readArchive(from, (p) => this.inScope(p)), this.backend.readArchive(to, (p) => this.inScope(p))]);
|
|
601
|
+
const out = new Map<string, string | null>();
|
|
602
|
+
for (const p of new Set([...a.keys(), ...b.keys()])) {
|
|
603
|
+
if (a.get(p) === b.get(p)) continue;
|
|
604
|
+
out.set(p, b.has(p) ? CHANGED : null);
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Move refs, compare-and-swap. **After any error, re-read both heads** (M1 → G6 (a):
|
|
611
|
+
* GitHub's error for a stale `beforeOid` is generic): if a head moved, it lost a race,
|
|
612
|
+
* and the copy is refreshed; returns false. Anything else is thrown. **If every ref
|
|
613
|
+
* already points where this call moved it**, the move landed and only its answer was
|
|
614
|
+
* lost: that's a success.
|
|
615
|
+
*/
|
|
616
|
+
private async moveRefs(updates: { branch: Branch; before: string; after: string; force: boolean }[]): Promise<boolean> {
|
|
617
|
+
try {
|
|
618
|
+
await this.backend.updateRefs(updates);
|
|
619
|
+
return true;
|
|
620
|
+
} catch (error) {
|
|
621
|
+
const heads = await this.backend.readHeads();
|
|
622
|
+
if (updates.every((u) => heads[u.branch].commit === u.after)) {
|
|
623
|
+
this.log(`the ref move's answer was lost, but ${updates.map((u) => u.branch).join(' and ')} point where it moved them: it landed`);
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
const moved = updates.some((u) => heads[u.branch].commit !== u.before);
|
|
627
|
+
if (!moved && !(error instanceof StaleHeadError)) throw error;
|
|
628
|
+
this.log(`a ref moved under us (${updates.map((u) => u.branch).join(', ')}); re-reading and trying again`);
|
|
629
|
+
await this.refresh(heads);
|
|
630
|
+
this.since = null;
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── Publish (Decision 10) ───────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Publish: commit every pending autosave; refuse to go on unless `main` is in
|
|
639
|
+
* `drafts` (merging inside the retry loop); one squashed commit with `drafts`' tree on
|
|
640
|
+
* `main`'s head; both refs moved in one `updateRefs`; after any error, re-read and
|
|
641
|
+
* start again. Nothing waiting: nothing happens. One Publish at a time per site (a
|
|
642
|
+
* Redis lock); a second press while one runs joins it.
|
|
643
|
+
*/
|
|
644
|
+
async publish(by: Person): Promise<PublishResult> {
|
|
645
|
+
const lock = this.key('publish:lock');
|
|
646
|
+
const token = randomBytes(12).toString('base64url');
|
|
647
|
+
if (!(await this.redis.set(lock, token, { nx: true, px: this.timings.publishLockMs }))) {
|
|
648
|
+
const until = this.now() + this.timings.publishLockMs;
|
|
649
|
+
while (this.now() < until && (await this.redis.get(lock)) !== null) await this.sleep(500);
|
|
650
|
+
return { kind: 'joined', record: await this.lastPublish() };
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
return await this.backend.tracker.within('publish', async () => {
|
|
654
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
655
|
+
await this.commitPending();
|
|
656
|
+
const r = await this.exclusive(() => this.publishOnce(by, attempt));
|
|
657
|
+
if (r) return r;
|
|
658
|
+
}
|
|
659
|
+
throw new Error(`caracms: publish gave up after ${MAX_ATTEMPTS} tries (main or drafts kept moving).`);
|
|
660
|
+
});
|
|
661
|
+
} finally {
|
|
662
|
+
await this.redis.compareAndDelete(lock, token).catch(() => {});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async publishOnce(by: Person, attempt: number): Promise<PublishResult | null> {
|
|
667
|
+
const heads = await this.reread();
|
|
668
|
+
if (heads.main.tree === heads.drafts.tree) {
|
|
669
|
+
this.log('publish: nothing waiting');
|
|
670
|
+
return { kind: 'nothing-waiting' };
|
|
671
|
+
}
|
|
672
|
+
const cmp = await this.backend.compare(heads.main.commit, heads.drafts.commit);
|
|
673
|
+
if (cmp.status !== 'ahead' && cmp.status !== 'identical') {
|
|
674
|
+
// A push landed on main (M1 → G6 (e)): merge it in (pending autosaves were just committed), then go round.
|
|
675
|
+
this.log(`publish: main isn't in drafts (${cmp.status}); merging it in first`);
|
|
676
|
+
const mainSide = await this.backend.compare(heads.drafts.commit, heads.main.commit);
|
|
677
|
+
await this.merge(heads, mainSide);
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
const repoFiles = (cmp.files ?? this.diffCopyFallback().map((path) => ({ path }))).map((f) => f.path);
|
|
681
|
+
const files = repoFiles.filter((p) => p.startsWith(this.prefix)).map(this.toSite);
|
|
682
|
+
// A file drafts no longer has is deleted: "Contact (deleted)", its title from main's copy first (M5a Decision 12).
|
|
683
|
+
const gone = files.filter((f) => this.read(f) === null);
|
|
684
|
+
const goneTitles = await this.titlesBeforeRemoval(gone, heads.main.commit);
|
|
685
|
+
const records = await this.moveRecords();
|
|
686
|
+
const moves = this.movesIn(files, records);
|
|
687
|
+
// Each file's own title (a "stood" notice names the document by it), then the headline's items.
|
|
688
|
+
const fileTitles = files.map((f) => {
|
|
689
|
+
const text = this.read(f);
|
|
690
|
+
return text === null ? goneTitles.get(f)! : titleOf(f, text);
|
|
691
|
+
});
|
|
692
|
+
const items = files.map((f, i) => {
|
|
693
|
+
if (this.read(f) === null) return `${fileTitles[i]!} (deleted)`;
|
|
694
|
+
return moves.from.has(f) ? `${fileTitles[i]!} (moved from ${moves.from.get(f)})` : fileTitles[i]!;
|
|
695
|
+
});
|
|
696
|
+
// Pre-M7 item 8: a moved document is one item in the headline, "Visit (moved from /studio/visit)",
|
|
697
|
+
// listed first; its old file is part of that item, not one of its own.
|
|
698
|
+
const titles = [...items.filter((_, i) => moves.from.has(files[i]!)), ...items.filter((_, i) => !moves.from.has(files[i]!) && !moves.to.has(files[i]!))];
|
|
699
|
+
const commit = await this.backend.createCommit(heads.drafts.tree, [heads.main.commit], publishMessage(titles, files, by));
|
|
700
|
+
const moved = await this.moveRefs([
|
|
701
|
+
// main: a fast-forward onto the squash commit. drafts: not a fast-forward, so
|
|
702
|
+
// `force`, but still compare-and-swap on `before` (M1 → G2).
|
|
703
|
+
{ branch: 'main', before: heads.main.commit, after: commit, force: false },
|
|
704
|
+
{ branch: 'drafts', before: heads.drafts.commit, after: commit, force: true },
|
|
705
|
+
]);
|
|
706
|
+
if (!moved) return null; // the squash commit is left orphaned; nothing points at it
|
|
707
|
+
this.copyHead = { commit, tree: heads.drafts.tree };
|
|
708
|
+
this.since = { main: commit, drafts: commit, commits: [], files: new Set(), absent: new Set() };
|
|
709
|
+
this.deletedTitles.clear();
|
|
710
|
+
this.historyCache = null;
|
|
711
|
+
// The moves this publish took live are done with (a move made since stays waiting).
|
|
712
|
+
const published = [...records].filter(([old]) => files.includes(old)).map(([old]) => old);
|
|
713
|
+
if (published.length) await this.redis.hdel(movesKey(this.keyPrefix), ...published).catch(() => 0);
|
|
714
|
+
const record: PublishRecord = { commit, by, at: new Date(this.now()).toISOString(), files, titles, tryAgainAt: null };
|
|
715
|
+
await this.redis.set(this.key('publish:last'), JSON.stringify(record));
|
|
716
|
+
this.log(`published ${commit.slice(0, 7)} (attempt ${attempt}): ${titles.join(', ')}`);
|
|
717
|
+
return { kind: 'published', record, attempts: attempt, fileTitles };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The newest publish, from Redis. */
|
|
721
|
+
async lastPublish(): Promise<PublishRecord | null> {
|
|
722
|
+
const raw = await this.redis.get(this.key('publish:last'));
|
|
723
|
+
if (!raw) return null;
|
|
724
|
+
try {
|
|
725
|
+
return JSON.parse(raw) as PublishRecord;
|
|
726
|
+
} catch {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// ── What's waiting (Decision 8) ─────────────────────────────────────────────
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Files that differ between `main` and `drafts`, with who changed them and when,
|
|
735
|
+
* from git (the cached compare plus this server's own commits since). **Never asks
|
|
736
|
+
* GitHub.** Phase 2 lays Redis's uncommitted autosaves on top.
|
|
737
|
+
*/
|
|
738
|
+
waiting(): (WaitingFile & { title: string; change: WaitingChange })[] {
|
|
739
|
+
if (!this.since) return [];
|
|
740
|
+
const files = [...this.since.files].filter((p) => this.inScope(p)).map(this.toSite);
|
|
741
|
+
return waitingFromCommits(files, this.since.commits).flatMap((w) => {
|
|
742
|
+
const change = this.changeOf(w.file, this.read(w.file));
|
|
743
|
+
return change ? [{ ...w, change, title: this.titleFor(w.file) }] : [];
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* M5a: does `main` have this file? From the cached compare when the file is waiting
|
|
749
|
+
* (`absent`), else as `drafts` has it (the two are the same there). Never asks GitHub.
|
|
750
|
+
*/
|
|
751
|
+
mainHas(sitePath: string): boolean {
|
|
752
|
+
const repo = this.toRepo(sitePath);
|
|
753
|
+
if (this.since?.files.has(repo)) return !this.since.absent.has(repo);
|
|
754
|
+
return this.read(sitePath) !== null;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* M5a: what a document whose text is now `text` (null: deleted) is to `main`: `new`,
|
|
759
|
+
* `deleted` or `changed`; null when it's neither on `main` nor here (made and
|
|
760
|
+
* deleted again since the last Publish: nothing to publish, so not waiting).
|
|
761
|
+
*/
|
|
762
|
+
changeOf(sitePath: string, text: string | null): WaitingChange | null {
|
|
763
|
+
const onMain = this.mainHas(sitePath);
|
|
764
|
+
if (text === null) return onMain ? 'deleted' : null;
|
|
765
|
+
return onMain ? 'changed' : 'new';
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** Pre-M7 item 8: the documents a publish moves (`movesIn`), over `drafts`' copy. */
|
|
769
|
+
private movesIn(files: string[], records: ReadonlyMap<string, string>): Moves {
|
|
770
|
+
return movesIn(this.site, files, (f) => this.read(f), records);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Pre-M7 item 8: the moves recorded as they were made (autosaves.ts → `moves`), old file → new file. */
|
|
774
|
+
private async moveRecords(): Promise<Map<string, string>> {
|
|
775
|
+
return new Map(Object.entries(await this.redis.hgetall(movesKey(this.keyPrefix))));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** A file's title for the waiting list and notices: the copy's, else the title it had when it was deleted, else its name. */
|
|
779
|
+
titleFor(sitePath: string): string {
|
|
780
|
+
const text = this.read(sitePath);
|
|
781
|
+
if (text !== null) return titleOf(sitePath, text);
|
|
782
|
+
return this.deletedTitles.get(sitePath) ?? titleOf(sitePath, null);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* M5a: the title of each deletion waiting to publish that this process hasn't heard of
|
|
787
|
+
* (a restart, another server's commit), from `main`'s copy of the file, which exists for
|
|
788
|
+
* any deletion still waiting. So the waiting list names it as the document, not its file.
|
|
789
|
+
* A failed read leaves the file's name (the same fallback as the commit messages) unsaved, so the next check tries again.
|
|
790
|
+
*/
|
|
791
|
+
private async learnDeletedTitles() {
|
|
792
|
+
if (!this.since) return;
|
|
793
|
+
const missing = [...this.since.files]
|
|
794
|
+
.filter((p) => this.inScope(p))
|
|
795
|
+
.map(this.toSite)
|
|
796
|
+
.filter((f) => this.read(f) === null && this.mainHas(f) && !this.deletedTitles.has(f));
|
|
797
|
+
if (missing.length === 0) return;
|
|
798
|
+
const titles = await this.titlesBeforeRemoval(missing, this.since.main);
|
|
799
|
+
for (const [file, title] of titles) if (title !== titleOf(file, null)) this.deletedTitles.set(file, title);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Deleted files' titles for the commit messages (M5a Decision 12): `main`'s copy
|
|
804
|
+
* (one read, only for files `main` has), else `drafts`' copy before the removal,
|
|
805
|
+
* else the file's name. A failed read falls back the same way.
|
|
806
|
+
*/
|
|
807
|
+
private async titlesBeforeRemoval(files: string[], mainCommit?: string): Promise<Map<string, string>> {
|
|
808
|
+
const out = new Map<string, string>();
|
|
809
|
+
if (files.length === 0) return out;
|
|
810
|
+
const onMain = files.filter((f) => this.mainHas(f));
|
|
811
|
+
let mainTexts: (string | null)[] = [];
|
|
812
|
+
if (onMain.length) {
|
|
813
|
+
try {
|
|
814
|
+
const main = mainCommit ?? this.since?.main ?? (await this.backend.readHeads()).main.commit;
|
|
815
|
+
mainTexts = await this.backend.readFiles(onMain.map((f) => ({ commit: main, path: this.toRepo(f) })));
|
|
816
|
+
} catch (error) {
|
|
817
|
+
this.log(`reading main's copy of deleted files for their titles: ${error instanceof Error ? error.message : String(error)}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
for (const file of files) {
|
|
821
|
+
const fromMain = mainTexts[onMain.indexOf(file)] ?? null;
|
|
822
|
+
const before = this.read(file);
|
|
823
|
+
const title = fromMain !== null ? titleOf(file, fromMain) : before !== null ? titleOf(file, before) : (this.deletedTitles.get(file) ?? titleOf(file, null));
|
|
824
|
+
out.set(file, title);
|
|
825
|
+
}
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Phase 2: each waiting file the merge decided for the draft, with the newest commit
|
|
831
|
+
* that said so (`Draft-kept:`), so a dismissed notice comes back only for a new
|
|
832
|
+
* conflict. From the cached compare; never asks GitHub.
|
|
833
|
+
*/
|
|
834
|
+
keptDraftNotices(): { file: string; commit: string }[] {
|
|
835
|
+
if (!this.since) return [];
|
|
836
|
+
const waiting = new Set([...this.since.files].filter((p) => this.inScope(p)).map(this.toSite));
|
|
837
|
+
const out = new Map<string, string>();
|
|
838
|
+
for (const c of this.since.commits) for (const file of parseMessage(c.message).keptDraft) if (waiting.has(file)) out.set(file, c.sha);
|
|
839
|
+
return [...out].map(([file, commit]) => ({ file, commit }));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ── Publish status (Decision 12) ────────────────────────────────────────────
|
|
843
|
+
|
|
844
|
+
/** The newest publish's state. Asks GitHub at most every `deploymentReadMs`, and only while it isn't live. */
|
|
845
|
+
async publishStatus(): Promise<PublishStatus> {
|
|
846
|
+
const record = await this.lastPublish();
|
|
847
|
+
if (!record) return { state: 'none', record: null, assumed: false, canTryAgain: false };
|
|
848
|
+
const running = this.runningCommit();
|
|
849
|
+
if (running === null) {
|
|
850
|
+
if (!this.assumedLogged) {
|
|
851
|
+
this.assumedLogged = true;
|
|
852
|
+
this.log('no RAILWAY_GIT_COMMIT_SHA or CARACMS_RUNNING_COMMIT: no deploy pipeline, so a publish reads as live once its refs moved');
|
|
853
|
+
}
|
|
854
|
+
return { state: 'live', record, assumed: true, canTryAgain: false };
|
|
855
|
+
}
|
|
856
|
+
return this.backend.tracker.within('status', async () => {
|
|
857
|
+
const includes = running === record.commit ? true : await this.includes(record.commit, running);
|
|
858
|
+
let facts: DeploymentFact[] = [];
|
|
859
|
+
let later = new Set<string>();
|
|
860
|
+
if (!includes) ({ facts, later } = await this.deploymentFacts(record));
|
|
861
|
+
const { state } = publishState({ record, running, runningIncludesPublish: includes, laterOnMain: later, deployments: facts });
|
|
862
|
+
return { state, record, assumed: false, canTryAgain: state === 'failed' && this.railway !== null };
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Is `commit` in `running` (the same, or an ancestor)? One compare per pair, per server. */
|
|
867
|
+
private async includes(commit: string, running: string): Promise<boolean> {
|
|
868
|
+
const key = `${commit}:${running}`;
|
|
869
|
+
const known = this.includesCache.get(key);
|
|
870
|
+
if (known !== undefined) return known;
|
|
871
|
+
let yes = false;
|
|
872
|
+
try {
|
|
873
|
+
const cmp = await this.backend.compare(commit, running);
|
|
874
|
+
yes = cmp.status === 'ahead' || cmp.status === 'identical';
|
|
875
|
+
} catch (error) {
|
|
876
|
+
if (!(error instanceof HttpError && error.status === 404)) throw error; // the running commit isn't in this repo
|
|
877
|
+
}
|
|
878
|
+
this.includesCache.set(key, yes); // ancestry never changes: one compare per pair
|
|
879
|
+
return yes;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private async deploymentFacts(record: PublishRecord): Promise<{ facts: DeploymentFact[]; later: Set<string> }> {
|
|
883
|
+
const key = `${record.commit}:${record.tryAgainAt ?? ''}`;
|
|
884
|
+
const cached = this.deploymentsCache;
|
|
885
|
+
if (cached && cached.key === key && this.now() - cached.at < this.timings.deploymentReadMs) return cached;
|
|
886
|
+
if (this.deploymentsDenied) return { facts: [], later: new Set() };
|
|
887
|
+
let facts: DeploymentFact[] = [];
|
|
888
|
+
let later = new Set<string>();
|
|
889
|
+
try {
|
|
890
|
+
// main's head from the cached compare when there is one (Decision 8 keeps it fresh), else one ref read.
|
|
891
|
+
const main = this.since?.main ?? (await this.backend.readHeads()).main.commit;
|
|
892
|
+
if (main !== record.commit) {
|
|
893
|
+
if (this.laterCache?.key !== `${record.commit}:${main}`) {
|
|
894
|
+
const cmp = await this.backend.compare(record.commit, main);
|
|
895
|
+
this.laterCache = { key: `${record.commit}:${main}`, later: new Set(cmp.commits.map((c) => c.sha)) };
|
|
896
|
+
}
|
|
897
|
+
later = this.laterCache.later;
|
|
898
|
+
}
|
|
899
|
+
const deployments = (await this.backend.listDeployments(30)).filter((d) => d.sha === record.commit || later.has(d.sha));
|
|
900
|
+
for (const d of deployments.slice(0, 5)) {
|
|
901
|
+
const s = await this.backend.deploymentStatus(d.id);
|
|
902
|
+
facts.push({ sha: d.sha, createdAt: d.createdAt, state: s?.state ?? null });
|
|
903
|
+
}
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if (error instanceof HttpError && (error.status === 403 || error.status === 404)) {
|
|
906
|
+
this.deploymentsDenied = true;
|
|
907
|
+
this.log(`can't read GitHub Deployments (${error.status}): the App needs "Deployments: read". A publish will show as publishing until the server runs it.`);
|
|
908
|
+
return { facts: [], later: new Set() };
|
|
909
|
+
}
|
|
910
|
+
throw error;
|
|
911
|
+
}
|
|
912
|
+
this.deploymentsCache = { key, at: this.now(), facts, later };
|
|
913
|
+
return this.deploymentsCache;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ── History (Decision 12) ───────────────────────────────────────────────────
|
|
917
|
+
|
|
918
|
+
/** The newest 20 publishes from `main`'s commits, cached; `refresh` asks again at most once a minute. */
|
|
919
|
+
async history(options: { refresh?: boolean } = {}): Promise<HistoryEntry[]> {
|
|
920
|
+
const cached = this.historyCache;
|
|
921
|
+
if (cached && (!options.refresh || this.now() - cached.at < this.timings.historyMs)) return cached.entries;
|
|
922
|
+
return this.backend.tracker.within('history', async () => {
|
|
923
|
+
const commits = await this.backend.listCommits('main', 100);
|
|
924
|
+
const status = await this.publishStatus();
|
|
925
|
+
const running = this.runningCommit();
|
|
926
|
+
const runningAt = running === null ? -1 : commits.findIndex((c) => c.sha === running);
|
|
927
|
+
const entries: HistoryEntry[] = [];
|
|
928
|
+
commits.forEach((c, i) => {
|
|
929
|
+
if (entries.length >= 20) return;
|
|
930
|
+
const parsed = parseMessage(c.message);
|
|
931
|
+
if (parsed.kind !== 'publish') return;
|
|
932
|
+
const live = running === null || (runningAt !== -1 && i >= runningAt) || (status.record?.commit === c.sha && status.state === 'live');
|
|
933
|
+
const pending: PublishState = status.state === 'failed' ? 'failed' : 'publishing';
|
|
934
|
+
entries.push({ commit: c.sha, by: parsed.publishedBy, at: c.date, titles: parsed.titles, files: parsed.publishedFiles, state: live ? 'live' : pending });
|
|
935
|
+
});
|
|
936
|
+
this.historyCache = { at: this.now(), entries };
|
|
937
|
+
return entries;
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ── Try again (Decision 12) ─────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
/** Redeploy through Railway's API with the project token. Never publishes again. */
|
|
944
|
+
async tryAgain(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
945
|
+
if (!this.railway) return { ok: false, reason: 'No CARACMS_RAILWAY_TOKEN: a developer can redeploy from Railway.' };
|
|
946
|
+
const record = await this.lastPublish();
|
|
947
|
+
if (!record) return { ok: false, reason: 'Nothing has been published.' };
|
|
948
|
+
const { token, serviceId, environmentId } = this.railway;
|
|
949
|
+
const res = await (this.railway.fetch ?? fetch)(RAILWAY_API, {
|
|
950
|
+
method: 'POST',
|
|
951
|
+
headers: { 'content-type': 'application/json', 'project-access-token': token },
|
|
952
|
+
body: JSON.stringify({
|
|
953
|
+
query: 'mutation($serviceId: String!, $environmentId: String!) { serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId) }',
|
|
954
|
+
variables: { serviceId, environmentId },
|
|
955
|
+
}),
|
|
956
|
+
});
|
|
957
|
+
const body = (await res.json().catch(() => ({}))) as { data?: { serviceInstanceRedeploy?: boolean }; errors?: { message: string }[] };
|
|
958
|
+
if (!res.ok || body.errors?.length || !body.data?.serviceInstanceRedeploy) {
|
|
959
|
+
const reason = body.errors?.map((e) => e.message).join('; ') || `Railway answered ${res.status}`;
|
|
960
|
+
this.log(`try again: Railway refused the redeploy (${reason})`);
|
|
961
|
+
return { ok: false, reason };
|
|
962
|
+
}
|
|
963
|
+
await this.redis.set(this.key('publish:last'), JSON.stringify({ ...record, tryAgainAt: new Date(this.now()).toISOString() }));
|
|
964
|
+
this.deploymentsCache = null;
|
|
965
|
+
this.log(`try again: Railway is redeploying (publish ${record.commit.slice(0, 7)})`);
|
|
966
|
+
return { ok: true };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export interface Moves {
|
|
971
|
+
/** The new file → its old address. */
|
|
972
|
+
from: Map<string, string>;
|
|
973
|
+
/** The old file → its new address. */
|
|
974
|
+
to: Map<string, string>;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Pre-M7 item 8: which of these changed files are documents moved, from the moves
|
|
979
|
+
* recorded as they were made (`records`, old file → new file: autosaves.ts → `moves`),
|
|
980
|
+
* never from Site Settings' redirects, so a move stays a move whether or not the
|
|
981
|
+
* editor keeps its redirect. A record counts while both files are among `files`, the
|
|
982
|
+
* old one gone (`read` null) and the new one there. Publish's headline and the waiting
|
|
983
|
+
* list read it ("Visit (moved from /studio/visit)").
|
|
984
|
+
*/
|
|
985
|
+
export function movesIn(site: Pick<ResolvedSite, 'root' | 'collections'>, files: readonly string[], read: (file: string) => string | null, records: ReadonlyMap<string, string>): Moves {
|
|
986
|
+
const from = new Map<string, string>();
|
|
987
|
+
const to = new Map<string, string>();
|
|
988
|
+
const urlOf = (file: string) => {
|
|
989
|
+
try {
|
|
990
|
+
const c = confinePath(site as ResolvedSite, file);
|
|
991
|
+
return documentUrl(site.collections[c.collection]!, c.path);
|
|
992
|
+
} catch {
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
const changed = new Set(files);
|
|
997
|
+
for (const [old, moved] of records) {
|
|
998
|
+
if (old === moved || !changed.has(old) || !changed.has(moved) || from.has(moved) || to.has(old)) continue;
|
|
999
|
+
if (read(old) !== null || read(moved) === null) continue;
|
|
1000
|
+
const fromUrl = urlOf(old);
|
|
1001
|
+
const toUrl = urlOf(moved);
|
|
1002
|
+
if (!fromUrl || !toUrl) continue;
|
|
1003
|
+
from.set(moved, fromUrl);
|
|
1004
|
+
to.set(old, toUrl);
|
|
1005
|
+
}
|
|
1006
|
+
return { from, to };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** A file's title for messages: the document's `title`, else its name title-cased. */
|
|
1010
|
+
export function titleOf(file: string, text: string | null): string {
|
|
1011
|
+
if (text !== null) {
|
|
1012
|
+
try {
|
|
1013
|
+
const t = (JSON.parse(text) as { title?: unknown } | null)?.title;
|
|
1014
|
+
if (typeof t === 'string' && t.trim()) return t.trim();
|
|
1015
|
+
} catch {
|
|
1016
|
+
/* not JSON: use the name */
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
const name = file.split('/').pop()!.replace(/\.json$/, '');
|
|
1020
|
+
return name.replace(/[-_]+/g, ' ').replace(/^./, (c) => c.toUpperCase());
|
|
1021
|
+
}
|