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,713 @@
1
+ /**
2
+ * Keeping: getting the editor's copy of each document to the server (M3 Decisions 5
3
+ * and 6; M4 Decisions 4, 9 and 11).
4
+ *
5
+ * - **About a second after the last change**, the whole document is POSTed to the
6
+ * write route with the base it was made against: `{ file, base, document }`
7
+ * (server/write.ts). One write per document in flight; a change made meanwhile
8
+ * goes in the next one. In live mode every write carries the tab's id
9
+ * (`X-Caracms-Tab`) and its place in the tab's order (`X-Caracms-Seq`, rising, from
10
+ * the clock so a reload keeps rising), and **Kept means the server has it** (Redis's
11
+ * autosave; an older write that lands late never replaces a newer one).
12
+ * - **The bar's keep state** (`status`): `kept` when the server has every change,
13
+ * `keeping` while a write is pending or in flight, `offline` ("can't reach the
14
+ * server") once a write fails on the network or with a 5xx (503 `unavailable`
15
+ * included), **until a write succeeds**. A 4xx is the server answering, not an
16
+ * outage: `refused`, with the server's own message in `error`. Live mode adds two:
17
+ * a 409 `locked` (someone else's lock, or your own in another tab) is `held`, and a
18
+ * 401 is `signedout`. Whatever the state, **the edits stay held**.
19
+ * - **Retrying.** In local mode (M3) there's no timer: the next change, or a reload,
20
+ * retries. In live mode (`retryDelays`) **a retry timer runs only while offline**,
21
+ * backing off, with its time in `status.retryAt` for the countdown, and `tryNow()`
22
+ * for Try now. `unsent` counts the changes the server doesn't have yet.
23
+ * - **Unsent edits are held in the browser's storage**, per document, from the
24
+ * change until the server has them: `{ file, base, document, fileVersion }`. The
25
+ * base travels with them, because the blocks' `source` indexes point into it
26
+ * (serialize.ts; M3 → Watch-outs). They're flushed on `pagehide` too (a keepalive
27
+ * write; in live mode with `X-Caracms-Release: 1`, and a keepalive release of any
28
+ * other lock the tab holds with nothing unsent), since closing the tab inside the
29
+ * second would lose the last change.
30
+ *
31
+ * **The re-send rule (`verdict`): a stored copy is dropped only when the server's text
32
+ * already holds those edits** (never lose work silently: Spec → Non-functional, M2
33
+ * Decision 11). Stored edits remember the version of the file they were made against
34
+ * (a hash of its text, server/write.ts → fileVersion).
35
+ * 1. The file is still at that version: nothing has written it since. Re-sent.
36
+ * 2. It has moved on: the keeper runs the serializer here in the browser,
37
+ * `applyEdits(base, document)` exactly as the write route does, and compares
38
+ * with the server's text (the file in local mode; the autosave or `drafts` in
39
+ * live mode). **Equal, the copy is dropped** (the keepalive write never updates
40
+ * the stored version and may have been refused, and an earlier write can land
41
+ * after a later one, so a moved version alone proves nothing).
42
+ * 3. Otherwise, in local mode, re-sent (M3: the last write wins). In live mode **the
43
+ * server decides (M4 Decision 11)** with `lastChange`: no editor known (`by:
44
+ * null`: only merges), you, or an editor change that left exactly the version
45
+ * these edits were made against: re-sent (the server merges, Decision 6).
46
+ * **Another editor changed it since, or the server can't tell who did (`known:
47
+ * false`): not re-sent**, held as a `conflict` until Use mine (`useMine`) or
48
+ * Discard mine (`discard`).
49
+ * Versions are compared for equality, never as times, so no clock is involved.
50
+ */
51
+ import { documentKind, normaliseAnyDocument, type AnyDocument } from '../../../document.ts';
52
+ import { applyDocumentEdits } from '../../../serialize.ts';
53
+ import type { Fields } from '../../../fields.ts';
54
+ import { ENDPOINTS, RELEASE_HEADER, SEQ_HEADER, TAB_HEADER, type EditorSite, type LastChange, type LockRefusal, type Person, type SignedOutError } from '../../types.ts';
55
+ import { createStore, type Store } from './store.ts';
56
+
57
+ /**
58
+ * `refused`: the server answered a write with a 4xx (its message is the status's
59
+ * `error`). `held` (live): a 409, or edits another editor's change overtook (Decision
60
+ * 11). `signedout` (live): a 401.
61
+ */
62
+ export type KeepState = 'kept' | 'keeping' | 'offline' | 'refused' | 'held' | 'signedout';
63
+
64
+ /** A document's edits that are held, not sent, until the person decides (Decision 11). */
65
+ export interface HeldView {
66
+ id: string;
67
+ file: string;
68
+ /** `locked`: a write got 409 (the lock's another tab's). `conflict`: someone else changed it since. */
69
+ reason: 'locked' | 'conflict';
70
+ /** Who changed it, or holds it. */
71
+ by: Person | null;
72
+ /** It's your own lock in another tab (Use mine is offered at once). */
73
+ elsewhere: boolean;
74
+ }
75
+
76
+ export interface KeepStatus {
77
+ state: KeepState;
78
+ /**
79
+ * Offline: the last failed write's error, for the console and a tooltip. Refused:
80
+ * the server's own message, which the bar shows. Null otherwise.
81
+ */
82
+ error: string | null;
83
+ /** Changes the server doesn't have yet (the offline note's "your last 3 changes"). */
84
+ unsent?: number;
85
+ /** Live mode, offline: when the retry timer fires next (ms since the epoch). Null otherwise. */
86
+ retryAt?: number | null;
87
+ /** Documents whose edits are held until the person decides. */
88
+ held?: readonly HeldView[];
89
+ }
90
+
91
+ /** A file as the server has it now: its version and its text, and in live mode who last changed it. */
92
+ export interface FileNow {
93
+ version: string;
94
+ text: string;
95
+ lastChange?: LastChange;
96
+ }
97
+
98
+ /** What's held in the browser's storage for a document until the file has it. */
99
+ export interface StoredUnsent {
100
+ v: 1;
101
+ file: string;
102
+ base: string;
103
+ document: AnyDocument;
104
+ /** The version of the file these edits were made against (see above). */
105
+ fileVersion: string;
106
+ }
107
+
108
+ /** The parts of Storage the keeper uses (localStorage, or a fake in tests). */
109
+ export type KeeperStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem' | 'key' | 'length'>;
110
+
111
+ export interface KeeperOptions {
112
+ fetch: typeof fetch;
113
+ /** The block schemas, for running the serializer on load (the re-send rule above). */
114
+ site: KeeperSite;
115
+ /** null: no storage available (a private window that refuses it). */
116
+ storage: KeeperStorage | null;
117
+ /** Storage keys are `caracms:unsent:<storageKey>:<docId>`, so two sites on one dev port don't mix. */
118
+ storageKey: string;
119
+ /** ms after the last change. Default 1000. */
120
+ delay?: number;
121
+ setTimer?: (fn: () => void, ms: number) => unknown;
122
+ clearTimer?: (timer: unknown) => void;
123
+ /** Errors go here too (default console.warn). */
124
+ log?: (message: string) => void;
125
+ /**
126
+ * Told whether any unsent edits are held, whenever that changes: the editor puts it
127
+ * in a cookie, so after a reload the server renders the bar as Keeping from the
128
+ * first paint instead of a Kept that isn't true yet (Decision 20's reason).
129
+ */
130
+ onHeld?: (held: boolean) => void;
131
+ /** The state the bar starts in (the server rendered it from that cookie). Default 'kept'. */
132
+ initial?: KeepState;
133
+
134
+ // ── Live mode (M4) ───────────────────────────────────────────────────────────
135
+ /** The tab's id, sent as `X-Caracms-Tab` on every write. Absent: local mode. */
136
+ tab?: string | null;
137
+ /** The retry timer's delays while offline, in ms (the last repeats). Absent: no timer (M3). */
138
+ retryDelays?: readonly number[];
139
+ now?: () => number;
140
+ /** Files this tab holds the lock on (from the last poll): pagehide releases them. */
141
+ lockedFiles?: () => readonly string[];
142
+ /** A write got 409 `locked`: the editor shows the lock at once. */
143
+ onLocked?: (file: string, refusal: LockRefusal) => void;
144
+ /** A write got 401: the editor shows it's signed out. */
145
+ onSignedOut?: (body: SignedOutError) => void;
146
+ /** Who is signed in, for the re-send rule ("your own commits"). */
147
+ you?: () => Person | null;
148
+ }
149
+
150
+ /** A held record the editor may need to look at again: once the lock lapses, or the session is back. */
151
+ export interface Blocked {
152
+ id: string;
153
+ file: string;
154
+ failure: 'locked' | 'conflict' | 'signedout';
155
+ }
156
+
157
+ export interface Keeper {
158
+ readonly status: Store<KeepStatus>;
159
+ /** A document was opened: remember its file and the version it's at. */
160
+ opened(id: string, file: string, fileVersion: string): void;
161
+ /** The document changed: store it, and write it a second from now. */
162
+ change(id: string, edit: { file: string; base: string; document: AnyDocument }): void;
163
+ /** Write now whatever is waiting (one document, or all). */
164
+ flush(id?: string): Promise<void>;
165
+ /** Stored unsent edits for a document, if any. */
166
+ stored(id: string): StoredUnsent | null;
167
+ /**
168
+ * The re-send rule for a document being opened, with its file as it is now: its
169
+ * stored edits unless the file already holds them, else null (and that copy is
170
+ * dropped).
171
+ */
172
+ recoverable(id: string, file: FileNow): StoredUnsent | null;
173
+ /** The same, saying whether they're to be sent (`send`) or held for the person (`by` changed it since). */
174
+ recover(id: string, file: FileNow): { stored: StoredUnsent; send: boolean; by: Person | null } | null;
175
+ /** Adopt stored edits and send them now (the bar shows Keeping until they land). */
176
+ resend(id: string, stored: StoredUnsent): void;
177
+ /** Adopt stored edits and hold them, unsent (another editor changed the document since). */
178
+ hold(id: string, stored: StoredUnsent, by: Person | null): void;
179
+ /**
180
+ * On load: every other document with stored edits, by the same rule. `fileOf` reads
181
+ * its file as it is now (null: it's gone, so its edits are kept but not sent; a
182
+ * rejection: the server couldn't be asked, so they wait for the next load).
183
+ */
184
+ recoverOthers(exceptId: string, fileOf: (id: string) => Promise<FileNow | null>): Promise<void>;
185
+ /** pagehide: send whatever is waiting with keepalive requests. */
186
+ pagehide(): void;
187
+ /** Recovery on load is done: show the real state (Kept when nothing was waiting after all). */
188
+ settle(): void;
189
+
190
+ // ── Live mode (M4) ───────────────────────────────────────────────────────────
191
+ /** Try now: retry every write that couldn't reach the server, at once. */
192
+ tryNow(): void;
193
+ /** Use mine: send a document's held edits (the server merges them, Decision 6). */
194
+ useMine(id: string): void;
195
+ /** Discard mine: drop a document's held edits, from the tab and the browser's storage. */
196
+ discard(id: string): void;
197
+ /** Held records (locked, conflict, signed out) to look at again. */
198
+ blocked(): Blocked[];
199
+ /** Apply the re-send rule to a held record against the file as it is now (the lock lapsed; signed in again). */
200
+ recheck(id: string, file: FileNow): 'send' | 'drop' | 'conflict' | null;
201
+ /** Whether a document has changes the server doesn't have. */
202
+ dirty(id: string): boolean;
203
+ }
204
+
205
+ type Failure = 'offline' | 'refused' | 'locked' | 'conflict' | 'signedout';
206
+
207
+ interface Record {
208
+ id: string;
209
+ file: string;
210
+ base: string;
211
+ document: AnyDocument | null;
212
+ fileVersion: string;
213
+ /** Bumped by every change. */
214
+ revision: number;
215
+ /** The revision the file is known to have. */
216
+ written: number;
217
+ inFlight: boolean;
218
+ /** How the last write failed, until one succeeds. */
219
+ failure: Failure | null;
220
+ /** The server's message when it refused the last write. */
221
+ refusal: string | null;
222
+ /** Held: who changed it or holds it, and whether it's your own other tab. */
223
+ by: Person | null;
224
+ elsewhere: boolean;
225
+ timer: unknown;
226
+ }
227
+
228
+ const PREFIX = 'caracms:unsent:';
229
+
230
+ /** Failures the next change or the retry timer don't retry: the person (or the session) decides. */
231
+ const WAITS: ReadonlySet<Failure> = new Set(['locked', 'conflict', 'signedout']);
232
+
233
+ /** A write the server answered with a 4xx: it was reached, and said no. */
234
+ class Refused extends Error {
235
+ readonly status: number;
236
+ readonly body: Record_<string, unknown>;
237
+ constructor(message: string, status: number, body: Record_<string, unknown>) {
238
+ super(message);
239
+ this.status = status;
240
+ this.body = body;
241
+ }
242
+ }
243
+ type Record_<K extends string, V> = { [key in K]: V };
244
+
245
+ /**
246
+ * What the keeper needs of the site: the block schemas, and (M5a) the collections, so a
247
+ * fields document's or Site Settings' edits are run through their own writer.
248
+ */
249
+ export type KeeperSite = Pick<EditorSite, 'blocks'> & {
250
+ /** The editor's list, or a resolved site's record (tests). */
251
+ collections?: readonly KeeperCollection[] | { readonly [name: string]: KeeperCollection };
252
+ };
253
+ type KeeperCollection = { kind: 'sections' | 'fields' | 'shared' | 'settings'; fields: Fields | null };
254
+
255
+ /**
256
+ * The collection a held document is written to, by its kind: a shared block's writer
257
+ * needs only the block schemas; a fields document's, Site Settings' and (M6c G2) a
258
+ * section builder's with page fields need their fields (the first collection of that
259
+ * kind whose fields match the document's `data` keys, else the first of that kind). A
260
+ * section builder's document without `data` has no page fields to write.
261
+ */
262
+ function collectionFor(document: AnyDocument, site: KeeperSite): { kind: 'sections' | 'fields' | 'shared' | 'settings'; fields: Fields | null } {
263
+ const kind = documentKind(document);
264
+ const data = (document as { data?: { [key: string]: unknown } }).data;
265
+ if (kind === 'shared' || (kind === 'sections' && data === undefined)) return { kind, fields: null };
266
+ const candidates = Object.values(site.collections ?? {}).filter((c: KeeperCollection) => c.kind === kind && (kind !== 'sections' || c.fields));
267
+ const own = (keys: string[]) => keys.filter((k) => kind !== 'settings' || (k !== 'editors' && k !== 'brand')).sort().join('\n');
268
+ const keys = own(Object.keys(data ?? {}));
269
+ const match = candidates.find((c) => own(Object.keys(c.fields ?? {})) === keys) ?? candidates[0];
270
+ return { kind, fields: match?.fields ?? (kind === 'sections' ? null : {}) };
271
+ }
272
+
273
+ const sameEmail = (a: Person | null | undefined, b: Person | null | undefined) => !!a && !!b && a.email.toLowerCase() === b.email.toLowerCase();
274
+
275
+ /** Whether the server's text is already what writing these edits would make it. */
276
+ export function holdsEdits(text: string, stored: Pick<StoredUnsent, 'base' | 'document'>, site: KeeperSite): boolean {
277
+ try {
278
+ const collection = collectionFor(stored.document, site);
279
+ return applyDocumentEdits(stored.base, normaliseAnyDocument(stored.document, site, collection), site, collection) === text;
280
+ } catch {
281
+ return false; // the edits don't fit their base: re-sent, and the server's refusal shows
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The re-send rule (see the header): `send`, `drop` (the server's text holds them), or
287
+ * `{ conflict: by }` (another editor changed the document since, Decision 11).
288
+ */
289
+ export function verdict(
290
+ stored: Pick<StoredUnsent, 'base' | 'document' | 'fileVersion'>,
291
+ file: FileNow,
292
+ site: KeeperSite,
293
+ you: Person | null,
294
+ ): 'send' | 'drop' | { conflict: Person | null } {
295
+ if (stored.fileVersion === file.version) return 'send'; // nothing has written it since
296
+ if (holdsEdits(file.text, stored, site)) return 'drop';
297
+ const last = file.lastChange;
298
+ if (!last) return 'send'; // local mode (M3): the last write wins
299
+ if (last.known === false) return { conflict: null }; // the server can't tell who changed it: never re-sent over unknown work
300
+ if (!last.by) return 'send'; // only merges from main are known
301
+ if (sameEmail(last.by, you)) return 'send'; // your own autosave or commit
302
+ if (last.version !== null && last.version === stored.fileVersion) return 'send'; // that change is what these edits were made on
303
+ return { conflict: last.by };
304
+ }
305
+
306
+ export function createKeeper(options: KeeperOptions): Keeper {
307
+ const delay = options.delay ?? 1000;
308
+ const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
309
+ const clearTimer = options.clearTimer ?? ((t) => clearTimeout(t as ReturnType<typeof setTimeout>));
310
+ const log = options.log ?? ((message) => console.warn(`[caracms] ${message}`));
311
+ const now = options.now ?? Date.now;
312
+ const prefix = `${PREFIX}${options.storageKey}:`;
313
+ const records = new Map<string, Record>();
314
+ let offline = false;
315
+ let offlineError: string | null = null;
316
+ const status = createStore<KeepStatus>({ state: options.initial ?? 'kept', error: null, unsent: 0, retryAt: null, held: [] });
317
+ let held: boolean | null = null;
318
+ // The retry timer (live mode only, and only while offline).
319
+ let retryTimer: unknown = null;
320
+ let retryAt: number | null = null;
321
+ let retryStep = 0;
322
+
323
+ const storage = {
324
+ /** Tell onHeld when whether anything is held changes. */
325
+ check() {
326
+ const now = storage.ids().length > 0;
327
+ if (now !== held) {
328
+ held = now;
329
+ options.onHeld?.(now);
330
+ }
331
+ },
332
+ get(id: string): StoredUnsent | null {
333
+ try {
334
+ const raw = options.storage?.getItem(prefix + id);
335
+ if (!raw) return null;
336
+ const value = JSON.parse(raw) as StoredUnsent;
337
+ return value && value.v === 1 && typeof value.base === 'string' && typeof value.fileVersion === 'string' && value.document ? value : null;
338
+ } catch {
339
+ return null;
340
+ }
341
+ },
342
+ set(id: string, value: StoredUnsent) {
343
+ try {
344
+ options.storage?.setItem(prefix + id, JSON.stringify(value));
345
+ } catch (error) {
346
+ log(`couldn't hold unsent edits in this browser's storage (${String(error)})`);
347
+ }
348
+ storage.check();
349
+ },
350
+ remove(id: string) {
351
+ try {
352
+ options.storage?.removeItem(prefix + id);
353
+ } catch {
354
+ /* nothing to do */
355
+ }
356
+ storage.check();
357
+ },
358
+ ids(): string[] {
359
+ const out: string[] = [];
360
+ try {
361
+ const s = options.storage;
362
+ if (!s) return out;
363
+ for (let i = 0; i < s.length; i++) {
364
+ const key = s.key(i);
365
+ if (key?.startsWith(prefix)) out.push(key.slice(prefix.length));
366
+ }
367
+ } catch {
368
+ /* none */
369
+ }
370
+ return out;
371
+ },
372
+ };
373
+
374
+ const dirty = (r: Record) => r.revision !== r.written;
375
+ let heldViews: readonly HeldView[] = [];
376
+ const refresh = () => {
377
+ const all = [...records.values()];
378
+ const busy = all.some((r) => dirty(r) || r.inFlight);
379
+ const refused = all.find((r) => r.failure === 'refused' && dirty(r));
380
+ const signedOut = all.some((r) => r.failure === 'signedout' && dirty(r));
381
+ const nextHeld = all
382
+ .filter((r) => dirty(r) && (r.failure === 'locked' || r.failure === 'conflict'))
383
+ .map((r): HeldView => ({ id: r.id, file: r.file, reason: r.failure as 'locked' | 'conflict', by: r.by, elsewhere: r.elsewhere }));
384
+ if (JSON.stringify(nextHeld) !== JSON.stringify(heldViews)) heldViews = nextHeld;
385
+ // Live mode's extras (the count shows only while offline or signed out); local mode's status is M3's.
386
+ const unsent = offline || signedOut ? all.reduce((n, r) => n + (dirty(r) ? r.revision - r.written : 0), 0) : 0;
387
+ const common = options.tab ? { unsent, retryAt, held: heldViews } : {};
388
+ if (offline) status.update({ state: 'offline', error: offlineError, ...common });
389
+ else if (signedOut) status.update({ state: 'signedout', error: null, ...common });
390
+ else if (refused) status.update({ state: 'refused', error: refused.refusal, ...common });
391
+ else if (heldViews.length) status.update({ state: 'held', error: null, ...common });
392
+ else status.update({ state: busy ? 'keeping' : 'kept', error: null, ...common });
393
+ };
394
+ const record = (id: string, file: string, fileVersion: string): Record => {
395
+ let r = records.get(id);
396
+ if (!r) {
397
+ r = { id, file, base: '', document: null, fileVersion, revision: 0, written: 0, inFlight: false, failure: null, refusal: null, by: null, elsewhere: false, timer: null };
398
+ records.set(id, r);
399
+ }
400
+ return r;
401
+ };
402
+ const stash = (r: Record) => {
403
+ if (r.document) storage.set(r.id, { v: 1, file: r.file, base: r.base, document: r.document, fileVersion: r.fileVersion });
404
+ };
405
+ const schedule = (r: Record, ms = delay) => {
406
+ if (r.timer !== null) clearTimer(r.timer);
407
+ r.timer = setTimer(() => {
408
+ r.timer = null;
409
+ void send(r);
410
+ }, ms);
411
+ };
412
+ /** Live mode: each write's place in this tab's order (from the clock, so a reload keeps rising). */
413
+ let writeSeq = 0;
414
+ const headers = (release = false, write = false): Record_<string, string> => {
415
+ const h: Record_<string, string> = { 'Content-Type': 'application/json' };
416
+ if (options.tab) h[TAB_HEADER] = options.tab;
417
+ if (options.tab && release) h[RELEASE_HEADER] = '1';
418
+ if (options.tab && write) h[SEQ_HEADER] = String((writeSeq = Math.max(writeSeq + 1, Math.floor(Date.now()))));
419
+ return h;
420
+ };
421
+
422
+ const stopRetry = () => {
423
+ if (retryTimer !== null) clearTimer(retryTimer);
424
+ retryTimer = null;
425
+ retryAt = null;
426
+ retryStep = 0;
427
+ };
428
+ const retryAll = () => {
429
+ for (const r of records.values()) if (r.failure === 'offline' && dirty(r)) void send(r);
430
+ };
431
+ /** Live mode: while offline, try again later, backing off. */
432
+ const scheduleRetry = () => {
433
+ const delays = options.retryDelays;
434
+ if (!delays?.length || retryTimer !== null) return;
435
+ const ms = delays[Math.min(retryStep, delays.length - 1)]!;
436
+ retryStep += 1;
437
+ retryAt = now() + ms;
438
+ retryTimer = setTimer(() => {
439
+ retryTimer = null;
440
+ retryAt = null;
441
+ retryAll();
442
+ refresh();
443
+ }, ms);
444
+ };
445
+
446
+ async function send(r: Record): Promise<void> {
447
+ if (!dirty(r) || !r.document) return;
448
+ if (r.inFlight) return; // the change waits; it's sent when this write returns
449
+ const revision = r.revision;
450
+ r.inFlight = true;
451
+ refresh();
452
+ let ok = false;
453
+ try {
454
+ const response = await options.fetch(ENDPOINTS.write, {
455
+ method: 'POST',
456
+ headers: headers(false, true),
457
+ body: JSON.stringify({ file: r.file, base: r.base, document: r.document }),
458
+ });
459
+ const body = (await response.json().catch(() => ({}))) as { error?: string; version?: string };
460
+ if (response.status >= 400 && response.status < 500) {
461
+ throw new Refused(typeof body.error === 'string' && body.error ? body.error : `The server refused the write (${response.status}).`, response.status, body);
462
+ }
463
+ if (!response.ok) throw new Error(body.error ?? `the write route answered ${response.status}`);
464
+ ok = true;
465
+ offline = false;
466
+ offlineError = null;
467
+ r.failure = null;
468
+ r.refusal = null;
469
+ r.by = null;
470
+ r.elsewhere = false;
471
+ if (typeof body.version === 'string') r.fileVersion = body.version;
472
+ r.written = Math.max(r.written, revision);
473
+ if (dirty(r)) stash(r); // newer edits wait: they're now against the version just written
474
+ else storage.remove(r.id);
475
+ } catch (error) {
476
+ const message = error instanceof Error ? error.message : String(error);
477
+ if (error instanceof Refused) {
478
+ // The server answered, so it isn't unreachable; the edits stay held.
479
+ offline = false;
480
+ offlineError = null;
481
+ const code = (error.body as { code?: unknown }).code;
482
+ if (options.tab && error.status === 401) {
483
+ r.failure = 'signedout';
484
+ r.refusal = null;
485
+ log(`signed out: ${r.file} is held in this tab`);
486
+ options.onSignedOut?.(error.body as unknown as SignedOutError);
487
+ } else if (options.tab && error.status === 409 && code === 'locked') {
488
+ const refusal = error.body as unknown as LockRefusal;
489
+ r.failure = 'locked';
490
+ r.refusal = message;
491
+ r.elsewhere = refusal.state === 'yours-elsewhere';
492
+ r.by = refusal.lastChange?.by ?? refusal.lock?.person ?? null;
493
+ log(`${r.file} is locked: ${message}`);
494
+ options.onLocked?.(r.file, refusal);
495
+ } else {
496
+ r.failure = 'refused';
497
+ r.refusal = message;
498
+ log(`the server refused to keep ${r.file}: ${message}`);
499
+ }
500
+ } else {
501
+ offline = true;
502
+ offlineError = message;
503
+ r.failure = 'offline';
504
+ log(`couldn't keep ${r.file}: ${message}`);
505
+ }
506
+ } finally {
507
+ r.inFlight = false;
508
+ if (ok && !offline) {
509
+ stopRetry();
510
+ // Live mode: the server is back, so whatever else couldn't reach it goes now.
511
+ if (options.retryDelays) for (const other of records.values()) if (other !== r && other.failure === 'offline' && dirty(other)) schedule(other, 0);
512
+ }
513
+ if (offline) scheduleRetry();
514
+ refresh();
515
+ }
516
+ // A change that came in during the write goes out now, unless its own timer is still running.
517
+ if (ok && dirty(r) && r.timer === null && !r.failure) void send(r);
518
+ }
519
+
520
+ function adopt(id: string, stored: StoredUnsent): Record {
521
+ const r = record(id, stored.file, stored.fileVersion);
522
+ r.file = stored.file;
523
+ r.base = stored.base;
524
+ r.document = stored.document;
525
+ r.fileVersion = stored.fileVersion;
526
+ r.revision += 1;
527
+ return r;
528
+ }
529
+
530
+ function resend(id: string, stored: StoredUnsent) {
531
+ const r = adopt(id, stored);
532
+ r.failure = null;
533
+ refresh();
534
+ schedule(r, 0);
535
+ }
536
+
537
+ function recover(id: string, file: FileNow) {
538
+ const stored = storage.get(id);
539
+ if (!stored) return null;
540
+ const v = verdict(stored, file, options.site, options.you?.() ?? null);
541
+ if (v === 'drop') {
542
+ storage.remove(id); // the server's text already holds them
543
+ return null;
544
+ }
545
+ return v === 'send' ? { stored, send: true, by: null } : { stored, send: false, by: v.conflict };
546
+ }
547
+
548
+ return {
549
+ status,
550
+ opened(id, file, fileVersion) {
551
+ const r = record(id, file, fileVersion);
552
+ if (!dirty(r) && !r.inFlight) {
553
+ r.file = file;
554
+ r.fileVersion = fileVersion;
555
+ r.document = null;
556
+ }
557
+ },
558
+ change(id, edit) {
559
+ const r = record(id, edit.file, '');
560
+ r.file = edit.file;
561
+ r.base = edit.base;
562
+ r.document = edit.document;
563
+ r.revision += 1;
564
+ stash(r);
565
+ if (!r.failure || !WAITS.has(r.failure)) schedule(r);
566
+ // After a failed write (unreachable or refused), the next change retries everything waiting.
567
+ for (const other of records.values()) if (other !== r && other.failure && !WAITS.has(other.failure) && dirty(other)) schedule(other, 0);
568
+ refresh();
569
+ },
570
+ async flush(id) {
571
+ const targets = id ? [records.get(id)].filter((r): r is Record => !!r) : [...records.values()];
572
+ await Promise.all(
573
+ targets.map((r) => {
574
+ if (r.failure && WAITS.has(r.failure)) return undefined;
575
+ if (r.timer !== null) {
576
+ clearTimer(r.timer);
577
+ r.timer = null;
578
+ }
579
+ return send(r);
580
+ }),
581
+ );
582
+ },
583
+ stored: (id) => storage.get(id),
584
+ recoverable: (id, file) => recover(id, file)?.stored ?? null,
585
+ recover,
586
+ resend,
587
+ hold(id, stored, by) {
588
+ const r = adopt(id, stored);
589
+ r.failure = 'conflict';
590
+ r.by = by;
591
+ r.elsewhere = false;
592
+ refresh();
593
+ },
594
+ async recoverOthers(exceptId, fileOf) {
595
+ for (const id of storage.ids()) {
596
+ if (id === exceptId || records.has(id)) continue;
597
+ const stored = storage.get(id);
598
+ if (!stored) continue;
599
+ const file = await fileOf(id).catch(() => undefined);
600
+ if (file === undefined) continue; // couldn't ask (the server is away): leave them for the next load
601
+ if (file === null) {
602
+ log(`unsent edits for ${stored.file} are kept in this browser, but the document is gone, so they weren't sent`);
603
+ continue;
604
+ }
605
+ const v = verdict(stored, file, options.site, options.you?.() ?? null);
606
+ if (v === 'send') resend(id, stored);
607
+ else if (v === 'drop') storage.remove(id); // the file already holds them
608
+ // A conflict stays in storage, unsent: opening that document shows it (Decision 11).
609
+ }
610
+ },
611
+ settle() {
612
+ storage.check();
613
+ refresh();
614
+ },
615
+ pagehide() {
616
+ const written = new Set<string>();
617
+ for (const r of records.values()) {
618
+ if (!dirty(r) || !r.document) continue;
619
+ stash(r);
620
+ if (r.failure && WAITS.has(r.failure)) continue; // it would be refused; it's re-sent from storage later
621
+ written.add(r.file);
622
+ try {
623
+ void options
624
+ .fetch(ENDPOINTS.write, {
625
+ method: 'POST',
626
+ keepalive: true,
627
+ // Live mode: the last write releases the lock once its autosave is committed (Decision 9).
628
+ headers: headers(true, true),
629
+ body: JSON.stringify({ file: r.file, base: r.base, document: r.document }),
630
+ })
631
+ .catch(() => {});
632
+ } catch {
633
+ /* the stored copy is re-sent on the next load */
634
+ }
635
+ }
636
+ // Live mode: a lock with nothing unsent is released on its own.
637
+ if (!options.tab) return;
638
+ for (const file of options.lockedFiles?.() ?? []) {
639
+ if (written.has(file)) continue;
640
+ try {
641
+ void options
642
+ .fetch(ENDPOINTS.lock, { method: 'POST', keepalive: true, headers: headers(), body: JSON.stringify({ file, action: 'release' }) })
643
+ .catch(() => {});
644
+ } catch {
645
+ /* it lapses on its own */
646
+ }
647
+ }
648
+ },
649
+ tryNow() {
650
+ stopRetry();
651
+ retryAll();
652
+ refresh();
653
+ },
654
+ useMine(id) {
655
+ const r = records.get(id);
656
+ if (!r || !dirty(r)) return;
657
+ r.failure = null;
658
+ r.by = null;
659
+ r.elsewhere = false;
660
+ refresh();
661
+ schedule(r, 0);
662
+ },
663
+ discard(id) {
664
+ const r = records.get(id);
665
+ if (r) {
666
+ if (r.timer !== null) clearTimer(r.timer);
667
+ r.timer = null;
668
+ r.written = r.revision;
669
+ r.document = null;
670
+ r.failure = null;
671
+ r.by = null;
672
+ r.elsewhere = false;
673
+ }
674
+ storage.remove(id);
675
+ refresh();
676
+ },
677
+ blocked() {
678
+ return [...records.values()]
679
+ .filter((r) => dirty(r) && r.failure && WAITS.has(r.failure))
680
+ .map((r) => ({ id: r.id, file: r.file, failure: r.failure as Blocked['failure'] }));
681
+ },
682
+ recheck(id, file) {
683
+ const r = records.get(id);
684
+ if (!r || !dirty(r) || !r.document) return null;
685
+ const v = verdict({ base: r.base, document: r.document, fileVersion: r.fileVersion }, file, options.site, options.you?.() ?? null);
686
+ if (v === 'drop') {
687
+ r.written = r.revision;
688
+ r.failure = null;
689
+ r.by = null;
690
+ storage.remove(id);
691
+ refresh();
692
+ return 'drop';
693
+ }
694
+ if (v === 'send') {
695
+ r.failure = null;
696
+ r.by = null;
697
+ r.elsewhere = false;
698
+ refresh();
699
+ schedule(r, 0);
700
+ return 'send';
701
+ }
702
+ r.failure = 'conflict';
703
+ r.by = v.conflict;
704
+ r.elsewhere = false;
705
+ refresh();
706
+ return 'conflict';
707
+ },
708
+ dirty: (id) => {
709
+ const r = records.get(id);
710
+ return !!r && (dirty(r) || r.inFlight);
711
+ },
712
+ };
713
+ }