@superbee/core 0.2.0-pre.41 → 0.2.0-pre.42

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 (264) hide show
  1. package/README.md +50 -9
  2. package/dist/backend.d.ts +17 -1
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +41 -6
  5. package/dist/backend.js.map +1 -1
  6. package/dist/body-pages.d.ts +20 -0
  7. package/dist/body-pages.d.ts.map +1 -0
  8. package/dist/body-pages.js +56 -0
  9. package/dist/body-pages.js.map +1 -0
  10. package/dist/body-replace-guards.d.ts +29 -3
  11. package/dist/body-replace-guards.d.ts.map +1 -1
  12. package/dist/body-replace-guards.js +112 -19
  13. package/dist/body-replace-guards.js.map +1 -1
  14. package/dist/bounded-yaml.d.ts +28 -0
  15. package/dist/bounded-yaml.d.ts.map +1 -0
  16. package/dist/bounded-yaml.js +182 -0
  17. package/dist/bounded-yaml.js.map +1 -0
  18. package/dist/builtin-recipes/site.d.ts +5 -0
  19. package/dist/builtin-recipes/site.d.ts.map +1 -0
  20. package/dist/builtin-recipes/site.js +83 -0
  21. package/dist/builtin-recipes/site.js.map +1 -0
  22. package/dist/builtin-recipes.d.ts.map +1 -1
  23. package/dist/builtin-recipes.js +22 -0
  24. package/dist/builtin-recipes.js.map +1 -1
  25. package/dist/bundle-health/index.d.ts +2 -2
  26. package/dist/bundle-health/index.d.ts.map +1 -1
  27. package/dist/bundle-health/index.js +1 -1
  28. package/dist/bundle-health/index.js.map +1 -1
  29. package/dist/bundle-health/legacy-page.d.ts +8 -26
  30. package/dist/bundle-health/legacy-page.d.ts.map +1 -1
  31. package/dist/bundle-health/legacy-page.js +10 -35
  32. package/dist/bundle-health/legacy-page.js.map +1 -1
  33. package/dist/bundle-health/report.d.ts +0 -16
  34. package/dist/bundle-health/report.d.ts.map +1 -1
  35. package/dist/bundle-health/report.js +24 -54
  36. package/dist/bundle-health/report.js.map +1 -1
  37. package/dist/bundle-ops.d.ts +4 -1
  38. package/dist/bundle-ops.d.ts.map +1 -1
  39. package/dist/bundle-ops.js +6 -2
  40. package/dist/bundle-ops.js.map +1 -1
  41. package/dist/bundle.d.ts +1 -1
  42. package/dist/bundle.d.ts.map +1 -1
  43. package/dist/bundle.js +1 -1
  44. package/dist/bundle.js.map +1 -1
  45. package/dist/cross-bundle.d.ts +2 -2
  46. package/dist/cross-bundle.js +2 -2
  47. package/dist/document-codec.d.ts +3 -1
  48. package/dist/document-codec.d.ts.map +1 -1
  49. package/dist/document-codec.js +3 -1
  50. package/dist/document-codec.js.map +1 -1
  51. package/dist/document-mutation.d.ts.map +1 -1
  52. package/dist/document-mutation.js +39 -9
  53. package/dist/document-mutation.js.map +1 -1
  54. package/dist/document-scan.d.ts +23 -0
  55. package/dist/document-scan.d.ts.map +1 -0
  56. package/dist/document-scan.js +70 -0
  57. package/dist/document-scan.js.map +1 -0
  58. package/dist/document-transfer.d.ts +110 -4
  59. package/dist/document-transfer.d.ts.map +1 -1
  60. package/dist/document-transfer.js +490 -30
  61. package/dist/document-transfer.js.map +1 -1
  62. package/dist/engine.d.ts +4 -19
  63. package/dist/engine.d.ts.map +1 -1
  64. package/dist/engine.js +7 -47
  65. package/dist/engine.js.map +1 -1
  66. package/dist/errors.d.ts +37 -1
  67. package/dist/errors.d.ts.map +1 -1
  68. package/dist/errors.js +75 -3
  69. package/dist/errors.js.map +1 -1
  70. package/dist/file-journaled-backend.d.ts.map +1 -1
  71. package/dist/file-journaled-backend.js +2 -2
  72. package/dist/file-journaled-backend.js.map +1 -1
  73. package/dist/filesystem-identity.d.ts +9 -0
  74. package/dist/filesystem-identity.d.ts.map +1 -1
  75. package/dist/filesystem-identity.js +186 -64
  76. package/dist/filesystem-identity.js.map +1 -1
  77. package/dist/filesystem-lock.d.ts +2 -2
  78. package/dist/filesystem-lock.d.ts.map +1 -1
  79. package/dist/filesystem-lock.js +81 -14
  80. package/dist/filesystem-lock.js.map +1 -1
  81. package/dist/filesystem-temp-name.d.ts +9 -0
  82. package/dist/filesystem-temp-name.d.ts.map +1 -0
  83. package/dist/filesystem-temp-name.js +20 -0
  84. package/dist/filesystem-temp-name.js.map +1 -0
  85. package/dist/filesystem.d.ts +6 -0
  86. package/dist/filesystem.d.ts.map +1 -1
  87. package/dist/filesystem.js +2 -1
  88. package/dist/filesystem.js.map +1 -1
  89. package/dist/frontmatter-contract.d.ts +14 -0
  90. package/dist/frontmatter-contract.d.ts.map +1 -1
  91. package/dist/frontmatter-contract.js +22 -0
  92. package/dist/frontmatter-contract.js.map +1 -1
  93. package/dist/frontmatter-splitter.d.ts +6 -0
  94. package/dist/frontmatter-splitter.d.ts.map +1 -1
  95. package/dist/frontmatter-splitter.js +8 -0
  96. package/dist/frontmatter-splitter.js.map +1 -1
  97. package/dist/frontmatter.d.ts +8 -1
  98. package/dist/frontmatter.d.ts.map +1 -1
  99. package/dist/frontmatter.js +43 -7
  100. package/dist/frontmatter.js.map +1 -1
  101. package/dist/hosted-client/client.d.ts +22 -5
  102. package/dist/hosted-client/client.d.ts.map +1 -1
  103. package/dist/hosted-client/client.js +57 -10
  104. package/dist/hosted-client/client.js.map +1 -1
  105. package/dist/hosted-client/index.d.ts +1 -0
  106. package/dist/hosted-client/index.d.ts.map +1 -1
  107. package/dist/hosted-client/index.js +1 -0
  108. package/dist/hosted-client/index.js.map +1 -1
  109. package/dist/hosted-client/view-content.d.ts +107 -0
  110. package/dist/hosted-client/view-content.d.ts.map +1 -0
  111. package/dist/hosted-client/view-content.js +155 -0
  112. package/dist/hosted-client/view-content.js.map +1 -0
  113. package/dist/hosted-transport/carrier.d.ts +1 -1
  114. package/dist/hosted-transport/carrier.d.ts.map +1 -1
  115. package/dist/hosted-transport/carrier.js +27 -18
  116. package/dist/hosted-transport/carrier.js.map +1 -1
  117. package/dist/hosted-transport/read-adapter.d.ts +1 -2
  118. package/dist/hosted-transport/read-adapter.d.ts.map +1 -1
  119. package/dist/hosted-transport/read-adapter.js.map +1 -1
  120. package/dist/identity-fold.d.ts +12 -14
  121. package/dist/identity-fold.d.ts.map +1 -1
  122. package/dist/identity-fold.js +17 -15
  123. package/dist/identity-fold.js.map +1 -1
  124. package/dist/index-projection.d.ts +2 -1
  125. package/dist/index-projection.d.ts.map +1 -1
  126. package/dist/index-projection.js +14 -5
  127. package/dist/index-projection.js.map +1 -1
  128. package/dist/index.d.ts +24 -15
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +19 -15
  131. package/dist/index.js.map +1 -1
  132. package/dist/indexeddb-backend.d.ts.map +1 -1
  133. package/dist/indexeddb-backend.js +2 -2
  134. package/dist/indexeddb-backend.js.map +1 -1
  135. package/dist/json-data.d.ts +3 -0
  136. package/dist/json-data.d.ts.map +1 -0
  137. package/dist/json-data.js +19 -0
  138. package/dist/json-data.js.map +1 -0
  139. package/dist/kind-authoring.d.ts +18 -0
  140. package/dist/kind-authoring.d.ts.map +1 -1
  141. package/dist/kind-authoring.js +54 -4
  142. package/dist/kind-authoring.js.map +1 -1
  143. package/dist/kind-coverage.d.ts +1 -0
  144. package/dist/kind-coverage.d.ts.map +1 -1
  145. package/dist/kind-coverage.js +4 -5
  146. package/dist/kind-coverage.js.map +1 -1
  147. package/dist/kind-undeclared-fields.d.ts +70 -0
  148. package/dist/kind-undeclared-fields.d.ts.map +1 -0
  149. package/dist/kind-undeclared-fields.js +120 -0
  150. package/dist/kind-undeclared-fields.js.map +1 -0
  151. package/dist/kinds-load.d.ts.map +1 -1
  152. package/dist/kinds-load.js +4 -1
  153. package/dist/kinds-load.js.map +1 -1
  154. package/dist/kinds.d.ts +9 -0
  155. package/dist/kinds.d.ts.map +1 -1
  156. package/dist/kinds.js +20 -0
  157. package/dist/kinds.js.map +1 -1
  158. package/dist/link-findings.d.ts +130 -0
  159. package/dist/link-findings.d.ts.map +1 -0
  160. package/dist/link-findings.js +272 -0
  161. package/dist/link-findings.js.map +1 -0
  162. package/dist/link-types.d.ts +7 -0
  163. package/dist/link-types.d.ts.map +1 -1
  164. package/dist/link-types.js +17 -2
  165. package/dist/link-types.js.map +1 -1
  166. package/dist/memory-backend.d.ts.map +1 -1
  167. package/dist/memory-backend.js +2 -2
  168. package/dist/memory-backend.js.map +1 -1
  169. package/dist/page.d.ts +3 -7
  170. package/dist/page.d.ts.map +1 -1
  171. package/dist/page.js +3 -7
  172. package/dist/page.js.map +1 -1
  173. package/dist/path-fold.d.ts +29 -16
  174. package/dist/path-fold.d.ts.map +1 -1
  175. package/dist/path-fold.js +30 -17
  176. package/dist/path-fold.js.map +1 -1
  177. package/dist/paths.d.ts +28 -0
  178. package/dist/paths.d.ts.map +1 -1
  179. package/dist/paths.js +82 -1
  180. package/dist/paths.js.map +1 -1
  181. package/dist/platform.d.ts +9 -0
  182. package/dist/platform.d.ts.map +1 -1
  183. package/dist/platform.js.map +1 -1
  184. package/dist/portable-frontmatter.d.ts +6 -0
  185. package/dist/portable-frontmatter.d.ts.map +1 -1
  186. package/dist/portable-frontmatter.js +26 -2
  187. package/dist/portable-frontmatter.js.map +1 -1
  188. package/dist/recipe-lifecycle/install.d.ts +4 -7
  189. package/dist/recipe-lifecycle/install.d.ts.map +1 -1
  190. package/dist/recipe-lifecycle/install.js +3 -20
  191. package/dist/recipe-lifecycle/install.js.map +1 -1
  192. package/dist/recipe-lifecycle/legacy-aliases.d.ts +1 -13
  193. package/dist/recipe-lifecycle/legacy-aliases.d.ts.map +1 -1
  194. package/dist/recipe-lifecycle/legacy-aliases.js +5 -34
  195. package/dist/recipe-lifecycle/legacy-aliases.js.map +1 -1
  196. package/dist/record-overlay/apply.d.ts.map +1 -1
  197. package/dist/record-overlay/apply.js +0 -4
  198. package/dist/record-overlay/apply.js.map +1 -1
  199. package/dist/record-overlay/findings.d.ts.map +1 -1
  200. package/dist/record-overlay/findings.js +4 -1
  201. package/dist/record-overlay/findings.js.map +1 -1
  202. package/dist/record-overlay/plan.js +3 -3
  203. package/dist/record-overlay/plan.js.map +1 -1
  204. package/dist/record-overlay/signal-text.d.ts +9 -2
  205. package/dist/record-overlay/signal-text.d.ts.map +1 -1
  206. package/dist/record-overlay/signal-text.js +21 -11
  207. package/dist/record-overlay/signal-text.js.map +1 -1
  208. package/dist/remote-backend.d.ts +9 -19
  209. package/dist/remote-backend.d.ts.map +1 -1
  210. package/dist/remote-backend.js +17 -5
  211. package/dist/remote-backend.js.map +1 -1
  212. package/dist/remote-operations.d.ts.map +1 -1
  213. package/dist/remote-operations.js +2 -0
  214. package/dist/remote-operations.js.map +1 -1
  215. package/dist/remote-parsers.d.ts +19 -0
  216. package/dist/remote-parsers.d.ts.map +1 -1
  217. package/dist/remote-parsers.js.map +1 -1
  218. package/dist/remote.d.ts +2 -4
  219. package/dist/remote.d.ts.map +1 -1
  220. package/dist/remote.js +2 -3
  221. package/dist/remote.js.map +1 -1
  222. package/dist/reserved-write.d.ts +9 -0
  223. package/dist/reserved-write.d.ts.map +1 -0
  224. package/dist/reserved-write.js +15 -0
  225. package/dist/reserved-write.js.map +1 -0
  226. package/dist/search/backend-source.d.ts +2 -2
  227. package/dist/search/backend-source.d.ts.map +1 -1
  228. package/dist/search/backend-source.js +30 -6
  229. package/dist/search/backend-source.js.map +1 -1
  230. package/dist/search/contract.d.ts +3 -0
  231. package/dist/search/contract.d.ts.map +1 -1
  232. package/dist/search/contract.js.map +1 -1
  233. package/dist/search/search.d.ts.map +1 -1
  234. package/dist/search/search.js +4 -1
  235. package/dist/search/search.js.map +1 -1
  236. package/dist/search/source.d.ts +3 -0
  237. package/dist/search/source.d.ts.map +1 -1
  238. package/dist/site-manifest.d.ts +49 -0
  239. package/dist/site-manifest.d.ts.map +1 -0
  240. package/dist/site-manifest.js +113 -0
  241. package/dist/site-manifest.js.map +1 -0
  242. package/dist/skip-reasons.d.ts +12 -0
  243. package/dist/skip-reasons.d.ts.map +1 -0
  244. package/dist/skip-reasons.js +15 -0
  245. package/dist/skip-reasons.js.map +1 -0
  246. package/dist/source-engine/driver.d.ts +1 -3
  247. package/dist/source-engine/driver.d.ts.map +1 -1
  248. package/dist/source-engine/driver.js +2 -18
  249. package/dist/source-engine/driver.js.map +1 -1
  250. package/dist/source-profiles/index.d.ts +1 -1
  251. package/dist/source-profiles/index.js +1 -1
  252. package/dist/string-identity-agreement.d.ts +1 -1
  253. package/dist/string-identity-agreement.d.ts.map +1 -1
  254. package/dist/string-identity-agreement.js +6 -0
  255. package/dist/string-identity-agreement.js.map +1 -1
  256. package/dist/system-documents.d.ts +13 -0
  257. package/dist/system-documents.d.ts.map +1 -0
  258. package/dist/system-documents.js +44 -0
  259. package/dist/system-documents.js.map +1 -0
  260. package/dist/types.d.ts +7 -0
  261. package/dist/types.d.ts.map +1 -1
  262. package/dist/validation.d.ts +5 -0
  263. package/dist/validation.d.ts.map +1 -1
  264. package/package.json +19 -7
@@ -11,8 +11,17 @@
11
11
  * all-or-nothing. It writes the Promotion record first (`promotion_state: applying`, with the
12
12
  * whole mapping) as the journal, then creates every document with an expect-absent write, then
13
13
  * marks the record `applied`. A failed write stops the apply and leaves the record at `applying`;
14
- * {@link resumeDocumentTransfer} is the one recovery path. Nothing here deletes or overwrites a
15
- * document.
14
+ * {@link resumeDocumentTransfer} is the one recovery path. Nothing here deletes a document, and only a
15
+ * refresh replaces one.
16
+ *
17
+ * A refresh ({@link planDocumentRefresh}, {@link applyDocumentRefresh}) brings the copies an
18
+ * applied record names up to date with their sources. The record's `source_version` and
19
+ * `target_version` per entry are the baseline: a changed source is an update, and a copy whose
20
+ * version differs from `target_version` was edited in the target and is a conflict unless the
21
+ * caller names it to overwrite or skip. The record is the journal again (`promotion_state:
22
+ * refreshing` with the pending rows), every copy is replaced under a hard compare-and-swap on the
23
+ * version the plan saw, and {@link resumeDocumentTransfer} finishes an interrupted refresh. A
24
+ * refresh never creates, deletes or picks up documents the record does not already name.
16
25
  *
17
26
  * v1 caveats: resume writes the remaining documents even if the target has since dropped a Kind
18
27
  * that governed them. Reference-style link definitions (`[x]: ../f.md`) are copied unchanged and
@@ -21,18 +30,20 @@
21
30
  * Storage-neutral: the source is a {@link BundleBinding} (any backend), the target is written
22
31
  * through the shared document mutation policy, and no Node module is imported.
23
32
  */
33
+ import { computeDroppedLinks, outboundLinks } from "./body-replace-guards.js";
24
34
  import { backendFor, readBundleOkfVersion, readDocVersioned } from "./bundle-ops.js";
25
35
  import { BUNDLE_NAME_DOC_ID, declaredBundleId, DEPENDENCIES_DOC_ID, parseDependencies, } from "./dependencies.js";
26
36
  import { DocumentNotFoundError, mutateDocument, prepareDocumentMutationCandidate } from "./document-mutation.js";
27
37
  import { readManyExisting } from "./engine.js";
28
38
  import { FilesystemIdentityAliasError, InvalidInputError, OkfActorError } from "./errors.js";
29
- import { foldIdentityPath } from "./identity-fold.js";
39
+ import { foldPath } from "./path-fold.js";
30
40
  import { loadKinds } from "./kinds-load.js";
31
41
  import { CONVENTIONS_PREFIX } from "./kinds.js";
32
42
  import { emitLinkHref } from "./link-emit.js";
33
43
  import { extractMarkdownLinkSpans, maskMarkdownCode, normalizeSegments, resolveConceptId, rewriteMarkdownLinks } from "./links.js";
34
44
  import { isOkfActor } from "./okf-actor.js";
35
- import { assertSafeConceptId, compareStorageKeys, isReservedFile, pathFromConceptId, trimTrailingSlashes } from "./paths.js";
45
+ import { SUPERBEE_UPDATED_BY_FIELD } from "./mutation-attribution.js";
46
+ import { assertCanonicalNewDocumentId, assertSafeConceptId, compareStorageKeys, isReservedFile, pathFromConceptId, trimTrailingSlashes } from "./paths.js";
36
47
  import { isQualifiedHref, parseQualifiedHref, qualifiedHref } from "./qualified-links.js";
37
48
  import { sha256HexOfUtf8 } from "./sha256.js";
38
49
  import { VersionConflict } from "./versioning.js";
@@ -70,16 +81,19 @@ export class TransferInterrupted extends Error {
70
81
  recordId;
71
82
  written;
72
83
  pending;
84
+ /** The state the record was left in: `applying` for an import, `refreshing` for a refresh. */
85
+ state;
73
86
  cause;
74
- constructor(recordId, written, pending, cause) {
87
+ constructor(recordId, written, pending, cause, state = "applying") {
75
88
  const why = cause instanceof Error ? cause.message : String(cause);
76
- super(`the import stopped after writing ${written.length} document${written.length === 1 ? "" : "s"}` +
89
+ super(`the ${state === "applying" ? "import" : "refresh"} stopped after writing ${written.length} document${written.length === 1 ? "" : "s"}` +
77
90
  `${pending.length > 0 ? ` (${pending.length} not written, first '${pending[0].target}')` : ""}: ${why}. ` +
78
- `'${recordId}' stays at promotion_state applying; resume it to finish.`);
91
+ `'${recordId}' stays at promotion_state ${state}; resume it to finish.`);
79
92
  this.name = "TransferInterrupted";
80
93
  this.recordId = recordId;
81
94
  this.written = written;
82
95
  this.pending = pending;
96
+ this.state = state;
83
97
  this.cause = cause;
84
98
  }
85
99
  }
@@ -89,7 +103,7 @@ export class TransferResumeConflict extends Error {
89
103
  id;
90
104
  written;
91
105
  constructor(recordId, detail, options = {}) {
92
- super(`cannot resume '${recordId}': ${detail}`);
106
+ super(`cannot ${options.verb ?? "resume"} '${recordId}': ${detail}`);
93
107
  this.name = "TransferResumeConflict";
94
108
  this.recordId = recordId;
95
109
  if (options.id !== undefined)
@@ -121,7 +135,7 @@ async function readSource(binding) {
121
135
  const docs = new Map(results.map((result) => [result.doc.id, result]));
122
136
  const ids = new Set([...docs.keys(), ...malformed.keys()]);
123
137
  const dependencies = parseDependencies(docs.get(DEPENDENCIES_DOC_ID)?.doc ?? null, { selfBundleId: binding.identity });
124
- const folds = new Set([...ids].map(foldIdentityPath));
138
+ const folds = new Set([...ids].map(foldPath));
125
139
  return { ids, folds, docs, malformed, dependencies, registry: await loadKinds(bundle), bundle };
126
140
  }
127
141
  async function readTarget(target) {
@@ -221,7 +235,7 @@ function planLink(ctx, sourceId, targetId, span, conflicts) {
221
235
  const present = ctx.source.ids.has(resolved);
222
236
  // A spelling variant of a source id names no document under the exact-identity rule; qualifying
223
237
  // it would send a reader to an id the source bundle refuses to serve.
224
- if (!present && ctx.source.folds.has(foldIdentityPath(resolved)))
238
+ if (!present && ctx.source.folds.has(foldPath(resolved)))
225
239
  return keep("case_variant", { source_target: resolved });
226
240
  const withFragment = fragment === undefined ? {} : { fragment };
227
241
  const mapped = ctx.mapping.get(resolved);
@@ -317,20 +331,24 @@ function normalizePrefix(prefix) {
317
331
  const trimmed = trimTrailingSlashes(prefix.trim());
318
332
  return trimmed === "" ? undefined : trimmed;
319
333
  }
320
- /** Every planned id must be safe, absent, unique and distinct under the filesystem identity fold. */
321
- const PROTECTED_TARGET_FOLDS = new Set([BUNDLE_NAME_DOC_ID, DEPENDENCIES_DOC_ID].map(foldIdentityPath));
322
- const CONVENTIONS_FOLD = `${foldIdentityPath(CONVENTIONS_PREFIX.replace(/\/$/, ""))}/`;
334
+ /** Every planned id must be safe, absent, unique and distinct under `foldPath`. */
335
+ const PROTECTED_TARGET_FOLDS = new Set([BUNDLE_NAME_DOC_ID, DEPENDENCIES_DOC_ID].map(foldPath));
336
+ const CONVENTIONS_FOLD = `${foldPath(CONVENTIONS_PREFIX.replace(/\/$/, ""))}/`;
323
337
  /**
324
338
  * Throw {@link InvalidInputError} unless an import may create `id`: a safe, unreserved concept id
325
339
  * that is not the target's own identity or dependency declaration and not a Kind convention
326
- * (installing Kinds is not part of v1). Compared under the identity fold, so a case or
340
+ * (installing Kinds is not part of v1). Compared under `foldPath`, so a case or
327
341
  * normalization variant of a protected id is refused too.
328
342
  */
329
- function assertImportableTargetId(id) {
343
+ function assertImportableTargetId(id, newTarget) {
330
344
  assertSafeConceptId(id);
345
+ // Only a planned target is a create; a stored record's targets may predate the canonical-id rule,
346
+ // and any create its resume still performs meets the rule in mutateDocument.
347
+ if (newTarget)
348
+ assertCanonicalNewDocumentId(id, newTarget.exists);
331
349
  if (isReservedFile(pathFromConceptId(id)))
332
350
  throw new InvalidInputError("index.md and log.md are reserved files, not documents");
333
- const fold = foldIdentityPath(id);
351
+ const fold = foldPath(id);
334
352
  if (PROTECTED_TARGET_FOLDS.has(fold))
335
353
  throw new InvalidInputError("it is the bundle's own identity or dependency declaration");
336
354
  if (fold.startsWith(CONVENTIONS_FOLD))
@@ -339,11 +357,11 @@ function assertImportableTargetId(id) {
339
357
  function checkTargetIds(planned, existing, conflicts) {
340
358
  const folds = new Map();
341
359
  const remember = (id) => {
342
- const keys = [[`${foldIdentityPath(id)}.md`, `${id}.md`]];
360
+ const keys = [[`${foldPath(id)}.md`, `${id}.md`]];
343
361
  const segments = id.split("/");
344
362
  for (let i = 1; i < segments.length; i++) {
345
363
  const dir = segments.slice(0, i).join("/");
346
- keys.push([`${foldIdentityPath(dir)}/`, `${dir}/`]);
364
+ keys.push([`${foldPath(dir)}/`, `${dir}/`]);
347
365
  }
348
366
  return keys;
349
367
  };
@@ -355,7 +373,7 @@ function checkTargetIds(planned, existing, conflicts) {
355
373
  for (const row of planned) {
356
374
  const where = { ...(row.source === undefined ? {} : { source: row.source }), target: row.target };
357
375
  try {
358
- assertImportableTargetId(row.target);
376
+ assertImportableTargetId(row.target, { exists: (candidate) => existing.has(candidate) });
359
377
  }
360
378
  catch (error) {
361
379
  if (!(error instanceof InvalidInputError))
@@ -581,8 +599,11 @@ async function computeTransfer(request) {
581
599
  if (types.has(String(doc.frontmatter.type)) || underPrefix(id))
582
600
  selected.add(id);
583
601
  }
602
+ // An unreadable document's type is unknowable, so a type selection cannot rule it out: report it
603
+ // rather than let the copy look complete. Ids already reported above are not repeated.
604
+ const namedIds = new Set(request.select.ids ?? []);
584
605
  for (const [id, reason] of source.malformed) {
585
- if (!SOURCE_SELF_DOCS.has(id) && underPrefix(id)) {
606
+ if (!SOURCE_SELF_DOCS.has(id) && (underPrefix(id) || (types.size > 0 && !namedIds.has(id)))) {
586
607
  conflicts.push({ code: "source_malformed", source: id, detail: `'${id}' is malformed in the source: ${reason}` });
587
608
  }
588
609
  }
@@ -593,6 +614,15 @@ async function computeTransfer(request) {
593
614
  const mapping = new Map(selectedIds.map((id) => [id, prefix === undefined ? id : `${prefix}/${id}`]));
594
615
  const recordId = request.recordId?.trim() || defaultRecordId(request.from.alias, bundleId, prefix, selectedIds);
595
616
  checkTargetIds([...[...mapping].map(([s, t]) => ({ source: s, target: t })), { target: recordId, record: true }], target.ids, conflicts);
617
+ for (const conflict of conflicts) {
618
+ if (conflict.code !== "target_exists" || conflict.target === undefined)
619
+ continue;
620
+ const promotedBy = await promotingRecord(request.target, conflict.target, request.from.alias);
621
+ if (promotedBy === undefined)
622
+ continue;
623
+ conflict.record = promotedBy;
624
+ conflict.detail += `; it is a copy promoted by '${promotedBy}', which a refresh of that record updates`;
625
+ }
596
626
  const ctx = { alias: request.from.alias, source, target, mapping };
597
627
  const built = [];
598
628
  for (const id of selectedIds) {
@@ -674,7 +704,7 @@ export async function planDocumentTransfer(request) {
674
704
  return (await computeTransfer(request)).plan;
675
705
  }
676
706
  // ── Writes ─────────────────────────────────────────────────────────────────
677
- async function writeRecordState(target, registry, recordId, expectedVersion, state, versions, actor, now) {
707
+ async function writeRecordState(target, registry, recordId, expectedVersion, state, versions, actor, now, edited = new Set()) {
678
708
  const result = await mutateDocument({
679
709
  bundle: target,
680
710
  id: recordId,
@@ -696,6 +726,8 @@ async function writeRecordState(target, registry, recordId, expectedVersion, sta
696
726
  const version = versions.get(String(entry.target));
697
727
  if (version !== undefined)
698
728
  entry.target_version = version;
729
+ if (edited.has(String(entry.target)))
730
+ entry.target_edited = true;
699
731
  return entry;
700
732
  });
701
733
  }
@@ -719,7 +751,7 @@ export async function applyDocumentTransfer(request, planToken) {
719
751
  const record = parseRecord(plan.record.id, prior.doc);
720
752
  if (record.state === "applied")
721
753
  return appliedResult(plan.record.id, prior.version, record.entries, false);
722
- throw new TransferConflictError([{ code: "record_exists", target: plan.record.id, detail: `'${plan.record.id}' from this plan is still applying; resume it to finish` }]);
754
+ throw new TransferConflictError([{ code: "record_exists", target: plan.record.id, detail: `'${plan.record.id}' from this plan is still ${record.state}; resume it to finish` }]);
723
755
  }
724
756
  throw new TransferConflictError(plan.conflicts);
725
757
  }
@@ -779,14 +811,14 @@ export async function applyDocumentTransfer(request, planToken) {
779
811
  }
780
812
  return { record: { id: recordId, version: recordVersion, state: "applied" }, written, changed: true };
781
813
  }
782
- function parseRecord(recordId, doc) {
814
+ function parseRecord(recordId, doc, verb = "resume") {
783
815
  const fm = doc.frontmatter;
784
- const refuse = (detail) => { throw new TransferResumeConflict(recordId, detail); };
816
+ const refuse = (detail) => { throw new TransferResumeConflict(recordId, detail, { verb }); };
785
817
  if (String(fm.type).trim() !== PROMOTION_TYPE)
786
818
  refuse(`it is not a ${PROMOTION_TYPE} record (type '${String(fm.type)}')`);
787
819
  const state = fm.promotion_state;
788
- if (state !== "applying" && state !== "applied")
789
- refuse("promotion_state must be applying or applied");
820
+ if (state !== "applying" && state !== "applied" && state !== "refreshing")
821
+ refuse("promotion_state must be applying, applied or refreshing");
790
822
  if (typeof fm.from_alias !== "string" || typeof fm.from_bundle_id !== "string")
791
823
  refuse("from_alias and from_bundle_id are required");
792
824
  if (!Array.isArray(fm.entries))
@@ -803,6 +835,8 @@ function parseRecord(recordId, doc) {
803
835
  const entry = { source: r.source, source_version: r.source_version, target: r.target };
804
836
  if (typeof r.target_version === "string")
805
837
  entry.target_version = r.target_version;
838
+ if (r.target_edited === true)
839
+ entry.target_edited = true;
806
840
  // The record is a document anyone can edit: every target is recomputed from its source and
807
841
  // the record's prefix, and must pass the same rule a plan applies, before resume trusts it.
808
842
  if (SOURCE_SELF_DOCS.has(entry.source))
@@ -821,7 +855,25 @@ function parseRecord(recordId, doc) {
821
855
  }
822
856
  entries.push(entry);
823
857
  }
824
- return { state: state, alias: fm.from_alias, bundleId: fm.from_bundle_id, ...(prefix === undefined ? {} : { prefix }), entries };
858
+ const parsed = { state: state, alias: fm.from_alias, bundleId: fm.from_bundle_id, ...(prefix === undefined ? {} : { prefix }), entries };
859
+ if (state === "refreshing") {
860
+ const journal = fm.refresh;
861
+ if (!journal || typeof journal !== "object" || !Array.isArray(journal.entries))
862
+ refuse("a refreshing record needs refresh.entries");
863
+ const byTarget = new Map(entries.map((e) => [e.target, e]));
864
+ parsed.refresh = [];
865
+ for (const row of journal.entries) {
866
+ const r = row;
867
+ if (!r || typeof r.source !== "string" || typeof r.source_version !== "string" || typeof r.target !== "string" || typeof r.replaces_version !== "string") {
868
+ refuse("every refresh entry needs source, source_version, target and replaces_version");
869
+ }
870
+ // A pending row may only name an entry the record already holds, with the same source.
871
+ if (byTarget.get(r.target)?.source !== r.source)
872
+ refuse(`refresh entry '${String(r.target)}' is not an entry of this record`);
873
+ parsed.refresh.push({ source: r.source, source_version: r.source_version, target: r.target, replaces_version: r.replaces_version });
874
+ }
875
+ }
876
+ return parsed;
825
877
  }
826
878
  /** The result an already-applied record stands for: nothing is written again. */
827
879
  function appliedResult(recordId, version, entries, changed) {
@@ -833,15 +885,57 @@ function appliedResult(recordId, version, entries, changed) {
833
885
  }
834
886
  return { record: { id: recordId, version, state: "applied" }, written, changed };
835
887
  }
836
- function hasProvenance(doc, recordId) {
888
+ function provenanceEntries(doc) {
837
889
  const sources = doc.frontmatter.sources;
838
- return Array.isArray(sources) && sources.some((s) => s !== null && typeof s === "object" && s.id === recordId);
890
+ return Array.isArray(sources) ? sources.filter((s) => s !== null && typeof s === "object" && !Array.isArray(s)) : [];
891
+ }
892
+ /**
893
+ * True when `doc` is what writing `candidate` over it would leave: the mutation service's own no-op
894
+ * test, attributed to whichever actor the copy names (the writer may not be the resuming actor).
895
+ */
896
+ function isWrittenCopy(doc, candidate, registry, okfVersion) {
897
+ const attributed = doc.frontmatter[okfVersion === "0.1" ? "actor" : SUPERBEE_UPDATED_BY_FIELD];
898
+ try {
899
+ // Overwrite mode compares after attribution, so a v0.1 `actor` the writer stamped matches too.
900
+ return !prepareDocumentMutationCandidate(doc, structuredClone(candidate), {
901
+ mode: "overwrite",
902
+ id: doc.id,
903
+ registry,
904
+ strict: false,
905
+ okfVersion,
906
+ ...(typeof attributed === "string" ? { actor: attributed } : {}),
907
+ seedGenerationClock: false,
908
+ }).changed;
909
+ }
910
+ catch {
911
+ return false;
912
+ }
913
+ }
914
+ function hasProvenance(doc, recordId) {
915
+ return provenanceEntries(doc).some((s) => s.id === recordId);
916
+ }
917
+ /** The Promotion record an existing copy at `id` names as its import from `alias`, if any. */
918
+ async function promotingRecord(target, id, alias) {
919
+ const read = await readOptional(target, id).catch(() => null);
920
+ if (!read)
921
+ return undefined;
922
+ for (const entry of provenanceEntries(read.doc)) {
923
+ if (typeof entry.id !== "string" || !entry.id.startsWith(PROMOTIONS_PREFIX))
924
+ continue;
925
+ if (typeof entry.resource !== "string" || !entry.resource.startsWith(`okf://${alias}/`))
926
+ continue;
927
+ const record = await readOptional(target, entry.id).catch(() => null);
928
+ if (record && String(record.doc.frontmatter.type).trim() === PROMOTION_TYPE)
929
+ return entry.id;
930
+ }
931
+ return undefined;
839
932
  }
840
933
  /**
841
934
  * Finish an `applying` Promotion record. For each entry: an absent target is created from the
842
935
  * source at the recorded source version (a changed source refuses); a present target carrying a
843
936
  * `sources` entry with this record's id is already done; anything else refuses. When every entry
844
937
  * is done the record becomes `applied`. Running it again on an `applied` record is a no-op.
938
+ * A `refreshing` record is finished the same way by {@link finishRefresh}.
845
939
  */
846
940
  export async function resumeDocumentTransfer(target, recordId, from, options = {}) {
847
941
  const read = await readOptional(target, recordId);
@@ -855,6 +949,8 @@ export async function resumeDocumentTransfer(target, recordId, from, options = {
855
949
  }
856
950
  if (record.state === "applied")
857
951
  return appliedResult(recordId, read.version, record.entries, false);
952
+ if (record.state === "refreshing")
953
+ return finishRefresh(target, recordId, read, record, from, options);
858
954
  const snapshot = await readTarget(target);
859
955
  assertActor(options.actor, snapshot.okfVersion);
860
956
  // The same alias and identity check a plan runs: the target's declaration may have moved since.
@@ -870,6 +966,7 @@ export async function resumeDocumentTransfer(target, recordId, from, options = {
870
966
  const registry = snapshot.registry;
871
967
  const written = [];
872
968
  const versions = new Map();
969
+ const edited = new Set();
873
970
  for (const entry of record.entries) {
874
971
  let existing;
875
972
  try {
@@ -887,6 +984,15 @@ export async function resumeDocumentTransfer(target, recordId, from, options = {
887
984
  }
888
985
  written.push({ source: entry.source, target: entry.target, version: existing.version });
889
986
  versions.set(entry.target, existing.version);
987
+ // A copy edited after the interrupted write still carries the provenance; it is adopted, but
988
+ // flagged so a refresh treats it as edited instead of as the import's own baseline.
989
+ const sourceRead = source.docs.get(entry.source);
990
+ const conflicts = [];
991
+ const built = sourceRead?.version === entry.source_version
992
+ ? buildEntry(ctx, record.bundleId, recordId, sourceRead, entry.target, selected, true, options.actor, now, conflicts)
993
+ : undefined;
994
+ if (!built || conflicts.length > 0 || !isWrittenCopy(existing.doc, built.candidate, registry, snapshot.okfVersion))
995
+ edited.add(entry.target);
890
996
  continue;
891
997
  }
892
998
  const sourceDoc = source.docs.get(entry.source);
@@ -922,7 +1028,361 @@ export async function resumeDocumentTransfer(target, recordId, from, options = {
922
1028
  throw error;
923
1029
  }
924
1030
  }
925
- const version = await writeRecordState(target, registry, recordId, read.version, "applied", versions, options.actor, options.now);
1031
+ const version = await writeRecordState(target, registry, recordId, read.version, "applied", versions, options.actor, options.now, edited);
926
1032
  return { record: { id: recordId, version, state: "applied" }, written, changed: true };
927
1033
  }
1034
+ // ── Refresh ────────────────────────────────────────────────────────────────
1035
+ export const DOCUMENT_REFRESH_TOKEN_PREFIX = `${DOCUMENT_TRANSFER_CONTRACT}:refresh`;
1036
+ function matchesEntry(names, entry) {
1037
+ return names.has(entry.target) || names.has(entry.source);
1038
+ }
1039
+ async function computeRefresh(request) {
1040
+ const conflicts = [];
1041
+ const now = request.now ?? (() => new Date().toISOString());
1042
+ const read = await readOptional(request.target, request.recordId);
1043
+ if (!read)
1044
+ throw new DocumentNotFoundError(request.recordId);
1045
+ const record = parseRecord(request.recordId, read.doc, "refresh");
1046
+ const target = await readTarget(request.target);
1047
+ assertActor(request.actor, target.okfVersion);
1048
+ if (record.alias !== request.from.alias) {
1049
+ conflicts.push({ code: "alias_identity", detail: `'${request.recordId}' was written for alias '${record.alias}', not '${request.from.alias}'` });
1050
+ }
1051
+ const identity = await sourceIdentity(request.from, target, conflicts);
1052
+ if (identity.bundleId !== record.bundleId) {
1053
+ conflicts.push({ code: "alias_identity", detail: `'${request.recordId}' imported from '${record.bundleId}', but '${request.from.alias}' is '${identity.bundleId}'` });
1054
+ }
1055
+ if (record.state !== "applied") {
1056
+ conflicts.push({ code: "record_state", detail: `'${request.recordId}' is ${record.state}; resume it before a refresh` });
1057
+ }
1058
+ const overwrite = new Set(request.overwrite ?? []);
1059
+ const skip = new Set(request.skip ?? []);
1060
+ for (const name of [...overwrite, ...skip]) {
1061
+ if (!record.entries.some((e) => e.target === name || e.source === name)) {
1062
+ conflicts.push({ code: "not_in_record", target: name, detail: `'${name}' is not a copy '${request.recordId}' names` });
1063
+ }
1064
+ }
1065
+ for (const name of overwrite) {
1066
+ if (skip.has(name))
1067
+ conflicts.push({ code: "not_in_record", target: name, detail: `'${name}' is named both to overwrite and to skip` });
1068
+ }
1069
+ const source = await readSource(request.from.binding);
1070
+ const mapping = new Map(record.entries.map((e) => [e.source, e.target]));
1071
+ const selected = new Set(mapping.keys());
1072
+ const ctx = { alias: request.from.alias, source, target, mapping };
1073
+ const candidates = new Map();
1074
+ const entries = [];
1075
+ for (const entry of record.entries) {
1076
+ let current;
1077
+ try {
1078
+ current = await readOptional(request.target, entry.target);
1079
+ }
1080
+ catch (error) {
1081
+ if (!(error instanceof FilesystemIdentityAliasError))
1082
+ throw error;
1083
+ current = null;
1084
+ conflicts.push({ code: "case_collision", source: entry.source, target: entry.target, detail: `'${entry.target}' is spelled differently on disk: ${error.message}` });
1085
+ }
1086
+ const sourceRead = source.docs.get(entry.source);
1087
+ const row = {
1088
+ source: entry.source,
1089
+ target: entry.target,
1090
+ action: "unchanged",
1091
+ recorded_source_version: entry.source_version,
1092
+ target_version: current?.version ?? null,
1093
+ edited: current !== null && (current.version !== entry.target_version || entry.target_edited === true),
1094
+ dropped_links: 0,
1095
+ };
1096
+ if (entry.target_version !== undefined)
1097
+ row.recorded_target_version = entry.target_version;
1098
+ if (sourceRead)
1099
+ row.source_version = sourceRead.version;
1100
+ entries.push(row);
1101
+ const where = { source: entry.source, target: entry.target };
1102
+ const block = (code, detail) => {
1103
+ row.action = "conflict";
1104
+ row.detail = detail;
1105
+ conflicts.push({ code, ...where, detail });
1106
+ };
1107
+ if (matchesEntry(skip, entry)) {
1108
+ row.action = "skip";
1109
+ continue;
1110
+ }
1111
+ if (entry.target_version === undefined) {
1112
+ block("record_state", `the record holds no target_version for '${entry.target}', so a local edit cannot be told apart`);
1113
+ continue;
1114
+ }
1115
+ if (source.malformed.has(entry.source)) {
1116
+ block("source_malformed", `'${entry.source}' is malformed in the source: ${source.malformed.get(entry.source)}`);
1117
+ continue;
1118
+ }
1119
+ const sourceChanged = sourceRead?.version !== entry.source_version;
1120
+ if (!sourceRead) {
1121
+ block("source_missing", `'${entry.source}' is no longer in ${request.from.binding.label}; a refresh never deletes the copy`);
1122
+ continue;
1123
+ }
1124
+ if (current === null) {
1125
+ if (sourceChanged)
1126
+ block("target_missing", `'${entry.target}' is gone from the target; a refresh never recreates it`);
1127
+ else
1128
+ row.detail = "the copy is gone from the target; its source has not changed";
1129
+ continue;
1130
+ }
1131
+ const overwriting = row.edited && matchesEntry(overwrite, entry);
1132
+ if (!sourceChanged && !overwriting) {
1133
+ if (row.edited)
1134
+ row.detail = "the copy was edited in the target; its source has not changed";
1135
+ continue;
1136
+ }
1137
+ if (row.edited && !overwriting) {
1138
+ block("target_edited", `'${entry.target}' was edited in the target since '${request.recordId}' wrote it; overwrite or skip it explicitly`);
1139
+ continue;
1140
+ }
1141
+ const built = buildEntry(ctx, record.bundleId, request.recordId, sourceRead, entry.target, selected, true, request.actor, now, conflicts);
1142
+ candidates.set(entry.source, built.candidate);
1143
+ row.action = overwriting ? "overwrite" : "update";
1144
+ row.dropped_links = computeDroppedLinks(outboundLinks(current.doc), outboundLinks({ ...current.doc, body: built.candidate.body })).length;
1145
+ }
1146
+ const counts = { update: 0, overwrite: 0, unchanged: 0, skip: 0, conflict: 0 };
1147
+ for (const row of entries)
1148
+ counts[row.action]++;
1149
+ const from = { alias: request.from.alias, bundle_id: identity.bundleId, label: request.from.label ?? request.from.binding.label };
1150
+ if (identity.pin)
1151
+ from.pin = identity.pin;
1152
+ const ready = conflicts.length === 0;
1153
+ const plan = {
1154
+ contract: DOCUMENT_TRANSFER_CONTRACT,
1155
+ record: { id: request.recordId, version: read.version, state: record.state },
1156
+ from,
1157
+ entries,
1158
+ counts,
1159
+ conflicts,
1160
+ ready,
1161
+ };
1162
+ if (ready && counts.update + counts.overwrite > 0) {
1163
+ plan.plan_token = `${DOCUMENT_REFRESH_TOKEN_PREFIX}:sha256:${hashOf({
1164
+ contract: DOCUMENT_REFRESH_TOKEN_PREFIX,
1165
+ record: { id: request.recordId, version: read.version },
1166
+ from: { bundle_id: identity.bundleId, pin: identity.pin?.value ?? null },
1167
+ target: { okf_version: target.okfVersion, bundle_id: target.bundleId ?? null, dependencies_version: target.dependenciesVersion },
1168
+ entries: entries.map((row) => ({
1169
+ source: row.source,
1170
+ target: row.target,
1171
+ action: row.action,
1172
+ source_version: row.source_version ?? null,
1173
+ target_version: row.target_version,
1174
+ candidate: candidates.has(row.source) ? hashOf(candidates.get(row.source)) : null,
1175
+ })),
1176
+ })}`;
1177
+ }
1178
+ return { plan, candidates, read };
1179
+ }
1180
+ /** Read-only: compute what a refresh of an applied Promotion record would write, and its conflicts. */
1181
+ export async function planDocumentRefresh(request) {
1182
+ return (await computeRefresh(request)).plan;
1183
+ }
1184
+ /** The record as applied, with each refreshed row's versions moved forward and the journal removed. */
1185
+ async function closeRefresh(target, registry, recordId, expectedVersion, done, token, pin, actor, now) {
1186
+ const result = await mutateDocument({
1187
+ bundle: target,
1188
+ id: recordId,
1189
+ mode: "replace-document",
1190
+ onAbsent: "fail",
1191
+ expectedVersion,
1192
+ registry,
1193
+ strict: false,
1194
+ actor,
1195
+ ...(now ? { now } : {}),
1196
+ buildCandidate: (existing) => {
1197
+ const frontmatter = structuredClone(existing.frontmatter);
1198
+ frontmatter.promotion_state = "applied";
1199
+ delete frontmatter.refresh;
1200
+ if (token !== undefined)
1201
+ frontmatter.refresh_token = token;
1202
+ if (pin)
1203
+ frontmatter.from_pin = pin.value;
1204
+ frontmatter.entries = frontmatter.entries.map((row) => {
1205
+ const entry = { ...row };
1206
+ const moved = done.get(String(entry.target));
1207
+ if (moved) {
1208
+ Object.assign(entry, moved);
1209
+ delete entry.target_edited;
1210
+ }
1211
+ return entry;
1212
+ });
1213
+ return { frontmatter, body: existing.body };
1214
+ },
1215
+ });
1216
+ return result.version;
1217
+ }
1218
+ function refreshedResult(recordId, version, written, changed) {
1219
+ return { record: { id: recordId, version, state: "applied" }, written, changed };
1220
+ }
1221
+ /**
1222
+ * Recompute the refresh plan from fresh reads, refuse unless it is ready and its token equals
1223
+ * `planToken`, then write: the record (`refreshing`, with the pending rows), each copy replaced
1224
+ * under a hard compare-and-swap on the version the plan saw, and the record again (`applied`, its
1225
+ * rows moved forward). A failed write throws {@link TransferInterrupted} with state `refreshing`;
1226
+ * {@link resumeDocumentTransfer} finishes it. The same token applied again after it completed is
1227
+ * a no-op success.
1228
+ */
1229
+ export async function applyDocumentRefresh(request, planToken) {
1230
+ const token = planToken.trim();
1231
+ const { plan, candidates, read } = await computeRefresh(request);
1232
+ if (!plan.ready)
1233
+ throw new TransferConflictError(plan.conflicts);
1234
+ if (plan.plan_token === undefined && read.doc.frontmatter.refresh_token === token) {
1235
+ return refreshedResult(request.recordId, read.version, [], false);
1236
+ }
1237
+ if (plan.plan_token !== token)
1238
+ throw new TransferPlanChanged(token, plan.plan_token ?? "(nothing to refresh)");
1239
+ const registry = await loadKinds(request.target);
1240
+ const pending = plan.entries.filter((row) => row.action === "update" || row.action === "overwrite");
1241
+ const journal = pending.map((row) => ({
1242
+ source: row.source,
1243
+ source_version: row.source_version,
1244
+ target: row.target,
1245
+ replaces_version: row.target_version,
1246
+ }));
1247
+ let recordVersion;
1248
+ try {
1249
+ recordVersion = (await mutateDocument({
1250
+ bundle: request.target,
1251
+ id: request.recordId,
1252
+ mode: "replace-document",
1253
+ onAbsent: "fail",
1254
+ expectedVersion: read.version,
1255
+ registry,
1256
+ strict: false,
1257
+ actor: request.actor,
1258
+ ...(request.now ? { now: request.now } : {}),
1259
+ buildCandidate: (existing) => {
1260
+ const frontmatter = structuredClone(existing.frontmatter);
1261
+ frontmatter.promotion_state = "refreshing";
1262
+ frontmatter.refresh = { plan_token: token, entries: journal.map((row) => ({ ...row })) };
1263
+ return { frontmatter, body: existing.body };
1264
+ },
1265
+ })).version;
1266
+ }
1267
+ catch (error) {
1268
+ if (error instanceof VersionConflict)
1269
+ throw new TransferPlanChanged(token, `'${request.recordId}' changed during the refresh`);
1270
+ throw error;
1271
+ }
1272
+ const written = [];
1273
+ const done = new Map();
1274
+ for (let index = 0; index < journal.length; index++) {
1275
+ const row = journal[index];
1276
+ try {
1277
+ const result = await replaceCopy(request.target, registry, row, candidates.get(row.source), request.actor, request.now);
1278
+ written.push({ source: row.source, target: row.target, version: result });
1279
+ done.set(row.target, { source_version: row.source_version, target_version: result });
1280
+ }
1281
+ catch (cause) {
1282
+ throw new TransferInterrupted(request.recordId, written, journal.slice(index).map((e) => ({ source: e.source, target: e.target })), cause, "refreshing");
1283
+ }
1284
+ }
1285
+ try {
1286
+ recordVersion = await closeRefresh(request.target, registry, request.recordId, recordVersion, done, token, plan.from.pin, request.actor, request.now);
1287
+ }
1288
+ catch (cause) {
1289
+ throw new TransferInterrupted(request.recordId, written, [], cause, "refreshing");
1290
+ }
1291
+ return refreshedResult(request.recordId, recordVersion, written, true);
1292
+ }
1293
+ /** Replace one copy, only if it is still exactly the version the refresh planned against. */
1294
+ async function replaceCopy(target, registry, row, candidate, actor, now) {
1295
+ const result = await mutateDocument({
1296
+ bundle: target,
1297
+ id: row.target,
1298
+ mode: "replace-document",
1299
+ onAbsent: "fail",
1300
+ expectedVersion: row.replaces_version,
1301
+ registry,
1302
+ strict: false,
1303
+ actor,
1304
+ seedGenerationClock: false,
1305
+ // The source is the authority for a promoted copy's body; the plan reported the links it drops.
1306
+ bodyReplace: { replaceLinks: true },
1307
+ ...(now ? { now } : {}),
1308
+ buildCandidate: () => structuredClone(candidate),
1309
+ });
1310
+ return result.version;
1311
+ }
1312
+ /**
1313
+ * Finish a `refreshing` record. For each pending row: a copy whose provenance already names this
1314
+ * record at the row's source version is done; a copy still at `replaces_version` whose source is
1315
+ * still at the row's `source_version` is replaced; any other row (the copy was deleted or edited,
1316
+ * or the source moved on) is left as it is, keeps its old baseline in the record, and is reported,
1317
+ * so the record always closes without overwriting anything the plan did not show.
1318
+ */
1319
+ async function finishRefresh(target, recordId, read, record, from, options) {
1320
+ const snapshot = await readTarget(target);
1321
+ assertActor(options.actor, snapshot.okfVersion);
1322
+ const identity = [];
1323
+ const { pin } = await sourceIdentity(from, snapshot, identity);
1324
+ if (identity.length > 0)
1325
+ throw new TransferResumeConflict(recordId, `${identity[0].code}: ${identity[0].detail}`);
1326
+ const source = await readSource(from.binding);
1327
+ const mapping = new Map(record.entries.map((e) => [e.source, e.target]));
1328
+ const ctx = { alias: from.alias, source, target: snapshot, mapping };
1329
+ const now = options.now ?? (() => new Date().toISOString());
1330
+ const registry = snapshot.registry;
1331
+ const token = read.doc.frontmatter.refresh.plan_token;
1332
+ const written = [];
1333
+ const left = [];
1334
+ const done = new Map();
1335
+ for (const row of record.refresh) {
1336
+ const leave = (detail) => { left.push({ source: row.source, target: row.target, detail }); };
1337
+ let current;
1338
+ try {
1339
+ current = await readOptional(target, row.target);
1340
+ }
1341
+ catch (error) {
1342
+ if (!(error instanceof FilesystemIdentityAliasError))
1343
+ throw error;
1344
+ leave(`'${row.target}' is spelled differently on disk: ${error.message}`);
1345
+ continue;
1346
+ }
1347
+ if (!current) {
1348
+ leave(`'${row.target}' was deleted during the refresh`);
1349
+ continue;
1350
+ }
1351
+ const sourceDoc = source.docs.get(row.source);
1352
+ if (!sourceDoc || sourceDoc.version !== row.source_version) {
1353
+ leave(`the source '${row.source}' is ${sourceDoc ? "changed" : "missing"} since the refresh was planned`);
1354
+ continue;
1355
+ }
1356
+ const conflicts = [];
1357
+ const built = buildEntry(ctx, record.bundleId, recordId, sourceDoc, row.target, new Set(mapping.keys()), true, options.actor, now, conflicts);
1358
+ if (conflicts.length > 0) {
1359
+ leave(`${conflicts[0].code}: ${conflicts[0].detail}`);
1360
+ continue;
1361
+ }
1362
+ if (current.version !== row.replaces_version) {
1363
+ // Done only when the copy is exactly what this refresh writes; provenance alone would also
1364
+ // match a copy edited after the write, and adopting it would make that edit the baseline.
1365
+ if (isWrittenCopy(current.doc, built.candidate, registry, snapshot.okfVersion)) {
1366
+ written.push({ source: row.source, target: row.target, version: current.version });
1367
+ done.set(row.target, { source_version: row.source_version, target_version: current.version });
1368
+ }
1369
+ else {
1370
+ leave(`'${row.target}' was changed by another writer during the refresh`);
1371
+ }
1372
+ continue;
1373
+ }
1374
+ try {
1375
+ const version = await replaceCopy(target, registry, row, built.candidate, options.actor, options.now);
1376
+ written.push({ source: row.source, target: row.target, version });
1377
+ done.set(row.target, { source_version: row.source_version, target_version: version });
1378
+ }
1379
+ catch (error) {
1380
+ if (!(error instanceof VersionConflict))
1381
+ throw error;
1382
+ leave(`'${row.target}' was changed by another writer during the resume`);
1383
+ }
1384
+ }
1385
+ const version = await closeRefresh(target, registry, recordId, read.version, done, typeof token === "string" ? token : undefined, pin, options.actor, options.now);
1386
+ return { ...refreshedResult(recordId, version, written, true), ...(left.length > 0 ? { left } : {}) };
1387
+ }
928
1388
  //# sourceMappingURL=document-transfer.js.map