diff --git a/apps/site/src/generated/repo-catalog.ts b/apps/site/src/generated/repo-catalog.ts index e5c286d4..e06763a1 100644 --- a/apps/site/src/generated/repo-catalog.ts +++ b/apps/site/src/generated/repo-catalog.ts @@ -362,6 +362,7 @@ export const repoCatalog = { "IdResolver", "IdResolverDiagnostic", "IdResolverDiagnosticCode", + "IdResolverDocument", "IdResolverEntry", "IdResolverOptions", "IdResolverScope", @@ -370,7 +371,7 @@ export const repoCatalog = { "ResolveIdResult", "createIdResolver" ], - "publicExportCount": 10, + "publicExportCount": 11, "keywords": [ "@interactive-os/json-document", "editing", @@ -1271,6 +1272,7 @@ export const repoCatalog = { "IdResolver", "IdResolverDiagnostic", "IdResolverDiagnosticCode", + "IdResolverDocument", "IdResolverEntry", "IdResolverOptions", "IdResolverScope", @@ -1279,7 +1281,7 @@ export const repoCatalog = { "ResolveIdResult", "createIdResolver" ], - "publicExportCount": 10, + "publicExportCount": 11, "keywords": [ "@interactive-os/json-document", "editing", @@ -1860,6 +1862,7 @@ export const repoCatalog = { "CausalIntentEnvelope", "CausalMaterializationDiagnostic", "CausalMaterializationPolicy", + "CausalPatchDocument", "CausalPatchEnvelope", "CausalPatchFailure", "CausalPatchHost", @@ -1870,6 +1873,7 @@ export const repoCatalog = { "CausalPatchIngestErrorCode", "CausalPatchIngestOk", "CausalPatchIngestResult", + "CausalPatchPublicationDocument", "CausalPositionalIntent", "CausalPositionalMaterializationFailure", "CausalStableIdReplaceIntent", @@ -1879,7 +1883,7 @@ export const repoCatalog = { "QueuedCausalPatch", "createCausalPatchInbox" ], - "publicExportCount": 28, + "publicExportCount": 30, "keywords": [ "@interactive-os/json-document", "causal", @@ -2982,12 +2986,13 @@ export const repoCatalog = { }, "publicExports": [ "StableIdRebaseDiagnostic", + "StableIdRebaseDocument", "StableIdRebaseResult", "StableIdReplaceInput", "StableIdTarget", "rebaseStableChange" ], - "publicExportCount": 5, + "publicExportCount": 6, "keywords": [ "@interactive-os/json-document", "conflict", diff --git a/docs/changelog.md b/docs/changelog.md index 72905c2b..71ff801e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. ## Unreleased +- Narrowed the id resolver, stable-id rebase, and causal inbox inputs to their + actual read/preflight/publication ports. Full `JSONDocument` values remain + compatible, while host adapters can guard a projection without exposing the + complete document command and history surface. + ### Added - Added lab-only ready-time materialization to the causal patch inbox for diff --git a/docs/generated/extensions-catalog.md b/docs/generated/extensions-catalog.md index 0a4eac45..afac7c6c 100644 --- a/docs/generated/extensions-catalog.md +++ b/docs/generated/extensions-catalog.md @@ -17,7 +17,7 @@ Official extensions: 19 | `@interactive-os/json-document-dirty-state` | 7 | compare a document to a clean baseline | persistence or server save status | Official headless dirty state tracking extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-form-draft` | 15 | hold temporary invalid form input before committing valid JSON | rendered form components | Official headless form draft extension for temporary input that is not ready to enter a schema-valid `@interactive-os/json-document` document. | | `@interactive-os/json-document-grouping` | 15 | group and ungroup selected sibling JSON items | Airtable group-by views | Official extension for structural `group` and `ungroup`. | -| `@interactive-os/json-document-id-resolver` | 10 | resolve scoped stable ids to current JSON Pointers | id generation, relation graphs, routing, or server identity | Official headless stable id resolver extension for `@interactive-os/json-document` documents. | +| `@interactive-os/json-document-id-resolver` | 11 | resolve scoped stable ids to current JSON Pointers | id generation, relation graphs, routing, or server identity | Official headless stable id resolver extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-outline` | 19 | project and edit nested document outline structures | Figma layer panels without a tree schema adapter | Official headless outline tree and structure editing extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-patch-log` | 9 | record and replay applied JSON Patch records | product activity feeds or audit authorization | Official headless patch recording and replay extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-patch-preview` | 8 | preview patch effects before confirmation | visual diff rendering | Official headless patch preview extension for `@interactive-os/json-document` documents. | @@ -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 | 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-causal-patch-inbox` | lab-only | 30 | 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. | @@ -69,7 +69,7 @@ Lab extensions are private candidates. They are listed to show product pressure, | `@interactive-os/json-document-sort-items` | lab-only | 13 | sort or reverse JSON array items | query views, filters, or server sorting | Lab collection sort extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-sparse-record` | lab-only | 14 | set or remove keyed entries in sparse JSON records with add/replace/remove/no-op planning | 2D grid coordinate expansion, product key normalization, or rendered selection | Lab sparse-record entry editing extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-split-text` | lab-only | 9 | split a string into array items by a delimiter (tag input, paste-as-list) | CSV/TSV quoting, split-to-columns, or clipboard access | Lab split-text extension for `@interactive-os/json-document` documents. | -| `@interactive-os/json-document-stable-id-rebase` | lab-only | 5 | materialize one delayed field replacement against a current stable-id target | id policy, structural patches, CRDT/OT protocols, transport, or merge UI | Lab planner for materializing one delayed field replacement against the current Pointer of a host-owned stable id. | +| `@interactive-os/json-document-stable-id-rebase` | lab-only | 6 | materialize one delayed field replacement against a current stable-id target | id policy, structural patches, CRDT/OT protocols, transport, or merge UI | Lab planner for materializing one delayed field replacement against the current Pointer of a host-owned stable id. | | `@interactive-os/json-document-swap-items` | lab-only | 8 | exchange the positions of two items in the same array | cross-array swaps or moving to an arbitrary index | Lab swap-items extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-toggle-option` | lab-only | 9 | toggle, add, or remove a value's presence in a JSON array (tag/multi-select) | ordered insertion position or deduping an existing array | Lab toggle-option extension for `@interactive-os/json-document` documents. | | `@interactive-os/json-document-toggle-value` | lab-only | 10 | toggle a boolean or advance an enum/value field (enum options come from the schema) | rendered toggle controls or keyboard policy | Lab toggle-value extension for `@interactive-os/json-document` documents. | diff --git a/docs/generated/repo-catalog.json b/docs/generated/repo-catalog.json index 9a92b975..faed4b89 100644 --- a/docs/generated/repo-catalog.json +++ b/docs/generated/repo-catalog.json @@ -361,6 +361,7 @@ "IdResolver", "IdResolverDiagnostic", "IdResolverDiagnosticCode", + "IdResolverDocument", "IdResolverEntry", "IdResolverOptions", "IdResolverScope", @@ -369,7 +370,7 @@ "ResolveIdResult", "createIdResolver" ], - "publicExportCount": 10, + "publicExportCount": 11, "keywords": [ "@interactive-os/json-document", "editing", @@ -1270,6 +1271,7 @@ "IdResolver", "IdResolverDiagnostic", "IdResolverDiagnosticCode", + "IdResolverDocument", "IdResolverEntry", "IdResolverOptions", "IdResolverScope", @@ -1278,7 +1280,7 @@ "ResolveIdResult", "createIdResolver" ], - "publicExportCount": 10, + "publicExportCount": 11, "keywords": [ "@interactive-os/json-document", "editing", @@ -1859,6 +1861,7 @@ "CausalIntentEnvelope", "CausalMaterializationDiagnostic", "CausalMaterializationPolicy", + "CausalPatchDocument", "CausalPatchEnvelope", "CausalPatchFailure", "CausalPatchHost", @@ -1869,6 +1872,7 @@ "CausalPatchIngestErrorCode", "CausalPatchIngestOk", "CausalPatchIngestResult", + "CausalPatchPublicationDocument", "CausalPositionalIntent", "CausalPositionalMaterializationFailure", "CausalStableIdReplaceIntent", @@ -1878,7 +1882,7 @@ "QueuedCausalPatch", "createCausalPatchInbox" ], - "publicExportCount": 28, + "publicExportCount": 30, "keywords": [ "@interactive-os/json-document", "causal", @@ -2981,12 +2985,13 @@ }, "publicExports": [ "StableIdRebaseDiagnostic", + "StableIdRebaseDocument", "StableIdRebaseResult", "StableIdReplaceInput", "StableIdTarget", "rebaseStableChange" ], - "publicExportCount": 5, + "publicExportCount": 6, "keywords": [ "@interactive-os/json-document", "conflict", diff --git a/labs/extensions/causal-patch-inbox/README.md b/labs/extensions/causal-patch-inbox/README.md index 971896a9..038f5bd9 100644 --- a/labs/extensions/causal-patch-inbox/README.md +++ b/labs/extensions/causal-patch-inbox/README.md @@ -268,6 +268,15 @@ observable. In-place mutation of an object reachable through `doc.value` emits no subscription event and cannot be detected here; callers must treat document values as owned projection state rather than a side channel for mutation. +Direct and positional configurations accept the narrow +`CausalPatchPublicationDocument` projection port: `value`, `commit`, and +`subscribe`. A stable-id configuration accepts `CausalPatchDocument`, which +adds `query`, `at`, and `canPatch`. The projection reference must change after +a concrete commit, and the matching subscription must run synchronously before +that commit returns. This lets a host validate external patches and hide +unrelated editing, history, clipboard, and schema APIs while a full +`JSONDocument` remains structurally compatible. + Typed patch failure is a clean seam: core keeps the JSON projection atomic, so the inbox can leave its frontier unchanged. The first strict `JSONDocumentError` still propagates but its structured result is retained as diff --git a/labs/extensions/causal-patch-inbox/src/create.ts b/labs/extensions/causal-patch-inbox/src/create.ts index 4f83f133..f4ea2750 100644 --- a/labs/extensions/causal-patch-inbox/src/create.ts +++ b/labs/extensions/causal-patch-inbox/src/create.ts @@ -1,6 +1,5 @@ import { JSONDocumentError, - type JSONDocument, type JSONPatchOperation, type SelectionPoint, } from "@interactive-os/json-document"; @@ -22,6 +21,8 @@ import type { CausalPatchInboxOptions, CausalPatchInboxSnapshot, CausalPatchIngestResult, + CausalPatchDocument, + CausalPatchPublicationDocument, FailedCausalMaterialization, FaultedCausalPatch, } from "./types.js"; @@ -71,14 +72,32 @@ interface CommittedReadyEnvelope { let nextInboxInstance = 0; export function createCausalPatchInbox( - doc: JSONDocument, - options: CausalPatchInboxOptions = {}, + doc: CausalPatchPublicationDocument, + options?: Omit, "stableIdScopes"> & { + readonly stableIdScopes?: undefined; + }, +): CausalPatchInbox; +// Keep the complete policy signature last for Parameters utility consumers. +export function createCausalPatchInbox( + doc: CausalPatchDocument, + options?: CausalPatchInboxOptions, +): CausalPatchInbox; +export function createCausalPatchInbox( + doc: CausalPatchPublicationDocument, + options: CausalPatchInboxOptions | ( + Omit, "stableIdScopes"> & { + readonly stableIdScopes?: undefined; + } + ) = {}, ): CausalPatchInbox { const positionalSchema = options.positionalSchema; const host = options.host; const stableIdScopes = options.stableIdScopes === undefined ? undefined : options.stableIdScopes.map((scope) => ({ ...scope })); + const stableIdDocument = stableIdScopes === undefined + ? undefined + : requireStableIdDocument(doc); const known = new Map>(); const queued = new Map>(); const dependents = new Map>(); @@ -343,6 +362,7 @@ export function createCausalPatchInbox( materialized = materializeReadyEnvelope( ready, doc, + stableIdDocument, positionalSchema, stableIdScopes, known, @@ -729,7 +749,8 @@ export function createCausalPatchInbox( function materializeReadyEnvelope( ready: StoredEnvelope, - doc: JSONDocument, + doc: CausalPatchPublicationDocument, + stableIdDocument: CausalPatchDocument | undefined, positionalSchema: CausalPatchInboxOptions["positionalSchema"], stableIdScopes: CausalPatchInboxOptions["stableIdScopes"], known: ReadonlyMap>, @@ -845,7 +866,10 @@ function materializeReadyEnvelope( if (stableIdScopes === undefined) { throw new Error("stable-id materialization policy was not configured"); } - const planned = rebaseStableChange(doc, { + if (stableIdDocument === undefined) { + throw new Error("stable-id materialization document port was not configured"); + } + const planned = rebaseStableChange(stableIdDocument, { scopes: stableIdScopes, target: ready.intent.target, relativePath: ready.intent.relativePath, @@ -877,6 +901,22 @@ function materializeReadyEnvelope( }; } +function requireStableIdDocument( + doc: CausalPatchPublicationDocument, +): CausalPatchDocument { + const candidate = doc as Partial>; + if ( + typeof candidate.at !== "function" + || typeof candidate.canPatch !== "function" + || typeof candidate.query !== "function" + ) { + throw new TypeError( + "stable-id materialization requires query, at, and canPatch document ports", + ); + } + return candidate as CausalPatchDocument; +} + function collectCausalPast( dependencies: ReadonlyArray, known: ReadonlyMap>, diff --git a/labs/extensions/causal-patch-inbox/src/index.ts b/labs/extensions/causal-patch-inbox/src/index.ts index 9467ec4c..f6c0e0dc 100644 --- a/labs/extensions/causal-patch-inbox/src/index.ts +++ b/labs/extensions/causal-patch-inbox/src/index.ts @@ -11,6 +11,8 @@ export type { CausalMaterializationDiagnostic, CausalMaterializationPolicy, CausalPatchEnvelope, + CausalPatchDocument, + CausalPatchPublicationDocument, CausalPatchFailure, CausalPatchInbox, CausalPatchInboxOptions, diff --git a/labs/extensions/causal-patch-inbox/src/types.ts b/labs/extensions/causal-patch-inbox/src/types.ts index b8f67355..e1dbc7eb 100644 --- a/labs/extensions/causal-patch-inbox/src/types.ts +++ b/labs/extensions/causal-patch-inbox/src/types.ts @@ -1,5 +1,6 @@ import type { JSONChangeMetadata, + JSONDocument, JSONPatchOperation, JSONResult, Pointer, @@ -11,6 +12,7 @@ import type { RebaseSchema, } from "@interactive-os/json-document-patch-rebase"; import type { + StableIdRebaseDocument, StableIdRebaseDiagnostic, StableIdRebaseResult, StableIdReplaceInput, @@ -67,6 +69,25 @@ export type CausalMaterializationPolicy = | "positional" | "stable-id-replace"; +/** + * Publication port used by direct and positional inbox policies. + * + * `value` must return the same immutable reference while the projection is + * unchanged and a different reference after a concrete publication. `commit` + * must synchronously notify `subscribe` observers before returning, once for + * one concrete projection change and zero times for a successful + * no-op/test-only commit. + */ +export type CausalPatchPublicationDocument = Pick< + JSONDocument, + "commit" | "subscribe" | "value" +>; + +/** Publication port plus the reads required by stable-id materialization. */ +export type CausalPatchDocument = + CausalPatchPublicationDocument + & StableIdRebaseDocument; + export interface CausalHostPublication { readonly operations: ReadonlyArray; readonly metadata?: JSONChangeMetadata; 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 6b3be201..1461f812 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 @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, expectTypeOf, test } from "vitest"; import * as z from "zod"; import { @@ -8,6 +8,7 @@ import { } from "@interactive-os/json-document"; import { createCausalPatchInbox, + type CausalPatchInboxOptions, } from "../src/index.js"; const LogSchema = z.object({ @@ -38,6 +39,38 @@ const CardScopes = [{ }]; describe("@interactive-os/json-document-causal-patch-inbox", () => { + test("depends only on the document patch publication port", () => { + expectTypeOf[1]>() + .toEqualTypeOf | undefined>(); + + const document = createJSONDocument(LogSchema, { log: [] }); + const port = { + get value() { + return document.value; + }, + commit: document.commit, + subscribe: document.subscribe, + }; + const inbox = createCausalPatchInbox(port); + + if (false) { + // @ts-expect-error stable-id policy requires the extended read/preflight port + createCausalPatchInbox(port, { stableIdScopes: [] }); + } + expect(() => createCausalPatchInbox(port as never, { + stableIdScopes: [], + })).toThrow( + "stable-id materialization requires query, at, and canPatch document ports", + ); + + expect(inbox.ingest({ + id: "remote", + dependsOn: [], + operations: [{ op: "add", path: "/log/-", value: "applied" }], + })).toMatchObject({ ok: true, applied: ["remote"] }); + expect(port.value.log).toEqual(["applied"]); + }); + test("queues a child until its parent is applied, then drains in causal order", () => { const doc = createJSONDocument(LogSchema, { log: [] }); const inbox = createCausalPatchInbox(doc); diff --git a/labs/extensions/stable-id-rebase/README.md b/labs/extensions/stable-id-rebase/README.md index ea6f6ba3..e6aab8f3 100644 --- a/labs/extensions/stable-id-rebase/README.md +++ b/labs/extensions/stable-id-rebase/README.md @@ -103,6 +103,11 @@ This tracer calls it once, but a broad scope is still linear in its match set. structural dry-run always verifies preview identity, and selection checking reuses that preview when requested. +The planner accepts the narrow `StableIdRebaseDocument` current-projection +port: `query`, `at`, `canPatch`, and `value`. A host can therefore guard those +reads and preflight semantics without exposing unrelated document commands or +history state. A full `JSONDocument` remains structurally compatible. + The module intentionally handles one replacement. If multiple anchored edits, identity-based destinations, or structural operations become real callers, they should first prove a shared operation language rather than widening this diff --git a/labs/extensions/stable-id-rebase/src/index.ts b/labs/extensions/stable-id-rebase/src/index.ts index 5996fe44..99b54f7f 100644 --- a/labs/extensions/stable-id-rebase/src/index.ts +++ b/labs/extensions/stable-id-rebase/src/index.ts @@ -1,5 +1,6 @@ export { rebaseStableChange } from "./plan.js"; export type { + StableIdRebaseDocument, StableIdReplaceInput, StableIdRebaseResult, StableIdRebaseDiagnostic, diff --git a/labs/extensions/stable-id-rebase/src/plan.ts b/labs/extensions/stable-id-rebase/src/plan.ts index 7eab0365..51121036 100644 --- a/labs/extensions/stable-id-rebase/src/plan.ts +++ b/labs/extensions/stable-id-rebase/src/plan.ts @@ -2,7 +2,6 @@ import { applyPatch, buildPointer, tryParsePointer, - type JSONDocument, type JSONPatchOperation, type Pointer, type SelectionPoint, @@ -13,6 +12,7 @@ import { } from "@interactive-os/json-document-id-resolver"; import type { + StableIdRebaseDocument, StableIdReplaceInput, StableIdRebaseResult, } from "./types.js"; @@ -27,24 +27,24 @@ const PERMISSIVE_JSON_SCHEMA = { }; export function rebaseStableChange( - doc: JSONDocument, + doc: StableIdRebaseDocument, input: StableIdReplaceInput, ): StableIdRebaseResult; export function rebaseStableChange( - doc: JSONDocument, + doc: StableIdRebaseDocument, input: StableIdReplaceInput, ): StableIdRebaseResult; export function rebaseStableChange( - doc: JSONDocument, + doc: StableIdRebaseDocument, input: StableIdReplaceInput, ): StableIdRebaseResult; // Keep the legacy Pointer signature last for Parameters/ReturnType consumers. export function rebaseStableChange( - doc: JSONDocument, + doc: StableIdRebaseDocument, input: StableIdReplaceInput, ): StableIdRebaseResult; export function rebaseStableChange( - doc: JSONDocument, + doc: StableIdRebaseDocument, input: StableIdReplaceInput, ): StableIdRebaseResult { const relativePath = canonicalPointer(input.relativePath); diff --git a/labs/extensions/stable-id-rebase/src/types.ts b/labs/extensions/stable-id-rebase/src/types.ts index aca6b2ae..7f756ad4 100644 --- a/labs/extensions/stable-id-rebase/src/types.ts +++ b/labs/extensions/stable-id-rebase/src/types.ts @@ -1,14 +1,23 @@ import type { JSONCapabilityResult, + JSONDocument, JSONPatchOperation, Pointer, SelectionPoint, } from "@interactive-os/json-document"; import type { + IdResolverDocument, IdResolverScope, ResolveIdErrorCode, } from "@interactive-os/json-document-id-resolver"; +/** Minimal current-projection port required by stable-id materialization. */ +export type StableIdRebaseDocument = + IdResolverDocument & Pick< + JSONDocument, + "canPatch" | "value" + >; + export interface StableIdTarget { readonly scope: string; readonly id: string; 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 ea396933..6f4d806f 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 @@ -76,6 +76,33 @@ function rebaseCardChange( } describe("@interactive-os/json-document-stable-id-rebase", () => { + test("depends only on the stable-id current-projection port", () => { + const document = createBoard(); + const port = { + at: document.at, + canPatch: document.canPatch, + query: document.query, + get value() { + return document.value; + }, + }; + + expect(rebaseStableChange(port, { + scopes: CARD_SCOPES, + target: { scope: "card", id: "b" }, + relativePath: "/title", + expected: "B", + value: "Reviewed", + })).toMatchObject({ + ok: true, + operations: [ + { op: "test", path: "/columns/0/cards/1" }, + { op: "test", path: "/columns/0/cards/1/title", value: "B" }, + { op: "replace", path: "/columns/0/cards/1/title", value: "Reviewed" }, + ], + }); + }); + test("preserves pointer-only result types while adding point overloads", () => { expectTypeOf[1]>() .toEqualTypeOf(); diff --git a/packages/id-resolver/README.md b/packages/id-resolver/README.md index 81dda7e0..55ce1562 100644 --- a/packages/id-resolver/README.md +++ b/packages/id-resolver/README.md @@ -44,10 +44,12 @@ ids.current(); ## Contract -`@interactive-os/json-document-id-resolver` accepts public `JSONDocument` plus scope descriptors. A -scope descriptor owns the JSONPath query and the `readId(value, pointer)` -function. The extension delegates all reads to the public `doc.query` and -`doc.at` facade and never mutates the document. +`@interactive-os/json-document-id-resolver` accepts either a public +`JSONDocument` or the narrower `IdResolverDocument` read port plus scope +descriptors. A scope descriptor owns the JSONPath query and the +`readId(value, pointer)` function. The extension delegates all reads to +`doc.query` and `doc.at`; it does not require or expose mutation, history, +selection, clipboard, or schema capabilities. If the same id appears more than once in one scope, `resolve(scope, id)` returns `ambiguous_id` with every matching pointer. If a value has no id, return diff --git a/packages/id-resolver/src/create.ts b/packages/id-resolver/src/create.ts index b0f61d62..c9f38a99 100644 --- a/packages/id-resolver/src/create.ts +++ b/packages/id-resolver/src/create.ts @@ -1,7 +1,3 @@ -import type { - JSONDocument, -} from "@interactive-os/json-document"; - import { resolveId, } from "./resolve.js"; @@ -10,11 +6,12 @@ import { } from "./snapshot.js"; import type { IdResolver, + IdResolverDocument, IdResolverOptions, } from "./types.js"; export function createIdResolver( - doc: JSONDocument, + doc: IdResolverDocument, options: IdResolverOptions, ): IdResolver { return { diff --git a/packages/id-resolver/src/index.ts b/packages/id-resolver/src/index.ts index 1557eea1..5271bab8 100644 --- a/packages/id-resolver/src/index.ts +++ b/packages/id-resolver/src/index.ts @@ -1,6 +1,7 @@ export { createIdResolver } from "./create.js"; export type { IdResolver, + IdResolverDocument, IdResolverDiagnostic, IdResolverDiagnosticCode, IdResolverEntry, diff --git a/packages/id-resolver/src/snapshot.ts b/packages/id-resolver/src/snapshot.ts index fbd881b4..aa68effe 100644 --- a/packages/id-resolver/src/snapshot.ts +++ b/packages/id-resolver/src/snapshot.ts @@ -1,19 +1,16 @@ -import type { - JSONDocument, -} from "@interactive-os/json-document"; - import { findDuplicateIds, } from "./diagnostics.js"; import type { IdResolverDiagnostic, + IdResolverDocument, IdResolverEntry, IdResolverScope, IdResolverSnapshot, } from "./types.js"; export function readCurrentSnapshot( - doc: JSONDocument, + doc: IdResolverDocument, scopes: ReadonlyArray, ): IdResolverSnapshot { const entries: IdResolverEntry[] = []; diff --git a/packages/id-resolver/src/types.ts b/packages/id-resolver/src/types.ts index 57c051fd..f3bc8f68 100644 --- a/packages/id-resolver/src/types.ts +++ b/packages/id-resolver/src/types.ts @@ -1,7 +1,14 @@ import type { + JSONDocument, Pointer, } from "@interactive-os/json-document"; +/** Minimal read port required to resolve stable ids. */ +export type IdResolverDocument = Pick< + JSONDocument, + "at" | "query" +>; + export type IdResolverDiagnosticCode = | "invalid_query" | "read_failed" diff --git a/packages/id-resolver/tests/id-resolver.test.ts b/packages/id-resolver/tests/id-resolver.test.ts index 125a03e7..68384de8 100644 --- a/packages/id-resolver/tests/id-resolver.test.ts +++ b/packages/id-resolver/tests/id-resolver.test.ts @@ -46,6 +46,25 @@ function createBoard() { } describe("@interactive-os/json-document-id-resolver", () => { + test("depends only on the current document read port", () => { + const document = createBoard(); + const ids = createIdResolver({ + at: document.at, + query: document.query, + }, { + scopes: [{ + scope: "card", + query: "$.columns[*].cards[*]", + readId: (value) => Card.safeParse(value).data?.id, + }], + }); + + expect(ids.resolve("card", "b")).toMatchObject({ + ok: true, + pointer: "/columns/0/cards/1", + }); + }); + test("resolves a stable id to the current JSON Pointer", () => { const doc = createBoard(); const ids = createIdResolver(doc, {