@superbee/core 0.2.0-pre.40 → 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.
- package/README.md +96 -9
- package/dist/backend.d.ts +17 -1
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +41 -6
- package/dist/backend.js.map +1 -1
- package/dist/body-pages.d.ts +20 -0
- package/dist/body-pages.d.ts.map +1 -0
- package/dist/body-pages.js +56 -0
- package/dist/body-pages.js.map +1 -0
- package/dist/body-replace-guards.d.ts +29 -3
- package/dist/body-replace-guards.d.ts.map +1 -1
- package/dist/body-replace-guards.js +112 -19
- package/dist/body-replace-guards.js.map +1 -1
- package/dist/bounded-yaml.d.ts +28 -0
- package/dist/bounded-yaml.d.ts.map +1 -0
- package/dist/bounded-yaml.js +182 -0
- package/dist/bounded-yaml.js.map +1 -0
- package/dist/builtin-recipes/site.d.ts +5 -0
- package/dist/builtin-recipes/site.d.ts.map +1 -0
- package/dist/builtin-recipes/site.js +83 -0
- package/dist/builtin-recipes/site.js.map +1 -0
- package/dist/builtin-recipes.d.ts.map +1 -1
- package/dist/builtin-recipes.js +22 -0
- package/dist/builtin-recipes.js.map +1 -1
- package/dist/bundle-health/index.d.ts +2 -2
- package/dist/bundle-health/index.d.ts.map +1 -1
- package/dist/bundle-health/index.js +1 -1
- package/dist/bundle-health/index.js.map +1 -1
- package/dist/bundle-health/legacy-page.d.ts +8 -26
- package/dist/bundle-health/legacy-page.d.ts.map +1 -1
- package/dist/bundle-health/legacy-page.js +10 -35
- package/dist/bundle-health/legacy-page.js.map +1 -1
- package/dist/bundle-health/report.d.ts +0 -16
- package/dist/bundle-health/report.d.ts.map +1 -1
- package/dist/bundle-health/report.js +24 -54
- package/dist/bundle-health/report.js.map +1 -1
- package/dist/bundle-ops.d.ts +4 -1
- package/dist/bundle-ops.d.ts.map +1 -1
- package/dist/bundle-ops.js +6 -2
- package/dist/bundle-ops.js.map +1 -1
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +1 -1
- package/dist/bundle.js.map +1 -1
- package/dist/cross-bundle.d.ts +2 -2
- package/dist/cross-bundle.js +2 -2
- package/dist/document-codec.d.ts +3 -1
- package/dist/document-codec.d.ts.map +1 -1
- package/dist/document-codec.js +3 -1
- package/dist/document-codec.js.map +1 -1
- package/dist/document-mutation.d.ts +2 -0
- package/dist/document-mutation.d.ts.map +1 -1
- package/dist/document-mutation.js +44 -9
- package/dist/document-mutation.js.map +1 -1
- package/dist/document-scan.d.ts +23 -0
- package/dist/document-scan.d.ts.map +1 -0
- package/dist/document-scan.js +70 -0
- package/dist/document-scan.js.map +1 -0
- package/dist/document-transfer.d.ts +110 -4
- package/dist/document-transfer.d.ts.map +1 -1
- package/dist/document-transfer.js +490 -30
- package/dist/document-transfer.js.map +1 -1
- package/dist/engine.d.ts +4 -19
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +7 -47
- package/dist/engine.js.map +1 -1
- package/dist/errors.d.ts +37 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +75 -3
- package/dist/errors.js.map +1 -1
- package/dist/file-journaled-backend.d.ts.map +1 -1
- package/dist/file-journaled-backend.js +2 -2
- package/dist/file-journaled-backend.js.map +1 -1
- package/dist/filesystem-identity.d.ts +9 -0
- package/dist/filesystem-identity.d.ts.map +1 -1
- package/dist/filesystem-identity.js +186 -64
- package/dist/filesystem-identity.js.map +1 -1
- package/dist/filesystem-lock.d.ts +2 -2
- package/dist/filesystem-lock.d.ts.map +1 -1
- package/dist/filesystem-lock.js +81 -14
- package/dist/filesystem-lock.js.map +1 -1
- package/dist/filesystem-temp-name.d.ts +9 -0
- package/dist/filesystem-temp-name.d.ts.map +1 -0
- package/dist/filesystem-temp-name.js +20 -0
- package/dist/filesystem-temp-name.js.map +1 -0
- package/dist/filesystem.d.ts +6 -0
- package/dist/filesystem.d.ts.map +1 -1
- package/dist/filesystem.js +2 -1
- package/dist/filesystem.js.map +1 -1
- package/dist/frontmatter-contract.d.ts +14 -0
- package/dist/frontmatter-contract.d.ts.map +1 -1
- package/dist/frontmatter-contract.js +22 -0
- package/dist/frontmatter-contract.js.map +1 -1
- package/dist/frontmatter-splitter.d.ts +6 -0
- package/dist/frontmatter-splitter.d.ts.map +1 -1
- package/dist/frontmatter-splitter.js +8 -0
- package/dist/frontmatter-splitter.js.map +1 -1
- package/dist/frontmatter.d.ts +8 -1
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +43 -7
- package/dist/frontmatter.js.map +1 -1
- package/dist/hosted-client/client.d.ts +22 -5
- package/dist/hosted-client/client.d.ts.map +1 -1
- package/dist/hosted-client/client.js +57 -10
- package/dist/hosted-client/client.js.map +1 -1
- package/dist/hosted-client/index.d.ts +1 -0
- package/dist/hosted-client/index.d.ts.map +1 -1
- package/dist/hosted-client/index.js +1 -0
- package/dist/hosted-client/index.js.map +1 -1
- package/dist/hosted-client/view-content.d.ts +107 -0
- package/dist/hosted-client/view-content.d.ts.map +1 -0
- package/dist/hosted-client/view-content.js +155 -0
- package/dist/hosted-client/view-content.js.map +1 -0
- package/dist/hosted-transport/carrier.d.ts +1 -1
- package/dist/hosted-transport/carrier.d.ts.map +1 -1
- package/dist/hosted-transport/carrier.js +27 -18
- package/dist/hosted-transport/carrier.js.map +1 -1
- package/dist/hosted-transport/document-backend.d.ts +74 -0
- package/dist/hosted-transport/document-backend.d.ts.map +1 -0
- package/dist/hosted-transport/document-backend.js +160 -0
- package/dist/hosted-transport/document-backend.js.map +1 -0
- package/dist/hosted-transport/index.d.ts +1 -0
- package/dist/hosted-transport/index.d.ts.map +1 -1
- package/dist/hosted-transport/index.js +1 -0
- package/dist/hosted-transport/index.js.map +1 -1
- package/dist/hosted-transport/read-adapter.d.ts +1 -2
- package/dist/hosted-transport/read-adapter.d.ts.map +1 -1
- package/dist/hosted-transport/read-adapter.js.map +1 -1
- package/dist/identity-fold.d.ts +12 -14
- package/dist/identity-fold.d.ts.map +1 -1
- package/dist/identity-fold.js +17 -15
- package/dist/identity-fold.js.map +1 -1
- package/dist/index-projection.d.ts +2 -1
- package/dist/index-projection.d.ts.map +1 -1
- package/dist/index-projection.js +14 -5
- package/dist/index-projection.js.map +1 -1
- package/dist/index.d.ts +24 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -15
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-backend.d.ts.map +1 -1
- package/dist/indexeddb-backend.js +2 -2
- package/dist/indexeddb-backend.js.map +1 -1
- package/dist/json-data.d.ts +3 -0
- package/dist/json-data.d.ts.map +1 -0
- package/dist/json-data.js +19 -0
- package/dist/json-data.js.map +1 -0
- package/dist/kind-authoring.d.ts +18 -0
- package/dist/kind-authoring.d.ts.map +1 -1
- package/dist/kind-authoring.js +54 -4
- package/dist/kind-authoring.js.map +1 -1
- package/dist/kind-coverage.d.ts +1 -0
- package/dist/kind-coverage.d.ts.map +1 -1
- package/dist/kind-coverage.js +4 -5
- package/dist/kind-coverage.js.map +1 -1
- package/dist/kind-undeclared-fields.d.ts +70 -0
- package/dist/kind-undeclared-fields.d.ts.map +1 -0
- package/dist/kind-undeclared-fields.js +120 -0
- package/dist/kind-undeclared-fields.js.map +1 -0
- package/dist/kinds-load.d.ts.map +1 -1
- package/dist/kinds-load.js +4 -1
- package/dist/kinds-load.js.map +1 -1
- package/dist/kinds.d.ts +9 -0
- package/dist/kinds.d.ts.map +1 -1
- package/dist/kinds.js +20 -0
- package/dist/kinds.js.map +1 -1
- package/dist/link-findings.d.ts +130 -0
- package/dist/link-findings.d.ts.map +1 -0
- package/dist/link-findings.js +272 -0
- package/dist/link-findings.js.map +1 -0
- package/dist/link-types.d.ts +7 -0
- package/dist/link-types.d.ts.map +1 -1
- package/dist/link-types.js +17 -2
- package/dist/link-types.js.map +1 -1
- package/dist/memory-backend.d.ts.map +1 -1
- package/dist/memory-backend.js +2 -2
- package/dist/memory-backend.js.map +1 -1
- package/dist/page.d.ts +3 -7
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +3 -7
- package/dist/page.js.map +1 -1
- package/dist/path-fold.d.ts +29 -16
- package/dist/path-fold.d.ts.map +1 -1
- package/dist/path-fold.js +30 -17
- package/dist/path-fold.js.map +1 -1
- package/dist/paths.d.ts +28 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +82 -1
- package/dist/paths.js.map +1 -1
- package/dist/platform.d.ts +9 -0
- package/dist/platform.d.ts.map +1 -1
- package/dist/platform.js.map +1 -1
- package/dist/portable-frontmatter.d.ts +6 -0
- package/dist/portable-frontmatter.d.ts.map +1 -1
- package/dist/portable-frontmatter.js +26 -2
- package/dist/portable-frontmatter.js.map +1 -1
- package/dist/recipe-lifecycle/install.d.ts +4 -7
- package/dist/recipe-lifecycle/install.d.ts.map +1 -1
- package/dist/recipe-lifecycle/install.js +3 -20
- package/dist/recipe-lifecycle/install.js.map +1 -1
- package/dist/recipe-lifecycle/legacy-aliases.d.ts +1 -13
- package/dist/recipe-lifecycle/legacy-aliases.d.ts.map +1 -1
- package/dist/recipe-lifecycle/legacy-aliases.js +5 -34
- package/dist/recipe-lifecycle/legacy-aliases.js.map +1 -1
- package/dist/record-overlay/apply.d.ts.map +1 -1
- package/dist/record-overlay/apply.js +0 -4
- package/dist/record-overlay/apply.js.map +1 -1
- package/dist/record-overlay/findings.d.ts.map +1 -1
- package/dist/record-overlay/findings.js +4 -1
- package/dist/record-overlay/findings.js.map +1 -1
- package/dist/record-overlay/plan.js +3 -3
- package/dist/record-overlay/plan.js.map +1 -1
- package/dist/record-overlay/signal-text.d.ts +9 -2
- package/dist/record-overlay/signal-text.d.ts.map +1 -1
- package/dist/record-overlay/signal-text.js +21 -11
- package/dist/record-overlay/signal-text.js.map +1 -1
- package/dist/remote-backend.d.ts +9 -19
- package/dist/remote-backend.d.ts.map +1 -1
- package/dist/remote-backend.js +17 -5
- package/dist/remote-backend.js.map +1 -1
- package/dist/remote-operations.d.ts.map +1 -1
- package/dist/remote-operations.js +2 -0
- package/dist/remote-operations.js.map +1 -1
- package/dist/remote-parsers.d.ts +19 -0
- package/dist/remote-parsers.d.ts.map +1 -1
- package/dist/remote-parsers.js.map +1 -1
- package/dist/remote.d.ts +2 -4
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +2 -3
- package/dist/remote.js.map +1 -1
- package/dist/reserved-write.d.ts +9 -0
- package/dist/reserved-write.d.ts.map +1 -0
- package/dist/reserved-write.js +15 -0
- package/dist/reserved-write.js.map +1 -0
- package/dist/search/backend-source.d.ts +2 -2
- package/dist/search/backend-source.d.ts.map +1 -1
- package/dist/search/backend-source.js +30 -6
- package/dist/search/backend-source.js.map +1 -1
- package/dist/search/contract.d.ts +3 -0
- package/dist/search/contract.d.ts.map +1 -1
- package/dist/search/contract.js.map +1 -1
- package/dist/search/search.d.ts.map +1 -1
- package/dist/search/search.js +4 -1
- package/dist/search/search.js.map +1 -1
- package/dist/search/source.d.ts +3 -0
- package/dist/search/source.d.ts.map +1 -1
- package/dist/site-manifest.d.ts +49 -0
- package/dist/site-manifest.d.ts.map +1 -0
- package/dist/site-manifest.js +113 -0
- package/dist/site-manifest.js.map +1 -0
- package/dist/skip-reasons.d.ts +12 -0
- package/dist/skip-reasons.d.ts.map +1 -0
- package/dist/skip-reasons.js +15 -0
- package/dist/skip-reasons.js.map +1 -0
- package/dist/source-engine/driver.d.ts +4 -4
- package/dist/source-engine/driver.d.ts.map +1 -1
- package/dist/source-engine/driver.js +155 -34
- package/dist/source-engine/driver.js.map +1 -1
- package/dist/source-engine/index.d.ts +3 -1
- package/dist/source-engine/index.d.ts.map +1 -1
- package/dist/source-engine/index.js +1 -0
- package/dist/source-engine/index.js.map +1 -1
- package/dist/source-engine/machine-state.d.ts +19 -6
- package/dist/source-engine/machine-state.d.ts.map +1 -1
- package/dist/source-engine/machine-state.js +48 -2
- package/dist/source-engine/machine-state.js.map +1 -1
- package/dist/source-engine/observe.d.ts.map +1 -1
- package/dist/source-engine/observe.js +10 -4
- package/dist/source-engine/observe.js.map +1 -1
- package/dist/source-engine/snapshot.js +1 -1
- package/dist/source-engine/snapshot.js.map +1 -1
- package/dist/source-engine/target-consumer.d.ts +12 -0
- package/dist/source-engine/target-consumer.d.ts.map +1 -0
- package/dist/source-engine/target-consumer.js +15 -0
- package/dist/source-engine/target-consumer.js.map +1 -0
- package/dist/source-engine/target-descriptor.d.ts +3 -0
- package/dist/source-engine/target-descriptor.d.ts.map +1 -0
- package/dist/source-engine/target-descriptor.js +26 -0
- package/dist/source-engine/target-descriptor.js.map +1 -0
- package/dist/source-engine/target.d.ts +82 -0
- package/dist/source-engine/target.d.ts.map +1 -0
- package/dist/source-engine/target.js +213 -0
- package/dist/source-engine/target.js.map +1 -0
- package/dist/source-engine/types.d.ts +5 -1
- package/dist/source-engine/types.d.ts.map +1 -1
- package/dist/source-profiles/index.d.ts +1 -1
- package/dist/source-profiles/index.js +1 -1
- package/dist/string-identity-agreement.d.ts +1 -1
- package/dist/string-identity-agreement.d.ts.map +1 -1
- package/dist/string-identity-agreement.js +6 -0
- package/dist/string-identity-agreement.js.map +1 -1
- package/dist/system-documents.d.ts +13 -0
- package/dist/system-documents.d.ts.map +1 -0
- package/dist/system-documents.js +44 -0
- package/dist/system-documents.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts +5 -0
- package/dist/validation.d.ts.map +1 -1
- 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
|
|
15
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
321
|
-
const PROTECTED_TARGET_FOLDS = new Set([BUNDLE_NAME_DOC_ID, DEPENDENCIES_DOC_ID].map(
|
|
322
|
-
const CONVENTIONS_FOLD = `${
|
|
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
|
|
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 =
|
|
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 = [[`${
|
|
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([`${
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
888
|
+
function provenanceEntries(doc) {
|
|
837
889
|
const sources = doc.frontmatter.sources;
|
|
838
|
-
return Array.isArray(sources)
|
|
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
|