diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index cf3f8433..e5c286d4 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -1851,12 +1851,18 @@ export const repoCatalog = { }, "publicExports": [ "CausalAuthoredIntent", + "CausalBaseRevisionFailure", "CausalEnvelope", + "CausalHostPublication", + "CausalHostPublicationOwnership", + "CausalHostReadyRequest", + "CausalHostReadyResult", "CausalIntentEnvelope", "CausalMaterializationDiagnostic", "CausalMaterializationPolicy", "CausalPatchEnvelope", "CausalPatchFailure", + "CausalPatchHost", "CausalPatchInbox", "CausalPatchInboxOptions", "CausalPatchInboxSnapshot", @@ -1865,6 +1871,7 @@ export const repoCatalog = { "CausalPatchIngestOk", "CausalPatchIngestResult", "CausalPositionalIntent", + "CausalPositionalMaterializationFailure", "CausalStableIdReplaceIntent", "FailedCausalMaterialization", "FailedCausalPatch", @@ -1872,7 +1879,7 @@ export const repoCatalog = { "QueuedCausalPatch", "createCausalPatchInbox" ], - "publicExportCount": 21, + "publicExportCount": 28, "keywords": [ "@interactive-os/json-document", "causal", diff --git a/docs/changelog.md b/docs/changelog.md index aae3a07c..72905c2b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,13 @@ All notable changes to this project are documented here. declared dependencies are applied. - Added structured policy-specific materialization failures and successful rebase diagnostics without changing direct-operation result shapes. +- Added an optional synchronous host coordination Adapter, an inbox-local + projection journal token, and structured `baseRevision` validation so DOM + input can be flushed before ready-time planning without giving up divergence + detection. +- Added single SelectionPoint support across positional, stable-id, and causal + planners. Paths are rebased while offset, edge, and affinity metadata remain + intact for atomic document commit. ### Changed @@ -19,6 +26,16 @@ All notable changes to this project are documented here. envelope once through `doc.commit`, including a rebased `selectionAfter`, and to advance its applied ledger and causal frontier only after success. +### Performance + +- Indexed explicit `baseRevision` materialization directly into the dense + journal suffix, replaced full-ledger dependency lookup with id-to-revision + lookup, and removed duplicate/unneeded patch copies. +- Reused the public trusted-state patch path after the initial rebase preflight + instead of rescanning a validated large base for every replay step. +- Added `perf:causal` to compare revision-suffix materialization with the legacy + full-journal path at configurable journal sizes. + ## 1.1.0-rc.0 - 2026-07-11 This prerelease lets sibling editors such as `editable` and `canvas` exercise diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 1465e4ca..0a4eac45 100644 --- a/docs/generated/extensions-catalog.md +++ b/docs/generated/extensions-catalog.md @@ -39,7 +39,7 @@ Lab extensions are private candidates. They are listed to show product pressure, | `@interactive-os/json-document-batch-update` | lab-only | 10 | set a field across a list of selected item pointers to a constant or computed value | selecting which items to edit, or JSONPath query-driven replacement | Lab batch-update extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-bookmarks` | lab-only | 8 | keep named JSON Pointer locations stable across edits | browser bookmarks or route state | Headless bookmark tracking extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-calculated-fields` | lab-only | 12 | sync host-computed derived JSON fields | formula languages or dependency runtimes | Lab calculated field extension for `@interactive-os/json-document` documents. | -| `@interactive-os/json-document-causal-patch-inbox` | lab-only | 21 | queue dependency-declared envelopes and materialize delayed positional or stable-id edits when causally ready | CRDT/OT convergence, transport, persistence, or automatic conflict resolution | Lab module for admitting dependency-declared JSON Patch or authored edit envelopes, holding out-of-order work, and committing each envelope when it becomes causally ready on one `JSONDocument`. | +| `@interactive-os/json-document-causal-patch-inbox` | lab-only | 28 | queue dependency-declared envelopes and materialize delayed positional or stable-id edits when causally ready | CRDT/OT convergence, transport, persistence, or automatic conflict resolution | Lab module for admitting dependency-declared JSON Patch or authored edit envelopes, holding out-of-order work, and committing each envelope when it becomes causally ready on one `JSONDocument`. | | `@interactive-os/json-document-change-case` | lab-only | 9 | apply case/whitespace transforms (upper, lower, trim, title) to a string field | locale-aware casing, rich text formatting toolbars, or find/replace | Lab change-case extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-checkpoints` | lab-only | 13 | name and restore document snapshots | durable version graphs or cloud backup | Lab checkpoint extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-clear-contents` | lab-only | 10 | reset selected fields to schema-derived empty values, keeping structure | structural delete, caller-supplied bulk replace, or enum/object default policy | Lab clear-contents extension for `@interactive-os/json-document` documents. | diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index b0cb56ff..9a92b975 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -1850,12 +1850,18 @@ }, "publicExports": [ "CausalAuthoredIntent", + "CausalBaseRevisionFailure", "CausalEnvelope", + "CausalHostPublication", + "CausalHostPublicationOwnership", + "CausalHostReadyRequest", + "CausalHostReadyResult", "CausalIntentEnvelope", "CausalMaterializationDiagnostic", "CausalMaterializationPolicy", "CausalPatchEnvelope", "CausalPatchFailure", + "CausalPatchHost", "CausalPatchInbox", "CausalPatchInboxOptions", "CausalPatchInboxSnapshot", @@ -1864,6 +1870,7 @@ "CausalPatchIngestOk", "CausalPatchIngestResult", "CausalPositionalIntent", + "CausalPositionalMaterializationFailure", "CausalStableIdReplaceIntent", "FailedCausalMaterialization", "FailedCausalPatch", @@ -1871,7 +1878,7 @@ "QueuedCausalPatch", "createCausalPatchInbox" ], - "publicExportCount": 21, + "publicExportCount": 28, "keywords": [ "@interactive-os/json-document", "causal", diff --git a/labs/extensions/causal-patch-inbox/README.md b/labs/extensions/causal-patch-inbox/README.md index 2ae1f843..971896a9 100644 --- a/labs/extensions/causal-patch-inbox/README.md +++ b/labs/extensions/causal-patch-inbox/README.md @@ -49,7 +49,11 @@ positional.ingest({ operations: [ { op: "replace", path: "/items/1/title", value: "Reviewed" }, ], - selectionAfter: "/items/1/title", + selectionAfter: { + path: "/items/1/title", + offset: 4, + affinity: "forward", + }, }, }); @@ -72,11 +76,63 @@ stable.ingest({ ``` The positional `base` must represent the authored projection containing the -transitive causal past declared by `dependsOn`. Once that envelope is ready, -the inbox replays successful applied batches outside that causal past in their -actual local commit order. Stable-id replacement instead resolves its target -against the current projection with the host scopes supplied once at factory -creation. Neither policy is run while its envelope is waiting. +transitive causal past declared by `dependsOn`. Without a host, the inbox +replays successful applied batches outside that causal past in their actual +local commit order. With a host, capture `current().journalRevision` beside the +base as `baseRevision`; materialization then reads only the later contiguous +journal suffix. A future token is `base_revision_ahead`, and a declared causal +dependency with a non-empty projection newer than the base is +`base_revision_mismatch`. Tokens belong to one inbox instance and are not +transport clocks. + +Stable-id replacement instead resolves its target against the current +projection with the host scopes supplied once at factory creation. Both +policies accept either a Pointer or one `SelectionPoint`. Only the point's +`path` is rebased; `offset`, `edge`, and `affinity` are preserved for core to +restore and clamp at commit time. Neither policy runs while its envelope is +waiting. Point objects are strict plain data; an offset must be a non-negative +safe integer, and core only clamps that valid offset to the final string +length. + +## Host coordination + +An optional host Adapter lets an editor flush pending DOM input before a ready +causal change and lets the inbox journal trusted local/native/history +publications instead of treating all of them as divergence. + +```ts +const inbox = createCausalPatchInbox(doc, { + positionalSchema: DocumentSchema, + host: { + ownsPublication: ({ metadata }) => { + const sequence = readEditableSequence(metadata); + return sequence === null ? false : { sequence }; + }, + runReady: ({ apply }) => { + if (compositionIsActive()) { + return { + ok: false, + code: "host_not_ready", + reason: "the browser still owns the composition island", + }; + } + flushPendingInput(); + apply(); + return { ok: true }; + }, + }, +}); +``` + +`runReady` is synchronous and scope-bound. It must call `apply()` exactly once +before returning `{ ok: true }`, or zero times when returning +`host_not_ready`. A deferred envelope remains ready and can be retried with +`inbox.ingest([])` after the host becomes idle. The closure expires when +`runReady` returns. `ownsPublication` must be pure and non-reentrant; nested +publication or ingestion closes the inbox as diverged rather than guessing an +order. The host assigns each owned publication's safe-integer sequence before +calling `doc.commit`; a duplicate or decreasing callback sequence detects +subscriber reordering and also closes as diverged. An `ingest` batch is preflighted and defensively copied before any new envelope is admitted. An exact duplicate compares the authored direct operations or @@ -90,10 +146,12 @@ Ready envelopes currently known to one drain are applied by locale-independent id order. A direct envelope uses its operations unchanged. A delayed intent is materialized at that point, then the resulting operations and optional selection are passed to exactly one atomic `doc.commit`. The concrete -`doc.lastPatch` is recorded only after success, and causal frontier state moves -only after that commit. A batch is not one transaction: earlier envelopes -remain applied when a later envelope fails. Patch or materialization failure -globally blocks the inbox, preserving the failed envelope and descendants for +subscription publication is recorded after success; a successful test-only or +empty commit records an empty projection batch instead of reusing +`doc.lastPatch`. Causal frontier state moves only after the host contract and +commit both succeed. A batch is not one transaction: earlier envelopes remain +applied when a later envelope fails. Patch or materialization failure globally +blocks the inbox, preserving the failed envelope and descendants for inspection without an implicit retry. `current().frontier` is the maximal set in the declared dependency DAG. It is @@ -102,8 +160,8 @@ that have not been applied by this inbox. Snapshot status `active` describes a usable inbox, including one that has pending dependencies; it does not mean that a ready envelope currently exists. `blocked` preserves a structured patch or policy-specific materialization failure. `faulted` preserves an unexpected -thrown execution failure; a planner throw also records -`phase: "materialization"`. Full queue snapshots are produced only by +thrown execution or host protocol failure; `phase` distinguishes `host` from +`materialization`. Full queue snapshots are produced only by `current()`. Successful `ingest` results report this call's `applied`, `pending`, and `duplicates` ids. A non-empty materialization diagnostic list is added as `diagnostics`; direct-only results remain unchanged. @@ -122,6 +180,8 @@ added as `diagnostics`; direct-only results remain unchanged. whole queue after every successful patch. - Materialize positional edits from their authored base plus applied non-ancestor batches when they become ready. +- Journal host-owned publications and use an inbox-local `baseRevision` to read + a gap-free suffix without scanning old journal entries. - Resolve stable-id replacements against the current projection using host-supplied resolver scopes when they become ready. - Commit one ready envelope through exactly one public `doc.commit`, including @@ -133,10 +193,10 @@ added as `diagnostics`; direct-only results remain unchanged. - Move to terminal `faulted` state when materialization or commit execution throws without changing the projection and without supplying a structured `JSONDocumentError`. -- Reject reentrant ingestion as `busy` while admission, materialization, or a - document commit is running. +- Reject reentrant ingestion as `busy` while admission, materialization, a + document commit, or host-publication classification is running. - Detect direct document mutation and move the inbox to `diverged` rather than - inferring a causal envelope from an observed patch. + inferring ownership from metadata alone. - Dispose the document subscription explicitly. ## Performance @@ -155,15 +215,22 @@ added as `diagnostics`; direct-only results remain unchanged. - `current()` sorts and defensively copies frontier and queue observations, so its cost grows with the exposed state. `ingest()` does not call `current()`; callers pay that observation cost only when they request a snapshot. -- Ready positional materialization walks the envelope's transitive causal past - and filters the applied ledger before delegating to the rebase planner. - Stable-id materialization pays the configured resolver scan and document - preflight costs. +- A positional intent with `baseRevision` indexes directly into the dense + journal suffix. Dependency validation walks only its causal ancestors. The + legacy tokenless path still walks the causal past and filters the complete + applied ledger. +- Causal publications are copied only when positional journaling is enabled, + and the owned copy is transferred into the ledger once. Empty projection + batches share one immutable empty array. Stable-id materialization pays the + configured resolver scan and document preflight costs. - Exact dedup retains accepted authored payloads, and the causal ledger has no compaction in this lab. An inbox configured for positional materialization also retains its concrete applied-patch ledger. Memory therefore grows with accepted work, while direct-only and stable-id-only inboxes avoid the extra applied-patch copy. +- `npm run perf:causal` reports p50/p90 materialization time for explicit + revision suffixes and the legacy full-journal path. It is an observational + benchmark; no machine-sensitive threshold is enforced yet. ## Non-goals @@ -173,27 +240,28 @@ added as `diagnostics`; direct-only results remain unchanged. ready set known to the current drain is ordered deterministically. - No local submit/envelope generation, acknowledgement, transport, reconnect, offline queue, persistence, checkpoint, or compaction format. -- No generic protocol or materialization Adapter interface. The two concrete - policies are deliberately a closed union. +- No generic transport or materializer registry. The two concrete intent + policies remain a closed union; the host Adapter coordinates only local + publication ownership and ready execution. - No target repair beyond conservative positional rebase or one stable-id field replacement; no automatic retry, conflict resolution, or independent-branch skipping after failure. -- No DOM Selection, multi-range or text-offset transform, presence, DOM input, - render scheduling, or focus policy beyond committing one headless Pointer - `selectionAfter` returned by a materializer. +- No SelectionRange/Snap, multi-range transform, same-string offset OT, + presence, DOM input, render scheduling, or focus policy. One headless + SelectionPoint is path-rebased and its caret metadata is preserved. - No support for multiple causal owners mutating the same document projection. - No change to the `JSONDocument` public interface or core state. ## Friction report The public document subscription, active envelope id, metadata marker, and -publication count distinguish this module's synchronous patch from ordinary -host publications. Reusing a previously observed marker outside that active -publication is rejected, and a nested patch that copies current metadata adds -a second publication and causes divergence. The marker remains a cooperative -in-process convention rather than a security boundary. Running another inbox -against the same document makes the first one diverge instead of trying to -merge two causal ledgers. +publication count distinguish this module's synchronous patch from host +publications. The host explicitly classifies its own local work; metadata alone +does not grant ownership. Reusing a previously observed marker, reentrant +classification, or a second publication during one apply causes divergence. +The marker remains a cooperative in-process convention rather than a security +boundary. Running another inbox against the same document makes the first one +diverge instead of trying to merge two causal ledgers. Only mutations published through the public `JSONDocument` interface are observable. In-place mutation of an object reachable through `doc.value` emits @@ -212,20 +280,20 @@ that publication back across modules. This lab detects the changed projection and halts as `diverged`; it does not claim crash-atomicity across arbitrary host callbacks. -Ready-time materialization removes the known stale-planning gap without adding -a protocol abstraction. Positional correctness still depends on the host -supplying a base that corresponds to `dependsOn`; the inbox has no clock or -checkpoint with which to prove that relationship. Stable-id recovery still -inherits resolver scan cost, host scope correctness, and the planner's -conservative entity guard. - -The positional planner also requires its concurrent batches to be ordered and -gap-free from the supplied base. A causal past need not be a prefix of this -inbox's local applied order, so filtering non-ancestors cannot prove that replay -relationship. Planner conflicts and final commit guards prevent an unsafe -overwrite, but a valid authored edit can conservatively become a false conflict -or a commit-time `patch_failed`. This tracer makes that limitation observable; -it does not claim a convergence protocol. +Ready-time materialization plus the host closure removes the known gap between +flushing pending input and planning the causal commit. `baseRevision` proves a +contiguous local journal suffix and checks dependency ordering, but it is still +an inbox-local token with no cross-peer clock or checkpoint meaning. Stable-id +recovery still inherits resolver scan cost, host scope correctness, and the +planner's conservative entity guard. + +The positional planner requires its concurrent batches to be ordered and +gap-free from the supplied base. `baseRevision` provides that local suffix. +The legacy tokenless path still filters non-ancestors, and a causal past need +not be a prefix of local apply order, so it cannot prove the same relationship. +Planner conflicts and final commit guards prevent an unsafe overwrite, but a +valid legacy authored edit can conservatively become a false conflict or a +commit-time `patch_failed`. This tracer does not claim a convergence protocol. The concrete rebase modules are in-process dependencies. Their results are preserved as structured failures rather than hidden behind a generic Adapter diff --git a/labs/extensions/causal-patch-inbox/src/create.ts b/labs/extensions/causal-patch-inbox/src/create.ts index ee60b449..4f83f133 100644 --- a/labs/extensions/causal-patch-inbox/src/create.ts +++ b/labs/extensions/causal-patch-inbox/src/create.ts @@ -2,7 +2,7 @@ import { JSONDocumentError, type JSONDocument, type JSONPatchOperation, - type Pointer, + type SelectionPoint, } from "@interactive-os/json-document"; import { rebaseChange, @@ -15,6 +15,8 @@ import { import type { CausalMaterializationDiagnostic, + CausalHostPublicationOwnership, + CausalHostReadyResult, CausalPatchInbox, CausalPatchFailure, CausalPatchInboxOptions, @@ -43,15 +45,17 @@ interface PendingEnvelope { } interface AppliedEnvelope { - readonly id: string; + readonly id?: string; readonly operations: ReadonlyArray; } +const EMPTY_OPERATIONS: ReadonlyArray = Object.freeze([]); + type ReadyMaterialization = | { readonly ok: true; readonly operations: ReadonlyArray; - readonly selectionAfter?: Pointer; + readonly selectionAfter?: SelectionPoint; readonly diagnostics: ReadonlyArray; } | { @@ -59,6 +63,11 @@ type ReadyMaterialization = readonly failure: FailedCausalMaterialization; }; +interface CommittedReadyEnvelope { + readonly materialization: Extract; + readonly operations: ReadonlyArray; +} + let nextInboxInstance = 0; export function createCausalPatchInbox( @@ -66,6 +75,7 @@ export function createCausalPatchInbox( options: CausalPatchInboxOptions = {}, ): CausalPatchInbox { const positionalSchema = options.positionalSchema; + const host = options.host; const stableIdScopes = options.stableIdScopes === undefined ? undefined : options.stableIdScopes.map((scope) => ({ ...scope })); @@ -73,29 +83,97 @@ export function createCausalPatchInbox( const queued = new Map>(); const dependents = new Map>(); const readyIds = new ReadyIdHeap(); - const applied = new Set(); + const appliedRevisions = new Map(); const appliedEnvelopes: AppliedEnvelope[] = []; const frontier = new Set(); const origin = `causal-patch-inbox:${nextInboxInstance += 1}`; + let journalRevision = 0; let failure: CausalPatchFailure | undefined; let fault: FaultedCausalPatch | undefined; let diverged = false; let disposed = false; let busy = false; let applyingId: string | undefined; + let acceptingHostPublications = false; let publicationCount = 0; + let publishedOperations: ReadonlyArray | undefined; + let classifyingHostPublication = false; + let lastHostPublicationSequence: number | undefined; let observing = true; - const unsubscribeDocument = doc.subscribe((_applied, metadata) => { - publicationCount += 1; + const appendApplied = ( + id: string | undefined, + operations: ReadonlyArray, + operationsOwned = false, + ): number => { + journalRevision += 1; + if (positionalSchema === undefined) return journalRevision; + appliedEnvelopes.push({ + ...(id === undefined ? {} : { id }), + operations: operations.length === 0 + ? EMPTY_OPERATIONS + : operationsOwned + ? operations + : copyJson(operations), + }); + return journalRevision; + }; + const unsubscribeDocument = doc.subscribe((published, metadata) => { + if (applyingId !== undefined) { + publicationCount += 1; + const validPublication = !( + publicationCount > 1 + || !busy + || metadata?.origin !== origin + || metadata.mergeKey !== applyingId + ); + if (!validPublication) { + diverged = true; + return; + } + if (positionalSchema !== undefined) { + publishedOperations = copyJson(published); + } + return; + } + if ( - publicationCount > 1 - || !busy - || applyingId === undefined - || metadata?.origin !== origin - || metadata.mergeKey !== applyingId + host === undefined + || (busy && !acceptingHostPublications) + || classifyingHostPublication ) { diverged = true; + return; + } + + let ownership: CausalHostPublicationOwnership | null = null; + try { + classifyingHostPublication = true; + ownership = prepareHostPublicationOwnership( + host.ownsPublication({ + operations: published, + ...(metadata === undefined ? {} : { metadata }), + }), + ); + } catch { + diverged = true; + return; + } finally { + classifyingHostPublication = false; } + if ( + ownership === null + || ownership === false + || diverged + || ( + lastHostPublicationSequence !== undefined + && ownership.sequence <= lastHostPublicationSequence + ) + ) { + diverged = true; + return; + } + lastHostPublicationSequence = ownership.sequence; + appendApplied(undefined, published); }); const stopObserving = (): void => { if (!observing) return; @@ -114,6 +192,7 @@ export function createCausalPatchInbox( ? "active" : "blocked", frontier: [...frontier].sort(compareIds), + ...(host === undefined ? {} : { journalRevision }), queued: [...queued.values()] .sort((left, right) => compareIds(left.envelope.id, right.envelope.id)) .map((pending) => ({ @@ -219,7 +298,9 @@ export function createCausalPatchInbox( for (const envelope of additions.values()) { known.set(envelope.id, envelope); const missing = new Set( - envelope.dependsOn.filter((dependency) => !applied.has(dependency)), + envelope.dependsOn.filter((dependency) => ( + !appliedRevisions.has(dependency) + )), ); queued.set(envelope.id, { envelope, missing }); if (missing.size === 0) { @@ -241,132 +322,292 @@ export function createCausalPatchInbox( const pending = queued.get(readyId); if (pending === undefined) continue; const ready = pending.envelope; - - const beforeMaterialization = doc.value; - let materialized: ReadyMaterialization; - try { - materialized = materializeReadyEnvelope( - ready, - doc, - positionalSchema, - stableIdScopes, - known, - appliedEnvelopes, - ); - } catch (error) { - if (doc.value !== beforeMaterialization) diverged = true; - if (!diverged) { - fault = { - id: ready.id, - reason: error instanceof Error - ? error.message - : "causal materialization threw an unknown value", - phase: "materialization", - }; - } - throw error; - } - if (doc.value !== beforeMaterialization) diverged = true; - if (disposed) { + const hostFault = (reason: string): CausalPatchIngestResult => { + fault = { id: ready.id, reason, phase: "host" }; return { ok: false, - code: "disposed", - reason: "causal inbox was disposed while materializing an envelope", - ...ingestProgress(integrated, diagnostics), - }; - } - if (diverged) { - return { - ok: false, - code: "projection_diverged", - reason: "document projection changed while materializing a causal envelope", - ...ingestProgress(integrated, diagnostics), - }; - } - if (!materialized.ok) { - const failedMaterialization = copyMaterializationFailure( - materialized.failure, - ); - failure = failedMaterialization; - return { - ok: false, - code: "materialization_failed", - reason: materialized.failure.materialization.reason, - ...failedMaterialization, + code: "faulted", + reason, + id: ready.id, + phase: "host", ...ingestProgress(integrated, diagnostics), }; - } + }; - const beforeProjection = doc.value; - let result: ReturnType; - publicationCount = 0; - applyingId = ready.id; - try { - result = doc.commit(materialized.operations, { - origin, - mergeKey: ready.id, - ...(materialized.selectionAfter === undefined - ? {} - : { selectionAfter: materialized.selectionAfter }), - }); - } catch (error) { - if (doc.value !== beforeProjection) diverged = true; - if (!diverged && error instanceof JSONDocumentError) { - failure = { - id: ready.id, - result: copyJson(error.result), + let committedReady: CommittedReadyEnvelope | undefined; + const applyReady = (): CausalPatchIngestResult | null => { + if (host !== undefined) acceptingHostPublications = false; + const beforeMaterialization = doc.value; + let materialized: ReadyMaterialization; + try { + materialized = materializeReadyEnvelope( + ready, + doc, + positionalSchema, + stableIdScopes, + known, + appliedEnvelopes, + appliedRevisions, + journalRevision, + ); + } catch (error) { + if (doc.value !== beforeMaterialization) diverged = true; + if (!diverged) { + fault = { + id: ready.id, + reason: error instanceof Error + ? error.message + : "causal materialization threw an unknown value", + phase: "materialization", + }; + } + throw error; + } + if (doc.value !== beforeMaterialization) diverged = true; + if (disposed) { + return { + ok: false, + code: "disposed", + reason: "causal inbox was disposed while materializing an envelope", + ...ingestProgress(integrated, diagnostics), + }; + } + if (diverged) { + return { + ok: false, + code: "projection_diverged", + reason: "document projection changed while materializing a causal envelope", + ...ingestProgress(integrated, diagnostics), + }; + } + if (!materialized.ok) { + const failedMaterialization = copyMaterializationFailure( + materialized.failure, + ); + failure = failedMaterialization; + return { + ok: false, + code: "materialization_failed", + reason: materialized.failure.materialization.reason, + ...failedMaterialization, + ...ingestProgress(integrated, diagnostics), }; - } else if (!diverged) { - fault = { + } + + const beforeProjection = doc.value; + let result: ReturnType; + publicationCount = 0; + publishedOperations = undefined; + applyingId = ready.id; + try { + result = doc.commit(materialized.operations, { + origin, + mergeKey: ready.id, + ...(materialized.selectionAfter === undefined + ? {} + : { selectionAfter: materialized.selectionAfter }), + }); + } catch (error) { + if (doc.value !== beforeProjection) diverged = true; + if (!diverged && error instanceof JSONDocumentError) { + failure = { + id: ready.id, + result: copyJson(error.result), + }; + } else if (!diverged) { + fault = { + id: ready.id, + reason: error instanceof Error + ? error.message + : "document patch threw an unknown value", + }; + } + throw error; + } finally { + applyingId = undefined; + } + + if (!result.ok) { + if (doc.value !== beforeProjection) diverged = true; + if (disposed) { + return { + ok: false, + code: "disposed", + reason: "causal inbox was disposed while applying an envelope", + ...ingestProgress(integrated, diagnostics), + }; + } + if (diverged) { + return { + ok: false, + code: "projection_diverged", + reason: "document projection changed while applying a causal envelope", + ...ingestProgress(integrated, diagnostics), + }; + } + failure = { id: ready.id, result: copyJson(result) }; + return { + ok: false, + code: "patch_failed", + reason: result.reason ?? `causal patch failed: ${ready.id}`, id: ready.id, - reason: error instanceof Error - ? error.message - : "document patch threw an unknown value", + result: copyJson(result), + ...ingestProgress(integrated, diagnostics), }; } - throw error; - } finally { - applyingId = undefined; - } - if (!result.ok) { - if (doc.value !== beforeProjection) diverged = true; - if (disposed) { + if (host !== undefined && diverged) { return { ok: false, - code: "disposed", - reason: "causal inbox was disposed while applying an envelope", + code: "projection_diverged", + reason: "document projection diverged while applying a causal envelope", ...ingestProgress(integrated, diagnostics), }; } + + committedReady = { + materialization: materialized, + operations: publishedOperations ?? EMPTY_OPERATIONS, + }; + return null; + }; + + let earlyResult: CausalPatchIngestResult | null = null; + if (host === undefined) { + earlyResult = applyReady(); + } else { + let applyCount = 0; + let applyError: unknown; + let applyThrew = false; + let hostProtocolViolation: string | undefined; + let hostScopeOpen = true; + publicationCount = 0; + acceptingHostPublications = true; + let hostResult: unknown; + try { + hostResult = host.runReady({ + id: ready.id, + apply() { + if (!hostScopeOpen) { + const reason = + "causal host called ready apply after runReady returned"; + if (!diverged && failure === undefined && fault === undefined) { + fault = { id: ready.id, reason, phase: "host" }; + } + throw new Error(reason); + } + applyCount += 1; + if (applyCount > 1) { + hostProtocolViolation = + "causal host called ready apply more than once"; + if (publicationCount > 0) diverged = true; + return; + } + try { + earlyResult = applyReady(); + } catch (error) { + applyError = error; + applyThrew = true; + throw error; + } + }, + }); + } catch (error) { + if (diverged || publicationCount > 0) { + diverged = true; + } else if (failure === undefined && fault === undefined) { + fault = { + id: ready.id, + reason: error instanceof Error + ? error.message + : "causal host threw an unknown value", + phase: "host", + }; + } + throw error; + } finally { + hostScopeOpen = false; + acceptingHostPublications = false; + } + if (applyThrew) throw applyError; + if (hostProtocolViolation !== undefined && earlyResult === null) { + if (diverged || publicationCount > 0) { + diverged = true; + return { + ok: false, + code: "projection_diverged", + reason: hostProtocolViolation, + ...ingestProgress(integrated, diagnostics), + }; + } + return hostFault(hostProtocolViolation); + } if (diverged) { return { ok: false, code: "projection_diverged", - reason: "document projection changed while applying a causal envelope", + reason: "document projection changed after host ready apply", ...ingestProgress(integrated, diagnostics), }; } - failure = { id: ready.id, result: copyJson(result) }; - return { - ok: false, - code: "patch_failed", - reason: result.reason ?? `causal patch failed: ${ready.id}`, - id: ready.id, - result: copyJson(result), - ...ingestProgress(integrated, diagnostics), - }; + if (earlyResult !== null) return earlyResult; + const readyResult = prepareHostReadyResult(hostResult); + if (readyResult === null) { + const reason = "causal host returned an invalid ready result"; + if (diverged || publicationCount > 0) { + diverged = true; + return { + ok: false, + code: "projection_diverged", + reason, + ...ingestProgress(integrated, diagnostics), + }; + } + return hostFault(reason); + } + if (!readyResult.ok && applyCount !== 0 && publicationCount > 0) { + diverged = true; + return { + ok: false, + code: "projection_diverged", + reason: "causal host deferred after calling ready apply", + ...ingestProgress(integrated, diagnostics), + }; + } + if (!readyResult.ok) { + if (applyCount !== 0) { + return hostFault( + "causal host deferred after calling ready apply", + ); + } + readyIds.push(ready.id); + return { + ok: false, + code: "host_not_ready", + reason: readyResult.reason, + id: ready.id, + ...ingestProgress(integrated, diagnostics), + }; + } + if (applyCount !== 1) { + return hostFault( + "causal host did not call ready apply exactly once", + ); + } } - - if (positionalSchema !== undefined) { - appliedEnvelopes.push({ - id: ready.id, - operations: copyJson(doc.lastPatch), - }); + if (earlyResult !== null) return earlyResult; + if (committedReady === undefined) { + throw new Error("causal ready envelope did not produce a commit"); } - diagnostics.push(...materialized.diagnostics); + + const appliedRevision = appendApplied( + ready.id, + committedReady.operations, + true, + ); + diagnostics.push(...committedReady.materialization.diagnostics); queued.delete(ready.id); - applied.add(ready.id); + appliedRevisions.set(ready.id, appliedRevision); for (const dependency of ready.dependsOn) frontier.delete(dependency); frontier.add(ready.id); integrated.push(ready.id); @@ -416,11 +657,14 @@ export function createCausalPatchInbox( applied: [], }; } - if (busy) { + if (busy || classifyingHostPublication) { + if (classifyingHostPublication) diverged = true; return { ok: false, code: "busy", - reason: "causal inbox is processing another ingestion", + reason: classifyingHostPublication + ? "causal inbox cannot ingest while classifying a host publication" + : "causal inbox is processing another ingestion", applied: [], }; } @@ -490,6 +734,8 @@ function materializeReadyEnvelope( stableIdScopes: CausalPatchInboxOptions["stableIdScopes"], known: ReadonlyMap>, appliedEnvelopes: ReadonlyArray, + appliedRevisions: ReadonlyMap, + journalRevision: number, ): ReadyMaterialization { if ("operations" in ready) { return { @@ -503,12 +749,72 @@ function materializeReadyEnvelope( if (positionalSchema === undefined) { throw new Error("positional materialization policy was not configured"); } + const baseRevision = ready.intent.baseRevision; + if (baseRevision !== undefined && baseRevision > journalRevision) { + return { + ok: false, + failure: { + id: ready.id, + policy: "positional", + materialization: { + ok: false, + code: "base_revision_ahead", + reason: + `positional base revision ${baseRevision} is ahead of journal revision ${journalRevision}`, + baseRevision, + journalRevision, + }, + }, + }; + } const causalPast = collectCausalPast(ready.dependsOn, known); + let newerDependency: + | { readonly id: string; readonly revision: number } + | undefined; + if (baseRevision !== undefined) { + for (const id of causalPast) { + const revision = appliedRevisions.get(id); + if ( + revision === undefined + || revision <= baseRevision + || appliedEnvelopes[revision - 1]?.operations.length === 0 + || ( + newerDependency !== undefined + && newerDependency.revision <= revision + ) + ) { + continue; + } + newerDependency = { id, revision }; + } + } + if (baseRevision !== undefined && newerDependency !== undefined) { + return { + ok: false, + failure: { + id: ready.id, + policy: "positional", + materialization: { + ok: false, + code: "base_revision_mismatch", + reason: + `causal dependency ${newerDependency.id} at revision ${newerDependency.revision} is newer than positional base revision ${baseRevision}`, + baseRevision, + dependency: newerDependency.id, + dependencyRevision: newerDependency.revision, + }, + }, + }; + } const planned = rebaseChange(positionalSchema, { base: ready.intent.base, - concurrentBatches: appliedEnvelopes - .filter(({ id }) => !causalPast.has(id)) - .map(({ operations }) => operations), + concurrentBatches: baseRevision === undefined + ? appliedEnvelopes + .filter(({ id }) => id === undefined || !causalPast.has(id)) + .map(({ operations }) => operations) + : appliedEnvelopes + .slice(baseRevision) + .map(({ operations }) => operations), operations: ready.intent.operations, ...(ready.intent.selectionAfter === undefined ? {} @@ -613,6 +919,81 @@ function ingestProgress( : { applied, diagnostics }; } +function prepareHostPublicationOwnership( + value: unknown, +): CausalHostPublicationOwnership | null { + if (value === false) return false; + try { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return null; + const keys = Reflect.ownKeys(value); + if (keys.length !== 1 || keys[0] !== "sequence") return null; + const descriptor = Object.getOwnPropertyDescriptor(value, "sequence"); + if ( + descriptor === undefined + || !descriptor.enumerable + || !("value" in descriptor) + || !Number.isSafeInteger(descriptor.value) + || (descriptor.value as number) < 0 + ) { + return null; + } + return { sequence: descriptor.value as number }; + } catch { + return null; + } +} + +function prepareHostReadyResult(value: unknown): CausalHostReadyResult | null { + try { + return readHostReadyResult(value); + } catch { + return null; + } +} + +function readHostReadyResult(value: unknown): CausalHostReadyResult | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const prototype = Object.getPrototypeOf(value) as unknown; + if (prototype !== Object.prototype && prototype !== null) return null; + + const fields = new Map(); + for (const key of Reflect.ownKeys(value)) { + if ( + typeof key !== "string" + || (key !== "ok" && key !== "code" && key !== "reason") + ) { + return null; + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !("value" in descriptor) + ) { + return null; + } + fields.set(key, descriptor.value); + } + + if (fields.size === 1 && fields.get("ok") === true) return { ok: true }; + return fields.size === 3 + && fields.get("ok") === false + && fields.get("code") === "host_not_ready" + && typeof fields.get("reason") === "string" + ? { + ok: false, + code: "host_not_ready", + reason: fields.get("reason") as string, + } + : null; +} + function copyFailure(failure: CausalPatchFailure): CausalPatchFailure { if ("materialization" in failure) { return copyMaterializationFailure(failure); diff --git a/labs/extensions/causal-patch-inbox/src/envelope.ts b/labs/extensions/causal-patch-inbox/src/envelope.ts index c0a8a2a3..1212293f 100644 --- a/labs/extensions/causal-patch-inbox/src/envelope.ts +++ b/labs/extensions/causal-patch-inbox/src/envelope.ts @@ -1,5 +1,6 @@ import type { JSONPatchOperation, + SelectionPoint, } from "@interactive-os/json-document"; import type { CausalAuthoredIntent, @@ -38,6 +39,7 @@ const ENVELOPE_FIELDS = new Set(["id", "dependsOn", "operations", "intent"]); const POSITIONAL_INTENT_FIELDS = new Set([ "kind", "base", + "baseRevision", "operations", "selectionAfter", ]); @@ -50,6 +52,12 @@ const STABLE_ID_INTENT_FIELDS = new Set([ "relativeSelectionAfter", ]); const STABLE_ID_TARGET_FIELDS = new Set(["scope", "id"]); +const SELECTION_POINT_FIELDS = new Set([ + "path", + "offset", + "edge", + "affinity", +]); export function prepareEnvelope( input: unknown, @@ -185,9 +193,16 @@ function preparePositionalIntent( !hasOnlyFields(input, POSITIONAL_INTENT_FIELDS) || !hasOwn(input, "base") || !Array.isArray(input.operations) + || ( + hasOwn(input, "baseRevision") + && ( + !Number.isSafeInteger(input.baseRevision) + || (input.baseRevision as number) < 0 + ) + ) || ( hasOwn(input, "selectionAfter") - && typeof input.selectionAfter !== "string" + && !isSelectionPointData(input.selectionAfter) ) ) { return { @@ -199,8 +214,11 @@ function preparePositionalIntent( const intent: CausalPositionalIntent = { kind: "positional", base: input.base as TDocument, + ...(typeof input.baseRevision === "number" + ? { baseRevision: input.baseRevision } + : {}), operations: input.operations as ReadonlyArray, - ...(typeof input.selectionAfter === "string" + ...(isSelectionPointData(input.selectionAfter) ? { selectionAfter: input.selectionAfter } : {}), }; @@ -219,7 +237,7 @@ function prepareStableIdIntent( || typeof input.relativePath !== "string" || ( hasOwn(input, "relativeSelectionAfter") - && typeof input.relativeSelectionAfter !== "string" + && !isSelectionPointData(input.relativeSelectionAfter) ) || !isPlainRecord(target) || !hasOnlyFields(target, STABLE_ID_TARGET_FIELDS) @@ -240,7 +258,7 @@ function prepareStableIdIntent( relativePath: input.relativePath, expected: input.expected, value: input.value, - ...(typeof input.relativeSelectionAfter === "string" + ...(isSelectionPointData(input.relativeSelectionAfter) ? { relativeSelectionAfter: input.relativeSelectionAfter } : {}), }; @@ -333,6 +351,30 @@ function hasOwn( return Object.prototype.hasOwnProperty.call(value, key); } +function isSelectionPointData(value: unknown): value is SelectionPoint { + if (typeof value === "string") return true; + if (!isPlainRecord(value) || !hasOnlyFields(value, SELECTION_POINT_FIELDS)) { + return false; + } + if (typeof value.path !== "string") return false; + if ( + hasOwn(value, "offset") + && (!Number.isSafeInteger(value.offset) || (value.offset as number) < 0) + ) { + return false; + } + if ( + hasOwn(value, "edge") + && value.edge !== "before" + && value.edge !== "after" + ) { + return false; + } + return !hasOwn(value, "affinity") + || value.affinity === "forward" + || value.affinity === "backward"; +} + function cloneJsonValue(value: unknown, ancestors: Set): unknown { if ( value === null diff --git a/labs/extensions/causal-patch-inbox/src/index.ts b/labs/extensions/causal-patch-inbox/src/index.ts index f666303a..9467ec4c 100644 --- a/labs/extensions/causal-patch-inbox/src/index.ts +++ b/labs/extensions/causal-patch-inbox/src/index.ts @@ -1,7 +1,12 @@ export { createCausalPatchInbox } from "./create.js"; export type { CausalAuthoredIntent, + CausalBaseRevisionFailure, CausalEnvelope, + CausalHostPublication, + CausalHostPublicationOwnership, + CausalHostReadyRequest, + CausalHostReadyResult, CausalIntentEnvelope, CausalMaterializationDiagnostic, CausalMaterializationPolicy, @@ -9,12 +14,14 @@ export type { CausalPatchFailure, CausalPatchInbox, CausalPatchInboxOptions, + CausalPatchHost, CausalPatchInboxSnapshot, CausalPatchIngestError, CausalPatchIngestErrorCode, CausalPatchIngestOk, CausalPatchIngestResult, CausalPositionalIntent, + CausalPositionalMaterializationFailure, CausalStableIdReplaceIntent, FailedCausalMaterialization, FailedCausalPatch, diff --git a/labs/extensions/causal-patch-inbox/src/types.ts b/labs/extensions/causal-patch-inbox/src/types.ts index 5e0270f3..b8f67355 100644 --- a/labs/extensions/causal-patch-inbox/src/types.ts +++ b/labs/extensions/causal-patch-inbox/src/types.ts @@ -1,7 +1,9 @@ import type { + JSONChangeMetadata, JSONPatchOperation, JSONResult, Pointer, + SelectionPoint, } from "@interactive-os/json-document"; import type { RebaseChangeResult, @@ -25,8 +27,16 @@ export interface CausalPatchEnvelope { export interface CausalPositionalIntent { readonly kind: "positional"; readonly base: TDocument; + /** + * Inbox-local journal token captured with `base` from `current()` when a + * host is configured. When present, only later projection batches are + * replayed. Omit it on the legacy hostless path. A token from another inbox + * instance is invalid; this is not a transport clock and must already + * include every declared causal dependency. + */ + readonly baseRevision?: number; readonly operations: ReadonlyArray; - readonly selectionAfter?: Pointer; + readonly selectionAfter?: SelectionPoint; } export interface CausalStableIdReplaceIntent { @@ -35,7 +45,7 @@ export interface CausalStableIdReplaceIntent { readonly relativePath: Pointer; readonly expected: unknown; readonly value: unknown; - readonly relativeSelectionAfter?: Pointer; + readonly relativeSelectionAfter?: SelectionPoint; } export type CausalAuthoredIntent = @@ -57,7 +67,51 @@ export type CausalMaterializationPolicy = | "positional" | "stable-id-replace"; +export interface CausalHostPublication { + readonly operations: ReadonlyArray; + readonly metadata?: JSONChangeMetadata; +} + +export type CausalHostPublicationOwnership = + | false + | { readonly sequence: number }; + +export interface CausalHostReadyRequest { + readonly id: string; + /** + * Scope-bound synchronous application. Call exactly once before `runReady` + * returns success, or do not call it when returning `host_not_ready`. + */ + apply(): void; +} + +export type CausalHostReadyResult = + | { readonly ok: true } + | { + readonly ok: false; + readonly code: "host_not_ready"; + readonly reason: string; + }; + +export interface CausalPatchHost { + /** + * Classifies a synchronous host-owned publication. This callback must be + * pure and must not publish another document change. An owned publication + * returns the monotonic sequence assigned before its commit began; `false` + * rejects it. + */ + ownsPublication( + publication: CausalHostPublication, + ): CausalHostPublicationOwnership; + /** + * Flushes host input and runs one ready envelope in the same call stack. + * The request cannot be retained for later use. + */ + runReady(request: CausalHostReadyRequest): CausalHostReadyResult; +} + export interface CausalPatchInboxOptions { + readonly host?: CausalPatchHost; readonly positionalSchema?: RebaseSchema; readonly stableIdScopes?: StableIdReplaceInput["scopes"]; } @@ -72,11 +126,32 @@ export interface FailedCausalPatch { readonly result: Extract; } +export type CausalBaseRevisionFailure = + | { + readonly ok: false; + readonly code: "base_revision_ahead"; + readonly reason: string; + readonly baseRevision: number; + readonly journalRevision: number; + } + | { + readonly ok: false; + readonly code: "base_revision_mismatch"; + readonly reason: string; + readonly baseRevision: number; + readonly dependency: string; + readonly dependencyRevision: number; + }; + +export type CausalPositionalMaterializationFailure = + | Exclude + | CausalBaseRevisionFailure; + export type FailedCausalMaterialization = | { readonly id: string; readonly policy: "positional"; - readonly materialization: Exclude; + readonly materialization: CausalPositionalMaterializationFailure; } | { readonly id: string; @@ -91,7 +166,7 @@ export type CausalPatchFailure = export interface FaultedCausalPatch { readonly id: string; readonly reason: string; - readonly phase?: "materialization"; + readonly phase?: "host" | "materialization"; } export type CausalMaterializationDiagnostic = @@ -112,6 +187,8 @@ export interface CausalPatchIngestProgress { export interface CausalPatchInboxSnapshot { readonly status: "active" | "blocked" | "diverged" | "faulted" | "disposed"; readonly frontier: ReadonlyArray; + /** Inbox-local projection journal token, exposed only when a host exists. */ + readonly journalRevision?: number; readonly queued: ReadonlyArray; readonly failure?: CausalPatchFailure; readonly fault?: FaultedCausalPatch; @@ -129,6 +206,7 @@ export type CausalPatchIngestErrorCode = | "dependency_cycle" | "policy_not_configured" | "materialization_failed" + | "host_not_ready" | "patch_failed" | "blocked" | "projection_diverged" @@ -163,6 +241,12 @@ export type CausalPatchIngestError = CausalPatchIngestProgress & ( readonly id: string; readonly policy: CausalMaterializationPolicy; } + | { + readonly ok: false; + readonly code: "host_not_ready"; + readonly reason: string; + readonly id: string; + } | ({ readonly ok: false; readonly code: "materialization_failed" | "blocked"; @@ -185,7 +269,7 @@ export type CausalPatchIngestError = CausalPatchIngestProgress & ( readonly code: "faulted"; readonly reason: string; readonly id: string; - readonly phase?: "materialization"; + readonly phase?: "host" | "materialization"; } ); diff --git a/labs/extensions/causal-patch-inbox/tests/causal-patch-inbox.test.ts b/labs/extensions/causal-patch-inbox/tests/causal-patch-inbox.test.ts index 18aabbe5..6b3be201 100644 --- a/labs/extensions/causal-patch-inbox/tests/causal-patch-inbox.test.ts +++ b/labs/extensions/causal-patch-inbox/tests/causal-patch-inbox.test.ts @@ -941,6 +941,1012 @@ describe("@interactive-os/json-document-causal-patch-inbox", () => { }); }); + test("rebases a delayed selection point without losing caret metadata", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "Before" }, + ], + }; + const doc = createJSONDocument(CollectionSchema, base, { + history: 10, + selection: { mode: "single", initial: ["/items/1/title"] }, + }); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + }); + + expect(inbox.ingest({ + id: "remote-title", + dependsOn: ["parent"], + intent: { + kind: "positional", + base, + operations: [{ + op: "replace", + path: "/items/1/title", + value: "Reviewed", + }], + selectionAfter: { + path: "/items/1/title", + offset: 4, + affinity: "forward", + }, + }, + })).toMatchObject({ ok: true, pending: ["remote-title"] }); + + expect(inbox.ingest([{ + id: "concurrent", + dependsOn: [], + operations: [{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }], + }, { + id: "parent", + dependsOn: [], + operations: [], + }])).toMatchObject({ + ok: true, + applied: ["concurrent", "parent", "remote-title"], + }); + expect(doc.selection?.caret).toEqual({ + path: "/items/2/title", + offset: 4, + affinity: "forward", + }); + }); + + test("rejects malformed selection point data before admitting an intent", () => { + const invalidPoints = [ + { path: "/items/0/title", offset: -1 }, + { path: "/items/0/title", offset: 1.5 }, + { path: "/items/0/title", edge: "inside" }, + { path: "/items/0/title", affinity: "nearest" }, + { path: "/items/0/title", decoration: true }, + ]; + + for (const selectionAfter of invalidPoints) { + const base = { items: [{ id: "a", title: "A" }] }; + const doc = createJSONDocument(CollectionSchema, base); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + }); + + expect(inbox.ingest({ + id: "invalid-point", + dependsOn: [], + intent: { + kind: "positional", + base, + operations: [], + selectionAfter, + }, + } as never)).toMatchObject({ + ok: false, + code: "invalid_envelope", + id: "invalid-point", + applied: [], + }); + expect(doc.value).toEqual(base); + expect(inbox.current().queued).toEqual([]); + } + }); + + test("integrates a host publication before applying one ready positional intent", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }; + const doc = createJSONDocument(CollectionSchema, base); + let hostSequence = 0; + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + host: { + ownsPublication: ({ metadata }) => metadata?.origin === "editable" + ? { sequence: hostSequence += 1 } + : false, + runReady: ({ apply }) => { + expect(doc.commit([{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }], { origin: "editable" })).toEqual({ ok: true }); + apply(); + return { ok: true }; + }, + }, + }); + + const result = inbox.ingest({ + id: "remote-title", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [{ + op: "replace", + path: "/items/1/title", + value: "Reviewed", + }], + }, + }); + + expect(result).toMatchObject({ + ok: true, + applied: ["remote-title"], + diagnostics: [{ + id: "remote-title", + policy: "positional", + code: "pointer_shifted", + pointer: "/items/1/title", + rebasedPointer: "/items/2/title", + }], + }); + expect(doc.value.items).toEqual([ + { id: "x", title: "X" }, + { id: "a", title: "A" }, + { id: "b", title: "Reviewed" }, + ]); + expect(inbox.current()).toMatchObject({ + status: "active", + journalRevision: 2, + frontier: ["remote-title"], + queued: [], + }); + }); + + test("journals an unpublished causal test as an empty projection batch", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }; + const doc = createJSONDocument(CollectionSchema, base); + let hostSequence = 0; + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + host: { + ownsPublication: ({ metadata }) => metadata?.origin === "editable" + ? { sequence: hostSequence += 1 } + : false, + runReady: ({ id, apply }) => { + if (id === "guard") { + expect(doc.commit([{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }], { origin: "editable" })).toEqual({ ok: true }); + } + apply(); + return { ok: true }; + }, + }, + }); + + expect(inbox.ingest({ + id: "guard", + dependsOn: [], + operations: [{ op: "test", path: "/items/0/id", value: "x" }], + })).toMatchObject({ ok: true, applied: ["guard"] }); + expect(inbox.ingest({ + id: "remote-title", + dependsOn: ["guard"], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [{ + op: "replace", + path: "/items/1/title", + value: "Reviewed", + }], + }, + })).toMatchObject({ + ok: true, + applied: ["remote-title"], + diagnostics: [{ + id: "remote-title", + code: "pointer_shifted", + pointer: "/items/1/title", + rebasedPointer: "/items/2/title", + }], + }); + expect(doc.value.items).toEqual([ + { id: "x", title: "X" }, + { id: "a", title: "A" }, + { id: "b", title: "Reviewed" }, + ]); + expect(inbox.current()).toMatchObject({ journalRevision: 3 }); + }); + + test("blocks a positional intent authored from a future journal revision", () => { + const base = { + items: [{ id: "a", title: "A" }], + }; + const doc = createJSONDocument(CollectionSchema, base); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote-title", + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision: 1, + operations: [{ + op: "replace", + path: "/items/0/title", + value: "Reviewed", + }], + }, + })).toMatchObject({ + ok: false, + code: "materialization_failed", + id: "remote-title", + policy: "positional", + materialization: { + ok: false, + code: "base_revision_ahead", + baseRevision: 1, + journalRevision: 0, + }, + applied: [], + }); + expect(doc.value).toEqual(base); + expect(inbox.current()).toMatchObject({ + status: "blocked", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote-title", missing: [] }], + }); + }); + + test("blocks when a declared dependency is newer than the positional base", () => { + const base = { + items: [{ id: "a", title: "A" }], + }; + const doc = createJSONDocument(CollectionSchema, base); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + expect(inbox.ingest([{ + id: "parent", + dependsOn: [], + operations: [{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }], + }, { + id: "child", + dependsOn: ["parent"], + intent: { + kind: "positional", + base, + baseRevision: 0, + operations: [{ + op: "replace", + path: "/items/0/title", + value: "Reviewed", + }], + }, + }])).toMatchObject({ + ok: false, + code: "materialization_failed", + id: "child", + policy: "positional", + materialization: { + ok: false, + code: "base_revision_mismatch", + baseRevision: 0, + dependency: "parent", + dependencyRevision: 1, + }, + applied: ["parent"], + }); + expect(doc.value.items).toEqual([ + { id: "x", title: "X" }, + { id: "a", title: "A" }, + ]); + expect(inbox.current()).toMatchObject({ + status: "blocked", + journalRevision: 1, + frontier: ["parent"], + queued: [{ id: "child", missing: [] }], + }); + }); + + test("rebases only journal batches newer than the authored revision", () => { + const doc = createJSONDocument(CollectionSchema, { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }); + let hostSequence = 0; + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + host: { + ownsPublication: ({ metadata }) => metadata?.origin === "editable" + ? { sequence: hostSequence += 1 } + : false, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + expect(doc.commit([{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }], { origin: "editable" })).toEqual({ ok: true }); + const authoredBase = doc.value; + expect(inbox.current().journalRevision).toBe(1); + expect(doc.commit([{ + op: "add", + path: "/items/0", + value: { id: "y", title: "Y" }, + }], { origin: "editable" })).toEqual({ ok: true }); + + expect(inbox.ingest({ + id: "remote-title", + dependsOn: [], + intent: { + kind: "positional", + base: authoredBase, + baseRevision: 1, + operations: [{ + op: "replace", + path: "/items/2/title", + value: "Reviewed", + }], + }, + })).toMatchObject({ + ok: true, + applied: ["remote-title"], + diagnostics: [{ + code: "pointer_shifted", + pointer: "/items/2/title", + rebasedPointer: "/items/3/title", + }], + }); + expect(doc.value.items).toEqual([ + { id: "y", title: "Y" }, + { id: "x", title: "X" }, + { id: "a", title: "A" }, + { id: "b", title: "Reviewed" }, + ]); + expect(inbox.current()).toMatchObject({ journalRevision: 3 }); + }); + + test("faults when a host reports success without applying the ready envelope", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: () => ({ ok: true }), + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ + ok: false, + code: "faulted", + id: "remote", + phase: "host", + applied: [], + }); + expect(doc.value.log).toEqual([]); + expect(inbox.current()).toEqual({ + status: "faulted", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + fault: { + id: "remote", + reason: "causal host did not call ready apply exactly once", + phase: "host", + }, + }); + }); + + test("diverges when a host returns an invalid result after applying", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: (({ apply }: { apply(): void }) => { + apply(); + return undefined; + }) as never, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ + ok: false, + code: "projection_diverged", + reason: "causal host returned an invalid ready result", + applied: [], + }); + expect(doc.value.log).toEqual(["remote"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + }); + + test("faults when a host returns an invalid result before applying", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: (() => Promise.resolve({ ok: true })) as never, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ + ok: false, + code: "faulted", + reason: "causal host returned an invalid ready result", + id: "remote", + phase: "host", + applied: [], + }); + expect(doc.value.log).toEqual([]); + expect(inbox.current()).toMatchObject({ + status: "faulted", + journalRevision: 0, + queued: [{ id: "remote", missing: [] }], + fault: { + id: "remote", + reason: "causal host returned an invalid ready result", + phase: "host", + }, + }); + }); + + test("diverges when a host calls ready apply more than once", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + apply(); + return { ok: true }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ + ok: false, + code: "projection_diverged", + applied: [], + }); + expect(doc.value.log).toEqual(["remote"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + }); + + test("diverges when a host defers after applying a ready envelope", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + return { + ok: false, + code: "host_not_ready", + reason: "composition started while publishing", + }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ + ok: false, + code: "projection_diverged", + applied: [], + }); + expect(doc.value.log).toEqual(["remote"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + }); + + test("preserves a patch failure when the host defers after apply", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + return { + ok: false, + code: "host_not_ready", + reason: "host observed the failed apply", + }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "replace", path: "/missing", value: true }], + })).toMatchObject({ + ok: false, + code: "patch_failed", + id: "remote", + applied: [], + }); + expect(inbox.current()).toMatchObject({ + status: "blocked", + journalRevision: 0, + queued: [{ id: "remote", missing: [] }], + failure: { id: "remote" }, + }); + expect(inbox.current()).not.toHaveProperty("fault"); + }); + + test("reports divergence over an earlier patch failure after host mutation", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + expect(doc.commit( + [{ op: "add", path: "/log/-", value: "external" }], + { origin: "editable" }, + )).toEqual({ ok: true }); + return { ok: true }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "replace", path: "/missing", value: true }], + })).toMatchObject({ + ok: false, + code: "projection_diverged", + applied: [], + }); + expect(doc.value.log).toEqual(["external"]); + expect(inbox.current()).toMatchObject({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + }); + + test("does not advance the frontier after a nested publication diverges", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + let nested = false; + doc.subscribe(() => { + if (nested) return; + nested = true; + doc.commit( + [{ op: "add", path: "/log/-", value: "nested" }], + { origin: "external" }, + ); + }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ + ok: false, + code: "projection_diverged", + applied: [], + }); + expect(doc.value.log).toEqual(["remote", "nested"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + }); + + test("diverges instead of reversing reentrant host publication order", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + let nested = false; + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => { + if (!nested) { + nested = true; + doc.commit( + [{ op: "add", path: "/log/-", value: "nested" }], + { origin: "editable" }, + ); + } + return { sequence: 1 }; + }, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + expect(doc.commit( + [{ op: "add", path: "/log/-", value: "outer" }], + { origin: "editable" }, + )).toEqual({ ok: true }); + expect(doc.value.log).toEqual(["outer", "nested"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [], + }); + }); + + test("diverges when host publication callbacks arrive out of commit order", () => { + const OrderedSchema = z.object({ + a: z.number(), + b: z.number(), + c: z.number(), + }).refine(({ a, b }) => b <= a); + const doc = createJSONDocument(OrderedSchema, { a: 0, b: 0, c: 0 }); + const publicationStack: number[] = []; + let nextSequence = 0; + let nested = false; + const hostCommit = ( + operations: Parameters[0], + ): ReturnType => { + const sequence = nextSequence += 1; + publicationStack.push(sequence); + try { + return doc.commit(operations, { origin: "editable" }); + } finally { + publicationStack.pop(); + } + }; + doc.subscribe(() => { + if (nested) return; + nested = true; + expect(hostCommit([{ + op: "replace", + path: "/b", + value: 1, + }])).toEqual({ ok: true }); + }); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: OrderedSchema, + host: { + ownsPublication: () => ({ + sequence: publicationStack.at(-1)!, + }), + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + expect(hostCommit([{ + op: "replace", + path: "/a", + value: 1, + }])).toEqual({ ok: true }); + expect(doc.value).toEqual({ a: 1, b: 1, c: 0 }); + expect(publicationStack).toEqual([]); + expect(inbox.current()).toMatchObject({ status: "diverged" }); + }); + + test("rejects causal ingestion reentered from host publication ownership", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + let nestedResult: ReturnType< + ReturnType["ingest"] + > | undefined; + let inbox: ReturnType; + inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => { + nestedResult = inbox.ingest({ + id: "nested", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "nested" }], + }); + return { sequence: 1 }; + }, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + expect(doc.commit( + [{ op: "add", path: "/log/-", value: "outer" }], + { origin: "editable" }, + )).toEqual({ ok: true }); + expect(nestedResult).toMatchObject({ + ok: false, + code: "busy", + applied: [], + }); + expect(doc.value.log).toEqual(["outer"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [], + }); + }); + + test("retries a host-deferred ready envelope on an empty ingestion", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + let ready = false; + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + if (!ready) { + return { + ok: false, + code: "host_not_ready", + reason: "composition is active", + }; + } + apply(); + return { ok: true }; + }, + }, + }); + const envelope = { + id: "remote", + dependsOn: [], + operations: [{ + op: "add" as const, + path: "/log/-" as const, + value: "remote", + }], + }; + + expect(inbox.ingest(envelope)).toEqual({ + ok: false, + code: "host_not_ready", + reason: "composition is active", + id: "remote", + applied: [], + }); + expect(inbox.current()).toEqual({ + status: "active", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + + ready = true; + expect(inbox.ingest([])).toEqual({ + ok: true, + applied: ["remote"], + pending: [], + duplicates: [], + }); + expect(doc.value.log).toEqual(["remote"]); + expect(inbox.current()).toEqual({ + status: "active", + journalRevision: 1, + frontier: ["remote"], + queued: [], + }); + }); + + test("faults and rethrows when a host throws before applying", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: () => { + throw new Error("host failed"); + }, + }, + }); + + expect(() => inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toThrow("host failed"); + expect(doc.value.log).toEqual([]); + expect(inbox.current()).toEqual({ + status: "faulted", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + fault: { + id: "remote", + reason: "host failed", + phase: "host", + }, + }); + }); + + test("diverges and rethrows when a host throws after applying", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + apply(); + throw new Error("host cleanup failed"); + }, + }, + }); + + expect(() => inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toThrow("host cleanup failed"); + expect(doc.value.log).toEqual(["remote"]); + expect(inbox.current()).toEqual({ + status: "diverged", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + }); + }); + + test("expires a ready apply closure when the host returns", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + let lateApply: (() => void) | undefined; + const inbox = createCausalPatchInbox(doc, { + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + lateApply = apply; + return { + ok: false, + code: "host_not_ready", + reason: "apply later", + }; + }, + }, + }); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "remote" }], + })).toMatchObject({ ok: false, code: "host_not_ready" }); + expect(() => lateApply?.()).toThrow( + "causal host called ready apply after runReady returned", + ); + expect(doc.value.log).toEqual([]); + expect(inbox.current()).toEqual({ + status: "faulted", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + fault: { + id: "remote", + reason: "causal host called ready apply after runReady returned", + phase: "host", + }, + }); + }); + + test("preserves an apply failure that the host catches", () => { + const base = { items: [{ id: "a", title: "A" }] }; + const doc = createJSONDocument(CollectionSchema, base); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: { + safeParse() { + throw new Error("materialization schema failed"); + }, + }, + host: { + ownsPublication: () => false, + runReady: ({ apply }) => { + try { + apply(); + } catch { + // A host wrapper may observe an error, but cannot consume it. + } + return { ok: true }; + }, + }, + }); + + expect(() => inbox.ingest({ + id: "remote", + dependsOn: [], + intent: { + kind: "positional", + base, + operations: [{ + op: "replace", + path: "/items/0/title", + value: "Remote", + }], + }, + })).toThrow("materialization schema failed"); + expect(doc.value).toEqual(base); + expect(inbox.current()).toEqual({ + status: "faulted", + journalRevision: 0, + frontier: [], + queued: [{ id: "remote", missing: [] }], + fault: { + id: "remote", + reason: "materialization schema failed", + phase: "materialization", + }, + }); + }); + test("blocks a positional intent rather than overwriting a concurrent field", () => { const base = { items: [ @@ -1053,7 +2059,11 @@ describe("@interactive-os/json-document-causal-patch-inbox", () => { relativePath: "/title", expected: "B", value: "Reviewed", - relativeSelectionAfter: "/title", + relativeSelectionAfter: { + path: "/title", + offset: 4, + affinity: "backward", + }, }, })).toMatchObject({ ok: true, pending: ["z-local"] }); @@ -1081,7 +2091,11 @@ describe("@interactive-os/json-document-causal-patch-inbox", () => { id: "b", title: "Reviewed", }]); - expect(doc.selection?.primaryPointer).toBe("/columns/1/cards/0/title"); + expect(doc.selection?.caret).toEqual({ + path: "/columns/1/cards/0/title", + offset: 4, + affinity: "backward", + }); expect(doc.history.undoDepth).toBe(2); }); diff --git a/labs/extensions/patch-rebase/README.md b/labs/extensions/patch-rebase/README.md index e4419e0c..93ee35fe 100644 --- a/labs/extensions/patch-rebase/README.md +++ b/labs/extensions/patch-rebase/README.md @@ -13,7 +13,11 @@ const planned = rebaseChange(DocumentSchema, { operations: [ { op: "replace", path: "/items/1/title", value: "Reviewed" }, ], - selectionAfter: "/items/1/title", + selectionAfter: { + path: "/items/1/title", + offset: 4, + affinity: "forward", + }, }); if (planned.ok) { @@ -31,20 +35,25 @@ document. Persisted workflows must own their snapshot and patch-log format. Pass the same Zod-compatible schema that owns the document. The lab types only the `safeParse` slice of that schema to avoid installing a second Zod runtime; failed parses must expose an `error.issues` collection, as core `applyPatch` -expects. The returned selection is authoritative even when its Pointer shifts; -diagnostics describe shifted operation targets and dropped selections. URI -fragment Pointers are accepted and canonicalized to ordinary RFC 6901 Pointers -in the returned plan. +expects. `selectionAfter` accepts a Pointer or one `SelectionPoint`. Its path is +authoritative after rebasing while `offset`, `edge`, and `affinity` are +preserved. Diagnostics describe shifted operation targets and dropped +selections. URI fragment paths are accepted and canonicalized to ordinary RFC +6901 Pointers in the returned plan. Point objects must be plain data and any +offset must be a non-negative safe integer; the final document commit may clamp +it to the selected string length. ## Scope -- Replay ordered concurrent batches from a shared base through public - `applyPatch` semantics. +- Replay ordered concurrent batches from a shared base through public patch + semantics. The first local preflight establishes the JSON boundary; later + replay and final validation use the trusted-state public path to avoid + rescanning the complete base for every batch. - Validate the local batch against that same base. - Rebase local non-root `replace` and `test` targets over concurrent `test`, `replace`, and object/array `add` or `remove` operations. -- Shift local targets and one headless Pointer selection after concrete array - index changes. +- Shift local targets and one headless Pointer or SelectionPoint path after + concrete array index changes while preserving caret metadata. - Treat numeric object keys as object members rather than array indexes. - Report same-path and ancestor/descendant writes as structured conflicts. - Revalidate the combined result so cross-field schema refinements can report a @@ -63,8 +72,8 @@ in the returned plan. - No automatic rebase of concurrent `move`, `copy`, or root mutations. - No automatic rebase of structural local operations while concurrent changes exist; uncertain cases return `unsupported_operation` conflict. -- No `SelectionSnap`, multi-range selection, text-offset transform, DOM focus, - rendering, keyboard, or UI policy. +- No `SelectionSnap`, range/multi-range selection, same-string text-offset + transform, DOM focus, rendering, keyboard, or UI policy. - No plugin registration and no `@interactive-os/json-document` internal imports. diff --git a/labs/extensions/patch-rebase/src/plan.ts b/labs/extensions/patch-rebase/src/plan.ts index 7e6212d0..17827f96 100644 --- a/labs/extensions/patch-rebase/src/plan.ts +++ b/labs/extensions/patch-rebase/src/plan.ts @@ -1,7 +1,10 @@ import { applyPatch, + applyPatchToTrustedState, type JSONPatchOperation, type Pointer, + type SelectionPoint, + type SelectionPointObject, } from "@interactive-os/json-document"; import { @@ -15,6 +18,10 @@ import { canonicalPointer, readPointerValue, } from "./pointer.js"; +import { + prepareSelectionPoint, + selectionPointPath, +} from "./selection.js"; import { transformLocalOperations, transformSelection, @@ -33,13 +40,30 @@ const PERMISSIVE_JSON_SCHEMA: RebaseSchema = { export function rebaseChange( schema: RebaseSchema, - input: RebaseChangeInput, -): RebaseChangeResult { - let selectionAfter: Pointer | undefined; + input: RebaseChangeInput, +): RebaseChangeResult; +export function rebaseChange( + schema: RebaseSchema, + input: RebaseChangeInput, +): RebaseChangeResult; +export function rebaseChange( + schema: RebaseSchema, + input: RebaseChangeInput, +): RebaseChangeResult; +// Keep the legacy Pointer signature last for Parameters/ReturnType consumers. +export function rebaseChange( + schema: RebaseSchema, + input: RebaseChangeInput, +): RebaseChangeResult; +export function rebaseChange( + schema: RebaseSchema, + input: RebaseChangeInput, +): RebaseChangeResult { + let selectionAfter: SelectionPoint | undefined; if (input.selectionAfter !== undefined) { - const canonicalSelection = canonicalPointer(input.selectionAfter); - if (canonicalSelection === null) { - const reason = `invalid selection pointer: ${input.selectionAfter}`; + const preparedSelection = prepareSelectionPoint(input.selectionAfter); + if (!preparedSelection.ok) { + const reason = preparedSelection.reason; return { ok: false, code: "conflict", @@ -47,11 +71,13 @@ export function rebaseChange( conflicts: [{ code: "invalid_selection", reason, - pointer: input.selectionAfter, + ...(preparedSelection.pointer === undefined + ? {} + : { pointer: preparedSelection.pointer }), }], }; } - selectionAfter = canonicalSelection; + selectionAfter = preparedSelection.point; } const local = applyPatchWithSchema( @@ -97,7 +123,9 @@ export function rebaseChange( const guards = createGuards( concurrent.state, transformed.operations, - selection.selectionAfter, + selection.selectionAfter === undefined + ? undefined + : selectionPointPath(selection.selectionAfter), ); if (!guards.ok) { return { @@ -116,7 +144,11 @@ export function rebaseChange( ...guards.operations, ...copyOperations(transformed.operations), ]; - const validated = applyPatchWithSchema(schema, concurrent.state, operations); + const validated = applyTrustedPatchWithSchema( + schema, + concurrent.state, + operations, + ); if (!validated.result.ok) { return { ok: false, @@ -131,7 +163,10 @@ export function rebaseChange( } const finalSelection = selection.selectionAfter !== undefined - && !readPointerValue(validated.state, selection.selectionAfter).ok + && !readPointerValue( + validated.state, + selectionPointPath(selection.selectionAfter), + ).ok ? { selectionAfter: undefined, diagnostics: [ @@ -139,7 +174,7 @@ export function rebaseChange( { code: "selection_dropped" as const, reason: "selection target does not exist after the rebased change", - pointer: selection.selectionAfter, + pointer: selectionPointPath(selection.selectionAfter), }, ], } @@ -160,7 +195,7 @@ export function rebaseChange( function replayConcurrentChanges( schema: RebaseSchema, - input: RebaseChangeInput, + input: RebaseChangeInput, ): | { ok: true; state: unknown; steps: ConcurrentStep[] } | { @@ -171,7 +206,7 @@ function replayConcurrentChanges( const steps: ConcurrentStep[] = []; for (let batchIndex = 0; batchIndex < input.concurrentBatches.length; batchIndex += 1) { const before = state; - const replayed = applyPatchWithSchema( + const replayed = applyTrustedPatchWithSchema( schema, state, canonicalizeOperations(input.concurrentBatches[batchIndex]!), @@ -191,7 +226,7 @@ function replayConcurrentChanges( let operationState = before; for (let operationIndex = 0; operationIndex < replayed.applied.length; operationIndex += 1) { const operation = replayed.applied[operationIndex]!; - const appliedStep = applyPatchWithSchema( + const appliedStep = applyTrustedPatchWithSchema( PERMISSIVE_JSON_SCHEMA, operationState, [operation], @@ -234,6 +269,18 @@ function applyPatchWithSchema( ); } +function applyTrustedPatchWithSchema( + schema: RebaseSchema, + state: unknown, + operations: ReadonlyArray, +) { + return applyPatchToTrustedState( + schema as Parameters[0], + state, + operations, + ); +} + function canonicalizeOperations( operations: ReadonlyArray, ): ReadonlyArray { diff --git a/labs/extensions/patch-rebase/src/selection.ts b/labs/extensions/patch-rebase/src/selection.ts new file mode 100644 index 00000000..2ea37229 --- /dev/null +++ b/labs/extensions/patch-rebase/src/selection.ts @@ -0,0 +1,119 @@ +import { + buildPointer, + tryParsePointer, + type Pointer, + type SelectionPoint, + type SelectionPointObject, +} from "@interactive-os/json-document"; + +const POINT_FIELDS = new Set(["path", "offset", "edge", "affinity"]); + +export type PreparedSelectionPoint = + | { + readonly ok: true; + readonly point: SelectionPoint; + readonly path: Pointer; + } + | { + readonly ok: false; + readonly reason: string; + readonly pointer?: Pointer; + }; + +export function prepareSelectionPoint(input: unknown): PreparedSelectionPoint { + try { + return readSelectionPoint(input); + } catch { + return { ok: false, reason: "invalid selection point" }; + } +} + +function readSelectionPoint(input: unknown): PreparedSelectionPoint { + if (typeof input === "string") { + const path = canonicalPointer(input); + return path === null + ? { + ok: false, + reason: `invalid selection pointer: ${input}`, + pointer: input, + } + : { ok: true, point: path, path }; + } + if (input === null || typeof input !== "object" || Array.isArray(input)) { + return { ok: false, reason: "invalid selection point" }; + } + const prototype = Object.getPrototypeOf(input) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + return { ok: false, reason: "invalid selection point" }; + } + + const fields = new Map(); + for (const key of Reflect.ownKeys(input)) { + if (typeof key !== "string" || !POINT_FIELDS.has(key)) { + return { ok: false, reason: "invalid selection point" }; + } + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !("value" in descriptor) + ) { + return { ok: false, reason: "invalid selection point" }; + } + fields.set(key, descriptor.value); + } + + const rawPath = fields.get("path"); + if (typeof rawPath !== "string") { + return { ok: false, reason: "invalid selection point" }; + } + const path = canonicalPointer(rawPath); + if (path === null) { + return { + ok: false, + reason: `invalid selection point path: ${rawPath}`, + pointer: rawPath, + }; + } + + const offset = fields.get("offset"); + const edge = fields.get("edge"); + const affinity = fields.get("affinity"); + if ( + (fields.has("offset") && (!Number.isSafeInteger(offset) || (offset as number) < 0)) + || (fields.has("edge") && edge !== "before" && edge !== "after") + || ( + fields.has("affinity") + && affinity !== "forward" + && affinity !== "backward" + ) + ) { + return { ok: false, reason: "invalid selection point", pointer: path }; + } + + const point: SelectionPointObject = { + path, + ...(typeof offset === "number" ? { offset } : {}), + ...(edge === "before" || edge === "after" ? { edge } : {}), + ...(affinity === "forward" || affinity === "backward" + ? { affinity } + : {}), + }; + return { ok: true, point, path }; +} + +export function selectionPointPath(point: SelectionPoint): Pointer { + return typeof point === "string" ? point : point.path; +} + +export function withSelectionPointPath( + point: SelectionPoint, + path: Pointer, +): SelectionPoint { + return typeof point === "string" ? path : { ...point, path }; +} + +function canonicalPointer(pointer: Pointer): Pointer | null { + const segments = tryParsePointer(pointer); + return segments === null ? null : buildPointer(segments); +} diff --git a/labs/extensions/patch-rebase/src/transform.ts b/labs/extensions/patch-rebase/src/transform.ts index 0a7aee90..23bd2894 100644 --- a/labs/extensions/patch-rebase/src/transform.ts +++ b/labs/extensions/patch-rebase/src/transform.ts @@ -3,12 +3,17 @@ import { trackPointer, type JSONPatchOperation, type Pointer, + type SelectionPoint, } from "@interactive-os/json-document"; import { isArrayAtEither, pointerRelation, } from "./pointer.js"; +import { + selectionPointPath, + withSelectionPointPath, +} from "./selection.js"; import type { RebaseConflict, RebaseDiagnostic, @@ -141,13 +146,14 @@ export function unsupportedConcurrentChange( } export function transformSelection( - selectionAfter: Pointer, + selectionAfter: SelectionPoint, concurrentSteps: ReadonlyArray, ): { - selectionAfter: Pointer | undefined; + selectionAfter: SelectionPoint | undefined; diagnostics: RebaseDiagnostic[]; } { - let pointer: Pointer | null = selectionAfter; + const originalPath = selectionPointPath(selectionAfter); + let pointer: Pointer | null = originalPath; for (const step of concurrentSteps) { if (pointer === null) break; pointer = trackPointerForConcurrentStep(pointer, step); @@ -158,10 +164,13 @@ export function transformSelection( diagnostics: [{ code: "selection_dropped", reason: "selection target was removed by concurrent edits", - pointer: selectionAfter, + pointer: originalPath, }], } - : { selectionAfter: pointer, diagnostics: [] }; + : { + selectionAfter: withSelectionPointPath(selectionAfter, pointer), + diagnostics: [], + }; } function overlappingConcurrentChange( diff --git a/labs/extensions/patch-rebase/src/types.ts b/labs/extensions/patch-rebase/src/types.ts index b443c7a9..6e81f842 100644 --- a/labs/extensions/patch-rebase/src/types.ts +++ b/labs/extensions/patch-rebase/src/types.ts @@ -2,6 +2,7 @@ import type { JSONPatchOperation, JSONResult, Pointer, + SelectionPoint, } from "@interactive-os/json-document"; export interface RebaseSchema { @@ -17,11 +18,14 @@ export interface RebaseSchema { | { success: false; error: { issues: ReadonlyArray } }; } -export interface RebaseChangeInput { +export interface RebaseChangeInput< + TDocument, + TSelection extends SelectionPoint = Pointer, +> { readonly base: TDocument; readonly concurrentBatches: ReadonlyArray>; readonly operations: ReadonlyArray; - readonly selectionAfter?: Pointer; + readonly selectionAfter?: TSelection; } export type RebaseDiagnosticCode = @@ -53,11 +57,13 @@ export interface RebaseConflict { readonly concurrentOperationIndex?: number; } -export type RebaseChangeResult = +export type RebaseChangeResult< + TSelection extends SelectionPoint = Pointer, +> = | { readonly ok: true; readonly operations: ReadonlyArray; - readonly selectionAfter?: Pointer; + readonly selectionAfter?: TSelection; readonly diagnostics: ReadonlyArray; } | { diff --git a/labs/extensions/patch-rebase/tests/patch-rebase.test.ts b/labs/extensions/patch-rebase/tests/patch-rebase.test.ts index e3ac458c..df9822aa 100644 --- a/labs/extensions/patch-rebase/tests/patch-rebase.test.ts +++ b/labs/extensions/patch-rebase/tests/patch-rebase.test.ts @@ -1,12 +1,16 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, expectTypeOf, test, vi } from "vitest"; import * as z from "zod"; import { createJSONDocument, type JSONPatchOperation, + type Pointer, + type SelectionPointObject, } from "@interactive-os/json-document"; import { rebaseChange, + type RebaseChangeInput, + type RebaseChangeResult, } from "../src/index.js"; const Schema = z.object({ @@ -56,6 +60,33 @@ const NullableAncestorSchema = z.object({ }); describe("@interactive-os/json-document-patch-rebase", () => { + test("preserves pointer-only result types while adding point overloads", () => { + expectTypeOf[1]>() + .toEqualTypeOf>(); + expectTypeOf>() + .toEqualTypeOf(); + const pointerPlan = rebaseChange(Schema, { + base: { title: "Draft", status: "draft" }, + concurrentBatches: [], + operations: [], + selectionAfter: "/title", + }); + const pointPlan = rebaseChange(Schema, { + base: { title: "Draft", status: "draft" }, + concurrentBatches: [], + operations: [], + selectionAfter: { path: "/title", offset: 1 }, + }); + if (pointerPlan.ok) { + expectTypeOf(pointerPlan.selectionAfter) + .toEqualTypeOf(); + } + if (pointPlan.ok) { + expectTypeOf(pointPlan.selectionAfter) + .toEqualTypeOf(); + } + }); + test("preserves disjoint concurrent and local changes", () => { const base = { title: "Draft", @@ -148,6 +179,52 @@ describe("@interactive-os/json-document-patch-rebase", () => { expect(doc.selection?.primaryPointer).toBe("/items/2/title"); }); + test("shifts a selection point path without losing caret metadata", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "Before" }, + ], + }; + + expect(rebaseChange(CollectionSchema, { + base, + concurrentBatches: [[{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }]], + operations: [{ + op: "replace", + path: "/items/1/title", + value: "Reviewed", + }], + selectionAfter: { + path: "/items/1/title", + offset: 4, + affinity: "forward", + }, + })).toEqual({ + ok: true, + operations: [ + { op: "test", path: "/items/2/title", value: "Before" }, + { op: "replace", path: "/items/2/title", value: "Reviewed" }, + ], + selectionAfter: { + path: "/items/2/title", + offset: 4, + affinity: "forward", + }, + diagnostics: [{ + code: "pointer_shifted", + reason: "local pointer shifted by concurrent array edits", + pointer: "/items/1/title", + rebasedPointer: "/items/2/title", + operationIndex: 0, + }], + }); + }); + test("shifts a local target and selection after an earlier array removal", () => { const base = { items: [ @@ -608,6 +685,26 @@ describe("@interactive-os/json-document-patch-rebase", () => { }); }); + test("returns a structured conflict for invalid selection point metadata", () => { + expect(rebaseChange(Schema, { + base: { title: "Draft", status: "draft" }, + concurrentBatches: [], + operations: [ + { op: "replace", path: "/title", value: "Local" }, + ], + selectionAfter: { path: "/title", offset: -1 }, + })).toEqual({ + ok: false, + code: "conflict", + reason: "invalid selection point", + conflicts: [{ + code: "invalid_selection", + reason: "invalid selection point", + pointer: "/title", + }], + }); + }); + test("uses an ancestor guard for a sequentially created local target", () => { expect(rebaseChange(NullableAncestorSchema, { base: { a: null, b: 0 }, diff --git a/labs/extensions/stable-id-rebase/README.md b/labs/extensions/stable-id-rebase/README.md index 3da2e920..ea6f6ba3 100644 --- a/labs/extensions/stable-id-rebase/README.md +++ b/labs/extensions/stable-id-rebase/README.md @@ -18,7 +18,11 @@ const planned = rebaseStableChange(doc, { relativePath: "/title", expected: "Draft", value: "Reviewed", - relativeSelectionAfter: "/title", + relativeSelectionAfter: { + path: "/title", + offset: 4, + affinity: "backward", + }, }); if (planned.ok) { @@ -29,12 +33,15 @@ if (planned.ok) { } ``` -`relativePath` and `relativeSelectionAfter` are RFC 6901 Pointers rooted at the -stable entity. A successful result's `selectionAfter` is instead an absolute -document Pointer. URI-fragment forms are accepted and returned as ordinary -canonical Pointers. `expected` is the field value observed when the delayed -input was authored; a different current value produces `target_changed` rather -than an overwrite. +`relativePath` is an RFC 6901 Pointer rooted at the stable entity. +`relativeSelectionAfter` accepts a relative Pointer or one SelectionPoint. A +successful result contains the corresponding absolute document Pointer or +point; only `path` is joined while `offset`, `edge`, and `affinity` are +preserved. URI-fragment paths are accepted and returned as ordinary canonical +Pointers. `expected` is the field value observed when the delayed input was +authored; a different current value produces `target_changed` rather than an +overwrite. Point objects must be plain data and any offset must be a +non-negative safe integer; core may clamp it to the final string length. ## Scope @@ -48,8 +55,8 @@ than an overwrite. guard for one atomic `doc.commit`. - Re-run the target scope's `readId` against the preview entity and reject an `readId`-changing replacement. -- Rebase one Pointer selection relative to the same stable entity, or report - that it was dropped when it will not exist after the change. +- Rebase one Pointer or SelectionPoint path relative to the same stable entity, + or report that it was dropped when it will not exist after the change. - Preflight the complete guarded plan through public `doc.canPatch` semantics. ## Non-goals @@ -64,7 +71,8 @@ than an overwrite. - No ABA detection when an identical entity disappears and reappears. - No scope-wide protection against a duplicate id introduced after planning; products needing that invariant must enforce it in their document schema. -- No text offset, multi-range selection, DOM focus, render, or input policy. +- No same-string offset transform, range/multi-range selection, DOM focus, + render, or input policy. Point offset and affinity are preserved as hints. - No mutation during planning and no plugin registration. ## Friction report diff --git a/labs/extensions/stable-id-rebase/src/plan.ts b/labs/extensions/stable-id-rebase/src/plan.ts index 512ea454..7eab0365 100644 --- a/labs/extensions/stable-id-rebase/src/plan.ts +++ b/labs/extensions/stable-id-rebase/src/plan.ts @@ -5,6 +5,8 @@ import { type JSONDocument, type JSONPatchOperation, type Pointer, + type SelectionPoint, + type SelectionPointObject, } from "@interactive-os/json-document"; import { createIdResolver, @@ -14,6 +16,11 @@ import type { StableIdReplaceInput, StableIdRebaseResult, } from "./types.js"; +import { + prepareSelectionPoint, + selectionPointPath, + withSelectionPointPath, +} from "./selection.js"; const PERMISSIVE_JSON_SCHEMA = { safeParse: (input: unknown) => ({ success: true as const, data: input }), @@ -21,8 +28,25 @@ const PERMISSIVE_JSON_SCHEMA = { export function rebaseStableChange( doc: JSONDocument, - input: StableIdReplaceInput, -): StableIdRebaseResult { + input: StableIdReplaceInput, +): StableIdRebaseResult; +export function rebaseStableChange( + doc: JSONDocument, + input: StableIdReplaceInput, +): StableIdRebaseResult; +export function rebaseStableChange( + doc: JSONDocument, + input: StableIdReplaceInput, +): StableIdRebaseResult; +// Keep the legacy Pointer signature last for Parameters/ReturnType consumers. +export function rebaseStableChange( + doc: JSONDocument, + input: StableIdReplaceInput, +): StableIdRebaseResult; +export function rebaseStableChange( + doc: JSONDocument, + input: StableIdReplaceInput, +): StableIdRebaseResult { const relativePath = canonicalPointer(input.relativePath); if (relativePath === null) { return { @@ -96,18 +120,25 @@ export function rebaseStableChange( } const changePointer = joinPointers(targetPointer, relativePath); - let selectionAfter: Pointer | undefined; + let selectionAfter: SelectionPoint | undefined; if (input.relativeSelectionAfter !== undefined) { - const relativeSelection = canonicalPointer(input.relativeSelectionAfter); - if (relativeSelection === null) { + const relativeSelection = prepareSelectionPoint( + input.relativeSelectionAfter, + ); + if (!relativeSelection.ok) { return { ok: false, code: "invalid_change", - reason: `invalid relative selection pointer: ${input.relativeSelectionAfter}`, - pointer: input.relativeSelectionAfter, + reason: relativeSelection.reason, + ...(relativeSelection.pointer === undefined + ? {} + : { pointer: relativeSelection.pointer }), }; } - selectionAfter = joinPointers(targetPointer, relativeSelection); + selectionAfter = withSelectionPointPath( + relativeSelection.point, + joinPointers(targetPointer, relativeSelection.path), + ); } const operations: JSONPatchOperation[] = [ @@ -183,11 +214,12 @@ export function rebaseStableChange( const diagnostics = []; if (selectionAfter !== undefined) { - if (!readPointerValue(applied.state, selectionAfter).ok) { + const selectionPath = selectionPointPath(selectionAfter); + if (!readPointerValue(applied.state, selectionPath).ok) { diagnostics.push({ code: "selection_dropped" as const, reason: "stable selection target will not exist after the change", - pointer: selectionAfter, + pointer: selectionPath, }); selectionAfter = undefined; } diff --git a/labs/extensions/stable-id-rebase/src/selection.ts b/labs/extensions/stable-id-rebase/src/selection.ts new file mode 100644 index 00000000..4c82e96d --- /dev/null +++ b/labs/extensions/stable-id-rebase/src/selection.ts @@ -0,0 +1,123 @@ +import { + buildPointer, + tryParsePointer, + type Pointer, + type SelectionPoint, + type SelectionPointObject, +} from "@interactive-os/json-document"; + +const POINT_FIELDS = new Set(["path", "offset", "edge", "affinity"]); + +export type PreparedSelectionPoint = + | { + readonly ok: true; + readonly point: SelectionPoint; + readonly path: Pointer; + } + | { + readonly ok: false; + readonly reason: string; + readonly pointer?: Pointer; + }; + +export function prepareSelectionPoint(input: unknown): PreparedSelectionPoint { + try { + return readSelectionPoint(input); + } catch { + return { ok: false, reason: "invalid relative selection point" }; + } +} + +function readSelectionPoint(input: unknown): PreparedSelectionPoint { + if (typeof input === "string") { + const path = canonicalPointer(input); + return path === null + ? { + ok: false, + reason: `invalid relative selection pointer: ${input}`, + pointer: input, + } + : { ok: true, point: path, path }; + } + if (input === null || typeof input !== "object" || Array.isArray(input)) { + return { ok: false, reason: "invalid relative selection point" }; + } + const prototype = Object.getPrototypeOf(input) as unknown; + if (prototype !== Object.prototype && prototype !== null) { + return { ok: false, reason: "invalid relative selection point" }; + } + + const fields = new Map(); + for (const key of Reflect.ownKeys(input)) { + if (typeof key !== "string" || !POINT_FIELDS.has(key)) { + return { ok: false, reason: "invalid relative selection point" }; + } + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if ( + descriptor === undefined + || !descriptor.enumerable + || !("value" in descriptor) + ) { + return { ok: false, reason: "invalid relative selection point" }; + } + fields.set(key, descriptor.value); + } + + const rawPath = fields.get("path"); + if (typeof rawPath !== "string") { + return { ok: false, reason: "invalid relative selection point" }; + } + const path = canonicalPointer(rawPath); + if (path === null) { + return { + ok: false, + reason: `invalid relative selection point path: ${rawPath}`, + pointer: rawPath, + }; + } + + const offset = fields.get("offset"); + const edge = fields.get("edge"); + const affinity = fields.get("affinity"); + if ( + (fields.has("offset") && (!Number.isSafeInteger(offset) || (offset as number) < 0)) + || (fields.has("edge") && edge !== "before" && edge !== "after") + || ( + fields.has("affinity") + && affinity !== "forward" + && affinity !== "backward" + ) + ) { + return { + ok: false, + reason: "invalid relative selection point", + pointer: path, + }; + } + + const point: SelectionPointObject = { + path, + ...(typeof offset === "number" ? { offset } : {}), + ...(edge === "before" || edge === "after" ? { edge } : {}), + ...(affinity === "forward" || affinity === "backward" + ? { affinity } + : {}), + }; + return { ok: true, point, path }; +} + +export function selectionPointPath(point: SelectionPoint): Pointer { + return typeof point === "string" ? point : point.path; +} + +export function withSelectionPointPath( + point: SelectionPoint, + path: Pointer, +): SelectionPoint { + return typeof point === "string" ? path : { ...point, path }; +} + +function canonicalPointer(pointer: Pointer): Pointer | null { + const segments = tryParsePointer(pointer); + return segments === null ? null : buildPointer(segments); +} diff --git a/labs/extensions/stable-id-rebase/src/types.ts b/labs/extensions/stable-id-rebase/src/types.ts index 583a8f0d..aca6b2ae 100644 --- a/labs/extensions/stable-id-rebase/src/types.ts +++ b/labs/extensions/stable-id-rebase/src/types.ts @@ -2,6 +2,7 @@ import type { JSONCapabilityResult, JSONPatchOperation, Pointer, + SelectionPoint, } from "@interactive-os/json-document"; import type { IdResolverScope, @@ -13,13 +14,15 @@ export interface StableIdTarget { readonly id: string; } -export interface StableIdReplaceInput { +export interface StableIdReplaceInput< + TSelection extends SelectionPoint = Pointer, +> { readonly scopes: ReadonlyArray; readonly target: StableIdTarget; readonly relativePath: Pointer; readonly expected: unknown; readonly value: unknown; - readonly relativeSelectionAfter?: Pointer; + readonly relativeSelectionAfter?: TSelection; } export interface StableIdRebaseDiagnostic { @@ -28,11 +31,13 @@ export interface StableIdRebaseDiagnostic { readonly pointer: Pointer; } -export type StableIdRebaseResult = +export type StableIdRebaseResult< + TSelection extends SelectionPoint = Pointer, +> = | { readonly ok: true; readonly operations: ReadonlyArray; - readonly selectionAfter?: Pointer; + readonly selectionAfter?: TSelection; readonly diagnostics: ReadonlyArray; } | { diff --git a/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts b/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts index ea3d2c7b..ea396933 100644 --- a/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts +++ b/labs/extensions/stable-id-rebase/tests/stable-id-rebase.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, expectTypeOf, test } from "vitest"; import * as z from "zod"; import { createJSONDocument, + type Pointer, + type SelectionPoint, + type SelectionPointObject, } from "@interactive-os/json-document"; import { createIdResolver, @@ -10,6 +13,7 @@ import { import { rebaseStableChange, type StableIdReplaceInput, + type StableIdRebaseResult, } from "../src/index.js"; const Card = z.object({ @@ -63,7 +67,7 @@ function createCardIds(doc: ReturnType) { function rebaseCardChange( doc: ReturnType, - input: Omit, + input: Omit, "scopes">, ) { return rebaseStableChange(doc, { scopes: CARD_SCOPES, @@ -72,6 +76,38 @@ function rebaseCardChange( } describe("@interactive-os/json-document-stable-id-rebase", () => { + test("preserves pointer-only result types while adding point overloads", () => { + expectTypeOf[1]>() + .toEqualTypeOf(); + expectTypeOf>() + .toEqualTypeOf(); + const doc = createBoard(); + const pointerPlan = rebaseStableChange(doc, { + scopes: CARD_SCOPES, + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + relativeSelectionAfter: "/title", + }); + const pointPlan = rebaseStableChange(doc, { + scopes: CARD_SCOPES, + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + relativeSelectionAfter: { path: "/title", offset: 1 }, + }); + if (pointerPlan.ok) { + expectTypeOf(pointerPlan.selectionAfter) + .toEqualTypeOf(); + } + if (pointPlan.ok) { + expectTypeOf(pointPlan.selectionAfter) + .toEqualTypeOf(); + } + }); + test("materializes a delayed field change at the stable target's current pointer", () => { const doc = createBoard(); expect(doc.move( @@ -123,6 +159,71 @@ describe("@interactive-os/json-document-stable-id-rebase", () => { expect(doc.selection?.primaryPointer).toBe("/columns/1/cards/1/title"); }); + test("joins a relative selection point path and preserves caret metadata", () => { + const doc = createBoard(); + expect(doc.move( + "/columns/0/cards/1", + "/columns/1/cards/-", + )).toMatchObject({ ok: true }); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + relativeSelectionAfter: { + path: "/title", + offset: 4, + affinity: "backward", + }, + })).toEqual({ + ok: true, + operations: [ + { + op: "test", + path: "/columns/1/cards/1", + value: { id: "b", title: "B" }, + }, + { + op: "test", + path: "/columns/1/cards/1/title", + value: "B", + }, + { + op: "replace", + path: "/columns/1/cards/1/title", + value: "Reviewed", + }, + ], + selectionAfter: { + path: "/columns/1/cards/1/title", + offset: 4, + affinity: "backward", + }, + diagnostics: [], + }); + }); + + test("returns a structured failure for invalid relative point metadata", () => { + const doc = createBoard(); + + expect(rebaseCardChange(doc, { + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + relativeSelectionAfter: { + path: "/title", + affinity: "sideways", + } as never, + })).toEqual({ + ok: false, + code: "invalid_change", + reason: "invalid relative selection point", + pointer: "/title", + }); + }); + test("reports a conflict when the stable field changed while the input was delayed", () => { const doc = createBoard(); expect(doc.replace( diff --git a/package.json b/package.json index 06bf2893..665f6b9d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "labs:extensions:verify": "node scripts/evaluate-extension-lab.mjs --verify", "labs:extensions:verify:changed": "node scripts/evaluate-extension-lab.mjs --verify --changed", "perf:core": "npm run build -w @interactive-os/json-document && node scripts/benchmark-core.mjs", + "perf:causal": "npm run build -w @interactive-os/json-document && npm run build -w @interactive-os/json-document-patch-rebase && npm run build -w @interactive-os/json-document-stable-id-rebase && npm run build -w @interactive-os/json-document-causal-patch-inbox && node scripts/benchmark-causal.mjs", "release:check": "npm run verify && npm run standard:check && npm run perf:core && npm run pack:library", "standard:check": "node scripts/evaluate-standardization.mjs && npm test -w @interactive-os/json-document -- standard-conformance", "verify": "npm run typecheck && npm test && npm run build && npm run smoke:package && npm run build && npm run extensions:verify && npm run labs:extensions:verify && npm run docs:evaluate && npm run playground:typecheck && npm run playground:test && npm run playground:build && npm run site:evaluate && npm run site:verify:pages && npm run browser:test", diff --git a/scripts/benchmark-causal.mjs b/scripts/benchmark-causal.mjs new file mode 100644 index 00000000..0aec633f --- /dev/null +++ b/scripts/benchmark-causal.mjs @@ -0,0 +1,180 @@ +import { existsSync } from "node:fs"; +import { performance } from "node:perf_hooks"; +import * as z from "zod"; + +const coreEntry = new URL( + "../packages/json-document/dist/application/document/index.js", + import.meta.url, +); +const causalEntry = new URL( + "../labs/extensions/causal-patch-inbox/dist/index.js", + import.meta.url, +); + +if (!existsSync(coreEntry) || !existsSync(causalEntry)) { + console.error("Missing package dist. Run `npm run perf:causal` from the repository root."); + process.exit(1); +} + +const { createJSONDocument } = await import(coreEntry.href); +const { createCausalPatchInbox } = await import(causalEntry.href); + +const rounds = envNumber("PERF_CAUSAL_ROUNDS", 10); +const warmups = envNumber("PERF_CAUSAL_WARMUPS", 1); +const journalSizes = envList("PERF_CAUSAL_JOURNAL", [1_000, 10_000]); +const suffixSize = envNumber("PERF_CAUSAL_SUFFIX", 10); +const noteCount = (rounds + warmups) * 3 + 8; +const Schema = z.object({ + title: z.string(), + notes: z.record(z.string(), z.string()), +}); + +console.log("json-document causal journal benchmark"); +console.log( + `journal=${journalSizes.join(",")} suffix=${suffixSize} rounds=${rounds} warmups=${warmups}`, +); + +for (const journalSize of journalSizes) { + const initial = { + title: "title-initial", + notes: Object.fromEntries( + Array.from({ length: noteCount }, (_, index) => [ + `note-${index}`, + "initial", + ]), + ), + }; + const doc = createJSONDocument(Schema, initial); + let hostSequence = 0; + const inbox = createCausalPatchInbox(doc, { + positionalSchema: Schema, + host: { + ownsPublication: ({ metadata }) => { + const match = metadata?.origin === "benchmark-host" + ? /^benchmark-host:(\d+)$/.exec(metadata.mergeKey ?? "") + : null; + return match === null ? false : { sequence: Number(match[1]) }; + }, + runReady: ({ apply }) => { + apply(); + return { ok: true }; + }, + }, + }); + + for (let index = 0; index < journalSize; index += 1) { + hostReplaceTitle(doc, `title-${index}`, hostSequence += 1); + } + + let nextId = 0; + let nextNote = 0; + const suffixZero = measure(rounds, () => { + const base = doc.value; + const baseRevision = inbox.current().journalRevision; + const note = `note-${nextNote++}`; + return () => inbox.ingest({ + id: `revision-zero-${nextId++}`, + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision, + operations: [{ op: "replace", path: `/notes/${note}`, value: "zero" }], + }, + }); + }); + + const suffix = measure(rounds, () => { + const base = doc.value; + const baseRevision = inbox.current().journalRevision; + for (let index = 0; index < suffixSize; index += 1) { + hostReplaceTitle( + doc, + `suffix-${nextId}-${index}`, + hostSequence += 1, + ); + } + const note = `note-${nextNote++}`; + return () => inbox.ingest({ + id: `revision-suffix-${nextId++}`, + dependsOn: [], + intent: { + kind: "positional", + base, + baseRevision, + operations: [{ op: "replace", path: `/notes/${note}`, value: "suffix" }], + }, + }); + }); + + const legacy = measure(rounds, () => { + const note = `note-${nextNote++}`; + return () => inbox.ingest({ + id: `legacy-${nextId++}`, + dependsOn: [], + intent: { + kind: "positional", + base: initial, + operations: [{ op: "replace", path: `/notes/${note}`, value: "legacy" }], + }, + }); + }); + + console.log(`\njournal=${journalSize}`); + printSamples("baseRevision suffix=0", suffixZero); + printSamples(`baseRevision suffix=${suffixSize}`, suffix); + printSamples("legacy full journal", legacy); +} + +function hostReplaceTitle(doc, value, sequence) { + const result = doc.commit( + [{ op: "replace", path: "/title", value }], + { origin: "benchmark-host", mergeKey: `benchmark-host:${sequence}` }, + ); + if (!result.ok) throw new Error(result.reason ?? "host commit failed"); +} + +function measure(count, prepare) { + const samples = []; + for (let index = 0; index < count + warmups; index += 1) { + const run = prepare(index); + const started = performance.now(); + const result = run(); + const elapsed = performance.now() - started; + if (!result.ok) { + throw new Error(`${result.code}: ${result.reason}`); + } + if (index >= warmups) samples.push(elapsed); + } + return samples.sort((left, right) => left - right); +} + +function printSamples(label, samples) { + const p50 = percentile(samples, 0.5); + const p90 = percentile(samples, 0.9); + console.log(`${label.padEnd(28)} p50=${p50.toFixed(3)}ms p90=${p90.toFixed(3)}ms`); +} + +function percentile(samples, ratio) { + const index = Math.min( + samples.length - 1, + Math.max(0, Math.ceil(samples.length * ratio) - 1), + ); + return samples[index]; +} + +function envNumber(name, fallback) { + const value = Number(process.env[name]); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +function envList(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) return fallback; + const values = raw + .split(",") + .map((value) => Number(value.trim())) + .filter((value) => Number.isFinite(value) && value > 0) + .map(Math.floor); + return values.length === 0 ? fallback : values; +}