From 3574cc41cbef2f567435f6a8695b135488292eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B2=E1=84=8B=E1=85=AD=E1=86=BC=E1=84=90?= =?UTF-8?q?=E1=85=A2?= Date: Sun, 12 Jul 2026 20:06:51 +0900 Subject: [PATCH] feat(labs): materialize causal intents when ready --- apps/site/src/generated/repo-catalog.ts | 18 +- docs/changelog.md | 15 +- docs/generated/extensions-catalog.md | 2 +- docs/generated/repo-catalog.json | 18 +- labs/extensions/causal-patch-inbox/README.md | 162 ++++-- .../causal-patch-inbox/package.json | 4 + .../causal-patch-inbox/src/create.ts | 323 ++++++++++- .../causal-patch-inbox/src/envelope.ts | 233 +++++++- .../causal-patch-inbox/src/graph.ts | 14 +- .../causal-patch-inbox/src/index.ts | 10 + .../causal-patch-inbox/src/types.ts | 120 +++- .../tests/causal-patch-inbox.test.ts | 521 ++++++++++++++++++ package-lock.json | 65 ++- package.json | 5 +- scripts/evaluate-extension-lab.mjs | 186 ++++++- scripts/generate-docs.mjs | 4 +- 16 files changed, 1569 insertions(+), 131 deletions(-) diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index ecdb76ca..cf3f8433 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -1844,25 +1844,35 @@ export const repoCatalog = { "version": "0.1.0", "description": "Lab causal JSON Patch inbox for json-document documents.", "license": "MIT", - "summary": "Lab module for admitting dependency-declared JSON Patch envelopes, holding\nout-of-order work, and applying each ready envelope to one `JSONDocument`.", + "summary": "Lab module for admitting dependency-declared JSON Patch or authored edit\nenvelopes, holding out-of-order work, and committing each envelope when it\nbecomes causally ready on one `JSONDocument`.", "guidance": { - "useFor": "queue and deduplicate dependency-declared envelopes, applying each as one core JSON Patch batch", - "notFor": "CRDT/OT convergence, transport, persistence, ready-time rebase, or conflict resolution" + "useFor": "queue dependency-declared envelopes and materialize delayed positional or stable-id edits when causally ready", + "notFor": "CRDT/OT convergence, transport, persistence, or automatic conflict resolution" }, "publicExports": [ + "CausalAuthoredIntent", + "CausalEnvelope", + "CausalIntentEnvelope", + "CausalMaterializationDiagnostic", + "CausalMaterializationPolicy", "CausalPatchEnvelope", + "CausalPatchFailure", "CausalPatchInbox", + "CausalPatchInboxOptions", "CausalPatchInboxSnapshot", "CausalPatchIngestError", "CausalPatchIngestErrorCode", "CausalPatchIngestOk", "CausalPatchIngestResult", + "CausalPositionalIntent", + "CausalStableIdReplaceIntent", + "FailedCausalMaterialization", "FailedCausalPatch", "FaultedCausalPatch", "QueuedCausalPatch", "createCausalPatchInbox" ], - "publicExportCount": 11, + "publicExportCount": 21, "keywords": [ "@interactive-os/json-document", "causal", diff --git a/docs/changelog.md b/docs/changelog.md index 4e711eec..aae3a07c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,7 +4,20 @@ All notable changes to this project are documented here. ## Unreleased -No unreleased changes. +### Added + +- Added lab-only ready-time materialization to the causal patch inbox for + delayed positional edits and stable-id field replacements. Authored intents + are admitted and deduplicated immutably, then materialized only after their + declared dependencies are applied. +- Added structured policy-specific materialization failures and successful + rebase diagnostics without changing direct-operation result shapes. + +### Changed + +- Changed the causal patch inbox to commit each ready direct or materialized + envelope once through `doc.commit`, including a rebased `selectionAfter`, and + to advance its applied ledger and causal frontier only after success. ## 1.1.0-rc.0 - 2026-07-11 diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 144d3ba7..1465e4ca 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 | 11 | queue and deduplicate dependency-declared envelopes, applying each as one core JSON Patch batch | CRDT/OT convergence, transport, persistence, ready-time rebase, or conflict resolution | Lab module for admitting dependency-declared JSON Patch envelopes, holding out-of-order work, and applying each ready envelope to one `JSONDocument`. | +| `@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-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 74004735..b0cb56ff 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -1843,25 +1843,35 @@ "version": "0.1.0", "description": "Lab causal JSON Patch inbox for json-document documents.", "license": "MIT", - "summary": "Lab module for admitting dependency-declared JSON Patch envelopes, holding\nout-of-order work, and applying each ready envelope to one `JSONDocument`.", + "summary": "Lab module for admitting dependency-declared JSON Patch or authored edit\nenvelopes, holding out-of-order work, and committing each envelope when it\nbecomes causally ready on one `JSONDocument`.", "guidance": { - "useFor": "queue and deduplicate dependency-declared envelopes, applying each as one core JSON Patch batch", - "notFor": "CRDT/OT convergence, transport, persistence, ready-time rebase, or conflict resolution" + "useFor": "queue dependency-declared envelopes and materialize delayed positional or stable-id edits when causally ready", + "notFor": "CRDT/OT convergence, transport, persistence, or automatic conflict resolution" }, "publicExports": [ + "CausalAuthoredIntent", + "CausalEnvelope", + "CausalIntentEnvelope", + "CausalMaterializationDiagnostic", + "CausalMaterializationPolicy", "CausalPatchEnvelope", + "CausalPatchFailure", "CausalPatchInbox", + "CausalPatchInboxOptions", "CausalPatchInboxSnapshot", "CausalPatchIngestError", "CausalPatchIngestErrorCode", "CausalPatchIngestOk", "CausalPatchIngestResult", + "CausalPositionalIntent", + "CausalStableIdReplaceIntent", + "FailedCausalMaterialization", "FailedCausalPatch", "FaultedCausalPatch", "QueuedCausalPatch", "createCausalPatchInbox" ], - "publicExportCount": 11, + "publicExportCount": 21, "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 7a8afc11..2ae1f843 100644 --- a/labs/extensions/causal-patch-inbox/README.md +++ b/labs/extensions/causal-patch-inbox/README.md @@ -1,7 +1,8 @@ # @interactive-os/json-document-causal-patch-inbox -Lab module for admitting dependency-declared JSON Patch envelopes, holding -out-of-order work, and applying each ready envelope to one `JSONDocument`. +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`. ```ts const inbox = createCausalPatchInbox(doc); @@ -31,36 +32,86 @@ if (result.ok) { } ``` -The envelope operations are already-materialized JSON Patch. They can be raw -protocol output or guarded operations produced by another planner. This module -owns causal admission and scheduling, not target recovery or merge policy. +Direct `operations` envelopes remain supported with the same runtime result +shape. A closed alternative envelope carries one `intent` instead: -An `ingest` batch is preflighted before any new envelope is admitted. An exact -duplicate is a no-op; the same id with different dependencies or operations is -`duplicate_mismatch`. Dependency order has set semantics, while operation and -array order remain significant. A known graph cycle rejects the closing batch. +```ts +const positional = createCausalPatchInbox(doc, { + positionalSchema: DocumentSchema, +}); + +positional.ingest({ + id: "local-title", + dependsOn: ["observed-parent"], + intent: { + kind: "positional", + base: authoredDocument, + operations: [ + { op: "replace", path: "/items/1/title", value: "Reviewed" }, + ], + selectionAfter: "/items/1/title", + }, +}); + +const stable = createCausalPatchInbox(doc, { + stableIdScopes: cardScopes, +}); + +stable.ingest({ + id: "stable-title", + dependsOn: ["observed-parent"], + intent: { + kind: "stable-id-replace", + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "Draft", + value: "Reviewed", + relativeSelectionAfter: "/title", + }, +}); +``` + +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. + +An `ingest` batch is preflighted and defensively copied before any new envelope +is admitted. An exact duplicate compares the authored direct operations or +intent, never a later materialized patch. The same id with a different authored +payload is `duplicate_mismatch`. A missing policy configuration rejects the +whole batch as `policy_not_configured` before admission. Dependency order has +set semantics, while operation and array order remain significant. A known +graph cycle rejects the closing batch. Ready envelopes currently known to one drain are applied by locale-independent -id order. Each envelope is one atomic `doc.patch`; causal frontier state moves -only after that patch succeeds. A batch is not one transaction: earlier -envelopes remain applied when a later envelope fails. Patch failure globally -blocks this first tracer, preserving the failed envelope and its descendants -for inspection. Recovery, skipping, and conflict resolution need a later -policy rather than an implicit retry. +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 +inspection without an implicit retry. `current().frontier` is the maximal set in the declared dependency DAG. It is not an actor clock or version vector. `queued[].missing` contains dependencies 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 -failure, while `faulted` preserves an unexpected thrown execution failure that -has no `JSONResult`. Full queue snapshots are produced only by `current()`. -Successful `ingest` results report only this call's `applied`, `pending`, and -`duplicates` ids. +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 +`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. ## Scope -- Defensively validate and copy one envelope or a batch before admission. +- Defensively validate and copy one closed direct-operation or authored-intent + envelope union before admission. - Queue envelopes whose declared dependencies have not been applied. - Distinguish exact duplicate delivery from an id/payload mismatch. - Reject empty ids, invalid dependency ids, self/duplicate dependencies, @@ -69,14 +120,21 @@ Successful `ingest` results report only this call's `applied`, `pending`, and - Choose the smallest currently ready id with a private min-heap. - Wake dependents through a reverse dependency index rather than rescanning the whole queue after every successful patch. -- Apply one envelope through one public `doc.patch` call and advance causal - state only after success. +- Materialize positional edits from their authored base plus applied + non-ancestor batches when they become ready. +- 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 + its materialized `selectionAfter`, and advance causal state only after + success. - Permit an empty projection patch to advance causal state. - Stop with structured `patch_failed` state for non-strict documents; preserve the core `JSONDocumentError` throw policy for strict documents. -- Move to terminal `faulted` state when patch execution throws without changing - the projection and without supplying a structured `JSONDocumentError`. -- Reject reentrant ingestion as `busy` while a document patch is publishing. +- 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. - Detect direct document mutation and move the inbox to `diverged` rather than inferring a causal envelope from an observed patch. - Dispose the document subscription explicitly. @@ -84,8 +142,9 @@ Successful `ingest` results report only this call's `applied`, `pending`, and ## Performance - Envelope validation, defensive copying, and exact comparison are linear in - envelope payload size; dependency normalization also sorts the dependency - ids. + envelope payload size. A positional base is part of that payload, so its + admission and deduplication cost grow with the base document. Dependency + normalization also sorts the dependency ids. - Known-id lookup is average `O(1)`. Ready selection is `O(log V)`, and each reverse dependency edge is released once. - Cycle preflight first consults the reverse dependency index. A new id with no @@ -96,10 +155,15 @@ Successful `ingest` results report only this call's `applied`, `pending`, and - `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. -- The module does not clone, diff, or stringify the whole document. Core patch - application and schema/refinement validation can still dominate runtime. -- Exact dedup retains accepted envelope payloads, and the causal ledger has no - compaction in this lab. Memory therefore grows with accepted work. +- 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. +- 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. ## Non-goals @@ -109,10 +173,14 @@ Successful `ingest` results report only this call's `applied`, `pending`, and 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 yet. -- No ready-time positional rebase, stable-id lookup, target repair, automatic - retry, conflict resolution, or independent-branch skipping after failure. -- No selection, presence, DOM input, render scheduling, or focus policy. +- No generic protocol or materialization Adapter interface. The two concrete + policies are deliberately a closed union. +- 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 support for multiple causal owners mutating the same document projection. - No change to the `JSONDocument` public interface or core state. @@ -144,8 +212,22 @@ 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. -Already-materialized guarded operations can become stale while waiting for -dependencies. The inbox then blocks without rewriting their intent. The next -useful experiment is ready-time materialization with at least two concrete -policies, such as positional patch rebase and stable-id target recovery. That -evidence should come before exposing a generic Adapter seam or choosing OT/CRDT. +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. + +The concrete rebase modules are in-process dependencies. Their results are +preserved as structured failures rather than hidden behind a generic Adapter +seam. This supplies evidence for two real policies while keeping transport, +clocks, CRDT/OT, async execution, and universal intent languages out of scope. diff --git a/labs/extensions/causal-patch-inbox/package.json b/labs/extensions/causal-patch-inbox/package.json index bf19d950..fb5b2f23 100644 --- a/labs/extensions/causal-patch-inbox/package.json +++ b/labs/extensions/causal-patch-inbox/package.json @@ -37,6 +37,10 @@ "typecheck": "tsc -p tsconfig.test.json --noEmit", "verify": "npm run typecheck && npm test && npm run build" }, + "dependencies": { + "@interactive-os/json-document-patch-rebase": "0.1.0", + "@interactive-os/json-document-stable-id-rebase": "0.1.0" + }, "peerDependencies": { "@interactive-os/json-document": "^1.0.0" }, diff --git a/labs/extensions/causal-patch-inbox/src/create.ts b/labs/extensions/causal-patch-inbox/src/create.ts index 8fb97157..ee60b449 100644 --- a/labs/extensions/causal-patch-inbox/src/create.ts +++ b/labs/extensions/causal-patch-inbox/src/create.ts @@ -1,13 +1,26 @@ import { JSONDocumentError, type JSONDocument, + type JSONPatchOperation, + type Pointer, } from "@interactive-os/json-document"; +import { + rebaseChange, + type RebaseDiagnostic, +} from "@interactive-os/json-document-patch-rebase"; +import { + rebaseStableChange, + type StableIdRebaseDiagnostic, +} from "@interactive-os/json-document-stable-id-rebase"; import type { + CausalMaterializationDiagnostic, CausalPatchInbox, + CausalPatchFailure, + CausalPatchInboxOptions, CausalPatchInboxSnapshot, CausalPatchIngestResult, - FailedCausalPatch, + FailedCausalMaterialization, FaultedCausalPatch, } from "./types.js"; import { @@ -24,24 +37,47 @@ import { compareIds, } from "./ready.js"; -interface PendingEnvelope { - readonly envelope: StoredEnvelope; +interface PendingEnvelope { + readonly envelope: StoredEnvelope; readonly missing: Set; } +interface AppliedEnvelope { + readonly id: string; + readonly operations: ReadonlyArray; +} + +type ReadyMaterialization = + | { + readonly ok: true; + readonly operations: ReadonlyArray; + readonly selectionAfter?: Pointer; + readonly diagnostics: ReadonlyArray; + } + | { + readonly ok: false; + readonly failure: FailedCausalMaterialization; + }; + let nextInboxInstance = 0; export function createCausalPatchInbox( doc: JSONDocument, -): CausalPatchInbox { - const known = new Map(); - const queued = new Map(); + options: CausalPatchInboxOptions = {}, +): CausalPatchInbox { + const positionalSchema = options.positionalSchema; + const stableIdScopes = options.stableIdScopes === undefined + ? undefined + : options.stableIdScopes.map((scope) => ({ ...scope })); + const known = new Map>(); + const queued = new Map>(); const dependents = new Map>(); const readyIds = new ReadyIdHeap(); const applied = new Set(); + const appliedEnvelopes: AppliedEnvelope[] = []; const frontier = new Set(); const origin = `causal-patch-inbox:${nextInboxInstance += 1}`; - let failure: FailedCausalPatch | undefined; + let failure: CausalPatchFailure | undefined; let fault: FaultedCausalPatch | undefined; let diverged = false; let disposed = false; @@ -93,11 +129,11 @@ export function createCausalPatchInbox( }); const ingestWhileBusy = ( - input: Parameters[0], + input: Parameters["ingest"]>[0], ): CausalPatchIngestResult => { - const envelopes: StoredEnvelope[] = []; + const envelopes: StoredEnvelope[] = []; for (const candidate of Array.isArray(input) ? input : [input]) { - const prepared = prepareEnvelope(candidate); + const prepared = prepareEnvelope(candidate); if (!prepared.ok) { return { ok: false, @@ -126,8 +162,26 @@ export function createCausalPatchInbox( }; } + for (const envelope of envelopes) { + if (!("intent" in envelope)) continue; + const policy = envelope.intent.kind; + const configured = policy === "positional" + ? positionalSchema !== undefined + : stableIdScopes !== undefined; + if (!configured) { + return { + ok: false, + code: "policy_not_configured", + reason: `causal materialization policy is not configured: ${policy}`, + id: envelope.id, + policy, + applied: [], + }; + } + } + const duplicates = new Set(); - const additions = new Map(); + const additions = new Map>(); for (const envelope of envelopes) { const existing = known.get(envelope.id) ?? additions.get(envelope.id); if (existing !== undefined) { @@ -180,6 +234,7 @@ export function createCausalPatchInbox( } const integrated: string[] = []; + const diagnostics: CausalMaterializationDiagnostic[] = []; while (true) { const readyId = readyIds.pop(); if (readyId === undefined) break; @@ -187,14 +242,72 @@ export function createCausalPatchInbox( 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) { + 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), + }; + } + const beforeProjection = doc.value; - let result: ReturnType; + let result: ReturnType; publicationCount = 0; applyingId = ready.id; try { - result = doc.patch(ready.operations, { + result = doc.commit(materialized.operations, { origin, mergeKey: ready.id, + ...(materialized.selectionAfter === undefined + ? {} + : { selectionAfter: materialized.selectionAfter }), }); } catch (error) { if (doc.value !== beforeProjection) diverged = true; @@ -223,7 +336,7 @@ export function createCausalPatchInbox( ok: false, code: "disposed", reason: "causal inbox was disposed while applying an envelope", - applied: integrated, + ...ingestProgress(integrated, diagnostics), }; } if (diverged) { @@ -231,7 +344,7 @@ export function createCausalPatchInbox( ok: false, code: "projection_diverged", reason: "document projection changed while applying a causal envelope", - applied: integrated, + ...ingestProgress(integrated, diagnostics), }; } failure = { id: ready.id, result: copyJson(result) }; @@ -241,10 +354,17 @@ export function createCausalPatchInbox( reason: result.reason ?? `causal patch failed: ${ready.id}`, id: ready.id, result: copyJson(result), - applied: integrated, + ...ingestProgress(integrated, diagnostics), }; } + if (positionalSchema !== undefined) { + appliedEnvelopes.push({ + id: ready.id, + operations: copyJson(doc.lastPatch), + }); + } + diagnostics.push(...materialized.diagnostics); queued.delete(ready.id); applied.add(ready.id); for (const dependency of ready.dependsOn) frontier.delete(dependency); @@ -265,7 +385,7 @@ export function createCausalPatchInbox( ok: false, code: "disposed", reason: "causal inbox was disposed while applying an envelope", - applied: integrated, + ...ingestProgress(integrated, diagnostics), }; } if (diverged) { @@ -273,7 +393,7 @@ export function createCausalPatchInbox( ok: false, code: "projection_diverged", reason: "document projection changed while applying a causal envelope", - applied: integrated, + ...ingestProgress(integrated, diagnostics), }; } } @@ -281,13 +401,13 @@ export function createCausalPatchInbox( const inputIds = new Set(envelopes.map(({ id }) => id)); return { ok: true, - applied: integrated, + ...ingestProgress(integrated, diagnostics), pending: [...inputIds].filter((id) => queued.has(id)).sort(compareIds), duplicates: [...duplicates], }; }; - const ingest: CausalPatchInbox["ingest"] = (input) => { + const ingest: CausalPatchInbox["ingest"] = (input) => { if (disposed) { return { ok: false, @@ -313,6 +433,16 @@ export function createCausalPatchInbox( }; } if (failure !== undefined) { + if ("materialization" in failure) { + const failedMaterialization = copyMaterializationFailure(failure); + return { + ok: false, + code: "blocked", + reason: `causal inbox is blocked by failed materialization: ${failure.id}`, + ...failedMaterialization, + applied: [], + }; + } return { ok: false, code: "blocked", @@ -328,6 +458,7 @@ export function createCausalPatchInbox( code: "faulted", reason: `causal inbox faulted while applying envelope: ${fault.id}`, id: fault.id, + ...(fault.phase === undefined ? {} : { phase: fault.phase }), applied: [], }; } @@ -352,9 +483,159 @@ export function createCausalPatchInbox( }; } -function copyFailure(failure: FailedCausalPatch): FailedCausalPatch { +function materializeReadyEnvelope( + ready: StoredEnvelope, + doc: JSONDocument, + positionalSchema: CausalPatchInboxOptions["positionalSchema"], + stableIdScopes: CausalPatchInboxOptions["stableIdScopes"], + known: ReadonlyMap>, + appliedEnvelopes: ReadonlyArray, +): ReadyMaterialization { + if ("operations" in ready) { + return { + ok: true, + operations: ready.operations, + diagnostics: [], + }; + } + + if (ready.intent.kind === "positional") { + if (positionalSchema === undefined) { + throw new Error("positional materialization policy was not configured"); + } + const causalPast = collectCausalPast(ready.dependsOn, known); + const planned = rebaseChange(positionalSchema, { + base: ready.intent.base, + concurrentBatches: appliedEnvelopes + .filter(({ id }) => !causalPast.has(id)) + .map(({ operations }) => operations), + operations: ready.intent.operations, + ...(ready.intent.selectionAfter === undefined + ? {} + : { selectionAfter: ready.intent.selectionAfter }), + }); + if (!planned.ok) { + return { + ok: false, + failure: { + id: ready.id, + policy: "positional", + materialization: copyJson(planned), + }, + }; + } + return { + ok: true, + operations: planned.operations, + ...(planned.selectionAfter === undefined + ? {} + : { selectionAfter: planned.selectionAfter }), + diagnostics: planned.diagnostics.map((diagnostic) => { + return positionalDiagnostic(ready.id, diagnostic); + }), + }; + } + + if (stableIdScopes === undefined) { + throw new Error("stable-id materialization policy was not configured"); + } + const planned = rebaseStableChange(doc, { + scopes: stableIdScopes, + target: ready.intent.target, + relativePath: ready.intent.relativePath, + expected: ready.intent.expected, + value: ready.intent.value, + ...(ready.intent.relativeSelectionAfter === undefined + ? {} + : { relativeSelectionAfter: ready.intent.relativeSelectionAfter }), + }); + if (!planned.ok) { + return { + ok: false, + failure: { + id: ready.id, + policy: "stable-id-replace", + materialization: copyJson(planned), + }, + }; + } + return { + ok: true, + operations: planned.operations, + ...(planned.selectionAfter === undefined + ? {} + : { selectionAfter: planned.selectionAfter }), + diagnostics: planned.diagnostics.map((diagnostic) => { + return stableIdDiagnostic(ready.id, diagnostic); + }), + }; +} + +function collectCausalPast( + dependencies: ReadonlyArray, + known: ReadonlyMap>, +): Set { + const past = new Set(); + const pending = [...dependencies]; + while (pending.length > 0) { + const id = pending.pop()!; + if (past.has(id)) continue; + past.add(id); + const dependency = known.get(id); + if (dependency !== undefined) pending.push(...dependency.dependsOn); + } + return past; +} + +function positionalDiagnostic( + id: string, + diagnostic: RebaseDiagnostic, +): CausalMaterializationDiagnostic { + return { id, policy: "positional", ...copyJson(diagnostic) }; +} + +function stableIdDiagnostic( + id: string, + diagnostic: StableIdRebaseDiagnostic, +): CausalMaterializationDiagnostic { + return { id, policy: "stable-id-replace", ...copyJson(diagnostic) }; +} + +function ingestProgress( + applied: ReadonlyArray, + diagnostics: ReadonlyArray, +): { + readonly applied: ReadonlyArray; + readonly diagnostics?: ReadonlyArray; +} { + return diagnostics.length === 0 + ? { applied } + : { applied, diagnostics }; +} + +function copyFailure(failure: CausalPatchFailure): CausalPatchFailure { + if ("materialization" in failure) { + return copyMaterializationFailure(failure); + } return { id: failure.id, result: copyJson(failure.result), }; } + +function copyMaterializationFailure( + failure: FailedCausalMaterialization, +): FailedCausalMaterialization { + if (failure.policy === "positional") { + return { + id: failure.id, + policy: "positional", + materialization: copyJson(failure.materialization), + }; + } + return { + id: failure.id, + policy: "stable-id-replace", + materialization: copyJson(failure.materialization), + }; +} diff --git a/labs/extensions/causal-patch-inbox/src/envelope.ts b/labs/extensions/causal-patch-inbox/src/envelope.ts index 390a2d1f..c0a8a2a3 100644 --- a/labs/extensions/causal-patch-inbox/src/envelope.ts +++ b/labs/extensions/causal-patch-inbox/src/envelope.ts @@ -1,24 +1,59 @@ import type { JSONPatchOperation, } from "@interactive-os/json-document"; +import type { + CausalAuthoredIntent, + CausalPositionalIntent, + CausalStableIdReplaceIntent, +} from "./types.js"; import { compareIds, } from "./ready.js"; -export interface StoredEnvelope { +interface StoredEnvelopeFields { readonly id: string; readonly dependsOn: ReadonlyArray; +} + +export interface StoredPatchEnvelope extends StoredEnvelopeFields { readonly operations: ReadonlyArray; + readonly intent?: never; +} + +export interface StoredIntentEnvelope extends StoredEnvelopeFields { + readonly intent: CausalAuthoredIntent; + readonly operations?: never; } -export type PreparedEnvelope = - | { readonly ok: true; readonly envelope: StoredEnvelope } +export type StoredEnvelope = + | StoredPatchEnvelope + | StoredIntentEnvelope; + +export type PreparedEnvelope = + | { readonly ok: true; readonly envelope: StoredEnvelope } | { readonly ok: false; readonly reason: string; readonly id?: string }; -const ENVELOPE_FIELDS = new Set(["id", "dependsOn", "operations"]); +const ENVELOPE_FIELDS = new Set(["id", "dependsOn", "operations", "intent"]); +const POSITIONAL_INTENT_FIELDS = new Set([ + "kind", + "base", + "operations", + "selectionAfter", +]); +const STABLE_ID_INTENT_FIELDS = new Set([ + "kind", + "target", + "relativePath", + "expected", + "value", + "relativeSelectionAfter", +]); +const STABLE_ID_TARGET_FIELDS = new Set(["scope", "id"]); -export function prepareEnvelope(input: unknown): PreparedEnvelope { +export function prepareEnvelope( + input: unknown, +): PreparedEnvelope { const fields = readEnvelopeFields(input); if (!fields.ok) return fields; @@ -28,10 +63,10 @@ export function prepareEnvelope(input: unknown): PreparedEnvelope { } let dependsOn: unknown; - let operations: unknown; + let payload: unknown; try { dependsOn = copyJson(fields.dependsOn); - operations = copyJson(fields.operations); + payload = copyJson(fields.payload); } catch { return { ok: false, @@ -72,30 +107,144 @@ export function prepareEnvelope(input: unknown): PreparedEnvelope { } dependencies.add(dependency); } - if (!Array.isArray(operations)) { + const envelopeFields = { + id, + dependsOn: [...dependencies].sort(compareIds), + }; + if (fields.kind === "operations") { + if (!Array.isArray(payload)) { + return { + ok: false, + reason: `causal envelope operations must be an array: ${id}`, + id, + }; + } + return { + ok: true, + envelope: { + ...envelopeFields, + operations: payload as ReadonlyArray, + }, + }; + } + + const intent = prepareIntent(payload, id); + return intent.ok + ? { + ok: true, + envelope: { + ...envelopeFields, + intent: intent.intent, + }, + } + : intent; +} + +export function envelopesEqual( + left: StoredEnvelope, + right: StoredEnvelope, +): boolean { + if (!jsonEqual(left.dependsOn, right.dependsOn)) return false; + if ("operations" in left) { + return "operations" in right + && jsonEqual(left.operations, right.operations); + } + return "intent" in right && jsonEqual(left.intent, right.intent); +} + +type PreparedIntent = + | { readonly ok: true; readonly intent: CausalAuthoredIntent } + | { readonly ok: false; readonly reason: string; readonly id: string }; + +function prepareIntent( + input: unknown, + id: string, +): PreparedIntent { + if (!isPlainRecord(input)) { return { ok: false, - reason: `causal envelope operations must be an array: ${id}`, + reason: `causal envelope intent must be an object: ${id}`, id, }; } - + const kind = input.kind; + if (kind === "positional") return preparePositionalIntent(input, id); + if (kind === "stable-id-replace") return prepareStableIdIntent(input, id); return { - ok: true, - envelope: { + ok: false, + reason: `causal envelope intent kind is unsupported: ${id}`, + id, + }; +} + +function preparePositionalIntent( + input: Readonly>, + id: string, +): PreparedIntent { + if ( + !hasOnlyFields(input, POSITIONAL_INTENT_FIELDS) + || !hasOwn(input, "base") + || !Array.isArray(input.operations) + || ( + hasOwn(input, "selectionAfter") + && typeof input.selectionAfter !== "string" + ) + ) { + return { + ok: false, + reason: `causal positional intent is invalid: ${id}`, id, - dependsOn: [...dependencies].sort(compareIds), - operations: operations as ReadonlyArray, - }, + }; + } + const intent: CausalPositionalIntent = { + kind: "positional", + base: input.base as TDocument, + operations: input.operations as ReadonlyArray, + ...(typeof input.selectionAfter === "string" + ? { selectionAfter: input.selectionAfter } + : {}), }; + return { ok: true, intent }; } -export function envelopesEqual( - left: StoredEnvelope, - right: StoredEnvelope, -): boolean { - return jsonEqual(left.dependsOn, right.dependsOn) - && jsonEqual(left.operations, right.operations); +function prepareStableIdIntent( + input: Readonly>, + id: string, +): PreparedIntent { + const target = input.target; + if ( + !hasOnlyFields(input, STABLE_ID_INTENT_FIELDS) + || !hasOwn(input, "expected") + || !hasOwn(input, "value") + || typeof input.relativePath !== "string" + || ( + hasOwn(input, "relativeSelectionAfter") + && typeof input.relativeSelectionAfter !== "string" + ) + || !isPlainRecord(target) + || !hasOnlyFields(target, STABLE_ID_TARGET_FIELDS) + || typeof target.scope !== "string" + || target.scope.trim().length === 0 + || typeof target.id !== "string" + || target.id.trim().length === 0 + ) { + return { + ok: false, + reason: `causal stable-id intent is invalid: ${id}`, + id, + }; + } + const intent: CausalStableIdReplaceIntent = { + kind: "stable-id-replace", + target: { scope: target.scope, id: target.id }, + relativePath: input.relativePath, + expected: input.expected, + value: input.value, + ...(typeof input.relativeSelectionAfter === "string" + ? { relativeSelectionAfter: input.relativeSelectionAfter } + : {}), + }; + return { ok: true, intent }; } export function copyJson(value: T): T { @@ -107,7 +256,8 @@ type EnvelopeFields = readonly ok: true; readonly id: unknown; readonly dependsOn: unknown; - readonly operations: unknown; + readonly kind: "operations" | "intent"; + readonly payload: unknown; } | { readonly ok: false; readonly reason: string }; @@ -139,17 +289,50 @@ function readEnvelopeFields(input: unknown): EnvelopeFields { const id = descriptors.get("id"); const dependsOn = descriptors.get("dependsOn"); const operations = descriptors.get("operations"); - if (id === undefined || dependsOn === undefined || operations === undefined) { - return { ok: false, reason: "causal envelope requires id, dependsOn, and operations" }; + const intent = descriptors.get("intent"); + if ( + id === undefined + || dependsOn === undefined + || (operations === undefined) === (intent === undefined) + ) { + return { + ok: false, + reason: "causal envelope requires id, dependsOn, and exactly one of operations or intent", + }; } return { ok: true, id: id.value, dependsOn: dependsOn.value, - operations: operations.value, + kind: operations === undefined ? "intent" : "operations", + payload: (operations ?? intent)!.value, }; } +function isPlainRecord( + value: unknown, +): value is Readonly> { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value) as unknown; + return prototype === Object.prototype || prototype === null; +} + +function hasOnlyFields( + value: Readonly>, + supported: ReadonlySet, +): boolean { + return Object.keys(value).every((key) => supported.has(key)); +} + +function hasOwn( + value: Readonly>, + key: string, +): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + function cloneJsonValue(value: unknown, ancestors: Set): unknown { if ( value === null diff --git a/labs/extensions/causal-patch-inbox/src/graph.ts b/labs/extensions/causal-patch-inbox/src/graph.ts index 7c181a62..8d5e22f4 100644 --- a/labs/extensions/causal-patch-inbox/src/graph.ts +++ b/labs/extensions/causal-patch-inbox/src/graph.ts @@ -7,14 +7,14 @@ export interface IntroducedDependencyCycle { readonly cycle: ReadonlyArray; } -export function findIntroducedDependencyCycle( - additions: ReadonlyMap, - existing: (id: string) => StoredEnvelope | undefined, +export function findIntroducedDependencyCycle( + additions: ReadonlyMap>, + existing: (id: string) => StoredEnvelope | undefined, hasExistingDependent: (id: string) => boolean, ): IntroducedDependencyCycle | null { - const admitted = new Map(); + const admitted = new Map>(); const admittedDependencyTargets = new Set(); - const lookup = (id: string): StoredEnvelope | undefined => { + const lookup = (id: string): StoredEnvelope | undefined => { return admitted.get(id) ?? existing(id); }; @@ -41,10 +41,10 @@ export function findIntroducedDependencyCycle( return null; } -function findDependencyPath( +function findDependencyPath( start: string, target: string, - lookup: (id: string) => StoredEnvelope | undefined, + lookup: (id: string) => StoredEnvelope | undefined, ): string[] | null { if (start === target) return [start]; diff --git a/labs/extensions/causal-patch-inbox/src/index.ts b/labs/extensions/causal-patch-inbox/src/index.ts index 937a45d5..f666303a 100644 --- a/labs/extensions/causal-patch-inbox/src/index.ts +++ b/labs/extensions/causal-patch-inbox/src/index.ts @@ -1,12 +1,22 @@ export { createCausalPatchInbox } from "./create.js"; export type { + CausalAuthoredIntent, + CausalEnvelope, + CausalIntentEnvelope, + CausalMaterializationDiagnostic, + CausalMaterializationPolicy, CausalPatchEnvelope, + CausalPatchFailure, CausalPatchInbox, + CausalPatchInboxOptions, CausalPatchInboxSnapshot, CausalPatchIngestError, CausalPatchIngestErrorCode, CausalPatchIngestOk, CausalPatchIngestResult, + CausalPositionalIntent, + CausalStableIdReplaceIntent, + FailedCausalMaterialization, FailedCausalPatch, FaultedCausalPatch, QueuedCausalPatch, diff --git a/labs/extensions/causal-patch-inbox/src/types.ts b/labs/extensions/causal-patch-inbox/src/types.ts index 3e92659d..5e0270f3 100644 --- a/labs/extensions/causal-patch-inbox/src/types.ts +++ b/labs/extensions/causal-patch-inbox/src/types.ts @@ -1,12 +1,65 @@ import type { JSONPatchOperation, JSONResult, + Pointer, } from "@interactive-os/json-document"; +import type { + RebaseChangeResult, + RebaseDiagnostic, + RebaseSchema, +} from "@interactive-os/json-document-patch-rebase"; +import type { + StableIdRebaseDiagnostic, + StableIdRebaseResult, + StableIdReplaceInput, + StableIdTarget, +} from "@interactive-os/json-document-stable-id-rebase"; export interface CausalPatchEnvelope { readonly id: string; readonly dependsOn: ReadonlyArray; readonly operations: ReadonlyArray; + readonly intent?: never; +} + +export interface CausalPositionalIntent { + readonly kind: "positional"; + readonly base: TDocument; + readonly operations: ReadonlyArray; + readonly selectionAfter?: Pointer; +} + +export interface CausalStableIdReplaceIntent { + readonly kind: "stable-id-replace"; + readonly target: StableIdTarget; + readonly relativePath: Pointer; + readonly expected: unknown; + readonly value: unknown; + readonly relativeSelectionAfter?: Pointer; +} + +export type CausalAuthoredIntent = + | CausalPositionalIntent + | CausalStableIdReplaceIntent; + +export interface CausalIntentEnvelope { + readonly id: string; + readonly dependsOn: ReadonlyArray; + readonly intent: CausalAuthoredIntent; + readonly operations?: never; +} + +export type CausalEnvelope = + | CausalPatchEnvelope + | CausalIntentEnvelope; + +export type CausalMaterializationPolicy = + | "positional" + | "stable-id-replace"; + +export interface CausalPatchInboxOptions { + readonly positionalSchema?: RebaseSchema; + readonly stableIdScopes?: StableIdReplaceInput["scopes"]; } export interface QueuedCausalPatch { @@ -19,22 +72,53 @@ export interface FailedCausalPatch { readonly result: Extract; } +export type FailedCausalMaterialization = + | { + readonly id: string; + readonly policy: "positional"; + readonly materialization: Exclude; + } + | { + readonly id: string; + readonly policy: "stable-id-replace"; + readonly materialization: Exclude; + }; + +export type CausalPatchFailure = + | FailedCausalPatch + | FailedCausalMaterialization; + export interface FaultedCausalPatch { readonly id: string; readonly reason: string; + readonly phase?: "materialization"; +} + +export type CausalMaterializationDiagnostic = + | ({ + readonly id: string; + readonly policy: "positional"; + } & RebaseDiagnostic) + | ({ + readonly id: string; + readonly policy: "stable-id-replace"; + } & StableIdRebaseDiagnostic); + +export interface CausalPatchIngestProgress { + readonly applied: ReadonlyArray; + readonly diagnostics?: ReadonlyArray; } export interface CausalPatchInboxSnapshot { readonly status: "active" | "blocked" | "diverged" | "faulted" | "disposed"; readonly frontier: ReadonlyArray; readonly queued: ReadonlyArray; - readonly failure?: FailedCausalPatch; + readonly failure?: CausalPatchFailure; readonly fault?: FaultedCausalPatch; } -export interface CausalPatchIngestOk { +export interface CausalPatchIngestOk extends CausalPatchIngestProgress { readonly ok: true; - readonly applied: ReadonlyArray; readonly pending: ReadonlyArray; readonly duplicates: ReadonlyArray; } @@ -43,6 +127,8 @@ export type CausalPatchIngestErrorCode = | "invalid_envelope" | "duplicate_mismatch" | "dependency_cycle" + | "policy_not_configured" + | "materialization_failed" | "patch_failed" | "blocked" | "projection_diverged" @@ -50,34 +136,42 @@ export type CausalPatchIngestErrorCode = | "busy" | "disposed"; -export type CausalPatchIngestError = +export type CausalPatchIngestError = CausalPatchIngestProgress & ( | { readonly ok: false; readonly code: "invalid_envelope"; readonly reason: string; - readonly applied: ReadonlyArray; readonly id?: string; } | { readonly ok: false; readonly code: "duplicate_mismatch"; readonly reason: string; - readonly applied: ReadonlyArray; readonly id: string; } | { readonly ok: false; readonly code: "dependency_cycle"; readonly reason: string; - readonly applied: ReadonlyArray; readonly id: string; readonly cycle: ReadonlyArray; } + | { + readonly ok: false; + readonly code: "policy_not_configured"; + readonly reason: string; + readonly id: string; + readonly policy: CausalMaterializationPolicy; + } + | ({ + readonly ok: false; + readonly code: "materialization_failed" | "blocked"; + readonly reason: string; + } & FailedCausalMaterialization) | { readonly ok: false; readonly code: "patch_failed" | "blocked"; readonly reason: string; - readonly applied: ReadonlyArray; readonly id: string; readonly result: Extract; } @@ -85,23 +179,23 @@ export type CausalPatchIngestError = readonly ok: false; readonly code: "projection_diverged" | "busy" | "disposed"; readonly reason: string; - readonly applied: ReadonlyArray; } | { readonly ok: false; readonly code: "faulted"; readonly reason: string; - readonly applied: ReadonlyArray; readonly id: string; - }; + readonly phase?: "materialization"; + } +); export type CausalPatchIngestResult = | CausalPatchIngestOk | CausalPatchIngestError; -export interface CausalPatchInbox { +export interface CausalPatchInbox { ingest( - input: CausalPatchEnvelope | ReadonlyArray, + input: CausalEnvelope | ReadonlyArray>, ): CausalPatchIngestResult; current(): CausalPatchInboxSnapshot; dispose(): void; 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 b47ef1aa..18aabbe5 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 @@ -14,6 +14,29 @@ const LogSchema = z.object({ log: z.array(z.string()), }); +const CollectionSchema = z.object({ + items: z.array(z.object({ + id: z.string(), + title: z.string(), + })), +}); + +const CardSchema = z.object({ + id: z.string(), + title: z.string(), +}); +const BoardSchema = z.object({ + columns: z.array(z.object({ + id: z.string(), + cards: z.array(CardSchema), + })), +}); +const CardScopes = [{ + scope: "card", + query: "$.columns[*].cards[*]", + readId: (value: unknown) => CardSchema.safeParse(value).data?.id, +}]; + describe("@interactive-os/json-document-causal-patch-inbox", () => { test("queues a child until its parent is applied, then drains in causal order", () => { const doc = createJSONDocument(LogSchema, { log: [] }); @@ -844,4 +867,502 @@ describe("@interactive-os/json-document-causal-patch-inbox", () => { expect(doc.value.log).toEqual(["parent"]); unsubscribe(); }); + + test("materializes a positional intent when it becomes causally ready", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }; + const doc = createJSONDocument(CollectionSchema, base, { + history: 10, + selection: { mode: "single", initial: ["/items/1/title"] }, + }); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + }); + + expect(inbox.ingest({ + id: "z-local", + dependsOn: ["b-parent"], + intent: { + kind: "positional", + base, + operations: [{ + op: "replace", + path: "/items/1/title", + value: "B2", + }], + selectionAfter: "/items/1/title", + }, + })).toMatchObject({ + ok: true, + applied: [], + pending: ["z-local"], + }); + + const result = inbox.ingest([{ + id: "a-concurrent", + dependsOn: [], + operations: [{ + op: "add", + path: "/items/0", + value: { id: "x", title: "X" }, + }], + }, { + id: "b-parent", + dependsOn: [], + operations: [], + }]); + + expect(result).toMatchObject({ + ok: true, + applied: ["a-concurrent", "b-parent", "z-local"], + diagnostics: [{ + id: "z-local", + 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: "B2" }, + ]); + expect(doc.selection?.primaryPointer).toBe("/items/2/title"); + expect(doc.history.undoDepth).toBe(2); + expect(inbox.current()).toMatchObject({ + status: "active", + frontier: ["a-concurrent", "z-local"], + queued: [], + }); + }); + + test("blocks a positional intent rather than overwriting a concurrent field", () => { + const base = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }; + const doc = createJSONDocument(CollectionSchema, base, { history: 10 }); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + }); + + expect(inbox.ingest({ + id: "z-local", + dependsOn: ["b-parent"], + intent: { + kind: "positional", + base, + operations: [{ + op: "replace", + path: "/items/1/title", + value: "Local", + }], + }, + })).toMatchObject({ ok: true, pending: ["z-local"] }); + + const result = inbox.ingest([{ + id: "a-concurrent", + dependsOn: [], + operations: [{ + op: "replace", + path: "/items/1/title", + value: "Remote", + }], + }, { + id: "b-parent", + dependsOn: [], + operations: [], + }]); + + expect(result).toMatchObject({ + ok: false, + code: "materialization_failed", + id: "z-local", + policy: "positional", + applied: ["a-concurrent", "b-parent"], + materialization: { + ok: false, + code: "conflict", + conflicts: [{ code: "target_changed" }], + }, + }); + expect(doc.value.items[1]?.title).toBe("Remote"); + expect(doc.history.undoDepth).toBe(1); + expect(inbox.current()).toMatchObject({ + status: "blocked", + frontier: ["a-concurrent", "b-parent"], + queued: [{ id: "z-local", missing: [] }], + failure: { + id: "z-local", + policy: "positional", + materialization: { + ok: false, + code: "conflict", + }, + }, + }); + expect(inbox.ingest({ + id: "later", + dependsOn: [], + operations: [], + })).toMatchObject({ + ok: false, + code: "blocked", + id: "z-local", + policy: "positional", + applied: [], + }); + }); + + test("resolves a stable-id intent against the projection when it becomes ready", () => { + const doc = createJSONDocument(BoardSchema, { + columns: [{ + id: "todo", + cards: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }, { + id: "done", + cards: [], + }], + }, { + history: 10, + selection: { + mode: "single", + initial: ["/columns/0/cards/1/title"], + }, + }); + const inbox = createCausalPatchInbox(doc, { + stableIdScopes: CardScopes, + }); + + expect(inbox.ingest({ + id: "z-local", + dependsOn: ["b-parent"], + intent: { + kind: "stable-id-replace", + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + relativeSelectionAfter: "/title", + }, + })).toMatchObject({ ok: true, pending: ["z-local"] }); + + const result = inbox.ingest([{ + id: "a-concurrent", + dependsOn: [], + operations: [{ + op: "move", + from: "/columns/0/cards/1", + path: "/columns/1/cards/-", + }], + }, { + id: "b-parent", + dependsOn: [], + operations: [], + }]); + + expect(result).toEqual({ + ok: true, + applied: ["a-concurrent", "b-parent", "z-local"], + pending: [], + duplicates: [], + }); + expect(doc.value.columns[1]?.cards).toEqual([{ + id: "b", + title: "Reviewed", + }]); + expect(doc.selection?.primaryPointer).toBe("/columns/1/cards/0/title"); + expect(doc.history.undoDepth).toBe(2); + }); + + test("blocks a stable-id intent when its authored field changed", () => { + const doc = createJSONDocument(BoardSchema, { + columns: [{ + id: "todo", + cards: [{ id: "b", title: "B" }], + }], + }, { history: 10 }); + const inbox = createCausalPatchInbox(doc, { + stableIdScopes: CardScopes, + }); + + expect(inbox.ingest({ + id: "z-local", + dependsOn: ["b-parent"], + intent: { + kind: "stable-id-replace", + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Local", + }, + })).toMatchObject({ ok: true, pending: ["z-local"] }); + + const result = inbox.ingest([{ + id: "a-concurrent", + dependsOn: [], + operations: [{ + op: "replace", + path: "/columns/0/cards/0/title", + value: "Remote", + }], + }, { + id: "b-parent", + dependsOn: [], + operations: [], + }]); + + expect(result).toMatchObject({ + ok: false, + code: "materialization_failed", + id: "z-local", + policy: "stable-id-replace", + applied: ["a-concurrent", "b-parent"], + materialization: { + ok: false, + code: "target_changed", + pointer: "/columns/0/cards/0/title", + capability: { ok: false, code: "test_failed" }, + }, + }); + expect(doc.value.columns[0]?.cards[0]?.title).toBe("Remote"); + expect(doc.history.undoDepth).toBe(1); + expect(inbox.current()).toMatchObject({ + status: "blocked", + frontier: ["a-concurrent", "b-parent"], + queued: [{ id: "z-local", missing: [] }], + failure: { + id: "z-local", + policy: "stable-id-replace", + materialization: { ok: false, code: "target_changed" }, + }, + }); + }); + + test("deduplicates the immutable authored intent and owns its data", () => { + const originalBase = { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }; + const doc = createJSONDocument(CollectionSchema, originalBase); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: CollectionSchema, + }); + const operation = { + op: "replace" as const, + path: "/items/1/title" as const, + value: "B2", + }; + + expect(inbox.ingest({ + id: "local", + dependsOn: ["parent"], + intent: { + kind: "positional", + base: originalBase, + operations: [operation], + }, + })).toMatchObject({ ok: true, pending: ["local"], duplicates: [] }); + + originalBase.items[1]!.title = "tampered-base"; + operation.value = "tampered-operation"; + + expect(inbox.ingest({ + id: "local", + dependsOn: ["parent"], + intent: { + kind: "positional", + base: { + items: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }, + operations: [{ + op: "replace", + path: "/items/1/title", + value: "B2", + }], + }, + })).toMatchObject({ + ok: true, + pending: ["local"], + duplicates: ["local"], + }); + + expect(inbox.ingest({ + id: "parent", + dependsOn: [], + operations: [], + })).toMatchObject({ ok: true, applied: ["parent", "local"] }); + expect(doc.value.items[1]?.title).toBe("B2"); + }); + + test("rejects an unconfigured intent policy before admitting its batch", () => { + const base = { + items: [{ id: "a", title: "A" }], + }; + const doc = createJSONDocument(CollectionSchema, base); + const inbox = createCausalPatchInbox(doc); + + const result = inbox.ingest([{ + id: "direct", + dependsOn: [], + operations: [{ + op: "replace", + path: "/items/0/title", + value: "must-not-apply", + }], + }, { + id: "local", + dependsOn: [], + intent: { + kind: "positional", + base, + operations: [{ + op: "replace", + path: "/items/0/title", + value: "Local", + }], + }, + }]); + + expect(result).toEqual({ + ok: false, + code: "policy_not_configured", + reason: "causal materialization policy is not configured: positional", + id: "local", + policy: "positional", + applied: [], + }); + expect(doc.value.items[0]?.title).toBe("A"); + expect(inbox.current()).toEqual({ + status: "active", + frontier: [], + queued: [], + }); + }); + + test("drains a mixed direct-intent-direct dependency chain", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: LogSchema, + }); + + expect(inbox.ingest({ + id: "c-child", + dependsOn: ["b-intent"], + operations: [{ op: "add", path: "/log/-", value: "child" }], + })).toMatchObject({ ok: true, pending: ["c-child"] }); + expect(inbox.ingest({ + id: "b-intent", + dependsOn: ["a-root"], + intent: { + kind: "positional", + base: { log: ["root"] }, + operations: [{ op: "add", path: "/log/-", value: "intent" }], + }, + })).toMatchObject({ ok: true, pending: ["b-intent"] }); + + expect(inbox.ingest({ + id: "a-root", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "root" }], + })).toEqual({ + ok: true, + applied: ["a-root", "b-intent", "c-child"], + pending: [], + duplicates: [], + }); + expect(doc.value.log).toEqual(["root", "intent", "child"]); + expect(inbox.current()).toEqual({ + status: "active", + frontier: ["c-child"], + queued: [], + }); + }); + + test("rejects an envelope that mixes direct operations with an intent", () => { + const doc = createJSONDocument(LogSchema, { log: [] }); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: LogSchema, + }); + + expect(inbox.ingest({ + id: "mixed", + dependsOn: [], + operations: [], + intent: { + kind: "positional", + base: { log: [] }, + operations: [], + }, + } as never)).toMatchObject({ + ok: false, + code: "invalid_envelope", + applied: [], + }); + expect(inbox.current()).toEqual({ + status: "active", + frontier: [], + queued: [], + }); + }); + + test("faults with the materialization phase when a planner dependency throws", () => { + const base = { items: [{ id: "a", title: "A" }] }; + const doc = createJSONDocument(CollectionSchema, base); + const inbox = createCausalPatchInbox(doc, { + positionalSchema: { + safeParse() { + throw new Error("materialization schema failed"); + }, + }, + }); + const intent = { + id: "local", + dependsOn: [], + intent: { + kind: "positional" as const, + base, + operations: [{ + op: "replace" as const, + path: "/items/0/title" as const, + value: "Local", + }], + }, + }; + + expect(() => inbox.ingest(intent)).toThrow("materialization schema failed"); + expect(inbox.current()).toEqual({ + status: "faulted", + frontier: [], + queued: [{ id: "local", missing: [] }], + fault: { + id: "local", + reason: "materialization schema failed", + phase: "materialization", + }, + }); + expect(inbox.ingest(intent)).toMatchObject({ + ok: false, + code: "faulted", + id: "local", + phase: "materialization", + applied: [], + }); + expect(doc.value).toEqual(base); + }); }); diff --git a/package-lock.json b/package-lock.json index 21149a4e..2026c1b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,10 @@ "name": "@interactive-os/json-document-monorepo", "workspaces": [ "packages/*", - "apps/*" + "apps/*", + "labs/extensions/causal-patch-inbox", + "labs/extensions/patch-rebase", + "labs/extensions/stable-id-rebase" ], "devDependencies": { "@playwright/test": "^1.60.0" @@ -896,6 +899,54 @@ "vitest": "^4.1.7" } }, + "labs/extensions/causal-patch-inbox": { + "name": "@interactive-os/json-document-causal-patch-inbox", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@interactive-os/json-document-patch-rebase": "0.1.0", + "@interactive-os/json-document-stable-id-rebase": "0.1.0" + }, + "devDependencies": { + "@interactive-os/json-document": "*", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0" + } + }, + "labs/extensions/patch-rebase": { + "name": "@interactive-os/json-document-patch-rebase", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document": "*", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0" + } + }, + "labs/extensions/stable-id-rebase": { + "name": "@interactive-os/json-document-stable-id-rebase", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@interactive-os/json-document": "*", + "@interactive-os/json-document-id-resolver": "*", + "typescript": "^5.0.0", + "vitest": "^4.1.7", + "zod": "^4.0.0" + }, + "peerDependencies": { + "@interactive-os/json-document": "^1.0.0", + "@interactive-os/json-document-id-resolver": "^0.1.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1847,6 +1898,10 @@ "resolved": "packages/bulk-edit", "link": true }, + "node_modules/@interactive-os/json-document-causal-patch-inbox": { + "resolved": "labs/extensions/causal-patch-inbox", + "link": true + }, "node_modules/@interactive-os/json-document-clipboard-web": { "resolved": "packages/clipboard-web", "link": true @@ -1919,6 +1974,10 @@ "resolved": "packages/patch-preview", "link": true }, + "node_modules/@interactive-os/json-document-patch-rebase": { + "resolved": "labs/extensions/patch-rebase", + "link": true + }, "node_modules/@interactive-os/json-document-persist-web": { "resolved": "packages/persist-web", "link": true @@ -1967,6 +2026,10 @@ "resolved": "packages/snippets", "link": true }, + "node_modules/@interactive-os/json-document-stable-id-rebase": { + "resolved": "labs/extensions/stable-id-rebase", + "link": true + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index 2af8d6e3..06bf2893 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,10 @@ "type": "module", "workspaces": [ "packages/*", - "apps/*" + "apps/*", + "labs/extensions/causal-patch-inbox", + "labs/extensions/patch-rebase", + "labs/extensions/stable-id-rebase" ], "scripts": { "dev": "npm run playground:dev", diff --git a/scripts/evaluate-extension-lab.mjs b/scripts/evaluate-extension-lab.mjs index 1810d0b0..b9ecf7e3 100644 --- a/scripts/evaluate-extension-lab.mjs +++ b/scripts/evaluate-extension-lab.mjs @@ -68,6 +68,18 @@ function files(dir) { }); } +function importedSpecifiers(source) { + const specifiers = new Set(); + for (const pattern of [ + /\bfrom\s+["']([^"']+)["']/g, + /\bimport\s+["']([^"']+)["']/g, + /\b(?:import|require)\s*\(\s*["']([^"']+)["']/g, + ]) { + for (const match of source.matchAll(pattern)) specifiers.add(match[1]); + } + return specifiers; +} + function formatCommand(command, args, cwd = root) { const relativeCwd = cwd === root ? "." : cwd.slice(root.length).replace(/^\//, ""); return `$ ${command} ${args.join(" ")} # cwd=${relativeCwd}`; @@ -167,7 +179,7 @@ function labDirFromPath(path) { return `${labRoot}/${parts[2]}`; } -function verificationSelection(labs) { +function verificationSelection(labs, labDependencies) { if (!verifyChanged) { return { dirs: new Set(labs), reason: "full verification requested" }; } @@ -188,7 +200,61 @@ function verificationSelection(labs) { selectedLabs.add(labDir); } } - return { dirs: selectedLabs, reason }; + const reverseDependencies = new Map(labs.map((dir) => [dir, new Set()])); + for (const [dir, dependencies] of labDependencies) { + for (const dependency of dependencies) { + reverseDependencies.get(dependency)?.add(dir); + } + } + + const withDependents = transitiveLabClosure(selectedLabs, reverseDependencies); + const withDependencies = transitiveLabClosure(withDependents, labDependencies); + return { dirs: withDependencies, reason }; +} + +function transitiveLabClosure(initial, edges) { + const selected = new Set(initial); + const pending = [...initial]; + while (pending.length > 0) { + const dir = pending.pop(); + for (const related of edges.get(dir) ?? []) { + if (selected.has(related)) continue; + selected.add(related); + pending.push(related); + } + } + return selected; +} + +function findLabDependencyCycle(labs, labDependencies) { + const complete = new Set(); + const visiting = new Set(); + const path = []; + + function visit(dir) { + if (complete.has(dir)) return null; + if (visiting.has(dir)) { + const start = path.indexOf(dir); + return [...path.slice(start), dir]; + } + + visiting.add(dir); + path.push(dir); + for (const dependency of labDependencies.get(dir) ?? []) { + const cycle = visit(dependency); + if (cycle !== null) return cycle; + } + path.pop(); + visiting.delete(dir); + complete.add(dir); + return null; + } + + for (const dir of labs) { + const cycle = visit(dir); + if (cycle !== null) return cycle; + } + return null; } async function verifyPackage({ dir, name }) { @@ -201,9 +267,9 @@ async function verifyPackage({ dir, name }) { console.log(`[ok] ${name}`); } -async function verifyPackages(targets) { +async function verifyPackages(targets, levelLabel) { const concurrency = Math.min(verifyConcurrency(), targets.length); - console.log(`extension lab verify concurrency: ${concurrency}`); + console.log(`extension lab verify ${levelLabel} concurrency: ${concurrency}`); let cursor = 0; const failures = []; @@ -223,6 +289,49 @@ async function verifyPackages(targets) { for (const failure of failures) { fail(failure.message); } + return failures.length === 0; +} + +function verificationLevels(targets) { + const targetByDir = new Map(targets.map((target) => [target.dir, target])); + const unresolved = new Map(); + const dependents = new Map(targets.map((target) => [target.dir, new Set()])); + + for (const target of targets) { + const dependencies = target.labDependencies.filter((dir) => targetByDir.has(dir)); + unresolved.set(target.dir, dependencies.length); + for (const dependency of dependencies) { + dependents.get(dependency)?.add(target.dir); + } + } + + let ready = targets + .filter((target) => unresolved.get(target.dir) === 0) + .sort((left, right) => left.dir.localeCompare(right.dir)); + const levels = []; + let scheduled = 0; + + while (ready.length > 0) { + const level = ready; + levels.push(level); + scheduled += level.length; + const next = []; + for (const target of level) { + for (const dependentDir of dependents.get(target.dir) ?? []) { + const remaining = (unresolved.get(dependentDir) ?? 0) - 1; + unresolved.set(dependentDir, remaining); + if (remaining === 0) next.push(targetByDir.get(dependentDir)); + } + } + ready = next + .filter((target) => target !== undefined) + .sort((left, right) => left.dir.localeCompare(right.dir)); + } + + if (scheduled !== targets.length) { + throw new Error("extension lab dependency cycle prevented verification scheduling."); + } + return levels; } const labs = packages(); @@ -230,11 +339,42 @@ if (labs.length === 0) { fail("extension lab: no lab packages found."); } const officialPackageNames = new Set(officialPackages().map((pkg) => pkg.name)); -const selectedVerification = verify ? verificationSelection(labs) : { dirs: new Set(), reason: "check only" }; +const labPackages = labs.map((dir) => ({ + dir, + pkg: JSON.parse(read(`${dir}/package.json`)), +})); +const labPackageByDir = new Map(labPackages.map((target) => [target.dir, target])); +const labPackageByName = new Map(); +for (const target of labPackages) { + if (typeof target.pkg.name !== "string") continue; + const existing = labPackageByName.get(target.pkg.name); + if (existing !== undefined) { + fail(`${target.pkg.name}: lab package name is duplicated by ${existing.dir} and ${target.dir}.`); + continue; + } + labPackageByName.set(target.pkg.name, target); +} + +const labDependencies = new Map(labPackages.map(({ dir, pkg }) => [ + dir, + new Set( + Object.keys(pkg.dependencies ?? {}) + .map((name) => labPackageByName.get(name)?.dir) + .filter((dependencyDir) => dependencyDir !== undefined), + ), +])); +const dependencyCycle = findLabDependencyCycle(labs, labDependencies); +if (dependencyCycle !== null) { + const names = dependencyCycle.map((dir) => labPackageByDir.get(dir)?.pkg.name ?? dir); + fail(`extension lab dependency cycle: ${names.join(" -> ")}`); +} + +const selectedVerification = verify + ? verificationSelection(labs, labDependencies) + : { dirs: new Set(), reason: "check only" }; const verificationTargets = []; -for (const dir of labs) { - const pkg = JSON.parse(read(`${dir}/package.json`)); +for (const { dir, pkg } of labPackages) { const label = pkg.name ?? dir; const folderName = dir.slice(dir.lastIndexOf("/") + 1); const packageName = typeof pkg.name === "string" && pkg.name.startsWith("@interactive-os/json-document-") @@ -262,6 +402,16 @@ for (const dir of labs) { if (pkg.dependencies?.["@interactive-os/json-document"]) { fail(`${label}: json-document must not be a runtime dependency.`); } + for (const dependencyName of Object.keys(pkg.dependencies ?? {})) { + const dependency = labPackageByName.get(dependencyName); + if (dependency === undefined) continue; + if (dependency.pkg.private !== true) { + fail(`${label}: lab runtime dependency must target another private lab (${dependencyName}).`); + } + if (pkg.dependencies[dependencyName] !== dependency.pkg.version) { + fail(`${label}: lab runtime dependency must use the target lab version ${dependency.pkg.version} (${dependencyName}).`); + } + } if (pkg.sideEffects !== false) { fail(`${label}: sideEffects must be false.`); } @@ -281,9 +431,17 @@ for (const dir of labs) { for (const sourcePath of files(`${dir}/src`)) { const source = read(sourcePath); - for (const match of source.matchAll(/\bfrom\s+["']([^"']+)["']/g)) { - const specifier = match[1]; + for (const specifier of importedSpecifiers(source)) { if (specifier === "@interactive-os/json-document") continue; + const labDependency = labPackageByName.get(specifier); + if (labDependency !== undefined) { + if (labDependency.pkg.private !== true) { + fail(`${sourcePath}: lab source imports must target another private lab (${specifier}).`); + } else if (pkg.dependencies?.[specifier] !== labDependency.pkg.version) { + fail(`${sourcePath}: lab imports must be declared in dependencies at target version ${labDependency.pkg.version} (${specifier}).`); + } + continue; + } if (officialPackageNames.has(specifier)) { if (pkg.peerDependencies?.[specifier] === undefined) { fail(`${sourcePath}: official extension imports must be declared as peer dependencies (${specifier}).`); @@ -291,7 +449,7 @@ for (const dir of labs) { continue; } if (specifier.startsWith(".")) continue; - fail(`${sourcePath}: lab source may import only json-document or declared official extensions (${specifier}).`); + fail(`${sourcePath}: lab source may import only json-document, declared official extensions, or declared private lab dependencies (${specifier}).`); } if (/src\/application|src\/domain|src\/foundation|\.\.\/json-document\/src/.test(source)) { fail(`${sourcePath}: lab source must not import json-document internals.`); @@ -307,6 +465,7 @@ for (const dir of labs) { name: pkg.name, officialDependencies: Object.keys(pkg.peerDependencies ?? {}) .filter((dependency) => officialPackageNames.has(dependency)), + labDependencies: [...(labDependencies.get(dir) ?? [])], }); } } @@ -316,7 +475,12 @@ if (verify && process.exitCode !== 1) { if (verificationTargets.length > 0) { buildCorePackage(); buildOfficialDependencies(verificationTargets); - await verifyPackages(verificationTargets); + const levels = verificationLevels(verificationTargets); + console.log(`extension lab dependency levels: ${levels.length}`); + for (let index = 0; index < levels.length; index += 1) { + const levelOk = await verifyPackages(levels[index], `level ${index + 1}/${levels.length}`); + if (!levelOk) break; + } } } diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs index dbdf22f4..79f293b4 100644 --- a/scripts/generate-docs.mjs +++ b/scripts/generate-docs.mjs @@ -107,8 +107,8 @@ const extensionGuidance = { notFor: "id policy, structural patches, CRDT/OT protocols, transport, or merge UI", }, "@interactive-os/json-document-causal-patch-inbox": { - useFor: "queue and deduplicate dependency-declared envelopes, applying each as one core JSON Patch batch", - notFor: "CRDT/OT convergence, transport, persistence, ready-time rebase, or conflict resolution", + useFor: "queue dependency-declared envelopes and materialize delayed positional or stable-id edits when causally ready", + notFor: "CRDT/OT convergence, transport, persistence, or automatic conflict resolution", }, "@interactive-os/json-document-drag-drop": { useFor: "turn drag/drop intent into move or paste operations",