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.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Autosaves in Redis (M4 Decision 4), and the two small records beside them.
3
+ *
4
+ * - **`caracms:autosave:<file>`**: the whole document as the tab last sent it, with the
5
+ * base it was edited from, its text (`applyEdits(base, document)`), its editor and
6
+ * tab, and two times for the sweep: the first write since the last commit (the
7
+ * five-minute cap) and the newest write (the idle time). Expires after a week, as a
8
+ * backstop (`autosaveTtlMs`).
9
+ * - **`caracms:committed:<file>`**: what the sweep last committed from a tab's editing
10
+ * session (its tab, its base, and the text it committed). The next commit from the
11
+ * same session merges against that text, not the session's first base, so the
12
+ * session's own earlier commit isn't mistaken for a change made elsewhere (no false
13
+ * "also changed outside the editor" notice).
14
+ * - **`caracms:changed:<file>`**: who last changed the document and the version their
15
+ * autosave left (Decision 11). Set by every autosave; a merge from `main` or the sweep
16
+ * committing that person's own autosave leaves it alone. **It never expires** (one
17
+ * small key per file), so it outlives Publish; only a flush loses it.
18
+ *
19
+ * **Creating and deleting** (M5a Decisions 11 and 12) go through the same autosave:
20
+ *
21
+ * - **A new document** is an autosave with `created: { over }`: `over` is what `drafts`
22
+ * had at that path when it was created (null; or the file a pending deletion was
23
+ * removing, when a document deleted since the last Publish is made again). Until the
24
+ * sweep commits it, `over` is the merge's ancestor, not `base` (which is the new
25
+ * document's first text, the base the creator's tab edits against). Later writes
26
+ * carry `created` on until the commit.
27
+ * - **A deletion** is a "gone" autosave: `text` and `document` are null, and
28
+ * `gone: { title }` keeps the document's title from before the removal (the waiting
29
+ * list's and the commit message's name for it when `main` can't say). `base` is the
30
+ * text the deleter's tab was on, so the merge's ancestor is worked out as for any
31
+ * autosave. The sweep commits it as a removed file.
32
+ * - **`caracms:changed:`** records a deletion too (`gone: true`), so a viewer's tab
33
+ * learns who deleted the document after the sweep has committed it.
34
+ *
35
+ * **Moves** (pre-M7 item 8): **`caracms:moves`**, a hash from a moved document's old
36
+ * file to its new file, kept until the move is published (`movesKey`). A move records
37
+ * itself here rather than being read off Site Settings' redirects, so it stays one
38
+ * "Moved" entry (and one headline item) whether or not the editor keeps the redirect.
39
+ * A move of a document moved since the last Publish extends its record (old → newest);
40
+ * moving it back to where it was drops the record, as does deleting it. A record only
41
+ * counts while its old file is gone and its new file is there (`movesIn`, git.ts).
42
+ *
43
+ * Paths are site-relative (`content/pages/index.json`). Values are JSON text.
44
+ */
45
+ import type { AnyDocument } from '../document.ts';
46
+ import type { Person } from './messages.ts';
47
+ import type { RedisClient } from './redis.ts';
48
+ import type { LiveTimings } from './timings.ts';
49
+
50
+ export interface Autosave {
51
+ file: string;
52
+ /** The text the tab's edits were made against (the write body's `base`). */
53
+ base: string;
54
+ /**
55
+ * What `drafts` had under this autosave, when `base` isn't it: a write made against
56
+ * the text of an autosave the sweep hadn't committed yet (opened over it: a move's
57
+ * Site Settings, say) keeps the replaced autosave's ancestor here. The merge's
58
+ * ancestor when no session record applies (`ancestorFor`); absent, it's `base`.
59
+ */
60
+ ancestor?: string;
61
+ /** Any kind of document (M5a). Null: the document is deleted (`gone`). */
62
+ document: AnyDocument | null;
63
+ /** `applyEdits(base, document)`: what the document reads as now. Null: deleted (`gone`). */
64
+ text: string | null;
65
+ editor: Person;
66
+ tab: string;
67
+ /** ms since the epoch: the first write since the last commit. */
68
+ firstAt: number;
69
+ /** ms since the epoch: the newest write. */
70
+ updatedAt: number;
71
+ /** The tab's own write sequence (`X-Caracms-Seq`), when it sent one: a lower one from the same tab is older. */
72
+ seq?: number | null;
73
+ /**
74
+ * Ready for the sweep at once, with no idle wait (M5a Decision 10): Site Settings'
75
+ * autosave, so `editors` and `brand` apply on the sweep's next tick. The sweep
76
+ * commits it on that tick whatever its age (sweep.ts → `ready`).
77
+ */
78
+ sweepAtOnce?: boolean;
79
+ /**
80
+ * M5a: this autosave creates the document (Decision 11). `over` is the file `drafts`
81
+ * had at that path then (usually null): the merge's ancestor until it's committed.
82
+ */
83
+ created?: { over: string | null };
84
+ /** M5a: the document is deleted (Decision 12): `text` and `document` are null. `title` is its title before the removal. */
85
+ gone?: { title: string };
86
+ }
87
+
88
+ export interface Committed {
89
+ tab: string;
90
+ base: string;
91
+ /** The session's own text as committed (before any merge); null for a deletion. */
92
+ text: string | null;
93
+ at: number;
94
+ /** The editor's address, lower-cased (absent on records written before it was added). */
95
+ editor?: string;
96
+ }
97
+
98
+ /** Where an autosave's merge ancestor came from (sweep.ts logs anything but `session`/`base`). */
99
+ export type AncestorFrom = 'session' | 'own-commit' | 'base' | 'created' | 'base, record from another tab' | 'base, record from someone else';
100
+
101
+ export interface ChangeRecord {
102
+ /** Null: only merges from `main` have changed it (no editor has, since the record began). */
103
+ by: Person | null;
104
+ version: string;
105
+ /** ISO. */
106
+ at: string;
107
+ /** M5a: this change deleted the document (`by` deleted it). */
108
+ gone?: boolean;
109
+ }
110
+
111
+ /** A value with the raw text it was read as, for compare-and-swap. */
112
+ export interface Read<T> {
113
+ raw: string;
114
+ value: T;
115
+ }
116
+
117
+ /** The Redis hash of moves waiting to publish (old file → new file). */
118
+ export const movesKey = (prefix: string) => `${prefix}moves`;
119
+
120
+ const parse = <T>(raw: string | null): T | null => {
121
+ if (raw === null) return null;
122
+ try {
123
+ return JSON.parse(raw) as T;
124
+ } catch {
125
+ return null;
126
+ }
127
+ };
128
+
129
+ export class Autosaves {
130
+ readonly redis: RedisClient;
131
+ readonly timings: LiveTimings;
132
+ readonly now: () => number;
133
+ readonly prefix: string;
134
+
135
+ constructor(redis: RedisClient, timings: LiveTimings, now: () => number, prefix = 'caracms:') {
136
+ this.redis = redis;
137
+ this.timings = timings;
138
+ this.now = now;
139
+ this.prefix = prefix;
140
+ }
141
+
142
+ key = (file: string) => `${this.prefix}autosave:${file}`;
143
+ private committedKey = (file: string) => `${this.prefix}committed:${file}`;
144
+ private changedKey = (file: string) => `${this.prefix}changed:${file}`;
145
+
146
+ async get(file: string): Promise<Read<Autosave> | null> {
147
+ const raw = await this.redis.get(this.key(file));
148
+ const value = parse<Autosave>(raw);
149
+ return raw !== null && value ? { raw, value } : null;
150
+ }
151
+
152
+ /** Every autosave: one SCAN and one MGET. */
153
+ async all(): Promise<Read<Autosave>[]> {
154
+ const keys = await this.redis.scan(`${this.prefix}autosave:*`);
155
+ if (keys.length === 0) return [];
156
+ const raws = await this.redis.mget(keys);
157
+ const out: Read<Autosave>[] = [];
158
+ raws.forEach((raw) => {
159
+ const value = parse<Autosave>(raw);
160
+ if (raw !== null && value) out.push({ raw, value });
161
+ });
162
+ return out;
163
+ }
164
+
165
+ /** Store it only if the autosave is still what was read (`null`: only if there's none), so a write that read an older one never replaces a newer. */
166
+ putIf(read: Read<Autosave> | null, autosave: Autosave): Promise<boolean> {
167
+ return this.redis.compareAndSet(this.key(autosave.file), read?.raw ?? null, JSON.stringify(autosave), { px: this.timings.autosaveTtlMs });
168
+ }
169
+
170
+ /** Delete it only if it's still what was read (a newer write wins). */
171
+ removeIf(read: Read<Autosave>): Promise<boolean> {
172
+ return this.redis.compareAndDelete(this.key(read.value.file), read.raw);
173
+ }
174
+
175
+ /** Replace it only if it's still what was read. */
176
+ replaceIf(read: Read<Autosave>, next: Autosave): Promise<boolean> {
177
+ return this.redis.compareAndSet(this.key(next.file), read.raw, JSON.stringify(next), { px: this.timings.autosaveTtlMs });
178
+ }
179
+
180
+ async committed(file: string): Promise<Committed | null> {
181
+ return parse<Committed>(await this.redis.get(this.committedKey(file)));
182
+ }
183
+
184
+ async setCommitted(file: string, record: Committed): Promise<void> {
185
+ await this.redis.set(this.committedKey(file), JSON.stringify(record), { px: this.timings.autosaveTtlMs });
186
+ }
187
+
188
+ async changed(file: string): Promise<ChangeRecord | null> {
189
+ return parse<ChangeRecord>(await this.redis.get(this.changedKey(file)));
190
+ }
191
+
192
+ /** Several files' records in one MGET (the documents list's "Last edited"), in the order asked; none: null. */
193
+ async changedMany(files: readonly string[]): Promise<(ChangeRecord | null)[]> {
194
+ if (files.length === 0) return [];
195
+ return (await this.redis.mget(files.map(this.changedKey))).map((raw) => parse<ChangeRecord>(raw));
196
+ }
197
+
198
+ /** No expiry: one small key per file, overwritten on each change, so "who changed it" is never forgotten while Redis keeps its data. */
199
+ async setChanged(file: string, record: ChangeRecord): Promise<void> {
200
+ await this.redis.set(this.changedKey(file), JSON.stringify(record));
201
+ }
202
+
203
+ /**
204
+ * Record a change only if the file has no record yet (SET NX): a merge or fast-forward
205
+ * from `main` leaves `{ by: null }` ("only merges"), and never overwrites an editor's.
206
+ */
207
+ async setChangedIfAbsent(file: string, record: Omit<ChangeRecord, 'by'> & { by: Person | null }): Promise<void> {
208
+ await this.redis.set(this.changedKey(file), JSON.stringify(record), { nx: true });
209
+ }
210
+
211
+ /** Pre-M7 item 8: the moves waiting to publish, old file → new file. */
212
+ async moves(): Promise<Map<string, string>> {
213
+ return new Map(Object.entries(await this.redis.hgetall(movesKey(this.prefix))));
214
+ }
215
+
216
+ /**
217
+ * A document moved from `from` to `to`. Moved since the last Publish already (a record
218
+ * ends at `from`): that record now ends at `to`, or goes when `to` is where it started
219
+ * (moved back). Otherwise a new record, only when `from` is live (`wasLive`): a page
220
+ * made since the last Publish is just a new page at its new address.
221
+ */
222
+ async recordMove(from: string, to: string, wasLive: boolean): Promise<void> {
223
+ const moves = await this.moves();
224
+ const earlier = [...moves].find(([, t]) => t === from)?.[0];
225
+ if (earlier !== undefined) {
226
+ if (earlier === to) await this.redis.hdel(movesKey(this.prefix), earlier);
227
+ else await this.redis.hset(movesKey(this.prefix), { [earlier]: to });
228
+ } else if (wasLive) await this.redis.hset(movesKey(this.prefix), { [from]: to });
229
+ }
230
+
231
+ /** A deleted document's move, if it was moved, is no move any more: it's a deletion. */
232
+ async forgetMoveTo(file: string): Promise<void> {
233
+ const moves = await this.moves();
234
+ const olds = [...moves].filter(([, t]) => t === file).map(([o]) => o);
235
+ if (olds.length) await this.redis.hdel(movesKey(this.prefix), ...olds);
236
+ }
237
+
238
+ /** The merge's ancestor for an autosave (Decision 6). See `ancestorFor`. */
239
+ async ancestorOf(autosave: Autosave): Promise<string | null> {
240
+ return (await this.ancestorFor(autosave)).text;
241
+ }
242
+
243
+ /**
244
+ * The merge's ancestor for an autosave (Decision 6), so the editor's own earlier
245
+ * commit from the same editing session is never read as a change made elsewhere:
246
+ *
247
+ * - **The same tab, the same base** (the session's last commit): that commit's text.
248
+ * - **The same person and base, another tab id** (held edits re-sent from a reloaded
249
+ * or new tab, Use mine): per top-level key, the person's own committed value for
250
+ * each key this draft changed, and the base's value for every key it didn't. So
251
+ * their own commit isn't a conflict, and a key their other tab changed and this
252
+ * draft never touched is still taken from `drafts`, not undone.
253
+ * - Otherwise (no record, another base, someone else): what `drafts` had under it (its
254
+ * `ancestor`, else its own base).
255
+ * - **A new document not committed yet** (M5a, `created`): what `drafts` had at its
256
+ * path when it was made (`created.over`, usually null: the file didn't exist).
257
+ */
258
+ async ancestorFor(autosave: Autosave): Promise<{ text: string | null; from: AncestorFrom }> {
259
+ if (autosave.created) return { text: autosave.created.over, from: 'created' };
260
+ const c = await this.committed(autosave.file);
261
+ const start = autosave.ancestor ?? autosave.base;
262
+ if (!c || c.base !== autosave.base) return { text: start, from: 'base' };
263
+ if (c.tab === autosave.tab) return { text: c.text, from: 'session' };
264
+ if (c.editor !== autosave.editor.email.toLowerCase()) return { text: start, from: 'base, record from someone else' };
265
+ if (autosave.text === null || c.text === null) return { text: start, from: 'base, record from another tab' };
266
+ const own = ownAncestor(autosave.base, autosave.text, c.text);
267
+ return own === null ? { text: start, from: 'base, record from another tab' } : { text: own, from: 'own-commit' };
268
+ }
269
+ }
270
+
271
+ const equal = (x: unknown, y: unknown): boolean => {
272
+ if (x === y) return true;
273
+ if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null || Array.isArray(x) !== Array.isArray(y)) return false;
274
+ const xk = Object.keys(x);
275
+ const yk = Object.keys(y);
276
+ return xk.length === yk.length && xk.every((k) => Object.hasOwn(y, k) && equal((x as Record<string, unknown>)[k], (y as Record<string, unknown>)[k]));
277
+ };
278
+
279
+ const object = (text: string): Record<string, unknown> | null => {
280
+ try {
281
+ const v = JSON.parse(text) as unknown;
282
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ };
287
+
288
+ /**
289
+ * The per-key ancestor for a draft whose person already committed from the same base
290
+ * in another tab: the committed value where the draft changed a key, the base's where
291
+ * it didn't. Only values matter (merge.ts reads the ancestor's values, never its text),
292
+ * so it's plain JSON. Null when any side isn't a JSON object.
293
+ */
294
+ function ownAncestor(base: string, draft: string, committed: string): string | null {
295
+ const b = object(base);
296
+ const d = object(draft);
297
+ const c = object(committed);
298
+ if (!b || !d || !c) return null;
299
+ const out: Record<string, unknown> = {};
300
+ for (const key of new Set([...Object.keys(b), ...Object.keys(c), ...Object.keys(d)])) {
301
+ const changed = Object.hasOwn(d, key) !== Object.hasOwn(b, key) || !equal(d[key], b[key]);
302
+ const from = changed ? c : b;
303
+ if (Object.hasOwn(from, key)) out[key] = from[key];
304
+ }
305
+ return JSON.stringify(out, null, 2);
306
+ }