diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 1cb3a1bee..10ccf7f7e 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -32,6 +32,9 @@ jobs: - name: TS policy run: npm run typecheck:policy + - name: Build + run: npm run build --silent + - name: Test run: npm run test:local --if-present diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee24fdd3..603f9e522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,14 +9,136 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Docs now have a doctrine/runtime alignment ratchet that defines status labels, + runtime evidence, and release rules for public claims that run ahead of the + implementation. +- README, Guide, and Advanced Guide now point readers at a teaching-alignment + audit before treating target WARP doctrine as shipped runtime behavior. +- `GitWarpReceiptEnvelopeBoundary` now freezes the minimal receipt/provenance + anchor external envelope consumers may depend on without exposing raw debug + receipt details as protocol truth. +- `BoundedSupportRule` now gives query plans a runtime-backed support law so + exact entity reads, neighborhood traversals, and wildcard discovery are + distinguishable before execution. +- `CausalIndexPlan` now carries the query-provider index posture for bounded + support rules, including the existing provenance entity-patch index family and + explicit global-discovery fallback. +- `SupportFragmentPlan` now gives bounded query reads a support-scoped fragment + materialization contract, including coordinate-scoped fragment keys and an + explicit `global-fallback` posture for discovery queries. +- `GraphDiff` now exposes a first-class comparison diff result for live Lamport + ranges through `comparison.diff({ from, to })`, without routing through + wildcard query scans. +- `GitWarpWitnessedSuffixAdmissionShell` now gives suffix import/export an + observer-readable shell with explicit admission outcomes and replay-bearing + hologram material instead of a naked patch-list contract. +- A dated TSC Zero agent-merge audit now retires the historical #505/B171 + drift concern by reconstructing PR #73's conflict-resolution scope and + mapping it to current TypeScript owner modules. +- The CLI now exposes honest `sync`, `serve`, `fork`, `checkpoint`, `gc`, and + `watch` command families backed by current runtime capabilities, while docs + keep `export` / `import` and `upgrade` / `migrate` omitted until those + adapter boundaries exist. +- Conflict analysis pipeline stages now receive an explicit + `ConflictPipelineContext` instead of the `ConflictAnalyzerService` + orchestrator, keeping graph access and hashing dependencies narrow. +- Same-writer race coverage now includes isolated runtime handles so the + accepted outcome is one visible winner plus one retryable writer-ref loss. +- `MergeClassifier` now emits explicit projection, semantic, or governance + merge labels from runtime-backed merge evidence and is checked against the + normalized merge conflict corpus. +- Lane, coordinate, and debugger capability authority now has a frozen + substrate boundary naming `worldline`, `strand`, `braid`, and stable + coordinate anchors for external protocol consumers. +- The Guide and Advanced Guide now make Observer-first reads the documented + client posture while explicitly warning that aperture redaction is not + encryption. +- TTD merge inspection now has a public read-only object-merge protocol with + precursor, branch footprint, candidate join, obstruction, lowering, policy, + and classifier evidence. +- Graph traversal now exposes `bfsStream()` and `dfsStream()` async generator + APIs, with collected `bfs()` and `dfs()` results layered over the same + traversal path. +- Patch collection for materialization now has stream-first frontier, + checkpoint, and writer patch APIs, with legacy array collectors reduced to + stream collection wrappers. +- Materialization now reduces default-runtime patch streams directly and carries + a deterministic witness that fails if live materialization buffers patch + entries before reducing them. +- `WarpKernelPort` now names the cohesive WARP kernel persistence contract + (`CommitPort` + `BlobPort` + `TreePort` + `RefPort`) so domain services no + longer need anonymous four-port intersections for core graph persistence. +- Sync responses now support an explicit `{ maxPatches, cursor }` page contract + and return first-class response-shaping metrics for patch count, skipped + writers, estimated payload bytes, and injected latency observations. +- `WarpOpenOptions` now provides a frozen, runtime-backed open-options boundary + for runtime graph openers while raw option objects remain accepted at the + composition boundary. - Coordinate-backed Optics now expose the public v18 success path: `prepareOpticBasis()`, `coordinate()`, and `coordinate.optic()` let Worldline-first callers run coherent node and property optic reads from a stable coordinate while keeping `openWarpGraph()` and materialize-first APIs out of first-use application code. - -### Changed - +- Observers now carry an optional structural basis and expose deterministic + accumulation/emission objects (`ObserverBasis`, `ObserverAccumulation`, and + `ObserverEmission`) alongside the existing projection/query surface. +- Observers now expose `plan()` and `readingEnvelope()` so reusable observers + and one-shot reads share a source/config plan and emitted reading envelope + family with witness, shell, budget, plurality, and residual metadata. +- Observer reading envelopes now validate and carry receipt boundary anchors, so + read surfaces can expose substrate receipt truth without depending on raw + materialization receipt internals. +- Strand materialization now resolves parent-basis patches from the live + frontier when the runtime provides `getFrontier()`, so untouched strand + regions follow parent truth while overlay divergence remains strand-owned. +- `git warp mcp` now starts a local stdio MCP server with a read-only tool + catalog for graph info, node ids, node properties, edges, and existence + checks; write-capable tools remain absent until writer/trust policy is + explicit at the MCP boundary. +- `CasContentEncryptionPolicy` now gives `GitGraphAdapter` an operator-facing + vault-resolved git-cas encryption boundary for CAS content, including + current scheme selection, vault verification diagnostics, and legacy scheme + migration errors. +- The `@git-stunts/trailer-codec` dependency now carries a local + `patch-package` declaration patch, allowing git-warp to remove the ambient + trailer-codec shim and casted codec singleton construction. +- A normalized merge-conflict corpus and `benchmark:merge-conflicts` harness now + classify projection, semantic, and governance conflict cases for future merge + lifting work. +- `npm run lint` now includes a source-size ratchet that enforces the 500 LOC + source, 800 LOC test, and 300 LOC tooling caps for new files while keeping + current over-budget files in an explicit relaxation list. + +### Changed + +- The old exported `Worldline` read-handle class is now `ProjectionHandle`, + matching its actual role as a pinned projection/read handle instead of a + causal-history object. +- Removed the stale `tar` override after current dependent ranges resolve to + patched `tar@7.5.16`, relaxed the direct `zod` dependency to the current v3 + range, and documented every active `patch-package` patch. +- Updated development dependency locks for the current `npm audit` findings: + Vite resolves to the patched 8.0.16 line, `tmp` resolves to 0.2.7, + `brace-expansion` resolves to 5.0.6 where used by markdown tooling, the + markdownlint CLI resolves to 0.49.x, and the explicit `tar` override now + points at the patched 7.5.16 release. +- Retired the legacy root `index.d.ts` monolith posture: npm consumers now + receive generated `dist/index.d.ts` declarations from the TypeScript source + barrel, while JSR continues to publish `index.ts` directly. +- Patch write docs now state the public visibility contract: `commit()`, + `writer.commitPatch(...)`, and `graph.patches.patch(...)` only resolve after + the canonical writer ref advances and reads back at the returned patch SHA. +- HTTP sync auth now declares the signed scheme with + `x-warp-auth-scheme: shared-secret-hmac-sha256`; peers continue to accept + legacy HMAC requests without the header during migration, but unsupported + declared schemes are rejected before HMAC verification. +- The content attachment spec now presents `ContentAttachmentRecord`, + `ContentAttachmentPayload`, and `GraphContentAttachmentSetOp` as the primary + storage-plane model while documenting `_content*` keys only as legacy + compatibility input. +- CRDT tests and diagnostics now use the `VersionVector` and `ORSet` class API + names directly, with a regression guard preventing legacy helper shim exports + from returning to the domain modules. - `EffectSinkPort.deliver()` now returns `DeliveryObservation[]` consistently. Custom sinks must wrap single observations in an array; the built-in no-op, console, chunk, multiplex, and effect-pipeline surfaces all @@ -409,6 +531,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`browser.d.ts` deleted** — Same: `browser.ts` is the source of truth and npm consumers receive generated `dist/browser.d.ts`. - **`contracts/type-surface.m8.json` deleted** — The Ironclad manifest is redundant when the barrel IS the contract. - **Entry points renamed** — Source entry points moved from `index.js` → `index.ts`, `browser.js` → `browser.ts`, and `bin/warp-graph.js` → `bin/warp-graph.ts`. npm exports point at generated `dist/*.js`; JSR exports point at TypeScript source. +- **`@git-stunts/plumbing` class rename** — The substrate package now exposes + the Git plumbing runtime as the default `GitPlumbing` class. Consumers that + imported a named `Plumbing` symbol must switch to a default import and choose + the local name explicitly: `import GitPlumbing from '@git-stunts/plumbing';`. ### Changed diff --git a/README.md b/README.md index 8e0ea657c..243beff4f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,20 @@ design docs, witnesses, retros, and archived backlog cards are evidence. Current public API cost labels are in [PUBLIC_API_COSTS.md](docs/PUBLIC_API_COSTS.md). +## Runtime posture + +Use [GLOSSARY.md](docs/GLOSSARY.md) for shipped, transition, and target noun +status. Use the +[Doctrine/runtime Alignment Ratchet](docs/DOCTRINE_RUNTIME_ALIGNMENT.md) and +[teaching alignment audit](docs/audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md) when +a doc claim is stronger than the runtime surface. + +Current first-use docs teach `openWarpWorldline()`, worldline reads, +coordinates, and observer apertures as the current application path. Live +holographic strands, common-basis braid validation, witnessed suffix admission, +and support-scoped fragment materialization remain target doctrine unless their +own docs say otherwise. + ## Quick start ```typescript @@ -109,6 +123,13 @@ Worldlines and Optics unless it is deliberately working on those lower layers. `openWarpGraph()` is organized around four architectural moments: +Public examples use the flat capability aliases (`graph.patches`, +`graph.query`, `graph.checkpoint`) as the canonical user-facing form. The +moment-scoped form (`graph.commitment.patches`, +`graph.revelation.query`, `graph.folding.checkpoint`) is the same runtime +object and is available when code wants to make the architectural moment +explicit. + | Moment | Capabilities | What it does | |--------|-------------|--------------| | **Commitment** | `patches`, `strands`, `comparison` | Admits claims into frontier-relative truth | @@ -121,6 +142,7 @@ Worldlines and Optics unless it is deliberately working on those lower layers. | Term | Meaning | |------|---------| | **Worldline** | Canonical admitted causal lane. The shared truth others may rely on. | +| **ProjectionHandle** | Pinned read handle over a live, historical, coordinate, or strand source. | | **Coordinate** | Stable causal read position used by coherent Optics. | | **Strand** | Speculative causal lane with fork provenance. Private until admitted. | | **Braid** | Plural composition over a family of lanes. Not itself a lane. | @@ -165,6 +187,8 @@ your source tree. Sync happens through normal `git push` / `git fetch`. - **[Migration Guide](docs/migrations/v18.0.0.md)** — Worldline-first v18 API migration - **[CLI Guide](docs/CLI_GUIDE.md)** — terminal workflows - **[Vision](docs/VISION.md)** — repo doctrine +- **[Glossary](docs/GLOSSARY.md)** — shipped, transition, and target noun status +- **[Doctrine/runtime Alignment Ratchet](docs/DOCTRINE_RUNTIME_ALIGNMENT.md)** — evidence rule for docs-ahead claims - **[Specs](docs/specs/)** — normative protocol and format specifications ## Substrate stack diff --git a/bin/cli/commands/checkpoint.ts b/bin/cli/commands/checkpoint.ts new file mode 100644 index 000000000..823bf7206 --- /dev/null +++ b/bin/cli/commands/checkpoint.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; +import { openGraph, readActiveCursor, readCheckpointDate } from '../shared.ts'; +import type { CliOptions } from '../types.ts'; + +const CHECKPOINT_OPTIONS = {}; + +const checkpointSchema = z.object({}).strict(); + +function checkpointUsage(): never { + throw usageError('Usage: warp-graph checkpoint '); +} + +type CheckpointPayload = + | { graph: string; checkpoint: string | null; date: string | null } + | { graph: string; checkpoint: string; status: 'created' } + | { graph: string; status: 'coverage-synced' }; + +async function assertNoActiveCursor( + persistence: Awaited>['persistence'], + graphName: string, +): Promise { + const cursor = await readActiveCursor(persistence, graphName); + if (cursor !== null) { + throw usageError('checkpoint create refuses to run while seek cursor is active; run git warp seek --latest first'); + } +} + +async function checkpointStatus(options: CliOptions): Promise<{ payload: CheckpointPayload; exitCode: number }> { + const { graph, graphName, persistence } = await openGraph(options); + const checkpoint = await graph._readCheckpointSha(); + const date = await readCheckpointDate(persistence, checkpoint); + return { + payload: { graph: graphName, checkpoint, date }, + exitCode: EXIT_CODES.OK, + }; +} + +async function createCheckpoint(options: CliOptions): Promise<{ payload: CheckpointPayload; exitCode: number }> { + const { graph, graphName, persistence } = await openGraph(options); + await assertNoActiveCursor(persistence, graphName); + await graph.materialize(); + const checkpoint = await graph.createCheckpoint(); + return { + payload: { graph: graphName, checkpoint, status: 'created' }, + exitCode: EXIT_CODES.OK, + }; +} + +async function syncCoverage(options: CliOptions): Promise<{ payload: CheckpointPayload; exitCode: number }> { + const { graph, graphName } = await openGraph(options); + await graph.syncCoverage(); + return { + payload: { graph: graphName, status: 'coverage-synced' }, + exitCode: EXIT_CODES.OK, + }; +} + +export default async function handleCheckpoint( + { options, args }: { options: CliOptions; args: string[] }, +): Promise<{ payload: CheckpointPayload; exitCode: number }> { + const action = args[0] ?? 'status'; + const rest = args.slice(1); + parseCommandArgs(rest, CHECKPOINT_OPTIONS, checkpointSchema); + + if (action === 'status') { return await checkpointStatus(options); } + if (action === 'create') { return await createCheckpoint(options); } + if (action === 'sync-coverage') { return await syncCoverage(options); } + return checkpointUsage(); +} diff --git a/bin/cli/commands/fork.ts b/bin/cli/commands/fork.ts new file mode 100644 index 000000000..91cc695fc --- /dev/null +++ b/bin/cli/commands/fork.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +import { EXIT_CODES, parseCommandArgs } from '../infrastructure.ts'; +import { openGraph } from '../shared.ts'; +import type { CliOptions } from '../types.ts'; + +const FORK_OPTIONS = { + from: { type: 'string' }, + at: { type: 'string' }, + 'fork-name': { type: 'string' }, + 'fork-writer': { type: 'string' }, +}; + +const forkSchema = z.object({ + from: z.string().min(1, 'Missing value for --from'), + at: z.string().min(1, 'Missing value for --at'), + 'fork-name': z.string().min(1, 'Missing value for --fork-name').optional(), + 'fork-writer': z.string().min(1, 'Missing value for --fork-writer').optional(), +}).strict().transform((val) => ({ + from: val.from, + at: val.at, + forkName: val['fork-name'] ?? null, + forkWriter: val['fork-writer'] ?? null, +})); + +type ForkPayload = { + graph: string; + forkGraph: string; + forkWriter: string; + from: string; + at: string; + status: 'created'; +}; + +export default async function handleFork( + { options, args }: { options: CliOptions; args: string[] }, +): Promise<{ payload: ForkPayload; exitCode: number }> { + const { values } = parseCommandArgs(args, FORK_OPTIONS, forkSchema); + const { graph, graphName } = await openGraph(options); + const forked = await graph.fork({ + from: values.from, + at: values.at, + ...(values.forkName !== null ? { forkName: values.forkName } : {}), + ...(values.forkWriter !== null ? { forkWriterId: values.forkWriter } : {}), + }); + + return { + payload: { + graph: graphName, + forkGraph: forked.graphName, + forkWriter: forked.writerId, + from: values.from, + at: values.at, + status: 'created', + }, + exitCode: EXIT_CODES.OK, + }; +} diff --git a/bin/cli/commands/gc.ts b/bin/cli/commands/gc.ts new file mode 100644 index 000000000..03e66942d --- /dev/null +++ b/bin/cli/commands/gc.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; +import { openGraph, readActiveCursor } from '../shared.ts'; +import type { CliOptions } from '../types.ts'; + +const GC_OPTIONS = {}; + +const gcSchema = z.object({}).strict(); + +function gcUsage(): never { + throw usageError('Usage: warp-graph gc '); +} + +type GcPayload = + | { graph: string; metrics: ReturnType>['graph']['getGCMetrics']> } + | { graph: string; result: ReturnType>['graph']['maybeRunGC']> } + | { graph: string; result: ReturnType>['graph']['runGC']> }; + +async function assertNoActiveCursor( + persistence: Awaited>['persistence'], + graphName: string, +): Promise { + const cursor = await readActiveCursor(persistence, graphName); + if (cursor !== null) { + throw usageError('gc refuses to run while seek cursor is active; run git warp seek --latest first'); + } +} + +async function gcStatus(options: CliOptions): Promise<{ payload: GcPayload; exitCode: number }> { + const { graph, graphName } = await openGraph(options); + await graph.materialize(); + return { + payload: { graph: graphName, metrics: graph.getGCMetrics() }, + exitCode: EXIT_CODES.OK, + }; +} + +async function maybeRunGc(options: CliOptions): Promise<{ payload: GcPayload; exitCode: number }> { + const { graph, graphName, persistence } = await openGraph(options); + await assertNoActiveCursor(persistence, graphName); + await graph.materialize(); + const result = graph.maybeRunGC(); + return { + payload: { graph: graphName, result }, + exitCode: EXIT_CODES.OK, + }; +} + +async function runGc(options: CliOptions): Promise<{ payload: GcPayload; exitCode: number }> { + const { graph, graphName, persistence } = await openGraph(options); + await assertNoActiveCursor(persistence, graphName); + await graph.materialize(); + const result = graph.runGC(); + return { + payload: { graph: graphName, result }, + exitCode: EXIT_CODES.OK, + }; +} + +export default async function handleGc( + { options, args }: { options: CliOptions; args: string[] }, +): Promise<{ payload: GcPayload; exitCode: number }> { + const action = args[0] ?? 'status'; + const rest = args.slice(1); + parseCommandArgs(rest, GC_OPTIONS, gcSchema); + + if (action === 'status') { return await gcStatus(options); } + if (action === 'maybe-run') { return await maybeRunGc(options); } + if (action === 'run') { return await runGc(options); } + return gcUsage(); +} diff --git a/bin/cli/commands/mcp.ts b/bin/cli/commands/mcp.ts new file mode 100644 index 000000000..560bbded7 --- /dev/null +++ b/bin/cli/commands/mcp.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs'; +import process from 'node:process'; +import readline from 'node:readline'; + +import { usageError } from '../infrastructure.ts'; +import { openGraph } from '../shared.ts'; +import { + handleMcpMessage, + mcpParseError, + type McpResponse, +} from './mcp/McpProtocol.ts'; +import type { CliOptions } from '../types.ts'; + +type McpCommandResult = { + readonly payload: undefined; + readonly close: () => Promise; +}; + +function readPackageVersion(): string { + const packageUrl = new URL('../../../package.json', import.meta.url); + const packageText = fs.readFileSync(packageUrl, 'utf8'); + const packageJson = JSON.parse(packageText) as { readonly version?: string }; + return typeof packageJson.version === 'string' && packageJson.version.length > 0 + ? packageJson.version + : '0.0.0'; +} + +function writeResponse(response: McpResponse): void { + process.stdout.write(`${JSON.stringify(response)}\n`); +} + +/** Handles `git warp mcp`: starts a local stdio MCP server. */ +export default async function handleMcp({ + options, + args, +}: { + readonly options: CliOptions; + readonly args: string[]; +}): Promise { + if (args.length > 0) { + throw usageError('mcp does not accept positional args; use --repo, --graph, and --writer'); + } + + const { graph } = await openGraph(options); + const serverVersion = readPackageVersion(); + const lines = readline.createInterface({ + input: process.stdin, + terminal: false, + }); + + lines.on('line', (line) => { + void dispatchLine(graph, serverVersion, line); + }); + + return { + payload: undefined, + close: () => { + lines.close(); + return Promise.resolve(); + }, + }; +} + +async function dispatchLine( + graph: Parameters[0], + serverVersion: string, + line: string, +): Promise { + if (line.trim().length === 0) { + return; + } + try { + const parsed = JSON.parse(line) as unknown; + const response = await handleMcpMessage(graph, parsed, { serverVersion }); + if (response !== null) { + writeResponse(response); + } + } catch { + writeResponse(mcpParseError()); + } +} diff --git a/bin/cli/commands/mcp/McpJsonValue.ts b/bin/cli/commands/mcp/McpJsonValue.ts new file mode 100644 index 000000000..65cbc2d24 --- /dev/null +++ b/bin/cli/commands/mcp/McpJsonValue.ts @@ -0,0 +1,13 @@ +export type McpJsonValue = + | null + | boolean + | number + | string + | readonly McpJsonValue[] + | { readonly [key: string]: McpJsonValue }; + +export type McpJsonObject = { readonly [key: string]: McpJsonValue }; + +export function isMcpJsonObject(value: unknown): value is McpJsonObject { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/bin/cli/commands/mcp/McpProtocol.ts b/bin/cli/commands/mcp/McpProtocol.ts new file mode 100644 index 000000000..cc91850a9 --- /dev/null +++ b/bin/cli/commands/mcp/McpProtocol.ts @@ -0,0 +1,221 @@ +import { + callMcpTool, + listMcpTools, + type McpGraphReadSurface, +} from './McpToolCatalog.ts'; +import McpProtocolError from './McpProtocolError.ts'; +import { + isMcpJsonObject, + type McpJsonObject, + type McpJsonValue, +} from './McpJsonValue.ts'; + +export { + listMcpTools, + type McpGraphReadSurface, +} from './McpToolCatalog.ts'; + +type McpRequestId = string | number | null; + +type McpRequest = { + readonly jsonrpc: '2.0'; + readonly id?: McpRequestId; + readonly method: string; + readonly params?: McpJsonObject; +}; + +export type McpResponse = + | { + readonly jsonrpc: '2.0'; + readonly id: McpRequestId; + readonly result: McpJsonValue; + } + | { + readonly jsonrpc: '2.0'; + readonly id: McpRequestId; + readonly error: { + readonly code: number; + readonly message: string; + readonly data?: McpJsonValue; + }; + }; + +type McpOptions = { + readonly serverVersion: string; +}; + +type ToolCallInput = { + readonly name: string; + readonly arguments: McpJsonObject; +}; + +type MethodHandler = ( + graph: McpGraphReadSurface, + request: McpRequest, + options: McpOptions, +) => Promise; + +const METHOD_HANDLERS: ReadonlyMap = new Map([ + ['initialize', handleInitialize], + ['tools/list', handleToolsList], + ['tools/call', handleToolsCall], + ['resources/list', handleResourcesList], + ['ping', handlePing], +]); + +export function mcpParseError(): McpResponse { + return errorResponse(null, new McpProtocolError(-32700, 'Parse error')); +} + +export async function handleMcpMessage( + graph: McpGraphReadSurface, + message: unknown, + options: McpOptions, +): Promise { + const request = readRequest(message); + if (request === null) { + return errorResponse(null, new McpProtocolError(-32600, 'Invalid Request')); + } + if (request.id === undefined) { + return null; + } + try { + const result = await dispatchRequest(graph, request, options); + return resultResponse(request.id, result); + } catch (error) { + return errorResponse(request.id, normalizeError(error)); + } +} + +async function dispatchRequest( + graph: McpGraphReadSurface, + request: McpRequest, + options: McpOptions, +): Promise { + const handler = METHOD_HANDLERS.get(request.method); + if (handler !== undefined) { + return await handler(graph, request, options); + } + throw new McpProtocolError(-32601, `Method not found: ${request.method}`); +} + +function handleInitialize( + _graph: McpGraphReadSurface, + request: McpRequest, + options: McpOptions, +): Promise { + return Promise.resolve({ + protocolVersion: readProtocolVersion(request.params) ?? '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { + name: 'git-warp', + version: options.serverVersion, + }, + }); +} + +function handleToolsList(): Promise { + return Promise.resolve({ tools: listMcpTools() }); +} + +async function handleToolsCall( + graph: McpGraphReadSurface, + request: McpRequest, +): Promise { + const input = readToolCallInput(request.params); + return await callMcpTool(graph, input.name, input.arguments); +} + +function handleResourcesList(): Promise { + return Promise.resolve({ resources: [] }); +} + +function handlePing(): Promise { + return Promise.resolve({}); +} + +function readProtocolVersion(params: McpJsonObject | undefined): string | null { + const value = params?.['protocolVersion']; + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function readToolCallInput(params: McpJsonObject | undefined): ToolCallInput { + return { + name: readToolName(params), + arguments: readToolArguments(params), + }; +} + +function readToolName(params: McpJsonObject | undefined): string { + const name = params?.['name']; + if (typeof name === 'string' && name.length > 0) { + return name; + } + throw new McpProtocolError(-32602, 'tools/call requires a tool name'); +} + +function readToolArguments(params: McpJsonObject | undefined): McpJsonObject { + const args = params?.['arguments']; + if (args === undefined) { + return {}; + } + if (isMcpJsonObject(args)) { + return args; + } + throw new McpProtocolError(-32602, 'tools/call arguments must be an object'); +} + +function readRequest(value: unknown): McpRequest | null { + if (!isMcpJsonObject(value)) { + return null; + } + if (!hasValidRequestHeader(value) || !hasValidRequestId(value) || !hasValidParams(value)) { + return null; + } + return value as McpRequest; +} + +function hasValidRequestHeader(value: McpJsonObject): boolean { + return value['jsonrpc'] === '2.0' && typeof value['method'] === 'string'; +} + +function hasValidRequestId(value: McpJsonObject): boolean { + return isRequestId(value['id']); +} + +function hasValidParams(value: McpJsonObject): boolean { + return value['params'] === undefined || isMcpJsonObject(value['params']); +} + +function isRequestId(value: unknown): boolean { + return ( + value === undefined + || value === null + || typeof value === 'string' + || (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function resultResponse(id: McpRequestId, result: McpJsonValue): McpResponse { + return { jsonrpc: '2.0', id, result }; +} + +function errorResponse(id: McpRequestId, error: McpProtocolError): McpResponse { + return { + jsonrpc: '2.0', + id, + error: { + code: error.code, + message: error.message, + ...(error.data !== undefined ? { data: error.data } : {}), + }, + }; +} + +function normalizeError(error: unknown): McpProtocolError { + if (error instanceof McpProtocolError) { + return error; + } + const message = error instanceof Error ? error.message : 'Internal MCP error'; + return new McpProtocolError(-32000, message); +} diff --git a/bin/cli/commands/mcp/McpProtocolError.ts b/bin/cli/commands/mcp/McpProtocolError.ts new file mode 100644 index 000000000..360ff1d56 --- /dev/null +++ b/bin/cli/commands/mcp/McpProtocolError.ts @@ -0,0 +1,14 @@ +import type { McpJsonValue } from './McpJsonValue.ts'; + +export default class McpProtocolError extends Error { + readonly code: number; + readonly data?: McpJsonValue; + + constructor(code: number, message: string, data?: McpJsonValue) { + super(message); + this.code = code; + if (data !== undefined) { + this.data = data; + } + } +} diff --git a/bin/cli/commands/mcp/McpToolCatalog.ts b/bin/cli/commands/mcp/McpToolCatalog.ts new file mode 100644 index 000000000..81039a61e --- /dev/null +++ b/bin/cli/commands/mcp/McpToolCatalog.ts @@ -0,0 +1,204 @@ +import { z } from 'zod'; + +import { compactStringify } from '../../../presenters/json.ts'; +import ImmutableBytes from '../../../../src/domain/services/snapshot/ImmutableBytes.ts'; +import McpProtocolError from './McpProtocolError.ts'; +import type { + QueryPropertyBag, + VisibleEdge, +} from '../../../../src/domain/capabilities/QueryCapability.ts'; +import type { SnapshotPropValue } from '../../../../src/domain/services/snapshot/SnapshotPropValue.ts'; +import type { McpJsonObject, McpJsonValue } from './McpJsonValue.ts'; + +export type McpGraphReadSurface = { + readonly graphName: string; + readonly writerId: string; + hasNode(nodeId: string): Promise; + getNodes(): Promise; + getNodeProps(nodeId: string): Promise; + getEdges(): Promise; +}; + +export type McpToolDescriptor = { + readonly name: string; + readonly description: string; + readonly inputSchema: McpJsonObject; +}; + +type RegisteredMcpTool = McpToolDescriptor & { + readonly handle: (graph: McpGraphReadSurface, args: McpJsonObject) => Promise; +}; + +const EMPTY_ARGS_SCHEMA = z.object({}).strict(); +const NODE_ID_ARGS_SCHEMA = z.object({ nodeId: z.string().min(1) }).strict(); + +const EMPTY_INPUT_SCHEMA: McpJsonObject = Object.freeze({ + type: 'object', + properties: Object.freeze({}), + additionalProperties: false, +}); + +const NODE_ID_INPUT_SCHEMA: McpJsonObject = Object.freeze({ + type: 'object', + properties: Object.freeze({ + nodeId: Object.freeze({ type: 'string', minLength: 1 }), + }), + required: Object.freeze(['nodeId']), + additionalProperties: false, +}); + +const REGISTERED_TOOLS: readonly RegisteredMcpTool[] = Object.freeze([ + Object.freeze({ + name: 'warp_info', + description: 'Return graph identity and MCP server posture.', + inputSchema: EMPTY_INPUT_SCHEMA, + handle: warpInfo, + }), + Object.freeze({ + name: 'warp_nodes', + description: 'List visible node ids for the opened graph.', + inputSchema: EMPTY_INPUT_SCHEMA, + handle: warpNodes, + }), + Object.freeze({ + name: 'warp_node_props', + description: 'Read visible properties for a single node id.', + inputSchema: NODE_ID_INPUT_SCHEMA, + handle: warpNodeProps, + }), + Object.freeze({ + name: 'warp_edges', + description: 'List visible edges for the opened graph.', + inputSchema: EMPTY_INPUT_SCHEMA, + handle: warpEdges, + }), + Object.freeze({ + name: 'warp_has_node', + description: 'Return whether a visible node id exists.', + inputSchema: NODE_ID_INPUT_SCHEMA, + handle: warpHasNode, + }), +]); + +const TOOLS_BY_NAME = new Map(REGISTERED_TOOLS.map((tool) => [tool.name, tool])); + +export function listMcpTools(): readonly McpToolDescriptor[] { + return Object.freeze(REGISTERED_TOOLS.map((tool) => Object.freeze({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }))); +} + +export async function callMcpTool( + graph: McpGraphReadSurface, + name: string, + args: McpJsonObject, +): Promise { + const tool = TOOLS_BY_NAME.get(name); + if (tool === undefined) { + throw new McpProtocolError(-32602, `Unknown MCP tool: ${name}`); + } + return toolResponse(await tool.handle(graph, args)); +} + +function warpInfo(graph: McpGraphReadSurface, args: McpJsonObject): Promise { + parseEmptyArgs(args); + return Promise.resolve({ + graph: graph.graphName, + writer: graph.writerId, + posture: 'read-only', + }); +} + +async function warpNodes(graph: McpGraphReadSurface, args: McpJsonObject): Promise { + parseEmptyArgs(args); + return { nodes: await graph.getNodes() }; +} + +async function warpNodeProps(graph: McpGraphReadSurface, args: McpJsonObject): Promise { + const { nodeId } = parseNodeIdArgs(args); + const props = await graph.getNodeProps(nodeId); + return { + nodeId, + props: props === null ? null : propertyBagToJson(props), + }; +} + +async function warpEdges(graph: McpGraphReadSurface, args: McpJsonObject): Promise { + parseEmptyArgs(args); + return { edges: (await graph.getEdges()).map(edgeToJson) }; +} + +async function warpHasNode(graph: McpGraphReadSurface, args: McpJsonObject): Promise { + const { nodeId } = parseNodeIdArgs(args); + return { nodeId, exists: await graph.hasNode(nodeId) }; +} + +function parseEmptyArgs(args: McpJsonObject): void { + const parsed = EMPTY_ARGS_SCHEMA.safeParse(args); + if (!parsed.success) { + throw invalidToolInput(parsed.error.issues.map((issue) => issue.message)); + } +} + +function parseNodeIdArgs(args: McpJsonObject): { readonly nodeId: string } { + const parsed = NODE_ID_ARGS_SCHEMA.safeParse(args); + if (parsed.success) { + return parsed.data; + } + throw invalidToolInput(parsed.error.issues.map((issue) => issue.message)); +} + +function invalidToolInput(issues: readonly string[]): McpProtocolError { + return new McpProtocolError(-32602, 'Invalid MCP tool input', { issues }); +} + +function edgeToJson(edge: VisibleEdge): McpJsonValue { + return { + from: edge.from, + to: edge.to, + label: edge.label, + props: propertyBagToJson(edge.props), + }; +} + +function propertyBagToJson(props: QueryPropertyBag): McpJsonValue { + const bag: { [key: string]: McpJsonValue } = {}; + for (const [key, value] of Object.entries(props)) { + bag[key] = propValueToJson(value); + } + return Object.freeze(bag); +} + +function propValueToJson(value: SnapshotPropValue): McpJsonValue { + if (value instanceof ImmutableBytes) { + return { type: 'bytes', value: value.toArray() }; + } + if (isSnapshotPropArray(value)) { + return Object.freeze(value.map(propValueToJson)); + } + if (value !== null && typeof value === 'object') { + return propObjectToJson(value); + } + return value; +} + +function isSnapshotPropArray(value: SnapshotPropValue): value is readonly SnapshotPropValue[] { + return Array.isArray(value); +} + +function propObjectToJson(value: { readonly [key: string]: SnapshotPropValue }): McpJsonValue { + const objectValue: { [key: string]: McpJsonValue } = {}; + for (const [key, entry] of Object.entries(value)) { + objectValue[key] = propValueToJson(entry); + } + return Object.freeze(objectValue); +} + +function toolResponse(payload: McpJsonValue): McpJsonValue { + return { + content: [{ type: 'text', text: compactStringify(payload) }], + structuredContent: payload, + }; +} diff --git a/bin/cli/commands/registry.ts b/bin/cli/commands/registry.ts index 7601c1ccf..c4cb61748 100644 --- a/bin/cli/commands/registry.ts +++ b/bin/cli/commands/registry.ts @@ -19,6 +19,13 @@ import handleTrust from './trust.ts'; import handlePatch from './patch.ts'; import handleTree from './tree.ts'; import handleBisect from './bisect.ts'; +import handleMcp from './mcp.ts'; +import handleSync from './sync.ts'; +import handleServe from './serve.ts'; +import handleFork from './fork.ts'; +import handleCheckpoint from './checkpoint.ts'; +import handleGc from './gc.ts'; +import handleWatch from './watch.ts'; /** Opaque handler return value. The entry point normalizes any shape * into `{ payload, exitCode, close? }` at runtime via type guards. */ @@ -51,4 +58,11 @@ export const COMMANDS: ReadonlyMap = new Map; + +function optionalString(value: string | undefined): string | null { + if (value === undefined) { return null; } + return value; +} + +function optionalNumber(value: number | undefined): number | null { + if (value === undefined) { return null; } + return value; +} + +function optionalAuthMode(value: ServeInput['auth-mode']): 'enforce' | 'log-only' { + if (value === undefined) { return 'enforce'; } + return value; +} + +function allowedWriterList(value: ServeInput['allow-writer']): string[] { + if (value === undefined) { return []; } + if (Array.isArray(value)) { return value; } + return [value]; +} + +function transformServeInput(val: ServeInput) { + return { + port: val.port, + host: optionalString(val.host), + path: optionalString(val.path), + maxRequestBytes: optionalNumber(val['max-request-bytes']), + authSecret: optionalString(val['auth-secret']), + authKeyId: optionalString(val['auth-key-id']), + authMode: optionalAuthMode(val['auth-mode']), + allowedWriters: allowedWriterList(val['allow-writer']), + unsafeAllowUnauthenticatedLocalhost: val['unsafe-allow-unauthenticated-localhost'], + }; +} + +const serveSchema = serveInputSchema.transform(transformServeInput); + +type ServeValues = z.infer; + +type ServePayload = { + graph: string; + url: string; + status: 'serving'; + auth: 'configured' | 'unsafe-localhost'; +}; + +function requireServeAuth(values: ServeValues): void { + if (values.authKeyId !== null && values.authSecret === null) { + throw usageError('--auth-key-id requires --auth-secret'); + } + if (values.authSecret === null && !values.unsafeAllowUnauthenticatedLocalhost) { + throw usageError('serve requires --auth-secret or --unsafe-allow-unauthenticated-localhost'); + } +} + +function applyServeAddressOptions(options: ServeOptions, values: ServeValues): void { + if (values.host !== null) { options.host = values.host; } + if (values.path !== null) { options.path = values.path; } + if (values.maxRequestBytes !== null) { options.maxRequestBytes = values.maxRequestBytes; } + if (values.allowedWriters.length > 0) { options.allowedWriters = values.allowedWriters; } +} + +function applyServeAuthOptions(options: ServeOptions, values: ServeValues): void { + if (values.authSecret === null) { + options.unsafeAllowUnauthenticatedLocalhost = values.unsafeAllowUnauthenticatedLocalhost; + return; + } + const keyId = values.authKeyId ?? 'default'; + const keys: { [id: string]: SyncSecret } = {}; + keys[keyId] = SyncSecret.fromString(values.authSecret); + options.auth = { keys, mode: values.authMode }; +} + +function buildServeOptions(values: ServeValues): ServeOptions { + requireServeAuth(values); + const options: ServeOptions = { port: values.port, httpPort: new NodeHttpAdapter() }; + applyServeAddressOptions(options, values); + applyServeAuthOptions(options, values); + return options; +} + +export default async function handleServe( + { options, args }: { options: CliOptions; args: string[] }, +): Promise<{ payload: ServePayload; exitCode: number; close: () => Promise }> { + const { values } = parseCommandArgs(args, SERVE_OPTIONS, serveSchema); + const { graph, graphName } = await openGraph(options); + const handle = await graph.serve(buildServeOptions(values)); + return { + payload: { + graph: graphName, + url: handle.url, + status: 'serving', + auth: values.authSecret !== null ? 'configured' : 'unsafe-localhost', + }, + exitCode: EXIT_CODES.OK, + close: async () => { await handle.close(); }, + }; +} diff --git a/bin/cli/commands/sync.ts b/bin/cli/commands/sync.ts new file mode 100644 index 000000000..7f1c795f1 --- /dev/null +++ b/bin/cli/commands/sync.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; + +import SyncSecret from '../../../src/domain/services/sync/SyncSecret.ts'; +import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; +import { openGraph } from '../shared.ts'; +import type { CliOptions, WarpGraphInstance } from '../types.ts'; +import type { SyncRequest, SyncWithOptions } from '../../../src/domain/capabilities/SyncCapability.ts'; + +const SYNC_EMPTY_OPTIONS = {}; + +const SYNC_WITH_OPTIONS = { + path: { type: 'string' }, + retries: { type: 'string' }, + 'base-delay-ms': { type: 'string' }, + 'max-delay-ms': { type: 'string' }, + 'timeout-ms': { type: 'string' }, + materialize: { type: 'boolean', default: false }, + 'auth-secret': { type: 'string' }, + 'auth-key-id': { type: 'string' }, +}; + +const syncEmptySchema = z.object({}).strict(); + +const syncWithInputSchema = z.object({ + path: z.string().min(1, 'Missing value for --path').optional(), + retries: z.coerce.number().int().nonnegative().optional(), + 'base-delay-ms': z.coerce.number().int().nonnegative().optional(), + 'max-delay-ms': z.coerce.number().int().nonnegative().optional(), + 'timeout-ms': z.coerce.number().int().positive().optional(), + materialize: z.boolean().default(false), + 'auth-secret': z.string().min(1, 'Missing value for --auth-secret').optional(), + 'auth-key-id': z.string().min(1, 'Missing value for --auth-key-id').optional(), +}).strict(); + +type SyncWithInput = z.infer; + +function optionalString(value: string | undefined): string | null { + if (value === undefined) { return null; } + return value; +} + +function optionalNumber(value: number | undefined): number | null { + if (value === undefined) { return null; } + return value; +} + +function transformSyncWithInput(val: SyncWithInput) { + return { + path: optionalString(val.path), + retries: optionalNumber(val.retries), + baseDelayMs: optionalNumber(val['base-delay-ms']), + maxDelayMs: optionalNumber(val['max-delay-ms']), + timeoutMs: optionalNumber(val['timeout-ms']), + materialize: val.materialize, + authSecret: optionalString(val['auth-secret']), + authKeyId: optionalString(val['auth-key-id']), + }; +} + +const syncWithSchema = syncWithInputSchema.transform(transformSyncWithInput); + +type SyncWithValues = z.infer; + +function syncUsage(): never { + throw usageError('Usage: warp-graph sync '); +} + +type SyncStatusPayload = { + graph: string; + status: Awaited>; +}; + +type SyncRequestPayload = { + graph: string; + request: SyncRequest; +}; + +type SyncWithPayload = { + graph: string; + remote: string; + applied: number; + attempts: number; + skippedWriters: Awaited>['skippedWriters']; + materialized: boolean; +}; + +type SyncPayload = SyncStatusPayload | SyncRequestPayload | SyncWithPayload; + +async function syncStatus(options: CliOptions): Promise<{ payload: SyncPayload; exitCode: number }> { + const { graph, graphName } = await openGraph(options); + return { + payload: { graph: graphName, status: await graph.status() }, + exitCode: EXIT_CODES.OK, + }; +} + +async function syncRequest(options: CliOptions): Promise<{ payload: SyncPayload; exitCode: number }> { + const { graph, graphName } = await openGraph(options); + return { + payload: { graph: graphName, request: await graph.createSyncRequest() }, + exitCode: EXIT_CODES.OK, + }; +} + +function requireSyncAuth(values: SyncWithValues): void { + if (values.authKeyId !== null && values.authSecret === null) { + throw usageError('--auth-key-id requires --auth-secret'); + } +} + +function applySyncRetryOptions(options: SyncWithOptions, values: SyncWithValues): void { + if (values.retries !== null) { options.retries = values.retries; } + if (values.baseDelayMs !== null) { options.baseDelayMs = values.baseDelayMs; } + if (values.maxDelayMs !== null) { options.maxDelayMs = values.maxDelayMs; } +} + +function applySyncTransportOptions(options: SyncWithOptions, values: SyncWithValues): void { + if (values.path !== null) { options.path = values.path; } + if (values.timeoutMs !== null) { options.timeoutMs = values.timeoutMs; } + if (values.materialize) { options.materialize = true; } +} + +function syncAuth(values: SyncWithValues): NonNullable | null { + if (values.authSecret === null) { return null; } + const auth = { secret: SyncSecret.fromString(values.authSecret) }; + if (values.authKeyId !== null) { return { ...auth, keyId: values.authKeyId }; } + return auth; +} + +function buildSyncWithOptions(values: SyncWithValues): SyncWithOptions { + requireSyncAuth(values); + const options: SyncWithOptions = {}; + applySyncTransportOptions(options, values); + applySyncRetryOptions(options, values); + const auth = syncAuth(values); + if (auth !== null) { options.auth = auth; } + return options; +} + +async function syncWith( + options: CliOptions, + args: string[], +): Promise<{ payload: SyncPayload; exitCode: number }> { + const { values, positionals } = parseCommandArgs(args, SYNC_WITH_OPTIONS, syncWithSchema, { + allowPositionals: true, + }); + const remote = positionals[0]; + if (remote === undefined || remote.length === 0) { + throw usageError('Usage: warp-graph sync with [options]'); + } + if (positionals.length > 1) { + throw usageError('sync with accepts exactly one remote URL'); + } + const { graph, graphName } = await openGraph(options); + const result = await graph.syncWith(remote, buildSyncWithOptions(values)); + return { + payload: { + graph: graphName, + remote, + applied: result.applied, + attempts: result.attempts, + skippedWriters: result.skippedWriters, + materialized: result.state !== undefined, + }, + exitCode: EXIT_CODES.OK, + }; +} + +export default async function handleSync( + { options, args }: { options: CliOptions; args: string[] }, +): Promise<{ payload: SyncPayload; exitCode: number }> { + const action = args[0] ?? 'status'; + const rest = args.slice(1); + + if (action === 'status') { + parseCommandArgs(rest, SYNC_EMPTY_OPTIONS, syncEmptySchema); + return await syncStatus(options); + } + if (action === 'request') { + parseCommandArgs(rest, SYNC_EMPTY_OPTIONS, syncEmptySchema); + return await syncRequest(options); + } + if (action === 'with') { return await syncWith(options, rest); } + return syncUsage(); +} diff --git a/bin/cli/commands/watch.ts b/bin/cli/commands/watch.ts new file mode 100644 index 000000000..454cd6621 --- /dev/null +++ b/bin/cli/commands/watch.ts @@ -0,0 +1,75 @@ +import process from 'node:process'; +import { z } from 'zod'; + +import { compactStringify } from '../../presenters/json.ts'; +import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.ts'; +import { openGraph } from '../shared.ts'; +import type { CliOptions, WarpGraphInstance } from '../types.ts'; +import type { StateDiffResult } from '../../../src/domain/services/state/StateDiff.ts'; + +const WATCH_OPTIONS = { + poll: { type: 'string' }, +}; + +const watchSchema = z.object({ + poll: z.coerce.number().int().min(1000, 'poll must be >= 1000').optional(), +}).strict(); + +type WatchValues = z.infer; +type WatchOptions = Parameters[1]; +type WatchSubscription = ReturnType; + +type WatchPayload = { + graph: string; + pattern: string; + status: 'watching'; + eventFormat: 'ndjson'; +}; + +function parseWatchArgs(args: string[]): { pattern: string; values: WatchValues } { + const { values, positionals } = parseCommandArgs(args, WATCH_OPTIONS, watchSchema, { + allowPositionals: true, + }); + if (positionals.length > 1) { + throw usageError('Usage: warp-graph watch [pattern] [--poll ]'); + } + return { pattern: positionals[0] ?? '*', values }; +} + +function watchOptions(graphName: string, pattern: string, values: WatchValues): WatchOptions { + return { + ...(values.poll !== undefined ? { poll: values.poll } : {}), + onChange(diff: StateDiffResult) { + process.stdout.write(`${compactStringify({ type: 'change', graph: graphName, pattern, diff })}\n`); + }, + onError(error) { + process.stderr.write(`watch error: ${String(error)}\n`); + }, + }; +} + +function closeWatch(subscription: WatchSubscription): () => Promise { + return () => { + subscription.unsubscribe(); + return Promise.resolve(); + }; +} + +export default async function handleWatch( + { options, args }: { options: CliOptions; args: string[] }, +): Promise<{ payload: WatchPayload; exitCode: number; close: () => Promise }> { + const { pattern, values } = parseWatchArgs(args); + const { graph, graphName } = await openGraph(options); + const subscription = graph.watch(pattern, watchOptions(graphName, pattern, values)); + + return { + payload: { + graph: graphName, + pattern, + status: 'watching', + eventFormat: 'ndjson', + }, + exitCode: EXIT_CODES.OK, + close: closeWatch(subscription), + }; +} diff --git a/bin/cli/infrastructure.ts b/bin/cli/infrastructure.ts index 009520fd9..7ae67e2bc 100644 --- a/bin/cli/infrastructure.ts +++ b/bin/cli/infrastructure.ts @@ -111,13 +111,20 @@ Commands: patch Decode and inspect raw patches tree ASCII tree traversal from root nodes bisect Binary search for first bad patch in writer history + mcp Start a local read-only MCP server over stdio + sync Inspect sync status or sync with an HTTP peer + serve Serve the sync endpoint over HTTP + fork Create a graph fork at a writer patch + checkpoint Inspect or create checkpoint state + gc Inspect or run checkpoint garbage collection + watch Stream graph change notifications as NDJSON install-hooks Install post-merge git hook Options: --repo Path to git repo (default: cwd) --json Emit JSON output (pretty-printed, sorted keys) --ndjson Emit compact single-line JSON (for piping/scripting) - --view [mode] Visual output (ascii, svg:FILE, html:FILE) + --view [mode] Removed; use warp-ttd for visualization --graph Graph name (required if repo has multiple graphs) --writer Writer id (default: cli) -h, --help Show this help @@ -190,6 +197,45 @@ Bisect options: --bad Known-bad commit SHA (invariant violated) --test Shell command (exit 0=good, non-zero=bad) --writer Writer chain to bisect (required) + +Sync options: + status Show local sync status (default) + request Emit a sync request payload + with Sync with an HTTP peer + --path Override HTTP sync path + --materialize Materialize after sync + --auth-secret Shared-secret HMAC secret + --auth-key-id Shared-secret key id (default: default) + +Serve options: + --port Port to listen on (0 allowed) + --host Host to bind (default: 127.0.0.1) + --path Sync path (default: /sync) + --auth-secret Shared-secret HMAC secret + --auth-key-id Shared-secret key id (default: default) + --auth-mode enforce or log-only + --unsafe-allow-unauthenticated-localhost + Permit unauthenticated localhost serving + +Fork options: + --from Writer chain to fork from + --at Patch SHA to fork at + --fork-name New graph name + --fork-writer Writer id for the fork graph + +Checkpoint options: + status Show current checkpoint head (default) + create Materialize current state and create a checkpoint + sync-coverage Synchronize checkpoint coverage metadata + +GC options: + status Show current GC metrics (default) + maybe-run Run only when policy says GC is due + run Force a GC pass + +Watch options: + [pattern] Node pattern to watch (default: *) + --poll Enable polling interval `; /** @@ -230,7 +276,7 @@ export function notFoundError(message: string): CliError { return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND }); } -export const KNOWN_COMMANDS = ['info', 'check', 'doctor', 'debug', 'strand', 'materialize', 'seek', 'query', 'path', 'optic', 'history', 'verify-audit', 'verify-index', 'reindex', 'trust', 'patch', 'tree', 'bisect', 'install-hooks']; +export const KNOWN_COMMANDS = ['info', 'check', 'doctor', 'debug', 'strand', 'materialize', 'seek', 'query', 'path', 'optic', 'history', 'verify-audit', 'verify-index', 'reindex', 'trust', 'patch', 'tree', 'bisect', 'mcp', 'sync', 'serve', 'fork', 'checkpoint', 'gc', 'watch', 'install-hooks']; const BASE_OPTIONS = { repo: { type: 'string', short: 'r' }, diff --git a/docs/ADVANCED_GUIDE.md b/docs/ADVANCED_GUIDE.md index e215b38fe..b8f743587 100644 --- a/docs/ADVANCED_GUIDE.md +++ b/docs/ADVANCED_GUIDE.md @@ -7,6 +7,17 @@ Use it when you need to understand why `git-warp` is safe, how replay works, wha - If you are new, start with [Getting Started](GETTING_STARTED.md). - If you are building day-to-day product code, use the [Guide](GUIDE.md). - If you want every method and appendix in one place, use the [API Reference](API_REFERENCE.md). +- If you need noun status, use [GLOSSARY.md](GLOSSARY.md) and the + [Doctrine/runtime Alignment Ratchet](DOCTRINE_RUNTIME_ALIGNMENT.md). + +## Runtime posture + +This guide is allowed to discuss substrate internals and target doctrine, but +it must mark the difference. Today, pinned-base strands, braid support overlays, +frontier-based sync, and whole-state materialization are implementation +posture. Live holographic strands, common-basis braids, witnessed suffix +admission, and support-scoped fragments are target doctrine tracked in the +[teaching alignment audit](audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md). ## Public roots and boundaries @@ -110,6 +121,72 @@ For the normative details, use: - [Trust migration](trust/TRUST_MIGRATION.md) - [Trust operator runbook](trust/TRUST_OPERATOR_RUNBOOK.md) +### Observer redaction is not encryption + +`Aperture.redact` and Observer filtering hide fields from a selected read path. +They do not rewrite patch history, delete Git objects, or prevent a local +operator from inspecting raw objects under `.git/objects/`. Treat redaction as +application-layer projection, not data protection. + +Use vault-backed CAS content encryption when the stored bytes need protection +at rest. The key-management path is `@git-stunts/vault` and OS-native keychain +storage, not `.env` files or anonymous process-global secrets. + +### Vault-backed CAS content encryption + +CAS-backed graph content uses git-cas v6 encryption after an operator resolves +the content key through the vault workflow. Ordinary application code should not +carry anonymous raw encryption keys. The git-warp boundary is +`CasContentEncryptionPolicy`: it records the current git-cas scheme, the +verified vault source, privacy-mode state, and rotation counters, then hands the +resolved bytes to git-cas only inside the adapter call. + +```typescript +import { + CasContentEncryptionPolicy, + GitGraphAdapter, +} from '@git-stunts/git-warp'; + +const casContentEncryption = CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: resolvedVaultKey, + scheme: 'framed', + frameBytes: 64 * 1024, + vault: { + vaultSlug: 'graphs/team/content', + keyId: 'content-kek-2026-06', + verification: 'verified', + rotationEpoch: 3, + encryptionCount: 512, + encryptionCountLimit: 4294967295, + privacyMode: true, + }, +}); + +const persistence = new GitGraphAdapter({ + plumbing, + casContentEncryption, +}); +``` + +Operator flow: + +- Set up the git-cas vault first and keep its passphrase recovery procedure + outside the application process. +- Resolve and verify the vault key before constructing + `CasContentEncryptionPolicy`; wrong passphrases and missing vault metadata + are rejected at that boundary. +- Use only current git-cas schemes: `whole`, `framed`, or `convergent`. + `framed` is the usual streaming-friendly choice. `whole` is simplest but + buffers the encrypted payload as one unit. `convergent` preserves CDC + deduplication but leaks equality of identical plaintext chunks, so use it + only when the deduplication/confidentiality tradeoff is acceptable. +- Rotate before the vault encryption count reaches the git-cas nonce budget. + git-warp refuses to build a write policy once the supplied rotation witness is + at its limit. +- If git-cas reports `LEGACY_SCHEME`, migrate the old encrypted manifests with + the git-cas legacy encryption migration before restoring or rewriting them + through git-warp. + ## Advanced reads and inspection Drop below the ordinary app-facing read path when you intentionally need: @@ -149,6 +226,11 @@ own coordinate, aperture, and witness posture. Strands are the substrate's durable speculative lanes. +Status: the runtime currently uses pinned-overlay strand mechanics. The target +model is live holographic strands with basis-relative realization and +common-basis braid validation; see the +[teaching alignment audit](audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md). + What a strand records: - a pinned base observation diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 4c393266f..c8be980a4 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -10,6 +10,11 @@ Use it when you already understand the mental model and want the full method, fl - If you want engine-room internals, use the [Advanced Guide](ADVANCED_GUIDE.md). - If you want terminal workflows, use the [CLI Guide](CLI_GUIDE.md). +This reference describes shipped or transition runtime surfaces. When a stronger +WARP noun or semantic promise is still target doctrine, the status and evidence +rules live in the +[Doctrine/runtime Alignment Ratchet](DOCTRINE_RUNTIME_ALIGNMENT.md). + The rest of this file intentionally stays dense and comprehensive. For new application code, start with `openWarpWorldline()`. It returns the @@ -83,6 +88,10 @@ const todos = await openWarpWorldline({ }); ``` +`GitPlumbing` is the local name for the default export from +`@git-stunts/plumbing` v3. Do not import a named `Plumbing` symbol; v17 treats +that substrate rename as a breaking change. + ### WarpWorldlineOpenOptions `WarpWorldlineOpenOptions` accepts the same substrate ports as `WarpGraphDeps`, @@ -90,7 +99,7 @@ but the identity field is `worldlineName` instead of `graphName`. | Field | Type | Required | Description | |---|---|---|---| -| `persistence` | `CorePersistence` | Yes | Git storage adapter | +| `persistence` | `CorePersistence` (`WarpKernelPort`) | Yes | Git storage adapter | | `worldlineName` | `string` | Yes | Admitted worldline identity | | `writerId` | `string` | Yes | Writer identity | | `trust` | `{ mode?: 'off' \| 'log-only' \| 'enforce'; pin?: string \| null }` | No | Trust verification | @@ -208,7 +217,7 @@ const graph = await openWarpGraph({ | Field | Type | Required | Description | |---|---|---|---| -| `persistence` | `CorePersistence` | Yes | Git storage adapter | +| `persistence` | `CorePersistence` (`WarpKernelPort`) | Yes | Git storage adapter | | `graphName` | `string` | Yes | Graph identity | | `writerId` | `string` | Yes | Writer identity | | `trust` | `{ mode?: 'off' \| 'log-only' \| 'enforce'; pin?: string \| null }` | No | Trust verification | @@ -421,6 +430,13 @@ In every case, the commit updates `refs/warp//writers/`. It does **not** stage files, modify your normal Git worktree, or create a normal source-tree commit on your current branch. +Patch writes use a visibility contract: a successful `commit()`, +`writer.commitPatch(...)`, or `graph.patches.patch(...)` return means the patch +commit was created, the canonical writer ref advanced by compare-and-swap, and +the writer ref was read back pointing at the returned SHA. A patch object that +exists in storage but is not reachable from the visible writer tip is reported +as a failed write, not a successful hidden sibling commit. + ### Creating Patches ```typescript @@ -519,6 +535,11 @@ automatically. If another process advances the writer ref between `beginPatch()` and `commit()`, the commit fails with `WRITER_REF_ADVANCED` rather than silently losing data. +The returned SHA is the visible writer-tip commit. If the patch commit is +created but the writer ref cannot be advanced and verified at that SHA, the +write fails and post-commit hooks such as eager cache updates and audit receipt +recording do not run. + ### Writer ID Resolution When you call `graph.patches.writer()` without arguments, the ID is resolved from git config (`warp.writerId.`). If no config exists, a new canonical ID is generated and persisted. This gives each clone a stable, unique identity. @@ -715,6 +736,51 @@ const result = await worldline.query() - `'*:admin'` — matches `org:admin`, `team:admin`, etc. - `'doc:*:draft'` — matches `doc:1:draft`, `doc:abc:draft`, etc. +#### Support Rule Inspection + +`supportRule()` returns the current `BoundedSupportRule` for the accumulated +query plan. Exact node-id reads are `entity` support, exact node-id traversals +are `neighborhood` support, and wildcard/discovery reads are +`global-discovery`. + +```typescript +const support = worldline.query() + .match('user:alice') + .outgoing('manages', { depth: [1, 2] }) + .supportRule(); + +support.kind; // 'neighborhood' +support.isBounded(); // true +support.maxDepth; // 2 +``` + +This is an execution contract, not an index. It lets future causal indexes and +support fragments know which support set a read is allowed to use; it does not +make wildcard discovery cheaper by itself. + +For provider authors, `QueryRunner` sends both `supportRule` and +`causalIndexPlan` in `QueryReadModelOpenRequest`. `CausalIndexPlan` maps exact +entity reads to the existing provenance entity-patch index, maps exact rooted +traversals to a composite entity-patch plus neighborhood-adjacency posture, and +marks wildcard discovery as requiring a global scan. + +`supportFragmentPlan()` exposes the fragment-materialization posture derived +from the same support rule: + +```typescript +const fragmentPlan = worldline.query() + .match('user:alice') + .supportFragmentPlan(); + +fragmentPlan.canMaterializeSupportFragment(); // true +fragmentPlan.fragmentKeyForCoordinate('frontier:demo'); +``` + +`QueryReadModelOpenRequest` also carries `supportFragmentPlan`, so read-model +providers can cache fragments keyed by support scope plus coordinate. Wildcard +and discovery queries produce `global-fallback` plans instead of fake fragment +keys. + #### Filtering with `where()` **Object shorthand** — strict equality on primitive values. Multiple properties use AND semantics: @@ -818,6 +884,30 @@ const result = await worldline.query() .run(); ``` +### Graph Diff + +Use `graph.comparison.diff({ from, to })` when a caller asks what changed +between two live Lamport ceilings. The result is a frozen `GraphDiff` object +with structural and property deltas, visible patch divergence, and the resolved +coordinate summaries used to compute it. + +```typescript +const diff = await graph.comparison.diff({ + from: 120, + to: 135, + targetId: 'user:alice', +}); + +diff.diffVersion; // 'graph-diff/v1' +diff.nodes.added; +diff.nodeProperties.changed; +diff.visiblePatchDivergence.rightOnlyPatchShas; +``` + +`diff()` resolves the two ceilings as live coordinate reads and uses the +substrate comparison engine. It is not implemented by `query().match('*')` or +by client-side wildcard scans. + ### Graph Traversals `LogicalTraversal` is available on `Worldline`, `Observer`, and @@ -1326,6 +1416,33 @@ const admins = await view.query().match('user:*').where({ role: 'admin' }).run() const path = await view.traverse.shortestPath('user:alice', 'user:bob', { dir: 'out' }); ``` +The same observer can also surface its source/config split explicitly: + +```typescript +const plan = view.plan(); +// plan.source = { kind: 'live' } or the coordinate/strand selector used + +const envelope = await view.readingEnvelope({ + witnessRef: 'receipt-or-proof-ref', + shellRef: 'observer-shell-ref', + receiptAnchors: [receiptBoundary.stableAnchor()], +}); + +envelope.payload.nodeCount; +envelope.budget.propertyKeyCount; +envelope.residualBasis; +envelope.receiptAnchors[0]?.patchSha; +``` + +`ObserverPlan` freezes the observer name, aperture, structural basis, and +worldline source. `ObserverReadingEnvelope` ties that plan to an emitted +`ObserverEmission` payload, optional witness/shell/plurality references, and +validated receipt anchors from `GitWarpReceiptEnvelopeBoundary`, plus budget +metadata derived from the payload. Normal node/query/traversal reads and +envelope reads therefore share the same observer family, and external tools do +not need raw receipt `ops` or debug `reason` strings for observer-level routing +decisions. + For higher-layer reads, this is the preferred boundary: choose a worldline, choose an observer, optionally seek, then read through that observer instead of reconstructing a second graph-shaped read model above the substrate. @@ -1334,6 +1451,11 @@ Observers are pinned read handles. By default they capture the current materialized coordinate at creation time. They can also bind directly to an explicit coordinate or a pinned strand instead of following live truth. +Strand-backed observers use the strand overlay plus the current parent basis. +Untouched parent regions therefore follow live truth; callers that need a +frozen historical basis should bind an explicit coordinate instead of a strand +source. + `graph.query.observer(...)` remains available as a convenience entry point, but `worldline()` is the clearer public noun when the caller wants to pin history explicitly. @@ -2334,6 +2456,62 @@ PropSet user:alice.name: superseded **Zero-cost when disabled:** When receipts are not requested (the default), there is strictly zero overhead — no arrays allocated, no strings constructed. +External envelope consumers should not reinterpret raw receipt objects as a +stable protocol. Wrap a diagnostic receipt in `GitWarpReceiptEnvelopeBoundary` +when another tool needs substrate-owned anchors: + +```typescript +const boundary = new GitWarpReceiptEnvelopeBoundary({ receipt }); +const anchor = boundary.stableAnchor(); +// anchor = { +// boundaryVersion: 'git-warp.receipt-envelope-boundary/v1', +// substrateFactKind: 'git-warp.tick-receipt', +// patchSha: '...', +// writer: 'alice', +// lamport: 7, +// outcomeCount: 3, +// appliedCount: 2, +// supersededCount: 1, +// redundantCount: 0, +// hasExplanatoryReasons: true, +// } +``` + +The boundary deliberately excludes raw `ops` and `reason` text. Those stay +diagnostic; the stable contract is the receipt identity and aggregate outcome +anchor. + +Witnessed suffix import/export is represented by +`GitWarpWitnessedSuffixAdmissionShell`. The shell names the graph, lane, +transported site, source/basis/target frontiers, patch references, witness +material, admission law, transport law, explicit import outcome, and replay +hologram without reducing protocol truth to a naked `frontier + patches` +transport list. + +```typescript +const shell = new GitWarpWitnessedSuffixAdmissionShell({ + laneId: 'lane:writer-remote', + transportedSiteRef: 'site:remote', + admissionLawId: 'admission-law:witnessed-suffix', + outcome: GitWarpWitnessedSuffixAdmissionOutcome.admitted(), + sourceFacts, + hologram, +}); + +shell.patchRefs; // stable patch references for observers +shell.materializeFrom(localBasis); // deterministic target materialization +``` + +Admission outcomes are runtime-backed: + +| Outcome | Meaning | +|---|---| +| `admitted` | The suffix was normalized and admitted against the basis. | +| `staged` | The suffix is witnessed but waiting for local prerequisites. | +| `plural` | Multiple lawful continuations remain and need selection. | +| `conflict` | The suffix overlaps with local history in a conflicting way. | +| `obstruction` | A law or witness gap blocks admission. | + Use normal query/worldline readings when you do not need diagnostic receipt detail. @@ -2362,6 +2540,18 @@ const response = await graph.sync.processSyncRequest(request); const { applied } = await graph.sync.applySyncResponse(response); ``` +Sync requests may include a bounded response page: + +```typescript +const request = await graph.sync.createSyncRequest(); +request.page = { maxPatches: 500, cursor: null }; + +const response = await graph.sync.processSyncRequest(request); +// response.page.cursor is the next cursor when response.page.hasMore is true. +// response.metrics reports patch count, skipped writer count, estimated payload +// bytes, and caller-supplied latency when the server measured it. +``` + #### High-Level API For most use cases, use `syncWith()` which handles the full round-trip: @@ -2414,6 +2604,11 @@ const { close, url } = await graph.sync.serve({ await close(); // shut down ``` +HTTP sync auth remains shared-secret HMAC. New signed requests declare +`x-warp-auth-scheme: shared-secret-hmac-sha256`; peers accept legacy HMAC +requests without that header during migration and reject unsupported declared +schemes before HMAC verification. + Non-local bind hosts require enforced auth with a per-key rate-limit budget. Local unauthenticated serving is available only with `unsafeAllowUnauthenticatedLocalhost: true`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fa334ac88..b76bce87b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -129,6 +129,12 @@ The advanced compatibility entry point. Returns a frozen capability bag for tooling, diagnostics, and graph-first integrations that intentionally need the lower-level surface: +The flat aliases are canonical for user-facing examples. Moment-scoped names +are available for explicit architecture code and point at the same objects: +`graph.patches === graph.commitment.patches`, +`graph.query === graph.revelation.query`, and +`graph.checkpoint === graph.folding.checkpoint`. + ```text const graph = await openWarpGraph({ persistence, graphName, writerId }); @@ -203,7 +209,9 @@ Stateless services that implement domain logic: Abstract contracts between domain and infrastructure: -- **GraphPersistencePort** — composite of CommitPort + BlobPort + TreePort + RefPort +- **GraphPersistencePort** — runtime composite of CommitPort + BlobPort + TreePort + RefPort +- **WarpKernelPort** — type-only kernel persistence contract for CommitPort + + BlobPort + TreePort + RefPort - **CodecPort** — encode/decode (CBOR) - **CryptoPort** — hash, hmac, sign, verify - **ClockPort** — wall clock (injected, not ambient) diff --git a/docs/CLI_GUIDE.md b/docs/CLI_GUIDE.md index 527100fb5..c69249f05 100644 --- a/docs/CLI_GUIDE.md +++ b/docs/CLI_GUIDE.md @@ -38,12 +38,8 @@ git warp check --repo ./team-repo git warp doctor --repo ./team-repo --strict ``` -If you want the terminal dashboard view, use: - -```bash -git warp --view info --repo ./team-repo -git warp --view check --repo ./team-repo -``` +The old `--view` flag has been removed. Use `warp-ttd` for visualization +workflows instead of asking `git warp` to render dashboards. Typical operator output looks like this: @@ -178,6 +174,50 @@ git warp install-hooks --repo ./team-repo These are operator and maintainer tools, not normal product APIs. +Checkpoint and GC operations are explicit command families: + +```bash +git warp checkpoint status --repo ./team-repo +git warp checkpoint create --repo ./team-repo +git warp checkpoint sync-coverage --repo ./team-repo +git warp gc status --repo ./team-repo +git warp gc maybe-run --repo ./team-repo +git warp gc run --repo ./team-repo +``` + +Programmatic sync can be inspected, initiated, or served from the CLI: + +```bash +git warp sync status --repo ./team-repo +git warp sync request --repo ./team-repo --json +git warp sync with http://127.0.0.1:3900/sync --repo ./team-repo --auth-secret "$WARP_SYNC_SECRET" +git warp serve --repo ./team-repo --port 3900 --auth-secret "$WARP_SYNC_SECRET" +``` + +For local-only experiments, `serve` can run without auth only when the operator +explicitly chooses the unsafe localhost mode: + +```bash +git warp serve --repo ./team-repo --port 3900 --unsafe-allow-unauthenticated-localhost +``` + +Use `fork` when you need a new graph ref rooted at a known writer patch: + +```bash +git warp fork --repo ./team-repo --from alice --at --fork-name experiment +``` + +Use `watch` for NDJSON change notifications: + +```bash +git warp watch 'task:*' --repo ./team-repo --poll 2000 --ndjson +``` + +`export` / `import` and `upgrade` / `migrate` remain intentionally absent from +the CLI registry until the repository has explicit file-exchange and substrate +upgrade adapter boundaries. Do not paper over those gaps with command names that +pretend a complete boundary exists. + ## Where next - [Guide](GUIDE.md): builder patterns for app code diff --git a/docs/CONCEPTUAL_OVERVIEW.md b/docs/CONCEPTUAL_OVERVIEW.md index ff907a7df..62db735f0 100644 --- a/docs/CONCEPTUAL_OVERVIEW.md +++ b/docs/CONCEPTUAL_OVERVIEW.md @@ -5,6 +5,8 @@ This document explains the core ideas behind `git-warp` in plain language. Use it when you understand what the package does and want a deeper conceptual model before diving into the implementation specs or the formal AIΩN papers. For canonical noun definitions, use [GLOSSARY.md](GLOSSARY.md). +For the rule that separates shipped runtime behavior from target doctrine, use +the [Doctrine/runtime Alignment Ratchet](DOCTRINE_RUNTIME_ALIGNMENT.md). ## The Core Idea diff --git a/docs/DOCTRINE_RUNTIME_ALIGNMENT.md b/docs/DOCTRINE_RUNTIME_ALIGNMENT.md new file mode 100644 index 000000000..2cd34263d --- /dev/null +++ b/docs/DOCTRINE_RUNTIME_ALIGNMENT.md @@ -0,0 +1,77 @@ +# Doctrine/runtime alignment ratchet + +This guardrail defines when `git-warp` docs may run ahead of runtime +implementation and what evidence is required before a noun, API, or semantic +promise can be treated as settled. + +The rule is simple: docs may name the target, but only runtime evidence can +make the target current. + +## Status labels + +Use the same status words as [GLOSSARY.md](GLOSSARY.md): + +- **shipped**: implemented runtime behavior with public or internal consumers, + executable tests, and docs that describe it as current behavior. +- **transition**: partially implemented behavior or vocabulary that exists in + runtime but still carries compatibility, migration, or naming debt. +- **target**: intended doctrine or design direction that is not yet a complete + runtime contract. +- **historical**: archived or superseded material retained as evidence, not as + current guidance. + +Public docs may teach `shipped` and `transition` behavior as usable current +surfaces. They must not describe `target` behavior as already available. + +## Allowed docs-ahead posture + +Doctrine and design notes may run ahead of implementation when all of these are +true: + +- the stronger claim is marked `target` or `transition` +- the doc links to either [WARP_DRIFT.md](audits/WARP_DRIFT.md), a design note, + or a GitHub Issue that owns the runtime work +- public product docs preserve the current runtime behavior while naming the + stronger target as future or in-progress work +- release notes do not list the target as shipped until runtime evidence exists + +If those conditions are not met, the doc is not aspirational design; it is +runtime drift. + +## Runtime evidence + +A noun, API, or semantic promise moves from `target` to `transition` or +`shipped` only when the repository contains inspectable runtime evidence: + +- a runtime-backed exported noun, domain class, port, command, or adapter +- constructor, parser, or boundary validation for its important invariants +- behavior tests or conformance tests for the claimed semantics +- docs that point to the same noun and use the correct status label +- a GitHub Issue, design note, or drift-ledger row for remaining gaps +- public API cost posture when the surface is exported + +Tests are not optional decoration. If a future reader cannot replay the +evidence locally, the status has not ratcheted. + +## Major noun checklist + +Before treating a major public noun as settled: + +- add or update its [GLOSSARY.md](GLOSSARY.md) row +- mark it `shipped`, `transition`, or `target` +- connect any gap to [WARP_DRIFT.md](audits/WARP_DRIFT.md) or a GitHub Issue +- add executable coverage for the runtime behavior that carries the noun +- make public docs say whether the noun is current runtime truth or target + doctrine + +This applies to worldlines, coordinates, observers, optics, strands, braids, +suffix transport, holograms, admission shells, and future WARP nouns. + +## Release rule + +A release may include doctrine that is ahead of runtime, but the release must +make that posture visible. Changelogs, guides, and API references must separate +`shipped`, `transition`, and `target` claims before the release is cut. + +When in doubt, keep the target in design or audit docs and make the public API +docs smaller until runtime evidence catches up. diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 95afe4127..0c66e3736 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -32,8 +32,10 @@ If another document uses one of these nouns differently, this glossary wins. | Term | Canonical meaning | Current repo manifestation | Status | |---|---|---|---| -| `Worldline` | The causal history of a deterministic read basis; a lawful history object, not merely a handle. | The current `Worldline` class is a pinned read coordinate facade, not the full historical object. | transition | -| `Observer` | The realized reading surface for a question asked through an aperture. It executes a read contract and returns a view. | Current `Observer` is mostly the projection/filter half over materialized state. | transition | +| `Worldline` | The causal history of a deterministic read basis; a lawful history object, not merely a handle. | Public entry uses `openWarpWorldline()` for admitted lane workflows; pinned reads now return `ProjectionHandle` instead of a class named `Worldline`. | transition | +| `ProjectionHandle` | A pinned read/projection handle over a selected worldline source. | Returned by `WarpWorldline.live()`, `WarpWorldline.seek(...)`, and `graph.query.worldline(...)`. | shipped | +| `Observer` | The realized reading surface for a question asked through an aperture. It executes a read contract and returns a view. | Current `Observer` exposes normal read/query/traversal methods plus `ObserverPlan` and `ObserverReadingEnvelope` for the source-plan-reading split. | transition | +| `Live strand` | A strand realization over a live parent basis plus owned overlay divergence. Untouched parent regions should flow through from the current parent; overlay writes remain owned by the strand. | `StrandMaterializer` now resolves parent-basis patches from `getFrontier()` when the runtime provides it, while older test seams fall back to `baseObservation.frontier`. | transition | | `Aperture` | The observer-relative read boundary: what distinctions remain visible and which basis the read is taken over. | Current `Aperture` is a small `{ match, expose, redact }` policy object. | transition | | `Optic` | The semantic question being asked of the graph. It defines the shape of the read, not the execution plan. | No first-class optic noun exists in runtime today. | target | @@ -41,9 +43,9 @@ If another document uses one of these nouns differently, this glossary wins. | Term | Canonical meaning | Current repo manifestation | Status | |---|---|---|---| -| `Bounded support rule` | The smallest causally sufficient support set required to answer an optic through an aperture honestly. | Missing as a first-class runtime noun; partially implied by provenance and slice materialization. | target | -| `Causal index` | A materialized, rebuildable acceleration structure that helps find the relevant support set without whole-graph discovery. | Bits of this exist in provenance and receipts, but not as a unified indexed runtime surface. | target | -| `Support fragment` | A cached partial materialization keyed by support contract and coordinate, reusable for later reads. | Today the runtime mostly assumes one full cached state; fragments are not yet primary. | target | +| `Bounded support rule` | The smallest causally sufficient support set required to answer an optic through an aperture honestly. | `BoundedSupportRule` now exposes exact-entity, neighborhood, and global-discovery posture for query plans; execution still uses existing read models until causal indexes and fragments consume the rule. | transition | +| `Causal index` | A materialized, rebuildable acceleration structure that helps find the relevant support set without whole-graph discovery. | `CausalIndexPlan` now maps bounded query support rules to entity-patch, neighborhood-adjacency, or global-discovery index posture; the entity-patch family is backed by `ProvenanceIndex`. | transition | +| `Support fragment` | A cached partial materialization keyed by support contract and coordinate, reusable for later reads. | `SupportFragmentPlan` now travels through query open requests and names cacheable support scope versus global fallback; fragment cache storage is still pending. | transition | | `Materialization plan` | The runtime execution plan that decides whether to use receipts, indexes, fragments, replay, or full state to satisfy a read. | Not explicit today; buried in controller behavior. | target | ## Change and proof nouns @@ -52,7 +54,8 @@ If another document uses one of these nouns differently, this glossary wins. |---|---|---|---| | `Witness` | Minimal information sufficient to justify a local change/rewrite result. | No first-class witness type yet. | target | | `TickReceipt` | The operational envelope recording what happened for one admitted step, including outcomes and enough data to audit the admission. | First-class runtime type today. Larger than a witness. | shipped | -| `GraphDiff` | A first-class change result answering “what changed between these coordinates?” | Not yet a public runtime noun; substrate pieces exist (`PatchDiff`, `StateDiff`, receipts). | target | +| `Witnessed suffix admission shell` | Observer-readable import/export envelope for a transported suffix normalized against a comparable basis, with explicit admitted/staged/plural/conflict/obstruction outcome. | `GitWarpWitnessedSuffixAdmissionShell` binds source facts, patch references, witness material, and a replay-bearing suffix hologram. Sync still carries frontier negotiation as transport optimization until protocol wiring is upgraded. | transition | +| `GraphDiff` | A first-class change result answering “what changed between these coordinates?” | `GraphDiff` is returned by the comparison diff API for live Lamport ranges and is built from the same visible-state comparison engine as coordinate comparison. | transition | ## Persistence nouns diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 774eb1503..59aabb5d2 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -11,13 +11,26 @@ Use it when you are writing an app, an agent workflow, or a local-first tool on - If you want terminal workflows, use the [CLI Guide](CLI_GUIDE.md). - If you want the canonical meaning of core nouns like `Worldline`, `Observer`, `Aperture`, or `Coordinate`, use [GLOSSARY.md](GLOSSARY.md). +- If a doc claim seems stronger than the current API, use the + [Doctrine/runtime Alignment Ratchet](DOCTRINE_RUNTIME_ALIGNMENT.md) and the + [teaching alignment audit](audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md). + +## Runtime posture + +This guide teaches shipped and transition APIs for builder workflows. Worldline +commits, live reads, coordinates, observers, and apertures are the current +application path. Strand examples below describe the current pinned-overlay +implementation; live holographic strands, common-basis braids, witnessed suffix +admission, and support-scoped fragment materialization remain target doctrine. ## Mental model The most important thing to understand is state before methods. - `openWarpWorldline()` returns the first-use handle for application workflows. -- A `Worldline` is an admitted causal lane and a pinned read coordinate. +- A `Worldline` is an admitted causal lane. +- A `ProjectionHandle` is the pinned read handle returned by `live()`, + `seek(...)`, and `graph.query.worldline(...)`. - An `Aperture` defines what is visible. - An `Observer` is a filtered read-only view through that aperture. - A `Strand` is a speculative write lane branched from an observation. @@ -120,12 +133,15 @@ await graph.strands.patchStrand('review-auth', (p) => { const reviewLane = graph.query.worldline({ source: { kind: 'strand', strandId: 'review-auth' }, }); -// reviewLane is a Worldline pinned to the strand overlay +// reviewLane is a ProjectionHandle pinned to the strand overlay ``` Use strands for speculative work. Use ordinary patches for live truth. -For the deeper substrate story behind strands, braids, and transfer planning, use [Advanced Guide -> Strands and braids](ADVANCED_GUIDE.md#strands-and-braids). +For the deeper substrate story behind strands, braids, and transfer planning, +use [Advanced Guide -> Strands and braids](ADVANCED_GUIDE.md#strands-and-braids). +For the target-model gap, use the +[teaching alignment audit](audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md). ## Streamed substrate work @@ -193,6 +209,15 @@ const users = await view.query().match('user:*').run(); // } ``` +Observer redaction is application-layer filtering. It is useful for +multi-tenant views and product isolation, but it is not a cryptographic +boundary: a user with filesystem access to `.git/objects/` can still inspect +raw patch objects unless the graph content is encrypted at rest. Use +`CasContentEncryptionPolicy` and the vault-backed CAS workflow in the +[Advanced Guide](ADVANCED_GUIDE.md#vault-backed-cas-content-encryption) when +the data itself must be protected. `@git-stunts/vault` is the intended key +management path; do not put graph encryption secrets in `.env` files. + ### Pattern 3: the historical view Pin an explicit coordinate when you need to ask what the graph looked like earlier. @@ -280,6 +305,22 @@ const downstream = await worldline.query() // } ``` +The same accumulated plan can expose its support law before execution: + +```typescript +const support = worldline.query() + .match('epic:auth') + .outgoing('contains', { depth: [1, 2] }) + .supportRule(); + +// support.kind = 'neighborhood' +// support.maxDepth = 2 +``` + +Use support rules to decide whether a read is exact, neighborhood-bounded, or +global discovery. They do not replace indexes; they tell future indexes and +support fragments what the read is allowed to ask for. + ### Pattern 3: aggregate ```typescript diff --git a/docs/README.md b/docs/README.md index bc908fd01..b87bc8c83 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,9 @@ when you need that level of detail. Current committed release and milestone inventory. - [Release Guide](method/release.md) Release and preflight process. +- [Doctrine/runtime Alignment Ratchet](DOCTRINE_RUNTIME_ALIGNMENT.md) + Status labels, evidence requirements, and release rules for docs that run + ahead of runtime implementation. - [Trust Migration](trust/TRUST_MIGRATION.md) Migration path for signed trust evidence. - [Trust Operator Runbook](trust/TRUST_OPERATOR_RUNBOOK.md) diff --git a/docs/VISION.md b/docs/VISION.md index 92f6dcc5d..076ce0943 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -40,6 +40,13 @@ The architecture decomposes into three moments: - **Folding** — admitted history is re-expressed in boundary-equivalent form - **Revelation** — admitted truth is exposed under bounded rights +The public API prefers flat capability aliases for ordinary code: +`graph.patches`, `graph.query`, and `graph.checkpoint`. Moment-scoped names +remain available when architectural explicitness matters: +`graph.commitment.patches`, `graph.revelation.query`, and +`graph.folding.checkpoint`. They are aliases for the same runtime objects, not +separate APIs. + The read-side correction now matters just as much as the admission-side one: - the substrate is witnessed causal history, not a canonical materialized graph diff --git a/docs/archive/retrospectives/2026-04-01-tsc-zero-and-joinreducer-strategy.md b/docs/archive/retrospectives/2026-04-01-tsc-zero-and-joinreducer-strategy.md index 82f8d1aa6..ab79e7019 100644 --- a/docs/archive/retrospectives/2026-04-01-tsc-zero-and-joinreducer-strategy.md +++ b/docs/archive/retrospectives/2026-04-01-tsc-zero-and-joinreducer-strategy.md @@ -137,7 +137,10 @@ agent-authored type fixes that were never audited for subtle semantic drift (e.g. changed fallback values, widened types, reordered logic). Tests passing does not guarantee absence of drift. -**Status:** **not aligned** — tracked as B171 (high priority audit). +**Original status:** **not aligned** — tracked as B171 (high priority audit). + +**Closeout:** retired by the 2026-06-20 audit: +[TSC Zero Agent Merge Audit](../../audit/2026-06-20_tsc-zero-agent-merge-audit.md). ## Playback diff --git a/docs/audit/2026-06-20_tsc-zero-agent-merge-audit.md b/docs/audit/2026-06-20_tsc-zero-agent-merge-audit.md new file mode 100644 index 000000000..f7270f3ab --- /dev/null +++ b/docs/audit/2026-06-20_tsc-zero-agent-merge-audit.md @@ -0,0 +1,160 @@ +# TSC Zero Agent Merge Audit + +Date: 2026-06-20 + +Issue: [#505](https://github.com/git-stunts/git-warp/issues/505) + +Historical source: B171 from the TSC Zero retrospective. + +PR: [#73](https://github.com/git-stunts/git-warp/pull/73) +(`1391e68f9b7b99d5c80791e441e08e1069be3a7f`) + +## Verdict + +Retired. No revert is required. + +The original backlog card said 27 files were merged through +`checkout --theirs`. Git does not preserve that operator action directly, and +the 27-file count is not reproducible from repository metadata. The closest +reproducible source of truth is Git's remerge reconstruction for the ten +`worktree-agent-*` merge commits in PR #73. + +That reconstruction yields 55 unique conflict-resolution paths: + +- 44 production, runtime, CLI, visualization, infrastructure, and script paths +- 10 test paths +- 1 lint configuration path + +This audit treats those 55 paths as the authoritative closeout scope. + +## Evidence Commands + +```bash +gh pr view 73 --json number,title,state,mergedAt,mergeCommit,baseRefName,headRefName,files + +git show --no-patch --pretty=raw 1391e68f9b7b99d5c80791e441e08e1069be3a7f + +for commit in \ + c9671d51 b4d2b0b7 f6d5c066 a7c6e7bd 4562ab94 \ + b4bdd83d ab782c72 ff39b132 98588d34 3bb3b437 +do + git show --remerge-diff --name-only --format='' "$commit" +done | sed '/^$/d' | sort -u +``` + +The same merge commits were also inspected with: + +```bash +git show --remerge-diff +``` + +## Merge Commits + +| Commit | Subject | +| --- | --- | +| `c9671d51` | Merge branch `worktree-agent-a3c8ac74` | +| `b4d2b0b7` | Merge branch `worktree-agent-aa22fb83` | +| `f6d5c066` | Merge branch `worktree-agent-a81ab056` | +| `a7c6e7bd` | Merge branch `worktree-agent-a90cb614` | +| `4562ab94` | Merge branch `worktree-agent-a6c61824` | +| `b4bdd83d` | Merge branch `worktree-agent-a9f2120f` | +| `ab782c72` | Merge branch `worktree-agent-a752bfa8` | +| `ff39b132` | Merge branch `worktree-agent-ae175dcf` | +| `98588d34` | Merge branch `worktree-agent-a7651f20` | +| `3bb3b437` | Merge branch `worktree-agent-a3ae68f1` | + +## Reconstructed Paths + + + +- `bin/cli/commands/bisect.js` +- `bin/cli/commands/debug/conflicts.js` +- `bin/cli/commands/query.js` +- `bin/cli/commands/strand/materialize.js` +- `bin/cli/commands/verify-audit.js` +- `bin/cli/commands/verify-index.js` +- `bin/presenters/index.js` +- `bin/presenters/text.js` +- `bin/warp-graph.js` +- `eslint.config.js` +- `src/domain/WarpRuntime.js` +- `src/domain/services/AdjacencyNeighborProvider.js` +- `src/domain/services/AnchorMessageCodec.js` +- `src/domain/services/AuditMessageCodec.js` +- `src/domain/services/BitmapIndexBuilder.js` +- `src/domain/services/BoundaryTransitionRecord.js` +- `src/domain/services/CheckpointMessageCodec.js` +- `src/domain/services/CheckpointSerializerV5.js` +- `src/domain/services/CheckpointService.js` +- `src/domain/services/ConflictAnalyzerService.js` +- `src/domain/services/HttpSyncServer.js` +- `src/domain/services/IncrementalIndexUpdater.js` +- `src/domain/services/IndexRebuildService.js` +- `src/domain/services/JoinReducer.js` +- `src/domain/services/PatchBuilderV2.js` +- `src/domain/services/PatchMessageCodec.js` +- `src/domain/services/QueryBuilder.js` +- `src/domain/services/StateReaderV5.js` +- `src/domain/services/StrandService.js` +- `src/domain/services/SyncAuthService.js` +- `src/domain/services/SyncController.js` +- `src/domain/services/TemporalQuery.js` +- `src/domain/services/WarpStateIndexBuilder.js` +- `src/domain/services/WormholeService.js` +- `src/domain/trust/TrustCanonical.js` +- `src/domain/trust/TrustEvaluator.js` +- `src/domain/trust/TrustRecordService.js` +- `src/domain/trust/TrustStateBuilder.js` +- `src/domain/types/DeliveryObservation.js` +- `src/domain/utils/MinHeap.js` +- `src/domain/warp/comparison.methods.js` +- `src/infrastructure/adapters/CasSeekCacheAdapter.js` +- `src/infrastructure/adapters/GitGraphAdapter.js` +- `src/visualization/renderers/ascii/path.js` +- `src/visualization/renderers/ascii/seek.js` +- `test/unit/domain/WarpCore.emit.test.js` +- `test/unit/domain/WarpGraph.audit.test.js` +- `test/unit/domain/services/AuditReceiptService.test.js` +- `test/unit/domain/services/AuditVerifierService.test.js` +- `test/unit/domain/services/LogicalBitmapIndexBuilder.test.js` +- `test/unit/domain/services/LogicalIndexBuildService.test.js` +- `test/unit/domain/services/MaterializedViewService.test.js` +- `test/unit/domain/trust/TrustAdversarial.test.js` +- `test/unit/domain/trust/TrustEvaluator.test.js` +- `test/unit/domain/trust/TrustRecordService.convergence.test.js` + + + +## Audit Notes + +The reviewed remerge hunks fell into four risk classes. + +| Risk class | Historical examples | Current disposition | +| --- | --- | --- | +| Truthiness replacing nullish checks | `QueryBuilder.js`, `WormholeService.js`, `bin/warp-graph.js` | Current query and aggregation owners branch on explicit `undefined` and `null` semantics where empty labels, empty arrays, or false booleans are behaviorally significant. | +| Raw errors replacing domain errors | `WarpRuntime.js`, `SyncAuthService.js` | Current runtime helpers throw `WarpError` for trust configuration, and sync auth throws `SyncError` for configuration failures. | +| Helper deletion or inline rewrites | `WarpRuntime.js`, `CheckpointSerializerV5.js`, `StateReaderV5.js` | Current TS modules restored named helpers around effect pipeline construction, checkpoint serialization, state reading, and aggregation. | +| Type-only conflict resolution in tests and presenters | test paths, presenter paths, CLI output paths | Current TS-only surfaces compile through `tsconfig.src.json`, `tsconfig.test.json`, and eslint; no old `src` or `bin` `.js` files remain. | + +The highest-risk historical hunks were checked against these current owners: + +| Current owner | Relevant historical risk | Disposition | +| --- | --- | --- | +| `src/domain/runtimeHelpers.ts` | trust validation, effect pipeline construction, raw `Error` drift | Uses `WarpError`, explicit nullish checks, and named `buildEffectPipeline()`. | +| `src/domain/warp/RuntimeHostBoot.ts` | open-option normalization and trust spread drift | Uses `normalizeTrustConfig()` through the runtime-backed `WarpOpenOptions` boundary. | +| `src/domain/services/query/QueryBuilder.ts` | label and aggregate truthiness drift | Validates labels, preserves empty string labels by using `label !== undefined`, and validates aggregate field types. | +| `src/domain/services/query/QueryRunner.ts` | traversal filter drift | Forwards labels only when defined and keeps query result projection deterministic. | +| `src/domain/services/query/QueryAggregation.ts` | aggregate active-key drift | Uses `spec[key] !== undefined && spec[key] !== null`, not truthiness. | +| `src/domain/services/sync/SyncAuthService.ts` | raw `Error`, wall-clock, and signature validation drift | Uses `SyncError`, `SyncSecret`, lamport timestamps, and explicit missing-header checks. | +| `src/domain/services/state/CheckpointSerializer.ts` | checkpoint fallback and schema error drift | Uses `SchemaUnsupportedError`, `WarpError`, sorted serialization helpers, and explicit envelope decoding. | +| `src/domain/services/state/StateReader.ts` | visible edge/content filtering drift | Keeps reader behavior in named functions with current state-reader tests. | +| `src/domain/services/WormholeService.ts` | removed null guard and parent-chain drift | Current tests cover nullish JSON input, required fields, and malformed wormhole payloads. | + +## Closeout + +B171/#505 was a historical audit gap, not a current feature request. The +reconstructed merge conflict scope has been reviewed against the current TS +owners, and the surviving surfaces have explicit tests or type/lint gates. + +No suspicious semantic drift remains from the PR #73 conflict-resolution +events. The issue can close with this artifact. diff --git a/docs/audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md b/docs/audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md new file mode 100644 index 000000000..7112bbc93 --- /dev/null +++ b/docs/audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md @@ -0,0 +1,52 @@ +# WARP doctrine/runtime teaching alignment + +This audit applies the +[Doctrine/runtime Alignment Ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md) to the +main teaching docs. It is the readable checklist for issue +[#556](https://github.com/git-stunts/git-warp/issues/556). + +The purpose is not to weaken WARP doctrine. The purpose is to stop public docs +from teaching target nouns as if they are already complete runtime law. + +## Teaching surface matrix + +| Surface | Runtime posture | Required pointer | +|---|---|---| +| `README.md` | First-use docs teach worldline-first application work as current, observer and coordinate nouns as transition, and strands, braids, and suffix admission as target or advanced substrate work. | [GLOSSARY.md](../GLOSSARY.md) | +| `docs/GUIDE.md` | Builder patterns use shipped and transition APIs while warning that strand examples are the current pinned-overlay implementation, not live holographic strands. | [Doctrine/runtime Alignment Ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md) | +| `docs/ADVANCED_GUIDE.md` | Engine-room docs may describe substrate mechanics, but must mark pinned-base strands, braid support, and sync transport as implementation posture rather than final WARP doctrine. | [WARP_DRIFT.md](WARP_DRIFT.md) | +| `docs/API_REFERENCE.md` | Exhaustive API docs describe shipped or transition surfaces and point target doctrine back to the ratchet. | [Doctrine/runtime Alignment Ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md) | +| `docs/CONCEPTUAL_OVERVIEW.md` | Conceptual docs may explain WARP doctrine, but must distinguish current runtime behavior from target doctrine. | [GLOSSARY.md](../GLOSSARY.md) | + +## Active reconciliation hills + +These issues own the currently visible doctrine/runtime gaps: + +- [#560 Live holographic strands](https://github.com/git-stunts/git-warp/issues/560) + owns the move from pinned-base overlays to basis-relative strand realization. +- [#561 Observer plans and reading envelopes](https://github.com/git-stunts/git-warp/issues/561) + owns the move from snapshot/filter observers to plan-backed reading envelopes. +- [#564 Witnessed suffix admission shells](https://github.com/git-stunts/git-warp/issues/564) + owns the move from frontier-plus-patches sync to witnessed suffix admission. +- [#558 Bounded support rules for query surfaces](https://github.com/git-stunts/git-warp/issues/558), + [#559 Causal indexes for sliced queries](https://github.com/git-stunts/git-warp/issues/559), + [#562 Support-scoped fragment materialization](https://github.com/git-stunts/git-warp/issues/562), + and [#563 Tick-range graph diff API](https://github.com/git-stunts/git-warp/issues/563) + own the bounded-read execution model that keeps observers from falling back + to whole-graph materialization by default. +- [#557 WESLEY Receipt Envelope Boundary](https://github.com/git-stunts/git-warp/issues/557) + and [#554 Observer-readable receipts](https://github.com/git-stunts/git-warp/issues/554) + own the receipt/provenance split between substrate facts, debug envelopes, and + observer-readable truth. + +## Reader contract + +When an entry-point doc teaches a WARP noun: + +- it must either link to [GLOSSARY.md](../GLOSSARY.md) or use the same status + words: `shipped`, `transition`, or `target` +- it must not describe target doctrine as already available runtime behavior +- it must point unresolved semantic gaps at this audit, [WARP_DRIFT.md](WARP_DRIFT.md), + or the owning GitHub Issue + +This is the stop sign for accidental doctrine drift in release docs. diff --git a/docs/audits/WARP_DRIFT.md b/docs/audits/WARP_DRIFT.md index 3ead07477..0a6b76ca5 100644 --- a/docs/audits/WARP_DRIFT.md +++ b/docs/audits/WARP_DRIFT.md @@ -10,6 +10,8 @@ the full runtime architecture guide. For those roles, use: - [GLOSSARY](../GLOSSARY.md) +- [doctrine/runtime alignment ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md) +- [WARP doctrine/runtime teaching alignment](WARP_DOCTRINE_RUNTIME_ALIGNMENT.md) - [observer-geometry-architecture-ladder](../design/0035-observer-geometry-architecture-ladder.md) - [remaining-warp-drift-release-slotting](../design/0037-remaining-warp-drift-release-slotting.md) - [release-horizon-v20-v21](../design/release-horizon-v20-v21.md) @@ -198,6 +200,10 @@ contributors: The reconciliation work should tighten both together. +The practical guardrail is the +[doctrine/runtime alignment ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md): target +doctrine is allowed only when its status and runtime evidence path are visible. + ## Backlog capture status This audit has now been captured as tracked doctrine follow-through in @@ -206,6 +212,8 @@ This audit has now been captured as tracked doctrine follow-through in The canonical noun and runtime-planning surfaces for this drift now live in: - [GLOSSARY](../GLOSSARY.md) +- [doctrine/runtime alignment ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md) +- [WARP doctrine/runtime teaching alignment](WARP_DOCTRINE_RUNTIME_ALIGNMENT.md) - [observer-geometry-architecture-ladder](../design/0035-observer-geometry-architecture-ladder.md) - [release-horizon-v20-v21](../design/release-horizon-v20-v21.md) @@ -227,6 +235,8 @@ backlog items own the implementation work. ## Relevant design context - [GLOSSARY](../GLOSSARY.md) +- [doctrine/runtime alignment ratchet](../DOCTRINE_RUNTIME_ALIGNMENT.md) +- [WARP doctrine/runtime teaching alignment](WARP_DOCTRINE_RUNTIME_ALIGNMENT.md) - [observer-geometry-architecture-ladder](../design/0035-observer-geometry-architecture-ladder.md) - [remaining-warp-drift-release-slotting](../design/0037-remaining-warp-drift-release-slotting.md) - [release-horizon-v20-v21](../design/release-horizon-v20-v21.md) diff --git a/docs/checklists/adr-2-go-no-go-checklist.md b/docs/checklists/adr-2-go-no-go-checklist.md index 1ca6032df..eb1af8170 100644 --- a/docs/checklists/adr-2-go-no-go-checklist.md +++ b/docs/checklists/adr-2-go-no-go-checklist.md @@ -5,6 +5,11 @@ This checklist enforces ADR 3. ADR 2 must not be implemented or activated because it feels tidy, inevitable, or "basically done." It proceeds only when the required evidence exists. +Current executable no-go witness: +`test/unit/domain/services/EdgePropSetWireMigrationGate.test.ts` keeps canonical +`EdgePropSet` lowered to legacy raw `PropSet` storage and prevents accidental +schema v4 claims until the gates below are deliberately satisfied. + --- ## Gate 1 — Implementation Readiness diff --git a/docs/migrations/v17.0.0.md b/docs/migrations/v17.0.0.md index 9ecfe166b..9fb32cd61 100644 --- a/docs/migrations/v17.0.0.md +++ b/docs/migrations/v17.0.0.md @@ -136,6 +136,27 @@ your paths: The migration script handles all of these automatically. +### 2.1 `@git-stunts/plumbing`: `Plumbing` → `GitPlumbing` + +The `@git-stunts/plumbing` v3 substrate package names its default class +`GitPlumbing`. Code that used a default import can keep working by choosing the +new local name: + +```diff +-import Plumbing from '@git-stunts/plumbing'; +-const plumbing = new Plumbing({ cwd: './my-repo' }); ++import GitPlumbing from '@git-stunts/plumbing'; ++const plumbing = new GitPlumbing({ cwd: './my-repo' }); +``` + +Code that imported `Plumbing` as a named export must change to the default +import. There is no named `Plumbing` export in the v3 package: + +```diff +-import { Plumbing } from '@git-stunts/plumbing'; ++import GitPlumbing from '@git-stunts/plumbing'; +``` + ### 3. Symbol renames | Old name | New name | Reason | diff --git a/docs/specs/CONTENT_ATTACHMENT.md b/docs/specs/CONTENT_ATTACHMENT.md index 8971344cb..8df9c2a99 100644 --- a/docs/specs/CONTENT_ATTACHMENT.md +++ b/docs/specs/CONTENT_ATTACHMENT.md @@ -8,22 +8,33 @@ ## 1. Introduction -This document proposes **content attachment** — the ability to attach content-addressed blobs to WARP graph nodes (and optionally edges) as first-class payloads. +This document describes **content attachment**: content-addressed blobs attached +to WARP graph nodes and edges as first-class payloads. -Currently, git-warp models nodes and edges with flat key-value properties. Properties are powerful for structured metadata but are not designed for large or opaque payloads (documents, images, binary data). There is no first-class concept corresponding to the paper's **attachment** — the `α(v)` and `β(e)` mappings that assign a payload to every vertex and edge. +Earlier git-warp releases represented content through flat property +compatibility keys. Current graph-model code projects visible content into +runtime-backed `ContentAttachmentRecord` and `ContentAttachmentPayload` objects, +then emits `GraphContentAttachmentSetOp` operations in the graph-operation +algebra. Legacy `_content*` keys remain only as compatibility and migration +input, not as the public substrate contract. Content attachment bridges this gap by giving nodes the ability to carry `Atom(p)` payloads: content-addressed blobs stored in the Git object store and referenced by SHA from the graph. ### Motivation -Consumers of git-warp (e.g., git-mind) need to attach rich content to graph nodes — ADR bodies, spec documents, configuration files, narrative text. Without a substrate-level primitive, each consumer must: +Consumers of git-warp (e.g., git-mind) need to attach rich content to graph +nodes and edges: ADR bodies, spec documents, configuration files, narrative +text, and other opaque payloads. Without a substrate-level primitive, each +consumer must: -- Invent its own property convention for referencing external blobs +- Invent its own convention for referencing external blobs - Manage CAS storage independently - Re-derive time-travel, multi-writer merge, and observer scoping for content - Risk inconsistency between the content store and the graph state -All of these are already solved by git-warp for properties. Content attachment extends the same guarantees to blob payloads. +All of these are already solved by git-warp's graph substrate. Content +attachment extends the same deterministic visibility, merge, and traversal +guarantees to blob payloads. ### Relationship to the Paper @@ -42,15 +53,17 @@ This proposal implements the depth-0 case of attachments: nodes carry `Atom(p)` ### In scope -- Installing `git-cas` (or equivalent CAS-over-git primitive) as a dependency -- API for writing a blob and attaching its CAS key to a node -- API for reading attached content from a node -- CRDT semantics for content references (same as property semantics) -- Time-travel compatibility (content references participate in `materialize({ ceiling })`) +- Runtime-backed content attachment records for nodes and edges +- API for writing a blob and attaching its content storage reference +- API for reading attached node and edge content +- CRDT semantics for content references +- Time-travel compatibility through `materialize({ ceiling })` +- Deterministic projection from legacy `_content*` compatibility records ### Out of scope -- MIME type handling, storage policies, size thresholds (consumer concerns) +- Full removal of every legacy `_content*` compatibility reader +- MIME type policy, storage policy, and size thresholds beyond stored metadata - CLI commands for content manipulation (consumer concerns) - Editor integration, conflict resolution UX (consumer concerns) - Nested WARP attachments (future work) @@ -59,33 +72,57 @@ This proposal implements the depth-0 case of attachments: nodes carry `Atom(p)` ## 3. Design -### 3.1 Storage Model +### 3.1 Primary Runtime Model -Content blobs are stored as **Git objects** in the repository's object store. Git's object store is already a content-addressed store — every blob is identified by its SHA. No additional storage layer is required beyond what Git provides natively. +The primary runtime model is a typed content attachment record: -`git-cas` provides a clean API for writing arbitrary blobs to the Git object store and retrieving them by SHA, without involving the index or working tree. +```text +ContentAttachmentRecord + owner: NodeRecord | EdgeRecord + payload: ContentAttachmentPayload + oid: ContentAttachmentOid + mime: ContentAttachmentMime | null + size: ContentAttachmentSize | null +``` -### 3.2 Graph Representation +Materialized state is projected through `ContentAttachmentProjection`. Public +content reads (`getContent*` and `getEdgeContent*`) consume that projection +instead of branching on raw property maps. Graph-model exports use +`GraphContentAttachmentSetOp` for visible content and exclude content +compatibility keys from generic node and edge property operations. -A content attachment is represented as a **node property** with a well-known key. When a blob is attached to a node, its CAS SHA is stored as the property value: +### 3.2 Storage Model -```text -node: "adr:0007" -property: "_content" = "a1b2c3d4e5f6..." (git blob SHA) -``` +Content bytes are stored through the configured `BlobStoragePort`. The default +Git-backed path stores payloads in content-addressed CAS trees and keeps older +raw Git blob payloads readable through a compatibility path. Runtime content +records carry the storage reference as `ContentAttachmentOid`; callers should +not derive behavior from the legacy storage key names. + +### 3.3 Legacy Storage Compatibility -This approach: +Legacy state may still contain the well-known compatibility keys +`_content`, `_content.mime`, and `_content.size`. Those keys are migration +source facts and compatibility read inputs. They are centralized in +`LegacyContentPropertyKeys` / `KeyCodec` and projected into +`ContentAttachmentRecord` before public content reads or graph-operation +algebra exports observe them. -- Requires zero changes to the CRDT model (content SHAs are just property values) -- Gets time-travel for free (`materialize({ ceiling })` handles property history) -- Gets multi-writer merge for free (LWW on the `_content` property) -- Gets observer scoping for free (property visibility follows node visibility) +The compatibility mapping is: -The `_content` key prefix convention (underscore) signals a system-level property, distinguishing it from user-defined properties. +| Compatibility key | Typed field | +|---|---| +| `_content` | `ContentAttachmentPayload.oid` | +| `_content.mime` | `ContentAttachmentPayload.mime` | +| `_content.size` | `ContentAttachmentPayload.size` | -### 3.3 Final API +Metadata is accepted only when it belongs to the same content write lineage as +the content reference. Manual compatibility rewrites do not inherit stale MIME +or size metadata from an older content payload. -The hybrid approach was implemented: dedicated methods that encapsulate CAS details, while the `_content` property remains directly accessible for advanced use. +### 3.4 Final API + +Dedicated methods encapsulate blob storage and typed content projection. #### Write API (PatchBuilderV2 / PatchSession) @@ -119,36 +156,34 @@ const edgeMeta = await graph.getEdgeContentMeta('a', 'b', 'rel'); ``` `getContent()` returns raw `Uint8Array` bytes. Consumers wanting text should decode with `new TextDecoder().decode(buffer)`. -If `_content` points at a missing blob OID, `getContent()` throws instead of silently returning empty bytes. -`getEdgeContent()` has the same byte-decoding and missing-blob semantics for edge `_content` references. -`getContentMeta()` / `getEdgeContentMeta()` return `{ oid, mime, size }` when metadata exists, or `null` when no attachment exists. Historical attachments created before metadata support, or later manual `_content` rewrites that bypass the attachment helpers, may still surface `mime: null` / `size: null`. +If a projected content attachment points at a missing blob OID, `getContent()` +throws instead of silently returning empty bytes. `getEdgeContent()` has the +same byte-decoding and missing-blob semantics for edge content references. +`getContentMeta()` / `getEdgeContentMeta()` return `{ oid, mime, size }` when +metadata exists, or `null` when no attachment exists. Historical attachments +created before metadata support, or later manual compatibility rewrites that +bypass the attachment helpers, may still surface `mime: null` / `size: null`. -#### Constant +#### Compatibility Constant ```javascript import { CONTENT_PROPERTY_KEY } from '@git-stunts/git-warp'; // CONTENT_PROPERTY_KEY === '_content' ``` -### 3.4 Content Metadata - -git-warp stores logical attachment metadata in sibling system properties alongside the `_content` reference: - -| Property | Purpose | Example | -|---|---|---| -| `_content` | CAS blob SHA (required) | `"a1b2c3d4..."` | -| `_content.size` | Logical content byte length | `4096` | -| `_content.mime` | MIME type hint | `"text/markdown"` | - -`attachContent()` / `attachEdgeContent()` always persist `_content.size` from the actual encoded byte length. If callers provide `{ mime }`, the MIME hint is stored in `_content.mime`; otherwise the metadata API returns `mime: null`. The read APIs only surface `_content.mime` / `_content.size` when they belong to the current `_content` attachment lineage, so a later manual `_content` rewrite does not inherit stale metadata from an older blob reference. +The constant remains exported for migration code and compatibility tests. New +domain logic should prefer typed content attachment records. --- ## 4. CRDT Semantics -Content attachment inherits existing property CRDT semantics: +Content attachment inherits the same LWW visibility semantics as the +compatibility content reference that feeds the typed projection: -- **LWW (Last-Writer-Wins):** If two writers attach different content to the same node concurrently, the one with the higher Lamport timestamp wins. Ties broken by writer ID, then patch SHA. +- **LWW (Last-Writer-Wins):** If two writers attach different content to the + same node or edge concurrently, the one with the higher Lamport timestamp + wins. Ties are broken by writer ID, then patch SHA. - **Tombstones:** If a writer removes a node, its content attachment is removed with it (OR-Set semantics on nodes). - **No content-level merge:** Content blobs are opaque atoms. There is no attempt to merge conflicting blob contents — the SHA is the unit of conflict resolution, not the bytes. @@ -166,24 +201,38 @@ const content = await graph.getContent('adr:0007'); // Returns content as of the given tick ``` -The `_content` property at tick `N` points to whatever content storage OID was current at that tick. Current storage uses CAS trees for attachment payloads, so historical content is retrievable as long as the Git objects have not been garbage-collected. +The typed content projection at tick `N` resolves to the content storage OID +visible at that tick. Current storage uses CAS trees for attachment payloads, +so historical content is retrievable as long as the Git objects have not been +garbage-collected. --- ## 6. Durability / Git GC -Content storage trees can be unreachable unless an application commit or checkpoint commit anchors them. Without anchoring, `git gc --prune=now` would delete them. +Content storage trees can be unreachable unless an application commit or +checkpoint commit anchors them. Without anchoring, `git gc --prune=now` would +delete them. **Solution:** patch commits embed content storage OIDs in the patch commit tree alongside the encoded patch: ```text patch → encoded patch storage tree -_content_ → content storage tree, keyed by its hex OID +_content_ → compatibility content storage tree anchor ``` -This makes content storage reachable via the writer ref chain (`refs/warp//writers/` → commit → tree → content tree). GC protection is automatic. Sync replicates content along with patches. Zero new refs, zero new Git commands. +The tree-entry name is a storage compatibility anchor, not the runtime content +model. It makes content storage reachable via the writer ref chain +(`refs/warp//writers/` → commit → tree → content tree). GC +protection is automatic. Sync replicates content along with patches. -**Checkpoint anchoring:** checkpoint creation also scans `state.prop` for `_content` values and embeds the referenced storage OIDs in the checkpoint tree. This ensures content survives GC even if patch commits are ever pruned (e.g., by future compaction or writer-chain truncation). The invariant is: **content storage referenced by live state is always reachable from at least one ref** — either the writer ref (patch commit tree) or the checkpoint ref (checkpoint commit tree). +**Checkpoint anchoring:** checkpoint creation scans compatibility content +references that still back current state and embeds the referenced storage OIDs +in the checkpoint tree. This ensures content survives GC even if patch commits +are ever pruned (e.g., by future compaction or writer-chain truncation). The +invariant is: **content storage referenced by live state is always reachable +from at least one ref** — either the writer ref (patch commit tree) or the +checkpoint ref (checkpoint commit tree). Current content OIDs are CAS trees. Checkpoint anchoring also preserves legacy raw Git blob content by writing those anchors as blob tree entries instead of tree entries. @@ -193,7 +242,14 @@ Integration tests verify both anchoring paths with `git gc --prune=now`. ## 7. Implementation Notes -Content attachment now stores payloads through the configured `BlobStoragePort`; the default integration path uses Git CAS trees and falls back to raw Git blobs only for older stored payloads. +Content attachment stores payloads through the configured `BlobStoragePort`. +The default integration path uses Git CAS trees and falls back to raw Git blobs +only for older stored payloads. + +`ContentAttachmentProjection` is the compatibility boundary from legacy +content keys to typed content records. `GraphOpAlgebraProjection` emits +`GraphContentAttachmentSetOp` for visible content and filters content +compatibility keys out of generic property operations. Edge attachments are included in v1 (not deferred). @@ -203,6 +259,9 @@ Edge attachments are included in v1 (not deferred). - **Nested WARP attachments:** The paper allows `α(v)` to be a full WARP graph, not just an atom. This would mean a node's attachment is itself a graph with nodes, edges, and their own attachments. This is a significant extension beyond content blobs and is out of scope. - **Content integrity verification:** Optionally verify blob SHA on read to detect corruption. +- **Final compatibility removal:** Once migration fixtures prove all stored + legacy content references can be upgraded, remove the remaining `_content*` + compatibility readers and tree-entry naming from the storage plane. --- @@ -210,13 +269,15 @@ Edge attachments are included in v1 (not deferred). | Aspect | Decision | |---|---| -| Where content is stored | Git object store (content-addressed blobs) | -| How content is referenced | `_content` property on nodes/edges (CAS SHA) | -| CRDT model | Existing LWW property semantics, no change | +| Where content is stored | `BlobStoragePort` / Git CAS content storage | +| How content is represented | `ContentAttachmentRecord` + `ContentAttachmentPayload` | +| Compatibility input | `_content`, `_content.mime`, `_content.size` | +| Graph algebra | `GraphContentAttachmentSetOp`, not generic property ops | +| CRDT model | Existing LWW visibility semantics | | Time-travel | Automatic via `materialize({ ceiling })` | | New dependency | None (uses existing BlobPort on GitGraphAdapter) | -| API shape | Hybrid: dedicated methods + direct property access | -| GC protection | Blob OIDs embedded in patch commit tree | +| API shape | Dedicated methods over typed content projection | +| GC protection | Content OIDs embedded in patch/checkpoint trees | | Edge attachments | Included in v1 | | Nested WARP attachments | Future work | | Paper alignment | Implements `Atom(p)` for vertex and edge attachments | diff --git a/docs/specs/LANE_COORDINATE_CAPABILITY_BOUNDARY.md b/docs/specs/LANE_COORDINATE_CAPABILITY_BOUNDARY.md new file mode 100644 index 000000000..3bc4e877c --- /dev/null +++ b/docs/specs/LANE_COORDINATE_CAPABILITY_BOUNDARY.md @@ -0,0 +1,55 @@ +# Lane, Coordinate, and Capability Boundary + +This boundary names the substrate-owned nouns that debugger, agent, and UI +protocols may depend on. It does not define session policy, layout, shortcut, +or visualization behavior. + +## Substrate Lanes + +| Lane kind | Owned by substrate | Meaning | +| --- | --- | --- | +| `worldline` | yes | The admitted application-facing causal lane opened by `openWarpWorldline()`. | +| `strand` | yes | A pinned speculative lane with its own descriptor, base observation, and queued intents. | +| `braid` | yes | A lane relationship that reads across strand overlays without making debugger policy authoritative. | + +## Coordinate Anchors + +| Coordinate kind | Owned by substrate | Meaning | +| --- | --- | --- | +| `live` | yes | The current admitted frontier for a worldline. | +| `frontier` | yes | An explicit map of writer ids to patch heads. | +| `checkpoint` | yes | A checkpoint-backed reading anchor plus frontier evidence. | +| `strand-base` | yes | The base observation recorded by a strand descriptor. | + +## Capability Authority + +Substrate capabilities name graph truth or graph-control facts: + +- `worldline.commit` +- `worldline.live` +- `worldline.seek` +- `worldline.observer` +- `worldline.optic` +- `strand.create` +- `strand.braid` +- `strand.patch` +- `strand.intent` +- `coordinate.compare` +- `coordinate.transfer-plan` +- `sync.exchange` + +Session-policy capabilities name debugger or presentation behavior. They are +not substrate facts: + +- `debugger.cursor` +- `debugger.layout` +- `debugger.selection` +- `debugger.theme` +- `session.history` +- `session.shortcut` + +## Non-Authority Rule + +Mirrors, DTOs, and convenience protocol objects may copy these names, but they +do not become peer authorities. When a debugger or agent disagrees with this +boundary, the substrate boundary wins. diff --git a/index.ts b/index.ts index 68d3d2c74..d7e630a0c 100644 --- a/index.ts +++ b/index.ts @@ -42,6 +42,7 @@ import IndexRebuildService from './src/domain/services/index/IndexRebuildService import HealthCheckService, { HealthStatus } from './src/domain/services/HealthCheckService.ts'; import CommitDagTraversalService from './src/domain/services/dag/CommitDagTraversalService.ts'; import GraphPersistencePort from './src/ports/GraphPersistencePort.ts'; +import type WarpKernelPort from './src/ports/WarpKernelPort.ts'; import IndexStoragePort from './src/ports/IndexStoragePort.ts'; import LoggerPort from './src/ports/LoggerPort.ts'; import SeekCachePort from './src/ports/SeekCachePort.ts'; @@ -73,8 +74,16 @@ import { createV18BoundedMemoryCapabilityReport, } from './rootCompatibility.ts'; import QueryBuilder from './src/domain/services/query/QueryBuilder.ts'; +import BoundedSupportRule from './src/domain/services/query/BoundedSupportRule.ts'; +import CausalIndexPlan from './src/domain/services/query/CausalIndexPlan.ts'; +import SupportFragmentPlan from './src/domain/services/query/SupportFragmentPlan.ts'; import Observer from './src/domain/services/query/Observer.ts'; -import Worldline from './src/domain/services/Worldline.ts'; +import ObserverAccumulation from './src/domain/services/query/ObserverAccumulation.ts'; +import ObserverBasis from './src/domain/services/query/ObserverBasis.ts'; +import ObserverEmission from './src/domain/services/query/ObserverEmission.ts'; +import ObserverPlan from './src/domain/services/query/ObserverPlan.ts'; +import ObserverReadingEnvelope from './src/domain/services/query/ObserverReadingEnvelope.ts'; +import ProjectionHandle from './src/domain/services/ProjectionHandle.ts'; import WorldlineSelector from './src/domain/types/WorldlineSelector.ts'; import LiveSelector from './src/domain/types/LiveSelector.ts'; import CoordinateSelector from './src/domain/types/CoordinateSelector.ts'; @@ -143,6 +152,7 @@ import ContentAttachmentProjection from './src/domain/services/ContentAttachment import GraphOpAlgebraProjection from './src/domain/services/GraphOpAlgebraProjection.ts'; import { openWarpGraph } from './src/domain/WarpGraph.ts'; import WarpWorldline, { openWarpWorldline } from './src/domain/WarpWorldline.ts'; +import { WarpOpenOptions } from './src/domain/warp/RuntimeHostBoot.ts'; import WarpWorldlineCoordinate from './src/domain/WarpWorldlineCoordinate.ts'; import WarpWorldlineOpticBasis from './src/domain/WarpWorldlineOpticBasis.ts'; import { PatchBuilder } from './src/domain/services/PatchBuilder.ts'; @@ -153,6 +163,14 @@ import WarpStateIndexBuilder, { buildWarpStateIndex } from './src/domain/service import { computeStateHash, projectState } from './src/domain/services/state/StateSerializer.ts'; import { createStateReader } from './src/domain/services/state/StateReader.ts'; import { compareVisibleState } from './src/domain/services/comparison/VisibleStateComparison.ts'; +import GraphDiff from './src/domain/services/comparison/GraphDiff.ts'; +import TtdMergeBranch from './src/domain/services/merge/TtdMergeBranch.ts'; +import TtdMergeFootprint from './src/domain/services/merge/TtdMergeFootprint.ts'; +import TtdMergeInspection from './src/domain/services/merge/TtdMergeInspection.ts'; +import TtdMergeInspector from './src/domain/services/merge/TtdMergeInspector.ts'; +import TtdMergeLoweringWitness from './src/domain/services/merge/TtdMergeLoweringWitness.ts'; +import TtdMergeObstructionWitness from './src/domain/services/merge/TtdMergeObstructionWitness.ts'; +import TtdMergePolicyRequirement from './src/domain/services/merge/TtdMergePolicyRequirement.ts'; import ImmutableBytes from './src/domain/services/snapshot/ImmutableBytes.ts'; import SnapshotORSet from './src/domain/services/snapshot/SnapshotORSet.ts'; import SnapshotVersionVector from './src/domain/services/snapshot/SnapshotVersionVector.ts'; @@ -166,6 +184,28 @@ import type { ApertureOpeningProofFields } from './src/domain/services/wormhole/ import type { ZKWormholeEdgeFields } from './src/domain/services/wormhole/ZKWormholeEdge.ts'; import type { ApertureOpeningVerificationResult, ZKWormholeVerificationResult } from './src/domain/services/wormhole/ZKWormholeVerificationResult.ts'; import type { WarpWorldlineCoordinateFrontierEntry } from './src/domain/WarpWorldlineCoordinate.ts'; +import type { GraphDiffOptions } from './src/domain/capabilities/ComparisonCapability.ts'; +import type { GraphDiffFields } from './src/domain/services/comparison/GraphDiff.ts'; +import type { ObserverPlanFields } from './src/domain/services/query/ObserverPlan.ts'; +import type { + ObserverReadingEnvelopeBudget, + ObserverReadingEnvelopeFields, +} from './src/domain/services/query/ObserverReadingEnvelope.ts'; +import type { + BoundedSupportDirection, + BoundedSupportKind, + BoundedSupportRuleFields, + BoundedSupportSurface, +} from './src/domain/services/query/BoundedSupportRule.ts'; +import type { + CausalIndexFamily, + CausalIndexPlanFields, + CausalIndexPlanPosture, +} from './src/domain/services/query/CausalIndexPlan.ts'; +import type { + SupportFragmentMaterializationPosture, + SupportFragmentPlanFields, +} from './src/domain/services/query/SupportFragmentPlan.ts'; import { normalizeVisibleStateScope, scopeMaterializedState, @@ -177,6 +217,14 @@ import { export * from './src/domain/graph/publicGraphSubstrate.ts'; export * from './src/domain/memory/index.ts'; +export * from './src/continuumExports.ts'; +export { default as OperationPolicyPort } from './src/ports/OperationPolicyPort.ts'; +export type { OperationPolicyExecuteOptions, OperationRetryDecision, OperationRetryObserver } from './src/ports/OperationPolicyPort.ts'; +export { default as CasContentEncryptionPolicy } from './src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; +export type { CasContentEncryptionDiagnostics, CasContentEncryptionScheme, CasResolvedVaultKeyOptions, CasVaultResolutionWitness } from './src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; +export { default as AlfredOperationPolicyAdapter } from './src/infrastructure/adapters/AlfredOperationPolicyAdapter.ts'; +export { default as NoopOperationPolicyAdapter } from './src/infrastructure/adapters/NoopOperationPolicyAdapter.ts'; +export { OperationPolicyExhaustedError, OperationPolicyTimeoutError } from './src/domain/errors/index.ts'; export { AuditError, ContinuumArtifactAuthorityError, @@ -198,67 +246,15 @@ export { WormholeError, } from './src/domain/errors/index.ts'; -import ContinuumArtifactAuthority from './src/domain/continuum/ContinuumArtifactAuthority.ts'; -import ContinuumArtifactDescriptor from './src/domain/continuum/ContinuumArtifactDescriptor.ts'; -import ContinuumArtifactIngestionPolicy from './src/domain/continuum/ContinuumArtifactIngestionPolicy.ts'; -import ContinuumEvidenceAccess from './src/domain/continuum/ContinuumEvidenceAccess.ts'; -import ContinuumEvidenceClaim from './src/domain/continuum/ContinuumEvidenceClaim.ts'; -import ContinuumEvidenceCompleteness from './src/domain/continuum/ContinuumEvidenceCompleteness.ts'; -import ContinuumEvidenceOrigin from './src/domain/continuum/ContinuumEvidenceOrigin.ts'; -import ContinuumEvidencePosture from './src/domain/continuum/ContinuumEvidencePosture.ts'; -import ContinuumEvidenceProofStrength from './src/domain/continuum/ContinuumEvidenceProofStrength.ts'; -import ContinuumFamilyId from './src/domain/continuum/ContinuumFamilyId.ts'; -import ContinuumGeneratedFamilyInventory from './src/domain/continuum/ContinuumGeneratedFamilyInventory.ts'; -import ContinuumGeneratedFamilyInventoryEntry from './src/domain/continuum/ContinuumGeneratedFamilyInventoryEntry.ts'; -import ContinuumGeneratedFamilyStatus from './src/domain/continuum/ContinuumGeneratedFamilyStatus.ts'; -import ContinuumReceiptFamilyProjection from './src/domain/continuum/ContinuumReceiptFamilyProjection.ts'; -import GitWarpTickPatchReplayCore from './src/domain/continuum/GitWarpTickPatchReplayCore.ts'; -import GitWarpReadingEnvelopePayloadFact from './src/domain/continuum/GitWarpReadingEnvelopePayloadFact.ts'; -import GitWarpReadingEnvelopeSourceFacts from './src/domain/continuum/GitWarpReadingEnvelopeSourceFacts.ts'; -import GitWarpBraidHologram from './src/domain/continuum/GitWarpBraidHologram.ts'; -import GitWarpBraidHologramMember from './src/domain/continuum/GitWarpBraidHologramMember.ts'; -import GitWarpSuffixTransformHologram from './src/domain/continuum/GitWarpSuffixTransformHologram.ts'; -import GitWarpTickHologram from './src/domain/continuum/GitWarpTickHologram.ts'; -import GitWarpTickReceiptShell from './src/domain/continuum/GitWarpTickReceiptShell.ts'; -import GitWarpTickReceiptWitnessCore from './src/domain/continuum/GitWarpTickReceiptWitnessCore.ts'; -import GitWarpTickWitnessLadder from './src/domain/continuum/GitWarpTickWitnessLadder.ts'; -import GitWarpWitnessedSuffixPatchFact from './src/domain/continuum/GitWarpWitnessedSuffixPatchFact.ts'; -import GitWarpWitnessedSuffixSourceFacts from './src/domain/continuum/GitWarpWitnessedSuffixSourceFacts.ts'; -import GitWarpReceiptSourceFacts from './src/domain/continuum/GitWarpReceiptSourceFacts.ts'; -import createCurrentContinuumGeneratedFamilyInventory from './src/domain/continuum/createCurrentContinuumGeneratedFamilyInventory.ts'; -import ContinuumArtifactJsonFileAdapter from './src/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.ts'; -import type { ContinuumArtifactAuthorityValue } from './src/domain/continuum/ContinuumArtifactAuthority.ts'; -import type { ContinuumArtifactDescriptorFields } from './src/domain/continuum/ContinuumArtifactDescriptor.ts'; -import type { ContinuumEvidenceAccessValue } from './src/domain/continuum/ContinuumEvidenceAccess.ts'; -import type { ContinuumEvidenceClaimFields } from './src/domain/continuum/ContinuumEvidenceClaim.ts'; -import type { ContinuumEvidenceCompletenessValue } from './src/domain/continuum/ContinuumEvidenceCompleteness.ts'; -import type { ContinuumEvidenceOriginValue } from './src/domain/continuum/ContinuumEvidenceOrigin.ts'; -import type { ContinuumEvidencePostureFields } from './src/domain/continuum/ContinuumEvidencePosture.ts'; -import type { ContinuumEvidenceProofStrengthValue } from './src/domain/continuum/ContinuumEvidenceProofStrength.ts'; -import type { ContinuumFamilyIdValue } from './src/domain/continuum/ContinuumFamilyId.ts'; -import type { ContinuumGeneratedFamilyInventoryEntryFields } from './src/domain/continuum/ContinuumGeneratedFamilyInventoryEntry.ts'; -import type { ContinuumGeneratedFamilyStatusValue } from './src/domain/continuum/ContinuumGeneratedFamilyStatus.ts'; -import type { - ContinuumDeliveryObservationFact, - ContinuumReceiptFact, - ContinuumReceiptFamilyProjectionFields, - ContinuumReceiptOpFact, - ContinuumReceiptWitnessFact, -} from './src/domain/continuum/ContinuumReceiptFamilyProjection.ts'; -import type { GitWarpReceiptSourceFactsFields } from './src/domain/continuum/GitWarpReceiptSourceFacts.ts'; -import type { GitWarpReadingEnvelopePayloadFactFields } from './src/domain/continuum/GitWarpReadingEnvelopePayloadFact.ts'; -import type { GitWarpReadingEnvelopeSourceFactsFields } from './src/domain/continuum/GitWarpReadingEnvelopeSourceFacts.ts'; -import type { GitWarpBraidHologramFields } from './src/domain/continuum/GitWarpBraidHologram.ts'; -import type { GitWarpBraidHologramMemberFields } from './src/domain/continuum/GitWarpBraidHologramMember.ts'; -import type { GitWarpSuffixTransformHologramFields } from './src/domain/continuum/GitWarpSuffixTransformHologram.ts'; -import type { GitWarpTickHologramFields } from './src/domain/continuum/GitWarpTickHologram.ts'; -import type { GitWarpTickPatchReplayCoreFields } from './src/domain/continuum/GitWarpTickPatchReplayCore.ts'; -import type { GitWarpTickReceiptShellFields } from './src/domain/continuum/GitWarpTickReceiptShell.ts'; -import type { GitWarpTickReceiptWitnessCoreFields } from './src/domain/continuum/GitWarpTickReceiptWitnessCore.ts'; -import type { GitWarpTickWitnessLadderFields } from './src/domain/continuum/GitWarpTickWitnessLadder.ts'; -import type { GitWarpWitnessedSuffixPatchFactFields } from './src/domain/continuum/GitWarpWitnessedSuffixPatchFact.ts'; -import type { GitWarpWitnessedSuffixSourceFactsFields } from './src/domain/continuum/GitWarpWitnessedSuffixSourceFacts.ts'; -import type { ContinuumArtifactJsonLoadContext } from './src/infrastructure/adapters/ContinuumArtifactJsonFileAdapter.ts'; +import type { TtdMergeBranchFields } from './src/domain/services/merge/TtdMergeBranch.ts'; +import type { TtdMergeFootprintFields } from './src/domain/services/merge/TtdMergeFootprint.ts'; +import type { TtdMergeInspectionFields } from './src/domain/services/merge/TtdMergeInspection.ts'; +import type { TtdMergeInspectionDomain } from './src/domain/services/merge/TtdMergeInspectionDomain.ts'; +import type { TtdMergeLoweringSurface } from './src/domain/services/merge/TtdMergeLoweringSurface.ts'; +import type { TtdMergeLoweringWitnessFields } from './src/domain/services/merge/TtdMergeLoweringWitness.ts'; +import type { TtdMergeObjectBranchInput, TtdMergeObjectInspectionInput } from './src/domain/services/merge/TtdMergeInspector.ts'; +import type { TtdMergeObstructionWitnessFields } from './src/domain/services/merge/TtdMergeObstructionWitness.ts'; +import type { TtdMergePolicyRequirementFields } from './src/domain/services/merge/TtdMergePolicyRequirement.ts'; export { GitGraphAdapter, @@ -306,6 +302,7 @@ export { createTimeoutSignal, // Multi-writer graph — advanced compatibility composition root + WarpOpenOptions, openWarpGraph, // Worldline-first public handle @@ -313,17 +310,25 @@ export { WarpWorldline, WarpWorldlineCoordinate, WarpWorldlineOpticBasis, + ProjectionHandle, // Multi-writer graph support (legacy/diagnostic — prefer openWarpWorldline) WarpApp, WarpCore, - Worldline, WorldlineSelector, LiveSelector, CoordinateSelector, StrandSelector, + BoundedSupportRule, + CausalIndexPlan, + SupportFragmentPlan, QueryBuilder, Observer, + ObserverAccumulation, + ObserverBasis, + ObserverEmission, + ObserverPlan, + ObserverReadingEnvelope, PatchBuilder, PatchSession, Writer, @@ -352,6 +357,14 @@ export { projectState, createStateReader, compareVisibleState, + GraphDiff, + TtdMergeBranch, + TtdMergeFootprint, + TtdMergeInspection, + TtdMergeInspector, + TtdMergeLoweringWitness, + TtdMergeObstructionWitness, + TtdMergePolicyRequirement, ImmutableBytes, SnapshotORSet, SnapshotVersionVector, @@ -362,34 +375,6 @@ export { exportCoordinateTransferPlanFact, createV18BoundedMemoryCapabilityReport, - // Continuum boundary artifacts - ContinuumArtifactAuthority, - ContinuumArtifactDescriptor, - ContinuumArtifactIngestionPolicy, - ContinuumEvidenceAccess, - ContinuumEvidenceClaim, - ContinuumEvidenceCompleteness, - ContinuumEvidenceOrigin, - ContinuumEvidencePosture, - ContinuumEvidenceProofStrength, - ContinuumFamilyId, - ContinuumGeneratedFamilyInventory, - ContinuumGeneratedFamilyInventoryEntry, - ContinuumGeneratedFamilyStatus, - ContinuumReceiptFamilyProjection, - GitWarpReadingEnvelopePayloadFact, - GitWarpReadingEnvelopeSourceFacts, - GitWarpBraidHologram, GitWarpBraidHologramMember, GitWarpSuffixTransformHologram, GitWarpTickHologram, - GitWarpTickPatchReplayCore, - GitWarpTickReceiptShell, - GitWarpTickReceiptWitnessCore, - GitWarpTickWitnessLadder, - GitWarpWitnessedSuffixPatchFact, - GitWarpWitnessedSuffixSourceFacts, - GitWarpReceiptSourceFacts, - createCurrentContinuumGeneratedFamilyInventory, - ContinuumArtifactJsonFileAdapter, - // Tick receipts (LIGHTHOUSE) createTickReceipt, tickReceiptCanonicalJson, @@ -442,39 +427,36 @@ export type { PropValue, SnapshotPropValue, SyncRateLimitConfig, + WarpKernelPort, WarpWorldlineOpenOptions, WarpWorldlinePatchBuild, + BoundedSupportDirection, + BoundedSupportKind, + BoundedSupportRuleFields, + BoundedSupportSurface, + CausalIndexFamily, + CausalIndexPlanFields, + CausalIndexPlanPosture, + SupportFragmentMaterializationPosture, + SupportFragmentPlanFields, + GraphDiffOptions, + GraphDiffFields, + ObserverPlanFields, + ObserverReadingEnvelopeBudget, + ObserverReadingEnvelopeFields, ApertureOpeningProofFields, ApertureOpeningVerificationResult, ZKWormholeEdgeFields, ZKWormholeVerificationResult, WarpWorldlineCoordinateFrontierEntry, - ContinuumArtifactAuthorityValue, - ContinuumArtifactDescriptorFields, - ContinuumEvidenceAccessValue, - ContinuumEvidenceClaimFields, - ContinuumEvidenceCompletenessValue, - ContinuumEvidenceOriginValue, - ContinuumEvidencePostureFields, - ContinuumEvidenceProofStrengthValue, - ContinuumGeneratedFamilyInventoryEntryFields, - ContinuumGeneratedFamilyStatusValue, - ContinuumDeliveryObservationFact, - ContinuumReceiptFact, - ContinuumReceiptFamilyProjectionFields, - ContinuumReceiptOpFact, - ContinuumReceiptWitnessFact, - GitWarpReceiptSourceFactsFields, - GitWarpReadingEnvelopePayloadFactFields, - GitWarpReadingEnvelopeSourceFactsFields, - GitWarpBraidHologramFields, GitWarpBraidHologramMemberFields, - GitWarpSuffixTransformHologramFields, GitWarpTickHologramFields, - GitWarpTickPatchReplayCoreFields, - GitWarpTickReceiptShellFields, - GitWarpTickReceiptWitnessCoreFields, - GitWarpTickWitnessLadderFields, - GitWarpWitnessedSuffixPatchFactFields, - GitWarpWitnessedSuffixSourceFactsFields, - ContinuumArtifactJsonLoadContext, - ContinuumFamilyIdValue, + TtdMergeBranchFields, + TtdMergeFootprintFields, + TtdMergeInspectionDomain, + TtdMergeInspectionFields, + TtdMergeLoweringSurface, + TtdMergeLoweringWitnessFields, + TtdMergeObjectBranchInput, + TtdMergeObjectInspectionInput, + TtdMergeObstructionWitnessFields, + TtdMergePolicyRequirementFields, }; // WarpApp remains the compatibility default export for v15-era consumers. diff --git a/package-lock.json b/package-lock.json index cfd37905b..3dbfb9786 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "roaring-wasm": "^1.1.0", "string-width": "^7.1.0", "wrap-ansi": "^9.0.0", - "zod": "3.24.1" + "zod": "^3.24.1" }, "bin": { "git-warp": "bin/git-warp", @@ -43,7 +43,7 @@ "eslint-plugin-jsdoc": "^62.8.1", "fast-check": "^4.5.3", "jiti": "^2.6.1", - "markdownlint-cli": "^0.48.0", + "markdownlint-cli": "^0.49.0", "patch-package": "^8.0.0", "prettier": "^3.4.2", "typescript": "^5.9.3", @@ -700,14 +700,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -730,40 +730,10 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/@npmcli/agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", - "license": "ISC", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/fs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", - "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", - "license": "ISC", - "optional": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -771,9 +741,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -788,9 +758,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -805,9 +775,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -822,9 +792,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -839,9 +809,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -856,9 +826,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -876,9 +846,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -896,9 +866,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -916,9 +886,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -936,9 +906,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -956,9 +926,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -976,9 +946,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -993,9 +963,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1012,9 +982,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1029,9 +999,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1095,9 +1065,9 @@ } }, "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", "dev": true, "license": "MIT", "dependencies": { @@ -1829,29 +1799,6 @@ "node": ">=8" } }, - "node_modules/cacache": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", - "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0", - "unique-filename": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2316,16 +2263,6 @@ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -2349,13 +2286,6 @@ "node": ">=6" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "license": "MIT", - "optional": true - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2849,19 +2779,6 @@ "node": ">=12" } }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2888,9 +2805,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "license": "MIT", "engines": { "node": ">=18" @@ -2944,24 +2861,6 @@ "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", "license": "MIT" }, - "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2975,45 +2874,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "optional": true, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -3120,27 +2980,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "license": "BSD-2-Clause", - "optional": true - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3155,19 +2994,6 @@ "node": ">= 14" } }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3199,7 +3025,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -3215,16 +3041,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 12" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -3427,10 +3243,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3531,9 +3357,9 @@ } }, "node_modules/katex": { - "version": "0.16.38", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", - "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "dev": true, "funding": [ "https://opencollective.com/katex", @@ -3865,10 +3691,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -3897,16 +3733,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lru-cache": { - "version": "11.2.4", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", - "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3945,39 +3771,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-fetch-happen": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", - "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", - "license": "ISC", - "optional": true, - "dependencies": { - "@npmcli/agent": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -3987,9 +3800,9 @@ } }, "node_modules/markdownlint": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz", - "integrity": "sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg==", + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.0.tgz", + "integrity": "sha512-xMUI3ChBuRuxuLF4ENvCZyS8z/+Jly1coUcZwErKLIB3sDj7ojpaTBa1e9YVPhSN4jGEIjYGQCldbTJS/hqS+A==", "dev": true, "license": "MIT", "dependencies": { @@ -4001,40 +3814,40 @@ "micromark-extension-gfm-table": "2.1.1", "micromark-extension-math": "3.1.0", "micromark-util-types": "2.0.2", - "string-width": "8.1.0" + "string-width": "8.2.1" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/DavidAnson" } }, "node_modules/markdownlint-cli": { - "version": "0.48.0", - "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.48.0.tgz", - "integrity": "sha512-NkZQNu2E0Q5qLEEHwWj674eYISTLD4jMHkBzDobujXd1kv+yCxi8jOaD/rZoQNW1FBBMMGQpuW5So8B51N/e0A==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.49.0.tgz", + "integrity": "sha512-vS5tWq5W91Gg33LD4pyAaXPclnz/sRvo6/RGOyDQjQ3eds2DkK6H4szUuE0M9TiRB/u/VBx1gtd9Ktrtx5WlSA==", "dev": true, "license": "MIT", "dependencies": { - "commander": "~14.0.3", + "commander": "~15.0.0", "deep-extend": "~0.6.0", "ignore": "~7.0.5", - "js-yaml": "~4.1.1", + "js-yaml": "~4.2.0", "jsonc-parser": "~3.3.1", "jsonpointer": "~5.0.1", - "markdown-it": "~14.1.1", - "markdownlint": "~0.40.0", - "minimatch": "~10.2.4", + "markdown-it": "~14.2.0", + "markdownlint": "~0.41.0", + "minimatch": "~10.2.5", "run-con": "~1.3.2", - "smol-toml": "~1.6.0", - "tinyglobby": "~0.2.15" + "smol-toml": "~1.6.1", + "tinyglobby": "~0.2.17" }, "bin": { "markdownlint": "markdownlint.js" }, "engines": { - "node": ">=20" + "node": ">=22" } }, "node_modules/markdownlint-cli/node_modules/balanced-match": { @@ -4048,9 +3861,9 @@ } }, "node_modules/markdownlint-cli/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -4060,6 +3873,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/markdownlint-cli/node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/markdownlint-cli/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4071,13 +3894,13 @@ } }, "node_modules/markdownlint-cli/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -4087,14 +3910,14 @@ } }, "node_modules/markdownlint/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -4716,136 +4539,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.0.tgz", - "integrity": "sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A==", - "license": "MIT", - "optional": true, - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC", - "optional": true - }, "node_modules/minizlib": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", @@ -4867,9 +4560,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "dev": true, "funding": [ { @@ -4892,16 +4585,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -4924,21 +4607,21 @@ } }, "node_modules/node-gyp": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.1.0.tgz", - "integrity": "sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "license": "MIT", "optional": true, "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.5.2", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { @@ -5132,19 +4815,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5278,23 +4948,6 @@ "node": ">=8" } }, - "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "license": "BlueOak-1.0.0", - "optional": true, - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5323,9 +4976,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -5343,7 +4996,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5387,20 +5040,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "license": "MIT", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5461,16 +5100,6 @@ "node": ">=4" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/roaring": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/roaring/-/roaring-2.7.0.tgz", @@ -5506,13 +5135,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5522,21 +5151,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/run-con": { @@ -5555,13 +5184,6 @@ "run-con": "cli.js" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "optional": true - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -5633,17 +5255,6 @@ "node": ">=6" } }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, "node_modules/smol-toml": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", @@ -5657,36 +5268,6 @@ "url": "https://github.com/sponsors/cyyynthia" } }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "optional": true, - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "optional": true, - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5722,19 +5303,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/ssri": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", - "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", - "license": "ISC", - "optional": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -5808,9 +5376,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "license": "BlueOak-1.0.0", "optional": true, "dependencies": { @@ -5842,9 +5410,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "devOptional": true, "license": "MIT", "dependencies": { @@ -5869,9 +5437,9 @@ } }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -6006,6 +5574,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6013,32 +5591,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", - "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", - "license": "ISC", - "optional": true, - "dependencies": { - "unique-slug": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/unique-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", - "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", - "license": "ISC", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -6060,17 +5612,17 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index f11596a1d..33ee1677f 100644 --- a/package.json +++ b/package.json @@ -54,18 +54,16 @@ "publishConfig": { "access": "public" }, - "overrides": { - "tar": "7.5.11" - }, "scripts": { "build": "tsc -p tsconfig.publish.json", - "lint": "eslint .", + "lint": "sh -c 'eslint . \"$@\" && npm run lint:source-size' --", "lint:ratchet": "sh scripts/lint-ratchet.sh", "lint:md": "markdownlint \"**/*.md\" --ignore node_modules --ignore \"**/node_modules/**\"", "lint:md:code": "node scripts/lint-markdown-code-samples.ts", "lint:links": "lychee --config .lychee.toml '**/*.md'", "lint:semgrep": "node scripts/lint-semgrep-with-quarantines.ts", "lint:sludge": "bash scripts/check-anti-sludge.sh", + "lint:source-size": "node scripts/source-size-gate.ts", "lint:source-version-names": "node scripts/source-version-name-policy.ts", "lint:contamination": "node scripts/contamination-map.ts", "lint:quarantine-graduate": "node scripts/quarantine-graduate-check.ts", @@ -78,6 +76,7 @@ "test:coverage:ci": "vitest run --coverage test/unit test/conformance/v18*Optic*.test.ts", "benchmark": "sh -c 'if [ \"$GIT_STUNTS_DOCKER\" = \"1\" ]; then vitest bench --run test/benchmark \"$@\"; else docker compose -f docker/docker-compose.yml run --build --rm test npm run benchmark:local -- \"$@\"; fi' --", "benchmark:local": "vitest bench --run test/benchmark", + "benchmark:merge-conflicts": "vitest run test/benchmark/MergeConflictCorpus.benchmark.ts", "benchmark:detached-reads": "vitest run test/benchmark/DetachedReadBoundary.benchmark.ts", "benchmark:trie-geometry": "GIT_WARP_PROFILE=1 vitest run test/unit/benchmark/TrieGeometryProfile.profile.test.ts", "setup:hooks": "node scripts/setup-hooks.ts", @@ -122,7 +121,7 @@ "roaring-wasm": "^1.1.0", "string-width": "^7.1.0", "wrap-ansi": "^9.0.0", - "zod": "3.24.1" + "zod": "^3.24.1" }, "optionalDependencies": { "roaring": "^2.7.0" @@ -138,7 +137,7 @@ "eslint-plugin-jsdoc": "^62.8.1", "fast-check": "^4.5.3", "jiti": "^2.6.1", - "markdownlint-cli": "^0.48.0", + "markdownlint-cli": "^0.49.0", "patch-package": "^8.0.0", "prettier": "^3.4.2", "typescript": "^5.9.3", diff --git a/patches/@git-stunts+trailer-codec+2.1.1.patch b/patches/@git-stunts+trailer-codec+2.1.1.patch new file mode 100644 index 000000000..195dac886 --- /dev/null +++ b/patches/@git-stunts+trailer-codec+2.1.1.patch @@ -0,0 +1,169 @@ +diff --git a/node_modules/@git-stunts/trailer-codec/index.d.ts b/node_modules/@git-stunts/trailer-codec/index.d.ts +new file mode 100644 +index 0000000..6525b80 +--- /dev/null ++++ b/node_modules/@git-stunts/trailer-codec/index.d.ts +@@ -0,0 +1,163 @@ ++import type { ZodSchema } from 'zod'; ++ ++export interface GitTrailerJson { ++ readonly key: string; ++ readonly value: string; ++} ++ ++export interface GitCommitMessageInput { ++ readonly title: string; ++ readonly body?: string; ++ readonly trailers?: ReadonlyArray; ++} ++ ++export interface GitCommitMessageOptions { ++ readonly trailerSchema?: ZodSchema; ++ readonly formatters?: { ++ readonly titleFormatter?: (value: string) => string; ++ readonly bodyFormatter?: (value: string) => string; ++ }; ++} ++ ++export class GitCommitMessage { ++ readonly title: string; ++ readonly body: string; ++ readonly trailers: readonly GitTrailer[]; ++ constructor(input: GitCommitMessageInput, options?: GitCommitMessageOptions); ++ toString(): string; ++ toJSON(): { readonly title: string; readonly body: string; readonly trailers: readonly GitTrailerJson[] }; ++} ++ ++export class GitTrailer { ++ readonly key: string; ++ readonly value: string; ++ constructor(key: string, value: string, schema?: ZodSchema); ++ toString(): string; ++ toJSON(): GitTrailerJson; ++} ++ ++export class TrailerCodecError extends Error { ++ readonly meta: Record; ++ constructor(message: string, meta?: Record); ++} ++ ++export interface TrailerSchemaBundle { ++ readonly schema: ZodSchema; ++ readonly keyPattern: string; ++ readonly keyRegex: RegExp; ++} ++ ++export interface TrailerParserOptions { ++ readonly keyPattern?: string; ++} ++ ++export class TrailerParser { ++ readonly lineRegex: RegExp; ++ constructor(options?: TrailerParserOptions); ++ split(lines: readonly string[]): { ++ readonly trailerStart: number; ++ readonly bodyLines: readonly string[]; ++ readonly trailerLines: readonly string[]; ++ }; ++} ++ ++export interface TrailerCodecServiceOptions { ++ readonly schemaBundle?: TrailerSchemaBundle; ++ readonly trailerFactory?: (key: string, value: string, schema: ZodSchema) => GitTrailer; ++ readonly parser?: TrailerParser | null; ++ readonly messageNormalizer?: { ++ normalizeLines(message: string): string[]; ++ guardMessageSize(message: string): void; ++ }; ++ readonly titleExtractor?: (lines: string[]) => { readonly title: string; readonly nextIndex: number }; ++ readonly bodyComposer?: (lines: string[]) => string; ++ readonly formatters?: GitCommitMessageOptions['formatters']; ++} ++ ++export class TrailerCodecService { ++ constructor(options?: TrailerCodecServiceOptions); ++ decode(message: string): GitCommitMessage; ++ encode(messageEntity: GitCommitMessage | GitCommitMessageInput): string; ++} ++ ++export interface TrailerCodecPayload { ++ readonly title: string; ++ readonly body?: string; ++ readonly trailers?: Record; ++} ++ ++export interface TrailerCodecDecodedMessage { ++ readonly title: string; ++ readonly body: string; ++ readonly trailers: Record; ++} ++ ++export interface TrailerCodecFacade { ++ encode(payload: TrailerCodecPayload): string; ++ decode(message: string): TrailerCodecDecodedMessage; ++ encodeMessage(payload: TrailerCodecPayload): string; ++ decodeMessage(message: string): TrailerCodecDecodedMessage; ++} ++ ++export interface TrailerCodecOptions { ++ readonly service: TrailerCodecService; ++ readonly bodyFormatOptions?: BodyFormatOptions; ++} ++ ++export default class TrailerCodec implements TrailerCodecFacade { ++ constructor(options: TrailerCodecOptions); ++ encode(payload: TrailerCodecPayload): string; ++ decode(message: string): TrailerCodecDecodedMessage; ++ encodeMessage(payload: TrailerCodecPayload): string; ++ decodeMessage(message: string): TrailerCodecDecodedMessage; ++} ++ ++export { TrailerCodec }; ++ ++export interface BodyFormatOptions { ++ readonly keepTrailingNewline?: boolean; ++} ++ ++export function formatBodySegment(body?: string, options?: BodyFormatOptions): string; ++ ++export interface TrailerMessageHelpers { ++ decodeMessage(message: string): TrailerCodecDecodedMessage; ++ encodeMessage(payload: TrailerCodecPayload): string; ++} ++ ++export function createMessageHelpers(options?: { ++ readonly service?: TrailerCodecService; ++ readonly bodyFormatOptions?: BodyFormatOptions; ++}): TrailerMessageHelpers; ++ ++export function createDefaultTrailerCodec(options?: { ++ readonly bodyFormatOptions?: BodyFormatOptions; ++}): TrailerCodec; ++ ++export function decodeMessage(message: string, bodyFormatOptions?: BodyFormatOptions): TrailerCodecDecodedMessage; ++export function encodeMessage(payload: TrailerCodecPayload, bodyFormatOptions?: BodyFormatOptions): string; ++ ++export interface ConfiguredCodecOptions { ++ readonly keyPattern?: string | RegExp; ++ readonly keyMaxLength?: number; ++ readonly parserOptions?: TrailerParserOptions; ++ readonly formatters?: GitCommitMessageOptions['formatters']; ++ readonly bodyFormatOptions?: BodyFormatOptions; ++} ++ ++export interface ConfiguredCodec { ++ readonly service: TrailerCodecService; ++ readonly helpers: TrailerMessageHelpers; ++ decodeMessage(message: string): TrailerCodecDecodedMessage; ++ encodeMessage(payload: TrailerCodecPayload): string; ++} ++ ++export function createConfiguredCodec(options?: ConfiguredCodecOptions): ConfiguredCodec; ++ ++export function createGitTrailerSchemaBundle(options?: { ++ readonly keyPattern?: string | RegExp; ++ readonly keyMaxLength?: number; ++}): TrailerSchemaBundle; ++ ++export const TRAILER_KEY_RAW_PATTERN_STRING: string; ++export const TRAILER_KEY_REGEX: RegExp; diff --git a/patches/README.md b/patches/README.md index 82b93e95f..691f92b5a 100644 --- a/patches/README.md +++ b/patches/README.md @@ -5,7 +5,31 @@ This directory contains local modifications to dependencies, managed by [`patch- ## Rationale ### `@mapbox/node-pre-gyp@2.0.3` + - **Issue:** Uses deprecated legacy Node.js APIs (`url.parse`, `url.resolve`). - **Impact:** Causes warnings and potential resolution failures in modern Node.js environments (Node 22+). - **Why Patch?** Upstream maintenance on `mapbox/node-pre-gyp` is slow, and the package maintains strict backward compatibility with very old Node.js versions, preventing them from adopting the modern `URL` constructor. - **Status:** Required until upstream releases a version that uses the modern `URL` API or until dependencies (like `roaring`) migrate away from `node-pre-gyp`. + +### `@git-stunts/alfred@0.10.3` + +- **Issue:** The timeout policy only uses injected clocks, which can leave the + default runtime path without a real timer. +- **Impact:** Production timeout policies need to race against wall-clock + timers while tests can still supply deterministic clocks. +- **Why Patch?** git-warp depends on timeout enforcement before the upstream + package has released equivalent system-clock behavior. +- **Status:** Required until upstream ships the same real-clock fallback and + timer cleanup semantics. + +### `@git-stunts/trailer-codec@2.1.1` + +- **Issue:** The package ships JavaScript without bundled TypeScript + declarations. +- **Impact:** git-warp would otherwise need ambient declarations in its own + source tree, making dependency runtime drift harder to notice. +- **Why Patch?** Keeping declarations beside the dependency gives the + TypeScript compiler a package-local contract while preserving the runtime + dependency. +- **Status:** Required until upstream publishes equivalent package + declarations. diff --git a/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts b/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts new file mode 100644 index 000000000..6b757b994 --- /dev/null +++ b/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts @@ -0,0 +1,8 @@ +import SubstrateCompatibilityPolicy from '../../../src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts'; + +export const V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY = new SubstrateCompatibilityPolicy({ + legacyContentBlobReads: true, + legacyInlinePayloadReads: true, + legacyPatchStorageReads: true, + legacyTrustRecordBlobReads: true, +}); diff --git a/scripts/source-size-gate.ts b/scripts/source-size-gate.ts new file mode 100644 index 000000000..8e7c1cd85 --- /dev/null +++ b/scripts/source-size-gate.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export type SourceSizeBand = 'source' | 'test' | 'tooling'; + +export type SourceSizeInventoryEntry = { + readonly path: string; + readonly lines: number; + readonly band: SourceSizeBand; + readonly ceiling: number; + readonly relaxed: boolean; +}; + +export type SourceSizeGateReport = { + readonly entries: readonly SourceSizeInventoryEntry[]; + readonly violations: readonly SourceSizeInventoryEntry[]; + readonly staleRelaxations: readonly string[]; +}; + +const ROOT = dirname(fileURLToPath(new URL('../package.json', import.meta.url))); +const SOURCE_FILE_LOC_CEILING = 500; +const TEST_FILE_LOC_CEILING = 800; +const TOOLING_FILE_LOC_CEILING = 300; +const SCAN_ROOTS = Object.freeze([ + 'src', + 'bin', + 'scripts', + 'test/unit', + 'test/conformance', +]); + +export const SOURCE_SIZE_RELAXATIONS = Object.freeze([ + 'bin/cli/commands/doctor/checks.ts', + 'bin/cli/commands/seek.ts', + 'bin/cli/infrastructure.ts', + 'scripts/check-dts-surface.ts', + 'scripts/contamination-map.ts', + 'scripts/dead-export-report.ts', + 'scripts/issue-triage-report.ts', + 'scripts/lint-semgrep-with-quarantines.ts', + 'scripts/release-guard.sh', + 'scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureManifest.ts', + 'src/domain/RuntimeHost.ts', + 'src/domain/orset/trie/TrieCursor.ts', + 'src/domain/services/JoinReducerSession.ts', + 'src/domain/services/audit/AuditChainVerifier.ts', + 'src/domain/services/controllers/CheckpointController.ts', + 'src/domain/services/optic/CheckpointBasisManifest.ts', + 'src/domain/services/optic/CheckpointShardFactReader.ts', + 'src/domain/services/state/WarpState.ts', + 'test/unit/domain/WarpGraph.coverageGaps.test.ts', + 'test/unit/domain/services/AuditVerifierService.test.ts', + 'test/unit/domain/services/CheckpointService.edgeCases.test.ts', + 'test/unit/domain/services/CheckpointService.test.ts', + 'test/unit/domain/services/CommitDagTraversalService.test.ts', + 'test/unit/domain/services/GraphTraversal.test.ts', + 'test/unit/domain/services/IncrementalIndexUpdater.test.ts', + 'test/unit/domain/services/JoinReducer.integration.test.ts', + 'test/unit/domain/services/JoinReducer.test.ts', + 'test/unit/domain/services/SyncAuthService.test.ts', + 'test/unit/domain/services/SyncController.test.ts', + 'test/unit/domain/services/SyncProtocol.test.ts', + 'test/unit/domain/services/WarpMessageCodec.test.ts', + 'test/unit/domain/services/WormholeService.test.ts', + 'test/unit/domain/services/controllers/ComparisonController.test.ts', + 'test/unit/domain/services/controllers/MaterializeController.test.ts', + 'test/unit/domain/services/controllers/PatchController.test.ts', + 'test/unit/domain/services/controllers/QueryController.test.ts', + 'test/unit/domain/services/controllers/SyncController.test.ts', + 'test/unit/domain/services/strand/ConflictAnalyzerService.test.ts', + 'test/unit/domain/services/strand/StrandService.test.ts', + 'test/unit/infrastructure/adapters/GitTrieStoreAdapter.test.ts', + 'test/unit/scripts/visible-state-upgrade.test.ts', + 'test/unit/specs/audit-receipt-vectors.test.ts', +]); + +const SOURCE_SIZE_RELAXATION_SET = new Set(SOURCE_SIZE_RELAXATIONS); + +function shouldScanFile(path: string): boolean { + return path.endsWith('.ts') || path.endsWith('.js') || path.endsWith('.sh'); +} + +function collectFiles(root: string, relativeDirectory: string): readonly string[] { + const absoluteDirectory = join(root, relativeDirectory); + const files: string[] = []; + + for (const entry of readdirSync(absoluteDirectory)) { + if (entry === 'node_modules' || entry === 'dist') { + continue; + } + const absolutePath = join(absoluteDirectory, entry); + const relativePath = relative(root, absolutePath); + if (statSync(absolutePath).isDirectory()) { + files.push(...collectFiles(root, relativePath)); + continue; + } + if (shouldScanFile(relativePath)) { + files.push(relativePath); + } + } + + return files; +} + +function classifyPath(path: string): { readonly band: SourceSizeBand; readonly ceiling: number } { + if (path.startsWith('src/')) { + return { band: 'source', ceiling: SOURCE_FILE_LOC_CEILING }; + } + if (path.startsWith('test/')) { + return { band: 'test', ceiling: TEST_FILE_LOC_CEILING }; + } + return { band: 'tooling', ceiling: TOOLING_FILE_LOC_CEILING }; +} + +function countLines(root: string, path: string): number { + return readFileSync(join(root, path), 'utf8').split('\n').length; +} + +export function collectSourceSizeInventory(root: string = ROOT): readonly SourceSizeInventoryEntry[] { + return SCAN_ROOTS + .flatMap((scanRoot) => collectFiles(root, scanRoot)) + .sort() + .map((path) => { + const classification = classifyPath(path); + return { + path, + lines: countLines(root, path), + band: classification.band, + ceiling: classification.ceiling, + relaxed: SOURCE_SIZE_RELAXATION_SET.has(path), + }; + }); +} + +export function checkSourceSizeGate(root: string = ROOT): SourceSizeGateReport { + const entries = collectSourceSizeInventory(root); + const overBudget = entries.filter((entry) => entry.lines > entry.ceiling); + const violations = overBudget.filter((entry) => !entry.relaxed); + const overBudgetPaths = new Set(overBudget.map((entry) => entry.path)); + const staleRelaxations = SOURCE_SIZE_RELAXATIONS + .filter((path) => !overBudgetPaths.has(path)) + .sort(); + + return Object.freeze({ + entries, + violations, + staleRelaxations, + }); +} + +function formatEntry(entry: SourceSizeInventoryEntry): string { + return `${entry.path}: ${entry.lines}/${entry.ceiling} LOC (${entry.band})`; +} + +export function runSourceSizeGate(root: string = ROOT): number { + const report = checkSourceSizeGate(root); + if (report.violations.length === 0 && report.staleRelaxations.length === 0) { + console.log('source-size gate passed'); + return 0; + } + + if (report.violations.length > 0) { + console.error('source-size gate failed: new over-budget files'); + for (const violation of report.violations) { + console.error(` ${formatEntry(violation)}`); + } + } + + if (report.staleRelaxations.length > 0) { + console.error('source-size gate failed: stale relaxation entries'); + for (const path of report.staleRelaxations) { + console.error(` ${path}`); + } + } + + return 1; +} + +const invokedPath = process.argv[1]; +if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href) { + process.exitCode = runSourceSizeGate(); +} diff --git a/scripts/source-version-name-policy.ts b/scripts/source-version-name-policy.ts index eb4c0ff59..aacdfa5c9 100644 --- a/scripts/source-version-name-policy.ts +++ b/scripts/source-version-name-policy.ts @@ -59,7 +59,10 @@ const sourceVersionNameExceptions: readonly SourceVersionNameException[] = Objec pattern: policyPattern([ '(?:', 'git-warp:', + '|git-warp\\.receipt-envelope-boundary/v[0-9]+', '|coordinate-(?:compare|comparison|transfer)\\S*/v[0-9]+', + '|graph-diff/v[0-9]+', + '|ttd-merge-inspection/v[0-9]+', '|visible-state-\\S*/v[0-9]+', '|frontier-lamport/v[0-9]+', '|conflict-analyzer/v[0-9]+', @@ -69,6 +72,7 @@ const sourceVersionNameExceptions: readonly SourceVersionNameException[] = Objec '|full-v[0-9]+', '|git-cas-cbor-patch-v[0-9]+', '|cbor-v[0-9]+', + '|(?:whole|framed|convergent)-v[0-9]+', '|wesley\\.realization\\.manifest\\.v[0-9]+', '|property-target-key:length-prefixed-v[0-9]+', '|effect-emission-v[0-9]+', diff --git a/src/continuumExports.ts b/src/continuumExports.ts new file mode 100644 index 000000000..89b20ff65 --- /dev/null +++ b/src/continuumExports.ts @@ -0,0 +1,79 @@ +export { default as ContinuumArtifactAuthority } from './domain/continuum/ContinuumArtifactAuthority.ts'; +export { default as ContinuumArtifactDescriptor } from './domain/continuum/ContinuumArtifactDescriptor.ts'; +export { default as ContinuumArtifactIngestionPolicy } from './domain/continuum/ContinuumArtifactIngestionPolicy.ts'; +export { default as ContinuumEvidenceAccess } from './domain/continuum/ContinuumEvidenceAccess.ts'; +export { default as ContinuumEvidenceClaim } from './domain/continuum/ContinuumEvidenceClaim.ts'; +export { default as ContinuumEvidenceCompleteness } from './domain/continuum/ContinuumEvidenceCompleteness.ts'; +export { default as ContinuumEvidenceOrigin } from './domain/continuum/ContinuumEvidenceOrigin.ts'; +export { default as ContinuumEvidencePosture } from './domain/continuum/ContinuumEvidencePosture.ts'; +export { default as ContinuumEvidenceProofStrength } from './domain/continuum/ContinuumEvidenceProofStrength.ts'; +export { default as ContinuumFamilyId } from './domain/continuum/ContinuumFamilyId.ts'; +export { default as ContinuumGeneratedFamilyInventory } from './domain/continuum/ContinuumGeneratedFamilyInventory.ts'; +export { default as ContinuumGeneratedFamilyInventoryEntry } from './domain/continuum/ContinuumGeneratedFamilyInventoryEntry.ts'; +export { default as ContinuumGeneratedFamilyStatus } from './domain/continuum/ContinuumGeneratedFamilyStatus.ts'; +export { default as ContinuumReceiptFamilyProjection } from './domain/continuum/ContinuumReceiptFamilyProjection.ts'; +export { default as GitWarpBraidHologram } from './domain/continuum/GitWarpBraidHologram.ts'; +export { default as GitWarpBraidHologramMember } from './domain/continuum/GitWarpBraidHologramMember.ts'; +export { default as GitWarpReadingEnvelopePayloadFact } from './domain/continuum/GitWarpReadingEnvelopePayloadFact.ts'; +export { default as GitWarpReadingEnvelopeSourceFacts } from './domain/continuum/GitWarpReadingEnvelopeSourceFacts.ts'; +export { default as GitWarpReceiptEnvelopeBoundary } from './domain/continuum/GitWarpReceiptEnvelopeBoundary.ts'; +export { default as GitWarpReceiptSourceFacts } from './domain/continuum/GitWarpReceiptSourceFacts.ts'; +export { default as GitWarpSuffixTransformHologram } from './domain/continuum/GitWarpSuffixTransformHologram.ts'; +export { default as GitWarpTickHologram } from './domain/continuum/GitWarpTickHologram.ts'; +export { default as GitWarpTickPatchReplayCore } from './domain/continuum/GitWarpTickPatchReplayCore.ts'; +export { default as GitWarpTickReceiptShell } from './domain/continuum/GitWarpTickReceiptShell.ts'; +export { default as GitWarpTickReceiptWitnessCore } from './domain/continuum/GitWarpTickReceiptWitnessCore.ts'; +export { default as GitWarpTickWitnessLadder } from './domain/continuum/GitWarpTickWitnessLadder.ts'; +export { default as GitWarpWitnessedSuffixAdmissionOutcome } from './domain/continuum/GitWarpWitnessedSuffixAdmissionOutcome.ts'; +export { default as GitWarpWitnessedSuffixAdmissionShell } from './domain/continuum/GitWarpWitnessedSuffixAdmissionShell.ts'; +export { default as GitWarpWitnessedSuffixPatchFact } from './domain/continuum/GitWarpWitnessedSuffixPatchFact.ts'; +export { default as GitWarpWitnessedSuffixSourceFacts } from './domain/continuum/GitWarpWitnessedSuffixSourceFacts.ts'; +export { + default as createCurrentContinuumGeneratedFamilyInventory, +} from './domain/continuum/createCurrentContinuumGeneratedFamilyInventory.ts'; +export { default as ContinuumArtifactJsonFileAdapter } from './infrastructure/adapters/ContinuumArtifactJsonFileAdapter.ts'; + +export type { ContinuumArtifactAuthorityValue } from './domain/continuum/ContinuumArtifactAuthority.ts'; +export type { ContinuumArtifactDescriptorFields } from './domain/continuum/ContinuumArtifactDescriptor.ts'; +export type { ContinuumEvidenceAccessValue } from './domain/continuum/ContinuumEvidenceAccess.ts'; +export type { ContinuumEvidenceClaimFields } from './domain/continuum/ContinuumEvidenceClaim.ts'; +export type { ContinuumEvidenceCompletenessValue } from './domain/continuum/ContinuumEvidenceCompleteness.ts'; +export type { ContinuumEvidenceOriginValue } from './domain/continuum/ContinuumEvidenceOrigin.ts'; +export type { ContinuumEvidencePostureFields } from './domain/continuum/ContinuumEvidencePosture.ts'; +export type { ContinuumEvidenceProofStrengthValue } from './domain/continuum/ContinuumEvidenceProofStrength.ts'; +export type { ContinuumFamilyIdValue } from './domain/continuum/ContinuumFamilyId.ts'; +export type { + ContinuumGeneratedFamilyInventoryEntryFields, +} from './domain/continuum/ContinuumGeneratedFamilyInventoryEntry.ts'; +export type { ContinuumGeneratedFamilyStatusValue } from './domain/continuum/ContinuumGeneratedFamilyStatus.ts'; +export type { + ContinuumDeliveryObservationFact, + ContinuumReceiptFact, + ContinuumReceiptFamilyProjectionFields, + ContinuumReceiptOpFact, + ContinuumReceiptWitnessFact, +} from './domain/continuum/ContinuumReceiptFamilyProjection.ts'; +export type { GitWarpBraidHologramFields } from './domain/continuum/GitWarpBraidHologram.ts'; +export type { GitWarpBraidHologramMemberFields } from './domain/continuum/GitWarpBraidHologramMember.ts'; +export type { GitWarpReadingEnvelopePayloadFactFields } from './domain/continuum/GitWarpReadingEnvelopePayloadFact.ts'; +export type { GitWarpReadingEnvelopeSourceFactsFields } from './domain/continuum/GitWarpReadingEnvelopeSourceFacts.ts'; +export type { + GitWarpReceiptEnvelopeAnchor, + GitWarpReceiptEnvelopeBoundaryFields, +} from './domain/continuum/GitWarpReceiptEnvelopeBoundary.ts'; +export type { GitWarpReceiptSourceFactsFields } from './domain/continuum/GitWarpReceiptSourceFacts.ts'; +export type { GitWarpSuffixTransformHologramFields } from './domain/continuum/GitWarpSuffixTransformHologram.ts'; +export type { GitWarpTickHologramFields } from './domain/continuum/GitWarpTickHologram.ts'; +export type { GitWarpTickPatchReplayCoreFields } from './domain/continuum/GitWarpTickPatchReplayCore.ts'; +export type { GitWarpTickReceiptShellFields } from './domain/continuum/GitWarpTickReceiptShell.ts'; +export type { GitWarpTickReceiptWitnessCoreFields } from './domain/continuum/GitWarpTickReceiptWitnessCore.ts'; +export type { GitWarpTickWitnessLadderFields } from './domain/continuum/GitWarpTickWitnessLadder.ts'; +export type { + GitWarpWitnessedSuffixAdmissionOutcomeValue, +} from './domain/continuum/GitWarpWitnessedSuffixAdmissionOutcome.ts'; +export type { + GitWarpWitnessedSuffixAdmissionShellFields, +} from './domain/continuum/GitWarpWitnessedSuffixAdmissionShell.ts'; +export type { GitWarpWitnessedSuffixPatchFactFields } from './domain/continuum/GitWarpWitnessedSuffixPatchFact.ts'; +export type { GitWarpWitnessedSuffixSourceFactsFields } from './domain/continuum/GitWarpWitnessedSuffixSourceFacts.ts'; +export type { ContinuumArtifactJsonLoadContext } from './infrastructure/adapters/ContinuumArtifactJsonFileAdapter.ts'; diff --git a/src/domain/RuntimeHost.ts b/src/domain/RuntimeHost.ts index 6f42ab6e7..7c74267cd 100644 --- a/src/domain/RuntimeHost.ts +++ b/src/domain/RuntimeHost.ts @@ -81,8 +81,8 @@ import { } from './runtimeHelpers.ts'; import { resolveRuntimeHostConstructionOptions, + type RuntimeHostOpenInput, type RuntimeHostConstructionOptions, - type RuntimeHostOpenOptions, } from './warp/RuntimeHostBoot.ts'; import type { NeighborEdge } from '../ports/NeighborProviderPort.ts'; @@ -453,7 +453,7 @@ export default class RuntimeHost { strandId: string, options: { ceiling?: number | null } = {}, ): Promise { - const result = await this._strandController._materializeStrandLive(strandId, options); + const result = await this._strandController._materializeStrandRead(strandId, options); return await this._materializedGraphFromState(result.state); } @@ -673,6 +673,7 @@ export default class RuntimeHost { planStrandTransfer: ComparisonController['planStrandTransfer'] = (...args) => this._comparisonController.planStrandTransfer(...args); planCoordinateTransfer: ComparisonController['planCoordinateTransfer'] = (...args) => this._comparisonController.planCoordinateTransfer(...args); compareCoordinates: ComparisonController['compareCoordinates'] = (...args) => this._comparisonController.compareCoordinates(...args); + diff: ComparisonController['diff'] = (...args) => this._comparisonController.diff(...args); getFrontier: SyncController['getFrontier'] = (...args) => this._syncController.getFrontier(...args); hasFrontierChanged: SyncController['hasFrontierChanged'] = (...args) => this._syncController.hasFrontierChanged(...args); @@ -761,7 +762,7 @@ export default class RuntimeHost { * writerId: 'node-1' * }); */ - static async open(options: RuntimeHostOpenOptions): Promise { + static async open(options: RuntimeHostOpenInput): Promise { return await openRuntimeHost(options); } @@ -912,7 +913,7 @@ export default class RuntimeHost { } } -export async function openRuntimeHost(options: RuntimeHostOpenOptions): Promise { +export async function openRuntimeHost(options: RuntimeHostOpenInput): Promise { const { options: resolvedOptions } = await resolveRuntimeHostConstructionOptions(options); const graph = new RuntimeHost(resolvedOptions); await graph._validateMigrationBoundary(); diff --git a/src/domain/WarpApp.ts b/src/domain/WarpApp.ts index 825a6df7a..e9bc46fd5 100644 --- a/src/domain/WarpApp.ts +++ b/src/domain/WarpApp.ts @@ -17,7 +17,7 @@ import type { WatchOptions, } from './capabilities/SubscriptionCapability.ts'; import type Observer from './services/query/Observer.ts'; -import type Worldline from './services/Worldline.ts'; +import type ProjectionHandle from './services/ProjectionHandle.ts'; import type { Writer } from './warp/Writer.ts'; import type { PatchBuilder } from './services/PatchBuilder.ts'; @@ -31,7 +31,7 @@ type AppSurface = { patch(build: PatchBuild): Promise; patchMany(...builds: PatchBuild[]): Promise; syncWith(remote: string | AppSurface, options?: SyncWithOptions): Promise; - worldline(options?: WorldlineOptions): Worldline; + worldline(options?: WorldlineOptions): ProjectionHandle; observer( nameOrConfig: string | Aperture, configOrOptions?: Aperture | ObserverOptions, @@ -155,7 +155,7 @@ export default class WarpApp { return await this._surface().syncWith(unwrapSyncRemote(remote), options); } - worldline(options?: WorldlineOptions): Worldline { + worldline(options?: WorldlineOptions): ProjectionHandle { return this._surface().worldline(options); } diff --git a/src/domain/WarpCore.ts b/src/domain/WarpCore.ts index ee3a9c54b..588d04676 100644 --- a/src/domain/WarpCore.ts +++ b/src/domain/WarpCore.ts @@ -1,7 +1,7 @@ import WarpError from './errors/WarpError.ts'; import { openWarpCoreRuntimeProduct, - type WarpCoreOpenOptions, + type WarpCoreOpenInput, type WarpCoreRuntimeSurface, } from './warp/WarpCoreRuntimeProduct.ts'; @@ -130,6 +130,7 @@ export default class WarpCore { declare readonly compareStrand: WarpCoreRuntimeSurface['compareStrand']; declare readonly planStrandTransfer: WarpCoreRuntimeSurface['planStrandTransfer']; declare readonly compareCoordinates: WarpCoreRuntimeSurface['compareCoordinates']; + declare readonly diff: WarpCoreRuntimeSurface['diff']; declare readonly planCoordinateTransfer: WarpCoreRuntimeSurface['planCoordinateTransfer']; declare readonly subscribe: WarpCoreRuntimeSurface['subscribe']; declare readonly watch: WarpCoreRuntimeSurface['watch']; @@ -139,7 +140,7 @@ export default class WarpCore { declare _effectPipeline: EffectPipeline | null; declare readonly _crypto: CryptoPort; - static async open(options: WarpCoreOpenOptions): Promise { + static async open(options: WarpCoreOpenInput): Promise { return WarpCore._adopt(await openWarpCoreRuntimeProduct(options)); } diff --git a/src/domain/WarpGraph.ts b/src/domain/WarpGraph.ts index b3e7917f2..52ed02888 100644 --- a/src/domain/WarpGraph.ts +++ b/src/domain/WarpGraph.ts @@ -322,13 +322,14 @@ function bindProvenanceCapability(runtime: WarpGraphRuntimeSurface): ProvenanceC function bindComparisonCapability(runtime: WarpGraphRuntimeSurface): ComparisonCapability { requireCapability(runtime, 'comparison', [ 'buildPatchDivergence', 'compareStrand', 'planStrandTransfer', - 'compareCoordinates', 'planCoordinateTransfer', + 'compareCoordinates', 'diff', 'planCoordinateTransfer', ]); return Object.freeze({ buildPatchDivergence: runtime.buildPatchDivergence.bind(runtime), compareStrand: runtime.compareStrand.bind(runtime), planStrandTransfer: runtime.planStrandTransfer.bind(runtime), compareCoordinates: runtime.compareCoordinates.bind(runtime), + diff: runtime.diff.bind(runtime), planCoordinateTransfer: runtime.planCoordinateTransfer.bind(runtime), }); } diff --git a/src/domain/WarpWorldline.ts b/src/domain/WarpWorldline.ts index 64734330f..7eea2231e 100644 --- a/src/domain/WarpWorldline.ts +++ b/src/domain/WarpWorldline.ts @@ -14,7 +14,7 @@ import WarpWorldlineOpticBasis from './WarpWorldlineOpticBasis.ts'; import { openRuntimeHostProduct } from './warp/RuntimeHostProduct.ts'; import type { Aperture } from './types/Aperture.ts'; import type { PatchBuilder } from './services/PatchBuilder.ts'; -import type Worldline from './services/Worldline.ts'; +import type ProjectionHandle from './services/ProjectionHandle.ts'; import type Observer from './services/query/Observer.ts'; import type WorldlineOptic from './services/optic/WorldlineOptic.ts'; import CheckpointTailBasisVerifier from './services/optic/CheckpointTailBasisVerifier.ts'; @@ -30,8 +30,8 @@ export type WarpWorldlinePatchBuild = ( ) => void | Promise; type CommitPatch = (build: WarpWorldlinePatchBuild) => Promise; -type WorldlineOptions = Parameters[0]; -type CreateWorldline = (options?: WorldlineOptions) => Worldline; +type WorldlineOptions = Parameters[0]; +type CreateWorldline = (options?: WorldlineOptions) => ProjectionHandle; type PrepareOpticBasis = () => Promise; type GetFrontier = () => Promise>; type ReadOpticBasis = () => WarpWorldlineOpticBasis | null; @@ -76,11 +76,11 @@ export default class WarpWorldline { return await this._commitPatch(build); } - live(): Worldline { + live(): ProjectionHandle { return this._createWorldline(); } - async seek(options?: WorldlineOptions): Promise { + async seek(options?: WorldlineOptions): Promise { return await this.live().seek(options); } diff --git a/src/domain/WarpWorldlineCoordinate.ts b/src/domain/WarpWorldlineCoordinate.ts index 2d398f5be..07c7fe457 100644 --- a/src/domain/WarpWorldlineCoordinate.ts +++ b/src/domain/WarpWorldlineCoordinate.ts @@ -1,6 +1,6 @@ import WarpError from './errors/WarpError.ts'; import type WorldlineOptic from './services/optic/WorldlineOptic.ts'; -import type Worldline from './services/Worldline.ts'; +import type ProjectionHandle from './services/ProjectionHandle.ts'; import type { WorldlineOptions, WorldlineSource } from './capabilities/QueryCapability.ts'; export type WarpWorldlineCoordinateFrontierEntry = { @@ -12,7 +12,7 @@ export type WarpWorldlineCoordinateOptions = { readonly worldlineName: string; readonly checkpointSha: string; readonly frontier: Map; - readonly createWorldline: (options?: WorldlineOptions) => Worldline; + readonly createWorldline: (options?: WorldlineOptions) => ProjectionHandle; }; export default class WarpWorldlineCoordinate { @@ -20,7 +20,7 @@ export default class WarpWorldlineCoordinate { readonly worldlineName: string; readonly checkpointSha: string; readonly frontierEntries: readonly WarpWorldlineCoordinateFrontierEntry[]; - private readonly _createWorldline: (options?: WorldlineOptions) => Worldline; + private readonly _createWorldline: (options?: WorldlineOptions) => ProjectionHandle; constructor(options: WarpWorldlineCoordinateOptions) { assertFrontier(options.frontier); @@ -75,7 +75,7 @@ function assertFrontier(frontier: Map): void { } } -function assertWorldlineFactory(createWorldline: (options?: WorldlineOptions) => Worldline): void { +function assertWorldlineFactory(createWorldline: (options?: WorldlineOptions) => ProjectionHandle): void { if (typeof createWorldline !== 'function') { throw new WarpError( 'WarpWorldline coordinate requires a worldline factory', diff --git a/src/domain/capabilities/ComparisonCapability.ts b/src/domain/capabilities/ComparisonCapability.ts index 9ee639987..25142eeab 100644 --- a/src/domain/capabilities/ComparisonCapability.ts +++ b/src/domain/capabilities/ComparisonCapability.ts @@ -5,6 +5,7 @@ */ import type Patch from '../types/Patch.ts'; +import type GraphDiff from '../services/comparison/GraphDiff.ts'; import type { CoordinateComparisonSelectorInput, CoordinateComparison, @@ -51,6 +52,14 @@ export type PlanCoordinateTransferOptions = { scope?: VisibleStateScope | null; }; +/** Options for graph diff over the live coordinate at two Lamport ceilings. */ +export type GraphDiffOptions = { + from: number; + to: number; + targetId?: string | null; + scope?: VisibleStateScope | null; +}; + export default abstract class ComparisonCapability { /** Build a patch-divergence summary from two ordered patch streams. */ abstract buildPatchDivergence( @@ -76,6 +85,11 @@ export default abstract class ComparisonCapability { _options: CompareCoordinatesOptions, ): Promise; + /** Return a first-class graph delta between two live Lamport ceilings. */ + abstract diff( + _options: GraphDiffOptions, + ): Promise; + /** Plan transfer from one explicit coordinate selector into another. */ abstract planCoordinateTransfer( _options: PlanCoordinateTransferOptions, diff --git a/src/domain/capabilities/DetachedGraphFactory.ts b/src/domain/capabilities/DetachedGraphFactory.ts index ab9818458..e57de0e8b 100644 --- a/src/domain/capabilities/DetachedGraphFactory.ts +++ b/src/domain/capabilities/DetachedGraphFactory.ts @@ -77,7 +77,7 @@ export type DetachedGraphInternalReadSurface = * * Replaces the 3 duplicated `openDetachedReadGraph` / * `openDetachedObserverGraph` free functions scattered across - * QueryController, MaterializeController, and Worldline. + * QueryController, MaterializeController, and ProjectionHandle. * * The returned graph has `autoMaterialize: false` and is intended * for snapshot queries that must not mutate the primary graph. diff --git a/src/domain/capabilities/MaterializeCapability.ts b/src/domain/capabilities/MaterializeCapability.ts index 16fbeacbf..2d3a5881c 100644 --- a/src/domain/capabilities/MaterializeCapability.ts +++ b/src/domain/capabilities/MaterializeCapability.ts @@ -5,7 +5,7 @@ * index verification, and cache invalidation. * * Compatibility substrate capability. Application reads should prefer - * openWarpWorldline(), Worldline, Observer, and bounded optic handles. + * openWarpWorldline(), ProjectionHandle, Observer, and bounded optic handles. */ import type SnapshotWarpState from '../services/snapshot/SnapshotWarpState.ts'; diff --git a/src/domain/capabilities/PatchCollector.ts b/src/domain/capabilities/PatchCollector.ts index 62c8b1f0e..f5d7671e5 100644 --- a/src/domain/capabilities/PatchCollector.ts +++ b/src/domain/capabilities/PatchCollector.ts @@ -1,5 +1,6 @@ import type Patch from '../types/Patch.ts'; import type WarpState from '../services/state/WarpState.ts'; +import type { ProvenanceIndex } from '../services/provenance/ProvenanceIndex.ts'; /** A patch with its content-addressable SHA. */ export type PatchWithSha = { patch: Patch; sha: string }; @@ -10,10 +11,26 @@ export type CheckpointData = { frontier: Map; stateHash: string; schema: number; - provenanceIndex?: { clone(): unknown; addPatch(sha: string, reads?: string[], writes?: string[]): void } | undefined; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + provenanceIndex?: ProvenanceIndex | undefined; indexShardOids?: Record | null | undefined; }; +async function collectPatchEntries(source: AsyncIterable): Promise { + const entries: PatchWithSha[] = []; + for await (const entry of source) { + entries.push(entry); + } + return entries; +} + +function patchWithinCeiling(entry: PatchWithSha, ceiling: number | null): boolean { + return ceiling === null || entry.patch.lamport <= ceiling; +} + +function validTipSha(tipSha: string | undefined): tipSha is string { + return typeof tipSha === 'string' && tipSha.length > 0; +} + /** * Collects patches for materialization. * @@ -28,18 +45,48 @@ export default abstract class PatchCollector { /** Load all patches for a single writer. */ abstract loadWriterPatches(_writerId: string): Promise; + /** Stream all patches for a single writer. */ + async *streamWriterPatches(writerId: string): AsyncIterable { + for (const entry of await this.loadWriterPatches(writerId)) { + yield entry; + } + } + /** Load patches for a frontier, filtered by optional ceiling. */ - abstract collectForFrontier( - _frontier: Map, - _ceiling: number | null, - ): Promise; + async collectForFrontier(frontier: Map, ceiling: number | null): Promise { + return await collectPatchEntries(this.streamForFrontier(frontier, ceiling)); + } + + /** Stream patches for a frontier, filtered by optional ceiling. */ + async *streamForFrontier( + frontier: Map, + ceiling: number | null, + ): AsyncIterable { + for (const writerId of frontier.keys()) { + const tipSha = frontier.get(writerId); + if (!validTipSha(tipSha)) { continue; } + for (const entry of await this.loadPatchChain(tipSha)) { + if (patchWithinCeiling(entry, ceiling)) { + yield entry; + } + } + } + } collectForFrontierSinceCoordinate( frontier: Map, ceiling: number | null, - _baseCoordinate: { frontier: Map; ceiling: number | null }, + baseCoordinate: { frontier: Map; ceiling: number | null }, ): Promise { - return this.collectForFrontier(frontier, ceiling); + return collectPatchEntries(this.streamForFrontierSinceCoordinate(frontier, ceiling, baseCoordinate)); + } + + async *streamForFrontierSinceCoordinate( + frontier: Map, + ceiling: number | null, + _baseCoordinate: { frontier: Map; ceiling: number | null }, + ): AsyncIterable { + yield* this.streamForFrontier(frontier, ceiling); } /** Load the latest checkpoint, or null if none. */ @@ -48,6 +95,13 @@ export default abstract class PatchCollector { /** Load patches since a checkpoint. */ abstract loadPatchesSince(_checkpoint: CheckpointData): Promise; + /** Stream patches since a checkpoint. */ + async *streamPatchesSince(checkpoint: CheckpointData): AsyncIterable { + for (const entry of await this.loadPatchesSince(checkpoint)) { + yield entry; + } + } + /** Load a patch chain between two SHAs. */ abstract loadPatchChain(_toSha: string, _fromSha?: string | null): Promise; diff --git a/src/domain/capabilities/QueryCapability.ts b/src/domain/capabilities/QueryCapability.ts index 45bc66a38..76be4c038 100644 --- a/src/domain/capabilities/QueryCapability.ts +++ b/src/domain/capabilities/QueryCapability.ts @@ -9,15 +9,12 @@ import type SnapshotWarpState from '../services/snapshot/SnapshotWarpState.ts'; import type { SnapshotPropValue } from '../services/snapshot/SnapshotPropValue.ts'; import type { ContentMeta } from '../types/ContentMeta.ts'; import type QueryBuilder from '../services/query/QueryBuilder.ts'; -import type Worldline from '../services/Worldline.ts'; +import type ProjectionHandle from '../services/ProjectionHandle.ts'; import type Observer from '../services/query/Observer.ts'; +import type { Aperture } from '../types/Aperture.ts'; /** Observer lens configuration for match/expose/redact filtering. */ -export type ObserverConfig = { - match: string | string[]; - expose?: string[]; - redact?: string[]; -}; +export type ObserverConfig = Aperture; /** Translation cost breakdown between two observer configurations. */ export type TranslationCostResult = { @@ -95,8 +92,8 @@ export default abstract class QueryCapability { /** Create a fluent query builder over the current read model. */ abstract query(): QueryBuilder; - /** Create a worldline read handle pinned by the given options. */ - abstract worldline(_options?: WorldlineOptions): Worldline; + /** Create a projection read handle pinned by the given worldline options. */ + abstract worldline(_options?: WorldlineOptions): ProjectionHandle; /** Create an observer lens for filtered visible reads. */ abstract observer( diff --git a/src/domain/capabilities/SyncCapability.ts b/src/domain/capabilities/SyncCapability.ts index bed150be9..f9aba0fc4 100644 --- a/src/domain/capabilities/SyncCapability.ts +++ b/src/domain/capabilities/SyncCapability.ts @@ -23,6 +23,10 @@ export type WarpStatus = { export type SyncRequest = { type: 'sync-request'; frontier: Record; + page?: { + maxPatches: number; + cursor?: string | null; + }; }; /** Sync response message. */ @@ -30,6 +34,18 @@ export type SyncResponse = { type: 'sync-response'; frontier: Record; patches: Array<{ writerId: string; sha: string; patch: DecodedPatch }>; + page?: { + maxPatches: number | null; + cursor: string | null; + hasMore: boolean; + returnedPatches: number; + }; + metrics?: { + patchCount: number; + skippedWriterCount: number; + estimatedPayloadBytes: number; + latencyMs: number | null; + }; }; /** Trust options for sync verification. */ diff --git a/src/domain/continuum/GitWarpReceiptEnvelopeBoundary.ts b/src/domain/continuum/GitWarpReceiptEnvelopeBoundary.ts new file mode 100644 index 000000000..c8359d5ca --- /dev/null +++ b/src/domain/continuum/GitWarpReceiptEnvelopeBoundary.ts @@ -0,0 +1,73 @@ +import GitWarpTickReceiptShell from './GitWarpTickReceiptShell.ts'; +import GitWarpTickReceiptWitnessCore from './GitWarpTickReceiptWitnessCore.ts'; +import WarpError from '../errors/WarpError.ts'; +import { TickReceipt } from '../types/TickReceipt.ts'; + +export const GIT_WARP_RECEIPT_ENVELOPE_BOUNDARY_VERSION = 'git-warp.receipt-envelope-boundary/v1'; +export const GIT_WARP_RECEIPT_ENVELOPE_FACT_KIND = 'git-warp.tick-receipt'; + +export type GitWarpReceiptEnvelopeBoundaryFields = { + readonly receipt: TickReceipt; +}; + +export type GitWarpReceiptEnvelopeAnchor = { + readonly boundaryVersion: typeof GIT_WARP_RECEIPT_ENVELOPE_BOUNDARY_VERSION; + readonly substrateFactKind: typeof GIT_WARP_RECEIPT_ENVELOPE_FACT_KIND; + readonly patchSha: string; + readonly writer: string; + readonly lamport: number; + readonly outcomeCount: number; + readonly appliedCount: number; + readonly supersededCount: number; + readonly redundantCount: number; + readonly hasExplanatoryReasons: boolean; +}; + +/** Stable substrate-owned receipt anchors for external envelope consumers. */ +export default class GitWarpReceiptEnvelopeBoundary { + readonly boundaryVersion = GIT_WARP_RECEIPT_ENVELOPE_BOUNDARY_VERSION; + readonly substrateFactKind = GIT_WARP_RECEIPT_ENVELOPE_FACT_KIND; + readonly witnessCore: GitWarpTickReceiptWitnessCore; + readonly receiptShell: GitWarpTickReceiptShell; + + constructor(fields: GitWarpReceiptEnvelopeBoundaryFields) { + const receipt = requireReceipt(requireFields(fields).receipt); + this.witnessCore = new GitWarpTickReceiptWitnessCore({ receipt }); + this.receiptShell = new GitWarpTickReceiptShell({ receipt }); + Object.freeze(this); + } + + /** Returns the minimal public anchor without raw ops or debug reason text. */ + stableAnchor(): GitWarpReceiptEnvelopeAnchor { + return Object.freeze({ + boundaryVersion: this.boundaryVersion, + substrateFactKind: this.substrateFactKind, + patchSha: this.witnessCore.patchSha, + writer: this.witnessCore.writer, + lamport: this.witnessCore.lamport, + outcomeCount: this.witnessCore.outcomeCount, + appliedCount: this.witnessCore.appliedCount, + supersededCount: this.witnessCore.supersededCount, + redundantCount: this.witnessCore.redundantCount, + hasExplanatoryReasons: this.receiptShell.hasExplanatoryReasons(), + }); + } +} + +/** Validates the boundary constructor envelope. */ +function requireFields( + value: GitWarpReceiptEnvelopeBoundaryFields | null | undefined, +): GitWarpReceiptEnvelopeBoundaryFields { + if (value === null || value === undefined) { + throw new WarpError('GitWarpReceiptEnvelopeBoundary fields must be provided', 'E_VALIDATION'); + } + return value; +} + +/** Validates a TickReceipt carrier. */ +function requireReceipt(value: TickReceipt): TickReceipt { + if (!(value instanceof TickReceipt)) { + throw new WarpError('receipt must be a TickReceipt', 'E_VALIDATION'); + } + return value; +} diff --git a/src/domain/continuum/GitWarpWitnessedSuffixAdmissionOutcome.ts b/src/domain/continuum/GitWarpWitnessedSuffixAdmissionOutcome.ts new file mode 100644 index 000000000..dfddbb062 --- /dev/null +++ b/src/domain/continuum/GitWarpWitnessedSuffixAdmissionOutcome.ts @@ -0,0 +1,96 @@ +import WarpError from '../errors/WarpError.ts'; + +const ADMITTED = 'admitted'; +const STAGED = 'staged'; +const PLURAL = 'plural'; +const CONFLICT = 'conflict'; +const OBSTRUCTION = 'obstruction'; + +export type GitWarpWitnessedSuffixAdmissionOutcomeValue = + | typeof ADMITTED + | typeof STAGED + | typeof PLURAL + | typeof CONFLICT + | typeof OBSTRUCTION; + +export const GIT_WARP_WITNESSED_SUFFIX_ADMISSION_OUTCOMES: +readonly GitWarpWitnessedSuffixAdmissionOutcomeValue[] = Object.freeze([ + ADMITTED, + STAGED, + PLURAL, + CONFLICT, + OBSTRUCTION, +]); + +/** Runtime-backed admission result for a witnessed suffix shell import. */ +export default class GitWarpWitnessedSuffixAdmissionOutcome { + readonly value: GitWarpWitnessedSuffixAdmissionOutcomeValue; + + constructor(value: string) { + this.value = requireGitWarpWitnessedSuffixAdmissionOutcomeValue(value); + Object.freeze(this); + } + + static admitted(): GitWarpWitnessedSuffixAdmissionOutcome { + return new GitWarpWitnessedSuffixAdmissionOutcome(ADMITTED); + } + + static staged(): GitWarpWitnessedSuffixAdmissionOutcome { + return new GitWarpWitnessedSuffixAdmissionOutcome(STAGED); + } + + static plural(): GitWarpWitnessedSuffixAdmissionOutcome { + return new GitWarpWitnessedSuffixAdmissionOutcome(PLURAL); + } + + static conflict(): GitWarpWitnessedSuffixAdmissionOutcome { + return new GitWarpWitnessedSuffixAdmissionOutcome(CONFLICT); + } + + static obstruction(): GitWarpWitnessedSuffixAdmissionOutcome { + return new GitWarpWitnessedSuffixAdmissionOutcome(OBSTRUCTION); + } + + isAdmitted(): boolean { + return this.value === ADMITTED; + } + + isStaged(): boolean { + return this.value === STAGED; + } + + requiresResolution(): boolean { + return this.value === PLURAL || this.value === CONFLICT || this.value === OBSTRUCTION; + } + + equals(other: GitWarpWitnessedSuffixAdmissionOutcome): boolean { + return this.value === other.value; + } + + toString(): string { + return this.value; + } +} + +export function requireGitWarpWitnessedSuffixAdmissionOutcomeValue( + value: string, +): GitWarpWitnessedSuffixAdmissionOutcomeValue { + if (typeof value !== 'string') { + throw new WarpError( + `GitWarp witnessed suffix admission outcome must be one of: ${ + GIT_WARP_WITNESSED_SUFFIX_ADMISSION_OUTCOMES.join(', ') + }`, + 'E_VALIDATION', + ); + } + const valid = GIT_WARP_WITNESSED_SUFFIX_ADMISSION_OUTCOMES.find((candidate) => candidate === value); + if (valid === undefined) { + throw new WarpError( + `GitWarp witnessed suffix admission outcome must be one of: ${ + GIT_WARP_WITNESSED_SUFFIX_ADMISSION_OUTCOMES.join(', ') + }`, + 'E_VALIDATION', + ); + } + return valid; +} diff --git a/src/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.ts b/src/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.ts new file mode 100644 index 000000000..e5f56489f --- /dev/null +++ b/src/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.ts @@ -0,0 +1,147 @@ +import GitWarpSuffixTransformHologram from './GitWarpSuffixTransformHologram.ts'; +import GitWarpWitnessedSuffixAdmissionOutcome from './GitWarpWitnessedSuffixAdmissionOutcome.ts'; +import GitWarpWitnessedSuffixSourceFacts from './GitWarpWitnessedSuffixSourceFacts.ts'; +import WarpError from '../errors/WarpError.ts'; +import type WarpState from '../services/state/WarpState.ts'; + +export type GitWarpWitnessedSuffixAdmissionShellFields = { + readonly laneId: string; + readonly transportedSiteRef: string; + readonly admissionLawId: string; + readonly outcome: GitWarpWitnessedSuffixAdmissionOutcome; + readonly sourceFacts: GitWarpWitnessedSuffixSourceFacts; + readonly hologram: GitWarpSuffixTransformHologram; +}; + +/** Observer-readable shell for importing a witnessed git-warp suffix. */ +export default class GitWarpWitnessedSuffixAdmissionShell { + readonly graphName: string; + readonly laneId: string; + readonly transportedSiteRef: string; + readonly sourceFrontierRef: string; + readonly basisFrontierRef: string; + readonly targetFrontierRef: string; + readonly admissionLawId: string; + readonly transportLawId: string; + readonly outcome: GitWarpWitnessedSuffixAdmissionOutcome; + readonly sourceFacts: GitWarpWitnessedSuffixSourceFacts; + readonly hologram: GitWarpSuffixTransformHologram; + readonly patchRefs: readonly string[]; + readonly patchCount: number; + readonly witnessRef: string; + readonly bundleDigest: string; + readonly proofRef: string; + + constructor(fields: GitWarpWitnessedSuffixAdmissionShellFields) { + const checkedFields = requireFields(fields); + const sourceFacts = requireSourceFacts(checkedFields.sourceFacts); + const hologram = requireHologram(checkedFields.hologram); + requireMatchingFrontiers(sourceFacts, hologram); + requireMatchingPatchCount(sourceFacts, hologram); + + this.graphName = sourceFacts.graphName; + this.laneId = requireNonEmptyString(checkedFields.laneId, 'laneId'); + this.transportedSiteRef = requireNonEmptyString(checkedFields.transportedSiteRef, 'transportedSiteRef'); + this.sourceFrontierRef = sourceFacts.sourceFrontierRef; + this.basisFrontierRef = sourceFacts.basisFrontierRef; + this.targetFrontierRef = sourceFacts.targetFrontierRef; + this.admissionLawId = requireNonEmptyString(checkedFields.admissionLawId, 'admissionLawId'); + this.transportLawId = hologram.transportLawId; + this.outcome = requireOutcome(checkedFields.outcome); + this.sourceFacts = sourceFacts; + this.hologram = hologram; + this.patchRefs = freezePatchRefs(sourceFacts); + this.patchCount = sourceFacts.patchCount; + this.witnessRef = sourceFacts.witnessRef; + this.bundleDigest = sourceFacts.bundleDigest; + this.proofRef = hologram.proofRef; + Object.freeze(this); + } + + /** Deterministically materializes the admitted target state from a comparable basis. */ + materializeFrom(basis?: WarpState): WarpState { + return this.hologram.materializeFrom(basis); + } + + /** Returns true until runtime-boundary has a generated Wesley profile and fixture. */ + requiresGeneratedProfileBeforeProjection(): boolean { + return this.sourceFacts.requiresGeneratedProfileBeforeProjection(); + } + + isAdmitted(): boolean { + return this.outcome.isAdmitted(); + } + + requiresResolution(): boolean { + return this.outcome.requiresResolution(); + } +} + +function requireFields( + value: GitWarpWitnessedSuffixAdmissionShellFields | null | undefined, +): GitWarpWitnessedSuffixAdmissionShellFields { + if (value === null || value === undefined) { + throw new WarpError('GitWarpWitnessedSuffixAdmissionShell fields must be provided', 'E_VALIDATION'); + } + return value; +} + +function requireSourceFacts( + value: GitWarpWitnessedSuffixSourceFacts, +): GitWarpWitnessedSuffixSourceFacts { + if (!(value instanceof GitWarpWitnessedSuffixSourceFacts)) { + throw new WarpError('sourceFacts must be GitWarpWitnessedSuffixSourceFacts', 'E_VALIDATION'); + } + return value; +} + +function requireHologram(value: GitWarpSuffixTransformHologram): GitWarpSuffixTransformHologram { + if (!(value instanceof GitWarpSuffixTransformHologram)) { + throw new WarpError('hologram must be GitWarpSuffixTransformHologram', 'E_VALIDATION'); + } + return value; +} + +function requireOutcome( + value: GitWarpWitnessedSuffixAdmissionOutcome, +): GitWarpWitnessedSuffixAdmissionOutcome { + if (!(value instanceof GitWarpWitnessedSuffixAdmissionOutcome)) { + throw new WarpError('outcome must be GitWarpWitnessedSuffixAdmissionOutcome', 'E_VALIDATION'); + } + return value; +} + +function requireMatchingFrontiers( + sourceFacts: GitWarpWitnessedSuffixSourceFacts, + hologram: GitWarpSuffixTransformHologram, +): void { + requireSameValue(sourceFacts.sourceFrontierRef, hologram.sourceFrontierRef, 'sourceFrontierRef'); + requireSameValue(sourceFacts.basisFrontierRef, hologram.basisFrontierRef, 'basisFrontierRef'); + requireSameValue(sourceFacts.targetFrontierRef, hologram.targetFrontierRef, 'targetFrontierRef'); +} + +function requireMatchingPatchCount( + sourceFacts: GitWarpWitnessedSuffixSourceFacts, + hologram: GitWarpSuffixTransformHologram, +): void { + if (sourceFacts.patchCount !== hologram.patchCount) { + throw new WarpError('source facts and suffix hologram must name the same patch count', 'E_VALIDATION'); + } +} + +function requireSameValue(left: string, right: string, name: string): void { + if (left !== right) { + throw new WarpError(`source facts and suffix hologram ${name} must match`, 'E_VALIDATION'); + } +} + +function freezePatchRefs(sourceFacts: GitWarpWitnessedSuffixSourceFacts): readonly string[] { + return Object.freeze(sourceFacts.patches.map((patch) => patch.patchSha)); +} + +function requireNonEmptyString(value: string, name: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } + return value; +} diff --git a/src/domain/crdt/ORSet.ts b/src/domain/crdt/ORSet.ts index 6482116b9..2b4e26b3b 100644 --- a/src/domain/crdt/ORSet.ts +++ b/src/domain/crdt/ORSet.ts @@ -55,7 +55,7 @@ interface DeserializeInput { */ function _assertValidDot(dot: Dot): void { if (dot === null || dot === undefined || typeof dot.writerId !== 'string' || !Number.isInteger(dot.counter)) { - throw new CrdtError(`orsetAdd: invalid dot -- expected {writerId: string, counter: integer}, got ${JSON.stringify(dot)}`, { // nosemgrep: ts-no-json-stringify-in-core -- 0025B + throw new CrdtError(`ORSet.add: invalid dot -- expected {writerId: string, counter: integer}, got ${JSON.stringify(dot)}`, { // nosemgrep: ts-no-json-stringify-in-core -- 0025B code: 'E_CRDT_MALFORMED', context: { dot }, }); diff --git a/src/domain/crdt/VersionVector.ts b/src/domain/crdt/VersionVector.ts index 60cee40bf..600c6a298 100644 --- a/src/domain/crdt/VersionVector.ts +++ b/src/domain/crdt/VersionVector.ts @@ -142,7 +142,7 @@ export default class VersionVector { for (const key of sortedKeys) { const val = vv.get(key); if (val === undefined || val === 0) { - throw new CrdtError(`vvSerialize: zero counter for writerId "${key}" — VersionVector must not contain zero counters`, { + throw new CrdtError(`VersionVector.serialize: zero counter for writerId "${key}" — VersionVector must not contain zero counters`, { code: 'E_CRDT_ZERO_COUNTER', context: { writerId: key }, }); diff --git a/src/domain/errors/OperationPolicyExhaustedError.ts b/src/domain/errors/OperationPolicyExhaustedError.ts new file mode 100644 index 000000000..67003cc11 --- /dev/null +++ b/src/domain/errors/OperationPolicyExhaustedError.ts @@ -0,0 +1,18 @@ +import WarpError from './WarpError.ts'; + +export default class OperationPolicyExhaustedError extends WarpError { + static CODE = 'E_OPERATION_POLICY_EXHAUSTED'; + + readonly attempts: number; + declare cause: Error; + + constructor(attempts: number, cause: Error) { + super( + `Operation policy exhausted after ${attempts} attempts`, + OperationPolicyExhaustedError.CODE, + { context: { attempts } }, + ); + this.attempts = attempts; + this.cause = cause; + } +} diff --git a/src/domain/errors/OperationPolicyTimeoutError.ts b/src/domain/errors/OperationPolicyTimeoutError.ts new file mode 100644 index 000000000..80415928b --- /dev/null +++ b/src/domain/errors/OperationPolicyTimeoutError.ts @@ -0,0 +1,18 @@ +import WarpError from './WarpError.ts'; + +export default class OperationPolicyTimeoutError extends WarpError { + static CODE = 'E_OPERATION_POLICY_TIMEOUT'; + + readonly timeoutMs: number; + readonly elapsedMs: number; + + constructor(timeoutMs: number, elapsedMs: number) { + super( + `Operation exceeded timeout ${timeoutMs}ms after ${elapsedMs}ms`, + OperationPolicyTimeoutError.CODE, + { context: { timeoutMs, elapsedMs } }, + ); + this.timeoutMs = timeoutMs; + this.elapsedMs = elapsedMs; + } +} diff --git a/src/domain/errors/index.ts b/src/domain/errors/index.ts index 3e7e616f3..cd1528dee 100644 --- a/src/domain/errors/index.ts +++ b/src/domain/errors/index.ts @@ -7,6 +7,8 @@ export { default as ForkError } from './ForkError.ts'; export { default as IndexError } from './IndexError.ts'; export { default as MemoryBudgetError } from './MemoryBudgetError.ts'; export { default as OperationAbortedError } from './OperationAbortedError.ts'; +export { default as OperationPolicyExhaustedError } from './OperationPolicyExhaustedError.ts'; +export { default as OperationPolicyTimeoutError } from './OperationPolicyTimeoutError.ts'; export { default as PatchError } from './PatchError.ts'; export { default as QueryError } from './QueryError.ts'; export { default as SyncError } from './SyncError.ts'; diff --git a/src/domain/runtimeHelpers.ts b/src/domain/runtimeHelpers.ts index 867df12e9..23886dd6b 100644 --- a/src/domain/runtimeHelpers.ts +++ b/src/domain/runtimeHelpers.ts @@ -98,7 +98,7 @@ export async function resolveIndexStore( * Constructs an EffectPipeline from an array of sinks and an optional externalization lens. */ export async function buildEffectPipeline( - sinks: EffectSinkPort[], + sinks: readonly EffectSinkPort[], lens: ExternalizationPolicy | undefined, ): Promise { const multMod: { MultiplexSink: typeof MultiplexSink } = await import('./services/MultiplexSink.ts'); diff --git a/src/domain/services/OpStrategies.ts b/src/domain/services/OpStrategies.ts index d4aeeaff0..d258364bf 100644 --- a/src/domain/services/OpStrategies.ts +++ b/src/domain/services/OpStrategies.ts @@ -28,7 +28,6 @@ import { encodeEdgePropKey, EDGE_PROP_PREFIX, } from './KeyCodec.ts'; -import { OP_TYPES } from '../types/TickReceipt.ts'; import PatchError from '../errors/PatchError.ts'; import type WarpState from './state/WarpState.ts'; import type { MutablePatchDiff } from '../types/PatchDiff.ts'; @@ -38,6 +37,7 @@ import OpValidator from './OpValidator.ts'; import DiffCalculator from './DiffCalculator.ts'; import type { OpLike } from './OpLike.ts'; // nosemgrep: ts-no-like-types -- 0025C import OpStrategy from './OpStrategy.ts'; +import { validateOpStrategyRegistry } from './OpStrategyRegistryValidation.ts'; import ReceiptBuilder from './ReceiptBuilder.ts'; import type { SnapshotBeforeOp } from './SnapshotBeforeOp.ts'; @@ -303,13 +303,4 @@ export const OP_STRATEGIES: ReadonlyMap = Object.freeze(new ['BlobValue', new BlobValueStrategy()], ])); -// Load-time validation: every strategy must declare a valid receiptName -// that matches a TickReceipt OP_TYPES entry. -for (const [type, strategy] of OP_STRATEGIES) { - if (!OP_TYPES.includes(strategy.receiptName)) { - throw new PatchError( - `OpStrategy '${type}' receiptName '${strategy.receiptName}' is not in TickReceipt OP_TYPES`, - { context: { opType: type, receiptName: strategy.receiptName } }, - ); - } -} +validateOpStrategyRegistry(OP_STRATEGIES); diff --git a/src/domain/services/OpStrategyRegistryValidation.ts b/src/domain/services/OpStrategyRegistryValidation.ts new file mode 100644 index 000000000..1962d9ea0 --- /dev/null +++ b/src/domain/services/OpStrategyRegistryValidation.ts @@ -0,0 +1,79 @@ +import PatchError from '../errors/PatchError.ts'; +import { OP_TYPES } from '../types/TickReceipt.ts'; + +const REQUIRED_STRATEGY_METHODS = Object.freeze([ + 'validate', + 'mutate', + 'outcome', + 'snapshot', + 'accumulate', +] as const); + +type RequiredStrategyMethod = typeof REQUIRED_STRATEGY_METHODS[number]; + +export type OpStrategyRegistryEntry = { + readonly receiptName?: string; + readonly validate?: object; + readonly mutate?: object; + readonly outcome?: object; + readonly snapshot?: object; + readonly accumulate?: object; +}; + +const VALID_RECEIPT_OPS: ReadonlySet = new Set(OP_TYPES); + +export function validateOpStrategyRegistry( + registry: ReadonlyMap, + validReceiptOps: ReadonlySet = VALID_RECEIPT_OPS, +): void { + for (const [opType, strategy] of registry) { + const checkedStrategy = requireStrategyObject(opType, strategy); + for (const methodName of REQUIRED_STRATEGY_METHODS) { + requireStrategyMethod(opType, checkedStrategy, methodName); + } + const receiptName = requireReceiptName(opType, checkedStrategy); + if (!validReceiptOps.has(receiptName)) { + throw new PatchError( + `OpStrategy '${opType}' receiptName '${receiptName}' is not in TickReceipt OP_TYPES`, + { context: { opType, receiptName } }, + ); + } + } +} + +function requireStrategyObject( + opType: string, + strategy: OpStrategyRegistryEntry | null | undefined, +): OpStrategyRegistryEntry { + if (strategy !== null && strategy !== undefined && typeof strategy === 'object') { + return strategy; + } + throw new PatchError( + `OpStrategy '${opType}' must be an object`, + { context: { opType } }, + ); +} + +function requireStrategyMethod( + opType: string, + strategy: OpStrategyRegistryEntry, + methodName: RequiredStrategyMethod, +): void { + if (typeof strategy[methodName] === 'function') { + return; + } + throw new PatchError( + `OpStrategy '${opType}' is missing method '${methodName}'`, + { context: { opType, methodName } }, + ); +} + +function requireReceiptName(opType: string, strategy: OpStrategyRegistryEntry): string { + if (typeof strategy.receiptName === 'string' && strategy.receiptName.length > 0) { + return strategy.receiptName; + } + throw new PatchError( + `OpStrategy '${opType}' is missing receiptName`, + { context: { opType } }, + ); +} diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index 4fc0a37a0..519add27e 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -40,20 +40,16 @@ import { import { commitPatch } from './PatchCommitter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from './codec/WarpMessageCodec.ts'; import type { WarpState } from './JoinReducer.ts'; -import type CommitPort from '../../ports/CommitPort.ts'; -import type BlobPort from '../../ports/BlobPort.ts'; -import type TreePort from '../../ports/TreePort.ts'; -import type RefPort from '../../ports/RefPort.ts'; +import type WarpKernelPort from '../../ports/WarpKernelPort.ts'; import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; -type PersistencePorts = CommitPort & BlobPort & TreePort & RefPort; type DeletePolicy = 'reject' | 'cascade' | 'warn'; type PatchBuilderOptions = { - persistence: PersistencePorts; + persistence: WarpKernelPort; graphName: string; writerId: string; lamport: number; @@ -70,7 +66,7 @@ type PatchBuilderOptions = { }; export class PatchBuilder { - private readonly _persistence: PersistencePorts; + private readonly _persistence: WarpKernelPort; private readonly _graphName: string; private readonly _writerId: string; private readonly _targetRefPath: string | null; diff --git a/src/domain/services/PatchCommitter.ts b/src/domain/services/PatchCommitter.ts index 17f7a985d..9b187f193 100644 --- a/src/domain/services/PatchCommitter.ts +++ b/src/domain/services/PatchCommitter.ts @@ -17,10 +17,7 @@ import PatchError from '../errors/PatchError.ts'; import PersistenceError from '../errors/PersistenceError.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from './codec/WarpMessageCodec.ts'; import type { PatchOp, CanonicalPatchOp } from '../types/ops/unions.ts'; -import type CommitPort from '../../ports/CommitPort.ts'; -import type BlobPort from '../../ports/BlobPort.ts'; -import type TreePort from '../../ports/TreePort.ts'; -import type RefPort from '../../ports/RefPort.ts'; +import type WarpKernelPort from '../../ports/WarpKernelPort.ts'; import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import { @@ -28,10 +25,8 @@ import { type default as CommitMessageCodecPort, } from '../../ports/CommitMessageCodecPort.ts'; -type PersistencePorts = CommitPort & BlobPort & TreePort & RefPort; - export type CommitState = { - persistence: PersistencePorts; + persistence: WarpKernelPort; graphName: string; writerId: string; lamport: number; @@ -173,7 +168,7 @@ function buildWriterCasConflict(expectedSha: string | null, actualSha: string | /** Advances a writer ref atomically and translates stale-head failures. */ async function compareAndSwapWriterRef( - persistence: PersistencePorts, + persistence: WarpKernelPort, writerRef: string, newCommitSha: string, expectedSha: string | null, @@ -194,7 +189,7 @@ async function compareAndSwapWriterRef( /** Verifies that a successful commit is immediately visible at the writer ref. */ async function assertWriterRefVisible( - persistence: PersistencePorts, + persistence: WarpKernelPort, writerRef: string, expectedSha: string, ): Promise { diff --git a/src/domain/services/Worldline.ts b/src/domain/services/ProjectionHandle.ts similarity index 85% rename from src/domain/services/Worldline.ts rename to src/domain/services/ProjectionHandle.ts index 3e7dc4478..cfa93ff1d 100644 --- a/src/domain/services/Worldline.ts +++ b/src/domain/services/ProjectionHandle.ts @@ -22,7 +22,7 @@ import CheckpointTailExactIdQueryReadModel, { type VisibleNodeProps = NonNullable>>; type VisibleEdge = Awaited>[number]; -type WorldlineObserverFactory = { +type ProjectionObserverFactory = { observer(config: Aperture, options?: { source: WorldlineSource }): Promise; observer( name: string, @@ -40,8 +40,11 @@ function toSelector(source?: WorldlineSelector | WorldlineSource | null): Worldl return new LiveSelector(); } - const sourceKind = source.kind; + return selectorFromSource(source); +} +function selectorFromSource(source: WorldlineSource): WorldlineSelector { + const sourceKind = source.kind; if (sourceKind === 'live') { return new LiveSelector(source.ceiling); } @@ -79,8 +82,8 @@ function toWorldlineSource(source: WorldlineSelector): WorldlineSource { }); } -export default class Worldline { - private readonly _graph: WorldlineObserverFactory; +export default class ProjectionHandle { + private readonly _graph: ProjectionObserverFactory; private readonly _source: WorldlineSelector; private readonly _opticSource: CheckpointTailOpticSource | null; private _delegateObserverPromise: Promise | null; @@ -91,7 +94,7 @@ export default class Worldline { source, opticSource, }: { - graph: WorldlineObserverFactory; + graph: ProjectionObserverFactory; source?: WorldlineSelector | WorldlineSource | null; opticSource?: CheckpointTailOpticSource | null; }) { @@ -106,9 +109,9 @@ export default class Worldline { return toWorldlineSource(this._source); } - async seek(options?: WorldlineOptions): Promise { + async seek(options?: WorldlineOptions): Promise { return await Promise.resolve( - new Worldline({ + new ProjectionHandle({ graph: this._graph, source: options?.source ?? this._source, opticSource: this._opticSource, @@ -201,23 +204,34 @@ export default class Worldline { return null; } if (this._source instanceof LiveSelector) { - if (await source._readCheckpointSha() === null) { - return null; - } - return new WorldlineOptic({ source }); + return await this._liveExactReadOptic(source); } - if (this._source instanceof CoordinateSelector && this._source.checkpointSha !== null) { - return new WorldlineOptic({ - source: new CoordinateCheckpointTailOpticSource({ - source, - checkpointSha: this._source.checkpointSha, - frontier: this._source.frontier, - }), - }); + if (this._source instanceof CoordinateSelector) { + return this._coordinateExactReadOptic(source); } return null; } + private async _liveExactReadOptic(source: CheckpointTailOpticSource): Promise { + if (await source._readCheckpointSha() === null) { + return null; + } + return new WorldlineOptic({ source }); + } + + private _coordinateExactReadOptic(source: CheckpointTailOpticSource): WorldlineOptic | null { + if (!(this._source instanceof CoordinateSelector) || this._source.checkpointSha === null) { + return null; + } + return new WorldlineOptic({ + source: new CoordinateCheckpointTailOpticSource({ + source, + checkpointSha: this._source.checkpointSha, + frontier: this._source.frontier, + }), + }); + } + query(): QueryBuilder { return new QueryBuilder(this.queryReadModelProvider()); } diff --git a/src/domain/services/codec/MessageCodecInternal.ts b/src/domain/services/codec/MessageCodecInternal.ts index b1a77f0f2..6b7d60b71 100644 --- a/src/domain/services/codec/MessageCodecInternal.ts +++ b/src/domain/services/codec/MessageCodecInternal.ts @@ -8,7 +8,7 @@ * Not public API — import from WarpMessageCodec or individual codecs. */ -import { TrailerCodec, TrailerCodecService } from '@git-stunts/trailer-codec'; +import { TrailerCodec, TrailerCodecService, type TrailerCodecFacade } from '@git-stunts/trailer-codec'; import MessageCodecError from '../../errors/MessageCodecError.ts'; // ── Constants ─────────────────────────────────────────────────────── @@ -41,22 +41,14 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/; // ── Codec instance ────────────────────────────────────────────────── -type TrailerCodecShape = { - encode(msg: { title: string; trailers: Record }): string; - decode(msg: string): { trailers: Record }; -}; - -let _codec: TrailerCodecShape | null = null; +let _codec: TrailerCodecFacade | null = null; /** Returns the lazy singleton TrailerCodec instance. */ -export function getCodec(): TrailerCodecShape { +export function getCodec(): TrailerCodecFacade { if (_codec !== null) { return _codec; } - const TrailerCodecServiceCtor = TrailerCodecService as new () => unknown; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - const TrailerCodecCtor = TrailerCodec as new (opts: { service: unknown }) => TrailerCodecShape; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - const service: unknown = new TrailerCodecServiceCtor(); // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - _codec = new TrailerCodecCtor({ service }); + _codec = new TrailerCodec({ service: new TrailerCodecService() }); return _codec; } diff --git a/src/domain/services/codec/MessageSchemaDetector.ts b/src/domain/services/codec/MessageSchemaDetector.ts index 06c0793b6..b0bcd64f4 100644 --- a/src/domain/services/codec/MessageSchemaDetector.ts +++ b/src/domain/services/codec/MessageSchemaDetector.ts @@ -4,6 +4,9 @@ import { EDGE_PROP_PREFIX } from '../KeyCodec.ts'; import SchemaUnsupportedError from '../../errors/SchemaUnsupportedError.ts'; +import EdgePropSet from '../../types/ops/EdgePropSet.ts'; +import PropSet from '../../types/ops/PropSet.ts'; +import type { PatchOp } from '../../types/ops/unions.ts'; import { getCodec, TRAILER_KEYS } from './MessageCodecInternal.ts'; // ── Constants ─────────────────────────────────────────────────────── @@ -19,15 +22,13 @@ export const PATCH_SCHEMA_EDGE_PROPERTIES = EDGE_PROPERTY_PATCH_SCHEMA_VERSION; // ── Schema version detection ──────────────────────────────────────── -type OpLike = { type: string; node?: string }; // nosemgrep: ts-no-like-types -- 0025C - -function isEdgePropOp(op: OpLike): boolean { // nosemgrep: ts-no-like-types -- 0025C - if (op.type === 'EdgePropSet') { return true; } - return op.type === 'PropSet' && typeof op.node === 'string' && op.node.startsWith(EDGE_PROP_PREFIX); +function isEdgePropOp(op: PatchOp): boolean { + if (op instanceof EdgePropSet) { return true; } + return op instanceof PropSet && op.node.startsWith(EDGE_PROP_PREFIX); } /** Detects the schema version required for a set of ops. */ -export function detectSchemaVersion(ops: OpLike[]): number { // nosemgrep: ts-no-like-types -- 0025C +export function detectSchemaVersion(ops: readonly PatchOp[] | null | undefined): number { if (!Array.isArray(ops)) { return CLASSIC_PATCH_SCHEMA_VERSION; } for (const op of ops) { if (op === null || op === undefined || typeof op !== 'object') { continue; } @@ -39,7 +40,7 @@ export function detectSchemaVersion(ops: OpLike[]): number { // nosemgrep: ts-no // ── Schema compatibility ──────────────────────────────────────────── /** Asserts ops are compatible with a max supported schema version. */ -export function assertOpsCompatible(ops: OpLike[], maxSchema: number): void { // nosemgrep: ts-no-like-types -- 0025C +export function assertOpsCompatible(ops: readonly PatchOp[] | null | undefined, maxSchema: number): void { if (maxSchema >= EDGE_PROPERTY_PATCH_SCHEMA_VERSION) { return; } if (!Array.isArray(ops)) { return; } for (const op of ops) { diff --git a/src/domain/services/comparison/GraphDiff.ts b/src/domain/services/comparison/GraphDiff.ts new file mode 100644 index 000000000..5d3f3d9a3 --- /dev/null +++ b/src/domain/services/comparison/GraphDiff.ts @@ -0,0 +1,191 @@ +import QueryError from '../../errors/QueryError.ts'; +import type { + CoordinateComparison, + CoordinateComparisonSide, + VisibleStateComparison, + VisibleStateScope, +} from '../../types/CoordinateComparison.ts'; + +export type GraphDiffFields = { + readonly comparison: CoordinateComparison; +}; + +const GRAPH_DIFF_VERSION = 'graph-diff/v1'; + +/** First-class graph delta result over two resolved coordinates. */ +export default class GraphDiff { + readonly diffVersion = GRAPH_DIFF_VERSION; + readonly comparisonDigest: string; + readonly left: CoordinateComparisonSide; + readonly right: CoordinateComparisonSide; + readonly scope: VisibleStateScope | undefined; + readonly changed: boolean; + readonly summary: VisibleStateComparison['summary']; + readonly nodes: VisibleStateComparison['nodes']; + readonly edges: VisibleStateComparison['edges']; + readonly nodeProperties: VisibleStateComparison['nodeProperties']; + readonly edgeProperties: VisibleStateComparison['edgeProperties']; + readonly visiblePatchDivergence: CoordinateComparison['visiblePatchDivergence']; + + constructor(fields: GraphDiffFields) { + const comparison = requireComparison(requireFields(fields).comparison); + this.comparisonDigest = comparison.comparisonDigest; + this.left = copySide(comparison.left); + this.right = copySide(comparison.right); + this.scope = comparison.scope !== undefined ? copyScope(comparison.scope) : undefined; + this.changed = comparison.visibleState.changed; + this.summary = copySummary(comparison.visibleState.summary); + this.nodes = copyNodes(comparison.visibleState.nodes); + this.edges = copyEdges(comparison.visibleState.edges); + this.nodeProperties = copyNodeProperties(comparison.visibleState.nodeProperties); + this.edgeProperties = copyEdgeProperties(comparison.visibleState.edgeProperties); + this.visiblePatchDivergence = copyPatchDivergence(comparison.visiblePatchDivergence); + Object.freeze(this); + } +} + +function requireFields(fields: GraphDiffFields | null | undefined): GraphDiffFields { + if (fields === null || fields === undefined) { + throw new QueryError('GraphDiff fields must be provided', { + code: 'E_GRAPH_DIFF', + }); + } + return fields; +} + +function requireComparison(comparison: CoordinateComparison): CoordinateComparison { + if (comparison === null || typeof comparison !== 'object') { + throw new QueryError('GraphDiff requires a coordinate comparison', { + code: 'E_GRAPH_DIFF', + }); + } + return comparison; +} + +function freezeObject(value: T): T { + Object.freeze(value); + return value; +} + +function copyStringArray(values: readonly string[]): string[] { + return freezeObject([...values]); +} + +function copySide(side: CoordinateComparisonSide): CoordinateComparisonSide { + const resolved: CoordinateComparisonSide['resolved'] = side.resolved.strand === undefined + ? { + ...side.resolved, + patchFrontier: freezeObject({ ...side.resolved.patchFrontier }), + lamportFrontier: freezeObject({ ...side.resolved.lamportFrontier }), + summary: freezeObject({ ...side.resolved.summary }), + } + : { + ...side.resolved, + patchFrontier: freezeObject({ ...side.resolved.patchFrontier }), + lamportFrontier: freezeObject({ ...side.resolved.lamportFrontier }), + summary: freezeObject({ ...side.resolved.summary }), + strand: freezeObject({ + ...side.resolved.strand, + braid: freezeObject({ + readOverlayCount: side.resolved.strand.braid.readOverlayCount, + braidedStrandIds: copyStringArray(side.resolved.strand.braid.braidedStrandIds), + }), + }), + }; + return freezeObject({ + requested: freezeObject({ ...side.requested }), + resolved: freezeObject(resolved), + }); +} + +function copyScope(scope: VisibleStateScope): VisibleStateScope { + return freezeObject({ + ...(scope.nodeIdPrefixes !== undefined + ? { + nodeIdPrefixes: freezeObject({ + ...(scope.nodeIdPrefixes.include !== undefined + ? { include: copyStringArray(scope.nodeIdPrefixes.include) } + : {}), + ...(scope.nodeIdPrefixes.exclude !== undefined + ? { exclude: copyStringArray(scope.nodeIdPrefixes.exclude) } + : {}), + }), + } + : {}), + }); +} + +function copySummary(summary: VisibleStateComparison['summary']): VisibleStateComparison['summary'] { + return freezeObject({ + left: freezeObject({ ...summary.left }), + right: freezeObject({ ...summary.right }), + nodes: freezeObject({ ...summary.nodes }), + edges: freezeObject({ ...summary.edges }), + nodeProperties: freezeObject({ ...summary.nodeProperties }), + edgeProperties: freezeObject({ ...summary.edgeProperties }), + }); +} + +function copyNodes(nodes: VisibleStateComparison['nodes']): VisibleStateComparison['nodes'] { + return freezeObject({ + added: copyStringArray(nodes.added), + removed: copyStringArray(nodes.removed), + }); +} + +function copyObjectArray(values: readonly T[]): T[] { + return freezeObject(values.map((value) => freezeObject({ ...value }))); +} + +function copyEdges(edges: VisibleStateComparison['edges']): VisibleStateComparison['edges'] { + return freezeObject({ + added: copyObjectArray(edges.added), + removed: copyObjectArray(edges.removed), + }); +} + +function copyNodeProperties( + delta: VisibleStateComparison['nodeProperties'], +): VisibleStateComparison['nodeProperties'] { + return freezeObject({ + added: copyObjectArray(delta.added), + removed: copyObjectArray(delta.removed), + changed: copyObjectArray(delta.changed), + }); +} + +function copyEdgeProperties( + delta: VisibleStateComparison['edgeProperties'], +): VisibleStateComparison['edgeProperties'] { + return freezeObject({ + added: copyObjectArray(delta.added), + removed: copyObjectArray(delta.removed), + changed: copyObjectArray(delta.changed), + }); +} + +function copyPatchDivergence( + divergence: CoordinateComparison['visiblePatchDivergence'], +): CoordinateComparison['visiblePatchDivergence'] { + const copied: CoordinateComparison['visiblePatchDivergence'] = divergence.target === undefined + ? { + sharedCount: divergence.sharedCount, + leftOnlyCount: divergence.leftOnlyCount, + rightOnlyCount: divergence.rightOnlyCount, + leftOnlyPatchShas: copyStringArray(divergence.leftOnlyPatchShas), + rightOnlyPatchShas: copyStringArray(divergence.rightOnlyPatchShas), + } + : { + sharedCount: divergence.sharedCount, + leftOnlyCount: divergence.leftOnlyCount, + rightOnlyCount: divergence.rightOnlyCount, + leftOnlyPatchShas: copyStringArray(divergence.leftOnlyPatchShas), + rightOnlyPatchShas: copyStringArray(divergence.rightOnlyPatchShas), + target: freezeObject({ + ...divergence.target, + leftOnlyPatchShas: copyStringArray(divergence.target.leftOnlyPatchShas), + rightOnlyPatchShas: copyStringArray(divergence.target.rightOnlyPatchShas), + }), + }; + return freezeObject(copied); +} diff --git a/src/domain/services/controllers/ComparisonController.ts b/src/domain/services/controllers/ComparisonController.ts index 1b2003fdf..9b665b0da 100644 --- a/src/domain/services/controllers/ComparisonController.ts +++ b/src/domain/services/controllers/ComparisonController.ts @@ -15,8 +15,10 @@ import type { CompareStrandOptions, PlanStrandTransferOptions, CompareCoordinatesOptions, + GraphDiffOptions, PlanCoordinateTransferOptions, } from '../../capabilities/ComparisonCapability.ts'; +import type GraphDiff from '../comparison/GraphDiff.ts'; import type { ComparisonHost, ComparisonSelectorContext, @@ -28,6 +30,7 @@ import { planStrandTransferImpl, planCoordinateTransferImpl, compareCoordinatesImpl, + diffImpl, type VisiblePatchDivergence, } from './ComparisonEngine.ts'; @@ -79,4 +82,10 @@ export default class ComparisonController { ): Promise { return await compareCoordinatesImpl(this._host, this._selectorContext, options); } + + async diff( + options: GraphDiffOptions, + ): Promise { + return await diffImpl(this._host, this._selectorContext, options); + } } diff --git a/src/domain/services/controllers/ComparisonEngine.ts b/src/domain/services/controllers/ComparisonEngine.ts index 6e88a0870..8ce113aed 100644 --- a/src/domain/services/controllers/ComparisonEngine.ts +++ b/src/domain/services/controllers/ComparisonEngine.ts @@ -15,6 +15,7 @@ import { buildCoordinateTransferPlanFact, } from '../CoordinateFactExport.ts'; import { createStateReader } from '../state/StateReader.ts'; +import GraphDiff from '../comparison/GraphDiff.ts'; import { compareVisibleState } from '../comparison/VisibleStateComparison.ts'; import { planVisibleStateTransfer } from '../transfer/VisibleStateTransferPlanner.ts'; import { normalizeVisibleStateScope } from '../VisibleStateScope.ts'; @@ -29,6 +30,7 @@ import type { CompareStrandOptions, PlanStrandTransferOptions, CompareCoordinatesOptions, + GraphDiffOptions, PlanCoordinateTransferOptions, } from '../../capabilities/ComparisonCapability.ts'; import type Patch from '../../types/Patch.ts'; @@ -266,6 +268,40 @@ export async function compareCoordinatesImpl( }; } +function normalizeDiffTick(value: number, field: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new QueryError(`${field} must be a non-negative integer`, { + code: 'invalid_coordinate', + context: { field, value }, + }); + } + return value; +} + +export async function diffImpl( + graph: ComparisonHost, + selectorContext: ComparisonSelectorContext, + options: GraphDiffOptions, +): Promise { + assertRequiredOptions(options, 'diff()'); + const from = normalizeDiffTick(options.from, 'from'); + const to = normalizeDiffTick(options.to, 'to'); + if (from > to) { + throw new QueryError('diff() requires from <= to', { + code: 'invalid_coordinate', + context: { from, to }, + }); + } + const scope = normalizeVisibleStateScope(options.scope, 'scope'); + const comparison = await compareCoordinatesImpl(graph, selectorContext, { + left: { kind: 'live', ceiling: from }, + right: { kind: 'live', ceiling: to }, + targetId: normalizeOptionalString(options.targetId, 'targetId'), + ...(scope !== null && scope !== undefined ? { scope } : {}), + }); + return new GraphDiff({ comparison }); +} + export async function compareStrandImpl( graph: ComparisonHost, selectorContext: ComparisonSelectorContext, diff --git a/src/domain/services/controllers/MaterializeCeilingStrategy.ts b/src/domain/services/controllers/MaterializeCeilingStrategy.ts new file mode 100644 index 000000000..412099384 --- /dev/null +++ b/src/domain/services/controllers/MaterializeCeilingStrategy.ts @@ -0,0 +1,28 @@ +import type { + MaterializeCeilingOptions, + MaterializeStrategyRuntime, +} from './MaterializeStrategyRuntime.ts'; +import type { MaterializeResult } from './MaterializeController.ts'; +import type MaterializeCoordinateStrategy from './MaterializeCoordinateStrategy.ts'; + +export default class MaterializeCeilingStrategy { + private readonly runtime: MaterializeStrategyRuntime; + private readonly coordinateStrategy: MaterializeCoordinateStrategy; + + constructor( + runtime: MaterializeStrategyRuntime, + coordinateStrategy: MaterializeCoordinateStrategy, + ) { + this.runtime = runtime; + this.coordinateStrategy = coordinateStrategy; + } + + async materialize(opts: MaterializeCeilingOptions): Promise { + const frontier = await this.runtime.deps.patches.getFrontier(); + return await this.coordinateStrategy.materialize({ + frontier, + ceiling: opts.ceiling, + receipts: opts.receipts, + }); + } +} diff --git a/src/domain/services/controllers/MaterializeCheckpointStrategy.ts b/src/domain/services/controllers/MaterializeCheckpointStrategy.ts new file mode 100644 index 000000000..306dacaf6 --- /dev/null +++ b/src/domain/services/controllers/MaterializeCheckpointStrategy.ts @@ -0,0 +1,49 @@ +import { materializeIncremental } from '../state/checkpointLoad.ts'; +import { createFrontier, updateFrontier } from '../Frontier.ts'; +import { buildWriterRef } from '../../utils/RefLayout.ts'; +import SchemaUnsupportedError from '../../errors/SchemaUnsupportedError.ts'; +import type { MaterializeResult } from './MaterializeController.ts'; +import type { MaterializeStrategyRuntime } from './MaterializeStrategyRuntime.ts'; + +export default class MaterializeCheckpointStrategy { + private readonly runtime: MaterializeStrategyRuntime; + + constructor(runtime: MaterializeStrategyRuntime) { + this.runtime = runtime; + } + + async materializeAt(checkpointSha: string): Promise { + if (this.runtime.deps.openStateSession !== undefined) { + throw new SchemaUnsupportedError( + 'materializeAt() is not supported on the session-backed runtime line. ' + + 'Run the offline checkpoint migration first.', + ); + } + const frontier = await this.buildTargetFrontier(); + const patchLoader = async (_writerId: string, from: string | null, to: string) => + await this.runtime.deps.patches.loadPatchChain(to, from); + + const state = await materializeIncremental({ + persistence: this.runtime.loadPersistence(), + graphName: this.runtime.deps.graphName, + checkpointSha, + targetFrontier: frontier, + patchLoader, + codec: this.runtime.deps.codec, + }); + return await this.runtime.wrapState(state, null, null); + } + + private async buildTargetFrontier(): Promise> { + const writers = await this.runtime.deps.patches.discoverWriters(); + const frontier = createFrontier(); + for (const writerId of writers) { + const ref = buildWriterRef(this.runtime.deps.graphName, writerId); + const tipSha = await this.runtime.deps.persistence.readRef(ref); + if (typeof tipSha === 'string' && tipSha.length > 0) { + updateFrontier(frontier, writerId, tipSha); + } + } + return frontier; + } +} diff --git a/src/domain/services/controllers/MaterializeController.ts b/src/domain/services/controllers/MaterializeController.ts index 47dfa5d84..9eb8eac75 100644 --- a/src/domain/services/controllers/MaterializeController.ts +++ b/src/domain/services/controllers/MaterializeController.ts @@ -11,41 +11,45 @@ */ import { reducePatches as reduceJoinedPatches, createEmptyState } from '../JoinReducer.ts'; -import { isCurrentCheckpointSchema } from '../state/checkpointHelpers.ts'; -import { materializeIncremental, type LoadPersistence } from '../state/checkpointLoad.ts'; +import { type LoadPersistence } from '../state/checkpointLoad.ts'; import { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; import { computeStateHash } from '../state/StateSerializer.ts'; -import { createFrontier, updateFrontier } from '../Frontier.ts'; -import { buildWriterRef } from '../../utils/RefLayout.ts'; import { normalizeFrontierInput, normalizeExplicitCeiling, buildAdjacency, - maxLamportInPatches, type MaterializeAdjacency, } from './MaterializeHelpers.ts'; -import SchemaUnsupportedError from '../../errors/SchemaUnsupportedError.ts'; import { reduceSessionBackedState, type MaterializeSessionOpener, } from './MaterializeSessionBridge.ts'; +import MaterializeLiveStrategy from './MaterializeLiveStrategy.ts'; +import MaterializeCoordinateStrategy from './MaterializeCoordinateStrategy.ts'; +import MaterializeCeilingStrategy from './MaterializeCeilingStrategy.ts'; +import MaterializeCheckpointStrategy from './MaterializeCheckpointStrategy.ts'; +import MaterializePatchStreamReducer, { + type MaterializePatchStreamOptions, + type MaterializePatchStreamReduction, +} from './MaterializePatchStreamReducer.ts'; +import { summarizeMaterializePatches } from './MaterializePatchSummary.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type WarpStateCachePort from '../../../ports/WarpStateCachePort.ts'; -import type { - WarpStateCoordinate, - WarpStateSnapshotRecord, -} from '../../../ports/WarpStateCachePort.ts'; import type PatchCollector from '../../capabilities/PatchCollector.ts'; -import type { PatchWithSha, CheckpointData } from '../../capabilities/PatchCollector.ts'; +import type { PatchWithSha } from '../../capabilities/PatchCollector.ts'; import type DetachedGraphFactory from '../../capabilities/DetachedGraphFactory.ts'; import type WarpState from '../state/WarpState.ts'; import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { PatchDiff } from '../../types/PatchDiff.ts'; import AdjacencyMap from '../../capabilities/AdjacencyMap.ts'; +import type { + MaterializeResultBuildInput, + MaterializeStrategyRuntime, +} from './MaterializeStrategyRuntime.ts'; -type MaterializePersistence = { +export type MaterializePersistence = { readRef(ref: string): Promise; showNode(sha: string): Promise; readTreeOids(treeOid: string): Promise>; @@ -92,24 +96,32 @@ function toReducerInput(patches: PatchWithSha[]): ReducerInput { return patches as ReducerInput; } -type ReduceOutput = { +export type MaterializeReduceOutput = { state: WarpState; adjacency?: MaterializeAdjacency; receipts?: TickReceipt[]; diff?: PatchDiff; }; -function reduceWithReceipts(patches: PatchWithSha[], base?: WarpState): ReduceOutput { - const r = reduceJoinedPatches(toReducerInput(patches), base, { receipts: true }) as { state: WarpState; receipts: TickReceipt[] }; +function reduceWithReceipts(patches: PatchWithSha[], base?: WarpState): MaterializeReduceOutput { + const r = reduceJoinedPatches( + toReducerInput(patches), + base, + { receipts: true }, + ) as { state: WarpState; receipts: TickReceipt[] }; return { state: r.state, receipts: r.receipts }; } -function reduceWithDiff(patches: PatchWithSha[], base?: WarpState): ReduceOutput { - const r = reduceJoinedPatches(toReducerInput(patches), base, { trackDiff: true }) as { state: WarpState; diff: PatchDiff }; +function reduceWithDiff(patches: PatchWithSha[], base?: WarpState): MaterializeReduceOutput { + const r = reduceJoinedPatches( + toReducerInput(patches), + base, + { trackDiff: true }, + ) as { state: WarpState; diff: PatchDiff }; return { state: r.state, diff: r.diff }; } -function reducePlain(patches: PatchWithSha[], base?: WarpState): ReduceOutput { +function reducePlain(patches: PatchWithSha[], base?: WarpState): MaterializeReduceOutput { return { state: reduceJoinedPatches(toReducerInput(patches), base) }; } @@ -117,7 +129,7 @@ function reduceMaterializePatches( patches: PatchWithSha[], base: WarpState | undefined, opts: { receipts: boolean; wantDiff: boolean }, -): ReduceOutput { +): MaterializeReduceOutput { if (opts.receipts) { return reduceWithReceipts(patches, base); } if (opts.wantDiff) { return reduceWithDiff(patches, base); } return reducePlain(patches, base); @@ -143,207 +155,50 @@ async function computeHash(deps: MaterializeDeps, state: WarpState): Promise { + async materialize( + opts: { receipts?: boolean; ceiling?: number | null; wantDiff?: boolean } = {}, + ): Promise { const ceiling = normalizeExplicitCeiling(opts.ceiling); if (ceiling !== null) { - return await this._materializeWithCeiling({ ceiling, receipts: opts.receipts === true }); + return await this._ceilingStrategy.materialize({ ceiling, receipts: opts.receipts === true }); } - return await this._materializeLive({ receipts: opts.receipts === true, wantDiff: opts.wantDiff === true }); + return await this._liveStrategy.materialize({ + receipts: opts.receipts === true, + wantDiff: opts.wantDiff === true, + }); } /** Coordinate materialization — explicit frontier. */ - async materializeCoordinate(opts: { frontier: Map | Record; ceiling?: number | null; receipts?: boolean }): Promise { + async materializeCoordinate( + opts: { + frontier: Map | Record; + ceiling?: number | null; + receipts?: boolean; + }, + ): Promise { const frontier = normalizeFrontierInput(opts.frontier); const ceiling = normalizeExplicitCeiling(opts.ceiling); - return await this._materializeCoordinate({ frontier, ceiling, receipts: opts.receipts === true }); + return await this._coordinateStrategy.materialize({ frontier, ceiling, receipts: opts.receipts === true }); } /** Checkpoint materialization — replay from a specific checkpoint SHA. */ async materializeAt(checkpointSha: string): Promise { - return await this._materializeAtCheckpoint(checkpointSha); - } - - // ── Live pipeline ─────────────────────────────────────────────── - - private async _materializeLive(opts: { receipts: boolean; wantDiff: boolean }): Promise { - const checkpoint = await this._deps.patches.loadCheckpoint(); - if (isCurrentCheckpointSchema(checkpoint?.schema)) { - return await this._fromCheckpoint(checkpoint!, opts); - } - return await this._fromScratch(opts); - } - - private async _fromCheckpoint(ck: CheckpointData, opts: { receipts: boolean; wantDiff: boolean }): Promise { - const patches = await this._deps.patches.loadPatchesSince(ck); - const reduced = await this._reducePatches(patches, ck.state, opts); - const provenance = buildProvenance(patches, ck.provenanceIndex as ProvenanceIndex | undefined); - return await this._buildResult({ reduced, patches, provenance, degraded: false, ceiling: null, frontier: null }); - } - - private async _fromScratch(opts: { receipts: boolean; wantDiff: boolean }): Promise { - const writers = await this._deps.patches.discoverWriters(); - if (writers.length === 0) { - return await this._emptyResult(); - } - const patches = await this._loadAllPatches(writers); - if (patches.length === 0) { - return await this._emptyResult(); - } - const reduced = await this._reducePatches(patches, undefined, opts); - return await this._buildResult({ reduced, patches, provenance: buildProvenance(patches), degraded: false, ceiling: null, frontier: null }); - } - - private async _loadAllPatches(writers: string[]): Promise { - const all: PatchWithSha[] = []; - for (const writerId of writers) { - const patches = await this._deps.patches.loadWriterPatches(writerId); - for (const p of patches) { all.push(p); } - } - return all; - } - - // ── Ceiling + coordinate pipeline ───────────────────────────────── - - private async _materializeWithCeiling(opts: { ceiling: number; receipts: boolean }): Promise { - const frontier = await this._deps.patches.getFrontier(); - return await this._materializeCoordinate({ frontier, ceiling: opts.ceiling, receipts: opts.receipts }); - } - - private async _materializeCoordinate(opts: { frontier: Map; ceiling: number | null; receipts: boolean }): Promise { - if (opts.frontier.size === 0 || (opts.ceiling !== null && opts.ceiling <= 0)) { - return await this._emptyResult(opts.ceiling, opts.frontier); - } - const coordinate = this._snapshotCoordinate(opts.frontier, opts.ceiling); - const cacheResolved = await this._tryResolveSnapshotCache({ - coordinate, - receipts: opts.receipts, - }); - if (cacheResolved !== null) { - return cacheResolved; - } - const patches = await this._deps.patches.collectForFrontier(opts.frontier, opts.ceiling); - if (patches.length === 0) { - return await this._emptyResult(opts.ceiling, opts.frontier); - } - const reduced = await this._reducePatches(patches, undefined, { - receipts: opts.receipts, - wantDiff: false, - }); - return await this._buildResult({ reduced, patches, provenance: buildProvenance(patches), degraded: false, ceiling: opts.ceiling, frontier: opts.frontier }); - } - - private _snapshotCoordinate(frontier: Map, ceiling: number | null): WarpStateCoordinate { - return { - frontier, - ceiling, - }; - } - - private async _tryResolveSnapshotCache(opts: { - coordinate: WarpStateCoordinate; - receipts: boolean; - }): Promise { - const stateCache = this._deps.getStateCache?.() ?? null; - if (stateCache === null) { - return null; - } - - const exact = await stateCache.getExact(opts.coordinate); - if (this._canUseSnapshot(exact, opts.receipts)) { - return await this._snapshotToResult(exact!); - } - - const predecessor = await stateCache.getBestCompatiblePredecessor(opts.coordinate); - if (!this._canUseSnapshot(predecessor, opts.receipts)) { - return null; - } - if (predecessor === null || predecessor.state === undefined) { - return null; - } - - const patches = await this._deps.patches.collectForFrontierSinceCoordinate( - opts.coordinate.frontier, - opts.coordinate.ceiling, - predecessor.coordinate, - ); - const reduced = await this._reducePatches(patches, predecessor.state, { - receipts: false, - wantDiff: false, - }); - return await this._buildResult({ - reduced, - patches, - provenance: buildProvenance(patches), - degraded: predecessor.provenancePosture === 'degraded', - ceiling: opts.coordinate.ceiling, - frontier: opts.coordinate.frontier, - }); - } - - private _canUseSnapshot( - snapshot: WarpStateSnapshotRecord | null, - receipts: boolean, - ): boolean { - if (snapshot === null || snapshot.state === undefined) { - return false; - } - if (receipts && snapshot.provenancePosture === 'degraded') { - return false; - } - return true; - } - - private async _snapshotToResult(snapshot: WarpStateSnapshotRecord): Promise { - return await this._buildResult({ - reduced: { state: snapshot.state! }, - patches: [], - provenance: new ProvenanceIndex(), - degraded: snapshot.provenancePosture === 'degraded', - ceiling: snapshot.coordinate.ceiling, - frontier: snapshot.coordinate.frontier, - }); - } - - // ── Checkpoint SHA pipeline ─────────────────────────────────────── - - private async _materializeAtCheckpoint(checkpointSha: string): Promise { - if (this._deps.openStateSession !== undefined) { - throw new SchemaUnsupportedError( - 'materializeAt() is not supported on the session-backed runtime line. Run the offline checkpoint migration first.', - ); - } - const frontier = await this._buildTargetFrontier(); - const patchLoader = async (_w: string, from: string | null, to: string) => - await this._deps.patches.loadPatchChain(to, from); - - const state = await materializeIncremental({ - persistence: this._loadPersistence(), - graphName: this._deps.graphName, - checkpointSha, - targetFrontier: frontier, - patchLoader, - codec: this._deps.codec, - }); - return await this._wrapState(state, null, null); - } - - private async _buildTargetFrontier(): Promise> { - const writers = await this._deps.patches.discoverWriters(); - const frontier = createFrontier(); - for (const writerId of writers) { - const ref = buildWriterRef(this._deps.graphName, writerId); - const tipSha = await this._deps.persistence.readRef(ref); - if (typeof tipSha === 'string' && tipSha.length > 0) { - updateFrontier(frontier, writerId, tipSha); - } - } - return frontier; + return await this._checkpointStrategy.materializeAt(checkpointSha); } private _assertLoadPersistence( @@ -360,11 +215,18 @@ export default class MaterializeController { // ── Result building ─────────────────────────────────────────────── - private async _emptyResult(ceiling?: number | null, frontier?: Map | null): Promise { + private async _emptyResult( + ceiling?: number | null, + frontier?: Map | null, + ): Promise { return await this._wrapState(createEmptyState(), ceiling ?? null, frontier ?? null); } - private async _wrapState(state: WarpState, ceiling: number | null, frontier: Map | null): Promise { + private async _wrapState( + state: WarpState, + ceiling: number | null, + frontier: Map | null, + ): Promise { const stateHash = await computeHash(this._deps, state); const adjacency = buildAdjacency(state); await this._publishSnapshot({ @@ -387,14 +249,7 @@ export default class MaterializeController { }; } - private async _buildResult(params: { - reduced: ReduceOutput; - patches: PatchWithSha[]; - provenance: ProvenanceIndex; - degraded: boolean; - ceiling: number | null; - frontier: Map | null; - }): Promise { + private async _buildResult(params: MaterializeResultBuildInput): Promise { const stateHash = await computeHash(this._deps, params.reduced.state); const adjacency = params.reduced.adjacency ?? buildAdjacency(params.reduced.state); await this._publishSnapshot({ @@ -410,9 +265,9 @@ export default class MaterializeController { adjacency: new AdjacencyMap({ outgoing: adjacency.outgoing, incoming: adjacency.incoming }), receipts: params.reduced.receipts, diff: params.reduced.diff, - patchCount: params.patches.length, - maxObservedLamport: maxLamportInPatches(params.patches), - provenanceIndex: params.provenance, + patchCount: params.summary.patchCount, + maxObservedLamport: params.summary.maxObservedLamport, + provenanceIndex: params.summary.provenance, provenanceDegraded: params.degraded, frontier: params.frontier, ceiling: params.ceiling, @@ -423,7 +278,7 @@ export default class MaterializeController { patches: PatchWithSha[], base: WarpState | undefined, opts: { receipts: boolean; wantDiff: boolean }, - ): Promise { + ): Promise { const {openStateSession} = this._deps; if (openStateSession === undefined) { return reduceMaterializePatches(patches, base, opts); @@ -438,6 +293,45 @@ export default class MaterializeController { return await reduceSessionBackedState(sessionArgs); } + private async _reducePatchStream( + stream: AsyncIterable, + base: WarpState | undefined, + opts: MaterializePatchStreamOptions, + provenanceBase?: ProvenanceIndex, + ): Promise { + if (this._deps.openStateSession === undefined) { + return await MaterializePatchStreamReducer.reduce({ + source: stream, + base, + options: opts, + ...(provenanceBase === undefined ? {} : { provenanceBase }), + }); + } + const patches: PatchWithSha[] = []; + for await (const entry of stream) { + patches.push(entry); + } + const reduced = await this._reducePatches(patches, base, opts); + return { + reduced, + summary: summarizeMaterializePatches(patches, provenanceBase), + }; + } + + private _createStrategyRuntime(): MaterializeStrategyRuntime { + return { + deps: this._deps, + emptyResult: async (ceiling, frontier) => await this._emptyResult(ceiling, frontier), + wrapState: async (state, ceiling, frontier) => await this._wrapState(state, ceiling, frontier), + reducePatches: async (patches, base, opts) => await this._reducePatches(patches, base, opts), + reducePatchStream: async (stream, base, opts, provenanceBase) => + await this._reducePatchStream(stream, base, opts, provenanceBase), + buildResult: async (params) => await this._buildResult(params), + buildProvenance: (patches, base) => buildProvenance(patches, base), + loadPersistence: () => this._loadPersistence(), + }; + } + private async _publishSnapshot(args: { state: WarpState; stateHash: string; diff --git a/src/domain/services/controllers/MaterializeCoordinateStrategy.ts b/src/domain/services/controllers/MaterializeCoordinateStrategy.ts new file mode 100644 index 000000000..7a8987309 --- /dev/null +++ b/src/domain/services/controllers/MaterializeCoordinateStrategy.ts @@ -0,0 +1,165 @@ +import { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; +import type { + MaterializeCoordinateOptions, + MaterializeStrategyRuntime, +} from './MaterializeStrategyRuntime.ts'; +import type { MaterializeResult } from './MaterializeController.ts'; +import type WarpStateCachePort from '../../../ports/WarpStateCachePort.ts'; +import type { + WarpStateCoordinate, + WarpStateSnapshotRecord, +} from '../../../ports/WarpStateCachePort.ts'; +import type WarpState from '../state/WarpState.ts'; +import { MaterializePatchSummary } from './MaterializePatchSummary.ts'; + +type UsableSnapshotRecord = WarpStateSnapshotRecord & { + state: WarpState; +}; + +export default class MaterializeCoordinateStrategy { + private readonly runtime: MaterializeStrategyRuntime; + + constructor(runtime: MaterializeStrategyRuntime) { + this.runtime = runtime; + } + + async materialize(opts: MaterializeCoordinateOptions): Promise { + if (this.canReturnEmpty(opts)) { + return await this.runtime.emptyResult(opts.ceiling, opts.frontier); + } + const coordinate = this.snapshotCoordinate(opts.frontier, opts.ceiling); + const cacheResolved = await this.tryResolveSnapshotCache({ + coordinate, + receipts: opts.receipts, + }); + if (cacheResolved !== null) { + return cacheResolved; + } + const reduction = await this.reduceFrontierPatches(opts); + if (reduction.summary.patchCount === 0) { + return await this.runtime.emptyResult(opts.ceiling, opts.frontier); + } + return await this.runtime.buildResult({ + reduced: reduction.reduced, + summary: reduction.summary, + degraded: false, + ceiling: opts.ceiling, + frontier: opts.frontier, + }); + } + + private async reduceFrontierPatches(opts: MaterializeCoordinateOptions) { + return await this.runtime.reducePatchStream( + this.runtime.deps.patches.streamForFrontier(opts.frontier, opts.ceiling), + undefined, + { + receipts: opts.receipts, + wantDiff: false, + }, + ); + } + + private canReturnEmpty(opts: MaterializeCoordinateOptions): boolean { + return opts.frontier.size === 0 || this.ceilingExcludesAll(opts.ceiling); + } + + private ceilingExcludesAll(ceiling: number | null): boolean { + return ceiling !== null && ceiling <= 0; + } + + private snapshotCoordinate( + frontier: Map, + ceiling: number | null, + ): WarpStateCoordinate { + return { + frontier, + ceiling, + }; + } + + private async tryResolveSnapshotCache(opts: { + coordinate: WarpStateCoordinate; + receipts: boolean; + }): Promise { + const stateCache = this.runtime.deps.getStateCache?.() ?? null; + if (stateCache === null) { + return null; + } + return await this.tryResolveCachedSnapshot(stateCache, opts); + } + + private async tryResolveCachedSnapshot( + stateCache: WarpStateCachePort, + opts: { coordinate: WarpStateCoordinate; receipts: boolean }, + ): Promise { + const exactResult = await this.tryResolveExactSnapshot(stateCache, opts); + if (exactResult !== null) { + return exactResult; + } + return await this.tryResolvePredecessorSnapshot(stateCache, opts); + } + + private async tryResolveExactSnapshot( + stateCache: WarpStateCachePort, + opts: { coordinate: WarpStateCoordinate; receipts: boolean }, + ): Promise { + const exact = await stateCache.getExact(opts.coordinate); + if (this.canUseSnapshot(exact, opts.receipts)) { + return await this.snapshotToResult(exact); + } + return null; + } + + private async tryResolvePredecessorSnapshot( + stateCache: WarpStateCachePort, + opts: { coordinate: WarpStateCoordinate; receipts: boolean }, + ): Promise { + const predecessor = await stateCache.getBestCompatiblePredecessor(opts.coordinate); + if (!this.canUseSnapshot(predecessor, opts.receipts)) { + return null; + } + + const reduction = await this.runtime.reducePatchStream( + this.runtime.deps.patches.streamForFrontierSinceCoordinate( + opts.coordinate.frontier, + opts.coordinate.ceiling, + predecessor.coordinate, + ), + predecessor.state, + { + receipts: false, + wantDiff: false, + }, + ); + return await this.runtime.buildResult({ + reduced: reduction.reduced, + summary: reduction.summary, + degraded: predecessor.provenancePosture === 'degraded', + ceiling: opts.coordinate.ceiling, + frontier: opts.coordinate.frontier, + }); + } + + private canUseSnapshot( + snapshot: WarpStateSnapshotRecord | null, + receipts: boolean, + ): snapshot is UsableSnapshotRecord { + if (snapshot === null || snapshot.state === undefined) { + return false; + } + if (receipts && snapshot.provenancePosture === 'degraded') { + return false; + } + return true; + } + + private async snapshotToResult(snapshot: UsableSnapshotRecord): Promise { + return await this.runtime.buildResult({ + reduced: { state: snapshot.state }, + summary: MaterializePatchSummary.empty(new ProvenanceIndex()), + degraded: snapshot.provenancePosture === 'degraded', + ceiling: snapshot.coordinate.ceiling, + frontier: snapshot.coordinate.frontier, + }); + } +} diff --git a/src/domain/services/controllers/MaterializeLiveStrategy.ts b/src/domain/services/controllers/MaterializeLiveStrategy.ts new file mode 100644 index 000000000..8212b2485 --- /dev/null +++ b/src/domain/services/controllers/MaterializeLiveStrategy.ts @@ -0,0 +1,83 @@ +import { isCurrentCheckpointSchema } from '../state/checkpointHelpers.ts'; +import type { + MaterializeLiveOptions, + MaterializeStrategyRuntime, +} from './MaterializeStrategyRuntime.ts'; +import type { MaterializeResult } from './MaterializeController.ts'; +import type { + CheckpointData, + PatchWithSha, +} from '../../capabilities/PatchCollector.ts'; + +export default class MaterializeLiveStrategy { + private readonly runtime: MaterializeStrategyRuntime; + + constructor(runtime: MaterializeStrategyRuntime) { + this.runtime = runtime; + } + + async materialize(opts: MaterializeLiveOptions): Promise { + const checkpoint = await this.runtime.deps.patches.loadCheckpoint(); + if (checkpoint !== null && checkpoint !== undefined && isCurrentCheckpointSchema(checkpoint.schema)) { + return await this.fromCheckpoint(checkpoint, opts); + } + return await this.fromScratch(opts); + } + + private async fromCheckpoint( + checkpoint: CheckpointData, + opts: MaterializeLiveOptions, + ): Promise { + const reduction = await this.runtime.reducePatchStream( + this.streamPatchesSince(checkpoint), + checkpoint.state, + opts, + checkpoint.provenanceIndex, + ); + return await this.runtime.buildResult({ + reduced: reduction.reduced, + summary: reduction.summary, + degraded: false, + ceiling: null, + frontier: null, + }); + } + + private async *streamPatchesSince(checkpoint: CheckpointData): AsyncIterable { + if (typeof this.runtime.deps.patches.streamPatchesSince === 'function') { + yield* this.runtime.deps.patches.streamPatchesSince(checkpoint); + return; + } + for (const entry of await this.runtime.deps.patches.loadPatchesSince(checkpoint)) { + yield entry; + } + } + + private async fromScratch(opts: MaterializeLiveOptions): Promise { + const writers = await this.runtime.deps.patches.discoverWriters(); + if (writers.length === 0) { + return await this.runtime.emptyResult(); + } + const reduction = await this.runtime.reducePatchStream( + this.streamAllPatches(writers), + undefined, + opts, + ); + if (reduction.summary.patchCount === 0) { + return await this.runtime.emptyResult(); + } + return await this.runtime.buildResult({ + reduced: reduction.reduced, + summary: reduction.summary, + degraded: false, + ceiling: null, + frontier: null, + }); + } + + private async *streamAllPatches(writers: string[]): AsyncIterable { + for (const writerId of writers) { + yield* this.runtime.deps.patches.streamWriterPatches(writerId); + } + } +} diff --git a/src/domain/services/controllers/MaterializePatchStreamReducer.ts b/src/domain/services/controllers/MaterializePatchStreamReducer.ts new file mode 100644 index 000000000..83df61435 --- /dev/null +++ b/src/domain/services/controllers/MaterializePatchStreamReducer.ts @@ -0,0 +1,93 @@ +import { + applyFast, + applyWithDiff, + applyWithReceipt, + cloneState, + createEmptyState, +} from '../JoinReducer.ts'; +import { PatchDiff, mergeDiffs } from '../../types/PatchDiff.ts'; +import { + MaterializePatchSummaryAccumulator, + type MaterializePatchSummary, +} from './MaterializePatchSummary.ts'; +import type WarpState from '../state/WarpState.ts'; +import type { PatchWithSha } from '../../capabilities/PatchCollector.ts'; +import type { TickReceipt } from '../../types/TickReceipt.ts'; +import type { MaterializeReduceOutput } from './MaterializeController.ts'; +import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; + +export type MaterializePatchStreamOptions = { + receipts: boolean; + wantDiff: boolean; +}; + +export type MaterializePatchStreamReduction = { + readonly reduced: MaterializeReduceOutput; + readonly summary: MaterializePatchSummary; +}; + +export type MaterializePatchStreamReduceInput = { + readonly source: AsyncIterable; + readonly base: WarpState | undefined; + readonly options: MaterializePatchStreamOptions; + readonly provenanceBase?: ProvenanceIndex; +}; + +function initialState(base: WarpState | undefined): WarpState { + return base === undefined ? createEmptyState() : cloneState(base); +} + +async function reduceWithReceipts( + source: AsyncIterable, + state: WarpState, + summary: MaterializePatchSummaryAccumulator, +): Promise { + const receipts: TickReceipt[] = []; + for await (const entry of source) { + summary.record(entry); + const result = applyWithReceipt(state, entry.patch, entry.sha); + receipts.push(result.receipt); + } + return { reduced: { state, receipts }, summary: summary.toSummary() }; +} + +async function reduceWithDiff( + source: AsyncIterable, + state: WarpState, + summary: MaterializePatchSummaryAccumulator, +): Promise { + let diff = PatchDiff.empty(); + for await (const entry of source) { + summary.record(entry); + const result = applyWithDiff(state, entry.patch, entry.sha); + diff = mergeDiffs(diff, result.diff); + } + return { reduced: { state, diff }, summary: summary.toSummary() }; +} + +async function reducePlain( + source: AsyncIterable, + state: WarpState, + summary: MaterializePatchSummaryAccumulator, +): Promise { + for await (const entry of source) { + summary.record(entry); + applyFast(state, entry.patch, entry.sha); + } + return { reduced: { state }, summary: summary.toSummary() }; +} + +export default class MaterializePatchStreamReducer { + static async reduce(input: MaterializePatchStreamReduceInput): Promise { + const state = initialState(input.base); + const summary = new MaterializePatchSummaryAccumulator(input.provenanceBase); + + if (input.options.receipts) { + return await reduceWithReceipts(input.source, state, summary); + } + if (input.options.wantDiff) { + return await reduceWithDiff(input.source, state, summary); + } + return await reducePlain(input.source, state, summary); + } +} diff --git a/src/domain/services/controllers/MaterializePatchSummary.ts b/src/domain/services/controllers/MaterializePatchSummary.ts new file mode 100644 index 000000000..ffbc7c940 --- /dev/null +++ b/src/domain/services/controllers/MaterializePatchSummary.ts @@ -0,0 +1,77 @@ +import WarpError from '../../errors/WarpError.ts'; +import { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; +import type { PatchWithSha } from '../../capabilities/PatchCollector.ts'; + +function requireNonNegativeInteger(value: number, field: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new WarpError( + `${field} must be a non-negative integer`, + 'E_MATERIALIZE_PATCH_SUMMARY', + { context: { field, value } }, + ); + } + return value; +} + +export class MaterializePatchSummary { + readonly patchCount: number; + readonly maxObservedLamport: number; + readonly provenance: ProvenanceIndex; + + constructor(fields: { + patchCount: number; + maxObservedLamport: number; + provenance: ProvenanceIndex; + }) { + this.patchCount = requireNonNegativeInteger(fields.patchCount, 'patchCount'); + this.maxObservedLamport = requireNonNegativeInteger(fields.maxObservedLamport, 'maxObservedLamport'); + this.provenance = fields.provenance; + Object.freeze(this); + } + + static empty(provenanceBase?: ProvenanceIndex): MaterializePatchSummary { + return new MaterializePatchSummary({ + patchCount: 0, + maxObservedLamport: 0, + provenance: provenanceBase ? provenanceBase.clone() : new ProvenanceIndex(), + }); + } + +} + +export class MaterializePatchSummaryAccumulator { + #patchCount = 0; + #maxObservedLamport = 0; + readonly #provenance: ProvenanceIndex; + + constructor(provenanceBase?: ProvenanceIndex) { + this.#provenance = provenanceBase ? provenanceBase.clone() : new ProvenanceIndex(); + } + + record(entry: PatchWithSha): void { + this.#patchCount += 1; + if (Number.isInteger(entry.patch.lamport)) { + this.#maxObservedLamport = Math.max(this.#maxObservedLamport, entry.patch.lamport); + } + this.#provenance.addPatch(entry.sha, entry.patch.reads, entry.patch.writes); + } + + toSummary(): MaterializePatchSummary { + return new MaterializePatchSummary({ + patchCount: this.#patchCount, + maxObservedLamport: this.#maxObservedLamport, + provenance: this.#provenance, + }); + } +} + +export function summarizeMaterializePatches( + patches: readonly PatchWithSha[], + provenanceBase?: ProvenanceIndex, +): MaterializePatchSummary { + const accumulator = new MaterializePatchSummaryAccumulator(provenanceBase); + for (const entry of patches) { + accumulator.record(entry); + } + return accumulator.toSummary(); +} diff --git a/src/domain/services/controllers/MaterializeStrategyRuntime.ts b/src/domain/services/controllers/MaterializeStrategyRuntime.ts new file mode 100644 index 000000000..f4c967cb7 --- /dev/null +++ b/src/domain/services/controllers/MaterializeStrategyRuntime.ts @@ -0,0 +1,59 @@ +import type { LoadPersistence } from '../state/checkpointLoad.ts'; +import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; +import type WarpState from '../state/WarpState.ts'; +import type { PatchWithSha } from '../../capabilities/PatchCollector.ts'; +import type { + MaterializePatchStreamOptions, + MaterializePatchStreamReduction, +} from './MaterializePatchStreamReducer.ts'; +import type { MaterializePatchSummary } from './MaterializePatchSummary.ts'; +import type { + MaterializeDeps, + MaterializePersistence, + MaterializeResult, + MaterializeReduceOutput, +} from './MaterializeController.ts'; + +export type MaterializeLiveOptions = { + receipts: boolean; + wantDiff: boolean; +}; + +export type MaterializeCeilingOptions = { + ceiling: number; + receipts: boolean; +}; + +export type MaterializeCoordinateOptions = { + frontier: Map; + ceiling: number | null; + receipts: boolean; +}; + +export type MaterializeResultBuildInput = { + reduced: MaterializeReduceOutput; + summary: MaterializePatchSummary; + degraded: boolean; + ceiling: number | null; + frontier: Map | null; +}; + +export type MaterializeStrategyRuntime = { + deps: MaterializeDeps; + emptyResult(ceiling?: number | null, frontier?: Map | null): Promise; + wrapState(state: WarpState, ceiling: number | null, frontier: Map | null): Promise; + reducePatches( + patches: PatchWithSha[], + base: WarpState | undefined, + opts: { receipts: boolean; wantDiff: boolean }, + ): Promise; + reducePatchStream( + stream: AsyncIterable, + base: WarpState | undefined, + opts: MaterializePatchStreamOptions, + provenanceBase?: ProvenanceIndex, + ): Promise; + buildResult(params: MaterializeResultBuildInput): Promise; + buildProvenance(patches: PatchWithSha[], base?: ProvenanceIndex): ProvenanceIndex; + loadPersistence(): MaterializePersistence & LoadPersistence; +}; diff --git a/src/domain/services/controllers/QueryController.ts b/src/domain/services/controllers/QueryController.ts index f0a4adcd7..457adb3f8 100644 --- a/src/domain/services/controllers/QueryController.ts +++ b/src/domain/services/controllers/QueryController.ts @@ -8,7 +8,7 @@ import { cloneState } from '../JoinReducer.ts'; import LiveQueryReadModelProvider from '../query/LiveQueryReadModelProvider.ts'; import Observer from '../query/Observer.ts'; -import Worldline from '../Worldline.ts'; +import ProjectionHandle from '../ProjectionHandle.ts'; import WorldlineOptic from '../optic/WorldlineOptic.ts'; import type CheckpointTailOpticSource from '../optic/CheckpointTailOpticSource.ts'; import { computeTranslationCost } from '../TranslationCost.ts'; @@ -22,6 +22,7 @@ import QueryError from '../../errors/QueryError.ts'; import type DetachedGraphFactory from '../../capabilities/DetachedGraphFactory.ts'; import type QueryCapability from '../../capabilities/QueryCapability.ts'; import type WarpState from '../state/WarpState.ts'; +import type { Aperture } from '../../types/Aperture.ts'; import type { QueryContentHost, QueryReadHost } from './ReadGraphHost.ts'; import type { QueryReadModel, @@ -208,7 +209,7 @@ async function resolveStrandSnapshot( // ── Observer argument normalization ───────────────────────────────── -type ObserverConfig = { match: string | string[]; expose?: string[]; redact?: string[] }; +type ObserverConfig = Aperture; type NormalizedObserverArgs = { name: string; @@ -385,7 +386,7 @@ wire('query', function (this: QueryController) { }).query(); }); wire('worldline', function (this: QueryController, options?: ObserverOptions) { - return new Worldline({ + return new ProjectionHandle({ graph: host(this), source: toSelector(options?.source) ?? new LiveSelector(), opticSource: worldlineOpticSource(host(this)), diff --git a/src/domain/services/controllers/StrandController.ts b/src/domain/services/controllers/StrandController.ts index 8c5fa87d6..d3350f11f 100644 --- a/src/domain/services/controllers/StrandController.ts +++ b/src/domain/services/controllers/StrandController.ts @@ -7,12 +7,11 @@ * @module domain/services/controllers/StrandController */ -import createStrandCoordinator from '../strand/createStrandCoordinator.ts'; +import createStrandCoordinator, { type StrandCoordinatorGraphRuntime } from '../strand/createStrandCoordinator.ts'; import { ConflictAnalyzerService } from '../strand/ConflictAnalyzerService.ts'; import type StrandCoordinator from '../strand/StrandCoordinator.ts'; import type { StrandDescriptor, StrandQueuedIntent, StrandTickRecord } from '../strand/strandTypes.ts'; import type { ConflictAnalyzeOptions } from '../strand/ConflictAnalysisRequest.ts'; -import type { AnalyzerService } from '../strand/ConflictFrameLoader.ts'; import type ConflictAnalysis from '../../types/conflict/ConflictAnalysis.ts'; import type { WarpState } from '../JoinReducer.ts'; import type SnapshotWarpState from '../snapshot/SnapshotWarpState.ts'; @@ -20,7 +19,9 @@ import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { PatchBuilder } from '../PatchBuilder.ts'; import type Patch from '../../types/Patch.ts'; -type StrandHost = AnalyzerService['_graph']; +export type StrandHost = StrandCoordinatorGraphRuntime & { + _loadWriterPatches(writerId: string): Promise>; +}; export default class StrandController { _host: StrandHost; @@ -63,6 +64,10 @@ export default class StrandController { return await this._strandService.materializeLiveState(strandId, options); } + async _materializeStrandRead(strandId: string, options?: { receipts?: boolean; ceiling?: number | null }): Promise<{ state: WarpState; receipts: readonly TickReceipt[] }> { + return await this._strandService.materializeReadState(strandId, options); + } + async getStrandPatches(strandId: string, options?: { ceiling?: number | null }): Promise> { return await this._strandService.getPatchEntries(strandId, options) as Array<{ patch: Patch; sha: string }>; } diff --git a/src/domain/services/controllers/SyncController.ts b/src/domain/services/controllers/SyncController.ts index 0a8d5583e..78c0142e3 100644 --- a/src/domain/services/controllers/SyncController.ts +++ b/src/domain/services/controllers/SyncController.ts @@ -13,7 +13,6 @@ import { type SyncRequest, type SyncResponse, } from '../sync/SyncProtocol.ts'; -import { retry, RetryExhaustedError, TimeoutError, type RetryOptions } from '@git-stunts/alfred'; import { checkAborted } from '../../utils/cancellation.ts'; import { createFrontier, updateFrontier } from '../Frontier.ts'; import { buildWriterRef } from '../../utils/RefLayout.ts'; @@ -28,6 +27,9 @@ import type { SyncHttpAuth, SyncHttpClientResult, } from '../../../ports/SyncHttpClientPort.ts'; +import OperationPolicyExhaustedError from '../../errors/OperationPolicyExhaustedError.ts'; +import type OperationPolicyPort from '../../../ports/OperationPolicyPort.ts'; +import type { OperationPolicyExecuteOptions } from '../../../ports/OperationPolicyPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type SyncSecret from '../sync/SyncSecret.ts'; import { @@ -35,6 +37,7 @@ import { resolveSyncTarget, resolveSyncTrustGate, } from './syncHelpers.ts'; +import { SHARED_SECRET_HMAC_SYNC_AUTH_SCHEME } from '../sync/SyncAuthService.ts'; import type { SyncHost, SkippedWriter, @@ -83,6 +86,7 @@ function resolveAuth( ): SyncHttpAuth | undefined { if (auth === undefined || auth.secret === undefined) { return undefined; } return { + scheme: SHARED_SECRET_HMAC_SYNC_AUTH_SCHEME, secret: auth.secret, ...(auth.keyId !== undefined && auth.keyId !== '' ? { keyId: auth.keyId } : {}), lamport, @@ -134,11 +138,17 @@ export default class SyncController { readonly _host: SyncHost; readonly _trustGate: SyncTrustGate | null; private _httpClient: SyncHttpClientPort | null; + private _operationPolicy: OperationPolicyPort | null; - constructor(host: SyncHost, options: { trustGate?: SyncTrustGate; httpClient?: SyncHttpClientPort } = {}) { + constructor(host: SyncHost, options: { + trustGate?: SyncTrustGate; + httpClient?: SyncHttpClientPort; + operationPolicy?: OperationPolicyPort; + } = {}) { this._host = host; this._trustGate = options.trustGate ?? null; this._httpClient = options.httpClient ?? null; + this._operationPolicy = options.operationPolicy ?? null; } /** @@ -154,6 +164,14 @@ export default class SyncController { return adapter; } + private async _resolveOperationPolicy(): Promise { + if (this._operationPolicy !== null) { return this._operationPolicy; } + const mod = await import('../../../infrastructure/adapters/AlfredOperationPolicyAdapter.ts'); + const adapter = new mod.default(); + this._operationPolicy = adapter; + return adapter; + } + /** Returns the current frontier — a Map of writerId → tip SHA. */ async getFrontier(): Promise> { const writerIds = await this._host.discoverWriters(); @@ -332,7 +350,7 @@ export default class SyncController { if (err instanceof SyncError) { return ['E_SYNC_REMOTE', 'E_SYNC_TIMEOUT', 'E_SYNC_NETWORK'].includes(err.code); } - return err instanceof TimeoutError; + return false; }; const executeAttempt = async (): Promise<{ @@ -376,15 +394,17 @@ export default class SyncController { }; try { - const syncResult = await retry(executeAttempt, { + const operationPolicy = await this._resolveOperationPolicy(); + const syncResult = await operationPolicy.execute(executeAttempt, { retries, delay: baseDelayMs, maxDelay: maxDelayMs, - backoff: 'exponential', jitter: 'decorrelated', signal, shouldRetry, + backoff: 'exponential', jitter: 'decorrelated', shouldRetry, + ...(signal !== undefined ? { signal } : {}), onRetry: (error: Error, attemptNumber: number, delayMs: number) => { if (typeof onStatus === 'function') { onStatus({ type: 'retrying', attempt: attemptNumber, delayMs, error }); } }, - } as RetryOptions); + } satisfies OperationPolicyExecuteOptions); if (materializeAfterSync) { const state = this._host._cachedState; if (state === null) { @@ -401,7 +421,7 @@ export default class SyncController { if (typeof onStatus === 'function') { onStatus({ type: 'failed', attempt, error: abortedError }); } throw abortedError; } - if (err instanceof RetryExhaustedError) { + if (err instanceof OperationPolicyExhaustedError) { const { cause } = err; if (typeof onStatus === 'function') { onStatus({ type: 'failed', attempt: err.attempts, error: cause }); } throw cause; diff --git a/src/domain/services/coordinate/SubstrateCoordinateBoundary.ts b/src/domain/services/coordinate/SubstrateCoordinateBoundary.ts new file mode 100644 index 000000000..97299a1eb --- /dev/null +++ b/src/domain/services/coordinate/SubstrateCoordinateBoundary.ts @@ -0,0 +1,67 @@ +/** + * SubstrateCoordinateBoundary — stable lane, coordinate, and capability nouns. + * + * @module domain/services/coordinate/SubstrateCoordinateBoundary + */ + +export type SubstrateLaneKind = 'worldline' | 'strand' | 'braid'; +export type SubstrateCoordinateKind = 'live' | 'frontier' | 'checkpoint' | 'strand-base'; +export type SubstrateCapabilityAuthority = 'substrate' | 'session-policy'; + +export const SUBSTRATE_LANE_KINDS: readonly SubstrateLaneKind[] = Object.freeze([ + 'worldline', + 'strand', + 'braid', +]); + +export const SUBSTRATE_COORDINATE_KINDS: readonly SubstrateCoordinateKind[] = Object.freeze([ + 'live', + 'frontier', + 'checkpoint', + 'strand-base', +]); + +export const SUBSTRATE_CAPABILITY_NAMES: readonly string[] = Object.freeze([ + 'worldline.commit', + 'worldline.live', + 'worldline.seek', + 'worldline.observer', + 'worldline.optic', + 'strand.create', + 'strand.braid', + 'strand.patch', + 'strand.intent', + 'coordinate.compare', + 'coordinate.transfer-plan', + 'sync.exchange', +]); + +export const SESSION_POLICY_CAPABILITY_NAMES: readonly string[] = Object.freeze([ + 'debugger.cursor', + 'debugger.layout', + 'debugger.selection', + 'debugger.theme', + 'session.history', + 'session.shortcut', +]); + +function includesName(names: readonly string[], name: string): boolean { + return names.includes(name); +} + +export default class SubstrateCoordinateBoundary { + readonly laneKinds = SUBSTRATE_LANE_KINDS; + readonly coordinateKinds = SUBSTRATE_COORDINATE_KINDS; + readonly substrateCapabilities = SUBSTRATE_CAPABILITY_NAMES; + readonly sessionPolicyCapabilities = SESSION_POLICY_CAPABILITY_NAMES; + + authorityFor(capabilityName: string): SubstrateCapabilityAuthority | null { + if (includesName(SUBSTRATE_CAPABILITY_NAMES, capabilityName)) { + return 'substrate'; + } + if (includesName(SESSION_POLICY_CAPABILITY_NAMES, capabilityName)) { + return 'session-policy'; + } + return null; + } +} diff --git a/src/domain/services/merge/MergeClassification.ts b/src/domain/services/merge/MergeClassification.ts new file mode 100644 index 000000000..de7d82d45 --- /dev/null +++ b/src/domain/services/merge/MergeClassification.ts @@ -0,0 +1,51 @@ +/** + * MergeClassification — result label emitted by MergeClassifier. + * + * @module domain/services/merge/MergeClassification + */ + +import WarpError from '../../errors/WarpError.ts'; +import type { MergeClassificationConfidence, MergeClassificationKind } from './MergeClassificationKind.ts'; + +export type MergeClassificationFields = { + readonly kind: MergeClassificationKind; + readonly confidence: MergeClassificationConfidence; + readonly reasonCodes: readonly string[]; +}; + +const KINDS: readonly MergeClassificationKind[] = Object.freeze(['projection', 'semantic', 'governance']); +const CONFIDENCES: readonly MergeClassificationConfidence[] = Object.freeze(['high', 'medium']); + +function validateKind(kind: MergeClassificationKind): MergeClassificationKind { + if (!KINDS.includes(kind)) { + throw new WarpError('merge classification kind is invalid', 'E_MERGE_CLASSIFIER_INVALID_KIND'); + } + return kind; +} + +function validateConfidence(confidence: MergeClassificationConfidence): MergeClassificationConfidence { + if (!CONFIDENCES.includes(confidence)) { + throw new WarpError('merge classification confidence is invalid', 'E_MERGE_CLASSIFIER_INVALID_CONFIDENCE'); + } + return confidence; +} + +function validateReasonCodes(reasonCodes: readonly string[]): readonly string[] { + if (reasonCodes.length === 0 || reasonCodes.some((code) => code.length === 0)) { + throw new WarpError('merge classification requires non-empty reason codes', 'E_MERGE_CLASSIFIER_INVALID_REASON'); + } + return Object.freeze([...reasonCodes]); +} + +export default class MergeClassification { + readonly kind: MergeClassificationKind; + readonly confidence: MergeClassificationConfidence; + readonly reasonCodes: readonly string[]; + + constructor(fields: MergeClassificationFields) { + this.kind = validateKind(fields.kind); + this.confidence = validateConfidence(fields.confidence); + this.reasonCodes = validateReasonCodes(fields.reasonCodes); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/MergeClassificationEvidence.ts b/src/domain/services/merge/MergeClassificationEvidence.ts new file mode 100644 index 000000000..53bca440a --- /dev/null +++ b/src/domain/services/merge/MergeClassificationEvidence.ts @@ -0,0 +1,42 @@ +/** + * MergeClassificationEvidence — narrow evidence for first-pass merge labels. + * + * @module domain/services/merge/MergeClassificationEvidence + */ + +import WarpError from '../../errors/WarpError.ts'; + +export type MergeClassificationEvidenceFields = { + readonly sharedPrecursor: boolean; + readonly branchFootprintsOverlap: boolean; + readonly candidateJoin: boolean; + readonly obstructionWitness: boolean; + readonly loweringWitness: boolean; + readonly policyRequirement: boolean; +}; + +function requireBoolean(name: string, value: boolean): boolean { + if (typeof value !== 'boolean') { + throw new WarpError(`${name} must be a boolean`, 'E_MERGE_CLASSIFIER_INVALID_EVIDENCE'); + } + return value; +} + +export default class MergeClassificationEvidence { + readonly sharedPrecursor: boolean; + readonly branchFootprintsOverlap: boolean; + readonly candidateJoin: boolean; + readonly obstructionWitness: boolean; + readonly loweringWitness: boolean; + readonly policyRequirement: boolean; + + constructor(fields: MergeClassificationEvidenceFields) { + this.sharedPrecursor = requireBoolean('sharedPrecursor', fields.sharedPrecursor); + this.branchFootprintsOverlap = requireBoolean('branchFootprintsOverlap', fields.branchFootprintsOverlap); + this.candidateJoin = requireBoolean('candidateJoin', fields.candidateJoin); + this.obstructionWitness = requireBoolean('obstructionWitness', fields.obstructionWitness); + this.loweringWitness = requireBoolean('loweringWitness', fields.loweringWitness); + this.policyRequirement = requireBoolean('policyRequirement', fields.policyRequirement); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/MergeClassificationKind.ts b/src/domain/services/merge/MergeClassificationKind.ts new file mode 100644 index 000000000..2c2af1cf1 --- /dev/null +++ b/src/domain/services/merge/MergeClassificationKind.ts @@ -0,0 +1,3 @@ +export type MergeClassificationKind = 'projection' | 'semantic' | 'governance'; + +export type MergeClassificationConfidence = 'high' | 'medium'; diff --git a/src/domain/services/merge/MergeClassifier.ts b/src/domain/services/merge/MergeClassifier.ts new file mode 100644 index 000000000..5b7891b13 --- /dev/null +++ b/src/domain/services/merge/MergeClassifier.ts @@ -0,0 +1,43 @@ +/** + * MergeClassifier — first-pass labels for merge obstruction geometry. + * + * @module domain/services/merge/MergeClassifier + */ + +import MergeClassification from './MergeClassification.ts'; +import type MergeClassificationEvidence from './MergeClassificationEvidence.ts'; + +function precursorReason(evidence: MergeClassificationEvidence): string { + return evidence.sharedPrecursor ? 'shared-precursor' : 'missing-shared-precursor'; +} + +function footprintReason(evidence: MergeClassificationEvidence): string { + return evidence.branchFootprintsOverlap ? 'overlapping-footprints' : 'disjoint-footprints'; +} + +function baseReasons(evidence: MergeClassificationEvidence): string[] { + return [precursorReason(evidence), footprintReason(evidence)]; +} + +function classifyWithoutPolicy(evidence: MergeClassificationEvidence, reasons: string[]): MergeClassification { + if (evidence.candidateJoin && evidence.loweringWitness) { + return new MergeClassification({ kind: 'projection', confidence: 'high', reasonCodes: [...reasons, 'candidate-join', 'lowering-witness'] }); + } + if (evidence.candidateJoin) { + return new MergeClassification({ kind: 'projection', confidence: 'medium', reasonCodes: [...reasons, 'candidate-join'] }); + } + if (evidence.obstructionWitness) { + return new MergeClassification({ kind: 'semantic', confidence: 'high', reasonCodes: [...reasons, 'obstruction-witness'] }); + } + return new MergeClassification({ kind: 'semantic', confidence: 'medium', reasonCodes: [...reasons, 'no-candidate-join'] }); +} + +export default class MergeClassifier { + classify(evidence: MergeClassificationEvidence): MergeClassification { + const reasons = baseReasons(evidence); + if (evidence.policyRequirement) { + return new MergeClassification({ kind: 'governance', confidence: 'high', reasonCodes: [...reasons, 'policy-requirement'] }); + } + return classifyWithoutPolicy(evidence, reasons); + } +} diff --git a/src/domain/services/merge/TtdMergeBranch.ts b/src/domain/services/merge/TtdMergeBranch.ts new file mode 100644 index 000000000..1fd2663c5 --- /dev/null +++ b/src/domain/services/merge/TtdMergeBranch.ts @@ -0,0 +1,26 @@ +/** + * TtdMergeBranch — branch strand identity for merge inspection. + * + * @module domain/services/merge/TtdMergeBranch + */ + +import { requireNonEmptyText, requireStringRecord } from './TtdMergeValidation.ts'; + +export type TtdMergeBranchFields = { + readonly branchId: string; + readonly strandId: string; + readonly fields: Record; +}; + +export default class TtdMergeBranch { + readonly branchId: string; + readonly strandId: string; + readonly fields: Readonly>; + + constructor(fields: TtdMergeBranchFields) { + this.branchId = requireNonEmptyText(fields.branchId, 'branchId'); + this.strandId = requireNonEmptyText(fields.strandId, 'strandId'); + this.fields = requireStringRecord(fields.fields, 'branch.fields'); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/TtdMergeFootprint.ts b/src/domain/services/merge/TtdMergeFootprint.ts new file mode 100644 index 000000000..f1dd5a089 --- /dev/null +++ b/src/domain/services/merge/TtdMergeFootprint.ts @@ -0,0 +1,23 @@ +/** + * TtdMergeFootprint — changed object keys for one branch strand. + * + * @module domain/services/merge/TtdMergeFootprint + */ + +import { freezeSortedTexts, requireNonEmptyText } from './TtdMergeValidation.ts'; + +export type TtdMergeFootprintFields = { + readonly branchId: string; + readonly changedKeys: readonly string[]; +}; + +export default class TtdMergeFootprint { + readonly branchId: string; + readonly changedKeys: readonly string[]; + + constructor(fields: TtdMergeFootprintFields) { + this.branchId = requireNonEmptyText(fields.branchId, 'branchId'); + this.changedKeys = freezeSortedTexts(fields.changedKeys, 'changedKeys'); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/TtdMergeInspection.ts b/src/domain/services/merge/TtdMergeInspection.ts new file mode 100644 index 000000000..685e60926 --- /dev/null +++ b/src/domain/services/merge/TtdMergeInspection.ts @@ -0,0 +1,116 @@ +/** + * TtdMergeInspection — read-only protocol object for TTD merge panels. + * + * @module domain/services/merge/TtdMergeInspection + */ + +import WarpError from '../../errors/WarpError.ts'; +import MergeClassification from './MergeClassification.ts'; +import TtdMergeBranch from './TtdMergeBranch.ts'; +import TtdMergeFootprint from './TtdMergeFootprint.ts'; +import { + TTD_MERGE_INSPECTION_DOMAINS, + type TtdMergeInspectionDomain, +} from './TtdMergeInspectionDomain.ts'; +import TtdMergeLoweringWitness from './TtdMergeLoweringWitness.ts'; +import TtdMergeObstructionWitness from './TtdMergeObstructionWitness.ts'; +import TtdMergePolicyRequirement from './TtdMergePolicyRequirement.ts'; +import { + freezeSortedRecord, + freezeSortedTexts, + requireStringRecord, +} from './TtdMergeValidation.ts'; + +export const TTD_MERGE_INSPECTION_PROTOCOL_VERSION = 'ttd-merge-inspection/v1'; + +export type TtdMergeInspectionFields = { + readonly domain: TtdMergeInspectionDomain; + readonly sharedPrecursor: Record; + readonly branches: readonly TtdMergeBranch[]; + readonly footprints: readonly TtdMergeFootprint[]; + readonly overlapKeys: readonly string[]; + readonly candidateCanonicalJoin: Record | null; + readonly obstructionWitnesses: readonly TtdMergeObstructionWitness[]; + readonly loweringWitnesses: readonly TtdMergeLoweringWitness[]; + readonly policyRequirements: readonly TtdMergePolicyRequirement[]; + readonly classification: MergeClassification; +}; + +function requireDomain(domain: TtdMergeInspectionDomain): TtdMergeInspectionDomain { + if (!TTD_MERGE_INSPECTION_DOMAINS.includes(domain)) { + throw new WarpError('merge inspection domain is invalid', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return domain; +} + +function requireBranch(item: TtdMergeBranch): TtdMergeBranch { + if (!(item instanceof TtdMergeBranch)) { + throw new WarpError('merge inspection branches require TtdMergeBranch instances', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return item; +} + +function requireFootprint(item: TtdMergeFootprint): TtdMergeFootprint { + if (!(item instanceof TtdMergeFootprint)) { + throw new WarpError('merge inspection footprints require TtdMergeFootprint instances', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return item; +} + +function requireObstruction(item: TtdMergeObstructionWitness): TtdMergeObstructionWitness { + if (!(item instanceof TtdMergeObstructionWitness)) { + throw new WarpError('merge inspection obstructions require witness instances', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return item; +} + +function requireLowering(item: TtdMergeLoweringWitness): TtdMergeLoweringWitness { + if (!(item instanceof TtdMergeLoweringWitness)) { + throw new WarpError('merge inspection lowerings require witness instances', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return item; +} + +function requirePolicy(item: TtdMergePolicyRequirement): TtdMergePolicyRequirement { + if (!(item instanceof TtdMergePolicyRequirement)) { + throw new WarpError('merge inspection policies require policy instances', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return item; +} + +function requireClassification(classification: MergeClassification): MergeClassification { + if (!(classification instanceof MergeClassification)) { + throw new WarpError('merge inspection classification is invalid', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return classification; +} + +export default class TtdMergeInspection { + readonly protocolVersion = TTD_MERGE_INSPECTION_PROTOCOL_VERSION; + readonly domain: TtdMergeInspectionDomain; + readonly sharedPrecursor: Readonly>; + readonly branches: readonly TtdMergeBranch[]; + readonly footprints: readonly TtdMergeFootprint[]; + readonly overlapKeys: readonly string[]; + readonly candidateCanonicalJoin: Readonly> | null; + readonly obstructionWitnesses: readonly TtdMergeObstructionWitness[]; + readonly loweringWitnesses: readonly TtdMergeLoweringWitness[]; + readonly policyRequirements: readonly TtdMergePolicyRequirement[]; + readonly classification: MergeClassification; + + constructor(fields: TtdMergeInspectionFields) { + this.domain = requireDomain(fields.domain); + this.sharedPrecursor = requireStringRecord(fields.sharedPrecursor, 'sharedPrecursor'); + this.branches = Object.freeze(fields.branches.map(requireBranch)); + this.footprints = Object.freeze(fields.footprints.map(requireFootprint)); + this.overlapKeys = freezeSortedTexts(fields.overlapKeys, 'overlapKeys'); + this.candidateCanonicalJoin = fields.candidateCanonicalJoin === null + ? null + : freezeSortedRecord(fields.candidateCanonicalJoin); + this.obstructionWitnesses = Object.freeze(fields.obstructionWitnesses.map(requireObstruction)); + this.loweringWitnesses = Object.freeze(fields.loweringWitnesses.map(requireLowering)); + this.policyRequirements = Object.freeze(fields.policyRequirements.map(requirePolicy)); + this.classification = requireClassification(fields.classification); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/TtdMergeInspectionDomain.ts b/src/domain/services/merge/TtdMergeInspectionDomain.ts new file mode 100644 index 000000000..7789beda4 --- /dev/null +++ b/src/domain/services/merge/TtdMergeInspectionDomain.ts @@ -0,0 +1,5 @@ +export type TtdMergeInspectionDomain = 'json-object'; + +export const TTD_MERGE_INSPECTION_DOMAINS: readonly TtdMergeInspectionDomain[] = Object.freeze([ + 'json-object', +]); diff --git a/src/domain/services/merge/TtdMergeInspector.ts b/src/domain/services/merge/TtdMergeInspector.ts new file mode 100644 index 000000000..311e7b7f1 --- /dev/null +++ b/src/domain/services/merge/TtdMergeInspector.ts @@ -0,0 +1,229 @@ +/** + * TtdMergeInspector — deterministic merge protocol builder for TTD. + * + * @module domain/services/merge/TtdMergeInspector + */ + +import MergeClassificationEvidence from './MergeClassificationEvidence.ts'; +import MergeClassifier from './MergeClassifier.ts'; +import TtdMergeBranch from './TtdMergeBranch.ts'; +import TtdMergeFootprint from './TtdMergeFootprint.ts'; +import TtdMergeInspection from './TtdMergeInspection.ts'; +import TtdMergeLoweringWitness from './TtdMergeLoweringWitness.ts'; +import TtdMergeObstructionWitness from './TtdMergeObstructionWitness.ts'; +import { + freezeSortedRecord, + freezeSortedTexts, + requireStringRecord, +} from './TtdMergeValidation.ts'; +import type TtdMergePolicyRequirement from './TtdMergePolicyRequirement.ts'; + +export type TtdMergeObjectBranchInput = { + readonly branchId: string; + readonly strandId: string; + readonly fields: Record; +}; + +export type TtdMergeObjectInspectionInput = { + readonly precursor: Record; + readonly left: TtdMergeObjectBranchInput; + readonly right: TtdMergeObjectBranchInput; + readonly policyRequirements?: readonly TtdMergePolicyRequirement[]; +}; + +type ObstructionBuildInput = { + readonly precursor: Readonly>; + readonly left: TtdMergeBranch; + readonly right: TtdMergeBranch; + readonly overlapKeys: readonly string[]; +}; + +type CandidateJoinInput = { + readonly precursor: Readonly>; + readonly left: TtdMergeBranch; + readonly right: TtdMergeBranch; + readonly leftChangedKeys: readonly string[]; + readonly rightChangedKeys: readonly string[]; + readonly obstructionCount: number; +}; + +type ObjectMergeComputation = { + readonly precursor: Readonly>; + readonly left: TtdMergeBranch; + readonly right: TtdMergeBranch; + readonly leftChangedKeys: readonly string[]; + readonly rightChangedKeys: readonly string[]; + readonly overlapKeys: readonly string[]; + readonly obstructions: readonly TtdMergeObstructionWitness[]; + readonly candidate: Readonly> | null; + readonly lowerings: readonly TtdMergeLoweringWitness[]; + readonly policies: readonly TtdMergePolicyRequirement[]; +}; + +function collectKeys(records: readonly Readonly>[]): readonly string[] { + const keys: string[] = []; + for (const record of records) { + keys.push(...Object.keys(record)); + } + return freezeSortedTexts(keys, 'objectKeys'); +} + +function valueAt(fields: Readonly>, key: string): string | null { + return fields[key] ?? null; +} + +function changedKeys(precursor: Readonly>, branch: TtdMergeBranch): readonly string[] { + const keys = collectKeys([precursor, branch.fields]); + return keys.filter((key) => valueAt(precursor, key) !== valueAt(branch.fields, key)); +} + +function overlap(leftKeys: readonly string[], rightKeys: readonly string[]): readonly string[] { + const rightSet = new Set(rightKeys); + return freezeSortedTexts(leftKeys.filter((key) => rightSet.has(key)), 'overlapKeys'); +} + +function buildObstructions(input: ObstructionBuildInput): readonly TtdMergeObstructionWitness[] { + const witnesses: TtdMergeObstructionWitness[] = []; + for (const key of input.overlapKeys) { + const leftValue = valueAt(input.left.fields, key); + const rightValue = valueAt(input.right.fields, key); + if (leftValue !== rightValue) { + witnesses.push(new TtdMergeObstructionWitness({ + fieldKey: key, + precursorValue: valueAt(input.precursor, key), + leftValue, + rightValue, + })); + } + } + return Object.freeze(witnesses); +} + +function applyValue(candidate: Record, key: string, value: string | null): void { + if (value === null) { + delete candidate[key]; + return; + } + candidate[key] = value; +} + +function applyChanges( + candidate: Record, + branch: TtdMergeBranch, + keys: readonly string[], +): void { + for (const key of keys) { + applyValue(candidate, key, valueAt(branch.fields, key)); + } +} + +function buildCandidateJoin(input: CandidateJoinInput): Readonly> | null { + if (input.obstructionCount > 0) { + return null; + } + + const candidate: Record = { ...input.precursor }; + applyChanges(candidate, input.left, input.leftChangedKeys); + applyChanges(candidate, input.right, input.rightChangedKeys); + return freezeSortedRecord(candidate); +} + +function buildLowerings( + candidate: Readonly> | null, + obstructionWitnesses: readonly TtdMergeObstructionWitness[], +): readonly TtdMergeLoweringWitness[] { + if (candidate !== null) { + const keyOrder = collectKeys([candidate]); + return Object.freeze([ + new TtdMergeLoweringWitness({ + surface: 'canonical-json-object', + basisKeyCount: keyOrder.length, + conflictKeyCount: 0, + keyOrder, + }), + ]); + } + + const conflictKeys = obstructionWitnesses.map((witness) => witness.fieldKey); + return Object.freeze([ + new TtdMergeLoweringWitness({ + surface: 'obstruction-list', + basisKeyCount: conflictKeys.length, + conflictKeyCount: conflictKeys.length, + keyOrder: conflictKeys, + }), + ]); +} + +function buildObjectMergeComputation(input: TtdMergeObjectInspectionInput): ObjectMergeComputation { + const precursor = requireStringRecord(input.precursor, 'precursor'); + const left = new TtdMergeBranch(input.left); + const right = new TtdMergeBranch(input.right); + const leftChangedKeys = changedKeys(precursor, left); + const rightChangedKeys = changedKeys(precursor, right); + const overlapKeys = overlap(leftChangedKeys, rightChangedKeys); + const obstructions = buildObstructions({ precursor, left, right, overlapKeys }); + const candidate = buildCandidateJoin({ + precursor, + left, + right, + leftChangedKeys, + rightChangedKeys, + obstructionCount: obstructions.length, + }); + const lowerings = buildLowerings(candidate, obstructions); + return { + precursor, + left, + right, + leftChangedKeys, + rightChangedKeys, + overlapKeys, + obstructions, + candidate, + lowerings, + policies: Object.freeze([...(input.policyRequirements ?? [])]), + }; +} + +function classifyObjectMerge(computation: ObjectMergeComputation, classifier: MergeClassifier) { + return classifier.classify(new MergeClassificationEvidence({ + sharedPrecursor: true, + branchFootprintsOverlap: computation.overlapKeys.length > 0, + candidateJoin: computation.candidate !== null, + obstructionWitness: computation.obstructions.length > 0, + loweringWitness: computation.lowerings.length > 0, + policyRequirement: computation.policies.length > 0, + })); +} + +function buildInspection(computation: ObjectMergeComputation, classifier: MergeClassifier): TtdMergeInspection { + return new TtdMergeInspection({ + domain: 'json-object', + sharedPrecursor: computation.precursor, + branches: [computation.left, computation.right], + footprints: [ + new TtdMergeFootprint({ branchId: computation.left.branchId, changedKeys: computation.leftChangedKeys }), + new TtdMergeFootprint({ branchId: computation.right.branchId, changedKeys: computation.rightChangedKeys }), + ], + overlapKeys: computation.overlapKeys, + candidateCanonicalJoin: computation.candidate, + obstructionWitnesses: computation.obstructions, + loweringWitnesses: computation.lowerings, + policyRequirements: computation.policies, + classification: classifyObjectMerge(computation, classifier), + }); +} + +export default class TtdMergeInspector { + readonly classifier: MergeClassifier; + + constructor(classifier: MergeClassifier = new MergeClassifier()) { + this.classifier = classifier; + Object.freeze(this); + } + + inspectJsonObject(input: TtdMergeObjectInspectionInput): TtdMergeInspection { + return buildInspection(buildObjectMergeComputation(input), this.classifier); + } +} diff --git a/src/domain/services/merge/TtdMergeLoweringSurface.ts b/src/domain/services/merge/TtdMergeLoweringSurface.ts new file mode 100644 index 000000000..4d4abc2b5 --- /dev/null +++ b/src/domain/services/merge/TtdMergeLoweringSurface.ts @@ -0,0 +1,6 @@ +export type TtdMergeLoweringSurface = 'canonical-json-object' | 'obstruction-list'; + +export const TTD_MERGE_LOWERING_SURFACES: readonly TtdMergeLoweringSurface[] = Object.freeze([ + 'canonical-json-object', + 'obstruction-list', +]); diff --git a/src/domain/services/merge/TtdMergeLoweringWitness.ts b/src/domain/services/merge/TtdMergeLoweringWitness.ts new file mode 100644 index 000000000..d3bc94fe6 --- /dev/null +++ b/src/domain/services/merge/TtdMergeLoweringWitness.ts @@ -0,0 +1,44 @@ +/** + * TtdMergeLoweringWitness — TTD-renderable merge surface summary. + * + * @module domain/services/merge/TtdMergeLoweringWitness + */ + +import WarpError from '../../errors/WarpError.ts'; +import { + TTD_MERGE_LOWERING_SURFACES, + type TtdMergeLoweringSurface, +} from './TtdMergeLoweringSurface.ts'; +import { + freezeSortedTexts, + requireNonNegativeInteger, +} from './TtdMergeValidation.ts'; + +export type TtdMergeLoweringWitnessFields = { + readonly surface: TtdMergeLoweringSurface; + readonly basisKeyCount: number; + readonly conflictKeyCount: number; + readonly keyOrder: readonly string[]; +}; + +function requireSurface(surface: TtdMergeLoweringSurface): TtdMergeLoweringSurface { + if (!TTD_MERGE_LOWERING_SURFACES.includes(surface)) { + throw new WarpError('merge lowering surface is invalid', 'E_TTD_MERGE_INSPECTION_INVALID'); + } + return surface; +} + +export default class TtdMergeLoweringWitness { + readonly surface: TtdMergeLoweringSurface; + readonly basisKeyCount: number; + readonly conflictKeyCount: number; + readonly keyOrder: readonly string[]; + + constructor(fields: TtdMergeLoweringWitnessFields) { + this.surface = requireSurface(fields.surface); + this.basisKeyCount = requireNonNegativeInteger(fields.basisKeyCount, 'basisKeyCount'); + this.conflictKeyCount = requireNonNegativeInteger(fields.conflictKeyCount, 'conflictKeyCount'); + this.keyOrder = freezeSortedTexts(fields.keyOrder, 'keyOrder'); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/TtdMergeObstructionWitness.ts b/src/domain/services/merge/TtdMergeObstructionWitness.ts new file mode 100644 index 000000000..25d24797d --- /dev/null +++ b/src/domain/services/merge/TtdMergeObstructionWitness.ts @@ -0,0 +1,36 @@ +/** + * TtdMergeObstructionWitness — deterministic object-key collision evidence. + * + * @module domain/services/merge/TtdMergeObstructionWitness + */ + +import { requireNonEmptyText } from './TtdMergeValidation.ts'; + +export type TtdMergeObstructionWitnessFields = { + readonly fieldKey: string; + readonly precursorValue: string | null; + readonly leftValue: string | null; + readonly rightValue: string | null; +}; + +function requireOptionalText(value: string | null, fieldName: string): string | null { + if (value === null) { + return null; + } + return requireNonEmptyText(value, fieldName); +} + +export default class TtdMergeObstructionWitness { + readonly fieldKey: string; + readonly precursorValue: string | null; + readonly leftValue: string | null; + readonly rightValue: string | null; + + constructor(fields: TtdMergeObstructionWitnessFields) { + this.fieldKey = requireNonEmptyText(fields.fieldKey, 'fieldKey'); + this.precursorValue = requireOptionalText(fields.precursorValue, 'precursorValue'); + this.leftValue = requireOptionalText(fields.leftValue, 'leftValue'); + this.rightValue = requireOptionalText(fields.rightValue, 'rightValue'); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/TtdMergePolicyRequirement.ts b/src/domain/services/merge/TtdMergePolicyRequirement.ts new file mode 100644 index 000000000..2ed133ddf --- /dev/null +++ b/src/domain/services/merge/TtdMergePolicyRequirement.ts @@ -0,0 +1,23 @@ +/** + * TtdMergePolicyRequirement — governance gate carried by merge inspection. + * + * @module domain/services/merge/TtdMergePolicyRequirement + */ + +import { requireNonEmptyText } from './TtdMergeValidation.ts'; + +export type TtdMergePolicyRequirementFields = { + readonly code: string; + readonly message: string; +}; + +export default class TtdMergePolicyRequirement { + readonly code: string; + readonly message: string; + + constructor(fields: TtdMergePolicyRequirementFields) { + this.code = requireNonEmptyText(fields.code, 'policy.code'); + this.message = requireNonEmptyText(fields.message, 'policy.message'); + Object.freeze(this); + } +} diff --git a/src/domain/services/merge/TtdMergeValidation.ts b/src/domain/services/merge/TtdMergeValidation.ts new file mode 100644 index 000000000..f097751c5 --- /dev/null +++ b/src/domain/services/merge/TtdMergeValidation.ts @@ -0,0 +1,66 @@ +import WarpError from '../../errors/WarpError.ts'; + +const ERROR_CODE = 'E_TTD_MERGE_INSPECTION_INVALID'; + +export function requireNonEmptyText(value: string, fieldName: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new WarpError(`${fieldName} must be a non-empty string`, ERROR_CODE); + } + return value; +} + +function requireRecordContainer(fields: Record, fieldName: string): void { + if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) { + throw new WarpError(`${fieldName} must be an object`, ERROR_CODE); + } +} + +function requireRecordEntry(key: string, value: string, fieldName: string): void { + if (key.length === 0) { + throw new WarpError(`${fieldName} cannot contain an empty key`, ERROR_CODE); + } + if (typeof value !== 'string') { + throw new WarpError(`${fieldName}.${key} must be a string`, ERROR_CODE); + } +} + +function copyStringRecord(fields: Record, fieldName: string): Record { + const copy: Record = {}; + for (const [key, value] of Object.entries(fields)) { + requireRecordEntry(key, value, fieldName); + copy[key] = value; + } + return copy; +} + +export function requireStringRecord(fields: Record, fieldName: string): Readonly> { + requireRecordContainer(fields, fieldName); + const copy = copyStringRecord(fields, fieldName); + return freezeSortedRecord(copy); +} + +export function freezeSortedRecord(fields: Record): Readonly> { + const sorted: Record = {}; + for (const key of Object.keys(fields).sort()) { + const value = fields[key]; + if (value === undefined) { + throw new WarpError(`${key} must have a string value`, ERROR_CODE); + } + sorted[key] = value; + } + return Object.freeze(sorted); +} + +export function freezeSortedTexts(values: readonly string[], fieldName: string): readonly string[] { + for (const value of values) { + requireNonEmptyText(value, fieldName); + } + return Object.freeze([...new Set(values)].sort()); +} + +export function requireNonNegativeInteger(value: number, fieldName: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new WarpError(`${fieldName} must be a non-negative integer`, ERROR_CODE); + } + return value; +} diff --git a/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts b/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts index a1c6eb16b..bc5be7781 100644 --- a/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts +++ b/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts @@ -422,7 +422,6 @@ function manifestRootFamily(family: CheckpointBasisFactShardFamily): CheckpointB function isManifestRootFamily(family: CheckpointBasisFactShardFamily): family is CheckpointBasisRootFamily { return MANIFEST_ROOT_FAMILIES.includes(family as CheckpointBasisRootFamily); } - function appliedVersionVectorFromFrontier(frontier: Map): Map { const versionVector = new Map(); for (const writerId of [...frontier.keys()].sort()) { diff --git a/src/domain/services/query/BoundedSupportRule.ts b/src/domain/services/query/BoundedSupportRule.ts new file mode 100644 index 000000000..28e7bc406 --- /dev/null +++ b/src/domain/services/query/BoundedSupportRule.ts @@ -0,0 +1,272 @@ +import QueryError from '../../errors/QueryError.ts'; +import QueryPlan, { type QueryOperation, type TraversalOperation } from './QueryPlan.ts'; + +export type BoundedSupportSurface = 'query' | 'optic' | 'diff'; +export type BoundedSupportKind = 'entity' | 'neighborhood' | 'global-discovery' | 'interval-diff'; +export type BoundedSupportDirection = 'outgoing' | 'incoming'; + +export type BoundedSupportRuleFields = { + readonly surface: BoundedSupportSurface; + readonly kind: BoundedSupportKind; + readonly reason: string; + readonly rootNodeIds?: readonly string[]; + readonly maxDepth?: number; + readonly directions?: readonly BoundedSupportDirection[]; +}; + +const SUPPORT_SURFACES: readonly BoundedSupportSurface[] = Object.freeze(['query', 'optic', 'diff']); +const SUPPORT_KINDS: readonly BoundedSupportKind[] = Object.freeze([ + 'entity', + 'neighborhood', + 'global-discovery', + 'interval-diff', +]); +const SUPPORT_DIRECTIONS: readonly BoundedSupportDirection[] = Object.freeze(['outgoing', 'incoming']); + +/** Runtime-backed support law for a public read surface. */ +export default class BoundedSupportRule { + readonly surface: BoundedSupportSurface; + readonly kind: BoundedSupportKind; + readonly reason: string; + readonly rootNodeIds: readonly string[]; + readonly maxDepth: number | undefined; + readonly directions: readonly BoundedSupportDirection[]; + + constructor(fields: BoundedSupportRuleFields) { + const checkedFields = requireFields(fields); + this.surface = requireSurface(checkedFields.surface); + this.kind = requireKind(checkedFields.kind); + this.reason = requireNonEmptyString(checkedFields.reason, 'reason'); + this.rootNodeIds = freezeStringList(checkedFields.rootNodeIds ?? [], 'rootNodeIds'); + this.maxDepth = optionalNonNegativeInteger(checkedFields.maxDepth, 'maxDepth'); + this.directions = freezeDirections(checkedFields.directions ?? []); + Object.freeze(this); + } + + static entityRead(fields: { + readonly surface: BoundedSupportSurface; + readonly nodeIds: readonly string[]; + }): BoundedSupportRule { + return new BoundedSupportRule({ + surface: fields.surface, + kind: 'entity', + reason: 'exact node-id support', + rootNodeIds: fields.nodeIds, + }); + } + + static neighborhoodRead(fields: { + readonly surface: BoundedSupportSurface; + readonly rootNodeIds: readonly string[]; + readonly maxDepth: number; + readonly directions: readonly BoundedSupportDirection[]; + }): BoundedSupportRule { + return new BoundedSupportRule({ + surface: fields.surface, + kind: 'neighborhood', + reason: 'exact node-id traversal support', + rootNodeIds: fields.rootNodeIds, + maxDepth: fields.maxDepth, + directions: fields.directions, + }); + } + + static globalDiscovery(fields: { + readonly surface: BoundedSupportSurface; + readonly reason: string; + }): BoundedSupportRule { + return new BoundedSupportRule({ + surface: fields.surface, + kind: 'global-discovery', + reason: fields.reason, + }); + } + + static fromQueryPlan(plan: QueryPlan): BoundedSupportRule { + requireQueryPlan(plan); + const exactNodeIds = exactNodeIdsForPattern(plan.pattern); + if (exactNodeIds === null) { + return BoundedSupportRule.globalDiscovery({ + surface: 'query', + reason: 'query pattern requires discovery over the visible graph', + }); + } + + const traversals = traversalOperations(plan.operations); + if (traversals.length === 0) { + return BoundedSupportRule.entityRead({ + surface: 'query', + nodeIds: exactNodeIds, + }); + } + + return BoundedSupportRule.neighborhoodRead({ + surface: 'query', + rootNodeIds: exactNodeIds, + maxDepth: maxTraversalDepth(traversals), + directions: traversalDirections(traversals), + }); + } + + isBounded(): boolean { + return this.kind !== 'global-discovery'; + } + + requiresWholeGraphDiscovery(): boolean { + return this.kind === 'global-discovery'; + } +} + +function requireFields(fields: BoundedSupportRuleFields | null | undefined): BoundedSupportRuleFields { + if (fields === null || fields === undefined) { + throw new QueryError('BoundedSupportRule fields must be provided', { + code: 'E_QUERY_SUPPORT_RULE', + }); + } + return fields; +} + +function requireSurface(surface: BoundedSupportSurface): BoundedSupportSurface { + if (!SUPPORT_SURFACES.includes(surface)) { + throw new QueryError('BoundedSupportRule surface is unsupported', { + code: 'E_QUERY_SUPPORT_RULE', + context: { surface }, + }); + } + return surface; +} + +function requireKind(kind: BoundedSupportKind): BoundedSupportKind { + if (!SUPPORT_KINDS.includes(kind)) { + throw new QueryError('BoundedSupportRule kind is unsupported', { + code: 'E_QUERY_SUPPORT_RULE', + context: { kind }, + }); + } + return kind; +} + +function requireNonEmptyString(value: string, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new QueryError(`${field} must be a non-empty string`, { + code: 'E_QUERY_SUPPORT_RULE', + context: { field }, + }); + } + return value.trim(); +} + +function optionalNonNegativeInteger(value: number | undefined, field: string): number | undefined { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value) || value < 0) { + throw new QueryError(`${field} must be a non-negative integer`, { + code: 'E_QUERY_SUPPORT_RULE', + context: { field, value }, + }); + } + return value; +} + +function freezeStringList(values: readonly string[], field: string): readonly string[] { + if (!Array.isArray(values)) { + throw new QueryError(`${field} must be an array`, { + code: 'E_QUERY_SUPPORT_RULE', + context: { field }, + }); + } + const normalized: string[] = []; + for (const value of values) { + normalized.push(requireNonEmptyString(value, field)); + } + return Object.freeze([...new Set(normalized)].sort()); +} + +function freezeDirections(values: readonly BoundedSupportDirection[]): readonly BoundedSupportDirection[] { + if (!Array.isArray(values)) { + throw new QueryError('directions must be an array', { + code: 'E_QUERY_SUPPORT_RULE', + }); + } + const normalized: BoundedSupportDirection[] = []; + for (const value of values) { + if (!SUPPORT_DIRECTIONS.includes(value)) { + throw new QueryError('directions contains unsupported direction', { + code: 'E_QUERY_SUPPORT_RULE', + context: { direction: value }, + }); + } + normalized.push(value); + } + return Object.freeze([...new Set(normalized)].sort()); +} + +function requireQueryPlan(plan: QueryPlan): QueryPlan { + if (!(plan instanceof QueryPlan)) { + throw new QueryError('fromQueryPlan requires a QueryPlan', { + code: 'E_QUERY_SUPPORT_RULE', + }); + } + return plan; +} + +function exactNodeIdsForPattern(pattern: string | readonly string[]): readonly string[] | null { + if (typeof pattern === 'string') { + const nodeId = exactPatternNodeId(pattern); + if (nodeId === null) { + return null; + } + return Object.freeze([nodeId]); + } + + const exactIds: string[] = []; + for (const entry of pattern) { + const nodeId = exactPatternNodeId(entry); + if (nodeId === null) { + return null; + } + exactIds.push(nodeId); + } + return Object.freeze([...new Set(exactIds)].sort()); +} + +function exactPatternNodeId(pattern: string): string | null { + if (pattern.includes('*')) { + return null; + } + return requireNonEmptyString(pattern, 'pattern'); +} + +function traversalOperations(operations: readonly QueryOperation[]): readonly TraversalOperation[] { + const traversals: TraversalOperation[] = []; + for (const operation of operations) { + if (isTraversalOperation(operation)) { + traversals.push(operation); + } + } + return Object.freeze(traversals); +} + +function isTraversalOperation(operation: QueryOperation): operation is TraversalOperation { + return operation.type === 'outgoing' || operation.type === 'incoming'; +} + +function maxTraversalDepth(operations: readonly TraversalOperation[]): number { + let maxDepth = 0; + for (const operation of operations) { + const candidate = operation.depth[1]; + if (candidate > maxDepth) { + maxDepth = candidate; + } + } + return maxDepth; +} + +function traversalDirections(operations: readonly TraversalOperation[]): readonly BoundedSupportDirection[] { + const directions: BoundedSupportDirection[] = []; + for (const operation of operations) { + directions.push(operation.type); + } + return Object.freeze([...new Set(directions)].sort()); +} diff --git a/src/domain/services/query/CausalIndexPlan.ts b/src/domain/services/query/CausalIndexPlan.ts new file mode 100644 index 000000000..c707ff472 --- /dev/null +++ b/src/domain/services/query/CausalIndexPlan.ts @@ -0,0 +1,132 @@ +import QueryError from '../../errors/QueryError.ts'; +import BoundedSupportRule from './BoundedSupportRule.ts'; +import { freezeStringList, requireNonEmptyString } from './queryValidation.ts'; + +export type CausalIndexFamily = 'entity-patch' | 'neighborhood-adjacency' | 'global-discovery'; +export type CausalIndexPlanPosture = 'available' | 'composite' | 'unsupported'; + +export type CausalIndexPlanFields = { + readonly supportRule: BoundedSupportRule; + readonly posture: CausalIndexPlanPosture; + readonly families: readonly CausalIndexFamily[]; + readonly reason: string; + readonly requiredEntityIds?: readonly string[]; +}; + +const INDEX_FAMILIES: readonly CausalIndexFamily[] = Object.freeze([ + 'entity-patch', + 'neighborhood-adjacency', + 'global-discovery', +]); +const INDEX_POSTURES: readonly CausalIndexPlanPosture[] = Object.freeze([ + 'available', + 'composite', + 'unsupported', +]); +const CAUSAL_INDEX_PLAN_ERROR = 'E_QUERY_CAUSAL_INDEX_PLAN'; + +/** Index-selection posture for a bounded public read support rule. */ +export default class CausalIndexPlan { + readonly supportRule: BoundedSupportRule; + readonly posture: CausalIndexPlanPosture; + readonly families: readonly CausalIndexFamily[]; + readonly reason: string; + readonly requiredEntityIds: readonly string[]; + + constructor(fields: CausalIndexPlanFields) { + const checkedFields = requireFields(fields); + this.supportRule = requireSupportRule(checkedFields.supportRule); + this.posture = requirePosture(checkedFields.posture); + this.families = freezeFamilies(checkedFields.families); + this.reason = requireNonEmptyString(checkedFields.reason, 'reason', CAUSAL_INDEX_PLAN_ERROR); + this.requiredEntityIds = freezeStringList( + checkedFields.requiredEntityIds ?? [], + 'requiredEntityIds', + CAUSAL_INDEX_PLAN_ERROR, + ); + Object.freeze(this); + } + + static fromSupportRule(rule: BoundedSupportRule): CausalIndexPlan { + const supportRule = requireSupportRule(rule); + if (supportRule.kind === 'entity') { + return new CausalIndexPlan({ + supportRule, + posture: 'available', + families: ['entity-patch'], + reason: 'entity support can be served by the provenance entity-to-patch index', + requiredEntityIds: supportRule.rootNodeIds, + }); + } + if (supportRule.kind === 'neighborhood') { + return new CausalIndexPlan({ + supportRule, + posture: 'composite', + families: ['entity-patch', 'neighborhood-adjacency'], + reason: 'neighborhood support needs entity patch discovery plus adjacency expansion', + requiredEntityIds: supportRule.rootNodeIds, + }); + } + return new CausalIndexPlan({ + supportRule, + posture: 'unsupported', + families: ['global-discovery'], + reason: 'global discovery has no bounded causal index family', + }); + } + + canUseCausalIndex(): boolean { + return this.posture !== 'unsupported'; + } + + requiresGlobalScan(): boolean { + return this.posture === 'unsupported'; + } +} + +function requireFields(fields: CausalIndexPlanFields | null | undefined): CausalIndexPlanFields { + if (fields === null || fields === undefined) { + throw new QueryError('CausalIndexPlan fields must be provided', { + code: CAUSAL_INDEX_PLAN_ERROR, + }); + } + return fields; +} + +function requireSupportRule(value: BoundedSupportRule): BoundedSupportRule { + if (!(value instanceof BoundedSupportRule)) { + throw new QueryError('CausalIndexPlan requires a BoundedSupportRule', { + code: CAUSAL_INDEX_PLAN_ERROR, + }); + } + return value; +} + +function requirePosture(value: CausalIndexPlanPosture): CausalIndexPlanPosture { + if (!INDEX_POSTURES.includes(value)) { + throw new QueryError('CausalIndexPlan posture is unsupported', { + code: CAUSAL_INDEX_PLAN_ERROR, + context: { posture: value }, + }); + } + return value; +} + +function freezeFamilies(values: readonly CausalIndexFamily[]): readonly CausalIndexFamily[] { + if (!Array.isArray(values)) { + throw new QueryError('families must be an array', { + code: CAUSAL_INDEX_PLAN_ERROR, + }); + } + const normalized: CausalIndexFamily[] = []; + for (const value of values) { + if (!INDEX_FAMILIES.includes(value)) { + throw new QueryError('families contains unsupported index family', { + code: CAUSAL_INDEX_PLAN_ERROR, + context: { family: value }, + }); + } + normalized.push(value); + } + return Object.freeze([...new Set(normalized)].sort()); +} diff --git a/src/domain/services/query/GraphTraversal.ts b/src/domain/services/query/GraphTraversal.ts index 2d69b520b..6c0ffbacc 100644 --- a/src/domain/services/query/GraphTraversal.ts +++ b/src/domain/services/query/GraphTraversal.ts @@ -31,6 +31,7 @@ import type { Direction, NeighborOptions } from '../../../ports/NeighborProvider import type LoggerPort from '../../../ports/LoggerPort.ts'; import { checkAborted } from '../../utils/cancellation.ts'; import TraversalContext, { + type RunStats, type TraversalStats, type TraversalHooks, DEFAULT_MAX_NODES, @@ -40,6 +41,54 @@ import GraphPathFinding from './GraphPathFinding.ts'; import GraphTopology from './GraphTopology.ts'; import GraphAnalysis from './GraphAnalysis.ts'; +export type GraphTraversalStreamParams = { + readonly start: string; + readonly direction?: Direction | undefined; + readonly options?: NeighborOptions | undefined; + readonly maxNodes?: number | undefined; + readonly maxDepth?: number | undefined; + readonly signal?: AbortSignal | undefined; + readonly hooks?: TraversalHooks | undefined; +}; + +type PrimitiveTraversalRun = { + readonly start: string; + readonly direction: Direction; + readonly options: NeighborOptions | undefined; + readonly maxNodes: number; + readonly maxDepth: number; + readonly signal: AbortSignal | undefined; + readonly hooks: TraversalHooks | undefined; + readonly rs: RunStats; + readonly visited: Set; +}; + +function createPrimitiveRun( + params: GraphTraversalStreamParams, + rs: RunStats, + visited: Set, +): PrimitiveTraversalRun { + return { + start: params.start, + direction: params.direction ?? 'out', + options: params.options, + maxNodes: params.maxNodes ?? DEFAULT_MAX_NODES, + maxDepth: params.maxDepth ?? DEFAULT_MAX_DEPTH, + signal: params.signal, + hooks: params.hooks, + rs, + visited, + }; +} + +async function collectTraversal(stream: AsyncIterable): Promise { + const nodes: string[] = []; + for await (const node of stream) { + nodes.push(node); + } + return nodes; +} + export default class GraphTraversal { private readonly _ctx: TraversalContext; private readonly _pathFinding: GraphPathFinding; @@ -61,45 +110,45 @@ export default class GraphTraversal { // ── Primitive traversals (kept in facade) ────────────────────────── - async bfs(params: { - start: string; - direction?: Direction | undefined; - options?: NeighborOptions | undefined; - maxNodes?: number | undefined; - maxDepth?: number | undefined; - signal?: AbortSignal | undefined; - hooks?: TraversalHooks | undefined; - }): Promise<{ nodes: string[]; stats: TraversalStats }> { - const { start, direction = 'out', options, signal, hooks } = params; - const maxNodes = params.maxNodes ?? DEFAULT_MAX_NODES; - const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; + async bfs(params: GraphTraversalStreamParams): Promise<{ nodes: string[]; stats: TraversalStats }> { const rs = this._ctx.newRunStats(); - await this._ctx.validateStart(start); const visited = new Set(); - let currentLevel: Array<{ nodeId: string; depth: number }> = [{ nodeId: start, depth: 0 }]; - const result: string[] = []; + const run = createPrimitiveRun(params, rs, visited); + const nodes = await collectTraversal(this._bfsStream(run)); + return { nodes, stats: this._ctx.stats(visited.size, rs) }; + } + + async *bfsStream(params: GraphTraversalStreamParams): AsyncGenerator { + const rs = this._ctx.newRunStats(); + const visited = new Set(); + yield* this._bfsStream(createPrimitiveRun(params, rs, visited)); + } - while (currentLevel.length > 0 && visited.size < maxNodes) { + private async *_bfsStream(run: PrimitiveTraversalRun): AsyncGenerator { + await this._ctx.validateStart(run.start); + let currentLevel: Array<{ nodeId: string; depth: number }> = [{ nodeId: run.start, depth: 0 }]; + + while (currentLevel.length > 0 && run.visited.size < run.maxNodes) { currentLevel.sort((a, b) => (a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0)); const nextLevel: Array<{ nodeId: string; depth: number }> = []; const queued = new Set(); for (const { nodeId, depth } of currentLevel) { - if (visited.size >= maxNodes) { break; } - if (visited.has(nodeId)) { continue; } - if (depth > maxDepth) { continue; } - if (visited.size % 1000 === 0) { checkAborted(signal, 'bfs'); } - - visited.add(nodeId); - result.push(nodeId); - if (hooks?.onVisit) { hooks.onVisit(nodeId, depth); } - - if (depth < maxDepth) { - const neighbors = await this._ctx.getNeighbors(nodeId, direction, rs, options); - rs.edgesTraversed += neighbors.length; - if (hooks?.onExpand) { hooks.onExpand(nodeId, neighbors); } + if (run.visited.size >= run.maxNodes) { break; } + if (run.visited.has(nodeId)) { continue; } + if (depth > run.maxDepth) { continue; } + if (run.visited.size % 1000 === 0) { checkAborted(run.signal, 'bfs'); } + + run.visited.add(nodeId); + if (run.hooks?.onVisit) { run.hooks.onVisit(nodeId, depth); } + yield nodeId; + + if (depth < run.maxDepth) { + const neighbors = await this._ctx.getNeighbors(nodeId, run.direction, run.rs, run.options); + run.rs.edgesTraversed += neighbors.length; + if (run.hooks?.onExpand) { run.hooks.onExpand(nodeId, neighbors); } for (const { neighborId } of neighbors) { - if (!visited.has(neighborId) && !queued.has(neighborId)) { + if (!run.visited.has(neighborId) && !queued.has(neighborId)) { queued.add(neighborId); nextLevel.push({ nodeId: neighborId, depth: depth + 1 }); } @@ -108,52 +157,49 @@ export default class GraphTraversal { } currentLevel = nextLevel; } + } - return { nodes: result, stats: this._ctx.stats(visited.size, rs) }; - } - - async dfs(params: { - start: string; - direction?: Direction; - options?: NeighborOptions; - maxNodes?: number; - maxDepth?: number; - signal?: AbortSignal; - hooks?: TraversalHooks; - }): Promise<{ nodes: string[]; stats: TraversalStats }> { - const { start, direction = 'out', options, signal, hooks } = params; - const maxNodes = params.maxNodes ?? DEFAULT_MAX_NODES; - const maxDepth = params.maxDepth ?? DEFAULT_MAX_DEPTH; + async dfs(params: GraphTraversalStreamParams): Promise<{ nodes: string[]; stats: TraversalStats }> { const rs = this._ctx.newRunStats(); - await this._ctx.validateStart(start); const visited = new Set(); - const stack: Array<{ nodeId: string; depth: number }> = [{ nodeId: start, depth: 0 }]; - const result: string[] = []; - - while (stack.length > 0 && visited.size < maxNodes) { - const entry = stack.pop()!; - if (visited.has(entry.nodeId)) { continue; } - if (entry.depth > maxDepth) { continue; } - if (visited.size % 1000 === 0) { checkAborted(signal, 'dfs'); } - - visited.add(entry.nodeId); - result.push(entry.nodeId); - if (hooks?.onVisit) { hooks.onVisit(entry.nodeId, entry.depth); } - - if (entry.depth < maxDepth) { - const neighbors = await this._ctx.getNeighbors(entry.nodeId, direction, rs, options); - rs.edgesTraversed += neighbors.length; - if (hooks?.onExpand) { hooks.onExpand(entry.nodeId, neighbors); } + const run = createPrimitiveRun(params, rs, visited); + const nodes = await collectTraversal(this._dfsStream(run)); + return { nodes, stats: this._ctx.stats(visited.size, rs) }; + } + + async *dfsStream(params: GraphTraversalStreamParams): AsyncGenerator { + const rs = this._ctx.newRunStats(); + const visited = new Set(); + yield* this._dfsStream(createPrimitiveRun(params, rs, visited)); + } + + private async *_dfsStream(run: PrimitiveTraversalRun): AsyncGenerator { + await this._ctx.validateStart(run.start); + const stack: Array<{ nodeId: string; depth: number }> = [{ nodeId: run.start, depth: 0 }]; + + while (stack.length > 0 && run.visited.size < run.maxNodes) { + const entry = stack.pop(); + if (entry === undefined) { continue; } + if (run.visited.has(entry.nodeId)) { continue; } + if (entry.depth > run.maxDepth) { continue; } + if (run.visited.size % 1000 === 0) { checkAborted(run.signal, 'dfs'); } + + run.visited.add(entry.nodeId); + if (run.hooks?.onVisit) { run.hooks.onVisit(entry.nodeId, entry.depth); } + yield entry.nodeId; + + if (entry.depth < run.maxDepth) { + const neighbors = await this._ctx.getNeighbors(entry.nodeId, run.direction, run.rs, run.options); + run.rs.edgesTraversed += neighbors.length; + if (run.hooks?.onExpand) { run.hooks.onExpand(entry.nodeId, neighbors); } for (let i = neighbors.length - 1; i >= 0; i -= 1) { const nb = neighbors[i]; - if (nb !== undefined && !visited.has(nb.neighborId)) { + if (nb !== undefined && !run.visited.has(nb.neighborId)) { stack.push({ nodeId: nb.neighborId, depth: entry.depth + 1 }); } } } } - - return { nodes: result, stats: this._ctx.stats(visited.size, rs) }; } // ── Delegates to GraphPathFinding ────────────────────────────────── diff --git a/src/domain/services/query/LogicalTraversal.ts b/src/domain/services/query/LogicalTraversal.ts index e571a9862..16e3b9690 100644 --- a/src/domain/services/query/LogicalTraversal.ts +++ b/src/domain/services/query/LogicalTraversal.ts @@ -78,20 +78,36 @@ export default class LogicalTraversal { async bfs(start: string, options: TraversalOptions = {}): Promise { const { engine, direction, options: opts, depthLimit } = await this._prepare(start, options); const { nodes } = await engine.bfs(stripUndefined({ - start, direction, options: opts, maxDepth: depthLimit, maxNodes: Infinity, + start, direction, options: opts, maxDepth: depthLimit, maxNodes: Infinity, signal: options.signal, })); return nodes; } + /** Breadth-first traversal stream. */ + async *bfsStream(start: string, options: TraversalOptions = {}): AsyncGenerator { + const { engine, direction, options: opts, depthLimit } = await this._prepare(start, options); + yield* engine.bfsStream(stripUndefined({ + start, direction, options: opts, maxDepth: depthLimit, maxNodes: Infinity, signal: options.signal, + })); + } + /** Depth-first traversal (pre-order). */ async dfs(start: string, options: TraversalOptions = {}): Promise { const { engine, direction, options: opts, depthLimit } = await this._prepare(start, options); const { nodes } = await engine.dfs(stripUndefined({ - start, direction, options: opts, maxDepth: depthLimit, maxNodes: Infinity, + start, direction, options: opts, maxDepth: depthLimit, maxNodes: Infinity, signal: options.signal, })); return nodes; } + /** Depth-first traversal stream. */ + async *dfsStream(start: string, options: TraversalOptions = {}): AsyncGenerator { + const { engine, direction, options: opts, depthLimit } = await this._prepare(start, options); + yield* engine.dfsStream(stripUndefined({ + start, direction, options: opts, maxDepth: depthLimit, maxNodes: Infinity, signal: options.signal, + })); + } + /** Shortest path (unweighted BFS). */ async shortestPath( from: string, to: string, options: TraversalOptions = {}, diff --git a/src/domain/services/query/Observer.ts b/src/domain/services/query/Observer.ts index aa7abbe6f..a90481595 100644 --- a/src/domain/services/query/Observer.ts +++ b/src/domain/services/query/Observer.ts @@ -13,6 +13,10 @@ import QueryBuilder from './QueryBuilder.ts'; import StateQueryReadModel from './StateQueryReadModel.ts'; import VisibleQueryReadModel from './VisibleQueryReadModel.ts'; import LogicalTraversal from './LogicalTraversal.ts'; +import ObserverAccumulation from './ObserverAccumulation.ts'; +import ObserverBasis from './ObserverBasis.ts'; +import ObserverPlan from './ObserverPlan.ts'; +import ObserverReadingEnvelope, { type ObserverReadingEnvelopeFields } from './ObserverReadingEnvelope.ts'; import { createStateReader } from '../state/StateReader.ts'; import { matchGlob } from '../../utils/matchGlob.ts'; import QueryError from '../../errors/QueryError.ts'; @@ -24,6 +28,7 @@ import type { WorldlineSource } from '../../capabilities/QueryCapability.ts'; import type { VisibleStateReader } from '../../types/VisibleStateReader.ts'; import type { SnapshotPropValue } from '../snapshot/SnapshotPropValue.ts'; import type { WarpState } from '../JoinReducer.ts'; +import type ObserverEmission from './ObserverEmission.ts'; import type { QueryReadModel, QueryReadModelOpenRequest, @@ -33,6 +38,10 @@ import type { type VisibleNodeProps = Readonly<{ [key: string]: SnapshotPropValue }>; type VisibleEdge = { from: string; to: string; label: string; props: VisibleNodeProps }; type ObserverSnapshot = { state: WarpState; stateHash: string }; +type ObserverReadingEnvelopeOptions = Pick< +ObserverReadingEnvelopeFields, +'witnessRef' | 'shellRef' | 'pluralityRef' | 'receiptAnchors' +>; export interface ObserverBacking { hasNode: (nodeId: string) => Promise; @@ -93,6 +102,7 @@ export interface ObserverConfig { match: string | string[]; expose?: string[]; redact?: string[]; + basis?: string[]; } interface ObserverOptions { @@ -112,6 +122,7 @@ export default class Observer { private _matchPattern!: string | string[]; private _expose: string[] | undefined; private _redact: string[] | undefined; + private _basis!: ObserverBasis; private _graph!: ObserverBacking | null; private _snapshot!: ObserverSnapshot | null; private _source!: WorldlineSelector | null; @@ -130,6 +141,7 @@ export default class Observer { this._matchPattern = Array.isArray(config.match) ? [...config.match] : config.match; this._expose = config.expose ? [...config.expose] : undefined; this._redact = config.redact ? [...config.redact] : undefined; + this._basis = ObserverBasis.from(config.basis); } private _initBacking( @@ -157,6 +169,10 @@ export default class Observer { return this._snapshot ? this._snapshot.stateHash : null; } + get basis(): ObserverBasis { + return this._basis; + } + private _requireGraph(): ObserverBacking { if (!this._graph) { throw new QueryError( @@ -173,6 +189,7 @@ export default class Observer { }; if (this._expose) { config.expose = [...this._expose]; } if (this._redact) { config.redact = [...this._redact]; } + if (!this._basis.isEmpty()) { config.basis = this._basis.toConfigValue(); } return config; } @@ -226,6 +243,46 @@ export default class Observer { }; } + // =========================================================================== + // Structural observer API + // =========================================================================== + + async accumulate(): Promise { + let accumulation = ObserverAccumulation.empty(this._basis); + const nodeIds = await this.getNodes(); + for (const nodeId of nodeIds) { + const props = await this.getNodeProps(nodeId); + if (props !== null) { + accumulation = accumulation.includeNode(props); + } + } + return accumulation.includeEdges((await this.getEdges()).length); + } + + async emit(): Promise { + return (await this.accumulate()).emit(); + } + + plan(): ObserverPlan { + return new ObserverPlan({ + name: this._name, + match: Array.isArray(this._matchPattern) ? [...this._matchPattern] : this._matchPattern, + ...(this._expose !== undefined ? { expose: [...this._expose] } : {}), + ...(this._redact !== undefined ? { redact: [...this._redact] } : {}), + basis: this._basis, + source: this._source ?? new LiveSelector(), + }); + } + + async readingEnvelope(options: ObserverReadingEnvelopeOptions = {}): Promise { + return new ObserverReadingEnvelope({ + plan: this.plan(), + payload: await this.emit(), + stateHash: this.stateHash, + ...options, + }); + } + // =========================================================================== // Node API // =========================================================================== diff --git a/src/domain/services/query/ObserverAccumulation.ts b/src/domain/services/query/ObserverAccumulation.ts new file mode 100644 index 000000000..28f819fc8 --- /dev/null +++ b/src/domain/services/query/ObserverAccumulation.ts @@ -0,0 +1,130 @@ +import QueryError from '../../errors/QueryError.ts'; +import ObserverBasis from './ObserverBasis.ts'; +import ObserverEmission from './ObserverEmission.ts'; +import type { QueryPropertyBag } from './QueryReadModelProvider.ts'; + +export type ObserverAccumulationFields = { + readonly basis: ObserverBasis; + readonly nodeCount: number; + readonly edgeCount: number; + readonly propertyKeys: readonly string[]; +}; + +/** Immutable accumulation state for a structural observer fold. */ +export default class ObserverAccumulation { + readonly basis: ObserverBasis; + readonly nodeCount: number; + readonly edgeCount: number; + readonly propertyKeys: readonly string[]; + + constructor(fields: ObserverAccumulationFields) { + const checked = requireFields(fields); + this.basis = requireBasis(checked.basis); + this.nodeCount = requireNonNegativeInteger(checked.nodeCount, 'nodeCount'); + this.edgeCount = requireNonNegativeInteger(checked.edgeCount, 'edgeCount'); + this.propertyKeys = freezeSortedKeys(checked.propertyKeys); + Object.freeze(this); + } + + static empty(basis: ObserverBasis): ObserverAccumulation { + return new ObserverAccumulation({ + basis, + nodeCount: 0, + edgeCount: 0, + propertyKeys: [], + }); + } + + includeNode(props: QueryPropertyBag): ObserverAccumulation { + return new ObserverAccumulation({ + basis: this.basis, + nodeCount: this.nodeCount + 1, + edgeCount: this.edgeCount, + propertyKeys: mergePropertyKeys(this.propertyKeys, props), + }); + } + + includeEdges(edgeCount: number): ObserverAccumulation { + return new ObserverAccumulation({ + basis: this.basis, + nodeCount: this.nodeCount, + edgeCount: this.edgeCount + requireNonNegativeInteger(edgeCount, 'edgeCount'), + propertyKeys: this.propertyKeys, + }); + } + + emit(): ObserverEmission { + return new ObserverEmission({ + basis: this.basis.distinctions, + nodeCount: this.nodeCount, + edgeCount: this.edgeCount, + propertyKeys: this.propertyKeys, + matchedBasis: this.basis.matchedBy(this.propertyKeys), + }); + } +} + +function requireFields( + fields: ObserverAccumulationFields | null | undefined, +): ObserverAccumulationFields { + if (fields !== null && typeof fields === 'object') { + return fields; + } + throw new QueryError('observer accumulation requires object fields', { + code: 'E_OBSERVER_ACCUMULATION_FIELDS', + }); +} + +function requireBasis(basis: ObserverBasis): ObserverBasis { + if (basis instanceof ObserverBasis) { + return basis; + } + throw new QueryError('observer accumulation requires an ObserverBasis', { + code: 'E_OBSERVER_ACCUMULATION_BASIS', + }); +} + +function requireNonNegativeInteger(value: number, field: string): number { + if (Number.isInteger(value) && value >= 0) { + return value; + } + throw new QueryError('observer accumulation counts must be non-negative integers', { + code: 'E_OBSERVER_ACCUMULATION_COUNT', + context: { field }, + }); +} + +function mergePropertyKeys( + existingKeys: readonly string[], + props: QueryPropertyBag, +): readonly string[] { + const merged = new Set(existingKeys); + for (const key of Object.keys(props)) { + merged.add(key); + } + return freezeSortedKeys([...merged]); +} + +function freezeSortedKeys(keys: readonly string[]): readonly string[] { + const normalized: string[] = []; + for (const key of keys) { + if (typeof key !== 'string' || key.length === 0) { + throw new QueryError('observer accumulation property keys must be non-empty strings', { + code: 'E_OBSERVER_ACCUMULATION_PROPERTY_KEY', + context: { field: 'propertyKeys' }, + }); + } + normalized.push(key); + } + return Object.freeze([...new Set(normalized)].sort(compareStrings)); +} + +function compareStrings(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} diff --git a/src/domain/services/query/ObserverBasis.ts b/src/domain/services/query/ObserverBasis.ts new file mode 100644 index 000000000..a7ff67dfc --- /dev/null +++ b/src/domain/services/query/ObserverBasis.ts @@ -0,0 +1,76 @@ +import QueryError from '../../errors/QueryError.ts'; + +/** Runtime-backed native distinctions for a structural observer. */ +export default class ObserverBasis { + readonly #distinctions: readonly string[]; + + constructor(distinctions: readonly string[] = []) { + this.#distinctions = normalizeDistinctions(distinctions); + Object.freeze(this); + } + + static from(distinctions: readonly string[] | undefined): ObserverBasis { + return new ObserverBasis(distinctions ?? []); + } + + get distinctions(): readonly string[] { + return this.#distinctions; + } + + get size(): number { + return this.#distinctions.length; + } + + isEmpty(): boolean { + return this.#distinctions.length === 0; + } + + contains(distinction: string): boolean { + return this.#distinctions.includes(distinction); + } + + matchedBy(propertyKeys: readonly string[]): readonly string[] { + const keys = new Set(propertyKeys); + return Object.freeze(this.#distinctions.filter((distinction) => keys.has(distinction))); + } + + toConfigValue(): string[] { + return [...this.#distinctions]; + } +} + +function normalizeDistinctions(distinctions: readonly string[]): readonly string[] { + requireDistinctionArray(distinctions); + const seen = new Set(); + const normalized: string[] = []; + for (const distinction of distinctions) { + normalized.push(requireDistinction(distinction, seen)); + } + return Object.freeze(normalized); +} + +function requireDistinctionArray(distinctions: readonly string[]): void { + if (!Array.isArray(distinctions)) { + throw new QueryError('observer basis must be an array of distinctions', { + code: 'E_OBSERVER_BASIS_TYPE', + context: { field: 'basis' }, + }); + } +} + +function requireDistinction(distinction: string, seen: Set): string { + if (typeof distinction !== 'string' || distinction.length === 0) { + throw new QueryError('observer basis distinction must be non-empty', { + code: 'E_OBSERVER_BASIS_DISTINCTION', + context: { field: 'basis' }, + }); + } + if (seen.has(distinction)) { + throw new QueryError('observer basis distinction must be unique', { + code: 'E_OBSERVER_BASIS_DISTINCTION', + context: { field: 'basis', distinction }, + }); + } + seen.add(distinction); + return distinction; +} diff --git a/src/domain/services/query/ObserverEmission.ts b/src/domain/services/query/ObserverEmission.ts new file mode 100644 index 000000000..22ffaae86 --- /dev/null +++ b/src/domain/services/query/ObserverEmission.ts @@ -0,0 +1,69 @@ +import QueryError from '../../errors/QueryError.ts'; + +export type ObserverEmissionFields = { + readonly basis: readonly string[]; + readonly nodeCount: number; + readonly edgeCount: number; + readonly propertyKeys: readonly string[]; + readonly matchedBasis: readonly string[]; +}; + +/** Immutable emission map output for a structural observer accumulation. */ +export default class ObserverEmission { + readonly basis: readonly string[]; + readonly nodeCount: number; + readonly edgeCount: number; + readonly propertyKeys: readonly string[]; + readonly matchedBasis: readonly string[]; + + constructor(fields: ObserverEmissionFields) { + const checked = requireFields(fields); + this.basis = freezeStringList(checked.basis, 'basis'); + this.nodeCount = requireNonNegativeInteger(checked.nodeCount, 'nodeCount'); + this.edgeCount = requireNonNegativeInteger(checked.edgeCount, 'edgeCount'); + this.propertyKeys = freezeStringList(checked.propertyKeys, 'propertyKeys'); + this.matchedBasis = freezeStringList(checked.matchedBasis, 'matchedBasis'); + Object.freeze(this); + } +} + +function requireFields( + fields: ObserverEmissionFields | null | undefined, +): ObserverEmissionFields { + if (fields !== null && typeof fields === 'object') { + return fields; + } + throw new QueryError('observer emission requires object fields', { + code: 'E_OBSERVER_EMISSION_FIELDS', + }); +} + +function freezeStringList(values: readonly string[], field: string): readonly string[] { + if (!Array.isArray(values)) { + throw new QueryError('observer emission field must be a string array', { + code: 'E_OBSERVER_EMISSION_FIELD', + context: { field }, + }); + } + const normalized: string[] = []; + for (const value of values) { + if (typeof value !== 'string' || value.length === 0) { + throw new QueryError('observer emission field entries must be non-empty strings', { + code: 'E_OBSERVER_EMISSION_FIELD', + context: { field }, + }); + } + normalized.push(value); + } + return Object.freeze(normalized); +} + +function requireNonNegativeInteger(value: number, field: string): number { + if (Number.isInteger(value) && value >= 0) { + return value; + } + throw new QueryError('observer emission counts must be non-negative integers', { + code: 'E_OBSERVER_EMISSION_COUNT', + context: { field }, + }); +} diff --git a/src/domain/services/query/ObserverPlan.ts b/src/domain/services/query/ObserverPlan.ts new file mode 100644 index 000000000..7b864d1ee --- /dev/null +++ b/src/domain/services/query/ObserverPlan.ts @@ -0,0 +1,147 @@ +import LiveSelector from '../../types/LiveSelector.ts'; +import CoordinateSelector from '../../types/CoordinateSelector.ts'; +import StrandSelector from '../../types/StrandSelector.ts'; +import WorldlineSelector from '../../types/WorldlineSelector.ts'; +import QueryError from '../../errors/QueryError.ts'; +import ObserverBasis from './ObserverBasis.ts'; +import type { ObserverConfig, WorldlineSource } from '../../capabilities/QueryCapability.ts'; + +export type ObserverPlanFields = { + readonly name: string; + readonly match: string | readonly string[]; + readonly expose?: readonly string[]; + readonly redact?: readonly string[]; + readonly basis: ObserverBasis; + readonly source: WorldlineSelector | WorldlineSource; +}; + +/** Runtime-backed source/config plan for an observer reading. */ +export default class ObserverPlan { + readonly #source: WorldlineSelector; + readonly name: string; + readonly match: string | readonly string[]; + readonly expose: readonly string[] | null; + readonly redact: readonly string[] | null; + readonly basis: ObserverBasis; + readonly sourceKind: WorldlineSource['kind']; + + constructor(fields: ObserverPlanFields) { + const checkedFields = requireFields(fields); + const source = WorldlineSelector.from(checkedFields.source).clone(); + this.#source = source; + this.name = requireNonEmptyString(checkedFields.name, 'name'); + this.match = normalizeMatch(checkedFields.match); + this.expose = normalizeOptionalStringList(checkedFields.expose, 'expose'); + this.redact = normalizeOptionalStringList(checkedFields.redact, 'redact'); + this.basis = requireBasis(checkedFields.basis); + this.sourceKind = selectorToSource(source).kind; + Object.freeze(this); + } + + get source(): WorldlineSource { + return selectorToSource(this.#source); + } + + toConfig(): ObserverConfig { + return { + match: matchToConfigValue(this.match), + ...(this.expose !== null ? { expose: [...this.expose] } : {}), + ...(this.redact !== null ? { redact: [...this.redact] } : {}), + ...(!this.basis.isEmpty() ? { basis: this.basis.toConfigValue() } : {}), + }; + } +} + +function requireFields(fields: ObserverPlanFields | null | undefined): ObserverPlanFields { + if (fields !== null && typeof fields === 'object') { + return fields; + } + throw new QueryError('observer plan requires object fields', { + code: 'E_OBSERVER_PLAN_FIELDS', + }); +} + +function selectorToSource(source: WorldlineSelector): WorldlineSource { + if (source instanceof LiveSelector) { + return source.toDTO(); + } + if (source instanceof CoordinateSelector) { + return source.toDTO(); + } + if (source instanceof StrandSelector) { + return source.toDTO(); + } + throw new QueryError(`unrecognized observer plan source kind: ${source.constructor.name}`, { + code: 'E_OBSERVER_PLAN_SOURCE_UNKNOWN', + context: { sourceKind: source.constructor.name }, + }); +} + +function normalizeMatch(match: string | readonly string[]): string | readonly string[] { + if (typeof match === 'string') { + return match; + } + if (!Array.isArray(match) || match.length === 0) { + throw new QueryError('observer plan match must be a string or non-empty string array', { + code: 'E_OBSERVER_PLAN_MATCH', + context: { field: 'match' }, + }); + } + return freezeStringList(match, 'match'); +} + +function matchToConfigValue(match: string | readonly string[]): string | string[] { + if (typeof match === 'string') { + return match; + } + return [...match]; +} + +function normalizeOptionalStringList( + values: readonly string[] | undefined, + field: string, +): readonly string[] | null { + if (values === undefined) { + return null; + } + return freezeStringList(values, field); +} + +function freezeStringList(values: readonly string[], field: string): readonly string[] { + if (!Array.isArray(values)) { + throw new QueryError('observer plan field must be a string array', { + code: 'E_OBSERVER_PLAN_FIELD', + context: { field }, + }); + } + const normalized: string[] = []; + for (const value of values) { + if (typeof value !== 'string' || value.length === 0) { + throw new QueryError('observer plan field entries must be non-empty strings', { + code: 'E_OBSERVER_PLAN_FIELD', + context: { field }, + }); + } + normalized.push(value); + } + return Object.freeze(normalized); +} + +function requireBasis(basis: ObserverBasis): ObserverBasis { + if (basis instanceof ObserverBasis) { + return basis; + } + throw new QueryError('observer plan requires an ObserverBasis', { + code: 'E_OBSERVER_PLAN_BASIS', + }); +} + +function requireNonEmptyString(value: string, field: string): string { + if (typeof value === 'string' && value.length > 0) { + return value; + } + throw new QueryError('observer plan field must be a non-empty string', { + code: 'E_OBSERVER_PLAN_STRING', + context: { field }, + }); +} diff --git a/src/domain/services/query/ObserverReadingEnvelope.ts b/src/domain/services/query/ObserverReadingEnvelope.ts new file mode 100644 index 000000000..e2e124ca0 --- /dev/null +++ b/src/domain/services/query/ObserverReadingEnvelope.ts @@ -0,0 +1,221 @@ +import QueryError from '../../errors/QueryError.ts'; +import { + GIT_WARP_RECEIPT_ENVELOPE_BOUNDARY_VERSION, + GIT_WARP_RECEIPT_ENVELOPE_FACT_KIND, + type GitWarpReceiptEnvelopeAnchor, +} from '../../continuum/GitWarpReceiptEnvelopeBoundary.ts'; +import ObserverEmission from './ObserverEmission.ts'; +import ObserverPlan from './ObserverPlan.ts'; +import type { WorldlineSource } from '../../capabilities/QueryCapability.ts'; + +export type ObserverReadingEnvelopeBudget = { + readonly nodeCount: number; + readonly edgeCount: number; + readonly propertyKeyCount: number; + readonly matchedBasisCount: number; +}; + +export type ObserverReadingEnvelopeFields = { + readonly plan: ObserverPlan; + readonly payload: ObserverEmission; + readonly stateHash?: string | null; + readonly witnessRef?: string | null; + readonly shellRef?: string | null; + readonly pluralityRef?: string | null; + readonly receiptAnchors?: readonly GitWarpReceiptEnvelopeAnchor[]; +}; + +type ObserverReadingEnvelopeRefs = { + readonly stateHash: string | null; + readonly witnessRef: string | null; + readonly shellRef: string | null; + readonly pluralityRef: string | null; +}; + +/** Observer reading envelope tying source plan, emitted payload, and witness refs. */ +export default class ObserverReadingEnvelope { + readonly plan: ObserverPlan; + readonly payload: ObserverEmission; + readonly stateHash: string | null; + readonly witnessRef: string | null; + readonly shellRef: string | null; + readonly pluralityRef: string | null; + readonly receiptAnchors: readonly GitWarpReceiptEnvelopeAnchor[]; + readonly budget: ObserverReadingEnvelopeBudget; + readonly residualBasis: readonly string[]; + + constructor(fields: ObserverReadingEnvelopeFields) { + const checkedFields = requireFields(fields); + const refs = requireEnvelopeRefs(checkedFields); + this.plan = requirePlan(checkedFields.plan); + this.payload = requirePayload(checkedFields.payload); + this.stateHash = refs.stateHash; + this.witnessRef = refs.witnessRef; + this.shellRef = refs.shellRef; + this.pluralityRef = refs.pluralityRef; + this.receiptAnchors = freezeReceiptAnchors(checkedFields.receiptAnchors ?? []); + this.budget = freezeBudget(this.payload); + this.residualBasis = freezeResidualBasis(this.plan, this.payload); + Object.freeze(this); + } + + get source(): WorldlineSource { + return this.plan.source; + } + + hasResidual(): boolean { + return this.residualBasis.length > 0; + } + + hasPlurality(): boolean { + return this.pluralityRef !== null; + } + + hasReceiptAnchors(): boolean { + return this.receiptAnchors.length > 0; + } +} + +function requireFields( + fields: ObserverReadingEnvelopeFields | null | undefined, +): ObserverReadingEnvelopeFields { + if (fields !== null && typeof fields === 'object') { + return fields; + } + throw new QueryError('observer reading envelope requires object fields', { + code: 'E_OBSERVER_READING_ENVELOPE_FIELDS', + }); +} + +function requirePlan(plan: ObserverPlan): ObserverPlan { + if (plan instanceof ObserverPlan) { + return plan; + } + throw new QueryError('observer reading envelope requires an ObserverPlan', { + code: 'E_OBSERVER_READING_ENVELOPE_PLAN', + }); +} + +function requirePayload(payload: ObserverEmission): ObserverEmission { + if (payload instanceof ObserverEmission) { + return payload; + } + throw new QueryError('observer reading envelope requires an ObserverEmission payload', { + code: 'E_OBSERVER_READING_ENVELOPE_PAYLOAD', + }); +} + +function requireEnvelopeRefs(fields: ObserverReadingEnvelopeFields): ObserverReadingEnvelopeRefs { + return { + stateHash: requireOptionalRef(fields.stateHash ?? null, 'stateHash'), + witnessRef: requireOptionalRef(fields.witnessRef ?? null, 'witnessRef'), + shellRef: requireOptionalRef(fields.shellRef ?? null, 'shellRef'), + pluralityRef: requireOptionalRef(fields.pluralityRef ?? null, 'pluralityRef'), + }; +} + +function requireOptionalRef(value: string | null, field: string): string | null { + if (value === null) { + return null; + } + if (typeof value === 'string' && value.length > 0) { + return value; + } + throw new QueryError('observer reading envelope refs must be non-empty when provided', { + code: 'E_OBSERVER_READING_ENVELOPE_REF', + context: { field }, + }); +} + +function freezeBudget(payload: ObserverEmission): ObserverReadingEnvelopeBudget { + return Object.freeze({ + nodeCount: payload.nodeCount, + edgeCount: payload.edgeCount, + propertyKeyCount: payload.propertyKeys.length, + matchedBasisCount: payload.matchedBasis.length, + }); +} + +function freezeResidualBasis( + plan: ObserverPlan, + payload: ObserverEmission, +): readonly string[] { + const matched = new Set(payload.matchedBasis); + return Object.freeze(plan.basis.distinctions.filter((distinction) => !matched.has(distinction))); +} + +function freezeReceiptAnchors( + anchors: readonly GitWarpReceiptEnvelopeAnchor[], +): readonly GitWarpReceiptEnvelopeAnchor[] { + if (!Array.isArray(anchors)) { + throw new QueryError('observer reading envelope receipt anchors must be an array', { + code: 'E_OBSERVER_READING_ENVELOPE_RECEIPTS', + }); + } + return Object.freeze(anchors.map(requireReceiptAnchor)); +} + +function requireReceiptAnchor(anchor: GitWarpReceiptEnvelopeAnchor): GitWarpReceiptEnvelopeAnchor { + requireReceiptAnchorBoundary(anchor); + requireReceiptAnchorFields(anchor); + return freezeReceiptAnchor(anchor); +} + +function requireReceiptAnchorBoundary(anchor: GitWarpReceiptEnvelopeAnchor): void { + if (anchor.boundaryVersion === GIT_WARP_RECEIPT_ENVELOPE_BOUNDARY_VERSION + && anchor.substrateFactKind === GIT_WARP_RECEIPT_ENVELOPE_FACT_KIND) { + return; + } + throw new QueryError('observer reading envelope receipt anchor has an unsupported boundary', { + code: 'E_OBSERVER_READING_ENVELOPE_RECEIPT_BOUNDARY', + }); +} + +function requireReceiptAnchorFields(anchor: GitWarpReceiptEnvelopeAnchor): void { + requireNonEmptyString(anchor.patchSha, 'patchSha'); + requireNonEmptyString(anchor.writer, 'writer'); + requireNonNegativeInteger(anchor.lamport, 'lamport'); + requireNonNegativeInteger(anchor.outcomeCount, 'outcomeCount'); + requireNonNegativeInteger(anchor.appliedCount, 'appliedCount'); + requireNonNegativeInteger(anchor.supersededCount, 'supersededCount'); + requireNonNegativeInteger(anchor.redundantCount, 'redundantCount'); + if (typeof anchor.hasExplanatoryReasons !== 'boolean') { + throw new QueryError('observer reading envelope receipt anchor reason flag must be boolean', { + code: 'E_OBSERVER_READING_ENVELOPE_RECEIPT_FLAG', + context: { field: 'hasExplanatoryReasons' }, + }); + } +} + +function freezeReceiptAnchor(anchor: GitWarpReceiptEnvelopeAnchor): GitWarpReceiptEnvelopeAnchor { + return Object.freeze({ + boundaryVersion: anchor.boundaryVersion, + substrateFactKind: anchor.substrateFactKind, + patchSha: anchor.patchSha, + writer: anchor.writer, + lamport: anchor.lamport, + outcomeCount: anchor.outcomeCount, + appliedCount: anchor.appliedCount, + supersededCount: anchor.supersededCount, + redundantCount: anchor.redundantCount, + hasExplanatoryReasons: anchor.hasExplanatoryReasons, + }); +} + +function requireNonEmptyString(value: string, field: string): void { + if (typeof value !== 'string' || value.length === 0) { + throw new QueryError('observer reading envelope receipt anchor fields must be non-empty strings', { + code: 'E_OBSERVER_READING_ENVELOPE_RECEIPT_STRING', + context: { field }, + }); + } +} + +function requireNonNegativeInteger(value: number, field: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new QueryError('observer reading envelope receipt anchor counts must be non-negative integers', { + code: 'E_OBSERVER_READING_ENVELOPE_RECEIPT_COUNT', + context: { field }, + }); + } +} diff --git a/src/domain/services/query/QueryBuilder.ts b/src/domain/services/query/QueryBuilder.ts index 06b17329a..07217b802 100644 --- a/src/domain/services/query/QueryBuilder.ts +++ b/src/domain/services/query/QueryBuilder.ts @@ -9,6 +9,8 @@ import QueryError from '../../errors/QueryError.ts'; import ImmutableBytes from '../snapshot/ImmutableBytes.ts'; import type { SnapshotPropValue } from '../snapshot/SnapshotPropValue.ts'; import type { AggregateResult } from './QueryAggregation.ts'; +import BoundedSupportRule from './BoundedSupportRule.ts'; +import SupportFragmentPlan from './SupportFragmentPlan.ts'; import QueryPlan, { type AggregateSpec, type QueryNodeSnapshot, type QueryOperation } from './QueryPlan.ts'; import QueryRunner, { type QueryResult } from './QueryRunner.ts'; import type { QueryReadModelProvider } from './QueryReadModelProvider.ts'; @@ -161,6 +163,23 @@ export default class QueryBuilder { this._aggregate = null; } + toPlan(): QueryPlan { + return new QueryPlan({ + pattern: this._pattern ?? DEFAULT_PATTERN, + operations: this._operations, + select: this._select, + aggregate: this._aggregate, + }); + } + + supportRule(): BoundedSupportRule { + return BoundedSupportRule.fromQueryPlan(this.toPlan()); + } + + supportFragmentPlan(): SupportFragmentPlan { + return SupportFragmentPlan.fromSupportRule(this.supportRule()); + } + match(pattern: string | string[]): QueryBuilder { assertMatchPattern(pattern); this._pattern = pattern; @@ -239,13 +258,7 @@ export default class QueryBuilder { } async run(): Promise { - const plan = new QueryPlan({ - pattern: this._pattern ?? DEFAULT_PATTERN, - operations: this._operations, - select: this._select, - aggregate: this._aggregate, - }); const runner = new QueryRunner(this._provider); - return await runner.run(plan); + return await runner.run(this.toPlan()); } } diff --git a/src/domain/services/query/QueryReadModelProvider.ts b/src/domain/services/query/QueryReadModelProvider.ts index a9bf1c47b..38bdb56c1 100644 --- a/src/domain/services/query/QueryReadModelProvider.ts +++ b/src/domain/services/query/QueryReadModelProvider.ts @@ -3,6 +3,9 @@ import type { QueryNodeSnapshot, QueryOperation, } from './QueryPlan.ts'; +import type BoundedSupportRule from './BoundedSupportRule.ts'; +import type CausalIndexPlan from './CausalIndexPlan.ts'; +import type SupportFragmentPlan from './SupportFragmentPlan.ts'; export type QueryPropertyBag = QueryNodePropertyBag; @@ -35,6 +38,9 @@ export type QueryReadModelOpenRequest = { readonly nodeRequest: QueryNodeStreamRequest; readonly operations: readonly QueryOperation[]; readonly aggregate: boolean; + readonly supportRule: BoundedSupportRule; + readonly causalIndexPlan: CausalIndexPlan; + readonly supportFragmentPlan: SupportFragmentPlan; }; export interface QueryReadModelProvider { diff --git a/src/domain/services/query/QueryRunner.ts b/src/domain/services/query/QueryRunner.ts index 65e0a1d29..d24d7bcf6 100644 --- a/src/domain/services/query/QueryRunner.ts +++ b/src/domain/services/query/QueryRunner.ts @@ -1,6 +1,9 @@ /** QueryRunner - pure executor for QueryPlan instances. */ import QueryError from '../../errors/QueryError.ts'; +import BoundedSupportRule from './BoundedSupportRule.ts'; +import CausalIndexPlan from './CausalIndexPlan.ts'; +import SupportFragmentPlan from './SupportFragmentPlan.ts'; import type QueryPlan from './QueryPlan.ts'; import type { QueryNodeEdgeSnapshot, @@ -419,9 +422,17 @@ function openRequest( plan: QueryPlan, nodeRequest: QueryNodeStreamRequest, ): QueryReadModelOpenRequest { + const supportRule = BoundedSupportRule.fromQueryPlan(plan); + const causalIndexPlan = CausalIndexPlan.fromSupportRule(supportRule); return { nodeRequest, operations: plan.operations, aggregate: plan.aggregate !== null, + supportRule, + causalIndexPlan, + supportFragmentPlan: SupportFragmentPlan.fromSupportAndIndex({ + supportRule, + causalIndexPlan, + }), }; } diff --git a/src/domain/services/query/SupportFragmentPlan.ts b/src/domain/services/query/SupportFragmentPlan.ts new file mode 100644 index 000000000..65749d691 --- /dev/null +++ b/src/domain/services/query/SupportFragmentPlan.ts @@ -0,0 +1,172 @@ +import QueryError from '../../errors/QueryError.ts'; +import BoundedSupportRule from './BoundedSupportRule.ts'; +import CausalIndexPlan from './CausalIndexPlan.ts'; +import { freezeStringList, requireNonEmptyString } from './queryValidation.ts'; + +export type SupportFragmentMaterializationPosture = + | 'support-fragment' + | 'support-fragment-with-index-fill' + | 'global-fallback'; + +export type SupportFragmentPlanFields = { + readonly supportRule: BoundedSupportRule; + readonly causalIndexPlan: CausalIndexPlan; + readonly posture: SupportFragmentMaterializationPosture; + readonly scopeKey: string; + readonly requiredEntityIds?: readonly string[]; +}; + +const SUPPORT_FRAGMENT_POSTURES: readonly SupportFragmentMaterializationPosture[] = Object.freeze([ + 'support-fragment', + 'support-fragment-with-index-fill', + 'global-fallback', +]); +const SUPPORT_FRAGMENT_PLAN_ERROR = 'E_QUERY_SUPPORT_FRAGMENT_PLAN'; + +/** Support-scoped fragment materialization contract for a bounded read plan. */ +export default class SupportFragmentPlan { + readonly supportRule: BoundedSupportRule; + readonly causalIndexPlan: CausalIndexPlan; + readonly posture: SupportFragmentMaterializationPosture; + readonly scopeKey: string; + readonly requiredEntityIds: readonly string[]; + + constructor(fields: SupportFragmentPlanFields) { + const checkedFields = requireFields(fields); + this.supportRule = requireSupportRule(checkedFields.supportRule); + this.causalIndexPlan = requireMatchingCausalIndexPlan( + checkedFields.causalIndexPlan, + this.supportRule, + ); + this.posture = requirePosture(checkedFields.posture); + this.scopeKey = requireNonEmptyString(checkedFields.scopeKey, 'scopeKey', SUPPORT_FRAGMENT_PLAN_ERROR); + this.requiredEntityIds = freezeStringList( + checkedFields.requiredEntityIds ?? [], + 'requiredEntityIds', + SUPPORT_FRAGMENT_PLAN_ERROR, + ); + Object.freeze(this); + } + + static fromSupportRule(rule: BoundedSupportRule): SupportFragmentPlan { + const supportRule = requireSupportRule(rule); + return SupportFragmentPlan.fromSupportAndIndex({ + supportRule, + causalIndexPlan: CausalIndexPlan.fromSupportRule(supportRule), + }); + } + + static fromSupportAndIndex(fields: { + readonly supportRule: BoundedSupportRule; + readonly causalIndexPlan: CausalIndexPlan; + }): SupportFragmentPlan { + const supportRule = requireSupportRule(fields.supportRule); + const causalIndexPlan = requireMatchingCausalIndexPlan(fields.causalIndexPlan, supportRule); + return new SupportFragmentPlan({ + supportRule, + causalIndexPlan, + posture: postureFor(supportRule, causalIndexPlan), + scopeKey: scopeKeyFor(supportRule, causalIndexPlan), + requiredEntityIds: causalIndexPlan.requiredEntityIds, + }); + } + + canMaterializeSupportFragment(): boolean { + return this.posture !== 'global-fallback'; + } + + requiresFullGraphFallback(): boolean { + return this.posture === 'global-fallback'; + } + + fragmentKeyForCoordinate(coordinateRef: string): string { + if (this.requiresFullGraphFallback()) { + throw new QueryError('global fallback plans do not have support-scoped fragment keys', { + code: SUPPORT_FRAGMENT_PLAN_ERROR, + }); + } + return `${this.scopeKey}@${requireNonEmptyString( + coordinateRef, + 'coordinateRef', + SUPPORT_FRAGMENT_PLAN_ERROR, + )}`; + } +} + +function requireFields(fields: SupportFragmentPlanFields | null | undefined): SupportFragmentPlanFields { + if (fields === null || fields === undefined) { + throw new QueryError('SupportFragmentPlan fields must be provided', { + code: SUPPORT_FRAGMENT_PLAN_ERROR, + }); + } + return fields; +} + +function requireSupportRule(value: BoundedSupportRule): BoundedSupportRule { + if (!(value instanceof BoundedSupportRule)) { + throw new QueryError('SupportFragmentPlan requires a BoundedSupportRule', { + code: SUPPORT_FRAGMENT_PLAN_ERROR, + }); + } + return value; +} + +function requireMatchingCausalIndexPlan( + value: CausalIndexPlan, + supportRule: BoundedSupportRule, +): CausalIndexPlan { + if (!(value instanceof CausalIndexPlan)) { + throw new QueryError('SupportFragmentPlan requires a CausalIndexPlan', { + code: SUPPORT_FRAGMENT_PLAN_ERROR, + }); + } + if (value.supportRule !== supportRule) { + throw new QueryError('SupportFragmentPlan support rule and causal index plan must match', { + code: SUPPORT_FRAGMENT_PLAN_ERROR, + }); + } + return value; +} + +function requirePosture( + value: SupportFragmentMaterializationPosture, +): SupportFragmentMaterializationPosture { + if (!SUPPORT_FRAGMENT_POSTURES.includes(value)) { + throw new QueryError('SupportFragmentPlan posture is unsupported', { + code: SUPPORT_FRAGMENT_PLAN_ERROR, + context: { posture: value }, + }); + } + return value; +} + +function postureFor( + supportRule: BoundedSupportRule, + causalIndexPlan: CausalIndexPlan, +): SupportFragmentMaterializationPosture { + if (!supportRule.isBounded() || causalIndexPlan.requiresGlobalScan()) { + return 'global-fallback'; + } + if (causalIndexPlan.posture === 'composite') { + return 'support-fragment-with-index-fill'; + } + return 'support-fragment'; +} + +function scopeKeyFor(supportRule: BoundedSupportRule, causalIndexPlan: CausalIndexPlan): string { + return [ + `surface:${supportRule.surface}`, + `kind:${supportRule.kind}`, + `roots:${joinOrNone(supportRule.rootNodeIds)}`, + `depth:${supportRule.maxDepth ?? 'none'}`, + `directions:${joinOrNone(supportRule.directions)}`, + `indexes:${joinOrNone(causalIndexPlan.families)}`, + ].join('/'); +} + +function joinOrNone(values: readonly string[]): string { + if (values.length === 0) { + return 'none'; + } + return values.join('+'); +} diff --git a/src/domain/services/query/queryValidation.ts b/src/domain/services/query/queryValidation.ts new file mode 100644 index 000000000..49f7350dd --- /dev/null +++ b/src/domain/services/query/queryValidation.ts @@ -0,0 +1,29 @@ +import QueryError from '../../errors/QueryError.ts'; + +export function requireNonEmptyString(value: string, field: string, errorCode: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new QueryError(`${field} must be a non-empty string`, { + code: errorCode, + context: { field }, + }); + } + return value.trim(); +} + +export function freezeStringList( + values: readonly string[], + field: string, + errorCode: string, +): readonly string[] { + if (!Array.isArray(values)) { + throw new QueryError(`${field} must be an array`, { + code: errorCode, + context: { field }, + }); + } + const normalized: string[] = []; + for (const value of values) { + normalized.push(requireNonEmptyString(value, field, errorCode)); + } + return Object.freeze([...new Set(normalized)].sort()); +} diff --git a/src/domain/services/strand/ConflictAnalyzerService.ts b/src/domain/services/strand/ConflictAnalyzerService.ts index fb41883cc..b0f5a014a 100644 --- a/src/domain/services/strand/ConflictAnalyzerService.ts +++ b/src/domain/services/strand/ConflictAnalyzerService.ts @@ -7,17 +7,15 @@ * @module domain/services/strand/ConflictAnalyzerService */ -import { canonicalStringify } from '../../utils/canonicalStringify.ts'; import ConflictAnalysis from '../../types/conflict/ConflictAnalysis.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; import ConflictAnalysisRequest, { type ConflictAnalyzeOptions } from './ConflictAnalysisRequest.ts'; import { resolveAnalysisContext, attachReceipts, ScanWindow, CONFLICT_ANALYSIS_VERSION, - type AnalyzerService, } from './ConflictFrameLoader.ts'; +import ConflictPipelineContext, { type ConflictPipelineGraphRuntime } from './ConflictPipelineContext.ts'; import { ConflictCandidateCollector } from './ConflictCandidateCollector.ts'; import { groupCandidates, @@ -35,30 +33,13 @@ export { CONFLICT_ANALYSIS_VERSION }; * ConflictAnalyzerService analyzes read-only patch history for conflict traces. */ export class ConflictAnalyzerService { - /** @internal structural seam used by ConflictFrameLoader's strand-coordinator bridge. */ - readonly _graph: AnalyzerService['_graph']; - private readonly _digestCache: Map; + private readonly _pipelineContext: ConflictPipelineContext; /** * Initializes the analyzer with a warp runtime graph instance. */ - constructor({ graph }: { graph: AnalyzerService['_graph'] }) { - this._graph = graph; - this._digestCache = new Map(); - } - - /** - * Computes a cached SHA-256 digest of the canonical serialization - * of a hashable payload. - */ - async _hash(payload: HashablePayload): Promise { - const canonical = canonicalStringify(payload); - if (this._digestCache.has(canonical)) { - return this._digestCache.get(canonical)!; - } - const digest = await this._graph._crypto.hash('sha256', canonical); - this._digestCache.set(canonical, digest); - return digest; + constructor({ graph }: { graph: ConflictPipelineGraphRuntime }) { + this._pipelineContext = new ConflictPipelineContext({ graph }); } /** @@ -67,10 +48,8 @@ export class ConflictAnalyzerService { async analyze(options?: ConflictAnalyzeOptions): Promise { const request = ConflictAnalysisRequest.from(options); const diagnostics: ConflictDiagnostic[] = []; - // `this` structurally satisfies AnalyzerService: carries _graph - // (WarpRuntime ⊇ StrandCoordinatorGraphRuntime & _loadWriterPatches) - // and _hash(payload: HashablePayload). - const { patchFrames, resolvedCoordinate } = await resolveAnalysisContext(this, request); + const context = this._pipelineContext; + const { patchFrames, resolvedCoordinate } = await resolveAnalysisContext(context, request); if (patchFrames.length === 0) { return await this._emptyResult(resolvedCoordinate, request, diagnostics); } @@ -78,14 +57,14 @@ export class ConflictAnalyzerService { const scanWindow = new ScanWindow({ patchFrames, maxPatches: request.maxPatches, lamportCeiling: request.lamportCeiling, diagnostics, }); - const collector = await ConflictCandidateCollector.collect(this, { + const collector = await ConflictCandidateCollector.collect(context, { patchFrames, scannedPatchShas: scanWindow.scannedPatchShas, diagnostics, }); - const traces = await buildConflictTraces(this, { + const traces = await buildConflictTraces(context, { grouped: groupCandidates(collector.candidates).values(), evidence: request.evidence, resolvedCoordinate, }); const conflicts = filterTraces(traces, request); - const analysisSnapshotHash = await buildAnalysisSnapshotHash(this, { + const analysisSnapshotHash = await buildAnalysisSnapshotHash(context, { resolvedCoordinate, request, truncated: scanWindow.truncated, diagnostics, traces: conflicts, }); return new ConflictAnalysis({ @@ -99,9 +78,10 @@ export class ConflictAnalyzerService { request: ConflictAnalysisRequest, diagnostics: ConflictDiagnostic[], ): Promise { + const context = this._pipelineContext; return new ConflictAnalysis({ analysisVersion: CONFLICT_ANALYSIS_VERSION, resolvedCoordinate, - analysisSnapshotHash: await buildEmptySnapshotHash(this, { resolvedCoordinate, request }), + analysisSnapshotHash: await buildEmptySnapshotHash(context, { resolvedCoordinate, request }), diagnostics, conflicts: [], }); } diff --git a/src/domain/services/strand/ConflictCandidateCollector.ts b/src/domain/services/strand/ConflictCandidateCollector.ts index b337c7fbd..a197630f6 100644 --- a/src/domain/services/strand/ConflictCandidateCollector.ts +++ b/src/domain/services/strand/ConflictCandidateCollector.ts @@ -10,15 +10,11 @@ import type ConflictDiagnostic from '../../types/conflict/ConflictDiagnostic.ts'; import type ConflictCandidate from './ConflictCandidate.ts'; import type OpRecord from './OpRecord.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; +import type ConflictPipelineContext from './ConflictPipelineContext.ts'; import { analyzeFrameOps, addEventualOverrideCandidates, type PatchFrame } from './conflictCandidateAnalysis.ts'; export { inferCausalRelation } from './conflictCandidateAnalysis.ts'; -interface HashingService { - _hash(payload: HashablePayload): Promise; -} - /** * Mutable accumulator for conflict candidates during frame analysis. * @@ -44,7 +40,7 @@ export class ConflictCandidateCollector { * Walks all patch frames, builds op records, and classifies conflict candidates. */ static async collect( - service: HashingService, + context: ConflictPipelineContext, { patchFrames, scannedPatchShas, @@ -57,7 +53,7 @@ export class ConflictCandidateCollector { ): Promise { const collector = new ConflictCandidateCollector(); for (const frame of patchFrames) { - await analyzeFrameOps(service, { frame, scannedPatchShas, diagnostics, collector }); + await analyzeFrameOps(context, { frame, scannedPatchShas, diagnostics, collector }); } addEventualOverrideCandidates(collector, scannedPatchShas); return collector; diff --git a/src/domain/services/strand/ConflictFrameLoader.ts b/src/domain/services/strand/ConflictFrameLoader.ts index b2107222c..45fe7578d 100644 --- a/src/domain/services/strand/ConflictFrameLoader.ts +++ b/src/domain/services/strand/ConflictFrameLoader.ts @@ -12,13 +12,14 @@ import ConflictAnchor from '../../types/conflict/ConflictAnchor.ts'; import ConflictDiagnostic from '../../types/conflict/ConflictDiagnostic.ts'; import ConflictResolvedCoordinate from '../../types/conflict/ConflictResolvedCoordinate.ts'; import StrandCoordinateMetadata from '../../types/conflict/StrandCoordinateMetadata.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; import { compareStrings } from '../../types/conflict/validation.ts'; import { reducePatches } from '../JoinReducer.ts'; -import createStrandCoordinator, { type StrandCoordinatorGraphRuntime } from './createStrandCoordinator.ts'; +import createStrandCoordinator from './createStrandCoordinator.ts'; import { TickReceipt } from '../../types/TickReceipt.ts'; import type Patch from '../../types/Patch.ts'; import type ConflictAnalysisRequest from './ConflictAnalysisRequest.ts'; +import type ConflictPipelineContext from './ConflictPipelineContext.ts'; +import type { ConflictPipelineGraphRuntime } from './ConflictPipelineContext.ts'; // ── Constants re-exported for caller convenience ──────────────────── @@ -295,33 +296,21 @@ function buildResolvedCoordinate({ // ── Context resolution ────────────────────────────────────────────── -export type AnalyzerService = { - _graph: AnalyzerGraphRuntime; - _hash(payload: HashablePayload): Promise; -}; - /** - * The analyzer reaches into the shared strand-coordinator graph - * runtime (for strand-coordinate resolution) plus an additional - * `_loadWriterPatches` method (for frontier-coordinate enumeration). - * The intersection type lets the analyzer pass `service._graph` - * straight into `createStrandCoordinator` without a cast. + * The context reaches into the shared strand-coordinator graph runtime + * for strand-coordinate resolution plus `_loadWriterPatches` for + * frontier-coordinate enumeration. */ -type AnalyzerGraphRuntime = StrandCoordinatorGraphRuntime & { - _loadWriterPatches(writerId: string): Promise>; -}; - type AnalysisContext = { patchFrames: PatchFrame[]; resolvedCoordinate: ConflictResolvedCoordinate; }; async function resolveStrandContext( - service: AnalyzerService, + context: ConflictPipelineContext, request: ConflictAnalysisRequest, ): Promise { - // AnalyzerGraphRuntime structurally extends StrandCoordinatorGraphRuntime. - const strands = createStrandCoordinator(service._graph); + const strands = createStrandCoordinator(context.graph); const descriptor = await strands.getOrThrow(request.strandId!); const entries = await strands.getPatchEntries(request.strandId!, { ceiling: request.lamportCeiling, @@ -343,14 +332,14 @@ async function resolveStrandContext( } async function resolveFrontierContext( - service: AnalyzerService, + context: ConflictPipelineContext, request: ConflictAnalysisRequest, ): Promise { const { frontier, patchFrames } = await loadFrontierPatchFrames( - service._graph, + context.graph, request.lamportCeiling, ); - const frontierDigest = await service._hash(frontierToRecord(frontier)); + const frontierDigest = await context.hash(frontierToRecord(frontier)); return { patchFrames, resolvedCoordinate: buildResolvedCoordinate({ @@ -364,7 +353,7 @@ async function resolveFrontierContext( } async function loadFrontierPatchFrames( - graph: AnalyzerService['_graph'], + graph: ConflictPipelineGraphRuntime, lamportCeiling: number | null, ): Promise<{ frontier: Map; patchFrames: PatchFrame[] }> { const frontier = await graph.getFrontier(); @@ -389,11 +378,11 @@ async function loadFrontierPatchFrames( * strand or frontier coordinates. */ export async function resolveAnalysisContext( - service: AnalyzerService, + context: ConflictPipelineContext, request: ConflictAnalysisRequest, ): Promise { if (request.usesStrandCoordinate()) { - return await resolveStrandContext(service, request); + return await resolveStrandContext(context, request); } - return await resolveFrontierContext(service, request); + return await resolveFrontierContext(context, request); } diff --git a/src/domain/services/strand/ConflictPipelineContext.ts b/src/domain/services/strand/ConflictPipelineContext.ts new file mode 100644 index 000000000..1fff83c59 --- /dev/null +++ b/src/domain/services/strand/ConflictPipelineContext.ts @@ -0,0 +1,43 @@ +/** + * ConflictPipelineContext — explicit dependencies for conflict analysis stages. + * + * Conflict pipeline modules receive this narrow context instead of the + * ConflictAnalyzerService orchestrator. + * + * @module domain/services/strand/ConflictPipelineContext + */ + +import { canonicalStringify } from '../../utils/canonicalStringify.ts'; +import type Patch from '../../types/Patch.ts'; +import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; +import type { StrandCoordinatorGraphRuntime } from './createStrandCoordinator.ts'; + +export type ConflictPipelineGraphRuntime = StrandCoordinatorGraphRuntime & { + _loadWriterPatches(writerId: string): Promise>; +}; + +export default class ConflictPipelineContext { + readonly graph: ConflictPipelineGraphRuntime; + private readonly _digestCache: Map; + + constructor({ + graph, + }: { + graph: ConflictPipelineGraphRuntime; + }) { + this.graph = graph; + this._digestCache = new Map(); + Object.freeze(this); + } + + async hash(payload: HashablePayload): Promise { + const canonical = canonicalStringify(payload); + const cached = this._digestCache.get(canonical); + if (cached !== undefined) { + return cached; + } + const digest = await this.graph._crypto.hash('sha256', canonical); + this._digestCache.set(canonical, digest); + return digest; + } +} diff --git a/src/domain/services/strand/ConflictTraceAssembler.ts b/src/domain/services/strand/ConflictTraceAssembler.ts index 207de4507..8bba61dc1 100644 --- a/src/domain/services/strand/ConflictTraceAssembler.ts +++ b/src/domain/services/strand/ConflictTraceAssembler.ts @@ -18,12 +18,8 @@ import type ConflictTarget from '../../types/conflict/ConflictTarget.ts'; import type ConflictResolution from '../../types/conflict/ConflictResolution.ts'; import type ConflictDiagnostic from '../../types/conflict/ConflictDiagnostic.ts'; import type ConflictResolvedCoordinate from '../../types/conflict/ConflictResolvedCoordinate.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; import type ConflictAnalysisRequest from './ConflictAnalysisRequest.ts'; - -type HashingService = { - _hash(payload: HashablePayload): Promise; -}; +import type ConflictPipelineContext from './ConflictPipelineContext.ts'; type ConflictKind = 'supersession' | 'redundancy' | 'eventual_override'; @@ -165,7 +161,7 @@ function buildConflictIdInput({ } async function buildConflictTrace( - service: HashingService, + context: ConflictPipelineContext, { group, evidence, @@ -178,8 +174,8 @@ async function buildConflictTrace( ): Promise { const winner = ConflictWinner.fromRecord(group.winner); const losers = buildLosers(group, evidence); - const whyFingerprint = await service._hash(buildWhyFingerprintInput(group, losers)); - const conflictId = await service._hash(buildConflictIdInput({ group, winner, losers, resolvedCoordinate })); + const whyFingerprint = await context.hash(buildWhyFingerprintInput(group, losers)); + const conflictId = await context.hash(buildConflictIdInput({ group, winner, losers, resolvedCoordinate })); const classificationNotes = evidence === 'full' ? [...group.noteCodes].sort(compareStrings) : undefined; return new ConflictTrace({ conflictId, @@ -198,7 +194,7 @@ async function buildConflictTrace( * Transforms grouped conflicts into sorted, finalized ConflictTrace records. */ export async function buildConflictTraces( - service: HashingService, + context: ConflictPipelineContext, { grouped, evidence, @@ -211,7 +207,7 @@ export async function buildConflictTraces( ): Promise { const traces: ConflictTrace[] = []; for (const group of grouped) { - traces.push(await buildConflictTrace(service, { group, evidence, resolvedCoordinate })); + traces.push(await buildConflictTrace(context, { group, evidence, resolvedCoordinate })); } traces.sort((a, b) => ConflictTrace.compare(a, b)); return traces; @@ -236,7 +232,7 @@ function diagnosticCodes(diagnostics: ConflictDiagnostic[]): string[] { * Computes a snapshot hash over the entire analysis result. */ export async function buildAnalysisSnapshotHash( - service: HashingService, + context: ConflictPipelineContext, { resolvedCoordinate, request, @@ -251,7 +247,7 @@ export async function buildAnalysisSnapshotHash( traces: ConflictTrace[]; }, ): Promise { - return await service._hash({ + return await context.hash({ analysisVersion: CONFLICT_ANALYSIS_VERSION, resolvedCoordinate, filters: request.toSnapshotFilterRecord(), @@ -265,7 +261,7 @@ export async function buildAnalysisSnapshotHash( * Computes a snapshot hash for an analysis that found zero conflicts. */ export async function buildEmptySnapshotHash( - service: HashingService, + context: ConflictPipelineContext, { resolvedCoordinate, request, @@ -274,7 +270,7 @@ export async function buildEmptySnapshotHash( request: ConflictAnalysisRequest; }, ): Promise { - return await service._hash({ + return await context.hash({ analysisVersion: CONFLICT_ANALYSIS_VERSION, resolvedCoordinate, filters: request.toSnapshotFilterRecord(), diff --git a/src/domain/services/strand/StrandCoordinator.ts b/src/domain/services/strand/StrandCoordinator.ts index a99e101ae..0e888a3d9 100644 --- a/src/domain/services/strand/StrandCoordinator.ts +++ b/src/domain/services/strand/StrandCoordinator.ts @@ -74,6 +74,7 @@ export type StrandCoordinatorDeps = { */ type ImmutableWarpState = ReturnType; type ImmutableReceiptArray = ReturnType; +type ParentBasisMode = 'pinned' | 'live'; type MaterializedStrandResult = | ImmutableWarpState | Readonly<{ state: ImmutableWarpState; receipts: ImmutableReceiptArray }>; @@ -162,10 +163,16 @@ export default class StrandCoordinator { _hydrateOverlayMetadata(descriptor: ParsedStrandDescriptor): Promise { return this._deps.descriptors.hydrateDescriptor(descriptor); } - _collectPatchEntries(descriptor: StrandDescriptor, options: { ceiling: number | null }): Promise { + _collectPatchEntries( + descriptor: StrandDescriptor, + options: { ceiling: number | null; parentBasis?: 'pinned' | 'live' }, + ): Promise { return this._deps.materializer.collectPatchEntries(descriptor, options); } - _materializeDescriptor(descriptor: StrandDescriptor, options: { collectReceipts: boolean; ceiling: number | null }): ReturnType { + _materializeDescriptor( + descriptor: StrandDescriptor, + options: { collectReceipts: boolean; ceiling: number | null; parentBasis?: 'pinned' | 'live' }, + ): ReturnType { return this._deps.materializer.materializeDescriptor(descriptor, options); } _commitQueuedPatch(params: Parameters[0]): ReturnType { @@ -289,12 +296,24 @@ export default class StrandCoordinator { const { state, receipts } = await this._deps.materializer.materializeDescriptor(descriptor, { collectReceipts: options.receipts === true, ceiling, + parentBasis: 'live', + }); + return Object.freeze({ state, receipts }); + } + + async materializeReadState(strandId: string, options: { receipts?: boolean; ceiling?: number | null } = {}): Promise { + const descriptor = await this.getOrThrow(strandId); + const ceiling = normalizeLamportCeiling(options.ceiling); + const { state, receipts } = await this._deps.materializer.materializeDescriptor(descriptor, { + collectReceipts: options.receipts === true, + ceiling, + parentBasis: this._readParentBasisFor(descriptor), }); return Object.freeze({ state, receipts }); } async materialize(strandId: string, options: { receipts?: boolean; ceiling?: number | null } = {}): Promise { - const { state, receipts } = await this.materializeLiveState(strandId, options); + const { state, receipts } = await this.materializeReadState(strandId, options); if (options.receipts === true) { return Object.freeze({ state: createImmutableWarpStateSnapshot(state), @@ -304,6 +323,16 @@ export default class StrandCoordinator { return createImmutableWarpStateSnapshot(state); } + private _readParentBasisFor(descriptor: StrandDescriptor): ParentBasisMode { + if (descriptor.overlay.headPatchSha !== null && descriptor.overlay.headPatchSha !== undefined) { + return 'pinned'; + } + if ((descriptor.braid?.readOverlays ?? []).length > 0) { + return 'pinned'; + } + return 'live'; + } + // ── Patching (delegates) ──────────────────────────────────────── async createPatchBuilder(strandId: string): Promise { @@ -317,7 +346,10 @@ export default class StrandCoordinator { async getPatchEntries(strandId: string, options: { ceiling?: number | null } = {}): Promise { const descriptor = await this.getOrThrow(strandId); const ceiling = normalizeLamportCeiling(options.ceiling); - return await this._deps.materializer.collectPatchEntries(descriptor, { ceiling }); + return await this._deps.materializer.collectPatchEntries(descriptor, { + ceiling, + parentBasis: this._readParentBasisFor(descriptor), + }); } async patchesFor(strandId: string, entityId: string, options: { ceiling?: number | null } = {}): Promise { diff --git a/src/domain/services/strand/StrandMaterializer.ts b/src/domain/services/strand/StrandMaterializer.ts index ff5ce237e..13ad65099 100644 --- a/src/domain/services/strand/StrandMaterializer.ts +++ b/src/domain/services/strand/StrandMaterializer.ts @@ -4,8 +4,11 @@ import type Patch from '../../types/Patch.ts'; import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { StrandDescriptor } from './strandTypes.ts'; +type ParentBasisMode = 'pinned' | 'live'; + type WarpRuntime = { _loadPatchChainFromSha(sha: string): Promise>; + getFrontier?: () => Promise>; }; export default class StrandMaterializer { @@ -22,8 +25,26 @@ export default class StrandMaterializer { * Collect all base-observation patches from the pinned frontier writers. */ async collectBasePatches(descriptor: StrandDescriptor): Promise> { + return await this._collectParentBasisPatches(descriptor, 'pinned'); + } + + /** + * Collect parent-basis patches from live truth when the caller opts in. + */ + async collectParentBasisPatches( + descriptor: StrandDescriptor, + options: { parentBasis: ParentBasisMode }, + ): Promise> { + return await this._collectParentBasisPatches(descriptor, options.parentBasis); + } + + private async _collectParentBasisPatches( + descriptor: StrandDescriptor, + parentBasis: ParentBasisMode, + ): Promise> { const allPatches: Array<{ patch: Patch; sha: string }> = []; - for (const tipSha of this._sortedFrontierTipShas(descriptor)) { + const parentFrontier = await this._parentBasisFrontier(descriptor, parentBasis); + for (const tipSha of this._sortedFrontierTipShas(parentFrontier)) { const writerPatches = await this._graph._loadPatchChainFromSha(tipSha); this._pushVisibleBasePatches(allPatches, writerPatches, descriptor.baseObservation.lamportCeiling ?? null); } @@ -57,9 +78,9 @@ export default class StrandMaterializer { */ async collectPatchEntries( descriptor: StrandDescriptor, - { ceiling }: { ceiling: number | null }, + { ceiling, parentBasis = 'pinned' }: { ceiling: number | null; parentBasis?: ParentBasisMode }, ): Promise> { - const basePatches = await this.collectBasePatches(descriptor); + const basePatches = await this.collectParentBasisPatches(descriptor, { parentBasis }); const braidedOverlayPatches = await this.collectBraidedOverlayPatches(descriptor); const overlayPatches = await this.collectOverlayPatches(descriptor); const deduped = new Map(); @@ -80,19 +101,33 @@ export default class StrandMaterializer { */ async materializeDescriptor( descriptor: StrandDescriptor, - { collectReceipts, ceiling }: { collectReceipts: boolean; ceiling: number | null }, + { + collectReceipts, + ceiling, + parentBasis = 'pinned', + }: { collectReceipts: boolean; ceiling: number | null; parentBasis?: ParentBasisMode }, ): Promise<{ state: WarpState; receipts: TickReceipt[]; allPatches: Array<{ patch: Patch; sha: string }>; }> { - const allPatches = await this.collectPatchEntries(descriptor, { ceiling }); + const allPatches = await this.collectPatchEntries(descriptor, { ceiling, parentBasis }); const { state, receipts } = this._reduceCollectedPatches(allPatches, collectReceipts); return { state, receipts, allPatches }; } - private _sortedFrontierTipShas(descriptor: StrandDescriptor): string[] { - return Object.entries(descriptor.baseObservation.frontier) + private async _parentBasisFrontier( + descriptor: StrandDescriptor, + parentBasis: ParentBasisMode, + ): Promise> { + if (parentBasis === 'pinned' || this._graph.getFrontier === undefined) { + return descriptor.baseObservation.frontier; + } + return Object.fromEntries(await this._graph.getFrontier()); + } + + private _sortedFrontierTipShas(frontier: Record): string[] { + return Object.entries(frontier) .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([, tipSha]) => tipSha) .filter((tipSha): tipSha is string => isNonEmptyString(tipSha)); diff --git a/src/domain/services/strand/conflictCandidateAnalysis.ts b/src/domain/services/strand/conflictCandidateAnalysis.ts index fe6f5fb1b..c1ea8a8d6 100644 --- a/src/domain/services/strand/conflictCandidateAnalysis.ts +++ b/src/domain/services/strand/conflictCandidateAnalysis.ts @@ -13,10 +13,10 @@ import ConflictDiagnostic, { type ConflictDiagnosticData } from '../../types/con import ConflictResolution from '../../types/conflict/ConflictResolution.ts'; import { type TickReceipt, type OpOutcome } from '../../types/TickReceipt.ts'; import type Patch from '../../types/Patch.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; import type ConflictTarget from '../../types/conflict/ConflictTarget.ts'; import ConflictCandidate from './ConflictCandidate.ts'; import OpRecord from './OpRecord.ts'; +import type ConflictPipelineContext from './ConflictPipelineContext.ts'; import { receiptNameForOp, effectKey, @@ -86,10 +86,6 @@ export interface CollectorState { candidates: ConflictCandidate[]; } -interface HashingService { - _hash(payload: HashablePayload): Promise; -} - // ── Diagnostics ────────────────────────────────────────────────────── /** @@ -206,16 +202,16 @@ type AnalyzeOneOpResult = { record: OpRecord | null; nextReceiptOpIndex: number /** Resolves target and effectDigest; emits diagnostics on failure. */ async function resolveTarget( - service: HashingService, + pipeline: ConflictPipelineContext, params: BuildOpRecordParams, ): Promise { const { frame, opIndex, canonOp, receiptOutcome, receiptOpType, diagnostics } = params; - const target = await buildConflictTarget(service, { canonOp, receiptTarget: receiptOutcome.target }); + const target = await buildConflictTarget(pipeline, { canonOp, receiptTarget: receiptOutcome.target }); if (target === null) { pushRecordDiagnostic(diagnostics, { code: 'anchor_incomplete', messagePrefix: 'Target identity unavailable', frame, opIndex }); return null; } - const effectDigest = await buildEffectDigest(service, { target, receiptOpType, canonOp }); + const effectDigest = await buildEffectDigest(pipeline, { target, receiptOpType, canonOp }); if (typeof effectDigest !== 'string' || effectDigest.length === 0) { pushRecordDiagnostic(diagnostics, { code: 'digest_unavailable', messagePrefix: 'Effect payload unavailable', frame, opIndex }); return null; @@ -227,10 +223,10 @@ async function resolveTarget( * Builds a full OpRecord from a canonical op, its receipt outcome, and frame context. */ export async function buildOpRecord( - service: HashingService, + pipeline: ConflictPipelineContext, params: BuildOpRecordParams, ): Promise { - const identity = await resolveTarget(service, params); + const identity = await resolveTarget(pipeline, params); if (identity === null) { return null; } const { frame, opIndex, receiptOpIndex, receiptOutcome, receiptOpType } = params; const { patch, sha, context, patchOrder } = frame; @@ -267,7 +263,7 @@ function handleMissingReceipt( * Analyzes a single operation within a frame. */ export async function analyzeOneOp( - service: HashingService, + pipeline: ConflictPipelineContext, { frame, opIndex, receiptOpIndex, receipt, diagnostics }: AnalyzeOneOpParams, ): Promise { const rawOp = frame.patch.ops[opIndex]; @@ -279,7 +275,7 @@ export async function analyzeOneOp( if (receiptOutcome === undefined || receiptOutcome === null) { return handleMissingReceipt(diagnostics, { frame, opIndex, receiptOpIndex }); } - const record = await buildOpRecord(service, { + const record = await buildOpRecord(pipeline, { frame, opIndex, receiptOpIndex, canonOp, receiptOutcome, receiptOpType, diagnostics, }); return { record, nextReceiptOpIndex: receiptOpIndex + 1 }; @@ -479,13 +475,13 @@ type AnalyzeFrameOpsParams = { * Analyzes all operations in a single patch frame. */ export async function analyzeFrameOps( - service: HashingService, + pipeline: ConflictPipelineContext, { frame, scannedPatchShas, diagnostics, collector }: AnalyzeFrameOpsParams, ): Promise { const { patch, receipt, sha } = frame; let receiptOpIndex = 0; for (let opIndex = 0; opIndex < patch.ops.length; opIndex++) { - const result = await analyzeOneOp(service, { frame, opIndex, receiptOpIndex, receipt, diagnostics }); + const result = await analyzeOneOp(pipeline, { frame, opIndex, receiptOpIndex, receipt, diagnostics }); if (result === null) { continue; } receiptOpIndex = result.nextReceiptOpIndex; if (result.record !== null) { diff --git a/src/domain/services/strand/conflictTargetIdentity.ts b/src/domain/services/strand/conflictTargetIdentity.ts index 76c66bec9..df2c8cb31 100644 --- a/src/domain/services/strand/conflictTargetIdentity.ts +++ b/src/domain/services/strand/conflictTargetIdentity.ts @@ -8,7 +8,6 @@ */ import ConflictTarget from '../../types/conflict/ConflictTarget.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; import { normalizeConflictOp, receiptNameForOp, @@ -22,6 +21,7 @@ import ConflictEffectPayload, { } from './ConflictEffectPayload.ts'; import type { ConflictTargetIdentity } from './ConflictTargetIdentityModels.ts'; import ConflictTargetResolver from './ConflictTargetResolver.ts'; +import type ConflictPipelineContext from './ConflictPipelineContext.ts'; export { effectKey, @@ -52,12 +52,8 @@ export function buildTargetIdentity(canonOp: CanonicalOpBlob, receiptTarget: str return ConflictTargetResolver.resolve(canonOp, receiptTarget); } -type HashingService = { - _hash(payload: HashablePayload): Promise; -}; - export async function buildConflictTarget( - service: HashingService, + context: ConflictPipelineContext, { canonOp, receiptTarget }: { canonOp: CanonicalOpBlob; receiptTarget: string }, ): Promise { const targetIdentity = buildTargetIdentity(canonOp, receiptTarget); @@ -66,12 +62,12 @@ export async function buildConflictTarget( } return new ConflictTarget({ ...targetIdentity, - targetDigest: await service._hash(targetIdentity), + targetDigest: await context.hash(targetIdentity), }); } export async function buildEffectDigest( - service: HashingService, + context: ConflictPipelineContext, { target, receiptOpType, @@ -82,5 +78,5 @@ export async function buildEffectDigest( if (effectPayload === null) { return null; } - return await service._hash(buildEffectPayload(target, receiptOpType, effectPayload)); + return await context.hash(buildEffectPayload(target, receiptOpType, effectPayload)); } diff --git a/src/domain/services/sync/HttpSyncServerHelpers.ts b/src/domain/services/sync/HttpSyncServerHelpers.ts index 8105966bd..f4d9ebdce 100644 --- a/src/domain/services/sync/HttpSyncServerHelpers.ts +++ b/src/domain/services/sync/HttpSyncServerHelpers.ts @@ -8,7 +8,7 @@ * @module domain/services/sync/HttpSyncServerHelpers */ -import { z } from 'zod'; +import z from 'zod'; import SyncAuthService from './SyncAuthService.ts'; import SyncError from '../../errors/SyncError.ts'; import { validateSyncRequest } from './SyncPayloadSchema.ts'; diff --git a/src/domain/services/sync/SyncAuthService.ts b/src/domain/services/sync/SyncAuthService.ts index cd40fb64d..272041700 100644 --- a/src/domain/services/sync/SyncAuthService.ts +++ b/src/domain/services/sync/SyncAuthService.ts @@ -23,6 +23,8 @@ import SyncRateLimiter, { type SyncRateLimitConfig } from './SyncRateLimiter.ts' const SIG_VERSION = '2'; const SIG_PREFIX = 'warp-v2'; const HMAC_ALGO = 'sha256'; +export const SYNC_AUTH_SCHEME_HEADER = 'x-warp-auth-scheme'; +export const SHARED_SECRET_HMAC_SYNC_AUTH_SCHEME = 'shared-secret-hmac-sha256'; const DEFAULT_NONCE_CAPACITY = 100_000; const NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const SIG_HEX_LENGTH = 64; @@ -31,6 +33,7 @@ const MAX_TIMESTAMP_DIGITS = 16; type FailResult = { ok: false; reason: string; status: number }; type OkResult = { ok: true }; +export type SyncAuthScheme = typeof SHARED_SECRET_HMAC_SYNC_AUTH_SCHEME; /** * Canonicalizes a URL path for signature computation. @@ -44,6 +47,7 @@ export function canonicalizePath(url: string): string { * Builds the canonical string that gets signed. */ export function buildCanonicalPayload(params: { + authScheme?: SyncAuthScheme; keyId: string; method: string; path: string; @@ -52,23 +56,26 @@ export function buildCanonicalPayload(params: { contentType: string; bodySha256: string; }): string { - const { keyId, method, path, timestamp, nonce, contentType, bodySha256 } = params; - return `${SIG_PREFIX}|${keyId}|${method}|${path}|${timestamp}|${nonce}|${contentType}|${bodySha256}`; + const { authScheme, keyId, method, path, timestamp, nonce, contentType, bodySha256 } = params; + const prefix = authScheme === undefined ? SIG_PREFIX : `${SIG_PREFIX}|${authScheme}`; + return `${prefix}|${keyId}|${method}|${path}|${timestamp}|${nonce}|${contentType}|${bodySha256}`; } /** * Signs an outgoing sync request. */ export async function signSyncRequest( - params: { method: string; path: string; contentType: string; body: Uint8Array; secret: SyncSecret; keyId: string; lamport: number }, + params: { method: string; path: string; contentType: string; body: Uint8Array; secret: SyncSecret; keyId: string; lamport: number; authScheme?: SyncAuthScheme }, deps: { crypto?: CryptoPort } = {}, ): Promise> { const c = deps.crypto ?? defaultCrypto; const timestamp = String(params.lamport); const nonce = globalThis.crypto.randomUUID(); + const authScheme = params.authScheme ?? SHARED_SECRET_HMAC_SYNC_AUTH_SCHEME; const bodySha256 = await c.hash('sha256', params.body); const canonical = buildCanonicalPayload({ + authScheme, keyId: params.keyId, method: params.method.toUpperCase(), path: params.path, @@ -82,6 +89,7 @@ export async function signSyncRequest( const signature = hexEncode(hmacBuf); return { + [SYNC_AUTH_SCHEME_HEADER]: authScheme, 'x-warp-sig-version': SIG_VERSION, 'x-warp-key-id': params.keyId, 'x-warp-timestamp': timestamp, @@ -131,6 +139,19 @@ function _checkHeaderFormats(timestamp: string, nonce: string, signature: string return { ok: true }; } +function _validateAuthScheme( + headers: Record, +): FailResult | (OkResult & { authScheme: SyncAuthScheme | null }) { + const authScheme = headers[SYNC_AUTH_SCHEME_HEADER]; + if (authScheme === undefined) { + return { ok: true, authScheme: null }; + } + if (authScheme === SHARED_SECRET_HMAC_SYNC_AUTH_SCHEME) { + return { ok: true, authScheme }; + } + return fail('UNSUPPORTED_AUTH_SCHEME', 400); +} + function _validateKeys(keys: Record | undefined): asserts keys is Record { if (!keys || typeof keys !== 'object' || Object.keys(keys).length === 0) { throw new SyncError( @@ -201,7 +222,17 @@ export default class SyncAuthService { return this._mode; } - private _validateHeaders(headers: Record): FailResult | (OkResult & { sigVersion: string; signature: string; timestamp: string; nonce: string; keyId: string }) { + private _validateHeaders(headers: Record): FailResult | (OkResult & { + sigVersion: string; + signature: string; + timestamp: string; + nonce: string; + keyId: string; + authScheme: SyncAuthScheme | null; + }) { + const authSchemeResult = _validateAuthScheme(headers); + if (!authSchemeResult.ok) { return authSchemeResult; } + const sigVersion = headers['x-warp-sig-version']; if (sigVersion !== SIG_VERSION) { return fail('INVALID_VERSION', 400); } @@ -217,7 +248,7 @@ export default class SyncAuthService { const formatCheck = _checkHeaderFormats(timestamp, nonce, signature); if (!formatCheck.ok) { return formatCheck; } - return { ok: true, sigVersion, signature, timestamp, nonce, keyId }; + return { ok: true, sigVersion, signature, timestamp, nonce, keyId, authScheme: authSchemeResult.authScheme }; } private _validateFreshness(timestamp: string, keyId: string): FailResult | OkResult { @@ -273,14 +304,16 @@ export default class SyncAuthService { keyId: string; timestamp: string; nonce: string; + authScheme: SyncAuthScheme | null; }): Promise { - const { request, secret, keyId, timestamp, nonce } = params; + const { request, secret, keyId, timestamp, nonce, authScheme } = params; const body = request.body ?? new Uint8Array(0); const bodySha256 = await this._crypto.hash('sha256', body); const contentType = request.headers['content-type'] !== undefined ? request.headers['content-type'] : ''; const path = canonicalizePath(request.url); const canonical = buildCanonicalPayload({ + ...(authScheme !== null ? { authScheme } : {}), keyId, method: request.method.toUpperCase(), path, @@ -323,7 +356,7 @@ export default class SyncAuthService { return this._fail('header validation failed', { reason: headerResult.reason }, headerResult); } - const { timestamp, nonce, keyId } = headerResult; + const { timestamp, nonce, keyId, authScheme } = headerResult; const freshnessResult = this._validateFreshness(timestamp, keyId); if (!freshnessResult.ok) { @@ -336,7 +369,7 @@ export default class SyncAuthService { } const sigResult = await this._verifySignature({ - request, secret: keyResult.secret, keyId, timestamp, nonce, + request, secret: keyResult.secret, keyId, timestamp, nonce, authScheme, }); if (!sigResult.ok) { return this._fail('signature mismatch', { keyId }, sigResult); diff --git a/src/domain/services/sync/SyncPayloadSchema.ts b/src/domain/services/sync/SyncPayloadSchema.ts index 96bd032b9..262912c9e 100644 --- a/src/domain/services/sync/SyncPayloadSchema.ts +++ b/src/domain/services/sync/SyncPayloadSchema.ts @@ -9,7 +9,7 @@ * @see B64 -- Sync ingress payload validation */ -import { z } from 'zod'; +import z from 'zod'; // ── Resource Limits ───────────────────────────────────────────────────────── @@ -100,12 +100,38 @@ function patchEntrySchema(limits: SyncPayloadLimits): z.ZodObject }); } +function syncRequestPageSchema(limits: SyncPayloadLimits): z.ZodObject { + return z.object({ + maxPatches: z.number().int().min(1).max(limits.maxPatches), + cursor: z.string().min(1).nullable().optional(), + }).strict(); +} + +function syncResponsePageSchema(): z.ZodObject { + return z.object({ + maxPatches: z.number().int().min(1).nullable(), + cursor: z.string().min(1).nullable(), + hasMore: z.boolean(), + returnedPatches: z.number().int().min(0), + }).strict(); +} + +function syncResponseMetricsSchema(): z.ZodObject { + return z.object({ + patchCount: z.number().int().min(0), + skippedWriterCount: z.number().int().min(0), + estimatedPayloadBytes: z.number().int().min(0), + latencyMs: z.number().min(0).nullable(), + }).strict(); +} + // ── Sync Request Schema ───────────────────────────────────────────────────── export function createSyncRequestSchema(limits: SyncPayloadLimits = DEFAULT_LIMITS): z.ZodType { return z.object({ type: z.literal('sync-request'), frontier: frontierSchema(limits.maxWritersInFrontier), + page: syncRequestPageSchema(limits).optional(), }).strict(); } @@ -118,6 +144,8 @@ export function createSyncResponseSchema(limits: SyncPayloadLimits = DEFAULT_LIM type: z.literal('sync-response'), frontier: frontierSchema(limits.maxWritersInFrontier), patches: z.array(patchEntrySchema(limits)).max(limits.maxPatches), + page: syncResponsePageSchema().optional(), + metrics: syncResponseMetricsSchema().optional(), }).passthrough(); } @@ -125,6 +153,15 @@ const SyncResponseSchema = createSyncResponseSchema(); // ── Validation Helpers ────────────────────────────────────────────────────── +type ValidatedSyncRequestPayload = { + type: 'sync-request'; + frontier: Record; + page?: { + maxPatches: number; + cursor?: string | null; + }; +}; + function normalizePayloadFrontier(payload: unknown): string | null { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B if (!isPlainObject(payload)) { return null; } const p = payload as Record; // nosemgrep: ts-no-record-string-unknown-outside-adapters -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B @@ -143,7 +180,7 @@ function normalizePayloadFrontier(payload: unknown): string | null { // nosemgre export function validateSyncRequest( payload: unknown, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B limits: SyncPayloadLimits = DEFAULT_LIMITS, -): { ok: true; value: { type: 'sync-request'; frontier: Record } } | { ok: false; error: string } { +): { ok: true; value: ValidatedSyncRequestPayload } | { ok: false; error: string } { const frontierErr = normalizePayloadFrontier(payload); if (frontierErr !== null) { return { ok: false, error: frontierErr }; @@ -154,7 +191,7 @@ export function validateSyncRequest( if (!result.success) { return { ok: false, error: result.error.message }; } - return { ok: true, value: result.data as { type: 'sync-request'; frontier: Record } }; + return { ok: true, value: result.data as ValidatedSyncRequestPayload }; } /** diff --git a/src/domain/services/sync/SyncProtocol.ts b/src/domain/services/sync/SyncProtocol.ts index 2f774a970..100d57445 100644 --- a/src/domain/services/sync/SyncProtocol.ts +++ b/src/domain/services/sync/SyncProtocol.ts @@ -18,10 +18,14 @@ export { computeSyncDelta, syncNeeded } from './syncDelta.ts'; export type { SyncRequest, + SyncRequestPage, SyncResponse, + SyncResponsePage, + SyncResponseMetrics, SyncPatchEntry, SkippedWriterEntry, ProcessSyncRequestOptions, + CreateSyncRequestOptions, ApplySyncResponseResult, } from './syncRequestResponse.ts'; export { diff --git a/src/domain/services/sync/SyncResponsePaging.ts b/src/domain/services/sync/SyncResponsePaging.ts new file mode 100644 index 000000000..10416a8fb --- /dev/null +++ b/src/domain/services/sync/SyncResponsePaging.ts @@ -0,0 +1,194 @@ +import SyncError from '../../errors/SyncError.ts'; +import type LoggerPort from '../../../ports/LoggerPort.ts'; +import type { DecodedPatch } from './syncPatchLoader.ts'; + +/** Page request for bounded sync responses. */ +export type SyncRequestPage = { + readonly maxPatches: number; + readonly cursor?: string | null; +}; + +/** Page metadata returned with a sync response. */ +export type SyncResponsePage = { + readonly maxPatches: number | null; + readonly cursor: string | null; + readonly hasMore: boolean; + readonly returnedPatches: number; +}; + +/** Deterministic response-shaping metrics for sync operators. */ +export type SyncResponseMetrics = { + readonly patchCount: number; + readonly skippedWriterCount: number; + readonly estimatedPayloadBytes: number; + readonly latencyMs: number | null; +}; + +export type CreateSyncRequestOptions = { + readonly page?: SyncRequestPage; +}; + +export type NormalizedSyncPageRequest = { + readonly maxPatches: number | null; + readonly cursorOffset: number; +}; + +export type SyncPagePatchEntry = { + readonly writerId: string; + readonly sha: string; + readonly patch: DecodedPatch; +}; + +export type SyncPageSkippedWriterEntry = { + readonly writerId: string; + readonly reason: string; + readonly localSha: string; + readonly remoteSha: string | null; +}; + +type PageAppendOptions = { + readonly patches: SyncPagePatchEntry[]; + readonly entry: SyncPagePatchEntry; + readonly page: NormalizedSyncPageRequest; + readonly seenPatches: number; +}; + +export type PageAppendResult = { + readonly seenPatches: number; + readonly hasMore: boolean; + readonly cursor: string | null; +}; + +export type SyncResponsePayloadEstimateInput = { + readonly frontier: Record; + readonly patches: readonly SyncPagePatchEntry[]; + readonly skippedWriters: readonly SyncPageSkippedWriterEntry[]; + readonly page: SyncResponsePage; +}; + +export type SyncResponseMetricsLogInput = { + readonly logger: LoggerPort; + readonly graphName: string; + readonly page: SyncResponsePage; + readonly metrics: SyncResponseMetrics; +}; + +export function normalizeSyncPageRequest(page: SyncRequestPage | undefined): NormalizedSyncPageRequest { + if (page === undefined) { + return { maxPatches: null, cursorOffset: 0 }; + } + return { + maxPatches: requirePositiveMaxPatches(page.maxPatches), + cursorOffset: cursorOffsetFor(page.cursor ?? null), + }; +} + +export function appendPatchForPage(options: PageAppendOptions): PageAppendResult { + if (options.seenPatches < options.page.cursorOffset) { + return { seenPatches: options.seenPatches + 1, hasMore: false, cursor: null }; + } + if (options.page.maxPatches !== null && options.patches.length >= options.page.maxPatches) { + return { seenPatches: options.seenPatches, hasMore: true, cursor: options.seenPatches.toString() }; + } + options.patches.push(options.entry); + return { seenPatches: options.seenPatches + 1, hasMore: false, cursor: null }; +} + +export function normalizeObservedLatencyMs(value: number | undefined): number | null { + if (value === undefined) { + return null; + } + if (Number.isFinite(value) && value >= 0) { + return value; + } + throw new SyncError('Sync response observed latency must be non-negative when provided', { + code: 'E_SYNC_METRICS_INVALID', + context: { field: 'observedLatencyMs', value }, + }); +} + +export function estimateResponsePayloadBytes(input: SyncResponsePayloadEstimateInput): number { + let bytes = 'sync-response'.length + estimateFrontierBytes(input.frontier); + for (const entry of input.patches) { + bytes += entry.writerId.length + entry.sha.length + estimatePatchBytes(entry.patch); + } + bytes += estimateSkippedWriterBytes(input.skippedWriters); + bytes += (input.page.cursor ?? '').length + String(input.page.maxPatches ?? '').length; + bytes += String(input.page.hasMore).length + input.page.returnedPatches.toString().length; + return bytes; +} + +export function logResponseMetrics(input: SyncResponseMetricsLogInput): void { + input.logger.info('Sync response metrics', { + code: 'SYNC_RESPONSE_METRICS', + graphName: input.graphName, + patchCount: input.metrics.patchCount, + skippedWriterCount: input.metrics.skippedWriterCount, + estimatedPayloadBytes: input.metrics.estimatedPayloadBytes, + latencyMs: input.metrics.latencyMs, + syncResponseCursor: input.page.cursor, + syncResponseHasMore: input.page.hasMore, + syncResponseMaxPatches: input.page.maxPatches, + }); +} + +function requirePositiveMaxPatches(value: number): number { + if (Number.isInteger(value) && value > 0) { + return value; + } + throw new SyncError('Sync response page maxPatches must be a positive integer', { + code: 'E_SYNC_PAGING_INVALID', + context: { field: 'maxPatches', value }, + }); +} + +function cursorOffsetFor(cursor: string | null): number { + if (cursor === null) { + return 0; + } + const cursorOffset = Number.parseInt(cursor, 10); + if (Number.isInteger(cursorOffset) && cursorOffset >= 0 && cursorOffset.toString() === cursor) { + return cursorOffset; + } + throw new SyncError('Sync response page cursor must be a non-negative integer string', { + code: 'E_SYNC_PAGING_INVALID', + context: { field: 'cursor', value: cursor }, + }); +} + +function estimateFrontierBytes(frontier: Record): number { + let bytes = 0; + for (const [writerId, sha] of Object.entries(frontier)) { + bytes += writerId.length + sha.length; + } + return bytes; +} + +function estimateStringArrayBytes(values: readonly string[] | undefined): number { + if (values === undefined) { + return 0; + } + return values.reduce((total, value) => total + value.length, 0); +} + +function estimatePatchBytes(patch: DecodedPatch): number { + let bytes = patch.writer.length + patch.lamport.toString().length + patch.ops.length; + if (patch.schema !== undefined) { + bytes += patch.schema.toString().length; + } + for (const op of patch.ops) { + bytes += op.type.length; + } + bytes += estimateStringArrayBytes(patch.reads); + bytes += estimateStringArrayBytes(patch.writes); + return bytes; +} + +function estimateSkippedWriterBytes(skippedWriters: readonly SyncPageSkippedWriterEntry[]): number { + let bytes = 0; + for (const skipped of skippedWriters) { + bytes += skipped.writerId.length + skipped.reason.length + skipped.localSha.length; + bytes += skipped.remoteSha === null ? 0 : skipped.remoteSha.length; + } + return bytes; +} diff --git a/src/domain/services/sync/syncRequestResponse.ts b/src/domain/services/sync/syncRequestResponse.ts index 8a040c885..2304f9492 100644 --- a/src/domain/services/sync/syncRequestResponse.ts +++ b/src/domain/services/sync/syncRequestResponse.ts @@ -17,12 +17,30 @@ import SyncError from '../../errors/SyncError.ts'; import { cloneFrontier, updateFrontier } from '../Frontier.ts'; import { computeSyncDelta } from './syncDelta.ts'; import { normalizePatch, loadPatchRange, type DecodedPatch } from './syncPatchLoader.ts'; +import { + appendPatchForPage, + estimateResponsePayloadBytes, + logResponseMetrics, + normalizeObservedLatencyMs, + normalizeSyncPageRequest, + type CreateSyncRequestOptions, + type SyncRequestPage, + type SyncResponseMetrics, + type SyncResponsePage, +} from './SyncResponsePaging.ts'; import type WarpState from '../state/WarpState.ts'; import type CommitPort from '../../../ports/CommitPort.ts'; import type BlobPort from '../../../ports/BlobPort.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; +export type { + CreateSyncRequestOptions, + SyncRequestPage, + SyncResponseMetrics, + SyncResponsePage, +} from './SyncResponsePaging.ts'; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -42,6 +60,8 @@ export interface SyncRequest { * Converted from Map for JSON serialization. */ frontier: Record; + /** Optional page budget for bounded sync responses. */ + page?: SyncRequestPage; } /** A patch entry in a sync response. */ @@ -77,11 +97,16 @@ export interface SyncResponse { patches: SyncPatchEntry[]; /** Writers that were skipped during sync */ skippedWriters?: SkippedWriterEntry[]; + /** Page metadata for bounded responses */ + page?: SyncResponsePage; + /** Response-shaping metrics */ + metrics?: SyncResponseMetrics; } export interface ProcessSyncRequestOptions { patchJournal?: PatchJournalPort; logger?: LoggerPort; + observedLatencyMs?: number; } export interface ApplySyncResponseResult { @@ -131,10 +156,17 @@ function objectToFrontier(obj: Record): Map { * const request = createSyncRequest(frontier); * // { type: 'sync-request', frontier: { w1: 'sha-a', w2: 'sha-b' } } */ -export function createSyncRequest(frontier: Map): SyncRequest { +export function createSyncRequest( + frontier: Map, + options: CreateSyncRequestOptions = {}, +): SyncRequest { + const page = options.page !== undefined ? normalizeSyncPageRequest(options.page) : null; return { type: 'sync-request', frontier: frontierToObject(frontier), + ...(options.page !== undefined + ? { page: { maxPatches: page?.maxPatches ?? options.page.maxPatches, cursor: options.page.cursor ?? null } } + : {}), }; } @@ -172,11 +204,13 @@ export async function processSyncRequest( localFrontier: Map, persistence: CommitPort & BlobPort, graphName: string, - { patchJournal, logger }: ProcessSyncRequestOptions = {}, + { patchJournal, logger, observedLatencyMs }: ProcessSyncRequestOptions = {}, ): Promise { const log = logger ?? nullLogger; const remoteFrontier = objectToFrontier(request.frontier); + const pageRequest = normalizeSyncPageRequest(request.page); + const latencyMs = normalizeObservedLatencyMs(observedLatencyMs); // Compute what the requester needs const delta = computeSyncDelta(remoteFrontier, localFrontier); @@ -184,8 +218,17 @@ export async function processSyncRequest( // Load patches that the requester needs (from local to requester) const patches: SyncPatchEntry[] = []; const skippedWriters: SkippedWriterEntry[] = []; + let seenPatches = 0; + let hasMore = false; + let cursor: string | null = null; + + const writerRanges = [...delta.needFromRemote.entries()] + .sort(([left], [right]) => left.localeCompare(right)); - for (const [writerId, range] of delta.needFromRemote) { + for (const [writerId, range] of writerRanges) { + if (hasMore) { + break; + } try { // Pre-check ancestry to avoid expensive chain walk (B107 / S3 fix). // If the persistence layer provides isAncestor, use it to detect @@ -215,14 +258,36 @@ export async function processSyncRequest( if (patchJournal !== undefined && patchJournal !== null && typeof patchJournal.scanPatchRange === 'function') { const stream = patchJournal.scanPatchRange(writerId, range.from, range.to); for await (const entry of stream) { - patches.push({ writerId, sha: entry.sha, patch: entry.patch }); + const control = appendPatchForPage({ + patches, + entry: { writerId, sha: entry.sha, patch: entry.patch }, + page: pageRequest, + seenPatches, + }); + seenPatches = control.seenPatches; + hasMore = control.hasMore; + cursor = control.cursor; + if (hasMore) { + break; + } } } else { const writerPatches = await loadPatchRange( persistence, graphName, writerId, range.from, range.to, patchJournal !== undefined ? { patchJournal } : {}, ); for (const { patch, sha } of writerPatches) { - patches.push({ writerId, sha, patch }); + const control = appendPatchForPage({ + patches, + entry: { writerId, sha, patch }, + page: pageRequest, + seenPatches, + }); + seenPatches = control.seenPatches; + hasMore = control.hasMore; + cursor = control.cursor; + if (hasMore) { + break; + } } } } catch (err) { @@ -250,11 +315,38 @@ export async function processSyncRequest( } } + const frontier = frontierToObject(localFrontier); + const page: SyncResponsePage = { + maxPatches: pageRequest.maxPatches, + cursor: hasMore ? cursor : null, + hasMore, + returnedPatches: patches.length, + }; + const metrics: SyncResponseMetrics = { + patchCount: patches.length, + skippedWriterCount: skippedWriters.length, + estimatedPayloadBytes: estimateResponsePayloadBytes({ + frontier, + patches, + skippedWriters, + page, + }), + latencyMs, + }; + logResponseMetrics({ + logger: log, + graphName, + page, + metrics, + }); + return { type: 'sync-response', - frontier: frontierToObject(localFrontier), + frontier, patches, skippedWriters, + page, + metrics, }; } diff --git a/src/domain/trust/schemas.ts b/src/domain/trust/schemas.ts index 4b3bec92b..80adbc545 100644 --- a/src/domain/trust/schemas.ts +++ b/src/domain/trust/schemas.ts @@ -9,7 +9,7 @@ * @see docs/specs/TRUST_CRYPTO_ALGORITHM.md Sections 8-10, 14 */ -import { z } from 'zod'; +import z from 'zod'; // -- Primitives --------------------------------------------------------------- @@ -60,17 +60,22 @@ const WriterBindRevokeSubjectSchema = z.object({ reasonCode: z.enum(['ACCESS_REMOVED', 'ROTATION', 'KEY_REVOKED']), }); +// -- Inferred subject types (runtime truth via Zod) --------------------------- + +type KeyAddSubject = z.infer; +type KeyRevokeSubject = z.infer; +type WriterBindAddSubject = z.infer; +type WriterBindRevokeSubject = z.infer; +type TrustRecordSubject = + | KeyAddSubject + | KeyRevokeSubject + | WriterBindAddSubject + | WriterBindRevokeSubject; + // -- Subject dispatch map (DRY: one lookup, not four identical functions) ------ type RecordType = z.infer; -const SUBJECT_SCHEMAS: Record = { - KEY_ADD: KeyAddSubjectSchema, - KEY_REVOKE: KeyRevokeSubjectSchema, - WRITER_BIND_ADD: WriterBindAddSubjectSchema, - WRITER_BIND_REVOKE: WriterBindRevokeSubjectSchema, -}; - // -- Trust record envelope ---------------------------------------------------- const TrustRecordSchema = z.object({ @@ -83,19 +88,48 @@ const TrustRecordSchema = z.object({ subject: z.record(z.unknown()), // nosemgrep: ts-no-unknown-outside-adapters -- 0025B meta: z.record(z.unknown()).optional().default({}), // nosemgrep: ts-no-unknown-outside-adapters -- 0025B signature: TrustSignatureSchema, -}).superRefine((record, ctx) => { - const schema = SUBJECT_SCHEMAS[record.recordType]; - const result = schema.safeParse(record.subject); - if (!result.success) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid ${record.recordType} subject: ${result.error.message}`, - }); - } else { - record.subject = result.data as Record; // nosemgrep: ts-no-record-string-unknown-outside-adapters -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B - } +}).transform((record, ctx) => { + const subject = parseSubject(record.recordType, record.subject, ctx); + return { + ...record, + subject, + }; }); +function parseSubject( + recordType: RecordType, + subject: Record, // nosemgrep: ts-no-record-string-unknown-outside-adapters -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B + ctx: z.RefinementCtx, +): TrustRecordSubject { + if (recordType === 'KEY_ADD') { + return parseSpecificSubject({ schema: KeyAddSubjectSchema, recordType, subject, ctx }); + } + if (recordType === 'KEY_REVOKE') { + return parseSpecificSubject({ schema: KeyRevokeSubjectSchema, recordType, subject, ctx }); + } + if (recordType === 'WRITER_BIND_ADD') { + return parseSpecificSubject({ schema: WriterBindAddSubjectSchema, recordType, subject, ctx }); + } + return parseSpecificSubject({ schema: WriterBindRevokeSubjectSchema, recordType, subject, ctx }); +} + +function parseSpecificSubject(params: { + readonly schema: z.ZodType; + readonly recordType: RecordType; + readonly subject: Record; // nosemgrep: ts-no-record-string-unknown-outside-adapters -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B + readonly ctx: z.RefinementCtx; +}): T { + const result = params.schema.safeParse(params.subject); + if (result.success) { + return result.data; + } + params.ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid ${params.recordType} subject: ${result.error.message}`, + }); + return z.NEVER; +} + // -- Policy config ------------------------------------------------------------ const TrustPolicySchema = z.object({ @@ -136,12 +170,6 @@ const TrustAssessmentSchema = z.object({ }), }); -// -- Inferred subject types (runtime truth via Zod) --------------------------- - -type KeyAddSubject = z.infer; -type KeyRevokeSubject = z.infer; -type WriterBindAddSubject = z.infer; -type WriterBindRevokeSubject = z.infer; type TrustSignature = z.infer; type TrustPolicy = z.infer; type TrustExplanation = z.infer; diff --git a/src/domain/types/Aperture.ts b/src/domain/types/Aperture.ts index ed81109b3..90310740b 100644 --- a/src/domain/types/Aperture.ts +++ b/src/domain/types/Aperture.ts @@ -11,6 +11,8 @@ export interface Aperture { expose?: string[]; /** Property keys to exclude (blacklist). Takes precedence over expose. */ redact?: string[]; + /** Native observer distinctions for structural accumulation and emission. */ + basis?: string[]; } /** Legacy compatibility alias for Aperture. */ diff --git a/src/domain/types/DeliveryObservation.ts b/src/domain/types/DeliveryObservation.ts index 9f25083dc..728e4608f 100644 --- a/src/domain/types/DeliveryObservation.ts +++ b/src/domain/types/DeliveryObservation.ts @@ -11,6 +11,8 @@ */ import WarpError from '../errors/WarpError.ts'; +import { sortedReplacer } from '../utils/canonicalStringify.ts'; +import { requireNonEmptyString, validateTimestamp } from '../utils/scalarValidation.ts'; import { validateOutcome, DELIVERY_MODES, type ExternalizationPolicy, type DeliveryOutcome } from './ExternalizationPolicy.ts'; const modeSet = new Set(DELIVERY_MODES); @@ -73,24 +75,6 @@ export class DeliveryObservation { // Validation // ============================================================================ -/** - * Asserts that a value is a non-empty string, throwing if it is not. - */ -function requireNonEmptyString(value: string, name: string): void { - if (value.length === 0) { - throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); - } -} - -/** - * Asserts that a timestamp is a non-negative finite number. - */ -function validateTimestamp(value: number): void { - if (!Number.isFinite(value) || value < 0) { - throw new WarpError('timestamp must be a non-negative finite number', 'E_VALIDATION'); - } -} - /** * Asserts that a lens is a non-null object. */ @@ -151,23 +135,6 @@ export function createDeliveryObservation({ emissionId, sinkId, outcome, reason, // Canonical JSON // ============================================================================ -/** - * JSON.stringify replacer that sorts object keys alphabetically for deterministic output. - */ -function sortedReplacer(_key: string, value: Record | string | number | boolean | null): Record | string | number | boolean | null { - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - const sorted: Record = {}; - for (const k of Object.keys(value).sort()) { - const v = value[k]; - if (v !== undefined) { - sorted[k] = v; - } - } - return sorted; - } - return value; -} - /** * Produces a deterministic JSON string for a DeliveryObservation. */ diff --git a/src/domain/types/EffectEmission.ts b/src/domain/types/EffectEmission.ts index 86b697ec9..6fcd598ba 100644 --- a/src/domain/types/EffectEmission.ts +++ b/src/domain/types/EffectEmission.ts @@ -13,6 +13,8 @@ */ import WarpError from '../errors/WarpError.ts'; +import { sortedReplacer } from '../utils/canonicalStringify.ts'; +import { requireNonEmptyString, validateTimestamp } from '../utils/scalarValidation.ts'; import { DELIVERY_MODES, DELIVERY_OUTCOMES } from './ExternalizationPolicy.ts'; // Re-export constants for convenience (tests import from here too) @@ -83,24 +85,6 @@ export class EffectEmission { // Validation // ============================================================================ -/** - * Asserts that a value is a non-empty string, throwing if it is not. - */ -function requireNonEmptyString(value: unknown, name: string): void { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (typeof value !== 'string' || value.length === 0) { - throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); - } -} - -/** - * Asserts that a value is a non-negative finite number suitable for a wall-clock timestamp. - */ -function validateTimestamp(value: unknown): void { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { - throw new WarpError('timestamp must be a non-negative finite number', 'E_VALIDATION'); - } -} - /** * Asserts that a value is a non-null object suitable for use as an effect coordinate. */ @@ -132,21 +116,6 @@ export function createEffectEmission({ id, kind, payload, timestamp, writer, coo // Canonical JSON // ============================================================================ -/** - * JSON.stringify replacer that sorts object keys alphabetically for deterministic output. - */ -function sortedReplacer(_key: string, value: unknown): unknown { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - const sorted: { [x: string]: unknown } = {}; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - const obj = value as { [x: string]: unknown }; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - for (const k of Object.keys(obj).sort()) { - sorted[k] = obj[k]; - } - return sorted; - } - return value; -} - /** * Produces a deterministic JSON string for an EffectEmission. */ diff --git a/src/domain/types/TickReceipt.ts b/src/domain/types/TickReceipt.ts index fdb5896dc..b5ebc3498 100644 --- a/src/domain/types/TickReceipt.ts +++ b/src/domain/types/TickReceipt.ts @@ -12,6 +12,8 @@ */ import WarpError from '../errors/WarpError.ts'; +import { sortedReplacer } from '../utils/canonicalStringify.ts'; +import { requireNonEmptyString } from '../utils/scalarValidation.ts'; // ============================================================================ // Constants @@ -167,8 +169,8 @@ export class TickReceipt { * @throws If any field is invalid */ constructor({ patchSha, writer, lamport, ops }: { patchSha: string; writer: string; lamport: number; ops: OpOutcome[] }) { - assertNonEmptyString(patchSha, 'patchSha'); - assertNonEmptyString(writer, 'writer'); + requireNonEmptyString(patchSha, 'patchSha'); + requireNonEmptyString(writer, 'writer'); assertNonNegativeInt(lamport); assertOpsArray(ops); @@ -187,15 +189,6 @@ export function createTickReceipt({ patchSha, writer, lamport, ops }: { patchSha return new TickReceipt({ patchSha, writer, lamport, ops }); } -/** - * Asserts that a value is a non-empty string. - */ -function assertNonEmptyString(value: unknown, name: string): void { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (typeof value !== 'string' || value.length === 0) { - throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); - } -} - /** * Asserts that lamport is a non-negative integer. */ @@ -246,39 +239,3 @@ function freezeOps(ops: OpOutcome[]): ReadonlyArray> { export function canonicalJson(receipt: TickReceipt): string { return JSON.stringify(receipt, sortedReplacer); // nosemgrep: ts-no-json-stringify-in-core -- 0025B } - -/** - * JSON.stringify replacer callback that sorts object keys alphabetically. - * - * This function is passed as the second argument to `JSON.stringify()` and - * is called recursively for every key-value pair in the object being serialized. - * For plain objects, it returns a new object with keys in sorted order, ensuring - * deterministic JSON output regardless of property insertion order. - * - * Arrays are passed through unchanged since their order is semantically significant. - * Primitive values (strings, numbers, booleans, null) are also passed through unchanged. - * - * This is essential for producing canonical JSON representations that can be - * compared byte-for-byte or hashed consistently. - * - * @example - * // Used internally by canonicalJson - * JSON.stringify({ b: 1, a: 2 }, sortedReplacer); - * // Returns: '{"a":2,"b":1}' - * - * @example - * // Nested objects are also sorted - * JSON.stringify({ z: { b: 1, a: 2 }, y: 3 }, sortedReplacer); - * // Returns: '{"y":3,"z":{"a":2,"b":1}}' - */ -function sortedReplacer(_key: string, value: unknown): unknown { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (value !== null && typeof value === 'object' && !Array.isArray(value)) { - const sorted: { [x: string]: unknown } = {}; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - const obj = value as { [x: string]: unknown }; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - for (const k of Object.keys(obj).sort()) { - sorted[k] = obj[k]; - } - return sorted; - } - return value; -} diff --git a/src/domain/types/WarpPersistence.ts b/src/domain/types/WarpPersistence.ts index b313114b3..e41e9e26d 100644 --- a/src/domain/types/WarpPersistence.ts +++ b/src/domain/types/WarpPersistence.ts @@ -13,18 +13,18 @@ * @module domain/types/WarpPersistence */ -import type CommitPort from '../../ports/CommitPort.ts'; import type BlobPort from '../../ports/BlobPort.ts'; import type TreePort from '../../ports/TreePort.ts'; import type RefPort from '../../ports/RefPort.ts'; +import type WarpKernelPort from '../../ports/WarpKernelPort.ts'; /** - * Standard four-port persistence intersection — commit + blob + tree + ref. + * Standard WARP kernel persistence surface — commit + blob + tree + ref. * Used by sync readers, checkpoint creators, patch writers, and * materialize paths. Identical to CheckpointPersistence by design * (see module-level note). */ -export type CorePersistence = CommitPort & BlobPort & TreePort & RefPort; +export type CorePersistence = WarpKernelPort; /** * Index storage — blob reads/writes, tree reads/writes, ref reads/writes. diff --git a/src/domain/utils/canonicalStringify.ts b/src/domain/utils/canonicalStringify.ts index 43c31396d..eeb5561b5 100644 --- a/src/domain/utils/canonicalStringify.ts +++ b/src/domain/utils/canonicalStringify.ts @@ -1,5 +1,14 @@ import WarpError from '../errors/WarpError.ts'; +export type CanonicalJsonValue = + | null + | boolean + | number + | string + | undefined + | readonly CanonicalJsonValue[] + | { readonly [key: string]: CanonicalJsonValue }; + /** * Recursively stringifies a value with sorted object keys for deterministic output. * Used for computing checksums that must match across builders and readers. @@ -15,8 +24,27 @@ export function canonicalStringify(value: unknown): string { // nosemgrep: ts-no return _canonicalStringify(value, new WeakSet()); } +export function sortedReplacer(_key: string, value: CanonicalJsonValue): CanonicalJsonValue { + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return Object.fromEntries( + Object.entries(value).sort(([left], [right]) => compareJsonKeys(left, right)), + ); + } + return value; +} + const NULL_LITERAL: string = 'null'; +function compareJsonKeys(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + /** * Checks if a value should be serialized as null (undefined, function, or symbol). */ diff --git a/src/domain/utils/scalarValidation.ts b/src/domain/utils/scalarValidation.ts new file mode 100644 index 000000000..e818cac63 --- /dev/null +++ b/src/domain/utils/scalarValidation.ts @@ -0,0 +1,13 @@ +import WarpError from '../errors/WarpError.ts'; + +export function requireNonEmptyString(value: string, name: string): void { + if (typeof value !== 'string' || value.length === 0) { + throw new WarpError(`${name} must be a non-empty string`, 'E_VALIDATION'); + } +} + +export function validateTimestamp(value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new WarpError('timestamp must be a non-negative finite number', 'E_VALIDATION'); + } +} diff --git a/src/domain/warp/RuntimeHostBoot.ts b/src/domain/warp/RuntimeHostBoot.ts index 9a7268cab..3f7bea26b 100644 --- a/src/domain/warp/RuntimeHostBoot.ts +++ b/src/domain/warp/RuntimeHostBoot.ts @@ -34,10 +34,12 @@ import type EffectSinkPort from '../../ports/EffectSinkPort.ts'; import type RuntimeStorageCapabilityPort from '../../ports/RuntimeStorageCapabilityPort.ts'; import type { EffectPipeline } from '../services/EffectPipeline.ts'; import type { ExternalizationPolicy } from '../types/ExternalizationPolicy.ts'; -import type { GCPolicyConfig } from '../services/GCPolicy.ts'; -import type GCPolicy from '../services/GCPolicy.ts'; +import GCPolicy, { type GCPolicyConfig } from '../services/GCPolicy.ts'; import type { MaterializeSessionOpener } from '../services/controllers/MaterializeSessionBridge.ts'; +type DeletePolicy = 'reject' | 'cascade' | 'warn'; +const VALID_DELETE_POLICIES: ReadonlyArray = ['reject', 'cascade', 'warn']; + export type RuntimeHostConstructionOptions = { persistence: CorePersistence & Partial; graphName: string; @@ -46,7 +48,7 @@ export type RuntimeHostConstructionOptions = { adjacencyCacheSize?: number; checkpointPolicy?: { every: number }; autoMaterialize?: boolean; - onDeleteWithData?: 'reject' | 'cascade' | 'warn'; + onDeleteWithData?: DeletePolicy; logger?: LoggerPort; crypto?: CryptoPort; codec?: CodecPort; @@ -73,9 +75,9 @@ export type RuntimeHostOpenOptions = { writerId: string; gcPolicy?: GCPolicyConfig | GCPolicy; adjacencyCacheSize?: number; - checkpointPolicy?: { every: number }; + checkpointPolicy?: { every: number } | null; autoMaterialize?: boolean; - onDeleteWithData?: 'reject' | 'cascade' | 'warn'; + onDeleteWithData?: DeletePolicy; logger?: LoggerPort; crypto?: CryptoPort; codec?: CodecPort; @@ -90,86 +92,202 @@ export type RuntimeHostOpenOptions = { indexStore?: IndexStorePort | null; trust?: { mode?: TrustMode; pin?: string | null }; effectPipeline?: EffectPipeline; - effectSinks?: EffectSinkPort[]; + effectSinks?: readonly EffectSinkPort[]; externalizationPolicy?: ExternalizationPolicy; openStateSession?: MaterializeSessionOpener; }; -export type RuntimeMigrationBoundary = { - _validateMigrationBoundary(): Promise; -}; +export class WarpOpenOptions { + readonly persistence: CorePersistence & Partial; + readonly graphName: string; + readonly writerId: string; + readonly gcPolicy: GCPolicyConfig | GCPolicy; + readonly adjacencyCacheSize?: number; + readonly checkpointPolicy?: { every: number }; + readonly autoMaterialize?: boolean; + readonly onDeleteWithData?: DeletePolicy; + readonly logger?: LoggerPort; + readonly crypto: CryptoPort; + readonly codec: CodecPort; + readonly seekCache?: SeekCachePort; + readonly stateCache?: WarpStateCachePort; + readonly audit?: boolean; + readonly blobStorage?: BlobStoragePort; + readonly patchBlobStorage?: BlobStoragePort; + readonly commitMessageCodec?: CommitMessageCodecPort; + readonly patchJournal?: PatchJournalPort | null; + readonly checkpointStore?: CheckpointStorePort | null; + readonly indexStore?: IndexStorePort | null; + readonly trust?: { mode?: TrustMode; pin?: string | null }; + readonly effectPipeline?: EffectPipeline; + readonly effectSinks?: readonly EffectSinkPort[]; + readonly externalizationPolicy?: ExternalizationPolicy; + readonly openStateSession?: MaterializeSessionOpener; -export type RuntimeBooted = { - runtime: T; - normalizedTrust: NormalizedTrustConfig; -}; + constructor(options: RuntimeHostOpenOptions) { + if (options.persistence === null || options.persistence === undefined) { + throw new WarpError('persistence is required', 'E_INVALID_ARG'); + } + validateGraphName(options.graphName); + validateWriterId(options.writerId); -export async function resolveRuntimeHostConstructionOptions({ - persistence, - graphName, - writerId, - gcPolicy = {}, - adjacencyCacheSize, - checkpointPolicy, - autoMaterialize, - onDeleteWithData, - logger, - crypto, - codec, - seekCache, - stateCache, - audit, - blobStorage, - patchBlobStorage, - commitMessageCodec, - patchJournal, - checkpointStore, - indexStore, - trust, - effectPipeline, - effectSinks, - externalizationPolicy, - openStateSession, -}: RuntimeHostOpenOptions): Promise<{ - options: RuntimeHostConstructionOptions; - normalizedTrust: NormalizedTrustConfig; -}> { - validateGraphName(graphName); - validateWriterId(writerId); + this.persistence = options.persistence; + this.graphName = options.graphName; + this.writerId = options.writerId; + this.gcPolicy = snapshotGCPolicy(options.gcPolicy); + this.crypto = options.crypto ?? defaultCrypto; + this.codec = options.codec ?? defaultCodec; - if (persistence === null || persistence === undefined) { - throw new WarpError('persistence is required', 'E_INVALID_ARG'); + if (options.adjacencyCacheSize !== undefined) { + this.adjacencyCacheSize = options.adjacencyCacheSize; + } + const checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy); + if (checkpointPolicy !== undefined) { + this.checkpointPolicy = checkpointPolicy; + } + if (options.autoMaterialize !== undefined) { + this.autoMaterialize = normalizeBooleanOption( + options.autoMaterialize, + 'autoMaterialize', + 'E_AUTO_MATERIALIZE_TYPE', + ); + } + if (options.onDeleteWithData !== undefined) { + this.onDeleteWithData = normalizeDeletePolicy(options.onDeleteWithData); + } + if (options.logger !== undefined) { this.logger = options.logger; } + if (options.seekCache !== undefined) { this.seekCache = options.seekCache; } + if (options.stateCache !== undefined) { this.stateCache = options.stateCache; } + if (options.audit !== undefined) { + this.audit = normalizeBooleanOption(options.audit, 'audit', 'E_AUDIT_TYPE'); + } + if (options.blobStorage !== undefined) { this.blobStorage = options.blobStorage; } + if (options.patchBlobStorage !== undefined) { this.patchBlobStorage = options.patchBlobStorage; } + if (options.commitMessageCodec !== undefined) { this.commitMessageCodec = options.commitMessageCodec; } + if (options.patchJournal !== undefined) { this.patchJournal = options.patchJournal; } + if (options.checkpointStore !== undefined) { this.checkpointStore = options.checkpointStore; } + if (options.indexStore !== undefined) { this.indexStore = options.indexStore; } + if (options.trust !== undefined) { + this.trust = Object.freeze(normalizeTrustConfig(options.trust)); + } + if (options.effectPipeline !== undefined) { this.effectPipeline = options.effectPipeline; } + if (options.effectSinks !== undefined) { this.effectSinks = Object.freeze([...options.effectSinks]); } + if (options.externalizationPolicy !== undefined) { this.externalizationPolicy = options.externalizationPolicy; } + if (options.openStateSession !== undefined) { this.openStateSession = options.openStateSession; } + + Object.freeze(this); } - if (checkpointPolicy !== undefined && checkpointPolicy !== null) { - if (typeof checkpointPolicy !== 'object' || checkpointPolicy === null) { - throw new WarpError('checkpointPolicy must be an object with { every: number }', 'E_CHECKPOINT_POLICY_TYPE'); - } - if (!Number.isInteger(checkpointPolicy.every) || checkpointPolicy.every <= 0) { - throw new WarpError('checkpointPolicy.every must be a positive integer', 'E_CHECKPOINT_POLICY_EVERY'); + static from(options: RuntimeHostOpenOptions | WarpOpenOptions): WarpOpenOptions { + if (options instanceof WarpOpenOptions) { + return options; } + return new WarpOpenOptions(options); } - if (autoMaterialize !== undefined && typeof autoMaterialize !== 'boolean') { - throw new WarpError('autoMaterialize must be a boolean', 'E_AUTO_MATERIALIZE_TYPE'); + static minimal(options: { + persistence: CorePersistence & Partial; + graphName?: string; + writerId?: string; + }): WarpOpenOptions { + return new WarpOpenOptions({ + persistence: options.persistence, + graphName: options.graphName ?? 'default', + writerId: options.writerId ?? 'local', + }); } +} - if (audit !== undefined && typeof audit !== 'boolean') { - throw new WarpError('audit must be a boolean', 'E_AUDIT_TYPE'); +export type RuntimeHostOpenInput = RuntimeHostOpenOptions | WarpOpenOptions; + +function normalizeBooleanOption(value: boolean, label: string, code: string): boolean { + if (typeof value !== 'boolean') { + throw new WarpError(`${label} must be a boolean`, code); } + return value; +} - const normalizedTrust = normalizeTrustConfig(trust); +function normalizeCheckpointPolicy( + checkpointPolicy: { every: number } | null | undefined, +): { every: number } | undefined { + if (checkpointPolicy === null || checkpointPolicy === undefined) { + return undefined; + } + if (typeof checkpointPolicy !== 'object') { + throw new WarpError('checkpointPolicy must be an object with { every: number }', 'E_CHECKPOINT_POLICY_TYPE'); + } + if (!Number.isInteger(checkpointPolicy.every) || checkpointPolicy.every <= 0) { + throw new WarpError('checkpointPolicy.every must be a positive integer', 'E_CHECKPOINT_POLICY_EVERY'); + } + return Object.freeze({ every: checkpointPolicy.every }); +} - if (onDeleteWithData !== undefined) { - const valid = ['reject', 'cascade', 'warn'] as const; - if (!valid.includes(onDeleteWithData)) { - throw new WarpError( - `onDeleteWithData must be one of: ${valid.join(', ')}`, - 'E_ON_DELETE_WITH_DATA_INVALID', - { context: { got: onDeleteWithData, valid } }, - ); - } +function snapshotGCPolicy(value: GCPolicyConfig | GCPolicy | undefined): GCPolicyConfig | GCPolicy { + if (value === undefined) { + return Object.freeze({}); + } + if (value instanceof GCPolicy) { + return value; } + return Object.freeze({ ...value }); +} + +function normalizeDeletePolicy(policy: DeletePolicy): DeletePolicy { + if (!VALID_DELETE_POLICIES.includes(policy)) { + throw new WarpError( + `onDeleteWithData must be one of: ${VALID_DELETE_POLICIES.join(', ')}`, + 'E_ON_DELETE_WITH_DATA_INVALID', + { context: { got: policy, valid: VALID_DELETE_POLICIES } }, + ); + } + return policy; +} + +export type RuntimeMigrationBoundary = { + _validateMigrationBoundary(): Promise; +}; + +export type RuntimeBooted = { + runtime: T; + normalizedTrust: NormalizedTrustConfig; +}; + +export async function resolveRuntimeHostConstructionOptions( + input: RuntimeHostOpenInput, +): Promise<{ + options: RuntimeHostConstructionOptions; + normalizedTrust: NormalizedTrustConfig; +}> { + const options = WarpOpenOptions.from(input); + const { + persistence, + graphName, + writerId, + gcPolicy, + adjacencyCacheSize, + checkpointPolicy, + autoMaterialize, + onDeleteWithData, + logger, + crypto, + codec, + seekCache, + stateCache, + audit, + blobStorage, + patchBlobStorage, + commitMessageCodec, + patchJournal, + checkpointStore, + indexStore, + trust, + effectPipeline, + effectSinks, + externalizationPolicy, + openStateSession, + } = options; + + const normalizedTrust = normalizeTrustConfig(trust); const resolvedBlobStorage = await resolveBlobStorage(blobStorage, persistence); const resolvedCommitMessageCodec = commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC; @@ -194,7 +312,9 @@ export async function resolveRuntimeHostConstructionOptions({ commitPort, commitMessageCodec: resolvedCommitMessageCodec, ...(patchWriteStorage.strategy === 'git-cas' ? { blobStorage: resolvedBlobStorage } : {}), - ...(patchBlobStorage !== undefined && patchBlobStorage !== null ? { legacyPatchBlobStorage: patchBlobStorage } : {}), + ...(patchBlobStorage !== undefined && patchBlobStorage !== null + ? { legacyPatchBlobStorage: patchBlobStorage } + : {}), writeStorage: patchWriteStorage, }); } @@ -296,7 +416,9 @@ export async function resolveRuntimeHostConstructionOptions({ viewService: resolvedViewService, stateHashService: resolvedStateHashService, ...(resolvedAuditService !== undefined ? { auditService: resolvedAuditService } : {}), - ...(resolvedEffectPipeline !== undefined && resolvedEffectPipeline !== null ? { effectPipeline: resolvedEffectPipeline } : {}), + ...(resolvedEffectPipeline !== undefined && resolvedEffectPipeline !== null + ? { effectPipeline: resolvedEffectPipeline } + : {}), ...(resolvedOpenStateSession === undefined ? {} : { openStateSession: resolvedOpenStateSession }), }, }; diff --git a/src/domain/warp/RuntimeHostProduct.ts b/src/domain/warp/RuntimeHostProduct.ts index 50739885c..d67213042 100644 --- a/src/domain/warp/RuntimeHostProduct.ts +++ b/src/domain/warp/RuntimeHostProduct.ts @@ -31,7 +31,10 @@ import type Patch from '../types/Patch.ts'; import type WarpState from '../services/state/WarpState.ts'; import type SnapshotWarpState from '../services/snapshot/SnapshotWarpState.ts'; import type { TickReceipt } from '../types/TickReceipt.ts'; -import type { RuntimeHostOpenOptions as RuntimeHostBootOpenOptions } from './RuntimeHostBoot.ts'; +import type { + RuntimeHostOpenInput as RuntimeHostBootOpenInput, + RuntimeHostOpenOptions as RuntimeHostBootOpenOptions, +} from './RuntimeHostBoot.ts'; import { openRuntimeHost } from '../RuntimeHost.ts'; export type RuntimeCapabilitySurface = @@ -51,6 +54,7 @@ export type RuntimeGraphHostProduct = RuntimeCapabilitySurface & { }; export type RuntimeHostOpenOptions = RuntimeHostBootOpenOptions; +export type RuntimeHostOpenInput = RuntimeHostBootOpenInput; export type RuntimeForkRequest = { from: string; @@ -192,7 +196,7 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & { }; export async function openRuntimeHostProduct( - options: RuntimeHostOpenOptions, + options: RuntimeHostOpenInput, ): Promise { return await openRuntimeHost(options); } diff --git a/src/domain/warp/RuntimePatchCollector.ts b/src/domain/warp/RuntimePatchCollector.ts index 1c0036ddb..f04903a30 100644 --- a/src/domain/warp/RuntimePatchCollector.ts +++ b/src/domain/warp/RuntimePatchCollector.ts @@ -80,19 +80,20 @@ export default class RuntimePatchCollector extends PatchCollector { return await this._runtime._loadWriterPatches(writerId); } - async collectForFrontier(frontier: Map, ceiling: number | null): Promise { - const all: PatchWithSha[] = []; + override async *streamForFrontier( + frontier: Map, + ceiling: number | null, + ): AsyncIterable { for (const writerId of frontier.keys()) { const tipSha = frontier.get(writerId); if (typeof tipSha !== 'string' || tipSha.length === 0) { continue; } const patches = await this._runtime._loadPatchChainFromSha(tipSha); for (const entry of patches) { - if (ceiling === null || (entry.patch.lamport ?? 0) <= ceiling) { - all.push(entry); + if (ceiling === null || entry.patch.lamport <= ceiling) { + yield entry; } } } - return all; } async loadCheckpoint(): Promise { diff --git a/src/domain/warp/WarpCoreRuntimeProduct.ts b/src/domain/warp/WarpCoreRuntimeProduct.ts index 3ad4ea77a..b79218631 100644 --- a/src/domain/warp/WarpCoreRuntimeProduct.ts +++ b/src/domain/warp/WarpCoreRuntimeProduct.ts @@ -4,11 +4,13 @@ import type { EffectPipeline } from '../services/EffectPipeline.ts'; import type { WarpGraphRuntimeSurface } from './WarpGraphRuntimeProduct.ts'; import { openRuntimeHostProduct, + type RuntimeHostOpenInput, type RuntimeHostOpenOptions, type RuntimeHostProduct, } from './RuntimeHostProduct.ts'; export type WarpCoreOpenOptions = RuntimeHostOpenOptions; +export type WarpCoreOpenInput = RuntimeHostOpenInput; export type StrandCreateOptions = Parameters[0]; export type StrandDescriptor = Awaited>; export type StrandBraidOptions = Parameters[1]; @@ -112,6 +114,7 @@ export function buildWarpCoreRuntimeSurface(runtime: RuntimeHostProduct): WarpCo compareStrand: runtime.compareStrand.bind(runtime), planStrandTransfer: runtime.planStrandTransfer.bind(runtime), compareCoordinates: runtime.compareCoordinates.bind(runtime), + diff: runtime.diff.bind(runtime), planCoordinateTransfer: runtime.planCoordinateTransfer.bind(runtime), subscribe: runtime.subscribe.bind(runtime), watch: runtime.watch.bind(runtime), @@ -153,7 +156,7 @@ export function buildWarpCoreRuntimeSurface(runtime: RuntimeHostProduct): WarpCo } export async function openWarpCoreRuntimeProduct( - options: WarpCoreOpenOptions, + options: WarpCoreOpenInput, ): Promise { const runtime = await openRuntimeHostProduct(options); return buildWarpCoreRuntimeSurface(runtime); diff --git a/src/domain/warp/WarpGraphRuntimeBridge.ts b/src/domain/warp/WarpGraphRuntimeBridge.ts index d76482da7..e3695beca 100644 --- a/src/domain/warp/WarpGraphRuntimeBridge.ts +++ b/src/domain/warp/WarpGraphRuntimeBridge.ts @@ -1,13 +1,17 @@ import { openWarpGraphRuntimeProduct, - type WarpGraphRuntimeOpenOptions, + type WarpGraphRuntimeOpenInput, type WarpGraphRuntimeSurface, } from './WarpGraphRuntimeProduct.ts'; -export type { WarpGraphRuntimeOpenOptions, WarpGraphRuntimeSurface } from './WarpGraphRuntimeProduct.ts'; +export type { + WarpGraphRuntimeOpenInput, + WarpGraphRuntimeOpenOptions, + WarpGraphRuntimeSurface, +} from './WarpGraphRuntimeProduct.ts'; export async function openWarpGraphRuntime( - options: WarpGraphRuntimeOpenOptions, + options: WarpGraphRuntimeOpenInput, ): Promise { return await openWarpGraphRuntimeProduct(options); } diff --git a/src/domain/warp/WarpGraphRuntimeProduct.ts b/src/domain/warp/WarpGraphRuntimeProduct.ts index c98c22797..d4a9f4f9d 100644 --- a/src/domain/warp/WarpGraphRuntimeProduct.ts +++ b/src/domain/warp/WarpGraphRuntimeProduct.ts @@ -1,10 +1,12 @@ import { openRuntimeHostProduct, type RuntimeGraphHostProduct, + type RuntimeHostOpenInput, type RuntimeHostOpenOptions, } from './RuntimeHostProduct.ts'; export type WarpGraphRuntimeOpenOptions = RuntimeHostOpenOptions; +export type WarpGraphRuntimeOpenInput = RuntimeHostOpenInput; export type WarpGraphRuntimeSurface = RuntimeGraphHostProduct; @@ -77,6 +79,7 @@ export function buildWarpGraphRuntimeSurface(runtime: RuntimeGraphHostProduct): compareStrand: runtime.compareStrand.bind(runtime), planStrandTransfer: runtime.planStrandTransfer.bind(runtime), compareCoordinates: runtime.compareCoordinates.bind(runtime), + diff: runtime.diff.bind(runtime), planCoordinateTransfer: runtime.planCoordinateTransfer.bind(runtime), subscribe: runtime.subscribe.bind(runtime), watch: runtime.watch.bind(runtime), @@ -85,7 +88,7 @@ export function buildWarpGraphRuntimeSurface(runtime: RuntimeGraphHostProduct): } export async function openWarpGraphRuntimeProduct( - options: WarpGraphRuntimeOpenOptions, + options: WarpGraphRuntimeOpenInput, ): Promise { const runtime = await openRuntimeHostProduct(options); return buildWarpGraphRuntimeSurface(runtime); diff --git a/src/domain/warp/Writer.ts b/src/domain/warp/Writer.ts index b4e7ff209..33574a502 100644 --- a/src/domain/warp/Writer.ts +++ b/src/domain/warp/Writer.ts @@ -22,10 +22,7 @@ import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../services/codec/WarpMessageCodec import WriterError from '../errors/WriterError.ts'; import type VersionVector from '../crdt/VersionVector.ts'; import type Patch from '../types/Patch.ts'; -import type CommitPort from '../../ports/CommitPort.ts'; -import type BlobPort from '../../ports/BlobPort.ts'; -import type TreePort from '../../ports/TreePort.ts'; -import type RefPort from '../../ports/RefPort.ts'; +import type WarpKernelPort from '../../ports/WarpKernelPort.ts'; import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; @@ -36,8 +33,6 @@ import type { WarpState } from '../services/JoinReducer.ts'; // should migrate to importing from '../errors/WriterError.ts' directly. export { WriterError }; -type PersistencePorts = CommitPort & BlobPort & TreePort & RefPort; - /** * Asserts that a Lamport timestamp is a valid positive finite integer. */ @@ -63,7 +58,7 @@ function _validateJournal(patchJournal: PatchJournalPort): void { type OnDeleteWithData = 'reject' | 'cascade' | 'warn'; interface WriterOptions { - persistence: PersistencePorts; + persistence: WarpKernelPort; graphName: string; writerId: string; versionVector: VersionVector; @@ -80,7 +75,7 @@ interface WriterOptions { * Writer class for creating and committing patches to a WARP graph. */ export class Writer { - private _persistence: PersistencePorts; + private _persistence: WarpKernelPort; private _graphName: string; private _writerId: string; private _versionVector: VersionVector; @@ -154,7 +149,7 @@ export class Writer { * Constructs PatchBuilder options from Writer state. */ private _buildPatchOpts(core: { - persistence: PersistencePorts; + persistence: WarpKernelPort; graphName: string; writerId: string; lamport: number; diff --git a/src/globals.d.ts b/src/globals.d.ts index f4f925da7..9438e8953 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -79,35 +79,3 @@ declare module '@git-stunts/plumbing' { export const ShellRunnerFactory: any; export default Plumbing; } - -declare module '@git-stunts/trailer-codec' { - export interface TrailerCodecPayload { - title: string; - trailers: Record; - } - - export interface TrailerCodecDecodedMessage { - trailers: Record; - } - - export interface TrailerCodecFacade { - encode(payload: TrailerCodecPayload): string; - decode(message: string): TrailerCodecDecodedMessage; - } - - export class TrailerCodecService {} - - export class TrailerCodec implements TrailerCodecFacade { - constructor(options: { - service: TrailerCodecService; - bodyFormatOptions?: { keepTrailingNewline?: boolean }; - }); - encode(payload: TrailerCodecPayload): string; - decode(message: string): TrailerCodecDecodedMessage; - } - - export function createMessageHelpers(options?: { - service: TrailerCodecService; - bodyFormatOptions?: { keepTrailingNewline?: boolean }; - }): TrailerCodecFacade; -} diff --git a/src/infrastructure/adapters/AlfredOperationPolicyAdapter.ts b/src/infrastructure/adapters/AlfredOperationPolicyAdapter.ts new file mode 100644 index 000000000..7a4e5423c --- /dev/null +++ b/src/infrastructure/adapters/AlfredOperationPolicyAdapter.ts @@ -0,0 +1,86 @@ +import { + retry, + timeout, + RetryExhaustedError, + TimeoutError, +} from '@git-stunts/alfred'; +import OperationPolicyExhaustedError from '../../domain/errors/OperationPolicyExhaustedError.ts'; +import OperationPolicyTimeoutError from '../../domain/errors/OperationPolicyTimeoutError.ts'; +import OperationPolicyPort, { + type OperationPolicyExecuteOptions, +} from '../../ports/OperationPolicyPort.ts'; + +type AlfredOperationPolicyAdapterOptions = { + readonly retryOptions?: OperationPolicyExecuteOptions; +}; + +export default class AlfredOperationPolicyAdapter extends OperationPolicyPort { + private readonly _retryOptions: OperationPolicyExecuteOptions; + + constructor(options: AlfredOperationPolicyAdapterOptions = {}) { + super(); + this._retryOptions = options.retryOptions ?? {}; + } + + override async execute( + operation: (signal?: AbortSignal) => Promise, + options: OperationPolicyExecuteOptions = {}, + ): Promise { + const resolvedOptions = { ...this._retryOptions, ...options }; + return await this._mapErrors(async () => { + if (resolvedOptions.retries !== undefined) { + return await retry( + (retrySignal?: AbortSignal) => this._executeAttempt(operation, resolvedOptions, retrySignal), + resolvedOptions, + ); + } + return await this._executeAttempt(operation, resolvedOptions, resolvedOptions.signal); + }); + } + + override async stream( + operation: (signal?: AbortSignal) => Promise>, + options: OperationPolicyExecuteOptions = {}, + ): Promise> { + return await this.execute(operation, options); + } + + private async _executeAttempt( + operation: (signal?: AbortSignal) => Promise, + options: OperationPolicyExecuteOptions, + retrySignal: AbortSignal | undefined, + ): Promise { + if (options.timeoutMs !== undefined) { + return await timeout(options.timeoutMs, async (timeoutSignal: AbortSignal) => + await operation(combineAbortSignals(retrySignal, timeoutSignal))); + } + return await operation(retrySignal); + } + + private async _mapErrors(operation: () => Promise): Promise { + try { + return await operation(); + } catch (err) { + if (err instanceof RetryExhaustedError) { + throw new OperationPolicyExhaustedError(err.attempts, err.cause); + } + if (err instanceof TimeoutError) { + throw new OperationPolicyTimeoutError(err.timeout, err.elapsed); + } + throw err; + } + } +} + +function combineAbortSignals( + first: AbortSignal | undefined, + second: AbortSignal | undefined, +): AbortSignal | undefined { + if (first === undefined) { + return second; + } + if (second === undefined) { + return first; + } + return AbortSignal.any([first, second]); +} diff --git a/src/infrastructure/adapters/CasBlobAdapter.ts b/src/infrastructure/adapters/CasBlobAdapter.ts index c0247a186..03dddf9d9 100644 --- a/src/infrastructure/adapters/CasBlobAdapter.ts +++ b/src/infrastructure/adapters/CasBlobAdapter.ts @@ -5,9 +5,8 @@ * as a CAS tree in the Git object store. The tree OID serves as the * storage identifier. * - * Backward compatibility: if `retrieve()` fails to find a CAS manifest - * at the given OID, it falls back to reading a raw Git blob. This - * handles content written before the CAS migration. + * Current runtime reads require CAS manifests. Migration tooling can inject an + * explicit retired raw Git blob read policy while translating old substrates. * * @module infrastructure/adapters/CasBlobAdapter */ @@ -16,6 +15,16 @@ import BlobStoragePort from '../../ports/BlobStoragePort.ts'; import PersistenceError from '../../domain/errors/PersistenceError.ts'; import { createLazyCas } from './lazyCasInit.ts'; import { createCdcCasStore } from './CasStoreFactory.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; +import CasContentEncryptionPolicy, { + type CasRestoreEncryptionArguments, + type CasStoreEncryptionArguments, + type CasStoreEncryptionOptions, + mapCasContentEncryptionError, +} from './CasContentEncryptionPolicy.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import { Readable } from 'node:stream'; @@ -25,9 +34,9 @@ interface CasManifest { interface CasStore { readManifest(opts: { treeOid: string }): Promise; - restore(opts: { manifest: CasManifest; encryptionKey?: Uint8Array }): Promise<{ buffer: Uint8Array }>; - restoreStream?: (opts: { manifest: CasManifest; encryptionKey?: Uint8Array }) => AsyncIterable; - store(opts: { source: unknown; slug: string; filename: string; encryptionKey?: Uint8Array }): Promise; + restore(opts: { manifest: CasManifest } & CasRestoreEncryptionArguments): Promise<{ buffer: Uint8Array }>; + restoreStream?: (opts: { manifest: CasManifest } & CasRestoreEncryptionArguments) => AsyncIterable; + store(opts: { source: unknown; slug: string; filename: string } & CasStoreEncryptionArguments): Promise; createTree(opts: { manifest: CasManifest }): Promise; } @@ -48,7 +57,11 @@ function normalizeToUint8Array(buffer: Uint8Array): Uint8Array { * CAS migration). * * - `MANIFEST_NOT_FOUND` — tree exists but contains no manifest entry - * - `GIT_ERROR` — Git couldn't read the tree at all (wrong object type) + * - `GIT_ERROR` — Git couldn't read the tree at all + * + * `GIT_ERROR` can mean either "wrong object type" or "missing object" depending + * on the plumbing path. The legacy fallback path probes the raw object before + * deciding whether this is retired legacy content or a genuinely missing OID. */ const LEGACY_BLOB_CODES = new Set(['MANIFEST_NOT_FOUND', 'GIT_ERROR']); @@ -69,25 +82,44 @@ function hasLegacyBlobMessage(msg: string): boolean { || msg.includes('does not exist'); } +function missingGitObjectError(oid: string, cause: unknown): PersistenceError { + if (cause instanceof Error) { + return new PersistenceError( + `Missing Git object: ${oid}`, + PersistenceError.E_MISSING_OBJECT, + { cause, context: { oid } }, + ); + } + return new PersistenceError( + `Missing Git object: ${oid}`, + PersistenceError.E_MISSING_OBJECT, + { context: { oid } }, + ); +} + export default class CasBlobAdapter extends BlobStoragePort { private readonly _plumbing: unknown; private readonly _persistence: BlobPersistence; - private readonly _encryptionKey: Uint8Array | undefined; + private readonly _contentEncryption: CasContentEncryptionPolicy; private readonly _logger: LoggerPort | undefined; private readonly _getCas: () => Promise; + private readonly _compatibilityPolicy: SubstrateCompatibilityPolicyValue; - constructor({ plumbing, persistence, encryptionKey, logger }: { + constructor({ plumbing, persistence, encryptionKey, contentEncryption, logger, compatibilityPolicy }: { plumbing: unknown; persistence: BlobPersistence; encryptionKey?: Uint8Array; + contentEncryption?: CasContentEncryptionPolicy; logger?: LoggerPort; + compatibilityPolicy?: SubstrateCompatibilityPolicyValue; }) { super(); this._plumbing = plumbing; this._persistence = persistence; - this._encryptionKey = encryptionKey; + this._contentEncryption = resolveContentEncryption(contentEncryption, encryptionKey); this._logger = logger; this._getCas = createLazyCas(() => this._initCas()); + this._compatibilityPolicy = compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; } private async _initCas(): Promise { @@ -104,14 +136,12 @@ export default class CasBlobAdapter extends BlobStoragePort { : content; const source = Readable.from([buf]); - const storeOpts: { source: unknown; slug: string; filename: string; encryptionKey?: Uint8Array } = { + const storeOpts: { source: unknown; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { source, slug: options?.slug ?? `blob-${Date.now().toString(36)}`, filename: 'content', + ...this._contentEncryption.toStoreOptions(), }; - if (this._encryptionKey) { - storeOpts.encryptionKey = this._encryptionKey; - } const manifest = await cas.store(storeOpts); return await cas.createTree({ manifest }); @@ -123,10 +153,14 @@ export default class CasBlobAdapter extends BlobStoragePort { try { return await this._restoreFromCas(cas, oid); } catch (err) { + const encryptionError = mapCasContentEncryptionError(err, 'content-attachment'); + if (encryptionError !== null) { + throw encryptionError; + } if (!isLegacyBlobError(err)) { throw err; } - return await this._fallbackReadBlob(oid); + return await this._readLegacyContentBlobCandidate(oid, err); } } @@ -149,26 +183,20 @@ export default class CasBlobAdapter extends BlobStoragePort { return blob; } - private _buildRestoreOpts(manifest: CasManifest): { manifest: CasManifest; encryptionKey?: Uint8Array } { - const opts: { manifest: CasManifest; encryptionKey?: Uint8Array } = { manifest }; - if (this._encryptionKey) { - opts.encryptionKey = this._encryptionKey; - } - return opts; + private _buildRestoreOpts(manifest: CasManifest): { manifest: CasManifest } & CasRestoreEncryptionArguments { + return { manifest, ...this._contentEncryption.toRestoreOptions() }; } override async storeStream(source: AsyncIterable, options?: { slug?: string; mime?: string | null; size?: number | null }): Promise { const cas = await this._getCas(); const readable = Readable.from(source); - const storeOpts: { source: unknown; slug: string; filename: string; encryptionKey?: Uint8Array } = { + const storeOpts: { source: unknown; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { source: readable, slug: options?.slug ?? `blob-${Date.now().toString(36)}`, filename: 'content', + ...this._contentEncryption.toStoreOptions(), }; - if (this._encryptionKey) { - storeOpts.encryptionKey = this._encryptionKey; - } const manifest = await cas.store(storeOpts); return await cas.createTree({ manifest }); @@ -208,10 +236,14 @@ export default class CasBlobAdapter extends BlobStoragePort { try { return await this._streamFromCas(cas, oid); } catch (err) { + const encryptionError = mapCasContentEncryptionError(err, 'content-attachment-stream'); + if (encryptionError !== null) { + throw encryptionError; + } if (!isLegacyBlobError(err)) { throw err; } - const blob = await this._fallbackReadBlob(oid); + const blob = await this._readLegacyContentBlobCandidate(oid, err); return singleChunkIterator(blob); } } @@ -228,6 +260,49 @@ export default class CasBlobAdapter extends BlobStoragePort { const { buffer } = await cas.restore(restoreOpts); return singleChunkIterator(buffer); } + + private async _readLegacyContentBlobCandidate(oid: string, error: unknown): Promise { + if (this._compatibilityPolicy.legacyContentBlobReads) { + return await this._fallbackReadBlob(oid); + } + const blob = await this._probeLegacyContentBlob(oid); + if (blob === null) { + throw missingGitObjectError(oid, error); + } + throw new PersistenceError( + `Legacy raw blob reads require the substrate migration compatibility policy: ${oid}`, + 'E_LEGACY_SUBSTRATE_DISABLED', + { + ...(error instanceof Error ? { cause: error } : {}), + context: { oid }, + }, + ); + } + + private async _probeLegacyContentBlob(oid: string): Promise { + try { + const blob = await this._persistence.readBlob(oid); + return blob ?? null; + } catch (err) { + if (err instanceof PersistenceError && err.code === PersistenceError.E_MISSING_OBJECT) { + return null; + } + throw err; + } + } +} + +function resolveContentEncryption( + contentEncryption: CasContentEncryptionPolicy | undefined, + encryptionKey: Uint8Array | undefined, +): CasContentEncryptionPolicy { + if (contentEncryption !== undefined) { + return contentEncryption; + } + if (encryptionKey !== undefined) { + return CasContentEncryptionPolicy.fromInternalResolvedKey({ encryptionKey }); + } + return CasContentEncryptionPolicy.disabled(); } function singleChunkIterator(buf: Uint8Array): AsyncIterator { diff --git a/src/infrastructure/adapters/CasContentEncryptionPolicy.ts b/src/infrastructure/adapters/CasContentEncryptionPolicy.ts new file mode 100644 index 000000000..01d322da2 --- /dev/null +++ b/src/infrastructure/adapters/CasContentEncryptionPolicy.ts @@ -0,0 +1,429 @@ +import EncryptionError from '../../domain/errors/EncryptionError.ts'; + +export type CasContentEncryptionScheme = 'whole' | 'framed' | 'convergent'; + +export interface CasVaultResolutionWitness { + readonly vaultSlug: string; + readonly keyId: string; + readonly verification: 'verified' | 'failed-passphrase' | 'missing-metadata'; + readonly rotationEpoch: number; + readonly encryptionCount: number; + readonly encryptionCountLimit: number; + readonly privacyMode: boolean; +} + +export interface CasResolvedVaultKeyOptions { + readonly encryptionKey: Uint8Array; + readonly scheme: string; + readonly vault: CasVaultResolutionWitness; + readonly frameBytes?: number; +} + +export interface CasContentEncryptionDiagnostics { + readonly vaultSlug: string; + readonly keyId: string; + readonly rotationEpoch: number; + readonly encryptionCount: number; + readonly encryptionCountLimit: number; + readonly privacyMode: boolean; +} + +export interface CasStoreEncryptionOptions { + readonly scheme: CasContentEncryptionScheme; + readonly frameBytes?: number; + readonly convergent?: boolean; +} + +export interface CasStoreEncryptionArguments { + readonly encryptionKey?: Uint8Array; + readonly encryption?: CasStoreEncryptionOptions; +} + +export interface CasRestoreEncryptionArguments { + readonly encryptionKey?: Uint8Array; +} + +interface CasContentEncryptionPolicyFields { + readonly enabled: boolean; + readonly encryptionKey?: Uint8Array; + readonly scheme?: CasContentEncryptionScheme; + readonly frameBytes?: number; + readonly diagnostics?: CasContentEncryptionDiagnostics; +} + +interface InternalResolvedKeyOptions { + readonly encryptionKey: Uint8Array; + readonly scheme?: string; + readonly frameBytes?: number; +} + +interface EnabledFieldInput { + readonly encryptionKey: Uint8Array; + readonly scheme: CasContentEncryptionScheme; + readonly frameBytes: number | undefined; + readonly diagnostics: CasContentEncryptionDiagnostics | undefined; +} + +type CasContentEncryptionErrorKind = 'legacy-scheme' | 'wrong-passphrase' | 'missing-vault-metadata' | 'none'; + +const LEGACY_SCHEME_VALUES = new Set([ + 'whole-v1', + 'whole-v2', + 'framed-v1', + 'framed-v2', + 'convergent-v1', +]); + +const REQUIRED_KEY_BYTES = 32; + +export default class CasContentEncryptionPolicy { + private readonly _enabled: boolean; + private readonly _encryptionKey: Uint8Array | undefined; + private readonly _scheme: CasContentEncryptionScheme | undefined; + private readonly _frameBytes: number | undefined; + private readonly _diagnostics: CasContentEncryptionDiagnostics | undefined; + + private constructor(fields: CasContentEncryptionPolicyFields) { + this._enabled = fields.enabled; + this._encryptionKey = fields.encryptionKey; + this._scheme = fields.scheme; + this._frameBytes = fields.frameBytes; + this._diagnostics = fields.diagnostics; + Object.freeze(this); + } + + static disabled(): CasContentEncryptionPolicy { + return new CasContentEncryptionPolicy({ enabled: false }); + } + + static fromResolvedVaultKey(options: CasResolvedVaultKeyOptions): CasContentEncryptionPolicy { + const scheme = normalizeScheme(options.scheme); + const frameBytes = normalizeFrameBytes(scheme, options.frameBytes); + const diagnostics = validateVaultWitness(options.vault); + return new CasContentEncryptionPolicy( + enabledFields({ + encryptionKey: copyValidatedKey(options.encryptionKey, diagnostics), + scheme, + frameBytes, + diagnostics, + }), + ); + } + + /** @internal Raw keys may only enter here after a caller-owned boundary resolved them. */ + static fromInternalResolvedKey(options: InternalResolvedKeyOptions): CasContentEncryptionPolicy { + const scheme = normalizeScheme(options.scheme ?? 'whole'); + const frameBytes = normalizeFrameBytes(scheme, options.frameBytes); + return new CasContentEncryptionPolicy( + enabledFields({ + encryptionKey: copyValidatedKey(options.encryptionKey, null), + scheme, + frameBytes, + diagnostics: undefined, + }), + ); + } + + get enabled(): boolean { + return this._enabled; + } + + get scheme(): CasContentEncryptionScheme | null { + return this._scheme ?? null; + } + + vaultDiagnostics(): CasContentEncryptionDiagnostics | null { + return this._diagnostics ?? null; + } + + toStoreOptions(): CasStoreEncryptionArguments { + if (!this._enabled) { + return {}; + } + return { + encryptionKey: this._copyKey(), + encryption: this._storeEncryptionOptions(), + }; + } + + toRestoreOptions(): CasRestoreEncryptionArguments { + if (!this._enabled) { + return {}; + } + return { encryptionKey: this._copyKey() }; + } + + private _copyKey(): Uint8Array { + if (this._encryptionKey === undefined) { + throw encryptionPolicyError('CAS content encryption is enabled without a resolved key', 'E_CAS_ENCRYPTION_KEY_MISSING'); + } + return new Uint8Array(this._encryptionKey); + } + + private _storeEncryptionOptions(): CasStoreEncryptionOptions { + const scheme = this._requireScheme(); + if (scheme === 'convergent') { + return { scheme, convergent: true }; + } + if (scheme === 'framed' && this._frameBytes !== undefined) { + return { scheme, frameBytes: this._frameBytes }; + } + return { scheme }; + } + + private _requireScheme(): CasContentEncryptionScheme { + if (this._scheme === undefined) { + throw encryptionPolicyError('CAS content encryption is enabled without a current scheme', 'E_CAS_ENCRYPTION_SCHEME_MISSING'); + } + return this._scheme; + } +} + +export function mapCasContentEncryptionError(error: unknown, surface: string): EncryptionError | null { + if (error instanceof EncryptionError) { + return error; + } + const code = errorCode(error); + const message = errorMessage(error); + const kind = classifyCasContentEncryptionError(code, message); + if (kind === 'legacy-scheme') { + return encryptionPolicyError( + 'Legacy git-cas encryption schemes require migration before git-warp can restore this CAS content', + 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + { surface, upstreamCode: code, upstreamMessage: message }, + ); + } + if (kind === 'wrong-passphrase') { + return encryptionPolicyError( + 'git-cas vault passphrase verification failed while resolving CAS content encryption', + 'E_CAS_VAULT_PASSPHRASE_FAILED', + { surface, upstreamCode: code, upstreamMessage: message }, + ); + } + if (kind === 'missing-vault-metadata') { + return encryptionPolicyError( + 'git-cas vault metadata is missing or invalid for encrypted CAS content', + 'E_CAS_VAULT_METADATA_MISSING', + { surface, upstreamCode: code, upstreamMessage: message }, + ); + } + return null; +} + +function enabledFields(input: EnabledFieldInput): CasContentEncryptionPolicyFields { + const base = input.diagnostics === undefined + ? { enabled: true, encryptionKey: input.encryptionKey, scheme: input.scheme } + : { enabled: true, encryptionKey: input.encryptionKey, scheme: input.scheme, diagnostics: input.diagnostics }; + if (input.frameBytes === undefined) { + return base; + } + return { ...base, frameBytes: input.frameBytes }; +} + +function normalizeScheme(scheme: string): CasContentEncryptionScheme { + if (scheme === 'whole' || scheme === 'framed' || scheme === 'convergent') { + return scheme; + } + if (LEGACY_SCHEME_VALUES.has(scheme)) { + throw encryptionPolicyError( + `Legacy git-cas encryption scheme "${scheme}" is not accepted by git-warp current writes`, + 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + { scheme, migration: 'Run the git-cas legacy encryption migration before writing through git-warp.' }, + ); + } + throw encryptionPolicyError( + `Unsupported git-cas encryption scheme "${scheme}"`, + 'E_CAS_ENCRYPTION_SCHEME_UNSUPPORTED', + { scheme }, + ); +} + +function normalizeFrameBytes( + scheme: CasContentEncryptionScheme, + frameBytes: number | undefined, +): number | undefined { + if (frameBytes === undefined) { + return undefined; + } + if (scheme !== 'framed') { + throw encryptionPolicyError( + `encryption.frameBytes is only valid for framed CAS content encryption, not ${scheme}`, + 'E_CAS_ENCRYPTION_FRAME_BYTES_UNSUPPORTED', + { scheme, frameBytes }, + ); + } + if (!Number.isSafeInteger(frameBytes) || frameBytes < 1) { + throw encryptionPolicyError( + 'encryption.frameBytes must be a positive safe integer', + 'E_CAS_ENCRYPTION_FRAME_BYTES_INVALID', + { scheme, frameBytes }, + ); + } + return frameBytes; +} + +function validateVaultWitness(witness: CasVaultResolutionWitness): CasContentEncryptionDiagnostics { + assertVaultVerification(witness); + assertNonEmpty(witness.vaultSlug, 'vaultSlug', 'E_CAS_VAULT_SLUG_INVALID'); + assertNonEmpty(witness.keyId, 'keyId', 'E_CAS_VAULT_KEY_ID_INVALID'); + assertVaultRotationCounters(witness); + assertVaultRotationOpen(witness); + return vaultDiagnosticsFrom(witness); +} + +function assertVaultVerification(witness: CasVaultResolutionWitness): void { + if (witness.verification === 'failed-passphrase') { + throw encryptionPolicyError( + 'git-cas vault passphrase verification failed while resolving CAS content encryption', + 'E_CAS_VAULT_PASSPHRASE_FAILED', + { vaultSlug: witness.vaultSlug, keyId: witness.keyId }, + ); + } + if (witness.verification === 'missing-metadata') { + throw encryptionPolicyError( + 'git-cas vault metadata is required before enabling CAS content encryption', + 'E_CAS_VAULT_METADATA_MISSING', + { vaultSlug: witness.vaultSlug, keyId: witness.keyId }, + ); + } +} + +function assertVaultRotationCounters(witness: CasVaultResolutionWitness): void { + assertNonNegativeInteger(witness.rotationEpoch, 'rotationEpoch', 'E_CAS_VAULT_ROTATION_INVALID'); + assertNonNegativeInteger(witness.encryptionCount, 'encryptionCount', 'E_CAS_VAULT_ROTATION_INVALID'); + assertPositiveInteger(witness.encryptionCountLimit, 'encryptionCountLimit', 'E_CAS_VAULT_ROTATION_INVALID'); +} + +function assertVaultRotationOpen(witness: CasVaultResolutionWitness): void { + if (witness.encryptionCount >= witness.encryptionCountLimit) { + throw encryptionPolicyError( + 'git-cas vault encryption count reached its rotation limit; rotate before writing more encrypted CAS content', + 'E_CAS_VAULT_ROTATION_REQUIRED', + { + vaultSlug: witness.vaultSlug, + keyId: witness.keyId, + encryptionCount: witness.encryptionCount, + encryptionCountLimit: witness.encryptionCountLimit, + }, + ); + } +} + +function vaultDiagnosticsFrom(witness: CasVaultResolutionWitness): CasContentEncryptionDiagnostics { + return Object.freeze({ + vaultSlug: witness.vaultSlug, + keyId: witness.keyId, + rotationEpoch: witness.rotationEpoch, + encryptionCount: witness.encryptionCount, + encryptionCountLimit: witness.encryptionCountLimit, + privacyMode: witness.privacyMode, + }); +} + +function validateKey( + encryptionKey: Uint8Array, + diagnostics: CasContentEncryptionDiagnostics | null, +): Uint8Array { + if (!(encryptionKey instanceof Uint8Array) || encryptionKey.byteLength !== REQUIRED_KEY_BYTES) { + throw encryptionPolicyError( + `CAS content encryption requires a ${REQUIRED_KEY_BYTES}-byte resolved key`, + 'E_CAS_ENCRYPTION_KEY_INVALID', + diagnostics === null ? {} : { vaultSlug: diagnostics.vaultSlug, keyId: diagnostics.keyId }, + ); + } + return encryptionKey; +} + +function copyValidatedKey( + encryptionKey: Uint8Array, + diagnostics: CasContentEncryptionDiagnostics | null, +): Uint8Array { + return new Uint8Array(validateKey(encryptionKey, diagnostics)); +} + +function assertNonEmpty(value: string, field: string, code: string): void { + if (value.length === 0) { + throw encryptionPolicyError(`git-cas vault ${field} must not be empty`, code, { field }); + } +} + +function assertNonNegativeInteger(value: number, field: string, code: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw encryptionPolicyError(`git-cas vault ${field} must be a non-negative safe integer`, code, { field, value }); + } +} + +function assertPositiveInteger(value: number, field: string, code: string): void { + if (!Number.isSafeInteger(value) || value < 1) { + throw encryptionPolicyError(`git-cas vault ${field} must be a positive safe integer`, code, { field, value }); + } +} + +function errorCode(error: unknown): string | null { + return hasStringCode(error) ? error.code : null; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isLegacySchemeMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return normalized.includes('legacy encryption scheme') || normalized.includes('legacy_scheme'); +} + +function isWrongPassphraseMessage(message: string): boolean { + return message.toLowerCase().includes('vault passphrase verification failed'); +} + +function isMissingVaultMetadataMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return normalized.includes('.vault.json') + || normalized.includes('vault metadata') + || normalized.includes('privacy index metadata is missing'); +} + +function classifyCasContentEncryptionError( + code: string | null, + message: string, +): CasContentEncryptionErrorKind { + if (isLegacyCasEncryptionError(code, message)) { + return 'legacy-scheme'; + } + if (isWrongPassphraseMessage(message)) { + return 'wrong-passphrase'; + } + if (isMissingVaultMetadataError(code, message)) { + return 'missing-vault-metadata'; + } + return 'none'; +} + +function isLegacyCasEncryptionError(code: string | null, message: string): boolean { + if (code === 'LEGACY_SCHEME') { + return true; + } + return isLegacySchemeMessage(message); +} + +function isMissingVaultMetadataError(code: string | null, message: string): boolean { + if (code === 'VAULT_METADATA_INVALID') { + return true; + } + return isMissingVaultMetadataMessage(message); +} + +function hasStringCode(error: unknown): error is { readonly code: string } { + return typeof error === 'object' + && error !== null + && 'code' in error + && typeof error.code === 'string'; +} + +function encryptionPolicyError( + message: string, + code: string, + context: Record = {}, +): EncryptionError { + return new EncryptionError(message, { code, context }); +} diff --git a/src/infrastructure/adapters/CasIndexStorageAdapter.ts b/src/infrastructure/adapters/CasIndexStorageAdapter.ts index 6f9bfd1cb..f3a7afd1b 100644 --- a/src/infrastructure/adapters/CasIndexStorageAdapter.ts +++ b/src/infrastructure/adapters/CasIndexStorageAdapter.ts @@ -4,6 +4,7 @@ import type RefPort from '../../ports/RefPort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type { BlobStorageOptions } from '../../ports/BlobStoragePort.ts'; import StreamingIndexStoragePort from '../../ports/StreamingIndexStoragePort.ts'; +import WarpError from '../../domain/errors/WarpError.ts'; import { decodeCasPayloadPointer, encodeCasPayloadPointer, @@ -42,7 +43,11 @@ export class CasIndexStorageAdapter extends StreamingIndexStoragePort { } override async readBlob(oid: string): Promise { - return await readPayloadBlob(this._blobPort, this._blobStorage, oid); + return await readPayloadBlob({ + blobPort: this._blobPort, + blobStorage: this._blobStorage, + oid, + }); } override async writeBlobStream( @@ -60,8 +65,10 @@ export class CasIndexStorageAdapter extends StreamingIndexStoragePort { const pointerBytes = await adapter._blobPort.readBlob(oid); const storageOid = decodeCasPayloadPointer(pointerBytes); if (storageOid === null) { - yield pointerBytes; - return; + throw new WarpError( + `Inline index payload blob ${oid} requires the substrate migration compatibility policy`, + 'E_LEGACY_SUBSTRATE_DISABLED', + ); } for await (const chunk of adapter._blobStorage.retrieveStream(storageOid)) { yield chunk; diff --git a/src/infrastructure/adapters/CasPayloadPointer.ts b/src/infrastructure/adapters/CasPayloadPointer.ts index a76825fe9..39836d536 100644 --- a/src/infrastructure/adapters/CasPayloadPointer.ts +++ b/src/infrastructure/adapters/CasPayloadPointer.ts @@ -2,6 +2,10 @@ import WarpError from '../../domain/errors/WarpError.ts'; import { textDecode, textEncode } from '../../domain/utils/bytes.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type { BlobStorageOptions } from '../../ports/BlobStoragePort.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; const POINTER_PREFIX = 'git-warp:cas-pointer:v1:'; const POINTER_PREFIX_BYTES = textEncode(POINTER_PREFIX); @@ -21,6 +25,13 @@ type PayloadBlobWriteRequest = { readonly options?: BlobStorageOptions; }; +type PayloadBlobReadRequest = { + readonly blobPort: BlobReader; + readonly blobStorage: BlobStoragePort | null | undefined; + readonly oid: string; + readonly compatibilityPolicy?: SubstrateCompatibilityPolicyValue; +}; + function hasPointerPrefix(bytes: Uint8Array): boolean { if (bytes.length < POINTER_PREFIX_BYTES.length) { return false; @@ -64,21 +75,41 @@ export async function writePayloadBlob(request: PayloadBlobWriteRequest): Promis return await blobPort.writeBlob(encodeCasPayloadPointer(storageOid)); } -export async function readPayloadBlob( - blobPort: BlobReader, - blobStorage: BlobStoragePort | null | undefined, - oid: string, -): Promise { - const bytes = await blobPort.readBlob(oid); +export async function readPayloadBlob(request: PayloadBlobReadRequest): Promise { + const bytes = await request.blobPort.readBlob(request.oid); const storageOid = decodeCasPayloadPointer(bytes); if (storageOid === null) { - return bytes; + return inlinePayloadBytes(request, bytes); } - if (blobStorage === null || blobStorage === undefined) { + if (request.blobStorage === null || request.blobStorage === undefined) { throw new WarpError( - `Blob ${oid} is a CAS payload pointer but no blobStorage is configured`, + `Blob ${request.oid} is a CAS payload pointer but no blobStorage is configured`, 'E_INVALID_DEPENDENCY', ); } - return await blobStorage.retrieve(storageOid); + return await request.blobStorage.retrieve(storageOid); +} + +function inlinePayloadBytes( + request: PayloadBlobReadRequest, + bytes: Uint8Array, +): Uint8Array { + if (request.blobStorage !== null && request.blobStorage !== undefined) { + requireLegacyInlinePayloadPolicy(request.oid, request.compatibilityPolicy); + } + return bytes; +} + +function requireLegacyInlinePayloadPolicy( + oid: string, + policy: SubstrateCompatibilityPolicyValue | undefined, +): void { + const resolvedPolicy = policy ?? CURRENT_SUBSTRATE_ONLY_POLICY; + if (resolvedPolicy.legacyInlinePayloadReads) { + return; + } + throw new WarpError( + `Inline payload blob ${oid} requires the substrate migration compatibility policy`, + 'E_LEGACY_SUBSTRATE_DISABLED', + ); } diff --git a/src/infrastructure/adapters/CasSeekCacheAdapter.ts b/src/infrastructure/adapters/CasSeekCacheAdapter.ts index 8377f6d1b..2585e0676 100644 --- a/src/infrastructure/adapters/CasSeekCacheAdapter.ts +++ b/src/infrastructure/adapters/CasSeekCacheAdapter.ts @@ -22,14 +22,20 @@ import { createLazyCas } from './lazyCasInit.ts'; import { createCdcCasStore } from './CasStoreFactory.ts'; import CacheError from '../../domain/errors/CacheError.ts'; import { textEncode, textDecode, concatBytes } from '../../domain/utils/bytes.ts'; +import CasContentEncryptionPolicy, { + type CasRestoreEncryptionArguments, + type CasStoreEncryptionArguments, + type CasStoreEncryptionOptions, + mapCasContentEncryptionError, +} from './CasContentEncryptionPolicy.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import { Readable } from 'node:stream'; interface CasStore { readManifest(opts: { treeOid: string }): Promise; - restore(opts: { manifest: unknown; encryptionKey?: Uint8Array }): Promise<{ buffer: Uint8Array }>; - restoreStream?: (opts: { manifest: unknown; encryptionKey?: Uint8Array }) => AsyncIterable; - store(opts: { source: Readable; slug: string; filename: string; encryptionKey?: Uint8Array }): Promise; + restore(opts: { manifest: unknown } & CasRestoreEncryptionArguments): Promise<{ buffer: Uint8Array }>; + restoreStream?: (opts: { manifest: unknown } & CasRestoreEncryptionArguments) => AsyncIterable; + store(opts: { source: Readable; slug: string; filename: string } & CasStoreEncryptionArguments): Promise; createTree(opts: { manifest: unknown }): Promise; } @@ -109,15 +115,17 @@ export default class CasSeekCacheAdapter extends SeekCachePort { private readonly _maxEntries: number; private readonly _ref: string; private readonly _encryptionKey: Uint8Array | undefined; + private readonly _contentEncryption: CasContentEncryptionPolicy; private readonly _logger: LoggerPort | undefined; private readonly _getCas: () => Promise; - constructor({ persistence, plumbing, graphName, maxEntries, encryptionKey, logger }: { + constructor({ persistence, plumbing, graphName, maxEntries, encryptionKey, contentEncryption, logger }: { persistence: CachePersistence; plumbing: unknown; graphName: string; maxEntries?: number; encryptionKey?: Uint8Array; + contentEncryption?: CasContentEncryptionPolicy; logger?: LoggerPort; }) { super(); @@ -126,6 +134,7 @@ export default class CasSeekCacheAdapter extends SeekCachePort { this._maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES; this._ref = buildSeekCacheRef(graphName); this._encryptionKey = encryptionKey; + this._contentEncryption = resolveContentEncryption(contentEncryption, this._encryptionKey); this._logger = logger; this._getCas = createLazyCas(() => this._initCas()); } @@ -223,7 +232,7 @@ export default class CasSeekCacheAdapter extends SeekCachePort { // Restore helpers // --------------------------------------------------------------------------- - private async _restoreBuffer(cas: CasStore, restoreOpts: { manifest: unknown; encryptionKey?: Uint8Array }): Promise { + private async _restoreBuffer(cas: CasStore, restoreOpts: { manifest: unknown } & CasRestoreEncryptionArguments): Promise { if (typeof cas.restoreStream === 'function') { const stream = cas.restoreStream(restoreOpts); const chunks: Uint8Array[] = []; @@ -253,7 +262,11 @@ export default class CasSeekCacheAdapter extends SeekCachePort { try { return await this._getEntry(cas, key, entry); - } catch { + } catch (err) { + const encryptionError = mapCasContentEncryptionError(err, 'seek-cache'); + if (encryptionError !== null) { + throw encryptionError; + } await this._mutateIndex((idx) => { delete idx.entries[key]; return idx; @@ -264,10 +277,10 @@ export default class CasSeekCacheAdapter extends SeekCachePort { private async _getEntry(cas: CasStore, key: string, entry: IndexEntry): Promise { const manifest = await cas.readManifest({ treeOid: entry.treeOid }); - const restoreOpts: { manifest: unknown; encryptionKey?: Uint8Array } = { manifest }; - if (this._encryptionKey !== null && this._encryptionKey !== undefined) { - restoreOpts.encryptionKey = this._encryptionKey; - } + const restoreOpts: { manifest: unknown } & CasRestoreEncryptionArguments = { + manifest, + ...this._contentEncryption.toRestoreOptions(), + }; const buffer = await this._restoreBuffer(cas, restoreOpts); await this._mutateIndex((idx) => { const tracked = idx.entries[key]; @@ -309,10 +322,12 @@ export default class CasSeekCacheAdapter extends SeekCachePort { private async _storeCasAsset(cas: CasStore, key: string, buffer: Uint8Array): Promise<{ manifest: unknown; treeOid: string }> { const source = Readable.from([buffer]); - const storeOpts: { source: Readable; slug: string; filename: string; encryptionKey?: Uint8Array } = { source, slug: key, filename: 'state.cbor' }; - if (this._encryptionKey !== null && this._encryptionKey !== undefined) { - storeOpts.encryptionKey = this._encryptionKey; - } + const storeOpts: { source: Readable; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { + source, + slug: key, + filename: 'state.cbor', + ...this._contentEncryption.toStoreOptions(), + }; const manifest = await cas.store(storeOpts); const treeOid = await cas.createTree({ manifest }); return { manifest, treeOid }; @@ -346,3 +361,16 @@ export default class CasSeekCacheAdapter extends SeekCachePort { } } } + +function resolveContentEncryption( + contentEncryption: CasContentEncryptionPolicy | undefined, + encryptionKey: Uint8Array | undefined, +): CasContentEncryptionPolicy { + if (contentEncryption !== undefined) { + return contentEncryption; + } + if (encryptionKey !== undefined) { + return CasContentEncryptionPolicy.fromInternalResolvedKey({ encryptionKey }); + } + return CasContentEncryptionPolicy.disabled(); +} diff --git a/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts index 4bca74de8..47ef1630b 100644 --- a/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts +++ b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts @@ -124,14 +124,14 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { } const reads: Array> = [ - readPayloadBlob(this._blobPort, this._blobStorage, stateOid), - readPayloadBlob(this._blobPort, this._blobStorage, frontierOid), + readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: stateOid }), + readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: frontierOid }), ]; if (appliedVVOid !== undefined) { - reads.push(readPayloadBlob(this._blobPort, this._blobStorage, appliedVVOid)); + reads.push(readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: appliedVVOid })); } if (provenanceOid !== undefined) { - reads.push(readPayloadBlob(this._blobPort, this._blobStorage, provenanceOid)); + reads.push(readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: provenanceOid })); } const buffers = await Promise.all(reads); diff --git a/src/infrastructure/adapters/CborIndexStoreAdapter.ts b/src/infrastructure/adapters/CborIndexStoreAdapter.ts index 1b9d1682a..dc500533b 100644 --- a/src/infrastructure/adapters/CborIndexStoreAdapter.ts +++ b/src/infrastructure/adapters/CborIndexStoreAdapter.ts @@ -141,7 +141,11 @@ export class CborIndexStoreAdapter extends IndexStorePort { continue; } const blobOid = oids[path] as string; - const bytes = await readPayloadBlob(adapter._blobPort, adapter._blobStorage, blobOid); + const bytes = await readPayloadBlob({ + blobPort: adapter._blobPort, + blobStorage: adapter._blobStorage, + oid: blobOid, + }); const data = adapter._codec.decode(bytes); yield shard(data); } @@ -153,7 +157,11 @@ export class CborIndexStoreAdapter extends IndexStorePort { } override async decodeShard(blobOid: string): Promise { - const bytes = await readPayloadBlob(this._blobPort, this._blobStorage, blobOid); + const bytes = await readPayloadBlob({ + blobPort: this._blobPort, + blobStorage: this._blobStorage, + oid: blobOid, + }); return this._codec.decode(bytes); } } diff --git a/src/infrastructure/adapters/CborPatchJournalAdapter.ts b/src/infrastructure/adapters/CborPatchJournalAdapter.ts index 80eff0209..6451cf676 100644 --- a/src/infrastructure/adapters/CborPatchJournalAdapter.ts +++ b/src/infrastructure/adapters/CborPatchJournalAdapter.ts @@ -17,6 +17,10 @@ import { type PatchStorageRoute, type default as CommitMessageCodecPort, } from '../../ports/CommitMessageCodecPort.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; interface BlobPort { readBlob(oid: string): Promise; @@ -45,8 +49,9 @@ export class CborPatchJournalAdapter extends PatchJournalPort { private readonly _legacyPatchBlobStorage: BlobStoragePort | null; private readonly _writeStorage: PatchStorageRoute; private readonly _commitMessageCodec: CommitMessageCodecPort; + private readonly _compatibilityPolicy: SubstrateCompatibilityPolicyValue; - constructor({ codec, blobPort, commitPort, patchBlobStorage, blobStorage, legacyPatchBlobStorage, writeStorage, commitMessageCodec }: { + constructor({ codec, blobPort, commitPort, patchBlobStorage, blobStorage, legacyPatchBlobStorage, writeStorage, commitMessageCodec, compatibilityPolicy }: { codec: CodecPort; blobPort: BlobPort; commitPort?: CommitPort; @@ -55,6 +60,7 @@ export class CborPatchJournalAdapter extends PatchJournalPort { legacyPatchBlobStorage?: BlobStoragePort | null; writeStorage?: PatchStorageRoute; commitMessageCodec?: CommitMessageCodecPort; + compatibilityPolicy?: SubstrateCompatibilityPolicyValue; }) { super(); if (codec === null || codec === undefined) { @@ -72,6 +78,7 @@ export class CborPatchJournalAdapter extends PatchJournalPort { ? LEGACY_EXTERNAL_PATCH_STORAGE : LEGACY_GIT_BLOB_PATCH_STORAGE); this._commitMessageCodec = commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC; + this._compatibilityPolicy = compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; } override async writePatch(patch: Patch): Promise { @@ -97,7 +104,8 @@ export class CborPatchJournalAdapter extends PatchJournalPort { patchOid: string, { storage, encrypted = false }: ReadPatchOptions = {}, ): Promise { - const resolvedStorage = storage ?? (encrypted ? LEGACY_EXTERNAL_PATCH_STORAGE : LEGACY_GIT_BLOB_PATCH_STORAGE); + const resolvedStorage = storage ?? (encrypted ? LEGACY_EXTERNAL_PATCH_STORAGE : this._writeStorage); + this._requireReadableStorage(resolvedStorage); let bytes: Uint8Array; if (resolvedStorage.strategy === 'git-cas') { if (this._blobStorage === null) { @@ -119,6 +127,23 @@ export class CborPatchJournalAdapter extends PatchJournalPort { return hydrateDecodedPatch(this._codec.decode(bytes)); } + private _requireReadableStorage(storage: PatchStorageRoute): void { + if (storage.strategy === 'git-cas' || this._isConfiguredWriteRoute(storage)) { + return; + } + if (this._compatibilityPolicy.legacyPatchStorageReads) { + return; + } + throw new WarpError( + `Legacy patch storage reads require the substrate migration compatibility policy: ${storage.strategy}`, + 'E_LEGACY_SUBSTRATE_DISABLED', + ); + } + + private _isConfiguredWriteRoute(storage: PatchStorageRoute): boolean { + return this._writeStorage.strategy === storage.strategy && this._blobStorage === null; + } + override get writeStorage(): PatchStorageRoute { return this._writeStorage; } diff --git a/src/infrastructure/adapters/FetchSyncHttpClientAdapter.ts b/src/infrastructure/adapters/FetchSyncHttpClientAdapter.ts index 0688424c5..0997a91cf 100644 --- a/src/infrastructure/adapters/FetchSyncHttpClientAdapter.ts +++ b/src/infrastructure/adapters/FetchSyncHttpClientAdapter.ts @@ -12,7 +12,6 @@ * `src/domain/**`. */ -import { timeout, TimeoutError } from '@git-stunts/alfred'; import SyncHttpClientPort, { type SyncHttpAuth, type SyncHttpClientRequest, @@ -21,11 +20,25 @@ import SyncHttpClientPort, { } from '../../ports/SyncHttpClientPort.ts'; import type { SyncResponse } from '../../domain/services/sync/SyncProtocol.ts'; import { signSyncRequest, canonicalizePath } from '../../domain/services/sync/SyncAuthService.ts'; +import OperationPolicyTimeoutError from '../../domain/errors/OperationPolicyTimeoutError.ts'; +import type OperationPolicyPort from '../../ports/OperationPolicyPort.ts'; +import AlfredOperationPolicyAdapter from './AlfredOperationPolicyAdapter.ts'; + +type FetchSyncHttpClientAdapterOptions = { + readonly policy?: OperationPolicyPort; +}; /** * Implementation of SyncHttpClientPort using `fetch`. */ export default class FetchSyncHttpClientAdapter extends SyncHttpClientPort { + private readonly _policy: OperationPolicyPort; + + constructor(options: FetchSyncHttpClientAdapterOptions = {}) { + super(); + this._policy = options.policy ?? new AlfredOperationPolicyAdapter(); + } + async exchange( request: SyncHttpClientRequest, telemetry: SyncHttpClientTelemetry, @@ -51,17 +64,16 @@ export default class FetchSyncHttpClientAdapter extends SyncHttpClientPort { headers: Record, ): Promise<{ kind: 'http-response'; response: Response } | SyncHttpClientResult> { try { - const response = await timeout(request.timeoutMs, (timeoutSignal: AbortSignal) => { - const combinedSignal = request.signal - ? AbortSignal.any([timeoutSignal, request.signal]) - : timeoutSignal; - return fetch(request.targetUrl.toString(), { + const response = await this._policy.execute((policySignal?: AbortSignal) => { + const combinedSignal = combineAbortSignals(policySignal, request.signal); + const init: RequestInit = { method: 'POST', headers, body: bodyStr, - signal: combinedSignal, - }); - }); + ...(combinedSignal !== undefined ? { signal: combinedSignal } : {}), + }; + return fetch(request.targetUrl.toString(), init); + }, { timeoutMs: request.timeoutMs }); return { kind: 'http-response', response }; } catch (err) { return classifyTransportError(err); @@ -117,6 +129,7 @@ async function signForRequest( secret: auth.secret, keyId: auth.keyId !== undefined && auth.keyId !== '' ? auth.keyId : 'default', lamport: auth.lamport, + authScheme: auth.scheme, }, { crypto: auth.crypto }, ); @@ -127,8 +140,21 @@ async function signForRequest( * typed SyncHttpClientResult variant. */ function classifyTransportError(err: unknown): SyncHttpClientResult { - if (err instanceof TimeoutError) { return { kind: 'timeout' }; } + if (err instanceof OperationPolicyTimeoutError) { return { kind: 'timeout' }; } if (err instanceof Error && err.name === 'AbortError') { return { kind: 'aborted' }; } const message = err instanceof Error ? err.message : String(err); return { kind: 'network-failure', message }; } + +function combineAbortSignals( + first: AbortSignal | undefined, + second: AbortSignal | undefined, +): AbortSignal | undefined { + if (first === undefined) { + return second; + } + if (second === undefined) { + return first; + } + return AbortSignal.any([first, second]); +} diff --git a/src/infrastructure/adapters/GitGraphAdapter.ts b/src/infrastructure/adapters/GitGraphAdapter.ts index dd76c0bbb..3b5f2989d 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.ts +++ b/src/infrastructure/adapters/GitGraphAdapter.ts @@ -5,7 +5,6 @@ * composite GraphPersistencePort (CommitPort + BlobPort + TreePort + * RefPort + ConfigPort). */ -import { retry, type RetryOptions } from '@git-stunts/alfred'; import { GitPersistenceAdapter } from '@git-stunts/git-cas'; import type { CommitLogChunk, CommitNodeOptions, CommitNodeWithTreeOptions, LogNodesOptions, NodeInfo, PingResult } from '../../ports/CommitPort.ts'; import type { ListRefsOptions } from '../../ports/RefPort.ts'; @@ -20,9 +19,11 @@ import AdapterValidationError from '../../domain/errors/AdapterValidationError.t import PersistenceError from '../../domain/errors/PersistenceError.ts'; import GraphPersistencePort from '../../ports/GraphPersistencePort.ts'; import CasBlobAdapter from './CasBlobAdapter.ts'; +import type CasContentEncryptionPolicy from './CasContentEncryptionPolicy.ts'; import GitCasGraphReaderAdapter from './GitCasGraphReaderAdapter.ts'; import GitRecursiveTreeOidReaderAdapter from './GitRecursiveTreeOidReaderAdapter.ts'; import GitTrieStoreAdapter from './GitTrieStoreAdapter.ts'; +import AlfredOperationPolicyAdapter from './AlfredOperationPolicyAdapter.ts'; import WarpStream from '../../domain/stream/WarpStream.ts'; import { textEncode } from '../../domain/utils/bytes.ts'; import type { ContentAnchorObjectType } from '../../domain/services/state/checkpointHelpers.ts'; @@ -38,16 +39,16 @@ import { DEFAULT_RETRY_OPTIONS, } from './gitErrorClassification.ts'; import { createGitCasPatchStorage, type PatchStorageRoute } from '../../ports/CommitMessageCodecPort.ts'; - -// Re-export for downstream consumers that reference these types via the adapter module. -export type { GitPlumbing, GitError } from './gitErrorClassification.ts'; -export type { CollectableStream } from './gitErrorClassification.ts'; +import type OperationPolicyPort from '../../ports/OperationPolicyPort.ts'; +import type { OperationPolicyExecuteOptions } from '../../ports/OperationPolicyPort.ts'; +export type { GitPlumbing, GitError, CollectableStream } from './gitErrorClassification.ts'; interface GitGraphAdapterOptions { readonly plumbing: GitPlumbing; - readonly retryOptions?: Partial; + readonly retryOptions?: Partial; + readonly policy?: OperationPolicyPort; + readonly casContentEncryption?: CasContentEncryptionPolicy; } - interface GitCasPolicy { execute(operation: () => Promise): Promise; } @@ -65,10 +66,13 @@ function toGitCasBlobContent(content: Uint8Array | string): Uint8Array { /** * Adapts git-warp retry options to git-cas's policy-shaped boundary. */ -function createGitCasRetryPolicy(retryOptions: RetryOptions): GitCasPolicy { +function createGitCasRetryPolicy( + policy: OperationPolicyPort, + retryOptions: OperationPolicyExecuteOptions, +): GitCasPolicy { return Object.freeze({ async execute(operation: () => Promise): Promise { - return await retry(operation, retryOptions); + return await policy.execute(operation, retryOptions); }, }); } @@ -89,24 +93,31 @@ function parseContentAnchorObjectType( export default class GitGraphAdapter extends GraphPersistencePort implements RuntimeStorageCapabilityPort { private readonly plumbing: GitPlumbing; - private readonly _retryOptions: RetryOptions; + private readonly _policy: OperationPolicyPort; + private readonly _retryOptions: OperationPolicyExecuteOptions; private readonly _gitCasPersistence: GitPersistenceAdapter; private readonly _gitCasGraphReader: GitCasGraphReaderAdapter; private readonly _recursiveTreeOidReader: GitRecursiveTreeOidReaderAdapter; + private readonly _casContentEncryption: CasContentEncryptionPolicy | undefined; - constructor({ plumbing, retryOptions = {} }: GitGraphAdapterOptions) { + constructor({ plumbing, retryOptions = {}, policy, casContentEncryption }: GitGraphAdapterOptions) { super(); if (plumbing === null || plumbing === undefined) { throw new AdapterValidationError('plumbing is required'); } this.plumbing = plumbing; + this._casContentEncryption = casContentEncryption; this._retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...retryOptions }; + this._policy = policy ?? new AlfredOperationPolicyAdapter({ + retryOptions: this._retryOptions, + }); this._gitCasPersistence = new GitPersistenceAdapter({ plumbing, - policy: createGitCasRetryPolicy(this._retryOptions), + policy: createGitCasRetryPolicy(this._policy, this._retryOptions), }); this._recursiveTreeOidReader = new GitRecursiveTreeOidReaderAdapter({ plumbing, + policy: this._policy, retryOptions: this._retryOptions, }); this._gitCasGraphReader = new GitCasGraphReaderAdapter({ @@ -117,7 +128,7 @@ export default class GitGraphAdapter extends GraphPersistencePort implements Run } private async _executeWithRetry(options: { args: string[]; input?: string | Buffer }): Promise { - return await retry(() => this.plumbing.execute(options), this._retryOptions); + return await this._policy.execute(() => this.plumbing.execute(options), this._retryOptions); } /** @@ -152,6 +163,7 @@ export default class GitGraphAdapter extends GraphPersistencePort implements Run return Promise.resolve(new CasBlobAdapter({ plumbing: this.plumbing, persistence: this, + ...(this._casContentEncryption ? { contentEncryption: this._casContentEncryption } : {}), })); } @@ -266,7 +278,10 @@ export default class GitGraphAdapter extends GraphPersistencePort implements Run args.push(`--format=${cleanFormat}`); } args.push(ref); - const rawStream = await this.plumbing.executeStream({ args }); + const rawStream = await this._policy.stream( + () => this.plumbing.executeStream({ args }), + this._retryOptions, + ); return WarpStream.from(rawStream); } @@ -478,7 +493,6 @@ export default class GitGraphAdapter extends GraphPersistencePort implements Run } await this._executeWithRetry({ args: ['config', key, value] }); } - private _isConfigKeyNotFound(err: GitError): boolean { return getExitCode(err) === 1; } diff --git a/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts b/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts index b75372dc2..c71ce1488 100644 --- a/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts +++ b/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts @@ -1,4 +1,3 @@ -import { retry, type RetryOptions } from '@git-stunts/alfred'; import PersistenceError from '../../domain/errors/PersistenceError.ts'; import TreeEntryFound from '../../domain/tree/TreeEntryFound.ts'; import type TreeEntryLimit from '../../domain/tree/TreeEntryLimit.ts'; @@ -12,10 +11,13 @@ import { toGitError, wrapGitError, } from './gitErrorClassification.ts'; +import type OperationPolicyPort from '../../ports/OperationPolicyPort.ts'; +import type { OperationPolicyExecuteOptions } from '../../ports/OperationPolicyPort.ts'; type GitRecursiveTreeOidReaderAdapterOptions = { readonly plumbing: GitPlumbing; - readonly retryOptions: RetryOptions; + readonly policy: OperationPolicyPort; + readonly retryOptions: OperationPolicyExecuteOptions; }; type RecursiveTreeEntry = { @@ -44,17 +46,19 @@ const GIT_OBJECT_ID_PATTERN = /^[0-9a-fA-F]{4,64}$/u; export default class GitRecursiveTreeOidReaderAdapter { private readonly _plumbing: GitPlumbing; - private readonly _retryOptions: RetryOptions; + private readonly _policy: OperationPolicyPort; + private readonly _retryOptions: OperationPolicyExecuteOptions; constructor(options: GitRecursiveTreeOidReaderAdapterOptions) { this._plumbing = options.plumbing; + this._policy = options.policy; this._retryOptions = options.retryOptions; } async readTreeOids(treeOid: string): Promise> { validateOid(treeOid); try { - const output = await retry( + const output = await this._policy.execute( () => this._plumbing.execute({ args: ['ls-tree', '-rz', treeOid] }), this._retryOptions, ); @@ -67,7 +71,7 @@ export default class GitRecursiveTreeOidReaderAdapter { async readTreeEntryOid(treeOid: string, path: TreeEntryPath): Promise { validateOid(treeOid); try { - const output = await retry( + const output = await this._policy.execute( () => this._plumbing.execute({ args: ['ls-tree', '-z', treeOid, '--', path.value], }), @@ -87,7 +91,7 @@ export default class GitRecursiveTreeOidReaderAdapter { validateOid(treeOid); const context = prefixParseContext(prefix, limit); try { - const stream = await retry( + const stream = await this._policy.stream( () => this._plumbing.executeStream({ args: ['ls-tree', '-z', treeOid, '--', context.childPrefix], }), diff --git a/src/infrastructure/adapters/GitTrustChainAdapter.ts b/src/infrastructure/adapters/GitTrustChainAdapter.ts index faae6cec4..8b6b99dbb 100644 --- a/src/infrastructure/adapters/GitTrustChainAdapter.ts +++ b/src/infrastructure/adapters/GitTrustChainAdapter.ts @@ -22,6 +22,10 @@ import TrustError from '../../domain/errors/TrustError.ts'; import { createLazyCas } from './lazyCasInit.ts'; import { loadGitCasConstructors } from './gitCasModule.ts'; import LoggerObservabilityBridge from './LoggerObservabilityBridge.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import type CryptoPort from '../../ports/CryptoPort.ts'; import { Readable } from 'node:stream'; @@ -68,6 +72,7 @@ type GitTrustChainDeps = { readonly plumbing: Plumbing; readonly crypto: CryptoPort; readonly logger?: LoggerPort; + readonly compatibilityPolicy?: SubstrateCompatibilityPolicyValue; }; // -- Plumbing helpers --------------------------------------------------------- @@ -188,6 +193,7 @@ export default class GitTrustChainAdapter extends TrustChainPort { private readonly _crypto: CryptoPort; private readonly _logger: LoggerPort | undefined; private readonly _getCas: () => Promise; + private readonly _compatibilityPolicy: SubstrateCompatibilityPolicyValue; private _cbor: CborCodecInstance | null = null; constructor(deps: GitTrustChainDeps) { @@ -196,6 +202,7 @@ export default class GitTrustChainAdapter extends TrustChainPort { this._crypto = deps.crypto; this._logger = deps.logger; this._getCas = createLazyCas(() => this._initCas()); + this._compatibilityPolicy = deps.compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; } private async _initCas(): Promise { @@ -245,6 +252,7 @@ export default class GitTrustChainAdapter extends TrustChainPort { const decoded = cbor.decode(restored.buffer) as Record; return decoded['recordId'] ?? null; } catch { + this._requireLegacyTrustRecordPolicy(commitSha); // Fallback: try reading as raw blob (pre-CAS migration) const entries = await readTreeEntries(this._plumbing, info.treeSha); const manifestOid = entries.get(RECORD_BLOB_NAME); @@ -306,6 +314,7 @@ export default class GitTrustChainAdapter extends TrustChainPort { const restored = await cas.restore({ manifest }); rawRecord = cbor.decode(restored.buffer) as typeof rawRecord; } catch { + this._requireLegacyTrustRecordPolicy(commitSha); // Fallback: pre-CAS raw blob const entries = await readTreeEntries(this._plumbing, info.treeSha); const blobOid = entries.get(RECORD_BLOB_NAME); @@ -343,6 +352,16 @@ export default class GitTrustChainAdapter extends TrustChainPort { }); } + private _requireLegacyTrustRecordPolicy(commitSha: string): void { + if (this._compatibilityPolicy.legacyTrustRecordBlobReads) { + return; + } + throw new TrustError( + `Legacy trust record blob reads require the substrate migration compatibility policy: ${commitSha}`, + { code: 'E_LEGACY_SUBSTRATE_DISABLED', context: { commitSha } }, + ); + } + // -- Port implementation: persistRecord ------------------------------------- async persistRecord( diff --git a/src/infrastructure/adapters/NoopOperationPolicyAdapter.ts b/src/infrastructure/adapters/NoopOperationPolicyAdapter.ts new file mode 100644 index 000000000..767609af7 --- /dev/null +++ b/src/infrastructure/adapters/NoopOperationPolicyAdapter.ts @@ -0,0 +1,19 @@ +import OperationPolicyPort, { + type OperationPolicyExecuteOptions, +} from '../../ports/OperationPolicyPort.ts'; + +export default class NoopOperationPolicyAdapter extends OperationPolicyPort { + override async execute( + operation: (signal?: AbortSignal) => Promise, + options: OperationPolicyExecuteOptions = {}, + ): Promise { + return await operation(options.signal); + } + + override async stream( + operation: (signal?: AbortSignal) => Promise>, + options: OperationPolicyExecuteOptions = {}, + ): Promise> { + return await operation(options.signal); + } +} diff --git a/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts b/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts new file mode 100644 index 000000000..d7b6dd5c6 --- /dev/null +++ b/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts @@ -0,0 +1,26 @@ +type SubstrateCompatibilityPolicyFields = { + readonly legacyContentBlobReads?: boolean; + readonly legacyInlinePayloadReads?: boolean; + readonly legacyPatchStorageReads?: boolean; + readonly legacyTrustRecordBlobReads?: boolean; +}; + +/** Explicit adapter boundary for retired substrate read compatibility. */ +export default class SubstrateCompatibilityPolicy { + readonly legacyContentBlobReads: boolean; + readonly legacyInlinePayloadReads: boolean; + readonly legacyPatchStorageReads: boolean; + readonly legacyTrustRecordBlobReads: boolean; + + constructor(fields: SubstrateCompatibilityPolicyFields = {}) { + this.legacyContentBlobReads = fields.legacyContentBlobReads === true; + this.legacyInlinePayloadReads = fields.legacyInlinePayloadReads === true; + this.legacyPatchStorageReads = fields.legacyPatchStorageReads === true; + this.legacyTrustRecordBlobReads = fields.legacyTrustRecordBlobReads === true; + Object.freeze(this); + } +} + +export type SubstrateCompatibilityPolicyValue = SubstrateCompatibilityPolicy; + +export const CURRENT_SUBSTRATE_ONLY_POLICY = new SubstrateCompatibilityPolicy(); diff --git a/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts b/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts index 348133902..e9e883619 100644 --- a/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts +++ b/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts @@ -1,5 +1,5 @@ import { TrailerCodec, TrailerCodecService, type TrailerCodecFacade } from '@git-stunts/trailer-codec'; -import { z } from 'zod'; +import z from 'zod'; import CommitMessageCodecPort, { type AnchorCommitMessage, type CheckpointCommitMessage, diff --git a/src/infrastructure/adapters/gitErrorClassification.ts b/src/infrastructure/adapters/gitErrorClassification.ts index 699240382..731e32878 100644 --- a/src/infrastructure/adapters/gitErrorClassification.ts +++ b/src/infrastructure/adapters/gitErrorClassification.ts @@ -5,8 +5,8 @@ * PersistenceError categories. Extracted from GitGraphAdapter to keep * the adapter under the 500 LOC limit. */ -import type { RetryOptions } from '@git-stunts/alfred'; import PersistenceError from '../../domain/errors/PersistenceError.ts'; +import type { OperationPolicyExecuteOptions } from '../../ports/OperationPolicyPort.ts'; // --------------------------------------------------------------------------- // Types — shapes of errors and dependencies from the Git plumbing boundary @@ -201,7 +201,7 @@ export function wrapGitError(err: GitError, hint: GitErrorHint = {}): GitError | // --------------------------------------------------------------------------- /** Default retry options for git operations. Exponential backoff with jitter. */ -export const DEFAULT_RETRY_OPTIONS: RetryOptions = { +export const DEFAULT_RETRY_OPTIONS: OperationPolicyExecuteOptions = { retries: 3, delay: 100, maxDelay: 2000, diff --git a/src/ports/OperationPolicyPort.ts b/src/ports/OperationPolicyPort.ts new file mode 100644 index 000000000..9eac43efc --- /dev/null +++ b/src/ports/OperationPolicyPort.ts @@ -0,0 +1,28 @@ +export type OperationPolicyBackoff = 'constant' | 'linear' | 'exponential'; +export type OperationPolicyJitter = 'none' | 'full' | 'equal' | 'decorrelated'; +export type OperationRetryDecision = (error: Error) => boolean; +export type OperationRetryObserver = (error: Error, attempt: number, delayMs: number) => void; + +export type OperationPolicyExecuteOptions = { + readonly retries?: number; + readonly delay?: number; + readonly maxDelay?: number; + readonly backoff?: OperationPolicyBackoff; + readonly jitter?: OperationPolicyJitter; + readonly shouldRetry?: OperationRetryDecision; + readonly onRetry?: OperationRetryObserver; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +}; + +export default abstract class OperationPolicyPort { + abstract execute( + _operation: (signal?: AbortSignal) => Promise, + _options?: OperationPolicyExecuteOptions, + ): Promise; + + abstract stream( + _operation: (signal?: AbortSignal) => Promise>, + _options?: OperationPolicyExecuteOptions, + ): Promise>; +} diff --git a/src/ports/SyncHttpClientPort.ts b/src/ports/SyncHttpClientPort.ts index 1cda3a412..0d53c3c8a 100644 --- a/src/ports/SyncHttpClientPort.ts +++ b/src/ports/SyncHttpClientPort.ts @@ -13,6 +13,7 @@ import type { SyncRequest, SyncResponse } from '../domain/services/sync/SyncProtocol.ts'; import type SyncSecret from '../domain/services/sync/SyncSecret.ts'; +import type { SyncAuthScheme } from '../domain/services/sync/SyncAuthService.ts'; import type CryptoPort from './CryptoPort.ts'; /** @@ -20,6 +21,7 @@ import type CryptoPort from './CryptoPort.ts'; * this to sign the request body after serialization. */ export interface SyncHttpAuth { + readonly scheme: SyncAuthScheme; readonly secret: SyncSecret; readonly keyId?: string; readonly lamport: number; diff --git a/src/ports/WarpKernelPort.ts b/src/ports/WarpKernelPort.ts new file mode 100644 index 000000000..df8216458 --- /dev/null +++ b/src/ports/WarpKernelPort.ts @@ -0,0 +1,14 @@ +import type CommitPort from './CommitPort.ts'; +import type BlobPort from './BlobPort.ts'; +import type TreePort from './TreePort.ts'; +import type RefPort from './RefPort.ts'; + +/** + * Cohesive kernel persistence surface for the WARP runtime. + * + * `WarpKernelPort` names the four focused Git persistence ports required by + * graph mutation, materialization, and sync orchestration. It is a type-only + * port so adapters can keep extending `GraphPersistencePort` for runtime + * conformance while domain services depend on the explicit kernel contract. + */ +export default interface WarpKernelPort extends CommitPort, BlobPort, TreePort, RefPort {} diff --git a/test/benchmark/MergeConflictCorpus.benchmark.ts b/test/benchmark/MergeConflictCorpus.benchmark.ts new file mode 100644 index 000000000..2fd64108b --- /dev/null +++ b/test/benchmark/MergeConflictCorpus.benchmark.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + MERGE_CONFLICT_CORPUS, + summarizeMergeConflictCorpus, +} from '../fixtures/mergeConflictCorpus.ts'; +import { logEnvironment, runBenchmark } from './benchmarkUtils.ts'; + +const WARMUP_RUNS = 1; +const MEASURED_RUNS = 3; + +function projectionCases() { + return MERGE_CONFLICT_CORPUS.filter((item) => item.classification === 'projection'); +} + +function policyCases() { + return MERGE_CONFLICT_CORPUS.filter((item) => !item.liftingRemovesConflict); +} + +describe('Merge Conflict Corpus Benchmark', () => { + it('logs environment info', () => { + logEnvironment(); + expect(true).toBe(true); + }); + + it('measures corpus summarization and filter passes', async () => { + const summaryStats = await runBenchmark( + () => { + summarizeMergeConflictCorpus(); + }, + WARMUP_RUNS, + MEASURED_RUNS, + ); + const projectionStats = await runBenchmark( + () => { + projectionCases(); + }, + WARMUP_RUNS, + MEASURED_RUNS, + ); + const policyStats = await runBenchmark( + () => { + policyCases(); + }, + WARMUP_RUNS, + MEASURED_RUNS, + ); + + const summary = summarizeMergeConflictCorpus(); + console.log('\n merge conflict corpus:'); + console.log(` cases: ${summary.total}`); + console.log(` projection/semantic/governance: ${summary.projection}/${summary.semantic}/${summary.governance}`); + console.log(` lifted away: ${summary.liftedAway}`); + console.log(` requires policy: ${summary.requiresPolicy}`); + console.log(` summarize median: ${summaryStats.median.toFixed(3)}ms`); + console.log(` projection filter median: ${projectionStats.median.toFixed(3)}ms`); + console.log(` policy filter median: ${policyStats.median.toFixed(3)}ms`); + + expect(summary.total).toBe(MERGE_CONFLICT_CORPUS.length); + expect(summaryStats.median).toBeGreaterThanOrEqual(0); + expect(projectionStats.median).toBeGreaterThanOrEqual(0); + expect(policyStats.median).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/test/conformance/queryReadModelSeam.test.ts b/test/conformance/queryReadModelSeam.test.ts index 627364e1d..15e1c84ac 100644 --- a/test/conformance/queryReadModelSeam.test.ts +++ b/test/conformance/queryReadModelSeam.test.ts @@ -153,12 +153,17 @@ describe('query read model seam', () => { expect(provider.nodePropsCalls).toBe(0); expect(provider.neighborRequests).toEqual([]); expect(provider.openRequests).toEqual([ - { + expect.objectContaining({ nodeRequest: { pattern: 'node:target', select: ['id'] }, operations: [], aggregate: false, - }, + }), ]); + const openRequest = provider.openRequests[0]; + expect(openRequest?.supportRule.kind).toBe('entity'); + expect(openRequest?.causalIndexPlan.families).toEqual(['entity-patch']); + expect(openRequest?.supportFragmentPlan.posture).toBe('support-fragment'); + expect(openRequest?.supportFragmentPlan.scopeKey).toContain('roots:node:target'); }); it('lets QueryBuilder compose traversal and projection on the narrow provider', async () => { diff --git a/test/fixtures/mergeConflictCorpus.ts b/test/fixtures/mergeConflictCorpus.ts new file mode 100644 index 000000000..12acf85fe --- /dev/null +++ b/test/fixtures/mergeConflictCorpus.ts @@ -0,0 +1,252 @@ +export type MergeConflictClass = 'projection' | 'semantic' | 'governance'; + +export type MergeConflictDomain = + | 'api-docs' + | 'architecture-docs' + | 'cli' + | 'dependency-policy' + | 'method' + | 'runtime' + | 'testing'; + +export type MergeConflictCorpusCase = { + readonly id: string; + readonly classification: MergeConflictClass; + readonly domain: MergeConflictDomain; + readonly sourceAnchors: readonly string[]; + readonly filePaths: readonly string[]; + readonly writers: readonly string[]; + readonly scenario: string; + readonly liftingStrategy: string; + readonly liftingRemovesConflict: boolean; + readonly benchmarkWeight: number; +}; + +export type MergeConflictCorpusSummary = { + readonly total: number; + readonly projection: number; + readonly semantic: number; + readonly governance: number; + readonly liftedAway: number; + readonly requiresPolicy: number; + readonly weightedCases: number; +}; + +type CorpusArchetype = { + readonly slug: string; + readonly classification: MergeConflictClass; + readonly domain: MergeConflictDomain; + readonly sourceAnchors: readonly string[]; + readonly scenario: string; + readonly liftingStrategy: string; + readonly liftingRemovesConflict: boolean; + readonly benchmarkWeight: number; +}; + +type CorpusVariant = { + readonly slug: string; + readonly primaryFile: string; + readonly secondaryFile: string; + readonly writers: readonly string[]; +}; + +const variants: readonly CorpusVariant[] = Object.freeze([ + { + slug: 'readme-api-reference', + primaryFile: 'README.md', + secondaryFile: 'docs/API_REFERENCE.md', + writers: Object.freeze(['docs-a', 'api-b']), + }, + { + slug: 'guide-cli', + primaryFile: 'docs/GUIDE.md', + secondaryFile: 'docs/CLI_GUIDE.md', + writers: Object.freeze(['guide-a', 'cli-b']), + }, + { + slug: 'architecture-vision', + primaryFile: 'docs/ARCHITECTURE.md', + secondaryFile: 'docs/VISION.md', + writers: Object.freeze(['arch-a', 'vision-b']), + }, + { + slug: 'changelog-package', + primaryFile: 'CHANGELOG.md', + secondaryFile: 'package.json', + writers: Object.freeze(['release-a', 'release-b']), + }, + { + slug: 'runtime-controller', + primaryFile: 'src/domain/RuntimeHost.ts', + secondaryFile: 'src/domain/services/controllers/StrandController.ts', + writers: Object.freeze(['runtime-a', 'runtime-b']), + }, + { + slug: 'conflict-service', + primaryFile: 'src/domain/services/strand/ConflictAnalyzerService.ts', + secondaryFile: 'test/unit/domain/services/strand/ConflictAnalyzerService.test.ts', + writers: Object.freeze(['conflict-a', 'conflict-b']), + }, + { + slug: 'method-retro', + primaryFile: 'docs/METHOD.md', + secondaryFile: 'docs/method/retro/0012-conflict-analyzer-pipeline-decomposition/retro.md', + writers: Object.freeze(['method-a', 'retro-b']), + }, + { + slug: 'type-surface', + primaryFile: 'index.ts', + secondaryFile: 'test/type-check/consumer.ts', + writers: Object.freeze(['surface-a', 'surface-b']), + }, + { + slug: 'release-tooling', + primaryFile: 'scripts/release-preflight.sh', + secondaryFile: 'docs/method/release.md', + writers: Object.freeze(['tooling-a', 'release-b']), + }, + { + slug: 'benchmark-tests', + primaryFile: 'test/benchmark/DetachedReadBoundary.benchmark.ts', + secondaryFile: 'test/unit/scripts/run-stable-unit-tests.test.ts', + writers: Object.freeze(['bench-a', 'test-b']), + }, +]); + +const archetypes: readonly CorpusArchetype[] = Object.freeze([ + { + slug: 'lossy-rendered-view', + classification: 'projection', + domain: 'api-docs', + sourceAnchors: Object.freeze([ + 'docs/design/merge-geometry-and-theorem-spine.tex', + 'docs/design/merge-lifting-worked-examples.tex', + ]), + scenario: 'Two edits commute in structured source but collide after lowering into a rendered view.', + liftingStrategy: 'Lift paragraphs, table rows, or option entries by stable semantic keys before merge.', + liftingRemovesConflict: true, + benchmarkWeight: 2, + }, + { + slug: 'formatting-shadow', + classification: 'projection', + domain: 'architecture-docs', + sourceAnchors: Object.freeze([ + 'docs/design/causal-lifting-and-merge-conflicts.tex', + 'docs/design/merge-lifting-worked-examples.tex', + ]), + scenario: 'A formatter or section move makes independent edits appear adjacent in text.', + liftingStrategy: 'Lift through the document outline and compare only keyed content fragments.', + liftingRemovesConflict: true, + benchmarkWeight: 1, + }, + { + slug: 'singleton-slot', + classification: 'semantic', + domain: 'runtime', + sourceAnchors: Object.freeze([ + 'docs/design/merge-lifting-worked-examples.tex', + 'docs/invariants/explicit-conflict-surfacing.md', + ]), + scenario: 'Two branches assign incompatible values to the same invariant-bearing singleton.', + liftingStrategy: 'Preserve both alternatives and emit an explicit conflict object for later policy.', + liftingRemovesConflict: false, + benchmarkWeight: 3, + }, + { + slug: 'exclusive-invariant', + classification: 'semantic', + domain: 'testing', + sourceAnchors: Object.freeze([ + 'docs/design/merge-geometry-and-theorem-spine.tex', + 'docs/archive/plans/conflict-analyzer-v1.md', + ]), + scenario: 'Both branches satisfy local invariants but their combined state violates the global invariant.', + liftingStrategy: 'Keep causal evidence and classify the obstruction instead of selecting a silent winner.', + liftingRemovesConflict: false, + benchmarkWeight: 3, + }, + { + slug: 'release-authority', + classification: 'governance', + domain: 'dependency-policy', + sourceAnchors: Object.freeze([ + 'docs/method/release.md', + 'docs/invariants/explicit-conflict-surfacing.md', + ]), + scenario: 'Two valid edits require incompatible release, trust, or dependency authority decisions.', + liftingStrategy: 'Surface the competing authority claims and require an operator decision.', + liftingRemovesConflict: false, + benchmarkWeight: 2, + }, + { + slug: 'workflow-state', + classification: 'governance', + domain: 'method', + sourceAnchors: Object.freeze([ + 'docs/METHOD.md', + 'docs/design/0012-conflict-analyzer-pipeline-decomposition/conflict-analyzer-pipeline-decomposition.md', + ]), + scenario: 'Two branches move work through mutually exclusive workflow states.', + liftingStrategy: 'Lift issue state, dependency state, and acceptance evidence before applying workflow policy.', + liftingRemovesConflict: false, + benchmarkWeight: 2, + }, +]); + +function buildCase(archetype: CorpusArchetype, variant: CorpusVariant): MergeConflictCorpusCase { + return Object.freeze({ + id: `${archetype.classification}.${archetype.slug}.${variant.slug}`, + classification: archetype.classification, + domain: archetype.domain, + sourceAnchors: Object.freeze([...archetype.sourceAnchors]), + filePaths: Object.freeze([variant.primaryFile, variant.secondaryFile]), + writers: Object.freeze([...variant.writers]), + scenario: `${archetype.scenario} Corpus slice: ${variant.slug}.`, + liftingStrategy: archetype.liftingStrategy, + liftingRemovesConflict: archetype.liftingRemovesConflict, + benchmarkWeight: archetype.benchmarkWeight, + }); +} + +function buildCorpus(): readonly MergeConflictCorpusCase[] { + return Object.freeze(archetypes.flatMap((archetype) => ( + variants.map((variant) => buildCase(archetype, variant)) + ))); +} + +export const MERGE_CONFLICT_CORPUS = buildCorpus(); + +export function summarizeMergeConflictCorpus( + cases: readonly MergeConflictCorpusCase[] = MERGE_CONFLICT_CORPUS, +): MergeConflictCorpusSummary { + let projection = 0; + let semantic = 0; + let governance = 0; + let liftedAway = 0; + let weightedCases = 0; + + for (const item of cases) { + if (item.classification === 'projection') { + projection += 1; + } else if (item.classification === 'semantic') { + semantic += 1; + } else { + governance += 1; + } + if (item.liftingRemovesConflict) { + liftedAway += 1; + } + weightedCases += item.benchmarkWeight; + } + + return Object.freeze({ + total: cases.length, + projection, + semantic, + governance, + liftedAway, + requiresPolicy: cases.length - liftedAway, + weightedCases, + }); +} diff --git a/test/type-check/consumer.ts b/test/type-check/consumer.ts index 4c7e89cce..ead1cc13a 100644 --- a/test/type-check/consumer.ts +++ b/test/type-check/consumer.ts @@ -52,12 +52,13 @@ import WarpAppDefault, { createTimeoutSignal, openWarpWorldline, openWarpGraph, + WarpOpenOptions, WarpApp, WarpCore, WarpWorldline, WarpWorldlineCoordinate, WarpWorldlineOpticBasis, - Worldline, + ProjectionHandle, WorldlineSelector, LiveSelector, CoordinateSelector, @@ -86,6 +87,7 @@ import WarpAppDefault, { projectState, createStateReader, compareVisibleState, + GraphDiff, ImmutableBytes, SnapshotORSet, SnapshotVersionVector, @@ -130,8 +132,10 @@ import WarpAppDefault, { type PropValue, type SnapshotPropValue, type SyncRateLimitConfig, + type WarpKernelPort, type WarpWorldlineOpenOptions, type WarpWorldlinePatchBuild, + type GraphDiffOptions, type WarpWorldlineCoordinateFrontierEntry, } from '../../index.ts'; @@ -150,6 +154,7 @@ type PublicVisibleEdge = Readonly<{ }>; declare const persistence: GraphPersistencePort; +const kernelPersistence: WarpKernelPort = persistence; declare const indexStorage: IndexStoragePort; declare const logger: LoggerPort; declare const crypto: CryptoPort; @@ -160,6 +165,11 @@ declare const btrCodecOptions: Parameters[2]; declare const btrVerifyOptions: Parameters[2]; const sameAppCtor: typeof WarpAppDefault = WarpApp; +const parsedOpenOptions = new WarpOpenOptions({ + persistence, + graphName: 'consumer-open-options', + writerId: 'writer-open-options', +}); const exportedRuntimeSurface = [ GitGraphAdapter, @@ -204,11 +214,12 @@ const exportedRuntimeSurface = [ StrandError, WormholeError, WriterError, + WarpOpenOptions, WarpWorldline, openWarpGraph, WarpApp, WarpCore, - Worldline, + ProjectionHandle, WorldlineSelector, LiveSelector, CoordinateSelector, @@ -292,6 +303,8 @@ void sameAppCtor; void exportedRuntimeSurface; void exportedFunctionSurface; void exportedConstantSurface; +void kernelPersistence; +void parsedOpenOptions; const app: WarpApp = await WarpApp.open({ graphName: 'consumer-test', @@ -347,8 +360,8 @@ const publicUsersObserverConfig: ObserverConfig = publicUsersAperture; const worldlinePatchSha: string = await warpWorldline.commit(worldlinePatchBuild); const worldlineOpticBasis: WarpWorldlineOpticBasis = await warpWorldline.prepareOpticBasis(); const worldlineCoordinate: WarpWorldlineCoordinate = await warpWorldline.coordinate(); -const worldlineLive: Worldline = warpWorldline.live(); -const worldlineHistorical: Worldline = await warpWorldline.seek({ +const worldlineLive: ProjectionHandle = warpWorldline.live(); +const worldlineHistorical: ProjectionHandle = await warpWorldline.seek({ source: { kind: 'live', ceiling: 1 }, }); const appObserver: Observer = await app.observer(publicUsersAperture); @@ -380,11 +393,13 @@ void aliasApertureObserver; const materialized: SnapshotWarpState = await graph.materialize(); const materializedWithReceipts: { state: SnapshotWarpState; receipts: readonly ReturnType[] } = await graph.materialize({ receipts: true }); +const graphDiffOptions: GraphDiffOptions = { from: 0, to: 1, targetId: 'node-a' }; +const graphDiff: GraphDiff = await graph.diff(graphDiffOptions); const stateSnapshot: SnapshotWarpState | null = await graph.getStateSnapshot(); const graphBagStateSnapshot: SnapshotWarpState | null = await graphBag.query.getStateSnapshot(); const graphBagNodeProps: PublicPropBag | null = await graphBag.query.getNodeProps('node-a'); const graphBagQueryBuilder: QueryBuilder = graphBag.query.query(); -const graphBagWorldline: Worldline = graphBag.query.worldline(); +const graphBagWorldline: ProjectionHandle = graphBag.query.worldline(); const graphBagObserver: Observer = await graphBag.query.observer({ match: '*' }); const nodeAlive: SnapshotORSet = materialized.nodeAlive; const observedFrontier: SnapshotVersionVector = materialized.observedFrontier; @@ -403,6 +418,7 @@ if (snapshotValue instanceof ImmutableBytes) { } void materializedWithReceipts; +void graphDiff; void stateSnapshot; void graphBagStateSnapshot; void graphBagNodeProps; @@ -421,7 +437,7 @@ const neighbors: Array<{ nodeId: string; label: string; direction: 'outgoing' | const propertyCount: number = await graph.getPropertyCount(); const queryBuilder: QueryBuilder = graph.query(); const observer: Observer = await graph.observer({ match: '*' }); -const worldline: Worldline = graph.worldline(); +const worldline: ProjectionHandle = graph.worldline(); void nodeProps; void edgeProps; diff --git a/test/type-check/trailer-codec.d.ts b/test/type-check/trailer-codec.d.ts deleted file mode 100644 index 02b8a5be5..000000000 --- a/test/type-check/trailer-codec.d.ts +++ /dev/null @@ -1,23 +0,0 @@ -declare module '@git-stunts/trailer-codec' { - type TrailerMessage = { - readonly title: string; - readonly trailers: Record; - }; - - type DecodedTrailerMessage = { - readonly trailers: Record; - }; - - export type TrailerCodecFacade = { - encode(message: TrailerMessage): string; - decode(message: string): DecodedTrailerMessage; - }; - - export class TrailerCodecService {} - - export class TrailerCodec implements TrailerCodecFacade { - constructor(options: { readonly service: TrailerCodecService }); - encode(message: TrailerMessage): string; - decode(message: string): DecodedTrailerMessage; - } -} diff --git a/test/type-check/tsconfig.json b/test/type-check/tsconfig.json index 834df9935..3112e3b82 100644 --- a/test/type-check/tsconfig.json +++ b/test/type-check/tsconfig.json @@ -8,5 +8,5 @@ "noUnusedLocals": false, "noUnusedParameters": false }, - "include": ["consumer.ts", "runtime-declarations.d.ts", "trailer-codec.d.ts"] + "include": ["consumer.ts", "runtime-declarations.d.ts"] } diff --git a/test/unit/cli/commands/mcp.test.ts b/test/unit/cli/commands/mcp.test.ts new file mode 100644 index 000000000..bc61ab636 --- /dev/null +++ b/test/unit/cli/commands/mcp.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; + +import { + handleMcpMessage, + listMcpTools, + type McpGraphReadSurface, +} from '../../../../bin/cli/commands/mcp/McpProtocol.ts'; + +class McpReadGraph implements McpGraphReadSurface { + readonly graphName = 'events'; + readonly writerId = 'mcp-test'; + + hasNode(nodeId: string): Promise { + return Promise.resolve(nodeId === 'task:a'); + } + + getNodes(): Promise { + return Promise.resolve(['task:a', 'task:b']); + } + + getNodeProps(nodeId: string): Promise<{ readonly [key: string]: string } | null> { + if (nodeId === 'task:a') { + return Promise.resolve({ status: 'open' }); + } + return Promise.resolve(null); + } + + getEdges(): Promise> { + return Promise.resolve([ + { from: 'task:a', to: 'task:b', label: 'blocks', props: { status: 'active' } }, + ]); + } +} + +describe('MCP command protocol', () => { + it('advertises a narrow read-only tool catalog', () => { + expect(listMcpTools().map((tool) => tool.name)).toEqual([ + 'warp_info', + 'warp_nodes', + 'warp_node_props', + 'warp_edges', + 'warp_has_node', + ]); + }); + + it('responds to initialize with tool capability metadata', async () => { + const response = await handleMcpMessage(new McpReadGraph(), { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18' }, + }, { serverVersion: '18.0.0' }); + + expect(response).toEqual({ + jsonrpc: '2.0', + id: 1, + result: { + protocolVersion: '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { + name: 'git-warp', + version: '18.0.0', + }, + }, + }); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + 'rejects non-finite numeric request IDs', + async (id) => { + const response = await handleMcpMessage(new McpReadGraph(), { + jsonrpc: '2.0', + id, + method: 'ping', + }, { serverVersion: '18.0.0' }); + + expect(response).toEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: -32600, + message: 'Invalid Request', + }, + }); + }, + ); + + it('returns structured tool output without CLI text parsing', async () => { + const response = await handleMcpMessage(new McpReadGraph(), { + jsonrpc: '2.0', + id: 'props', + method: 'tools/call', + params: { + name: 'warp_node_props', + arguments: { nodeId: 'task:a' }, + }, + }, { serverVersion: '18.0.0' }); + + expect(response).toEqual({ + jsonrpc: '2.0', + id: 'props', + result: { + content: [ + { type: 'text', text: '{"nodeId":"task:a","props":{"status":"open"}}' }, + ], + structuredContent: { + nodeId: 'task:a', + props: { status: 'open' }, + }, + }, + }); + }); + + it('rejects invalid tool input at the MCP boundary', async () => { + const response = await handleMcpMessage(new McpReadGraph(), { + jsonrpc: '2.0', + id: 'invalid', + method: 'tools/call', + params: { + name: 'warp_node_props', + arguments: {}, + }, + }, { serverVersion: '18.0.0' }); + + expect(response).toMatchObject({ + jsonrpc: '2.0', + id: 'invalid', + error: { + code: -32602, + message: 'Invalid MCP tool input', + }, + }); + }); +}); diff --git a/test/unit/domain/RuntimePatchCollector.stream.test.ts b/test/unit/domain/RuntimePatchCollector.stream.test.ts new file mode 100644 index 000000000..f10658a45 --- /dev/null +++ b/test/unit/domain/RuntimePatchCollector.stream.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; + +import Patch from '../../../src/domain/types/Patch.ts'; +import RuntimePatchCollector from '../../../src/domain/warp/RuntimePatchCollector.ts'; +import type { PatchWithSha } from '../../../src/domain/capabilities/PatchCollector.ts'; + +function patchEntry(lamport: number, sha: string): PatchWithSha { + return { + patch: new Patch({ + writer: 'agent-1', + lamport, + context: {}, + ops: [], + }), + sha, + }; +} + +async function collect(source: AsyncIterable): Promise { + const entries: PatchWithSha[] = []; + for await (const entry of source) { + entries.push(entry); + } + return entries; +} + +describe('RuntimePatchCollector streams', () => { + it('streams frontier patches and keeps collectForFrontier as stream collection sugar', async () => { + const frontier = new Map([['agent-1', 'tip-sha']]); + const entries = [ + patchEntry(1, 'sha-1'), + patchEntry(3, 'sha-3'), + ]; + const host = { + discoverWriters: vi.fn(async () => ['agent-1']), + _loadWriterPatches: vi.fn(async () => entries), + _loadPatchChainFromSha: vi.fn(async () => entries), + _loadLatestCheckpoint: vi.fn(async () => null), + _loadPatchesSince: vi.fn(async () => []), + getFrontier: vi.fn(async () => frontier), + }; + const collector = new RuntimePatchCollector(host); + + const streamed = await collect(collector.streamForFrontier(frontier, 2)); + const collected = await collector.collectForFrontier(frontier, 2); + + expect(streamed.map((entry) => entry.sha)).toEqual(['sha-1']); + expect(collected).toEqual(streamed); + expect(host._loadPatchChainFromSha).toHaveBeenCalledWith('tip-sha'); + }); +}); diff --git a/test/unit/domain/WarpGraph.encryption.test.ts b/test/unit/domain/WarpGraph.encryption.test.ts index 0947f3a49..c97b1a62f 100644 --- a/test/unit/domain/WarpGraph.encryption.test.ts +++ b/test/unit/domain/WarpGraph.encryption.test.ts @@ -6,8 +6,11 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; import { openRuntimeHostProduct } from '../../../src/domain/warp/RuntimeHostProduct.ts'; +import defaultCodec from '../../../src/domain/utils/defaultCodec.ts'; import BlobStoragePort from '../../../src/ports/BlobStoragePort.ts'; import EncryptionError from '../../../src/domain/errors/EncryptionError.ts'; +import { CborPatchJournalAdapter } from '../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; +import SubstrateCompatibilityPolicy from '../../../src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts'; import { createInMemoryRepo } from '../../helpers/warpGraphTestUtils.ts'; // --------------------------------------------------------------------------- @@ -53,6 +56,18 @@ class InMemoryBlobStorage extends BlobStoragePort { } } +function createMixedStoragePatchJournal(repo, patchStorage) { + return new CborPatchJournalAdapter({ + codec: defaultCodec, + blobPort: repo.persistence, + commitPort: repo.persistence, + patchBlobStorage: patchStorage, + compatibilityPolicy: new SubstrateCompatibilityPolicy({ + legacyPatchStorageReads: true, + }), + }); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -162,6 +177,7 @@ describe('WarpCore encryption at rest (B164)', () => { graphName: 'mixed-test', writerId: 'writer-2', patchBlobStorage: patchStorage, + patchJournal: createMixedStoragePatchJournal(repo, patchStorage), }); await graph2.patch(p => { p.addNode('user:secret'); @@ -174,6 +190,7 @@ describe('WarpCore encryption at rest (B164)', () => { graphName: 'mixed-test', writerId: 'reader', patchBlobStorage: patchStorage, + patchJournal: createMixedStoragePatchJournal(repo, patchStorage), }); await graph3.materialize(); diff --git a/test/unit/domain/WarpGraph.materializeSlice.test.ts b/test/unit/domain/WarpGraph.materializeSlice.test.ts index d880b5ccc..859cb5f5d 100644 --- a/test/unit/domain/WarpGraph.materializeSlice.test.ts +++ b/test/unit/domain/WarpGraph.materializeSlice.test.ts @@ -41,7 +41,6 @@ describe('WarpCore.materializeSlice() (HG/SLICE/1)', () => { graphName: 'test', writerId: 'alice', }); - await graph.materialize(); const slice = await graph.materializeSlice('unknown'); diff --git a/test/unit/domain/WarpGraph.strands.compare.test.ts b/test/unit/domain/WarpGraph.strands.compare.test.ts index c81471393..364a9342b 100644 --- a/test/unit/domain/WarpGraph.strands.compare.test.ts +++ b/test/unit/domain/WarpGraph.strands.compare.test.ts @@ -364,6 +364,22 @@ describe('WarpCore strand foundation', () => { { node: 'n1', key: 'color', leftValue: 'red', rightValue: 'blue' }, ]); + const graphDiff = await graph.diff({ + from: 1, + to: 2, + targetId: 'n1', + }); + + expect(graphDiff.diffVersion).toBe('graph-diff/v1'); + expect(graphDiff.changed).toBe(true); + expect(graphDiff.left.resolved.lamportCeiling).toBe(1); + expect(graphDiff.right.resolved.lamportCeiling).toBe(2); + expect(graphDiff.nodeProperties.changed).toEqual([ + { node: 'n1', key: 'color', leftValue: 'red', rightValue: 'blue' }, + ]); + expect(graphDiff.visiblePatchDivergence.rightOnlyPatchShas).toEqual([blueSha]); + expect(Object.isFrozen(graphDiff)).toBe(true); + const factExport = exportCoordinateComparisonFact(coordinateComparison); expect(factExport).toEqual({ exportVersion: 'coordinate-comparison-fact/v1', diff --git a/test/unit/domain/WarpGraph.strands.test.ts b/test/unit/domain/WarpGraph.strands.test.ts index 45270a614..2eab314d0 100644 --- a/test/unit/domain/WarpGraph.strands.test.ts +++ b/test/unit/domain/WarpGraph.strands.test.ts @@ -237,7 +237,7 @@ describe('WarpCore strand foundation', () => { await expect(graph.getNodeProps('n1')).resolves.toMatchObject({ color: 'blue' }); }); - it('materializeStrand replays the pinned base observation even after later writes', async () => { + it('materializeStrand follows live parent truth outside overlay divergence', async () => { await simulatePatchCommit(persistence, { graphName, writerId: 'alice', @@ -265,8 +265,8 @@ describe('WarpCore strand foundation', () => { const result = /** @type {{ state: any, receipts: any[] }} */ (await graph.materializeStrand('ws_red', { receipts: true })); const reader = createStateReader(result.state); - expect(result.receipts).toHaveLength(1); - expect(reader.getNodeProps('n1')).toMatchObject({ color: 'red' }); + expect(result.receipts).toHaveLength(2); + expect(reader.getNodeProps('n1')).toMatchObject({ color: 'blue' }); await graph.materialize(); await expect(graph.getNodeProps('n1')).resolves.toMatchObject({ color: 'blue' }); @@ -339,7 +339,7 @@ describe('WarpCore strand foundation', () => { await expect(graph.getNodeProps('n1')).resolves.toMatchObject({ color: 'blue' }); }); - it('observer() can bind directly to a pinned strand instead of live truth', async () => { + it('observer() can bind to a strand overlay without sliding under live parent changes', async () => { await simulatePatchCommit(persistence, { graphName, writerId: 'alice', diff --git a/test/unit/domain/WarpGraph.test.ts b/test/unit/domain/WarpGraph.test.ts index db324e1fd..836700af9 100644 --- a/test/unit/domain/WarpGraph.test.ts +++ b/test/unit/domain/WarpGraph.test.ts @@ -646,7 +646,6 @@ describe('WarpCore', () => { .mockResolvedValueOnce(mockPatch2.patchBuffer); const state = (await graph.materialize()); - // V5 state uses ORSet expect(state.nodeAlive.contains('user:alice')).toBe(true); expect(state.nodeAlive.contains('user:bob')).toBe(true); diff --git a/test/unit/domain/WarpGraph.traverse.stream.test.ts b/test/unit/domain/WarpGraph.traverse.stream.test.ts new file mode 100644 index 000000000..41aeddd11 --- /dev/null +++ b/test/unit/domain/WarpGraph.traverse.stream.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { openRuntimeHostProduct } from '../../../src/domain/warp/RuntimeHostProduct.ts'; +import { createGitRepo } from '../../helpers/warpGraphTestUtils.ts'; + +async function collectNodes(stream: AsyncIterable): Promise { + const nodes: string[] = []; + for await (const node of stream) { + nodes.push(node); + } + return nodes; +} + +async function seedTraversalState(graph: Awaited>): Promise { + await graph.patch((patch) => { + patch.addNode('node:a'); + patch.addNode('node:b'); + patch.addNode('node:c'); + patch.addNode('node:d'); + patch.addEdge('node:a', 'node:c', 'z'); + patch.addEdge('node:a', 'node:b', 'a'); + patch.addEdge('node:b', 'node:d', 'a'); + patch.addEdge('node:c', 'node:d', 'z'); + }); + await graph.materialize(); +} + +describe('WarpCore logical traversal streams', () => { + it('exposes BFS as a public async traversal stream', async () => { + const repo = await createGitRepo('traverse-bfs-stream'); + try { + const graph = await openRuntimeHostProduct({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-1', + }); + await seedTraversalState(graph); + + const streamed = await collectNodes(graph.traverse.bfsStream('node:a', { dir: 'out' })); + const collected = await graph.traverse.bfs('node:a', { dir: 'out' }); + + expect(streamed).toEqual(['node:a', 'node:b', 'node:c', 'node:d']); + expect(streamed).toEqual(collected); + } finally { + await repo.cleanup(); + } + }); + + it('exposes DFS as a public async traversal stream', async () => { + const repo = await createGitRepo('traverse-dfs-stream'); + try { + const graph = await openRuntimeHostProduct({ + persistence: repo.persistence, + graphName: 'test', + writerId: 'writer-1', + }); + await seedTraversalState(graph); + + const streamed = await collectNodes(graph.traverse.dfsStream('node:a', { dir: 'out' })); + const collected = await graph.traverse.dfs('node:a', { dir: 'out' }); + + expect(streamed).toEqual(collected); + } finally { + await repo.cleanup(); + } + }); +}); diff --git a/test/unit/domain/WarpWorldline.capabilities.test.ts b/test/unit/domain/WarpWorldline.capabilities.test.ts index 7900d9356..817899e0a 100644 --- a/test/unit/domain/WarpWorldline.capabilities.test.ts +++ b/test/unit/domain/WarpWorldline.capabilities.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest'; import WarpWorldline from '../../../src/domain/WarpWorldline.ts'; import MemoryCapabilityReport from '../../../src/domain/memory/MemoryCapabilityReport.ts'; -import Worldline from '../../../src/domain/services/Worldline.ts'; +import ProjectionHandle from '../../../src/domain/services/ProjectionHandle.ts'; function createHandle(): WarpWorldline { return new WarpWorldline({ worldlineName: 'events', writerId: 'agent-1', commitPatch: async () => 'patch-sha', - createWorldline: () => new Worldline({ + createWorldline: () => new ProjectionHandle({ graph: { observer: async () => { throw new Error('unused observer path'); diff --git a/test/unit/domain/WarpWorldline.test.ts b/test/unit/domain/WarpWorldline.test.ts index 6343bff6e..c273b9944 100644 --- a/test/unit/domain/WarpWorldline.test.ts +++ b/test/unit/domain/WarpWorldline.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { openWarpWorldline } from '../../../index.ts'; import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; import WarpWorldline, { type WarpWorldlinePatchBuild } from '../../../src/domain/WarpWorldline.ts'; -import Worldline from '../../../src/domain/services/Worldline.ts'; +import ProjectionHandle from '../../../src/domain/services/ProjectionHandle.ts'; import Observer, { type ObserverBacking } from '../../../src/domain/services/query/Observer.ts'; import type { Aperture } from '../../../src/domain/types/Aperture.ts'; @@ -52,8 +52,8 @@ function createObserver( }); } -function createWorldline(calls: ObserverCall[]): Worldline { - return new Worldline({ +function createWorldline(calls: ObserverCall[]): ProjectionHandle { + return new ProjectionHandle({ graph: { observer: async ( nameOrConfig: string | Aperture, @@ -280,13 +280,13 @@ describe('WarpWorldline', () => { expect(receivedBuild).toBe(build); }); - it('returns existing Worldline read handles for live and historical reads', async () => { + it('returns existing ProjectionHandle read handles for live and historical reads', async () => { const handle = createHandle(); - expect(handle.live()).toBeInstanceOf(Worldline); + expect(handle.live()).toBeInstanceOf(ProjectionHandle); const historical = await handle.seek({ source: { kind: 'live', ceiling: 2 } }); - expect(historical).toBeInstanceOf(Worldline); + expect(historical).toBeInstanceOf(ProjectionHandle); expect(historical.source).toEqual({ kind: 'live', ceiling: 2 }); }); diff --git a/test/unit/domain/WarpWorldlineCoordinate.test.ts b/test/unit/domain/WarpWorldlineCoordinate.test.ts index 25249babc..92b50c145 100644 --- a/test/unit/domain/WarpWorldlineCoordinate.test.ts +++ b/test/unit/domain/WarpWorldlineCoordinate.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it } from 'vitest'; import WarpError from '../../../src/domain/errors/WarpError.ts'; import WarpWorldlineCoordinate from '../../../src/domain/WarpWorldlineCoordinate.ts'; -import type Worldline from '../../../src/domain/services/Worldline.ts'; +import type ProjectionHandle from '../../../src/domain/services/ProjectionHandle.ts'; -function unusedWorldlineFactory(): Worldline { +function unusedWorldlineFactory(): ProjectionHandle { throw new WarpError('unused worldline factory', 'E_TEST_UNUSED_WORLDLINE_FACTORY'); } diff --git a/test/unit/domain/continuum/GitWarpReceiptEnvelopeBoundary.test.ts b/test/unit/domain/continuum/GitWarpReceiptEnvelopeBoundary.test.ts new file mode 100644 index 000000000..adbff318f --- /dev/null +++ b/test/unit/domain/continuum/GitWarpReceiptEnvelopeBoundary.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { GitWarpReceiptEnvelopeBoundary } from '../../../../index.ts'; +import WarpError from '../../../../src/domain/errors/WarpError.ts'; +import { TickReceipt } from '../../../../src/domain/types/TickReceipt.ts'; + +function makeReceipt(): TickReceipt { + return new TickReceipt({ + patchSha: 'd'.repeat(40), + writer: 'writer-a', + lamport: 11, + ops: [{ + op: 'NodeAdd', + target: 'node:a', + result: 'applied', + }, { + op: 'PropSet', + target: 'node:a\u0000name', + result: 'superseded', + reason: 'LWW: writer writer-b at lamport 12 wins', + }, { + op: 'EdgeAdd', + target: 'node:a\u0000node:b\u0000knows', + result: 'redundant', + }], + }); +} + +describe('GitWarpReceiptEnvelopeBoundary', () => { + it('projects a TickReceipt into a frozen stable external anchor', () => { + const boundary = new GitWarpReceiptEnvelopeBoundary({ receipt: makeReceipt() }); + const anchor = boundary.stableAnchor(); + + expect(Object.isFrozen(boundary)).toBe(true); + expect(Object.isFrozen(anchor)).toBe(true); + expect(anchor).toEqual({ + boundaryVersion: 'git-warp.receipt-envelope-boundary/v1', + substrateFactKind: 'git-warp.tick-receipt', + patchSha: 'd'.repeat(40), + writer: 'writer-a', + lamport: 11, + outcomeCount: 3, + appliedCount: 1, + supersededCount: 1, + redundantCount: 1, + hasExplanatoryReasons: true, + }); + }); + + it('keeps raw operation details and debug reasons out of the stable anchor', () => { + const boundary = new GitWarpReceiptEnvelopeBoundary({ receipt: makeReceipt() }); + const anchor = boundary.stableAnchor(); + + expect(Object.hasOwn(anchor, 'ops')).toBe(false); + expect(Object.hasOwn(anchor, 'reason')).toBe(false); + expect(Object.hasOwn(anchor, 'receipt')).toBe(false); + expect(boundary.receiptShell.hasExplanatoryReasons()).toBe(true); + }); + + it('rejects missing or non-receipt carriers at runtime', () => { + expect(() => new GitWarpReceiptEnvelopeBoundary( + // @ts-expect-error runtime guard for JavaScript callers + undefined, + )).toThrow(WarpError); + + expect(() => new GitWarpReceiptEnvelopeBoundary({ + // @ts-expect-error runtime guard for JavaScript callers + receipt: undefined, + })).toThrow(WarpError); + }); +}); diff --git a/test/unit/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.test.ts b/test/unit/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.test.ts new file mode 100644 index 000000000..7b21aa74e --- /dev/null +++ b/test/unit/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; + +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; +import ContinuumEvidencePosture from '../../../../src/domain/continuum/ContinuumEvidencePosture.ts'; +import GitWarpSuffixTransformHologram + from '../../../../src/domain/continuum/GitWarpSuffixTransformHologram.ts'; +import GitWarpWitnessedSuffixAdmissionOutcome + from '../../../../src/domain/continuum/GitWarpWitnessedSuffixAdmissionOutcome.ts'; +import GitWarpWitnessedSuffixAdmissionShell + from '../../../../src/domain/continuum/GitWarpWitnessedSuffixAdmissionShell.ts'; +import GitWarpWitnessedSuffixPatchFact + from '../../../../src/domain/continuum/GitWarpWitnessedSuffixPatchFact.ts'; +import GitWarpWitnessedSuffixSourceFacts + from '../../../../src/domain/continuum/GitWarpWitnessedSuffixSourceFacts.ts'; +import createCurrentContinuumGeneratedFamilyInventory + from '../../../../src/domain/continuum/createCurrentContinuumGeneratedFamilyInventory.ts'; +import WarpError from '../../../../src/domain/errors/WarpError.ts'; +import ProvenancePayload from '../../../../src/domain/services/provenance/ProvenancePayload.ts'; +import Patch from '../../../../src/domain/types/Patch.ts'; +import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; + +function makePatch(fields: { + readonly writer: string; + readonly lamport: number; + readonly nodeId: string; +}): Patch { + return new Patch({ + schema: 3, + writer: fields.writer, + lamport: fields.lamport, + context: VersionVector.empty(), + ops: [new NodeAdd(fields.nodeId, new Dot(fields.writer, fields.lamport))], + writes: [fields.nodeId], + }); +} + +function makePatchFact(fields: { + readonly writerId?: string; + readonly patchSha?: string; + readonly lamport?: number; + readonly operationCount?: number; +} = {}): GitWarpWitnessedSuffixPatchFact { + return new GitWarpWitnessedSuffixPatchFact({ + writerId: fields.writerId ?? 'writer-remote', + patchSha: fields.patchSha ?? 'a'.repeat(40), + lamport: fields.lamport ?? 7, + operationCount: fields.operationCount ?? 1, + }); +} + +function makeSourceFacts(fields: { + readonly patches?: readonly GitWarpWitnessedSuffixPatchFact[]; + readonly sourceFrontierRef?: string; + readonly basisFrontierRef?: string; + readonly targetFrontierRef?: string; +} = {}): GitWarpWitnessedSuffixSourceFacts { + const inventory = createCurrentContinuumGeneratedFamilyInventory(); + return new GitWarpWitnessedSuffixSourceFacts({ + family: inventory.requireEntry('runtime-boundary-family'), + evidencePosture: ContinuumEvidencePosture.translatedGitWarpEvidence(), + graphName: 'demo', + sourceFrontierRef: fields.sourceFrontierRef ?? 'frontier:remote:writer-remote:7', + basisFrontierRef: fields.basisFrontierRef ?? 'frontier:local:writer-local:3', + targetFrontierRef: fields.targetFrontierRef ?? 'frontier:target:merged', + patches: fields.patches ?? [makePatchFact()], + witnessRef: 'receipt:'.concat('a'.repeat(40)), + bundleDigest: 'sha256:suffix-bundle', + }); +} + +function makeHologram(fields: { + readonly patch?: Patch; + readonly sha?: string; + readonly sourceFrontierRef?: string; + readonly basisFrontierRef?: string; + readonly targetFrontierRef?: string; + readonly payload?: ProvenancePayload; +} = {}): GitWarpSuffixTransformHologram { + const patch = fields.patch ?? makePatch({ + writer: 'writer-remote', + lamport: 7, + nodeId: 'node:remote', + }); + return new GitWarpSuffixTransformHologram({ + sourceFrontierRef: fields.sourceFrontierRef ?? 'frontier:remote:writer-remote:7', + basisFrontierRef: fields.basisFrontierRef ?? 'frontier:local:writer-local:3', + targetFrontierRef: fields.targetFrontierRef ?? 'frontier:target:merged', + transportLawId: 'transport-law:common-basis-suffix', + proofRef: 'proof:suffix-transform', + payload: fields.payload ?? new ProvenancePayload([{ patch, sha: fields.sha ?? 'a'.repeat(40) }]), + }); +} + +function makeShell(fields: { + readonly outcome?: GitWarpWitnessedSuffixAdmissionOutcome; + readonly sourceFacts?: GitWarpWitnessedSuffixSourceFacts; + readonly hologram?: GitWarpSuffixTransformHologram; +} = {}): GitWarpWitnessedSuffixAdmissionShell { + return new GitWarpWitnessedSuffixAdmissionShell({ + laneId: 'lane:writer-remote', + transportedSiteRef: 'site:remote', + admissionLawId: 'admission-law:witnessed-suffix', + outcome: fields.outcome ?? GitWarpWitnessedSuffixAdmissionOutcome.admitted(), + sourceFacts: fields.sourceFacts ?? makeSourceFacts(), + hologram: fields.hologram ?? makeHologram(), + }); +} + +describe('GitWarpWitnessedSuffixAdmissionShell', () => { + it('freezes an observer-readable witnessed suffix admission shell', () => { + const shell = makeShell(); + + expect(shell.graphName).toBe('demo'); + expect(shell.laneId).toBe('lane:writer-remote'); + expect(shell.transportedSiteRef).toBe('site:remote'); + expect(shell.sourceFrontierRef).toBe('frontier:remote:writer-remote:7'); + expect(shell.basisFrontierRef).toBe('frontier:local:writer-local:3'); + expect(shell.targetFrontierRef).toBe('frontier:target:merged'); + expect(shell.admissionLawId).toBe('admission-law:witnessed-suffix'); + expect(shell.transportLawId).toBe('transport-law:common-basis-suffix'); + expect(shell.patchRefs).toEqual(['a'.repeat(40)]); + expect(shell.patchCount).toBe(1); + expect(shell.witnessRef).toBe('receipt:'.concat('a'.repeat(40))); + expect(shell.bundleDigest).toBe('sha256:suffix-bundle'); + expect(shell.proofRef).toBe('proof:suffix-transform'); + expect(shell.isAdmitted()).toBe(true); + expect(shell.requiresGeneratedProfileBeforeProjection()).toBe(true); + expect(Object.isFrozen(shell)).toBe(true); + expect(Object.isFrozen(shell.patchRefs)).toBe(true); + }); + + it('materializes the suffix hologram from a comparable local basis', () => { + const basisPatch = makePatch({ + writer: 'writer-local', + lamport: 3, + nodeId: 'node:local', + }); + const basis = new ProvenancePayload([{ patch: basisPatch, sha: 'b'.repeat(40) }]).replay(); + const shell = makeShell(); + + const materialized = shell.materializeFrom(basis); + + expect(materialized.nodeAlive.contains('node:local')).toBe(true); + expect(materialized.nodeAlive.contains('node:remote')).toBe(true); + }); + + it('keeps plural, conflict, and obstruction outcomes explicit', () => { + expect(GitWarpWitnessedSuffixAdmissionOutcome.plural().requiresResolution()).toBe(true); + expect(GitWarpWitnessedSuffixAdmissionOutcome.conflict().requiresResolution()).toBe(true); + expect(GitWarpWitnessedSuffixAdmissionOutcome.obstruction().requiresResolution()).toBe(true); + expect(GitWarpWitnessedSuffixAdmissionOutcome.staged().isStaged()).toBe(true); + expect(GitWarpWitnessedSuffixAdmissionOutcome.admitted().toString()).toBe('admitted'); + expect( + GitWarpWitnessedSuffixAdmissionOutcome.admitted() + .equals(new GitWarpWitnessedSuffixAdmissionOutcome('admitted')), + ).toBe(true); + }); + + it('rejects unsupported admission outcomes', () => { + expect(() => new GitWarpWitnessedSuffixAdmissionOutcome('ignored')).toThrow(WarpError); + }); + + it('rejects shells whose source facts and hologram name different frontiers', () => { + expect(() => makeShell({ + hologram: makeHologram({ sourceFrontierRef: 'frontier:other' }), + })).toThrow(WarpError); + + expect(() => makeShell({ + hologram: makeHologram({ basisFrontierRef: 'frontier:other' }), + })).toThrow(WarpError); + + expect(() => makeShell({ + hologram: makeHologram({ targetFrontierRef: 'frontier:other' }), + })).toThrow(WarpError); + }); + + it('rejects shells whose source facts and hologram name different patch counts', () => { + const sourceFacts = makeSourceFacts({ + patches: [ + makePatchFact({ patchSha: 'a'.repeat(40), lamport: 7 }), + makePatchFact({ patchSha: 'b'.repeat(40), lamport: 8 }), + ], + }); + + expect(() => makeShell({ sourceFacts })).toThrow(WarpError); + }); + + it('rejects non-runtime-backed shell members', () => { + const sourceFacts = makeSourceFacts(); + const hologram = makeHologram(); + + expect(() => new GitWarpWitnessedSuffixAdmissionShell( + // @ts-expect-error runtime guard for JavaScript callers + undefined, + )).toThrow(WarpError); + + expect(() => new GitWarpWitnessedSuffixAdmissionShell({ + laneId: '', + transportedSiteRef: 'site:remote', + admissionLawId: 'admission-law:witnessed-suffix', + outcome: GitWarpWitnessedSuffixAdmissionOutcome.admitted(), + sourceFacts, + hologram, + })).toThrow(WarpError); + + expect(() => new GitWarpWitnessedSuffixAdmissionShell({ + laneId: 'lane:writer-remote', + transportedSiteRef: 'site:remote', + admissionLawId: 'admission-law:witnessed-suffix', + // @ts-expect-error runtime guard for JavaScript callers + outcome: 'admitted', + sourceFacts, + hologram, + })).toThrow(WarpError); + + expect(() => new GitWarpWitnessedSuffixAdmissionShell({ + laneId: 'lane:writer-remote', + transportedSiteRef: 'site:remote', + admissionLawId: 'admission-law:witnessed-suffix', + outcome: GitWarpWitnessedSuffixAdmissionOutcome.admitted(), + // @ts-expect-error runtime guard for JavaScript callers + sourceFacts: hologram, + hologram, + })).toThrow(WarpError); + }); +}); diff --git a/test/unit/domain/crdt/CrdtShimRetirement.test.ts b/test/unit/domain/crdt/CrdtShimRetirement.test.ts new file mode 100644 index 000000000..93d0ac685 --- /dev/null +++ b/test/unit/domain/crdt/CrdtShimRetirement.test.ts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; + +const FORBIDDEN_VERSION_VECTOR_SHIMS = Object.freeze([ + 'createVersionVector', + 'vvIncrement', + 'vvMerge', + 'vvDescends', + 'vvContains', + 'vvSerialize', + 'vvDeserialize', + 'vvClone', + 'vvEqual', +]); + +const FORBIDDEN_ORSET_SHIMS = Object.freeze([ + 'createORSet', + 'orsetAdd', + 'orsetRemove', + 'orsetContains', + 'orsetElements', + 'orsetGetDots', + 'orsetJoin', + 'orsetCompact', + 'orsetSerialize', + 'orsetDeserialize', +]); + +describe('CRDT compatibility shim retirement', () => { + it('keeps VersionVector shim exports out of the domain module', async () => { + const source = await readFile('src/domain/crdt/VersionVector.ts', 'utf8'); + + for (const shim of FORBIDDEN_VERSION_VECTOR_SHIMS) { + expect(source).not.toContain(`export function ${shim}`); + expect(source).not.toContain(`export const ${shim}`); + } + }); + + it('keeps ORSet shim exports out of the domain module', async () => { + const source = await readFile('src/domain/crdt/ORSet.ts', 'utf8'); + + for (const shim of FORBIDDEN_ORSET_SHIMS) { + expect(source).not.toContain(`export function ${shim}`); + expect(source).not.toContain(`export const ${shim}`); + } + }); +}); diff --git a/test/unit/domain/crdt/ORSet.test.ts b/test/unit/domain/crdt/ORSet.test.ts index 566b71b4d..76e703f3a 100644 --- a/test/unit/domain/crdt/ORSet.test.ts +++ b/test/unit/domain/crdt/ORSet.test.ts @@ -7,7 +7,7 @@ import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; const getEntry = (map, key) => map.get(key); describe('ORSet', () => { - describe('createORSet', () => { + describe('empty', () => { it('creates empty ORSet', () => { const set = ORSet.empty(); @@ -18,7 +18,7 @@ describe('ORSet', () => { }); }); - describe('orsetAdd', () => { + describe('add', () => { it('adds element with dot', () => { const set = ORSet.empty(); const dot = Dot.create('writer1', 1); @@ -64,7 +64,7 @@ describe('ORSet', () => { }); }); - describe('orsetRemove', () => { + describe('remove', () => { it('adds observed dots to tombstones', () => { const set = ORSet.empty(); const dot = Dot.create('writer1', 1); @@ -99,7 +99,7 @@ describe('ORSet', () => { }); }); - describe('orsetContains', () => { + describe('contains', () => { it('returns true for element with non-tombstoned dot', () => { const set = ORSet.empty(); const dot = Dot.create('writer1', 1); @@ -138,7 +138,7 @@ describe('ORSet', () => { }); }); - describe('orsetElements', () => { + describe('elements', () => { it('returns empty array for empty set', () => { const set = ORSet.empty(); @@ -178,7 +178,7 @@ describe('ORSet', () => { }); }); - describe('orsetGetDots', () => { + describe('getDots', () => { it('returns non-tombstoned dots for element', () => { const set = ORSet.empty(); const dot1 = Dot.create('writer1', 1); @@ -213,7 +213,7 @@ describe('ORSet', () => { }); }); - describe('orsetJoin - Lattice Properties', () => { + describe('join - lattice properties', () => { it('commutativity: a.join(b) equals b.join(a)', () => { const a = ORSet.empty(); const b = ORSet.empty(); @@ -241,7 +241,7 @@ describe('ORSet', () => { } }); - it('associativity: orsetJoin(a.join(b), c) equals a.join(orsetJoin(b, c))', () => { + it('associativity: a.join(b).join(c) equals a.join(b.join(c))', () => { const a = ORSet.empty(); const b = ORSet.empty(); const c = ORSet.empty(); @@ -297,7 +297,7 @@ describe('ORSet', () => { }); }); - describe('orsetJoin - Union Semantics', () => { + describe('join - union semantics', () => { it('unions entries from both sets', () => { const a = ORSet.empty(); const b = ORSet.empty(); @@ -441,7 +441,7 @@ describe('ORSet', () => { }); }); - describe('orsetCompact', () => { + describe('compact', () => { it('removes tombstoned dots that are <= includedVV', () => { const set = ORSet.empty(); const dot = Dot.create('writer1', 1); @@ -551,7 +551,7 @@ describe('ORSet', () => { }); }); - describe('orsetSerialize / orsetDeserialize', () => { + describe('serialize / deserialize', () => { it('serializes empty set', () => { const set = ORSet.empty(); const serialized = set.serialize(); diff --git a/test/unit/domain/crdt/VersionVector.test.ts b/test/unit/domain/crdt/VersionVector.test.ts index a6964c769..8dacec61c 100644 --- a/test/unit/domain/crdt/VersionVector.test.ts +++ b/test/unit/domain/crdt/VersionVector.test.ts @@ -4,7 +4,7 @@ import { Dot } from '../../../../src/domain/crdt/Dot.ts'; describe('VersionVector', () => { - describe('createVersionVector', () => { + describe('empty', () => { it('creates an empty version vector', () => { const vv = VersionVector.empty(); @@ -13,7 +13,7 @@ describe('VersionVector', () => { }); }); - describe('vvIncrement', () => { + describe('increment', () => { it('increments counter for new writer', () => { const vv = VersionVector.empty(); @@ -63,7 +63,7 @@ describe('VersionVector', () => { }); }); - describe('vvMerge', () => { + describe('merge', () => { it('merges empty vectors', () => { const a = VersionVector.empty(); const b = VersionVector.empty(); @@ -155,7 +155,7 @@ describe('VersionVector', () => { }); }); - describe('vvDescends', () => { + describe('descends', () => { it('empty vector descends from empty vector', () => { const a = VersionVector.empty(); const b = VersionVector.empty(); @@ -236,7 +236,7 @@ describe('VersionVector', () => { }); }); - describe('vvContains', () => { + describe('contains', () => { it('empty vector does not contain any dot', () => { const vv = VersionVector.empty(); const dot = Dot.create('alice', 1); @@ -277,7 +277,7 @@ describe('VersionVector', () => { }); }); - describe('vvSerialize / vvDeserialize', () => { + describe('serialize / from', () => { it('serializes empty vector', () => { const vv = VersionVector.empty(); @@ -361,7 +361,7 @@ describe('VersionVector', () => { }); }); - describe('vvClone', () => { + describe('clone', () => { it('creates a copy', () => { const original = VersionVector.empty(); original.set('alice', 1); @@ -383,7 +383,7 @@ describe('VersionVector', () => { }); }); - describe('vvEqual', () => { + describe('equals', () => { it('empty vectors are equal', () => { const a = VersionVector.empty(); const b = VersionVector.empty(); diff --git a/test/unit/domain/errors/index.test.ts b/test/unit/domain/errors/index.test.ts index 5bd106f01..22f7255f8 100644 --- a/test/unit/domain/errors/index.test.ts +++ b/test/unit/domain/errors/index.test.ts @@ -12,6 +12,8 @@ describe('domain/errors index barrel', () => { 'IndexError', 'MemoryBudgetError', 'OperationAbortedError', + 'OperationPolicyExhaustedError', + 'OperationPolicyTimeoutError', 'PatchError', 'QueryError', 'SchemaUnsupportedError', diff --git a/test/unit/domain/index.exports.test.ts b/test/unit/domain/index.exports.test.ts index c4963566b..b48e5bdc1 100644 --- a/test/unit/domain/index.exports.test.ts +++ b/test/unit/domain/index.exports.test.ts @@ -6,6 +6,7 @@ */ import { describe, it, expect } from 'vitest'; +import * as publicApi from '../../../index.ts'; // Import everything from the main entry point import WarpAppDefault, { @@ -62,6 +63,9 @@ import WarpAppDefault, { OperationAbortedError, MemoryBudgetError, Observer, + ObserverPlan, + ObserverReadingEnvelope, + ProjectionHandle, ContinuumArtifactAuthorityError, // Cancellation utilities @@ -78,6 +82,8 @@ import WarpAppDefault, { createBlobValue, createStateReader, compareVisibleState, + GraphDiff, + SupportFragmentPlan, normalizeVisibleStateScope, scopeMaterializedState, ContinuumArtifactAuthority, @@ -99,6 +105,8 @@ import WarpAppDefault, { GitWarpBraidHologram, GitWarpBraidHologramMember, GitWarpSuffixTransformHologram, + GitWarpWitnessedSuffixAdmissionOutcome, + GitWarpWitnessedSuffixAdmissionShell, GitWarpTickHologram, GitWarpTickPatchReplayCore, GitWarpTickReceiptShell, @@ -129,8 +137,6 @@ import WarpAppDefault, { ZKWormholeProofVerifierPort, } from '../../../index.ts'; -const { WarpGraph, WarpRuntime, Worldline, ObserverView } = (await import('../../../index.ts') as any); - describe('index.ts exports', () => { describe('default export', () => { it('exports WarpApp as default', () => { @@ -155,11 +161,11 @@ describe('index.ts exports', () => { }); it('does not export WarpRuntime from the public entry point', () => { - expect(WarpRuntime).toBeUndefined(); + expect('WarpRuntime' in publicApi).toBe(false); }); it('does not export WarpGraph as a public compatibility alias', () => { - expect(WarpGraph).toBeUndefined(); + expect('WarpGraph' in publicApi).toBe(false); }); }); @@ -176,16 +182,21 @@ describe('index.ts exports', () => { }); describe('core classes', () => { - it('exports Worldline', () => { - expect(Worldline).toBeDefined(); - expect(typeof Worldline).toBe('function'); + it('exports ProjectionHandle and not the retired Worldline class alias', () => { + expect(ProjectionHandle).toBeDefined(); + expect(typeof ProjectionHandle).toBe('function'); + expect('Worldline' in publicApi).toBe(false); }); it('exports Observer', () => { expect(Observer).toBeDefined(); expect(typeof Observer).toBe('function'); expect(Observer.name).toBe('Observer'); - expect(ObserverView).toBeUndefined(); + expect(ObserverPlan).toBeDefined(); + expect(typeof ObserverPlan).toBe('function'); + expect(ObserverReadingEnvelope).toBeDefined(); + expect(typeof ObserverReadingEnvelope).toBe('function'); + expect('ObserverView' in publicApi).toBe(false); }); it('exports GitGraphAdapter', () => { @@ -431,6 +442,8 @@ describe('index.ts exports', () => { expect(GitWarpBraidHologram).toBeDefined(); expect(GitWarpBraidHologramMember).toBeDefined(); expect(GitWarpSuffixTransformHologram).toBeDefined(); + expect(GitWarpWitnessedSuffixAdmissionOutcome).toBeDefined(); + expect(GitWarpWitnessedSuffixAdmissionShell).toBeDefined(); expect(GitWarpTickHologram).toBeDefined(); expect(GitWarpTickPatchReplayCore).toBeDefined(); expect(GitWarpTickReceiptShell).toBeDefined(); @@ -584,6 +597,16 @@ describe('index.ts exports', () => { expect(compareVisibleState).toBeDefined(); expect(typeof compareVisibleState).toBe('function'); }); + + it('exports GraphDiff', () => { + expect(GraphDiff).toBeDefined(); + expect(typeof GraphDiff).toBe('function'); + }); + + it('exports SupportFragmentPlan', () => { + expect(SupportFragmentPlan).toBeDefined(); + expect(typeof SupportFragmentPlan).toBe('function'); + }); }); describe('usage patterns', () => { diff --git a/test/unit/domain/publicReadingSurface.behavior.test.ts b/test/unit/domain/publicReadingSurface.behavior.test.ts index 1510b390d..25266c495 100644 --- a/test/unit/domain/publicReadingSurface.behavior.test.ts +++ b/test/unit/domain/publicReadingSurface.behavior.test.ts @@ -8,7 +8,11 @@ import WarpAppDefault, { WarpCore, } from '../../../index.ts'; -function openOptions(graphName: string, writerId: string): Parameters[0] { +function openOptions(graphName: string, writerId: string): { + persistence: InMemoryGraphAdapter; + graphName: string; + writerId: string; +} { return { persistence: new InMemoryGraphAdapter(), graphName, diff --git a/test/unit/domain/services/EdgePropSetWireMigrationGate.test.ts b/test/unit/domain/services/EdgePropSetWireMigrationGate.test.ts new file mode 100644 index 000000000..1fdd44e6d --- /dev/null +++ b/test/unit/domain/services/EdgePropSetWireMigrationGate.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectSchemaVersion, + EDGE_PROPERTY_PATCH_SCHEMA_VERSION, +} from '../../../../src/domain/services/codec/WarpMessageCodec.ts'; +import { lowerCanonicalOp } from '../../../../src/domain/services/OpNormalizer.ts'; +import EdgePropSet from '../../../../src/domain/types/ops/EdgePropSet.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; + +const RAW_EDGE_PROPSET_SCHEMA_VERSION = 4; + +describe('ADR 2 EdgePropSet wire migration gate', () => { + it('keeps canonical EdgePropSet lowered to legacy raw PropSet storage', () => { + const canonical = new EdgePropSet({ + from: 'alice', + to: 'bob', + label: 'follows', + key: 'weight', + value: 0.9, + }); + + const raw = lowerCanonicalOp(canonical); + + expect(raw).toBeInstanceOf(PropSet); + if (raw instanceof PropSet) { + expect(raw.type).toBe('PropSet'); + expect(raw.node).toBe('\x01alice\0bob\0follows'); + expect(raw.key).toBe('weight'); + expect(raw.value).toBe(0.9); + } + }); + + it('does not claim the deferred raw EdgePropSet schema version', () => { + const canonical = new EdgePropSet({ + from: 'source', + to: 'target', + label: 'rel', + key: 'status', + value: 'draft', + }); + + expect(detectSchemaVersion([canonical])).toBe(EDGE_PROPERTY_PATCH_SCHEMA_VERSION); + expect(detectSchemaVersion([canonical])).not.toBe(RAW_EDGE_PROPSET_SCHEMA_VERSION); + }); +}); diff --git a/test/unit/domain/services/GraphTraversal.stream.test.ts b/test/unit/domain/services/GraphTraversal.stream.test.ts new file mode 100644 index 000000000..bfdf3c2e8 --- /dev/null +++ b/test/unit/domain/services/GraphTraversal.stream.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import type { Direction, NeighborOptions } from '../../../../src/ports/NeighborProviderPort.ts'; +import NeighborProviderPort, { NeighborEdge, type LatencyClass } from '../../../../src/ports/NeighborProviderPort.ts'; +import GraphTraversal from '../../../../src/domain/services/query/GraphTraversal.ts'; +import { + F1_BFS_LEVEL_SORT_TRAP, + F2_DFS_LEFTMOST_REVERSE_PUSH, + makeAdjacencyProvider, +} from '../../../helpers/fixtureDsl.ts'; + +async function collectNodes(stream: AsyncIterable): Promise { + const nodes: string[] = []; + for await (const node of stream) { + nodes.push(node); + } + return nodes; +} + +class CountingNeighborProvider extends NeighborProviderPort { + expansionCount = 0; + + async getNeighbors( + nodeId: string, + _direction: Direction, + _options?: NeighborOptions, + ): Promise { + this.expansionCount += 1; + if (nodeId === 'A') { + return [new NeighborEdge('B', '')]; + } + return []; + } + + async hasNode(nodeId: string): Promise { + return nodeId === 'A' || nodeId === 'B'; + } + + override get latencyClass(): LatencyClass { + return 'sync'; + } +} + +describe('GraphTraversal stream traversals', () => { + it('streams BFS nodes in the same deterministic order as bfs()', async () => { + const provider = makeAdjacencyProvider(F1_BFS_LEVEL_SORT_TRAP); + const engine = new GraphTraversal({ provider }); + + const streamed = await collectNodes(engine.bfsStream({ start: 'A' })); + const collected = await engine.bfs({ start: 'A' }); + + expect(streamed).toEqual(['A', 'B', 'C', 'D', 'Z']); + expect(streamed).toEqual(collected.nodes); + }); + + it('streams DFS nodes in the same deterministic order as dfs()', async () => { + const provider = makeAdjacencyProvider(F2_DFS_LEFTMOST_REVERSE_PUSH); + const engine = new GraphTraversal({ provider }); + + const streamed = await collectNodes(engine.dfsStream({ start: 'A' })); + const collected = await engine.dfs({ start: 'A' }); + + expect(streamed).toEqual(['A', 'B', 'D', 'C', 'E']); + expect(streamed).toEqual(collected.nodes); + }); + + it('does not expand after a consumer stops at the first BFS item', async () => { + const provider = new CountingNeighborProvider(); + const engine = new GraphTraversal({ provider }); + const seen: string[] = []; + + for await (const node of engine.bfsStream({ start: 'A' })) { + seen.push(node); + break; + } + + expect(seen).toEqual(['A']); + expect(provider.expansionCount).toBe(0); + }); +}); diff --git a/test/unit/domain/services/MessageCodecModules.test.ts b/test/unit/domain/services/MessageCodecModules.test.ts index f5675e60b..2befc57ee 100644 --- a/test/unit/domain/services/MessageCodecModules.test.ts +++ b/test/unit/domain/services/MessageCodecModules.test.ts @@ -31,6 +31,7 @@ import { } from '../../../../src/domain/services/codec/TrailerValidation.ts'; import { EDGE_PROP_PREFIX } from '../../../../src/domain/services/KeyCodec.ts'; import SchemaUnsupportedError from '../../../../src/domain/errors/SchemaUnsupportedError.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; const OID = 'a'.repeat(40); const STATE_HASH = 'b'.repeat(64); @@ -77,10 +78,7 @@ describe('message codec modules', () => { }); it('detects message kind and schema compatibility from shared detector module', () => { - const edgePropOp = { - type: 'PropSet', - node: `${EDGE_PROP_PREFIX}node:a\0node:b\0rel\0weight`, - }; + const edgePropOp = new PropSet(`${EDGE_PROP_PREFIX}node:a\0node:b\0rel`, 'weight', 1); const anchorMessage = encodeAnchorMessage({ graph: 'events', schema: 2 }); expect(detectSchemaVersion([edgePropOp])).toBe(EDGE_PROPERTY_PATCH_SCHEMA_VERSION); diff --git a/test/unit/domain/services/OpStrategyRegistryValidation.test.ts b/test/unit/domain/services/OpStrategyRegistryValidation.test.ts new file mode 100644 index 000000000..d3e9aef5c --- /dev/null +++ b/test/unit/domain/services/OpStrategyRegistryValidation.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import PatchError from '../../../../src/domain/errors/PatchError.ts'; +import { + validateOpStrategyRegistry, + type OpStrategyRegistryEntry, +} from '../../../../src/domain/services/OpStrategyRegistryValidation.ts'; + +const VALID_RECEIPT_OPS = new Set(['NodeAdd']); + +function makeStrategy(overrides: Partial = {}): OpStrategyRegistryEntry { + return { + receiptName: 'NodeAdd', + validate() {}, + mutate() {}, + outcome() {}, + snapshot() {}, + accumulate() {}, + ...overrides, + }; +} + +describe('validateOpStrategyRegistry', () => { + it('accepts a complete strategy whose receipt name is valid', () => { + const registry = new Map([['NodeAdd', makeStrategy()]]); + + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).not.toThrow(); + }); + + it('rejects a strategy missing a required method', () => { + const { mutate: _mutate, ...strategy } = makeStrategy(); + const registry = new Map([['NodeAdd', strategy]]); + + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow(PatchError); + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow( + "OpStrategy 'NodeAdd' is missing method 'mutate'", + ); + }); + + it('rejects a null strategy value with PatchError', () => { + const registry = new Map([['NodeAdd', null]]); + + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow(PatchError); + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow( + "OpStrategy 'NodeAdd' must be an object", + ); + }); + + it('rejects a strategy missing receiptName', () => { + const { receiptName: _receiptName, ...strategy } = makeStrategy(); + const registry = new Map([['NodeAdd', strategy]]); + + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow(PatchError); + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow( + "OpStrategy 'NodeAdd' is missing receiptName", + ); + }); + + it('rejects a strategy whose receiptName is outside the receipt op set', () => { + const registry = new Map([['NodeAdd', makeStrategy({ receiptName: 'BogusReceipt' })]]); + + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow(PatchError); + expect(() => validateOpStrategyRegistry(registry, VALID_RECEIPT_OPS)).toThrow( + "OpStrategy 'NodeAdd' receiptName 'BogusReceipt' is not in TickReceipt OP_TYPES", + ); + }); +}); diff --git a/test/unit/domain/services/Worldline.test.ts b/test/unit/domain/services/ProjectionHandle.test.ts similarity index 83% rename from test/unit/domain/services/Worldline.test.ts rename to test/unit/domain/services/ProjectionHandle.test.ts index 77289f01c..f2de0f9ae 100644 --- a/test/unit/domain/services/Worldline.test.ts +++ b/test/unit/domain/services/ProjectionHandle.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'; import type { Aperture } from '../../../../src/domain/types/Aperture.ts'; import type { WorldlineSource } from '../../../../src/domain/capabilities/QueryCapability.ts'; import Observer, { type ObserverBacking } from '../../../../src/domain/services/query/Observer.ts'; +import BoundedSupportRule from '../../../../src/domain/services/query/BoundedSupportRule.ts'; +import CausalIndexPlan from '../../../../src/domain/services/query/CausalIndexPlan.ts'; +import SupportFragmentPlan from '../../../../src/domain/services/query/SupportFragmentPlan.ts'; import type { QueryNeighborEntry, QueryNeighborOptions, @@ -13,7 +16,7 @@ import type { } from '../../../../src/domain/services/query/QueryReadModelProvider.ts'; import type { QueryNodeSnapshot } from '../../../../src/domain/services/query/QueryPlan.ts'; import type { SnapshotPropValue } from '../../../../src/domain/services/snapshot/SnapshotPropValue.ts'; -import Worldline from '../../../../src/domain/services/Worldline.ts'; +import ProjectionHandle from '../../../../src/domain/services/ProjectionHandle.ts'; class EmptyQueryReadModel implements QueryReadModel { readonly stateHash = 'empty'; @@ -40,7 +43,7 @@ class RecordingReadModelProvider implements QueryReadModelProvider { } } -class WorldlineGraphFixture { +class ProjectionGraphFixture { readonly observerRequests: ObserverRequest[] = []; constructor(private readonly observerResult: Observer) {} @@ -134,12 +137,12 @@ function requireAperture(value: Aperture | { source: WorldlineSource } | undefin if (value !== undefined && 'match' in value) { return value; } - throw new WorldlineTestError('expected observer aperture'); + throw new ProjectionHandleTestError('expected observer aperture'); } -class WorldlineTestError extends Error {} +class ProjectionHandleTestError extends Error {} -describe('Worldline', () => { +describe('ProjectionHandle', () => { it('forwards query read-model requests to the delegate fallback', async () => { const provider = new RecordingReadModelProvider(); const delegate = new Observer({ @@ -147,11 +150,22 @@ describe('Worldline', () => { config: { match: '*' }, readModelProvider: provider, }); - const worldline = new Worldline({ graph: new WorldlineGraphFixture(delegate) }); + const worldline = new ProjectionHandle({ graph: new ProjectionGraphFixture(delegate) }); + const supportRule = BoundedSupportRule.entityRead({ + surface: 'query', + nodeIds: ['node:a'], + }); + const causalIndexPlan = CausalIndexPlan.fromSupportRule(supportRule); const request: QueryReadModelOpenRequest = { nodeRequest: { pattern: 'node:a', select: ['id'] }, operations: [], aggregate: false, + supportRule, + causalIndexPlan, + supportFragmentPlan: SupportFragmentPlan.fromSupportAndIndex({ + supportRule, + causalIndexPlan, + }), }; await worldline.openQueryReadModel(request); @@ -165,14 +179,14 @@ describe('Worldline', () => { config: { match: '*' }, readModelProvider: new RecordingReadModelProvider(), }); - const graph = new WorldlineGraphFixture(delegate); + const graph = new ProjectionGraphFixture(delegate); const source: WorldlineSource = { kind: 'coordinate', frontier: { 'writer-a': 'a'.repeat(40) }, ceiling: 7, checkpointSha: 'b'.repeat(40), }; - const worldline = new Worldline({ graph, source }); + const worldline = new ProjectionHandle({ graph, source }); await worldline.observer('coordinate-reader', { match: 'node:*' }); @@ -198,8 +212,8 @@ describe('Worldline', () => { graph: backing, source: { kind: 'live' }, }); - const graph = new WorldlineGraphFixture(delegate); - const worldline = new Worldline({ + const graph = new ProjectionGraphFixture(delegate); + const worldline = new ProjectionHandle({ graph, source: { kind: 'strand', strandId: 'review-lane', ceiling: 3 }, }); diff --git a/test/unit/domain/services/SchemaCompat.test.ts b/test/unit/domain/services/SchemaCompat.test.ts index 20efc12f5..3e675d00d 100644 --- a/test/unit/domain/services/SchemaCompat.test.ts +++ b/test/unit/domain/services/SchemaCompat.test.ts @@ -6,31 +6,31 @@ import { EDGE_PROPERTY_PATCH_SCHEMA_VERSION, } from '../../../../src/domain/services/codec/WarpMessageCodec.ts'; import SchemaUnsupportedError from '../../../../src/domain/errors/SchemaUnsupportedError.ts'; +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import { EDGE_PROP_PREFIX } from '../../../../src/domain/services/JoinReducer.ts'; +import EdgeAdd from '../../../../src/domain/types/ops/EdgeAdd.ts'; +import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; // --------------------------------------------------------------------------- // Helpers — minimal op factories // --------------------------------------------------------------------------- -/** @param {string} nodeId */ -function nodeAddOp(nodeId) { - return { type: 'NodeAdd', node: nodeId, dot: { writer: 'w1', counter: 1 } }; +function nodeAddOp(nodeId: string): NodeAdd { + return new NodeAdd(nodeId, new Dot('w1', 1)); } -/** @param {string} from @param {string} to @param {string} label */ -function edgeAddOp(from, to, label) { - return { type: 'EdgeAdd', from, to, label, dot: { writer: 'w1', counter: 1 } }; +function edgeAddOp(from: string, to: string, label: string): EdgeAdd { + return new EdgeAdd({ from, to, label, dot: new Dot('w1', 1) }); } -/** @param {string} nodeId @param {string} key @param {any} value */ -function nodePropSetOp(nodeId, key, value) { - return { type: 'PropSet', node: nodeId, key, value }; +function nodePropSetOp(nodeId: string, key: string, value: string | number): PropSet { + return new PropSet(nodeId, key, value); } -/** @param {string} from @param {string} to @param {string} label @param {string} key @param {any} value */ -function edgePropSetOp(from, to, label, key, value) { +function edgePropSetOp(from: string, to: string, label: string, key: string, value: string | number): PropSet { // Edge prop ops use the \x01 prefix namespace in the node field - return { type: 'PropSet', node: `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`, key, value }; + return new PropSet(`${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`, key, value); } // --------------------------------------------------------------------------- @@ -69,8 +69,8 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { }); it('accepts non-array ops (defensive)', () => { - expect(() => assertOpsCompatible((null as any), CLASSIC_PATCH_SCHEMA_VERSION)).not.toThrow(); - expect(() => assertOpsCompatible((undefined as any), CLASSIC_PATCH_SCHEMA_VERSION)).not.toThrow(); + expect(() => assertOpsCompatible(null, CLASSIC_PATCH_SCHEMA_VERSION)).not.toThrow(); + expect(() => assertOpsCompatible(undefined, CLASSIC_PATCH_SCHEMA_VERSION)).not.toThrow(); }); it('throws E_SCHEMA_UNSUPPORTED for edge property ops', () => { @@ -88,8 +88,12 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { try { assertOpsCompatible(ops, CLASSIC_PATCH_SCHEMA_VERSION); expect.unreachable('should have thrown'); - } catch (/** @type {any} */ err) { - expect((err as any).code).toBe('E_SCHEMA_UNSUPPORTED'); + } catch (err) { + expect(err).toBeInstanceOf(SchemaUnsupportedError); + if (!(err instanceof SchemaUnsupportedError)) { + throw err; + } + expect(err.code).toBe('E_SCHEMA_UNSUPPORTED'); } }); @@ -99,10 +103,14 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { try { assertOpsCompatible(ops, CLASSIC_PATCH_SCHEMA_VERSION); expect.unreachable('should have thrown'); - } catch (/** @type {any} */ err) { - expect((err as any).message).toContain('>=7.3.0'); - expect((err as any).message).toContain('WEIGHTED'); - expect((err as any).message).toContain('edge properties'); + } catch (err) { + expect(err).toBeInstanceOf(SchemaUnsupportedError); + if (!(err instanceof SchemaUnsupportedError)) { + throw err; + } + expect(err.message).toContain('>=7.3.0'); + expect(err.message).toContain('WEIGHTED'); + expect(err.message).toContain('edge properties'); } }); @@ -112,9 +120,13 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { try { assertOpsCompatible(ops, CLASSIC_PATCH_SCHEMA_VERSION); expect.unreachable('should have thrown'); - } catch (/** @type {any} */ err) { - expect((err as any).context.requiredSchema).toBe(EDGE_PROPERTY_PATCH_SCHEMA_VERSION); - expect((err as any).context.maxSupportedSchema).toBe(CLASSIC_PATCH_SCHEMA_VERSION); + } catch (err) { + expect(err).toBeInstanceOf(SchemaUnsupportedError); + if (!(err instanceof SchemaUnsupportedError)) { + throw err; + } + expect(err.context['requiredSchema']).toBe(EDGE_PROPERTY_PATCH_SCHEMA_VERSION); + expect(err.context['maxSupportedSchema']).toBe(CLASSIC_PATCH_SCHEMA_VERSION); } }); @@ -142,13 +154,9 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { expect(() => assertOpsCompatible(ops, CLASSIC_PATCH_SCHEMA_VERSION)).not.toThrow(); }); - it('handles unknown op types gracefully (no crash)', () => { - const ops = [ - { type: 'FutureOp', data: 'unknown' }, - nodeAddOp('user:x'), - ]; + it('handles runtime node ops without raw-shape fallback', () => { + const ops = [nodeAddOp('user:x')]; - // Unknown op types that don't look like edge prop PropSets pass through expect(() => assertOpsCompatible(ops, CLASSIC_PATCH_SCHEMA_VERSION)).not.toThrow(); }); }); diff --git a/test/unit/domain/services/SyncAuthService.test.ts b/test/unit/domain/services/SyncAuthService.test.ts index f5f7ad7bc..ec3bd7427 100644 --- a/test/unit/domain/services/SyncAuthService.test.ts +++ b/test/unit/domain/services/SyncAuthService.test.ts @@ -124,6 +124,20 @@ describe('buildCanonicalPayload', () => { }); expect(result).toBe('warp-v2|||||||'); }); + + it('includes auth scheme when a versioned scheme is declared', () => { + const result = buildCanonicalPayload({ + authScheme: 'shared-secret-hmac-sha256', + keyId: 'k1', + method: 'POST', + path: '/sync', + timestamp: '123', + nonce: 'abc', + contentType: 'application/json', + bodySha256: 'deadbeef', + }); + expect(result).toBe('warp-v2|shared-secret-hmac-sha256|k1|POST|/sync|123|abc|application/json|deadbeef'); + }); }); // --------------------------------------------------------------------------- @@ -151,13 +165,14 @@ describe('canonicalizePath', () => { // signSyncRequest // --------------------------------------------------------------------------- describe('signSyncRequest', () => { - it('returns exactly 5 auth headers', async () => { + it('returns exactly 6 versioned auth headers', async () => { const body = Buffer.from('hello'); const headers = await signSyncRequest( { method: 'POST', path: '/sync', contentType: 'text/plain', body, secret: SECRET, keyId: KEY_ID, lamport: 1 }, { crypto: defaultCrypto }, ); - expect(Object.keys(headers)).toHaveLength(5); + expect(Object.keys(headers)).toHaveLength(6); + expect(headers).toHaveProperty('x-warp-auth-scheme', 'shared-secret-hmac-sha256'); expect(headers).toHaveProperty('x-warp-sig-version', '2'); expect(headers).toHaveProperty('x-warp-key-id', KEY_ID); expect(headers).toHaveProperty('x-warp-timestamp'); @@ -227,6 +242,20 @@ describe('verify() reject paths', () => { expect(result).toEqual({ ok: false, reason: 'INVALID_VERSION', status: 400 }); }); + it('accepts legacy HMAC requests without an auth scheme during migration', async () => { + const svc = makeService(); + const req = await buildSignedRequest(); + const result = await svc.verify(req); + expect(result).toEqual({ ok: true }); + }); + + it('rejects unsupported asymmetric auth scheme before HMAC verification', async () => { + const svc = makeService(); + const req = await buildSignedRequest({ 'x-warp-auth-scheme': 'ed25519' }); + const result = await svc.verify(req); + expect(result).toEqual({ ok: false, reason: 'UNSUPPORTED_AUTH_SCHEME', status: 400 }); + }); + it('rejects missing signature -> 401 MISSING_AUTH', async () => { const svc = makeService(); const req = await buildSignedRequest(); diff --git a/test/unit/domain/services/WarpMessageCodec.v3.test.ts b/test/unit/domain/services/WarpMessageCodec.v3.test.ts index 461c61f48..5026fa4e7 100644 --- a/test/unit/domain/services/WarpMessageCodec.v3.test.ts +++ b/test/unit/domain/services/WarpMessageCodec.v3.test.ts @@ -11,6 +11,10 @@ import { CLASSIC_PATCH_SCHEMA_VERSION, EDGE_PROPERTY_PATCH_SCHEMA_VERSION, } from '../../../../src/domain/services/codec/WarpMessageCodec.ts'; +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import EdgeAdd from '../../../../src/domain/types/ops/EdgeAdd.ts'; +import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; // Test fixtures const VALID_OID_SHA1 = 'a'.repeat(40); @@ -19,6 +23,22 @@ const VALID_STATE_HASH = 'c'.repeat(64); // Edge property prefix used in JoinReducer const EDGE_PROP_PREFIX = '\x01'; +function nodeAddOp(nodeId: string, counter: number): NodeAdd { + return new NodeAdd(nodeId, new Dot('w1', counter)); +} + +function edgeAddOp(from: string, to: string, label: string, counter: number): EdgeAdd { + return new EdgeAdd({ from, to, label, dot: new Dot('w1', counter) }); +} + +function nodePropSetOp(nodeId: string, key: string, value: string): PropSet { + return new PropSet(nodeId, key, value); +} + +function edgePropSetOp(from: string, to: string, label: string, key: string, value: number): PropSet { + return new PropSet(`${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`, key, value); +} + describe('WarpMessageCodec schema v3', () => { describe('constants', () => { it('CLASSIC_PATCH_SCHEMA_VERSION is 2', () => { @@ -33,28 +53,26 @@ describe('WarpMessageCodec schema v3', () => { describe('detectSchemaVersion', () => { it('returns schema 2 for ops with only node PropSet', () => { const ops = [ - { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, - { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, + nodeAddOp('user:alice', 1), + nodePropSetOp('user:alice', 'name', 'Alice'), ]; expect(detectSchemaVersion(ops)).toBe(2); }); it('returns schema 3 when any PropSet has edge prop prefix', () => { - const edgePropNode = `${EDGE_PROP_PREFIX}user:alice\0user:bob\0manages\0weight`; const ops = [ - { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, - { type: 'PropSet', node: edgePropNode, key: 'weight', value: 1.5 }, + nodeAddOp('user:alice', 1), + edgePropSetOp('user:alice', 'user:bob', 'manages', 'weight', 1.5), ]; expect(detectSchemaVersion(ops)).toBe(3); }); it('returns schema 3 even if only one PropSet has edge prop prefix among many', () => { - const edgePropNode = `${EDGE_PROP_PREFIX}a\0b\0rel\0key`; const ops = [ - { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, - { type: 'PropSet', node: 'user:bob', key: 'name', value: 'Bob' }, - { type: 'PropSet', node: edgePropNode, key: 'key', value: 42 }, - { type: 'PropSet', node: 'user:carol', key: 'name', value: 'Carol' }, + nodePropSetOp('user:alice', 'name', 'Alice'), + nodePropSetOp('user:bob', 'name', 'Bob'), + edgePropSetOp('a', 'b', 'rel', 'key', 42), + nodePropSetOp('user:carol', 'name', 'Carol'), ]; expect(detectSchemaVersion(ops)).toBe(3); }); @@ -64,22 +82,24 @@ describe('WarpMessageCodec schema v3', () => { }); it('returns schema 2 for non-array input', () => { - expect(detectSchemaVersion((null as any))).toBe(2); - expect(detectSchemaVersion((undefined as any))).toBe(2); - expect(detectSchemaVersion(('not-an-array' as any))).toBe(2); + expect(detectSchemaVersion(null)).toBe(2); + expect(detectSchemaVersion(undefined)).toBe(2); + // @ts-expect-error Exercising the runtime guard for untyped JavaScript callers. + expect(detectSchemaVersion('not-an-array')).toBe(2); }); it('returns schema 2 when no PropSet ops exist', () => { const ops = [ - { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, - { type: 'EdgeAdd', from: 'user:alice', to: 'user:bob', label: 'knows', dot: { writer: 'w1', seq: 2 } }, + nodeAddOp('user:alice', 1), + edgeAddOp('user:alice', 'user:bob', 'knows', 2), ]; expect(detectSchemaVersion(ops)).toBe(2); }); - it('ignores non-PropSet ops even if their node starts with \\x01', () => { + it('returns schema 2 for non-property runtime ops', () => { const ops = [ - { type: 'NodeAdd', node: `${EDGE_PROP_PREFIX}weird`, dot: { writer: 'w1', seq: 1 } }, + nodeAddOp('user:alice', 1), + edgeAddOp('user:alice', 'user:bob', 'knows', 2), ]; expect(detectSchemaVersion(ops)).toBe(2); }); @@ -193,8 +213,8 @@ describe('WarpMessageCodec schema v3', () => { describe('detectSchemaVersion integration with encodePatchMessage', () => { it('node-only ops produce schema 2', () => { const ops = [ - { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, - { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, + nodeAddOp('user:alice', 1), + nodePropSetOp('user:alice', 'name', 'Alice'), ]; const schema = detectSchemaVersion(ops); expect(schema).toBe(2); @@ -210,10 +230,9 @@ describe('WarpMessageCodec schema v3', () => { }); it('edge prop ops produce schema 3', () => { - const edgePropNode = `${EDGE_PROP_PREFIX}a\0b\0rel\0weight`; const ops = [ - { type: 'NodeAdd', node: 'user:alice', dot: { writer: 'w1', seq: 1 } }, - { type: 'PropSet', node: edgePropNode, key: 'weight', value: 3.14 }, + nodeAddOp('user:alice', 1), + edgePropSetOp('a', 'b', 'rel', 'weight', 3.14), ]; const schema = detectSchemaVersion(ops); expect(schema).toBe(3); diff --git a/test/unit/domain/services/comparison/GraphDiff.test.ts b/test/unit/domain/services/comparison/GraphDiff.test.ts new file mode 100644 index 000000000..e38a3b8cf --- /dev/null +++ b/test/unit/domain/services/comparison/GraphDiff.test.ts @@ -0,0 +1,165 @@ +import { assert, describe, expect, it } from 'vitest'; + +import GraphDiff from '../../../../../src/domain/services/comparison/GraphDiff.ts'; +import type { CoordinateComparison } from '../../../../../src/domain/types/CoordinateComparison.ts'; + +function comparisonFixture(): CoordinateComparison { + return { + comparisonVersion: 'coordinate-compare/v1', + comparisonDigest: 'comparison-digest', + scope: { + nodeIdPrefixes: { + include: ['task:'], + exclude: ['task:archive:'], + }, + }, + left: { + requested: { kind: 'strand', strandId: 'strand-a' }, + resolved: { + coordinateKind: 'strand', + patchFrontier: { alice: 'a'.repeat(40) }, + patchFrontierDigest: 'left-frontier', + lamportFrontier: { alice: 1 }, + lamportFrontierDigest: 'left-lamport', + lamportCeiling: 1, + stateHash: 'left-state', + patchUniverseDigest: 'left-universe', + summary: { + nodeCount: 1, + edgeCount: 0, + nodePropertyCount: 1, + edgePropertyCount: 0, + patchCount: 1, + }, + strand: { + strandId: 'strand-a', + baseLamportCeiling: 0, + overlayHeadPatchSha: 'b'.repeat(40), + overlayPatchCount: 1, + overlayWritable: true, + braid: { + readOverlayCount: 1, + braidedStrandIds: ['strand-b'], + }, + }, + }, + }, + right: { + requested: { kind: 'live', ceiling: 2 }, + resolved: { + coordinateKind: 'frontier', + patchFrontier: { alice: 'c'.repeat(40) }, + patchFrontierDigest: 'right-frontier', + lamportFrontier: { alice: 2 }, + lamportFrontierDigest: 'right-lamport', + lamportCeiling: 2, + stateHash: 'right-state', + patchUniverseDigest: 'right-universe', + summary: { + nodeCount: 1, + edgeCount: 1, + nodePropertyCount: 1, + edgePropertyCount: 1, + patchCount: 2, + }, + }, + }, + visiblePatchDivergence: { + sharedCount: 1, + leftOnlyCount: 0, + rightOnlyCount: 1, + leftOnlyPatchShas: [], + rightOnlyPatchShas: ['c'.repeat(40)], + target: { + targetId: 'task:1', + leftCount: 1, + rightCount: 2, + sharedCount: 1, + leftOnlyCount: 0, + rightOnlyCount: 1, + leftOnlyPatchShas: [], + rightOnlyPatchShas: ['c'.repeat(40)], + }, + }, + visibleState: { + comparisonVersion: 'visible-state-compare/v1', + changed: true, + summary: { + left: { + nodeCount: 1, + edgeCount: 0, + nodePropertyCount: 1, + edgePropertyCount: 0, + }, + right: { + nodeCount: 1, + edgeCount: 1, + nodePropertyCount: 1, + edgePropertyCount: 1, + }, + nodes: { added: 0, removed: 0 }, + edges: { added: 1, removed: 0 }, + nodeProperties: { added: 0, removed: 0, changed: 1 }, + edgeProperties: { added: 1, removed: 0, changed: 0 }, + }, + nodes: { + added: ['task:2'], + removed: [], + }, + edges: { + added: [{ from: 'task:1', to: 'task:2', label: 'blocks' }], + removed: [], + }, + nodeProperties: { + added: [], + removed: [], + changed: [{ node: 'task:1', key: 'status', leftValue: 'open', rightValue: 'done' }], + }, + edgeProperties: { + added: [{ from: 'task:1', to: 'task:2', label: 'blocks', key: 'weight', value: 1 }], + removed: [], + changed: [], + }, + }, + }; +} + +function rowAt(values: readonly T[], index: number): T { + const value = values[index]; + assert.isDefined(value); + return value; +} + +describe('GraphDiff', () => { + it('freezes nested public diff evidence', () => { + const graphDiff = new GraphDiff({ comparison: comparisonFixture() }); + + expect(Object.isFrozen(graphDiff)).toBe(true); + expect(Object.isFrozen(graphDiff.left)).toBe(true); + expect(Object.isFrozen(graphDiff.left.resolved)).toBe(true); + expect(Object.isFrozen(graphDiff.left.resolved.patchFrontier)).toBe(true); + expect(Object.isFrozen(graphDiff.left.resolved.strand?.braid.braidedStrandIds)).toBe(true); + expect(Object.isFrozen(graphDiff.scope?.nodeIdPrefixes?.include)).toBe(true); + expect(Object.isFrozen(graphDiff.summary.nodeProperties)).toBe(true); + expect(Object.isFrozen(graphDiff.nodes.added)).toBe(true); + expect(Object.isFrozen(graphDiff.edges.added)).toBe(true); + expect(Object.isFrozen(rowAt(graphDiff.edges.added, 0))).toBe(true); + expect(Object.isFrozen(graphDiff.nodeProperties.changed)).toBe(true); + expect(Object.isFrozen(rowAt(graphDiff.nodeProperties.changed, 0))).toBe(true); + expect(Object.isFrozen(graphDiff.edgeProperties.added)).toBe(true); + expect(Object.isFrozen(rowAt(graphDiff.edgeProperties.added, 0))).toBe(true); + expect(Object.isFrozen(graphDiff.visiblePatchDivergence.rightOnlyPatchShas)).toBe(true); + expect(Object.isFrozen(graphDiff.visiblePatchDivergence.target)).toBe(true); + expect(Object.isFrozen(graphDiff.visiblePatchDivergence.target?.rightOnlyPatchShas)).toBe(true); + + expect(() => { + graphDiff.nodes.added.push('task:mutated'); + }).toThrow(TypeError); + expect(() => { + rowAt(graphDiff.nodeProperties.changed, 0).key = 'mutated'; + }).toThrow(TypeError); + expect(() => { + graphDiff.visiblePatchDivergence.rightOnlyPatchShas.push('d'.repeat(40)); + }).toThrow(TypeError); + }); +}); diff --git a/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts b/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts index cbc8f0715..6e43c315c 100644 --- a/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts +++ b/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import MaterializeController from '../../../../../src/domain/services/controllers/MaterializeController.ts'; import { createEmptyState } from '../../../../../src/domain/services/JoinReducer.ts'; +import Patch from '../../../../../src/domain/types/Patch.ts'; +import type { CheckpointData, PatchWithSha } from '../../../../../src/domain/capabilities/PatchCollector.ts'; type Coordinate = { frontier: Map; @@ -18,30 +20,18 @@ type SnapshotRecord = { createdAt: string; }; -type PatchRecord = { - patch: { - schema: number; - writer: string; - lamport: number; - context: Record; - ops: []; - reads: string[]; - writes: string[]; - }; - sha: string; -}; +type PatchRecord = PatchWithSha; function patchRecord(lamport: number, sha: string): PatchRecord { return { - patch: { - schema: 2, + patch: new Patch({ writer: 'writer-1', lamport, context: {}, ops: [], reads: [], writes: [], - }, + }), sha, }; } @@ -63,6 +53,12 @@ function snapshotRecord( }; } +async function* streamFromPromise(items: Promise): AsyncIterable { + for (const item of await items) { + yield item; + } +} + function createControllerFixtures() { const stateCache = { getExact: vi.fn<(_coordinate: Coordinate) => Promise>(), @@ -76,13 +72,26 @@ function createControllerFixtures() { const patches = { discoverWriters: vi.fn().mockResolvedValue([]), - loadWriterPatches: vi.fn().mockResolvedValue([]), - collectForFrontier: vi.fn().mockResolvedValue([]), - collectForFrontierSinceCoordinate: vi.fn().mockResolvedValue([]), + loadWriterPatches: vi.fn<(_writerId: string) => Promise>().mockResolvedValue([]), + collectForFrontier: + vi.fn<(_frontier: Map, _ceiling: number | null) => Promise>().mockResolvedValue([]), + collectForFrontierSinceCoordinate: + vi.fn<(_frontier: Map, _ceiling: number | null, _coordinate: Coordinate) => Promise>() + .mockResolvedValue([]), loadCheckpoint: vi.fn().mockResolvedValue(null), - loadPatchesSince: vi.fn().mockResolvedValue([]), - loadPatchChain: vi.fn().mockResolvedValue([]), + loadPatchesSince: vi.fn<(_checkpoint: CheckpointData) => Promise>().mockResolvedValue([]), + loadPatchChain: vi.fn<(_toSha: string, _fromSha?: string | null) => Promise>().mockResolvedValue([]), getFrontier: vi.fn().mockResolvedValue(new Map([['writer-1', 'tip-7']])), + streamWriterPatches: vi.fn((writerId: string) => streamFromPromise(patches.loadWriterPatches(writerId))), + streamForFrontier: vi.fn((frontier: Map, ceiling: number | null) => + streamFromPromise(patches.collectForFrontier(frontier, ceiling))), + streamForFrontierSinceCoordinate: vi.fn(( + frontier: Map, + ceiling: number | null, + coordinate: Coordinate, + ) => streamFromPromise(patches.collectForFrontierSinceCoordinate(frontier, ceiling, coordinate))), + streamPatchesSince: vi.fn((checkpoint: Parameters[0]) => + streamFromPromise(patches.loadPatchesSince(checkpoint))), }; const deps = { diff --git a/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts b/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts index 1bd0bd470..f93c8d43d 100644 --- a/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts +++ b/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts @@ -11,6 +11,8 @@ import cborCodec from "../../../../../src/infrastructure/codecs/CborCodec.ts"; import SchemaUnsupportedError from "../../../../../src/domain/errors/SchemaUnsupportedError.ts"; import { InMemoryTrieStore } from "../../../../helpers/trieHelpers.ts"; import { createEmptyState } from "../../../../../src/domain/services/JoinReducer.ts"; +import Patch from "../../../../../src/domain/types/Patch.ts"; +import type { CheckpointData, PatchWithSha } from "../../../../../src/domain/capabilities/PatchCollector.ts"; const GEOMETRY = TrieGeometry.default16way(); @@ -19,18 +21,7 @@ type Coordinate = { ceiling: number | null; }; -type PatchRecord = { - patch: { - schema: number; - writer: string; - lamport: number; - context: Record; - ops: readonly (NodeAdd | EdgeAdd)[]; - reads: readonly string[]; - writes: readonly string[]; - }; - sha: string; -}; +type PatchRecord = PatchWithSha; function nodeAddPatchRecord(args: { readonly writer: string; @@ -39,15 +30,14 @@ function nodeAddPatchRecord(args: { readonly node: string; }): PatchRecord { return { - patch: { - schema: 2, + patch: new Patch({ writer: args.writer, lamport: args.lamport, context: {}, ops: [new NodeAdd(args.node, Dot.create(args.writer, args.lamport))], reads: [], writes: [args.node], - }, + }), sha: args.sha, }; } @@ -61,8 +51,7 @@ function edgeAddPatchRecord(args: { readonly label: string; }): PatchRecord { return { - patch: { - schema: 2, + patch: new Patch({ writer: args.writer, lamport: args.lamport, context: {}, @@ -76,7 +65,7 @@ function edgeAddPatchRecord(args: { ], reads: [], writes: [`${args.from}\0${args.to}\0${args.label}`], - }, + }), sha: args.sha, }; } @@ -96,6 +85,12 @@ function snapshotRecord(coordinate: Coordinate) { }; } +async function* streamFromPromise(items: Promise): AsyncIterable { + for (const item of await items) { + yield item; + } +} + function createControllerFixtures() { const stateCache = { getExact: vi.fn(), @@ -108,13 +103,26 @@ function createControllerFixtures() { }; const patches = { discoverWriters: vi.fn().mockResolvedValue([]), - loadWriterPatches: vi.fn().mockResolvedValue([]), - collectForFrontier: vi.fn().mockResolvedValue([]), - collectForFrontierSinceCoordinate: vi.fn().mockResolvedValue([]), + loadWriterPatches: vi.fn<(_writerId: string) => Promise>().mockResolvedValue([]), + collectForFrontier: + vi.fn<(_frontier: Map, _ceiling: number | null) => Promise>().mockResolvedValue([]), + collectForFrontierSinceCoordinate: + vi.fn<(_frontier: Map, _ceiling: number | null, _coordinate: Coordinate) => Promise>() + .mockResolvedValue([]), loadCheckpoint: vi.fn().mockResolvedValue(null), - loadPatchesSince: vi.fn().mockResolvedValue([]), - loadPatchChain: vi.fn().mockResolvedValue([]), + loadPatchesSince: vi.fn<(_checkpoint: CheckpointData) => Promise>().mockResolvedValue([]), + loadPatchChain: vi.fn<(_toSha: string, _fromSha?: string | null) => Promise>().mockResolvedValue([]), getFrontier: vi.fn().mockResolvedValue(new Map([["writer-1", "tip-1"]])), + streamWriterPatches: vi.fn((writerId: string) => streamFromPromise(patches.loadWriterPatches(writerId))), + streamForFrontier: vi.fn((frontier: Map, ceiling: number | null) => + streamFromPromise(patches.collectForFrontier(frontier, ceiling))), + streamForFrontierSinceCoordinate: vi.fn(( + frontier: Map, + ceiling: number | null, + coordinate: Coordinate, + ) => streamFromPromise(patches.collectForFrontierSinceCoordinate(frontier, ceiling, coordinate))), + streamPatchesSince: vi.fn((checkpoint: Parameters[0]) => + streamFromPromise(patches.loadPatchesSince(checkpoint))), }; const store = new InMemoryTrieStore(); const pageCache = new PageCache({ maxResident: 32 }); diff --git a/test/unit/domain/services/controllers/MaterializeController.test.ts b/test/unit/domain/services/controllers/MaterializeController.test.ts index 21c262a8c..e2bb15bfd 100644 --- a/test/unit/domain/services/controllers/MaterializeController.test.ts +++ b/test/unit/domain/services/controllers/MaterializeController.test.ts @@ -53,6 +53,12 @@ function fakePatchEntry(opts = {}) { }; } +async function* streamFromPromise(items: Promise): AsyncIterable { + for (const item of await items) { + yield item; + } +} + // ── Mock deps factories ────────────────────────────────────────────────────── /** @@ -61,16 +67,30 @@ function fakePatchEntry(opts = {}) { * @param {object} [overrides] */ function makeMockPatches(overrides = {}) { - return { + const patches = { discoverWriters: vi.fn().mockResolvedValue([]), loadWriterPatches: vi.fn().mockResolvedValue([]), collectForFrontier: vi.fn().mockResolvedValue([]), + collectForFrontierSinceCoordinate: vi.fn().mockResolvedValue([]), loadCheckpoint: vi.fn().mockResolvedValue(null), loadPatchesSince: vi.fn().mockResolvedValue([]), loadPatchChain: vi.fn().mockResolvedValue([]), getFrontier: vi.fn().mockResolvedValue(new Map()), ...overrides, }; + return { + ...patches, + streamWriterPatches: vi.fn((writerId: string) => streamFromPromise(patches.loadWriterPatches(writerId))), + streamForFrontier: vi.fn((frontier: Map, ceiling: number | null) => + streamFromPromise(patches.collectForFrontier(frontier, ceiling))), + streamForFrontierSinceCoordinate: vi.fn(( + frontier: Map, + ceiling: number | null, + coordinate: { frontier: Map; ceiling: number | null }, + ) => streamFromPromise(patches.collectForFrontierSinceCoordinate(frontier, ceiling, coordinate))), + streamPatchesSince: vi.fn((checkpoint: Parameters[0]) => + streamFromPromise(patches.loadPatchesSince(checkpoint))), + }; } /** diff --git a/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts b/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts new file mode 100644 index 000000000..7c7e74d96 --- /dev/null +++ b/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import CodecPort from '../../../../../src/ports/CodecPort.ts'; +import CryptoPort from '../../../../../src/ports/CryptoPort.ts'; +import LoggerPort from '../../../../../src/ports/LoggerPort.ts'; +import DetachedGraphFactory, { + type DetachedGraphInternalReadSurface, +} from '../../../../../src/domain/capabilities/DetachedGraphFactory.ts'; +import PatchCollector, { + type CheckpointData, + type PatchWithSha, +} from '../../../../../src/domain/capabilities/PatchCollector.ts'; +import MaterializeController, { + type MaterializeDeps, + type MaterializePersistence, +} from '../../../../../src/domain/services/controllers/MaterializeController.ts'; +import MaterializePatchStreamReducer from '../../../../../src/domain/services/controllers/MaterializePatchStreamReducer.ts'; +import Patch from '../../../../../src/domain/types/Patch.ts'; +import type CodecValue from '../../../../../src/domain/types/codec/CodecValue.ts'; +import type LogFields from '../../../../../src/domain/types/log/LogFields.ts'; + +describe('MaterializePatchStreamReducer', () => { + it('reduces each patch before requesting the next stream item', async () => { + const reduction = await MaterializePatchStreamReducer.reduce( + { + source: ephemeralPatchStream(128), + base: undefined, + options: { receipts: false, wantDiff: false }, + }, + ); + + expect(reduction.summary.patchCount).toBe(128); + expect(reduction.summary.maxObservedLamport).toBe(128); + expect(reduction.summary.provenance.patchesFor('node-001')).toEqual(['sha-001']); + expect(reduction.summary.provenance.patchesFor('node-128')).toEqual(['sha-128']); + expect(reduction.summary.provenance.has('poisoned-node')).toBe(false); + }); +}); + +describe('MaterializeController patch streams', () => { + it('materializes live graphs from streamWriterPatches without loading writer arrays', async () => { + const collector = new StreamingOnlyPatchCollector([ + patchEntry(1), + patchEntry(2), + patchEntry(3), + ]); + const controller = new MaterializeController(materializeDeps(collector)); + + const result = await controller.materialize(); + + expect(result.patchCount).toBe(3); + expect(result.maxObservedLamport).toBe(3); + expect(result.provenanceIndex.patchesFor('node-001')).toEqual(['sha-001']); + expect(result.provenanceIndex.patchesFor('node-003')).toEqual(['sha-003']); + expect(collector.streamedWriters).toEqual(['writer-a']); + expect(collector.writerLoadCount).toBe(0); + }); +}); + +async function* ephemeralPatchStream(count: number): AsyncIterable { + let previous: PatchWithSha | null = null; + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + if (previous !== null) { + poison(previous); + } + const entry = patchEntry(ordinal); + previous = entry; + yield entry; + } + if (previous !== null) { + poison(previous); + } +} + +function poison(entry: PatchWithSha): void { + entry.patch = new Patch({ + writer: 'writer-poison', + lamport: 0, + context: {}, + ops: [], + writes: ['poisoned-node'], + }); + entry.sha = 'poisoned-sha'; +} + +function patchEntry(lamport: number): PatchWithSha { + const suffix = lamport.toString().padStart(3, '0'); + return { + patch: new Patch({ + writer: 'writer-a', + lamport, + context: {}, + ops: [], + writes: [`node-${suffix}`], + }), + sha: `sha-${suffix}`, + }; +} + +class StreamingOnlyPatchCollector extends PatchCollector { + readonly streamedWriters: string[] = []; + writerLoadCount = 0; + readonly #entries: readonly PatchWithSha[]; + + constructor(entries: readonly PatchWithSha[]) { + super(); + this.#entries = entries; + } + + async discoverWriters(): Promise { + return ['writer-a']; + } + + async loadWriterPatches(_writerId: string): Promise { + this.writerLoadCount += 1; + throw new Error('loadWriterPatches must not be called by stream materialization'); + } + + override async *streamWriterPatches(writerId: string): AsyncIterable { + this.streamedWriters.push(writerId); + yield* ephemeralEntries(this.#entries); + } + + async loadCheckpoint(): Promise { + return null; + } + + async loadPatchesSince(_checkpoint: CheckpointData): Promise { + throw new Error('loadPatchesSince must not be called by stream materialization'); + } + + async loadPatchChain(_toSha: string, _fromSha?: string | null): Promise { + throw new Error('loadPatchChain must not be called by live materialization'); + } + + async getFrontier(): Promise> { + return new Map([['writer-a', 'sha-003']]); + } +} + +async function* ephemeralEntries(entries: readonly PatchWithSha[]): AsyncIterable { + let previous: PatchWithSha | null = null; + for (const source of entries) { + if (previous !== null) { + poison(previous); + } + const entry = patchEntry(source.patch.lamport); + previous = entry; + yield entry; + } + if (previous !== null) { + poison(previous); + } +} + +function materializeDeps(patches: PatchCollector): MaterializeDeps { + return { + logger: new TestLogger(), + codec: new TestCodec(), + crypto: new TestCrypto(), + persistence: new TestPersistence(), + patches, + graphCloner: new UnusedDetachedGraphFactory(), + graphName: 'stream-memory-witness', + }; +} + +class TestCodec extends CodecPort { + encode(_data: TEncoded): Uint8Array { + return new Uint8Array([1, 2, 3]); + } + + decode(_bytes: Uint8Array): TDecoded { + throw new Error('decode is not used by stream materialization witness'); + } +} + +class TestCrypto extends CryptoPort { + async hash(_algorithm: string, _data: string | Uint8Array): Promise { + return 'state-hash'; + } + + async hmac( + _algorithm: string, + _key: string | Uint8Array, + _data: string | Uint8Array, + ): Promise { + return new Uint8Array([1]); + } + + timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length; + } +} + +class TestLogger extends LoggerPort { + debug(_message: string, _context?: LogFields): void {} + info(_message: string, _context?: LogFields): void {} + warn(_message: string, _context?: LogFields): void {} + error(_message: string, _context?: LogFields): void {} + + child(_context: LogFields): LoggerPort { + return this; + } +} + +class TestPersistence implements MaterializePersistence { + async readRef(_ref: string): Promise { + return null; + } + + async showNode(_sha: string): Promise { + return ''; + } + + async readTreeOids(_treeOid: string): Promise> { + return {}; + } + + async readBlob(_oid: string): Promise { + return new Uint8Array(); + } +} + +class UnusedDetachedGraphFactory extends DetachedGraphFactory { + async openReadOnly(): Promise { + throw new Error('detached graph factory is not used by stream materialization witness'); + } +} diff --git a/test/unit/domain/services/controllers/QueryController.test.ts b/test/unit/domain/services/controllers/QueryController.test.ts index fbf8a72c3..5fdc93085 100644 --- a/test/unit/domain/services/controllers/QueryController.test.ts +++ b/test/unit/domain/services/controllers/QueryController.test.ts @@ -915,7 +915,7 @@ describe('QueryController', () => { // ── worldline ──────────────────────────────────────────────────────────── describe('worldline()', () => { - it('returns a Worldline instance', () => { + it('returns a ProjectionHandle instance', () => { const wl = ctrl.worldline(); expect(wl).toBeDefined(); expect(typeof wl.query).toBe('function'); diff --git a/test/unit/domain/services/controllers/StrandController.host-interface.test.ts b/test/unit/domain/services/controllers/StrandController.host-interface.test.ts new file mode 100644 index 000000000..e4399c760 --- /dev/null +++ b/test/unit/domain/services/controllers/StrandController.host-interface.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import StrandController, { + type StrandHost, +} from '../../../../../src/domain/services/controllers/StrandController.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/domain/services/codec/WarpMessageCodec.ts'; +import InMemoryGraphAdapter from '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import CryptoPort from '../../../../../src/ports/CryptoPort.ts'; + +import type Patch from '../../../../../src/domain/types/Patch.ts'; +import type { HashablePayload } from '../../../../../src/domain/types/conflict/HashablePayload.ts'; + +class StrandControllerCrypto extends CryptoPort { + async hash(_algorithm: string, _data: string | Uint8Array): Promise { + return 'digest'; + } + + async hmac(_algorithm: string, _key: string | Uint8Array, _data: string | Uint8Array): Promise { + return new Uint8Array(); + } + + timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.byteLength === b.byteLength; + } +} + +function createStrandHost(): StrandHost { + return { + _graphName: 'strand-host-interface', + _persistence: new InMemoryGraphAdapter(), + _crypto: new StrandControllerCrypto(), + _loadPatchChainFromSha: async (_sha: string): Promise> => [], + _loadWriterPatches: async (_writerId: string): Promise> => [], + _maxObservedLamport: 0, + _provenanceIndex: null, + _provenanceDegraded: false, + _cachedCeiling: null, + _cachedFrontier: null, + _lastFrontier: null, + _setMaterializedState: async () => undefined, + getFrontier: async () => new Map(), + _patchInProgress: false, + _stateDirty: false, + _cachedViewHash: null, + _cachedState: null, + _patchJournal: null, + _patchBlobStorage: null, + _blobStorage: null, + _commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + _logger: null, + _codec: { + encode(_value: HashablePayload): Uint8Array { + return new Uint8Array(); + }, + }, + _onDeleteWithData: 'warn', + }; +} + +describe('StrandController host interface', () => { + it('constructs from the named controller capability interface', () => { + const host = createStrandHost(); + const controller = new StrandController(host); + + expect(controller._host).toBe(host); + }); +}); diff --git a/test/unit/domain/services/coordinate/SubstrateCoordinateBoundary.test.ts b/test/unit/domain/services/coordinate/SubstrateCoordinateBoundary.test.ts new file mode 100644 index 000000000..67575e29c --- /dev/null +++ b/test/unit/domain/services/coordinate/SubstrateCoordinateBoundary.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import SubstrateCoordinateBoundary, { + SESSION_POLICY_CAPABILITY_NAMES, + SUBSTRATE_CAPABILITY_NAMES, + SUBSTRATE_COORDINATE_KINDS, + SUBSTRATE_LANE_KINDS, +} from '../../../../../src/domain/services/coordinate/SubstrateCoordinateBoundary.ts'; + +describe('SubstrateCoordinateBoundary', () => { + it('freezes the stable lane and coordinate noun families', () => { + expect(SUBSTRATE_LANE_KINDS).toEqual(['worldline', 'strand', 'braid']); + expect(SUBSTRATE_COORDINATE_KINDS).toEqual(['live', 'frontier', 'checkpoint', 'strand-base']); + expect(Object.isFrozen(SUBSTRATE_LANE_KINDS)).toBe(true); + expect(Object.isFrozen(SUBSTRATE_COORDINATE_KINDS)).toBe(true); + }); + + it('separates substrate capabilities from debugger session policy', () => { + const boundary = new SubstrateCoordinateBoundary(); + + expect(boundary.authorityFor('worldline.live')).toBe('substrate'); + expect(boundary.authorityFor('strand.braid')).toBe('substrate'); + expect(boundary.authorityFor('coordinate.transfer-plan')).toBe('substrate'); + expect(boundary.authorityFor('debugger.cursor')).toBe('session-policy'); + expect(boundary.authorityFor('session.shortcut')).toBe('session-policy'); + expect(boundary.authorityFor('debugger.unregistered')).toBeNull(); + expect(Object.isFrozen(SUBSTRATE_CAPABILITY_NAMES)).toBe(true); + expect(Object.isFrozen(SESSION_POLICY_CAPABILITY_NAMES)).toBe(true); + }); +}); diff --git a/test/unit/domain/services/merge/MergeClassifier.test.ts b/test/unit/domain/services/merge/MergeClassifier.test.ts new file mode 100644 index 000000000..9f42782d6 --- /dev/null +++ b/test/unit/domain/services/merge/MergeClassifier.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import MergeClassifier from '../../../../../src/domain/services/merge/MergeClassifier.ts'; +import MergeClassificationEvidence from '../../../../../src/domain/services/merge/MergeClassificationEvidence.ts'; +import { + MERGE_CONFLICT_CORPUS, + type MergeConflictCorpusCase, +} from '../../../../fixtures/mergeConflictCorpus.ts'; + +const classifier = new MergeClassifier(); + +function classify(fields: ConstructorParameters[0]) { + return classifier.classify(new MergeClassificationEvidence(fields)); +} + +function evidenceForCorpusCase(item: MergeConflictCorpusCase): MergeClassificationEvidence { + const base = { + sharedPrecursor: true, + branchFootprintsOverlap: true, + }; + if (item.classification === 'projection') { + return new MergeClassificationEvidence({ + ...base, + candidateJoin: true, + obstructionWitness: false, + loweringWitness: item.liftingRemovesConflict, + policyRequirement: false, + }); + } + if (item.classification === 'semantic') { + return new MergeClassificationEvidence({ + ...base, + candidateJoin: false, + obstructionWitness: true, + loweringWitness: false, + policyRequirement: false, + }); + } + return new MergeClassificationEvidence({ + ...base, + candidateJoin: false, + obstructionWitness: true, + loweringWitness: false, + policyRequirement: true, + }); +} + +describe('MergeClassifier', () => { + it('classifies lossy map or import rendering conflicts as projection conflicts', () => { + const result = classify({ + sharedPrecursor: true, + branchFootprintsOverlap: true, + candidateJoin: true, + obstructionWitness: false, + loweringWitness: true, + policyRequirement: false, + }); + + expect(result.kind).toBe('projection'); + expect(result.confidence).toBe('high'); + expect(result.reasonCodes).toContain('lowering-witness'); + }); + + it('classifies singleton slot obstructions as semantic conflicts', () => { + const result = classify({ + sharedPrecursor: true, + branchFootprintsOverlap: true, + candidateJoin: false, + obstructionWitness: true, + loweringWitness: false, + policyRequirement: false, + }); + + expect(result.kind).toBe('semantic'); + expect(result.confidence).toBe('high'); + expect(result.reasonCodes).toContain('obstruction-witness'); + }); + + it('classifies release authority disputes as governance conflicts', () => { + const result = classify({ + sharedPrecursor: true, + branchFootprintsOverlap: true, + candidateJoin: false, + obstructionWitness: true, + loweringWitness: false, + policyRequirement: true, + }); + + expect(result.kind).toBe('governance'); + expect(result.confidence).toBe('high'); + expect(result.reasonCodes).toContain('policy-requirement'); + }); + + it('agrees with the normalized merge conflict corpus labels', () => { + for (const item of MERGE_CONFLICT_CORPUS) { + const result = classifier.classify(evidenceForCorpusCase(item)); + + expect(result.kind).toBe(item.classification); + } + }); +}); diff --git a/test/unit/domain/services/merge/TtdMergeInspector.test.ts b/test/unit/domain/services/merge/TtdMergeInspector.test.ts new file mode 100644 index 000000000..4e06c0a4a --- /dev/null +++ b/test/unit/domain/services/merge/TtdMergeInspector.test.ts @@ -0,0 +1,119 @@ +import { assert, describe, expect, it } from 'vitest'; + +import { + TtdMergeBranch, + TtdMergeFootprint, + TtdMergeInspector, + TtdMergeLoweringWitness, + TtdMergeObstructionWitness, + TtdMergePolicyRequirement, +} from '../../../../../index.ts'; + +function itemAt(items: readonly T[], index: number): T { + const item = items[index]; + assert.isDefined(item); + return item; +} + +describe('TtdMergeInspector', () => { + it('renders a read-only object merge inspection with a canonical join', () => { + const inspector = new TtdMergeInspector(); + + const inspection = inspector.inspectJsonObject({ + precursor: { owner: 'ada', status: 'draft' }, + left: { + branchId: 'branch-a', + strandId: 'strand-a', + fields: { owner: 'ada', status: 'ready' }, + }, + right: { + branchId: 'branch-b', + strandId: 'strand-b', + fields: { owner: 'ada', priority: 'high', status: 'draft' }, + }, + }); + const leftBranch = itemAt(inspection.branches, 0); + const rightBranch = itemAt(inspection.branches, 1); + const leftFootprint = itemAt(inspection.footprints, 0); + const rightFootprint = itemAt(inspection.footprints, 1); + const lowering = itemAt(inspection.loweringWitnesses, 0); + + expect(inspection.protocolVersion).toBe('ttd-merge-inspection/v1'); + expect(inspection.domain).toBe('json-object'); + expect(leftBranch.strandId).toBe('strand-a'); + expect(rightBranch.strandId).toBe('strand-b'); + expect(leftFootprint.changedKeys).toEqual(['status']); + expect(rightFootprint.changedKeys).toEqual(['priority']); + expect(inspection.overlapKeys).toEqual([]); + expect(inspection.candidateCanonicalJoin).toEqual({ + owner: 'ada', + priority: 'high', + status: 'ready', + }); + expect(inspection.obstructionWitnesses).toEqual([]); + expect(lowering.surface).toBe('canonical-json-object'); + expect(inspection.classification.kind).toBe('projection'); + expect(inspection.classification.reasonCodes).toContain('candidate-join'); + expect(Object.isFrozen(inspection)).toBe(true); + expect(Object.isFrozen(inspection.candidateCanonicalJoin)).toBe(true); + }); + + it('renders object key collisions as obstruction witnesses', () => { + const inspector = new TtdMergeInspector(); + + const inspection = inspector.inspectJsonObject({ + precursor: { owner: 'ada', status: 'draft' }, + left: { + branchId: 'branch-a', + strandId: 'strand-a', + fields: { owner: 'ada', status: 'approved' }, + }, + right: { + branchId: 'branch-b', + strandId: 'strand-b', + fields: { owner: 'ada', status: 'rejected' }, + }, + }); + const obstruction = itemAt(inspection.obstructionWitnesses, 0); + const lowering = itemAt(inspection.loweringWitnesses, 0); + + expect(inspection.overlapKeys).toEqual(['status']); + expect(inspection.candidateCanonicalJoin).toBeNull(); + expect(inspection.obstructionWitnesses).toHaveLength(1); + expect(obstruction.fieldKey).toBe('status'); + expect(obstruction.precursorValue).toBe('draft'); + expect(obstruction.leftValue).toBe('approved'); + expect(obstruction.rightValue).toBe('rejected'); + expect(lowering.surface).toBe('obstruction-list'); + expect(inspection.classification.kind).toBe('semantic'); + expect(inspection.classification.reasonCodes).toContain('obstruction-witness'); + }); + + it('keeps policy requirements as first-class governance evidence', () => { + const inspector = new TtdMergeInspector(); + const policy = new TtdMergePolicyRequirement({ + code: 'human-review', + message: 'A release authority must accept this join.', + }); + + const inspection = inspector.inspectJsonObject({ + precursor: { status: 'draft' }, + left: { + branchId: 'branch-a', + strandId: 'strand-a', + fields: { status: 'ready' }, + }, + right: { + branchId: 'branch-b', + strandId: 'strand-b', + fields: { status: 'ready' }, + }, + policyRequirements: [policy], + }); + + expect(inspection.candidateCanonicalJoin).toEqual({ status: 'ready' }); + expect(inspection.policyRequirements).toEqual([policy]); + expect(inspection.classification.kind).toBe('governance'); + expect(inspection.classification.reasonCodes).toContain('policy-requirement'); + }); +}); diff --git a/test/unit/domain/services/query/BoundedSupportRule.test.ts b/test/unit/domain/services/query/BoundedSupportRule.test.ts new file mode 100644 index 000000000..4af682078 --- /dev/null +++ b/test/unit/domain/services/query/BoundedSupportRule.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + BoundedSupportRule, + QueryBuilder, +} from '../../../../../index.ts'; +import QueryError from '../../../../../src/domain/errors/QueryError.ts'; +import type { + QueryNeighborEntry, + QueryReadModel, + QueryReadModelOpenRequest, + QueryReadModelProvider, +} from '../../../../../src/domain/services/query/QueryReadModelProvider.ts'; +import type { QueryNodeSnapshot } from '../../../../../src/domain/services/query/QueryPlan.ts'; + +class NoopQueryReadModelProvider implements QueryReadModelProvider { + async openQueryReadModel(_request?: QueryReadModelOpenRequest): Promise { + return { + stateHash: 'unused', + async *nodes() { + const nodes: QueryNodeSnapshot[] = []; + for (const node of nodes) { + yield node; + } + }, + async *neighbors() { + const neighbors: QueryNeighborEntry[] = []; + for (const neighbor of neighbors) { + yield neighbor; + } + }, + async nodeProps() { + return null; + }, + }; + } +} + +function query(): QueryBuilder { + return new QueryBuilder(new NoopQueryReadModelProvider()); +} + +describe('BoundedSupportRule', () => { + it('classifies exact node-id queries as entity support', () => { + const rule = query() + .match('user:alice') + .supportRule(); + + expect(rule).toBeInstanceOf(BoundedSupportRule); + expect(Object.isFrozen(rule)).toBe(true); + expect(rule.kind).toBe('entity'); + expect(rule.rootNodeIds).toEqual(['user:alice']); + expect(rule.isBounded()).toBe(true); + expect(rule.requiresWholeGraphDiscovery()).toBe(false); + }); + + it('classifies exact rooted traversal as neighborhood support', () => { + const rule = query() + .match('user:ceo') + .outgoing('manages', { depth: [1, 3] }) + .incoming('reports-to', { depth: 1 }) + .supportRule(); + + expect(rule.kind).toBe('neighborhood'); + expect(rule.rootNodeIds).toEqual(['user:ceo']); + expect(rule.maxDepth).toBe(3); + expect(rule.directions).toEqual(['incoming', 'outgoing']); + expect(rule.isBounded()).toBe(true); + }); + + it('classifies wildcard reads as global discovery', () => { + const rule = query() + .match('task:*') + .where({ status: 'todo' }) + .supportRule(); + + expect(rule.kind).toBe('global-discovery'); + expect(rule.rootNodeIds).toEqual([]); + expect(rule.requiresWholeGraphDiscovery()).toBe(true); + expect(rule.reason).toContain('visible graph'); + }); + + it('normalizes explicit support-rule fields', () => { + const rule = new BoundedSupportRule({ + surface: 'query', + kind: 'entity', + reason: 'manual exact read', + rootNodeIds: ['node:b', 'node:a', 'node:a'], + }); + + expect(rule.rootNodeIds).toEqual(['node:a', 'node:b']); + expect(Object.isFrozen(rule.rootNodeIds)).toBe(true); + }); + + it('rejects invalid runtime carriers', () => { + expect(() => new BoundedSupportRule({ + surface: 'query', + kind: 'entity', + reason: 'bad max depth', + maxDepth: -1, + })).toThrow(QueryError); + + expect(() => BoundedSupportRule.fromQueryPlan( + // @ts-expect-error runtime guard for JavaScript callers + undefined, + )).toThrow(QueryError); + }); +}); diff --git a/test/unit/domain/services/query/CausalIndexPlan.test.ts b/test/unit/domain/services/query/CausalIndexPlan.test.ts new file mode 100644 index 000000000..2a0ef3871 --- /dev/null +++ b/test/unit/domain/services/query/CausalIndexPlan.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { + BoundedSupportRule, + CausalIndexPlan, +} from '../../../../../index.ts'; +import QueryError from '../../../../../src/domain/errors/QueryError.ts'; + +describe('CausalIndexPlan', () => { + it('maps entity support to the provenance entity-patch index family', () => { + const supportRule = BoundedSupportRule.entityRead({ + surface: 'query', + nodeIds: ['node:b', 'node:a'], + }); + const plan = CausalIndexPlan.fromSupportRule(supportRule); + + expect(plan.supportRule).toBe(supportRule); + expect(plan.posture).toBe('available'); + expect(plan.families).toEqual(['entity-patch']); + expect(plan.requiredEntityIds).toEqual(['node:a', 'node:b']); + expect(plan.canUseCausalIndex()).toBe(true); + expect(plan.requiresGlobalScan()).toBe(false); + }); + + it('maps neighborhood support to a composite causal-index posture', () => { + const supportRule = BoundedSupportRule.neighborhoodRead({ + surface: 'query', + rootNodeIds: ['node:root'], + maxDepth: 2, + directions: ['outgoing'], + }); + const plan = CausalIndexPlan.fromSupportRule(supportRule); + + expect(plan.posture).toBe('composite'); + expect(plan.families).toEqual(['entity-patch', 'neighborhood-adjacency']); + expect(plan.requiredEntityIds).toEqual(['node:root']); + expect(plan.canUseCausalIndex()).toBe(true); + }); + + it('marks global discovery as unsupported by bounded causal indexes', () => { + const supportRule = BoundedSupportRule.globalDiscovery({ + surface: 'query', + reason: 'wildcard query', + }); + const plan = CausalIndexPlan.fromSupportRule(supportRule); + + expect(plan.posture).toBe('unsupported'); + expect(plan.families).toEqual(['global-discovery']); + expect(plan.requiredEntityIds).toEqual([]); + expect(plan.canUseCausalIndex()).toBe(false); + expect(plan.requiresGlobalScan()).toBe(true); + }); + + it('rejects invalid runtime carriers', () => { + expect(() => CausalIndexPlan.fromSupportRule( + // @ts-expect-error runtime guard for JavaScript callers + undefined, + )).toThrow(QueryError); + }); +}); diff --git a/test/unit/domain/services/query/ObserverStructural.test.ts b/test/unit/domain/services/query/ObserverStructural.test.ts new file mode 100644 index 000000000..584d8ce66 --- /dev/null +++ b/test/unit/domain/services/query/ObserverStructural.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest'; + +import Observer, { + type ObserverBacking, + type ObserverConfig, +} from '../../../../../src/domain/services/query/Observer.ts'; +import ObserverAccumulation from '../../../../../src/domain/services/query/ObserverAccumulation.ts'; +import ObserverBasis from '../../../../../src/domain/services/query/ObserverBasis.ts'; +import ObserverEmission from '../../../../../src/domain/services/query/ObserverEmission.ts'; +import ObserverPlan from '../../../../../src/domain/services/query/ObserverPlan.ts'; +import ObserverReadingEnvelope from '../../../../../src/domain/services/query/ObserverReadingEnvelope.ts'; +import GitWarpReceiptEnvelopeBoundary + from '../../../../../src/domain/continuum/GitWarpReceiptEnvelopeBoundary.ts'; +import { TickReceipt } from '../../../../../src/domain/types/TickReceipt.ts'; +import type { WorldlineSource } from '../../../../../src/domain/capabilities/QueryCapability.ts'; + +type ObserverCall = { + readonly name: string; + readonly config: ObserverConfig; + readonly source: WorldlineSource; +}; + +class StructuralObserverBacking implements ObserverBacking { + readonly seekCalls: ObserverCall[] = []; + + hasNode(nodeId: string): Promise { + return Promise.resolve(nodeId === 'task:a' || nodeId === 'task:c'); + } + + getNodes(): Promise { + return Promise.resolve(['task:a', 'note:b', 'task:c']); + } + + getNodeProps(nodeId: string): Promise<{ readonly [key: string]: string } | null> { + if (nodeId === 'task:a') { + return Promise.resolve({ status: 'open', owner: 'ada', secret: 'hidden' }); + } + if (nodeId === 'task:c') { + return Promise.resolve({ status: 'done' }); + } + return Promise.resolve(null); + } + + getEdges(): Promise> { + return Promise.resolve([ + { from: 'task:a', to: 'task:c', label: 'blocks', props: { status: 'active' } }, + { from: 'task:a', to: 'note:b', label: 'mentions', props: { status: 'ignored' } }, + ]); + } + + observer( + name: string, + config: ObserverConfig, + options: { source: WorldlineSource }, + ): Promise { + this.seekCalls.push({ name, config, source: options.source }); + return Promise.resolve(new Observer({ + name, + config, + graph: this, + source: options.source, + })); + } +} + +function makeReceiptBoundary(): GitWarpReceiptEnvelopeBoundary { + return new GitWarpReceiptEnvelopeBoundary({ + receipt: new TickReceipt({ + patchSha: 'c'.repeat(40), + writer: 'writer-a', + lamport: 4, + ops: [{ + op: 'NodeAdd', + target: 'task:a', + result: 'applied', + }], + }), + }); +} + +describe('Observer structural surface', () => { + it('exposes a native basis and preserves it through seek()', async () => { + const backing = new StructuralObserverBacking(); + const observer = new Observer({ + name: 'structural', + config: { + match: 'task:*', + expose: ['status'], + basis: ['status', 'owner'], + }, + graph: backing, + }); + + expect(observer.basis).toBeInstanceOf(ObserverBasis); + expect(observer.basis.distinctions).toEqual(['status', 'owner']); + expect(Object.isFrozen(observer.basis)).toBe(true); + + await observer.seek(); + + expect(backing.seekCalls).toEqual([ + { + name: 'structural', + config: { + match: 'task:*', + expose: ['status'], + basis: ['status', 'owner'], + }, + source: { kind: 'live' }, + }, + ]); + }); + + it('accumulates visible observer state and emits a deterministic summary', async () => { + const backing = new StructuralObserverBacking(); + const observer = new Observer({ + name: 'structural', + config: { + match: 'task:*', + expose: ['status'], + basis: ['status', 'owner'], + }, + graph: backing, + }); + + const accumulation = await observer.accumulate(); + const emission = accumulation.emit(); + + expect(accumulation).toBeInstanceOf(ObserverAccumulation); + expect(accumulation.nodeCount).toBe(2); + expect(accumulation.edgeCount).toBe(1); + expect(accumulation.propertyKeys).toEqual(['status']); + expect(Object.isFrozen(accumulation.propertyKeys)).toBe(true); + + expect(emission).toBeInstanceOf(ObserverEmission); + expect(emission).toEqual(new ObserverEmission({ + basis: ['status', 'owner'], + nodeCount: 2, + edgeCount: 1, + propertyKeys: ['status'], + matchedBasis: ['status'], + })); + await expect(observer.emit()).resolves.toEqual(emission); + }); + + it('emits one reading envelope family from the observer plan and payload', async () => { + const observer = new Observer({ + name: 'structural', + config: { + match: 'task:*', + expose: ['status'], + basis: ['status', 'owner'], + }, + graph: new StructuralObserverBacking(), + }); + + const plan = observer.plan(); + const envelope = await observer.readingEnvelope({ + witnessRef: 'witness:observer-structural', + shellRef: 'shell:observer-structural', + pluralityRef: 'plurality:status-owner', + receiptAnchors: [makeReceiptBoundary().stableAnchor()], + }); + + expect(plan).toBeInstanceOf(ObserverPlan); + expect(plan.name).toBe('structural'); + expect(plan.source).toEqual({ kind: 'live' }); + expect(plan.toConfig()).toEqual({ + match: 'task:*', + expose: ['status'], + basis: ['status', 'owner'], + }); + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(plan.expose)).toBe(true); + + expect(envelope).toBeInstanceOf(ObserverReadingEnvelope); + expect(envelope.plan).toEqual(plan); + expect(envelope.payload).toEqual(new ObserverEmission({ + basis: ['status', 'owner'], + nodeCount: 2, + edgeCount: 1, + propertyKeys: ['status'], + matchedBasis: ['status'], + })); + expect(envelope.budget).toEqual({ + nodeCount: 2, + edgeCount: 1, + propertyKeyCount: 1, + matchedBasisCount: 1, + }); + expect(envelope.residualBasis).toEqual(['owner']); + expect(envelope.hasResidual()).toBe(true); + expect(envelope.hasPlurality()).toBe(true); + expect(envelope.hasReceiptAnchors()).toBe(true); + expect(envelope.receiptAnchors).toEqual([{ + boundaryVersion: 'git-warp.receipt-envelope-boundary/v1', + substrateFactKind: 'git-warp.tick-receipt', + patchSha: 'c'.repeat(40), + writer: 'writer-a', + lamport: 4, + outcomeCount: 1, + appliedCount: 1, + supersededCount: 0, + redundantCount: 0, + hasExplanatoryReasons: false, + }]); + expect(envelope.source).toEqual({ kind: 'live' }); + expect(envelope.witnessRef).toBe('witness:observer-structural'); + expect(envelope.shellRef).toBe('shell:observer-structural'); + expect(Object.isFrozen(envelope)).toBe(true); + expect(Object.isFrozen(envelope.budget)).toBe(true); + expect(Object.isFrozen(envelope.receiptAnchors)).toBe(true); + expect(Object.isFrozen(envelope.receiptAnchors[0])).toBe(true); + expect(Object.isFrozen(envelope.residualBasis)).toBe(true); + }); + + it('rejects invalid basis distinctions at construction time', () => { + expect(() => new Observer({ + name: 'invalid', + config: { match: '*', basis: ['status', ''] }, + graph: new StructuralObserverBacking(), + })).toThrow('observer basis distinction must be non-empty'); + }); + + it('rejects invalid observer plans and reading envelopes', () => { + const basis = new ObserverBasis(['status']); + const payload = new ObserverEmission({ + basis: ['status'], + nodeCount: 1, + edgeCount: 0, + propertyKeys: ['status'], + matchedBasis: ['status'], + }); + + expect(() => new ObserverPlan({ + name: '', + match: '*', + basis, + source: { kind: 'live' }, + })).toThrow('observer plan field must be a non-empty string'); + + expect(() => new ObserverPlan({ + name: 'invalid', + match: [], + basis, + source: { kind: 'live' }, + })).toThrow('observer plan match must be a string or non-empty string array'); + + expect(() => new ObserverReadingEnvelope({ + plan: new ObserverPlan({ + name: 'valid', + match: '*', + basis, + source: { kind: 'live' }, + }), + payload, + witnessRef: '', + })).toThrow('observer reading envelope refs must be non-empty when provided'); + + expect(() => new ObserverReadingEnvelope({ + // @ts-expect-error runtime guard for JavaScript callers + plan: payload, + payload, + })).toThrow('observer reading envelope requires an ObserverPlan'); + + expect(() => new ObserverReadingEnvelope({ + plan: new ObserverPlan({ + name: 'valid', + match: '*', + basis, + source: { kind: 'live' }, + }), + payload, + receiptAnchors: [{ + // @ts-expect-error runtime guard for JavaScript callers + boundaryVersion: 'unsupported-boundary', + substrateFactKind: 'git-warp.tick-receipt', + patchSha: 'c'.repeat(40), + writer: 'writer-a', + lamport: 4, + outcomeCount: 1, + appliedCount: 1, + supersededCount: 0, + redundantCount: 0, + hasExplanatoryReasons: false, + }], + })).toThrow('observer reading envelope receipt anchor has an unsupported boundary'); + }); +}); diff --git a/test/unit/domain/services/query/SupportFragmentPlan.test.ts b/test/unit/domain/services/query/SupportFragmentPlan.test.ts new file mode 100644 index 000000000..de1aec25e --- /dev/null +++ b/test/unit/domain/services/query/SupportFragmentPlan.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { + BoundedSupportRule, + CausalIndexPlan, + QueryBuilder, + SupportFragmentPlan, +} from '../../../../../index.ts'; +import QueryError from '../../../../../src/domain/errors/QueryError.ts'; +import type { + QueryNeighborEntry, + QueryReadModel, + QueryReadModelOpenRequest, + QueryReadModelProvider, +} from '../../../../../src/domain/services/query/QueryReadModelProvider.ts'; +import type { QueryNodeSnapshot } from '../../../../../src/domain/services/query/QueryPlan.ts'; + +class NoopQueryReadModelProvider implements QueryReadModelProvider { + async openQueryReadModel(_request?: QueryReadModelOpenRequest): Promise { + return { + stateHash: 'unused', + async *nodes() { + const nodes: QueryNodeSnapshot[] = []; + for (const node of nodes) { + yield node; + } + }, + async *neighbors() { + const neighbors: QueryNeighborEntry[] = []; + for (const neighbor of neighbors) { + yield neighbor; + } + }, + async nodeProps() { + return null; + }, + }; + } +} + +function query(): QueryBuilder { + return new QueryBuilder(new NoopQueryReadModelProvider()); +} + +describe('SupportFragmentPlan', () => { + it('creates a cacheable support fragment plan for exact entity reads', () => { + const plan = query() + .match('node:a') + .supportFragmentPlan(); + + expect(plan).toBeInstanceOf(SupportFragmentPlan); + expect(plan.posture).toBe('support-fragment'); + expect(plan.supportRule.kind).toBe('entity'); + expect(plan.causalIndexPlan.families).toEqual(['entity-patch']); + expect(plan.requiredEntityIds).toEqual(['node:a']); + expect(plan.canMaterializeSupportFragment()).toBe(true); + expect(plan.requiresFullGraphFallback()).toBe(false); + expect(plan.fragmentKeyForCoordinate('frontier:demo')).toBe( + 'surface:query/kind:entity/roots:node:a/depth:none/directions:none/indexes:entity-patch@frontier:demo', + ); + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(plan.requiredEntityIds)).toBe(true); + }); + + it('creates an index-fill support fragment plan for rooted traversals', () => { + const plan = query() + .match('node:a') + .outgoing('links', { depth: [1, 2] }) + .supportFragmentPlan(); + + expect(plan.posture).toBe('support-fragment-with-index-fill'); + expect(plan.supportRule.kind).toBe('neighborhood'); + expect(plan.scopeKey).toContain('depth:2'); + expect(plan.scopeKey).toContain('directions:outgoing'); + expect(plan.scopeKey).toContain('indexes:entity-patch+neighborhood-adjacency'); + }); + + it('marks wildcard discovery as full-graph fallback instead of a fragment key', () => { + const plan = query() + .match('node:*') + .supportFragmentPlan(); + + expect(plan.posture).toBe('global-fallback'); + expect(plan.canMaterializeSupportFragment()).toBe(false); + expect(plan.requiresFullGraphFallback()).toBe(true); + expect(() => plan.fragmentKeyForCoordinate('frontier:demo')).toThrow(QueryError); + }); + + it('rejects mismatched support and causal index plans', () => { + const supportRule = BoundedSupportRule.entityRead({ + surface: 'query', + nodeIds: ['node:a'], + }); + const otherSupportRule = BoundedSupportRule.entityRead({ + surface: 'query', + nodeIds: ['node:b'], + }); + + expect(() => SupportFragmentPlan.fromSupportAndIndex({ + supportRule, + causalIndexPlan: CausalIndexPlan.fromSupportRule(otherSupportRule), + })).toThrow(QueryError); + + expect(() => SupportFragmentPlan.fromSupportRule( + // @ts-expect-error runtime guard for JavaScript callers + undefined, + )).toThrow(QueryError); + }); +}); diff --git a/test/unit/domain/services/strand/ConflictAnalyzerService.test.ts b/test/unit/domain/services/strand/ConflictAnalyzerService.test.ts index fc908dbd9..747772976 100644 --- a/test/unit/domain/services/strand/ConflictAnalyzerService.test.ts +++ b/test/unit/domain/services/strand/ConflictAnalyzerService.test.ts @@ -8,6 +8,7 @@ import QueryError from '../../../../../src/domain/errors/QueryError.ts'; import { textEncode } from '../../../../../src/domain/utils/bytes.ts'; import { createHash } from 'node:crypto'; import StrandCoordinator from '../../../../../src/domain/services/strand/StrandCoordinator.ts'; +import ConflictPipelineContext from '../../../../../src/domain/services/strand/ConflictPipelineContext.ts'; // ── Deterministic helpers ───────────────────────────────────────────────────── @@ -89,14 +90,13 @@ describe('ConflictAnalyzerService', () => { // ── Constructor ───────────────────────────────────────────────────────── describe('constructor', () => { - it('stores graph reference and initializes digest cache', () => { + it('initializes the explicit conflict pipeline context', () => { const graph = createMockGraph(); const analyzer = new ConflictAnalyzerService({ graph }); const anyAnalyzer = analyzer as any; - expect(anyAnalyzer._graph).toBe(graph); - expect(anyAnalyzer._digestCache).toBeInstanceOf(Map); - expect(anyAnalyzer._digestCache.size).toBe(0); + expect(anyAnalyzer._pipelineContext).toBeInstanceOf(ConflictPipelineContext); + expect(anyAnalyzer._pipelineContext.graph).toBe(graph); }); }); @@ -1454,8 +1454,11 @@ describe('ConflictAnalyzerService', () => { }, }); const analyzer = new ConflictAnalyzerService({ graph }); - const originalHash = analyzer._hash.bind(analyzer); - const hashSpy = vi.spyOn(analyzer, '_hash').mockImplementation(async (payload) => { + const originalHash = ConflictPipelineContext.prototype.hash; + const hashSpy = vi.spyOn(ConflictPipelineContext.prototype, 'hash').mockImplementation(async function ( + this: ConflictPipelineContext, + payload, + ) { if ( payload !== null && typeof payload === 'object' && @@ -1464,7 +1467,7 @@ describe('ConflictAnalyzerService', () => { ) { return ''; } - return await originalHash(payload); + return await originalHash.call(this, payload); }); try { @@ -1740,35 +1743,37 @@ describe('ConflictAnalyzerService', () => { }); }); - // ── _hash ─────────────────────────────────────────────────────────────── + // ── ConflictPipelineContext.hash ──────────────────────────────────────── - describe('_hash', () => { + describe('ConflictPipelineContext.hash', () => { it('returns deterministic hash for same payload', async () => { const graph = createMockGraph(); - const analyzer = new ConflictAnalyzerService({ graph }); + const context = new ConflictPipelineContext({ graph }); - const h1 = await analyzer._hash({ key: 'value' }); - const h2 = await analyzer._hash({ key: 'value' }); + const h1 = await context.hash({ key: 'value' }); + const h2 = await context.hash({ key: 'value' }); expect(h1).toBe(h2); }); it('returns different hash for different payloads', async () => { const graph = createMockGraph(); - const analyzer = new ConflictAnalyzerService({ graph }); + const context = new ConflictPipelineContext({ graph }); - const h1 = await analyzer._hash({ a: 1 }); - const h2 = await analyzer._hash({ b: 2 }); + const h1 = await context.hash({ a: 1 }); + const h2 = await context.hash({ b: 2 }); expect(h1).not.toBe(h2); }); - it('caches results in digest cache', async () => { + it('caches repeated payload digests', async () => { const graph = createMockGraph(); - const analyzer = new ConflictAnalyzerService({ graph }); + const context = new ConflictPipelineContext({ graph }); + + await context.hash({ x: 1 }); + await context.hash({ x: 1 }); - await (analyzer as any)._hash({ x: 1 }); - expect((analyzer as any)._digestCache.size).toBeGreaterThan(0); + expect(graph._crypto.hash).toHaveBeenCalledTimes(1); }); }); diff --git a/test/unit/domain/services/strand/StrandService.test.ts b/test/unit/domain/services/strand/StrandService.test.ts index 1f392fe7b..17f80629d 100644 --- a/test/unit/domain/services/strand/StrandService.test.ts +++ b/test/unit/domain/services/strand/StrandService.test.ts @@ -1544,6 +1544,31 @@ describe('StrandService', () => { expect(entries).toHaveLength(2); }); + it('uses live parent basis for patch entries when no pinned overlays exist', async () => { + const desc = buildValidDescriptor({ + strandId: 'alpha', + baseObservation: { + coordinateVersion: STRAND_COORDINATE_VERSION, + frontier: { writer1: 'base-tip' }, + frontierDigest: 'digest-abc', + lamportCeiling: null, + }, + }); + storeDescriptor(desc); + graph.getFrontier.mockResolvedValue(new Map([['writer1', 'live-tip']])); + + patchChains.set('base-tip', [ + { patch: makePatch({ lamport: 1, writer: 'writer1' }), sha: 'base-1' }, + ]); + patchChains.set('live-tip', [ + { patch: makePatch({ lamport: 2, writer: 'writer1' }), sha: 'live-1' }, + ]); + + const entries = await service.getPatchEntries('alpha'); + + expect(entries.map((entry) => entry.sha)).toEqual(['live-1']); + }); + it('filters by ceiling when provided', async () => { const desc = buildValidDescriptor({ strandId: 'alpha', diff --git a/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts b/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts new file mode 100644 index 000000000..6d86b410c --- /dev/null +++ b/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; + +import PatchEntry from '../../../../../src/domain/artifacts/PatchEntry.ts'; +import VersionVector from '../../../../../src/domain/crdt/VersionVector.ts'; +import Patch from '../../../../../src/domain/types/Patch.ts'; +import type LogFields from '../../../../../src/domain/types/log/LogFields.ts'; +import WarpStream from '../../../../../src/domain/stream/WarpStream.ts'; +import { + createSyncRequest, + processSyncRequest, +} from '../../../../../src/domain/services/sync/SyncProtocol.ts'; +import { + validateSyncRequest, + validateSyncResponse, +} from '../../../../../src/domain/services/sync/SyncPayloadSchema.ts'; +import BlobPort from '../../../../../src/ports/BlobPort.ts'; +import CommitPort from '../../../../../src/ports/CommitPort.ts'; +import type { + CommitNodeOptions, + CommitNodeWithTreeOptions, + CommitLogChunk, + LogNodesOptions, + NodeInfo, + PingResult, +} from '../../../../../src/ports/CommitPort.ts'; +import LoggerPort from '../../../../../src/ports/LoggerPort.ts'; +import PatchJournalPort from '../../../../../src/ports/PatchJournalPort.ts'; +import type { ReadPatchOptions } from '../../../../../src/ports/PatchJournalPort.ts'; + +const SHA_1 = '1'.repeat(40); +const SHA_2 = '2'.repeat(40); +const SHA_3 = '3'.repeat(40); +const STRICT_SYNC_LIMITS = Object.freeze({ + maxWritersInFrontier: 10, + maxPatches: 2, + maxOpsPerPatch: 10, + maxStringBytes: 256, + maxBlobBytes: 1024, +}); + +describe('Sync response paging and metrics', () => { + it('pages broad sync responses and emits deterministic response metrics', async () => { + const logger = new RecordingLogger(); + const patchJournal = new StreamingPatchJournal([ + patchEntry('writer-a', SHA_1, 1), + patchEntry('writer-a', SHA_2, 2), + patchEntry('writer-a', SHA_3, 3), + ]); + const localFrontier = new Map([['writer-a', SHA_3]]); + + const firstRequest = createSyncRequest(new Map(), { + page: { maxPatches: 2 }, + }); + const first = await processSyncRequest( + firstRequest, + localFrontier, + new UnusedPersistence(), + 'events', + { patchJournal, logger, observedLatencyMs: 17 }, + ); + + expect(first.patches.map((entry) => entry.sha)).toEqual([SHA_1, SHA_2]); + expect(first.page).toEqual({ + maxPatches: 2, + cursor: '2', + hasMore: true, + returnedPatches: 2, + }); + expect(first.metrics).toMatchObject({ + patchCount: 2, + skippedWriterCount: 0, + latencyMs: 17, + }); + expect(first.metrics?.estimatedPayloadBytes).toBeGreaterThan(0); + + const secondRequest = createSyncRequest(new Map(), { + page: { maxPatches: 2, cursor: first.page?.cursor ?? null }, + }); + const second = await processSyncRequest( + secondRequest, + localFrontier, + new UnusedPersistence(), + 'events', + { patchJournal, logger }, + ); + + expect(second.patches.map((entry) => entry.sha)).toEqual([SHA_3]); + expect(second.page).toEqual({ + maxPatches: 2, + cursor: null, + hasMore: false, + returnedPatches: 1, + }); + expect(second.metrics?.latencyMs).toBeNull(); + + expect(logger.infoCalls).toHaveLength(2); + expect(logger.infoCalls[0]).toEqual({ + message: 'Sync response metrics', + context: { + code: 'SYNC_RESPONSE_METRICS', + graphName: 'events', + patchCount: 2, + skippedWriterCount: 0, + estimatedPayloadBytes: first.metrics?.estimatedPayloadBytes, + latencyMs: 17, + syncResponseCursor: '2', + syncResponseHasMore: true, + syncResponseMaxPatches: 2, + }, + }); + }); + + it('validates paged sync request and response payloads at the boundary', () => { + expect(validateSyncRequest({ + type: 'sync-request', + frontier: {}, + page: { maxPatches: 2, cursor: '2' }, + }).ok).toBe(true); + + expect(validateSyncRequest({ + type: 'sync-request', + frontier: {}, + page: { maxPatches: 0 }, + }).ok).toBe(false); + + expect(validateSyncRequest({ + type: 'sync-request', + frontier: {}, + page: { maxPatches: 3 }, + }, STRICT_SYNC_LIMITS).ok).toBe(false); + + expect(validateSyncResponse({ + type: 'sync-response', + frontier: {}, + patches: [], + page: { + maxPatches: 2, + cursor: null, + hasMore: false, + returnedPatches: 0, + }, + metrics: { + patchCount: 0, + skippedWriterCount: 0, + estimatedPayloadBytes: 42, + latencyMs: null, + }, + }).ok).toBe(true); + }); +}); + +type LogCall = { + readonly message: string; + readonly context: LogFields | undefined; +}; + +class RecordingLogger extends LoggerPort { + readonly infoCalls: LogCall[] = []; + + debug(_message: string, _context?: LogFields): void {} + + info(message: string, context?: LogFields): void { + this.infoCalls.push({ message, context }); + } + + warn(_message: string, _context?: LogFields): void {} + + error(_message: string, _context?: LogFields): void {} + + child(_context: LogFields): LoggerPort { + return this; + } +} + +class StreamingPatchJournal extends PatchJournalPort { + private readonly _entries: readonly PatchEntry[]; + + constructor(entries: readonly PatchEntry[]) { + super(); + this._entries = Object.freeze([...entries]); + } + + async writePatch(_patch: Patch): Promise { + throw unusedMethod('writePatch'); + } + + async readPatch(_patchOid: string, _options?: ReadPatchOptions): Promise { + throw unusedMethod('readPatch'); + } + + scanPatchRange(writerId: string, _fromSha: string | null, _toSha: string): WarpStream { + return WarpStream.from(this._entries.filter((entry) => entry.patch.writer === writerId)); + } +} + +class UnusedPersistence extends CommitPort implements BlobPort { + async commitNode(_options: CommitNodeOptions): Promise { + throw unusedMethod('commitNode'); + } + + async showNode(_sha: string): Promise { + throw unusedMethod('showNode'); + } + + async getNodeInfo(_sha: string): Promise { + throw unusedMethod('getNodeInfo'); + } + + async logNodes(_options: LogNodesOptions): Promise { + throw unusedMethod('logNodes'); + } + + async logNodesStream(_options: LogNodesOptions): Promise> { + throw unusedMethod('logNodesStream'); + } + + async countNodes(_ref: string): Promise { + throw unusedMethod('countNodes'); + } + + async commitNodeWithTree(_options: CommitNodeWithTreeOptions): Promise { + throw unusedMethod('commitNodeWithTree'); + } + + async nodeExists(_sha: string): Promise { + throw unusedMethod('nodeExists'); + } + + async getCommitTree(_sha: string): Promise { + throw unusedMethod('getCommitTree'); + } + + async ping(): Promise { + throw unusedMethod('ping'); + } + + async writeBlob(_content: Uint8Array | string): Promise { + throw unusedMethod('writeBlob'); + } + + async readBlob(_oid: string): Promise { + throw unusedMethod('readBlob'); + } +} + +function patchEntry(writer: string, sha: string, lamport: number): PatchEntry { + return new PatchEntry({ + sha, + patch: new Patch({ + writer, + lamport, + context: VersionVector.empty(), + ops: [], + }), + }); +} + +function unusedMethod(methodName: string): Error { + return new Error(`Unexpected ${methodName} call`); +} diff --git a/test/unit/domain/specCompliance.test.ts b/test/unit/domain/specCompliance.test.ts index bab85b00d..aa465b62a 100644 --- a/test/unit/domain/specCompliance.test.ts +++ b/test/unit/domain/specCompliance.test.ts @@ -44,9 +44,9 @@ describe('CRDT spec compliance (Phase 5 / Invariant 7 / Test 24)', () => { }); // --------------------------------------------------------------------------- - // 2. orsetJoin is commutative + // 2. ORSet.join is commutative // --------------------------------------------------------------------------- - describe('orsetJoin is commutative', () => { + describe('ORSet.join is commutative', () => { it('a.join(b) equals b.join(a) for entries and tombstones', () => { const a = ORSet.empty(); a.add('node:1', Dot.create('alice', 1)); @@ -73,9 +73,9 @@ describe('CRDT spec compliance (Phase 5 / Invariant 7 / Test 24)', () => { }); // --------------------------------------------------------------------------- - // 3. vvMerge is commutative + // 3. VersionVector.merge is commutative // --------------------------------------------------------------------------- - describe('vvMerge is commutative', () => { + describe('VersionVector.merge is commutative', () => { it('a.merge(b) equals b.merge(a)', () => { const a = VersionVector.empty(); a.increment('alice'); // alice:1 @@ -98,9 +98,9 @@ describe('CRDT spec compliance (Phase 5 / Invariant 7 / Test 24)', () => { }); // --------------------------------------------------------------------------- - // 4. vvMerge is idempotent + // 4. VersionVector.merge is idempotent // --------------------------------------------------------------------------- - describe('vvMerge is idempotent', () => { + describe('VersionVector.merge is idempotent', () => { it('a.merge(a) equals a', () => { const a = VersionVector.empty(); a.increment('alice'); // alice:1 diff --git a/test/unit/domain/trust/schemas.test.ts b/test/unit/domain/trust/schemas.test.ts index 5882f755d..8b00e36c1 100644 --- a/test/unit/domain/trust/schemas.test.ts +++ b/test/unit/domain/trust/schemas.test.ts @@ -85,6 +85,7 @@ describe('TrustRecordSchema — subject transforms propagate', () => { if (result.success) { expect(result.data.subject['writerId']).toBe('alice'); } + expect(record.subject.writerId).toBe(' alice '); }); it('trims writerId in WRITER_BIND_REVOKE subject through TrustRecordSchema', () => { @@ -107,5 +108,6 @@ describe('TrustRecordSchema — subject transforms propagate', () => { if (result.success) { expect(result.data.subject['writerId']).toBe('bob'); } + expect(record.subject.writerId).toBe(' bob '); }); }); diff --git a/test/unit/domain/utils/canonicalStringify.test.ts b/test/unit/domain/utils/canonicalStringify.test.ts index 91dc580a0..879cc9a12 100644 --- a/test/unit/domain/utils/canonicalStringify.test.ts +++ b/test/unit/domain/utils/canonicalStringify.test.ts @@ -1,8 +1,22 @@ import { describe, it, expect } from 'vitest'; import WarpError from '../../../../src/domain/errors/WarpError.ts'; -import { canonicalStringify } from '../../../../src/domain/utils/canonicalStringify.ts'; +import { canonicalStringify, sortedReplacer } from '../../../../src/domain/utils/canonicalStringify.ts'; describe('canonicalStringify', () => { + describe('sortedReplacer', () => { + it('sorts object keys for JSON.stringify consumers', () => { + const json = JSON.stringify({ z: 1, a: 2, m: 3 }, sortedReplacer); + + expect(json).toBe('{"a":2,"m":3,"z":1}'); + }); + + it('leaves array order intact while sorting nested object keys', () => { + const json = JSON.stringify([{ b: 1, a: 2 }], sortedReplacer); + + expect(json).toBe('[{"a":2,"b":1}]'); + }); + }); + describe('primitives', () => { it('returns "null" for undefined', () => { expect(canonicalStringify(undefined)).toBe('null'); diff --git a/test/unit/domain/utils/scalarValidation.test.ts b/test/unit/domain/utils/scalarValidation.test.ts new file mode 100644 index 000000000..daf1beb8e --- /dev/null +++ b/test/unit/domain/utils/scalarValidation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import WarpError from '../../../../src/domain/errors/WarpError.ts'; +import { + requireNonEmptyString, + validateTimestamp, +} from '../../../../src/domain/utils/scalarValidation.ts'; + +describe('scalarValidation', () => { + describe('requireNonEmptyString', () => { + it('accepts non-empty strings', () => { + expect(() => requireNonEmptyString('value', 'field')).not.toThrow(); + }); + + it('rejects empty strings', () => { + expect(() => requireNonEmptyString('', 'field')).toThrow(WarpError); + expect(() => requireNonEmptyString('', 'field')).toThrow('field must be a non-empty string'); + }); + + it('rejects non-string runtime values', () => { + expect(() => Reflect.apply(requireNonEmptyString, undefined, [123, 'field'])).toThrow(WarpError); + expect(() => Reflect.apply(requireNonEmptyString, undefined, [123, 'field'])).toThrow( + 'field must be a non-empty string', + ); + }); + }); + + describe('validateTimestamp', () => { + it('accepts non-negative finite numbers', () => { + expect(() => validateTimestamp(0)).not.toThrow(); + expect(() => validateTimestamp(1.5)).not.toThrow(); + }); + + it('rejects invalid runtime timestamp values', () => { + expect(() => Reflect.apply(validateTimestamp, undefined, ['now'])).toThrow(WarpError); + expect(() => validateTimestamp(-1)).toThrow('timestamp must be a non-negative finite number'); + expect(() => validateTimestamp(Infinity)).toThrow('timestamp must be a non-negative finite number'); + }); + }); +}); diff --git a/test/unit/domain/warp/PatchSession.test.ts b/test/unit/domain/warp/PatchSession.test.ts index 83a6cafe1..084bf1595 100644 --- a/test/unit/domain/warp/PatchSession.test.ts +++ b/test/unit/domain/warp/PatchSession.test.ts @@ -106,4 +106,15 @@ describe('PatchSession', () => { cause, }); }); + + it('does not classify raw concurrent-looking messages as CAS conflicts', async () => { + const { builder, session } = createSession(); + const cause = new Error('Concurrent commit detected while updating writer ref'); + builder.commit.mockRejectedValue(cause); + + await expect(session.commit()).rejects.toMatchObject({ + code: 'PERSIST_WRITE_FAILED', + cause, + }); + }); }); diff --git a/test/unit/domain/warp/RuntimeStateStore.test.ts b/test/unit/domain/warp/RuntimeStateStore.test.ts new file mode 100644 index 000000000..b051477c0 --- /dev/null +++ b/test/unit/domain/warp/RuntimeStateStore.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import AdjacencyMap from '../../../../src/domain/capabilities/AdjacencyMap.ts'; +import { createEmptyState, type WarpState } from '../../../../src/domain/services/JoinReducer.ts'; +import RuntimeStateStore from '../../../../src/domain/warp/RuntimeStateStore.ts'; +import type { NeighborEdge } from '../../../../src/ports/NeighborProviderPort.ts'; + +type RuntimeMaterializedGraphFixture = { + state: WarpState; + stateHash: string; + adjacency: { + outgoing: Map; + incoming: Map; + }; +}; + +type RuntimeStateStoreHostFixture = { + _cachedState: WarpState | null; + _stateDirty: boolean; + _materializedGraph: RuntimeMaterializedGraphFixture | null; +}; + +function edge(neighborId: string, label: string): NeighborEdge { + return { neighborId, label }; +} + +function emptyAdjacency(): AdjacencyMap { + return new AdjacencyMap({ + outgoing: new Map(), + incoming: new Map(), + }); +} + +function createHost(): RuntimeStateStoreHostFixture { + return { + _cachedState: null, + _stateDirty: true, + _materializedGraph: null, + }; +} + +describe('RuntimeStateStore', () => { + it('returns null until cached state and graph snapshot are both present', () => { + const host = createHost(); + const store = new RuntimeStateStore(host); + const state = createEmptyState(); + + expect(store.get()).toBeNull(); + + host._cachedState = state; + expect(store.get()).toBeNull(); + + host._cachedState = null; + host._materializedGraph = { + state, + stateHash: 'state-a', + adjacency: { + outgoing: new Map(), + incoming: new Map(), + }, + }; + + expect(store.get()).toBeNull(); + }); + + it('stores snapshots and isolates adjacency arrays from caller mutation', () => { + const host = createHost(); + const store = new RuntimeStateStore(host); + const state = createEmptyState(); + const sourceOutgoing = [edge('beta', 'child')]; + const sourceIncoming = [edge('alpha', 'parent')]; + const adjacency = new AdjacencyMap({ + outgoing: new Map([['alpha', sourceOutgoing]]), + incoming: new Map([['beta', sourceIncoming]]), + }); + + store.set(state, 'state-a', adjacency); + sourceOutgoing.push(edge('gamma', 'late')); + sourceIncoming.push(edge('delta', 'late')); + + expect(host._cachedState).toBe(state); + expect(host._stateDirty).toBe(false); + expect(host._materializedGraph?.state).toBe(state); + expect(host._materializedGraph?.stateHash).toBe('state-a'); + expect(host._materializedGraph?.adjacency.outgoing.get('alpha')).toEqual([ + edge('beta', 'child'), + ]); + expect(host._materializedGraph?.adjacency.incoming.get('beta')).toEqual([ + edge('alpha', 'parent'), + ]); + }); + + it('returns copied snapshots and clears runtime cache state', () => { + const host = createHost(); + const store = new RuntimeStateStore(host); + const state = createEmptyState(); + + store.set(state, null, emptyAdjacency()); + + expect(host._materializedGraph?.stateHash).toBe(''); + expect(store.get()?.stateHash).toBe(''); + + host._materializedGraph?.adjacency.outgoing.set('alpha', [edge('beta', 'child')]); + const snapshot = store.get(); + expect(snapshot).not.toBeNull(); + if (snapshot === null) { + return; + } + + host._materializedGraph?.adjacency.outgoing.get('alpha')?.push(edge('gamma', 'late')); + + expect(snapshot.state).toBe(state); + expect(snapshot.adjacency.outgoing.get('alpha')).toEqual([ + edge('beta', 'child'), + ]); + + store.clear(); + + expect(host._cachedState).toBeNull(); + expect(host._stateDirty).toBe(true); + expect(host._materializedGraph).toBeNull(); + expect(store.get()).toBeNull(); + }); +}); diff --git a/test/unit/domain/warp/WarpOpenOptions.test.ts b/test/unit/domain/warp/WarpOpenOptions.test.ts new file mode 100644 index 000000000..da5730889 --- /dev/null +++ b/test/unit/domain/warp/WarpOpenOptions.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveRuntimeHostConstructionOptions, + WarpOpenOptions, +} from '../../../../src/domain/warp/RuntimeHostBoot.ts'; +import { openRuntimeHostProduct } from '../../../../src/domain/warp/RuntimeHostProduct.ts'; +import defaultCodec from '../../../../src/domain/utils/defaultCodec.ts'; +import defaultCrypto from '../../../../src/domain/utils/defaultCrypto.ts'; +import { createMockPersistence } from '../../../helpers/warpGraphTestUtils.ts'; + +describe('WarpOpenOptions', () => { + it('freezes required runtime open options and defaults codec, crypto, and gc policy', () => { + const persistence = createMockPersistence(); + const options = new WarpOpenOptions({ + persistence, + graphName: 'parsed-options', + writerId: 'writer-1', + }); + + expect(Object.isFrozen(options)).toBe(true); + expect(options.persistence).toBe(persistence); + expect(options.graphName).toBe('parsed-options'); + expect(options.writerId).toBe('writer-1'); + expect(options.gcPolicy).toEqual({}); + expect(options.codec).toBe(defaultCodec); + expect(options.crypto).toBe(defaultCrypto); + expect(options.checkpointPolicy).toBeUndefined(); + }); + + it('normalizes checkpointPolicy into a frozen value object', () => { + const checkpointPolicy = { every: 5 }; + const options = new WarpOpenOptions({ + persistence: createMockPersistence(), + graphName: 'checkpoint-options', + writerId: 'writer-1', + checkpointPolicy, + }); + + expect(options.checkpointPolicy).toEqual({ every: 5 }); + expect(options.checkpointPolicy).not.toBe(checkpointPolicy); + expect(Object.isFrozen(options.checkpointPolicy)).toBe(true); + }); + + it('snapshots gcPolicy config objects before freezing open options', () => { + const gcPolicy = { enabled: true }; + const options = new WarpOpenOptions({ + persistence: createMockPersistence(), + graphName: 'gc-options', + writerId: 'writer-1', + gcPolicy, + }); + + gcPolicy.enabled = false; + + expect(options.gcPolicy).toEqual({ enabled: true }); + expect(options.gcPolicy).not.toBe(gcPolicy); + expect(Object.isFrozen(options.gcPolicy)).toBe(true); + }); + + it('rejects invalid checkpointPolicy values before async boot resolution', () => { + expect(() => new WarpOpenOptions({ + persistence: createMockPersistence(), + graphName: 'bad-checkpoint-options', + writerId: 'writer-1', + checkpointPolicy: { every: 0 }, + })).toThrow('checkpointPolicy.every must be a positive integer'); + }); + + it('builds a minimal frozen open-options object for tests', () => { + const persistence = createMockPersistence(); + const options = WarpOpenOptions.minimal({ persistence }); + + expect(Object.isFrozen(options)).toBe(true); + expect(options.persistence).toBe(persistence); + expect(options.graphName).toBe('default'); + expect(options.writerId).toBe('local'); + }); + + it('keeps raw object compatibility at the construction resolver boundary', async () => { + const { options } = await resolveRuntimeHostConstructionOptions({ + persistence: createMockPersistence(), + graphName: 'raw-options', + writerId: 'writer-1', + }); + + expect(options.graphName).toBe('raw-options'); + expect(options.writerId).toBe('writer-1'); + expect(options.codec).toBe(defaultCodec); + expect(options.crypto).toBe(defaultCrypto); + }); + + it('opens runtime products from a parsed options instance', async () => { + const runtime = await openRuntimeHostProduct(new WarpOpenOptions({ + persistence: createMockPersistence(), + graphName: 'parsed-runtime', + writerId: 'writer-1', + onDeleteWithData: 'cascade', + })); + + expect(runtime.graphName).toBe('parsed-runtime'); + expect(runtime.writerId).toBe('writer-1'); + expect(runtime.onDeleteWithData).toBe('cascade'); + }); +}); diff --git a/test/unit/domain/warp/Writer.sameWriterRace.test.ts b/test/unit/domain/warp/Writer.sameWriterRace.test.ts index fb3f3193f..ff90fb1f3 100644 --- a/test/unit/domain/warp/Writer.sameWriterRace.test.ts +++ b/test/unit/domain/warp/Writer.sameWriterRace.test.ts @@ -112,4 +112,55 @@ describe('same-writer concurrent patch race witness', () => { expect(await materialized.hasNode(FIRST_NODE)).toBe(firstWon); expect(await materialized.hasNode(SECOND_NODE)).toBe(!firstWon); }); + + it('preserves visible truth when isolated handles race the same writer ref', async () => { + const persistence = new SameWriterRaceAdapter(); + const firstHandle = await openRuntimeHostProduct({ + persistence, + graphName: GRAPH_NAME, + writerId: WRITER_ID, + }); + const secondHandle = await openRuntimeHostProduct({ + persistence, + graphName: GRAPH_NAME, + writerId: WRITER_ID, + }); + const firstWriter = await firstHandle.writer(WRITER_ID); + const secondWriter = await secondHandle.writer(WRITER_ID); + const firstPatch = await firstWriter.beginPatch(); + const secondPatch = await secondWriter.beginPatch(); + firstPatch.addNode(FIRST_NODE); + secondPatch.addNode(SECOND_NODE); + + persistence.armCommitPrecheckRace(2); + const results = await Promise.allSettled([ + firstPatch.commit(), + secondPatch.commit(), + ]); + + const winners = fulfilled(results); + const losers = rejected(results); + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(1); + const loser = losers[0]; + const winner = winners[0]; + if (loser === undefined || winner === undefined) { + expect.fail('isolated-handle race witness must produce one winner and one loser'); + } + expect(loser.reason).toMatchObject({ code: 'WRITER_REF_ADVANCED' }); + + const finalTip = await persistence.readRef(WRITER_REF); + expect(finalTip).toBe(winner.value); + + const firstWon = results[0].status === 'fulfilled'; + const materialized = await openRuntimeHostProduct({ + persistence, + graphName: GRAPH_NAME, + writerId: WRITER_ID, + }); + await materialized.materialize(); + + expect(await materialized.hasNode(FIRST_NODE)).toBe(firstWon); + expect(await materialized.hasNode(SECOND_NODE)).toBe(!firstWon); + }); }); diff --git a/test/unit/fixtures/mergeConflictCorpus.test.ts b/test/unit/fixtures/mergeConflictCorpus.test.ts new file mode 100644 index 000000000..6f4de6bba --- /dev/null +++ b/test/unit/fixtures/mergeConflictCorpus.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { + MERGE_CONFLICT_CORPUS, + summarizeMergeConflictCorpus, +} from '../../fixtures/mergeConflictCorpus.ts'; + +describe('merge conflict corpus', () => { + it('keeps a benchmark-sized normalized corpus', () => { + const summary = summarizeMergeConflictCorpus(); + + expect(summary.total).toBeGreaterThanOrEqual(50); + expect(summary.total).toBeLessThanOrEqual(100); + expect(summary).toMatchObject({ + projection: 20, + semantic: 20, + governance: 20, + liftedAway: 20, + requiresPolicy: 40, + }); + expect(summary.weightedCases).toBe(130); + }); + + it('keeps every case traceable to sources, files, and writers', () => { + const ids = new Set(); + + for (const item of MERGE_CONFLICT_CORPUS) { + expect(ids.has(item.id)).toBe(false); + ids.add(item.id); + + expect(item.sourceAnchors.length).toBeGreaterThanOrEqual(2); + expect(item.filePaths).toHaveLength(2); + expect(item.writers).toHaveLength(2); + expect(item.scenario).toContain('Corpus slice:'); + expect(item.liftingStrategy.length).toBeGreaterThan(20); + expect(item.benchmarkWeight).toBeGreaterThan(0); + } + }); + + it('separates projection conflicts from conflicts that require policy', () => { + for (const item of MERGE_CONFLICT_CORPUS) { + if (item.classification === 'projection') { + expect(item.liftingRemovesConflict).toBe(true); + } else { + expect(item.liftingRemovesConflict).toBe(false); + } + } + }); +}); diff --git a/test/unit/index.exports.test.ts b/test/unit/index.exports.test.ts index 9e9f1b284..5d39ed2f0 100644 --- a/test/unit/index.exports.test.ts +++ b/test/unit/index.exports.test.ts @@ -7,6 +7,8 @@ describe('public runtime exports', () => { expect(api.WarpWorldline).toBeDefined(); expect(api.WarpWorldlineCoordinate).toBeDefined(); expect(api.WarpWorldlineOpticBasis).toBeDefined(); + expect(api.ProjectionHandle).toBeDefined(); + expect('Worldline' in api).toBe(false); }); it('does not export the retired browser viewer service', () => { diff --git a/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts b/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts index a512f80d9..fdf4bfd38 100644 --- a/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts +++ b/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts @@ -43,6 +43,12 @@ const { default: BlobStoragePort } = await import( const { default: PersistenceError } = await import( '../../../../src/domain/errors/PersistenceError.ts' ); +const { default: CasContentEncryptionPolicy } = await import( + '../../../../src/infrastructure/adapters/CasContentEncryptionPolicy.ts' +); +const { V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY } = await import( + '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts' +); // --------------------------------------------------------------------------- // Helpers @@ -151,7 +157,44 @@ describe('CasBlobAdapter', () => { await adapter.store('secret data'); const storeCall = (mockStore.mock.calls[0] as any)[0]; - expect(storeCall.encryptionKey).toBe(encKey); + expect(storeCall.encryptionKey).toEqual(encKey); + expect(storeCall.encryptionKey).not.toBe(encKey); + }); + + it('passes vault-backed encryption policy to CAS store when configured', async () => { + mockStore.mockResolvedValue({}); + mockCreateTree.mockResolvedValue('tree-oid'); + + const encKey = new Uint8Array(32).fill(9); + const contentEncryption = CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: encKey, + scheme: 'framed', + frameBytes: 65536, + vault: { + vaultSlug: 'graphs/team/content', + keyId: 'content-key-1', + verification: 'verified', + rotationEpoch: 1, + encryptionCount: 1, + encryptionCountLimit: 100, + privacyMode: true, + }, + }); + const adapter = new CasBlobAdapter({ + plumbing: makePlumbing(), + persistence: makePersistence(), + contentEncryption, + }); + + await adapter.store('secret data'); + + expect(mockStore).toHaveBeenCalledWith( + expect.objectContaining({ + encryptionKey: encKey, + encryption: { scheme: 'framed', frameBytes: 65536 }, + }), + ); + expect(mockStore.mock.calls[0]?.[0].encryptionKey).not.toBe(encKey); }); it('does not include encryptionKey when not configured', async () => { @@ -206,7 +249,41 @@ describe('CasBlobAdapter', () => { expect(mockRestore).toHaveBeenCalledWith({ manifest, encryptionKey: encKey }); }); - it('falls back to raw Git blob when CAS readManifest throws MANIFEST_NOT_FOUND', async () => { + it('probes but rejects raw Git blob fallback by default', async () => { + const persistence = makePersistence(); + const casErr = Object.assign(new Error('No manifest entry'), { code: 'MANIFEST_NOT_FOUND' }); + mockReadManifest.mockRejectedValue(casErr); + + const adapter = new CasBlobAdapter({ + plumbing: makePlumbing(), + persistence, + }); + + await expect(adapter.retrieve('raw-blob-oid')).rejects.toMatchObject({ + code: 'E_LEGACY_SUBSTRATE_DISABLED', + }); + expect(persistence.readBlob).toHaveBeenCalledWith('raw-blob-oid'); + }); + + it('returns E_MISSING_OBJECT for missing content OIDs by default', async () => { + const persistence = makePersistence(); + persistence.readBlob.mockResolvedValue(null); + mockReadManifest.mockRejectedValue(new Error('not a tree object')); + + const adapter = new CasBlobAdapter({ + plumbing: makePlumbing(), + persistence, + }); + + await expect(adapter.retrieve('ghost-oid')) + .rejects.toMatchObject({ + code: PersistenceError.E_MISSING_OBJECT, + message: 'Missing Git object: ghost-oid', + }); + expect(persistence.readBlob).toHaveBeenCalledWith('ghost-oid'); + }); + + it('falls back to raw Git blob when CAS readManifest throws MANIFEST_NOT_FOUND under migration policy', async () => { const rawBuf = new TextEncoder().encode('legacy raw blob'); const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(rawBuf); @@ -216,6 +293,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const result = await adapter.retrieve('raw-blob-oid'); @@ -224,7 +302,7 @@ describe('CasBlobAdapter', () => { expect(persistence.readBlob).toHaveBeenCalledWith('raw-blob-oid'); }); - it('falls back to raw Git blob when CAS readManifest throws GIT_ERROR', async () => { + it('falls back to raw Git blob when CAS readManifest throws GIT_ERROR under migration policy', async () => { const rawBuf = new TextEncoder().encode('legacy raw blob'); const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(rawBuf); @@ -234,6 +312,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const result = await adapter.retrieve('raw-blob-oid'); @@ -242,7 +321,7 @@ describe('CasBlobAdapter', () => { expect(persistence.readBlob).toHaveBeenCalledWith('raw-blob-oid'); }); - it('falls back to raw Git blob on message-based legacy errors (no .code)', async () => { + it('falls back to raw Git blob on message-based legacy errors under migration policy', async () => { const rawBuf = new TextEncoder().encode('legacy raw blob'); const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(rawBuf); @@ -252,6 +331,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const result = await adapter.retrieve('bad-tree-oid'); @@ -260,7 +340,7 @@ describe('CasBlobAdapter', () => { expect(persistence.readBlob).toHaveBeenCalledWith('bad-tree-oid'); }); - it('falls back to raw Git blob on "bad object" message (no .code)', async () => { + it('falls back to raw Git blob on "bad object" message under migration policy', async () => { const rawBuf = new TextEncoder().encode('legacy raw blob'); const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(rawBuf); @@ -269,6 +349,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const result = await adapter.retrieve('bad-obj-oid'); @@ -277,7 +358,7 @@ describe('CasBlobAdapter', () => { expect(persistence.readBlob).toHaveBeenCalledWith('bad-obj-oid'); }); - it('falls back to raw Git blob on "does not exist" message (no .code)', async () => { + it('falls back to raw Git blob on "does not exist" message under migration policy', async () => { const rawBuf = new TextEncoder().encode('legacy raw blob'); const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(rawBuf); @@ -286,6 +367,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const result = await adapter.retrieve('missing-oid'); @@ -303,6 +385,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); await expect(adapter.retrieve('ghost-oid')) @@ -326,6 +409,44 @@ describe('CasBlobAdapter', () => { await expect(adapter.retrieve('enc-oid')).rejects.toThrow('decryption failed'); expect(persistence.readBlob).not.toHaveBeenCalled(); }); + + it('surfaces legacy git-cas encryption scheme errors with migration guidance', async () => { + const persistence = makePersistence(); + const casErr = Object.assign(new Error('Legacy encryption scheme "whole-v1" is no longer supported'), { + code: 'LEGACY_SCHEME', + }); + mockReadManifest.mockRejectedValue(casErr); + + const adapter = new CasBlobAdapter({ + plumbing: makePlumbing(), + persistence, + }); + + await expect(adapter.retrieve('enc-legacy-oid')).rejects.toMatchObject({ + code: 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + }); + expect(persistence.readBlob).not.toHaveBeenCalled(); + }); + + it('surfaces wrong vault passphrase errors without deleting or falling back', async () => { + const manifest = { chunks: ['chunk1'] }; + const persistence = makePersistence(); + const casErr = Object.assign(new Error('Vault passphrase verification failed'), { + code: 'INTEGRITY_ERROR', + }); + mockReadManifest.mockResolvedValue(manifest); + mockRestore.mockRejectedValue(casErr); + + const adapter = new CasBlobAdapter({ + plumbing: makePlumbing(), + persistence, + }); + + await expect(adapter.retrieve('enc-oid')).rejects.toMatchObject({ + code: 'E_CAS_VAULT_PASSPHRASE_FAILED', + }); + expect(persistence.readBlob).not.toHaveBeenCalled(); + }); }); describe('storeStream()', () => { @@ -372,7 +493,8 @@ describe('CasBlobAdapter', () => { await adapter.storeStream(source()); const storeCall = (mockStore.mock.calls[0] as any)[0]; - expect(storeCall.encryptionKey).toBe(encKey); + expect(storeCall.encryptionKey).toEqual(encKey); + expect(storeCall.encryptionKey).not.toBe(encKey); }); }); @@ -409,7 +531,7 @@ describe('CasBlobAdapter', () => { expect(new TextDecoder().decode(result)).toBe('hello world'); }); - it('falls back to single-chunk yield for legacy raw Git blobs', async () => { + it('falls back to single-chunk yield for legacy raw Git blobs under migration policy', async () => { const rawBuf = new TextEncoder().encode('legacy blob content'); const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(rawBuf); @@ -419,6 +541,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const stream = adapter.retrieveStream('raw-blob-oid'); @@ -463,6 +586,7 @@ describe('CasBlobAdapter', () => { const adapter = new CasBlobAdapter({ plumbing: makePlumbing(), persistence, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); const stream = adapter.retrieveStream('ghost-oid'); diff --git a/test/unit/infrastructure/adapters/CasContentEncryptionPolicy.test.ts b/test/unit/infrastructure/adapters/CasContentEncryptionPolicy.test.ts new file mode 100644 index 000000000..b94dc2490 --- /dev/null +++ b/test/unit/infrastructure/adapters/CasContentEncryptionPolicy.test.ts @@ -0,0 +1,147 @@ +import { assert, describe, expect, it } from 'vitest'; +import EncryptionError from '../../../../src/domain/errors/EncryptionError.ts'; +import CasContentEncryptionPolicy, { + mapCasContentEncryptionError, + type CasVaultResolutionWitness, +} from '../../../../src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; + +function verifiedVault(overrides: Partial = {}): CasVaultResolutionWitness { + return { + vaultSlug: 'graphs/team/content', + keyId: 'content-key-1', + verification: 'verified', + rotationEpoch: 1, + encryptionCount: 3, + encryptionCountLimit: 100, + privacyMode: true, + ...overrides, + }; +} + +function requireKey(key: Uint8Array | undefined): Uint8Array { + assert.isDefined(key); + return key; +} + +describe('CasContentEncryptionPolicy', () => { + it('keeps disabled content encryption empty at the git-cas boundary', () => { + const policy = CasContentEncryptionPolicy.disabled(); + + expect(policy.enabled).toBe(false); + expect(policy.scheme).toBeNull(); + expect(policy.vaultDiagnostics()).toBeNull(); + expect(policy.toStoreOptions()).toEqual({}); + expect(policy.toRestoreOptions()).toEqual({}); + }); + + it('exposes a vault-resolved framed policy without sharing the caller key object', () => { + const key = new Uint8Array(32).fill(7); + const policy = CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: key, + scheme: 'framed', + frameBytes: 65536, + vault: verifiedVault(), + }); + + expect(policy.enabled).toBe(true); + expect(policy.scheme).toBe('framed'); + expect(policy.vaultDiagnostics()).toEqual({ + vaultSlug: 'graphs/team/content', + keyId: 'content-key-1', + rotationEpoch: 1, + encryptionCount: 3, + encryptionCountLimit: 100, + privacyMode: true, + }); + expect(policy.toStoreOptions()).toEqual({ + encryptionKey: key, + encryption: { scheme: 'framed', frameBytes: 65536 }, + }); + expect(policy.toStoreOptions().encryptionKey).not.toBe(key); + expect(policy.toRestoreOptions().encryptionKey).not.toBe(key); + }); + + it('does not share internal resolved key buffers or exported option buffers', () => { + const originalKey = new Uint8Array(32).fill(5); + const expectedKey = new Uint8Array(32).fill(5); + const policy = CasContentEncryptionPolicy.fromInternalResolvedKey({ + encryptionKey: originalKey, + scheme: 'whole', + }); + + originalKey[0] = 99; + const firstStoreKey = requireKey(policy.toStoreOptions().encryptionKey); + const firstRestoreKey = requireKey(policy.toRestoreOptions().encryptionKey); + + expect(firstStoreKey).toEqual(expectedKey); + expect(firstRestoreKey).toEqual(expectedKey); + expect(firstStoreKey).not.toBe(originalKey); + expect(firstRestoreKey).not.toBe(originalKey); + expect(firstStoreKey).not.toBe(firstRestoreKey); + + firstStoreKey[1] = 88; + + expect(requireKey(policy.toStoreOptions().encryptionKey)).toEqual(expectedKey); + expect(requireKey(policy.toRestoreOptions().encryptionKey)).toEqual(expectedKey); + }); + + it('rejects failed vault passphrase verification', () => { + expect(() => CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: new Uint8Array(32), + scheme: 'whole', + vault: verifiedVault({ verification: 'failed-passphrase' }), + })).toThrowError( + expect.objectContaining({ code: 'E_CAS_VAULT_PASSPHRASE_FAILED' }), + ); + }); + + it('rejects missing vault metadata before raw key bytes reach git-cas', () => { + expect(() => CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: new Uint8Array(32), + scheme: 'whole', + vault: verifiedVault({ verification: 'missing-metadata' }), + })).toThrowError( + expect.objectContaining({ code: 'E_CAS_VAULT_METADATA_MISSING' }), + ); + }); + + it('rejects vault writes that require rotation', () => { + expect(() => CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: new Uint8Array(32), + scheme: 'convergent', + vault: verifiedVault({ encryptionCount: 100, encryptionCountLimit: 100 }), + })).toThrowError( + expect.objectContaining({ code: 'E_CAS_VAULT_ROTATION_REQUIRED' }), + ); + }); + + it('rejects legacy git-cas schemes with migration guidance', () => { + expect(() => CasContentEncryptionPolicy.fromResolvedVaultKey({ + encryptionKey: new Uint8Array(32), + scheme: 'whole-v1', + vault: verifiedVault(), + })).toThrowError( + expect.objectContaining({ + code: 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + message: expect.stringContaining('Legacy git-cas encryption scheme'), + }), + ); + }); + + it('maps upstream legacy scheme errors to the git-warp migration error', () => { + const upstream = Object.assign(new Error('Legacy encryption scheme "whole-v1" is no longer supported'), { + code: 'LEGACY_SCHEME', + }); + + const mapped = mapCasContentEncryptionError(upstream, 'content-attachment'); + + expect(mapped).toBeInstanceOf(EncryptionError); + expect(mapped).toMatchObject({ + code: 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + context: { + surface: 'content-attachment', + upstreamCode: 'LEGACY_SCHEME', + }, + }); + }); +}); diff --git a/test/unit/infrastructure/adapters/CasPayloadPointer.test.ts b/test/unit/infrastructure/adapters/CasPayloadPointer.test.ts new file mode 100644 index 000000000..40f868d3a --- /dev/null +++ b/test/unit/infrastructure/adapters/CasPayloadPointer.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, +} from '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts'; +import { readPayloadBlob } from '../../../../src/infrastructure/adapters/CasPayloadPointer.ts'; +import BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; + +class MemoryBlobStorage extends BlobStoragePort { + override store(_content: Uint8Array | string): Promise { + return Promise.resolve('storage-oid'); + } + + override retrieve = vi.fn(async (_oid: string): Promise => new Uint8Array([9])); + + override storeStream(_source: AsyncIterable): Promise { + return Promise.resolve('storage-oid'); + } + + override async *retrieveStream(_oid: string): AsyncIterable { + yield new Uint8Array([9]); + } +} + +describe('CasPayloadPointer compatibility boundary', () => { + it('rejects inline payload bytes when CAS blob storage is configured', async () => { + const bytes = new Uint8Array([1, 2, 3]); + const blobPort = { readBlob: vi.fn(async () => bytes) }; + const blobStorage = new MemoryBlobStorage(); + + await expect(readPayloadBlob({ blobPort, blobStorage, oid: 'inline-oid' })) + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + expect(blobStorage.retrieve).not.toHaveBeenCalled(); + }); + + it('allows inline payload bytes only under migration compatibility policy', async () => { + const bytes = new Uint8Array([1, 2, 3]); + const blobPort = { readBlob: vi.fn(async () => bytes) }; + + await expect(readPayloadBlob({ + blobPort, + blobStorage: new MemoryBlobStorage(), + oid: 'inline-oid', + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + })).resolves.toEqual(bytes); + }); +}); diff --git a/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts b/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts index 7a9d4b6c1..b3ea08247 100644 --- a/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts +++ b/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts @@ -2,15 +2,26 @@ import { describe, it, expect, vi } from 'vitest'; import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import EncryptionError from '../../../../src/domain/errors/EncryptionError.ts'; import SyncError from '../../../../src/domain/errors/SyncError.ts'; +import { reducePatches } from '../../../../src/domain/services/JoinReducer.ts'; +import { hydrateDecodedPatch } from '../../../../src/domain/services/PatchHydrator.ts'; import Patch from '../../../../src/domain/types/Patch.ts'; +import EdgeAdd from '../../../../src/domain/types/ops/EdgeAdd.ts'; import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; +import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; import { encodePatchMessage } from '../../../../src/domain/services/codec/PatchMessageCodec.ts'; - -/** @param {Record} opts */ -function createPatch(opts) { return new Patch((opts)); } +import { + LEGACY_EXTERNAL_PATCH_STORAGE, + LEGACY_GIT_BLOB_PATCH_STORAGE, + createGitCasPatchStorage, +} from '../../../../src/ports/CommitMessageCodecPort.ts'; +import { + V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, +} from '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts'; import PatchJournalPort from '../../../../src/ports/PatchJournalPort.ts'; +import BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; +import type { BlobStorageOptions } from '../../../../src/ports/BlobStoragePort.ts'; +import MockBlobPort from '../../../helpers/MockBlobPort.ts'; /** * Golden fixture: a known Patch encoded with the canonical CBOR codec. @@ -20,24 +31,21 @@ import PatchJournalPort from '../../../../src/ports/PatchJournalPort.ts'; * that CBOR (de)serializes. The domain typedef uses Dot class, but the codec * boundary handles the tuple ↔ Dot mapping. */ -const GOLDEN_PATCH = createPatch({ +const GOLDEN_PATCH = hydrateDecodedPatch({ schema: 2, writer: 'alice', lamport: 1, context: { alice: 0 }, - ops: (([ + ops: [ { type: 'NodeAdd', id: 'user:alice', dot: ['alice', 1] }, { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, - ]) as any), + ], reads: [], writes: ['user:alice'], }); const GOLDEN_HEX = - 'b9000767636f6e74657874b9000165616c69636500676c616d706f727401636f707382b9000363646f748265616c696365016269646a757365723a616c6963656474797065674e6f6465416464b90004636b6579646e616d65646e6f64656a757365723a616c69636564747970656750726f705365746576616c756565416c696365657265616473f766736368656d61026677726974657265616c69636566777269746573816a757365723a616c696365'; - -import MockBlobPort from '../../../helpers/MockBlobPort.ts'; -import BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; + 'b9000767636f6e74657874b9000165616c69636500676c616d706f727401636f707382b9000563646f74b9000267636f756e7465720168777269746572496465616c696365646e6f64656a757365723a616c6963656b726563656970744e616d65674e6f64654164646573636f7065036474797065674e6f6465416464b90006636b6579646e616d65646e6f64656a757365723a616c6963656b726563656970744e616d656750726f705365746573636f70650264747970656750726f705365746576616c756565416c696365657265616473f766736368656d61026677726974657265616c69636566777269746573816a757365723a616c696365'; /** * Creates an in-memory BlobPort backed by MockBlobPort. @@ -47,6 +55,84 @@ function createMemoryBlobPort() { return new MockBlobPort(); } +function createPatch(opts: ConstructorParameters[0]): Patch { + return new Patch(opts); +} + +function hexToBytes(hex: string): Uint8Array { + const hexPairs = hex.match(/.{2}/g); + if (hexPairs === null) { + throw new Error('golden hex must contain bytes'); + } + return new Uint8Array(hexPairs.map((h) => parseInt(h, 16))); +} + +function storedBlobBytes(blobPort: MockBlobPort): Uint8Array { + const stored = blobPort.store.values().next(); + if (stored.done === true || !(stored.value instanceof Uint8Array)) { + throw new Error('expected one stored Uint8Array blob'); + } + return stored.value; +} + +function createRuntimeClassPatch(): Patch { + return new Patch({ + schema: 3, + writer: 'alice', + lamport: 2, + context: { alice: 1 }, + ops: [ + new NodeAdd('user:alice', new Dot('alice', 1)), + new EdgeAdd({ + from: 'user:alice', + to: 'user:bob', + label: 'knows', + dot: new Dot('alice', 2), + }), + new PropSet('user:alice', 'name', 'Alice'), + ], + writes: ['user:alice'], + }); +} + +function reducePatch(patch: Patch) { + return reducePatches([{ patch, sha: 'a'.repeat(40) }]); +} + +type MockBlobStorageOptions = { + readonly storeResult?: string; + readonly retrieveResult?: Uint8Array; +}; + +class MockPatchBlobStorage extends BlobStoragePort { + private readonly _storeResult: string; + private readonly _retrieveResult: Uint8Array; + + constructor(opts: MockBlobStorageOptions = {}) { + super(); + this._storeResult = opts.storeResult ?? 'encrypted_oid'; + this._retrieveResult = opts.retrieveResult ?? new Uint8Array(0); + } + + override store = vi.fn(async ( + _content: Uint8Array | string, + _options?: BlobStorageOptions, + ): Promise => this._storeResult); + + override retrieve = vi.fn(async (_oid: string): Promise => this._retrieveResult); + + override async storeStream( + _source: AsyncIterable, + _options?: BlobStorageOptions, + ): Promise { + return this._storeResult; + } + + override async *retrieveStream(_oid: string): AsyncIterable { + yield this._retrieveResult; + } +} + describe('CborPatchJournalAdapter', () => { it('extends PatchJournalPort', () => { const codec = new CborCodec(); @@ -59,8 +145,10 @@ describe('CborPatchJournalAdapter', () => { const codec = new CborCodec(); const blobPort = createMemoryBlobPort(); - expect(() => new CborPatchJournalAdapter(({ blobPort } as any))).toThrow('CborPatchJournalAdapter requires a codec'); - expect(() => new CborPatchJournalAdapter(({ codec } as any))).toThrow('CborPatchJournalAdapter requires a blobPort'); + // @ts-expect-error Exercising the runtime guard for untyped JavaScript callers. + expect(() => new CborPatchJournalAdapter({ blobPort })).toThrow('CborPatchJournalAdapter requires a codec'); + // @ts-expect-error Exercising the runtime guard for untyped JavaScript callers. + expect(() => new CborPatchJournalAdapter({ codec })).toThrow('CborPatchJournalAdapter requires a blobPort'); }); it('writePatch returns a string OID', async () => { @@ -104,7 +192,7 @@ describe('CborPatchJournalAdapter', () => { const adapter = new CborPatchJournalAdapter({ codec, blobPort }); await adapter.writePatch(GOLDEN_PATCH); - const storedBytes = (blobPort.store.values().next().value as Uint8Array); + const storedBytes = storedBlobBytes(blobPort); const storedHex = Array.from(storedBytes).map( (/** @type {number} */ b) => b.toString(16).padStart(2, '0'), ).join(''); @@ -114,10 +202,7 @@ describe('CborPatchJournalAdapter', () => { it('round-trips the golden bytes back to the same domain object', async () => { const codec = new CborCodec(); - const hexPairs = (GOLDEN_HEX.match(/.{2}/g) as string[]); - const goldenBytes = new Uint8Array( - hexPairs.map((h) => parseInt(h, 16)), - ); + const goldenBytes = hexToBytes(GOLDEN_HEX); const blobPort = createMemoryBlobPort(); blobPort.store.set('golden', goldenBytes); const adapter = new CborPatchJournalAdapter({ codec, blobPort }); @@ -133,22 +218,27 @@ describe('CborPatchJournalAdapter', () => { } expect(firstOp.dot).toBeInstanceOf(Dot); }); + + it('round-trips runtime op classes through CBOR and preserves reducer state', async () => { + const codec = new CborCodec(); + const blobPort = createMemoryBlobPort(); + const adapter = new CborPatchJournalAdapter({ codec, blobPort }); + const originalPatch = createRuntimeClassPatch(); + + const oid = await adapter.writePatch(originalPatch); + const hydratedPatch = await adapter.readPatch(oid); + const [nodeAdd, edgeAdd, propSet] = hydratedPatch.ops; + + expect(nodeAdd).toBeInstanceOf(NodeAdd); + expect(edgeAdd).toBeInstanceOf(EdgeAdd); + expect(propSet).toBeInstanceOf(PropSet); + expect(reducePatch(hydratedPatch)).toEqual(reducePatch(originalPatch)); + }); }); describe('encrypted patch support', () => { - /** - * Creates a mock BlobStoragePort with vitest spies. - * @param {{ storeResult?: string, retrieveResult?: Uint8Array }} [opts] - * @returns {BlobStoragePort} - */ - function createMockBlobStorage(opts = {}) { - const mock = ((({ - store: vi.fn().mockResolvedValue((opts as any).storeResult ?? 'encrypted_oid'), - retrieve: vi.fn().mockResolvedValue((opts as any).retrieveResult ?? new Uint8Array(0)), - storeStream: vi.fn(), - retrieveStream: vi.fn(), - })) as BlobStoragePort); - return mock; + function createMockBlobStorage(opts: MockBlobStorageOptions = {}): MockPatchBlobStorage { + return new MockPatchBlobStorage(opts); } it('uses patchBlobStorage when provided for writePatch', async () => { @@ -190,12 +280,46 @@ describe('CborPatchJournalAdapter', () => { expect(encryptedAdapter.usesExternalStorage).toBe(true); }); - it('rejects encrypted reads when no patchBlobStorage is configured', async () => { + it('rejects encrypted legacy reads without migration compatibility policy', async () => { const codec = new CborCodec(); const blobPort = createMemoryBlobPort(); const adapter = new CborPatchJournalAdapter({ codec, blobPort }); - await expect(adapter.readPatch('encrypted_oid', { encrypted: true })).rejects.toBeInstanceOf(EncryptionError); + await expect(adapter.readPatch('encrypted_oid', { encrypted: true })) + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + }); + + it('rejects legacy patch storage reads when current git-cas storage is configured', async () => { + const codec = new CborCodec(); + const blobPort = createMemoryBlobPort(); + const adapter = new CborPatchJournalAdapter({ + codec, + blobPort, + blobStorage: createMockBlobStorage(), + writeStorage: createGitCasPatchStorage(false), + }); + + await expect(adapter.readPatch('legacy_oid', { storage: LEGACY_GIT_BLOB_PATCH_STORAGE })) + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + await expect(adapter.readPatch('legacy_oid', { storage: LEGACY_EXTERNAL_PATCH_STORAGE })) + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + }); + + it('allows legacy patch storage reads only under migration compatibility policy', async () => { + const codec = new CborCodec(); + const blobPort = createMemoryBlobPort(); + const patchOid = 'legacy_oid'; + blobPort.store.set(patchOid, codec.encode(GOLDEN_PATCH)); + const adapter = new CborPatchJournalAdapter({ + codec, + blobPort, + blobStorage: createMockBlobStorage(), + writeStorage: createGitCasPatchStorage(false), + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + + await expect(adapter.readPatch(patchOid, { storage: LEGACY_GIT_BLOB_PATCH_STORAGE })) + .resolves.toMatchObject({ writer: 'alice' }); }); }); @@ -216,14 +340,14 @@ describe('CborPatchJournalAdapter', () => { writer: 'alice', lamport: 1, context: { alice: 0 }, - ops: [{ type: 'NodeAdd', id: 'n1', dot: ['alice', 1] }], + ops: [new NodeAdd('n1', new Dot('alice', 1))], }); const patch2 = createPatch({ schema: 2, writer: 'alice', lamport: 2, context: { alice: 1 }, - ops: [{ type: 'NodeAdd', id: 'n2', dot: ['alice', 2] }], + ops: [new NodeAdd('n2', new Dot('alice', 2))], }); const patchOid1 = 'a'.repeat(40); const patchOid2 = 'b'.repeat(40); diff --git a/test/unit/infrastructure/adapters/GitGraphAdapter.gitCasPersistence.test.ts b/test/unit/infrastructure/adapters/GitGraphAdapter.gitCasPersistence.test.ts index d89079cb8..3ffa4eef1 100644 --- a/test/unit/infrastructure/adapters/GitGraphAdapter.gitCasPersistence.test.ts +++ b/test/unit/infrastructure/adapters/GitGraphAdapter.gitCasPersistence.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest'; import GitGraphAdapter, { type CollectableStream, type GitPlumbing } from '../../../../src/infrastructure/adapters/GitGraphAdapter.ts'; +import OperationPolicyPort, { + type OperationPolicyExecuteOptions, +} from '../../../../src/ports/OperationPolicyPort.ts'; interface GitExecuteOptions { readonly args: string[]; @@ -104,6 +107,27 @@ class BlobStreamPlumbing extends RecordingPlumbing { } } +class RecordingOperationPolicy extends OperationPolicyPort { + executeCalls = 0; + streamCalls = 0; + + override async execute( + operation: (signal?: AbortSignal) => Promise, + options: OperationPolicyExecuteOptions = {}, + ): Promise { + this.executeCalls += 1; + return await operation(options.signal); + } + + override async stream( + operation: (signal?: AbortSignal) => Promise>, + options: OperationPolicyExecuteOptions = {}, + ): Promise> { + this.streamCalls += 1; + return await operation(options.signal); + } +} + describe('GitGraphAdapter git-cas persistence bridge', () => { it('delegates blob writes through the git-cas persistence adapter', async () => { const oid = 'a'.repeat(40); @@ -166,6 +190,33 @@ describe('GitGraphAdapter git-cas persistence bridge', () => { expect(plumbing.calls).toHaveLength(2); }); + it('routes delegated git-cas writes through the injected operation policy', async () => { + const oid = 'e'.repeat(40); + const plumbing = new RecordingPlumbing(oid); + const policy = new RecordingOperationPolicy(); + const adapter = new GitGraphAdapter({ plumbing, policy }); + + await expect(adapter.writeBlob('payload')).resolves.toBe(oid); + + expect(policy.executeCalls).toBeGreaterThan(0); + expect(policy.streamCalls).toBe(0); + }); + + it('routes log stream setup through the injected operation policy', async () => { + const oid = 'f'.repeat(40); + const plumbing = new BlobStreamPlumbing(oid, [new TextEncoder().encode('commit\0')]); + const policy = new RecordingOperationPolicy(); + const adapter = new GitGraphAdapter({ plumbing, policy }); + + const stream = await adapter.logNodesStream({ ref: 'refs/heads/main', limit: 1 }); + + expect(policy.streamCalls).toBe(1); + expect(plumbing.streamCalls).toEqual([{ + args: ['log', '-z', '-1', 'refs/heads/main'], + }]); + await expect(stream.collect()).resolves.toEqual([new TextEncoder().encode('commit\0')]); + }); + it('reads recursive tree OIDs through the injected Git plumbing boundary', async () => { const treeOid = '1'.repeat(40); const blobOid = '2'.repeat(40); diff --git a/test/unit/infrastructure/adapters/OperationPolicyAdapter.test.ts b/test/unit/infrastructure/adapters/OperationPolicyAdapter.test.ts new file mode 100644 index 000000000..953a3f3d8 --- /dev/null +++ b/test/unit/infrastructure/adapters/OperationPolicyAdapter.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import AlfredOperationPolicyAdapter from '../../../../src/infrastructure/adapters/AlfredOperationPolicyAdapter.ts'; + +async function collect(source: AsyncIterable): Promise { + const values: T[] = []; + for await (const value of source) { + values.push(value); + } + return values; +} + +describe('AlfredOperationPolicyAdapter stream setup policy', () => { + it('retries stream acquisition before returning an iterable', async () => { + const policy = new AlfredOperationPolicyAdapter(); + let attempts = 0; + + const stream = await policy.stream(async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('resource temporarily unavailable'); + } + return async function* values(): AsyncIterable { + yield 1; + yield 2; + }(); + }, { + retries: 1, + delay: 0, + maxDelay: 0, + shouldRetry: () => true, + }); + + await expect(collect(stream)).resolves.toEqual([1, 2]); + expect(attempts).toBe(2); + }); + + it('does not pull stream chunks before the caller starts iterating', async () => { + const policy = new AlfredOperationPolicyAdapter(); + let pulls = 0; + + const stream = await policy.stream(async () => async function* values(): AsyncIterable { + pulls += 1; + yield 1; + pulls += 1; + yield 2; + }()); + + expect(pulls).toBe(0); + const iterator = stream[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ value: 1, done: false }); + expect(pulls).toBe(1); + }); + + it('does not retry after a stream has emitted caller-visible values', async () => { + const policy = new AlfredOperationPolicyAdapter(); + let acquisitions = 0; + + const stream = await policy.stream(async () => { + acquisitions += 1; + return async function* values(): AsyncIterable { + yield 1; + throw new Error('mid-stream failure'); + }(); + }, { + retries: 3, + delay: 0, + maxDelay: 0, + shouldRetry: () => true, + }); + + const iterator = stream[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ value: 1, done: false }); + await expect(iterator.next()).rejects.toThrow('mid-stream failure'); + expect(acquisitions).toBe(1); + }); + + it('preserves the source return path for partial consumption', async () => { + const policy = new AlfredOperationPolicyAdapter(); + let closed = false; + + const stream = await policy.stream(async () => async function* values(): AsyncIterable { + try { + yield 1; + yield 2; + } finally { + closed = true; + } + }()); + + const iterator = stream[Symbol.asyncIterator](); + await expect(iterator.next()).resolves.toEqual({ value: 1, done: false }); + await iterator.return?.(); + expect(closed).toBe(true); + }); +}); diff --git a/test/unit/scripts/architecture-doc-shape.test.ts b/test/unit/scripts/architecture-doc-shape.test.ts index f744be9b8..263854afc 100644 --- a/test/unit/scripts/architecture-doc-shape.test.ts +++ b/test/unit/scripts/architecture-doc-shape.test.ts @@ -14,6 +14,14 @@ const architecture = readFileSync( fileURLToPath(new URL('../../../docs/ARCHITECTURE.md', import.meta.url)), 'utf8', ); +const readme = readFileSync( + fileURLToPath(new URL('../../../README.md', import.meta.url)), + 'utf8', +); +const vision = readFileSync( + fileURLToPath(new URL('../../../docs/VISION.md', import.meta.url)), + 'utf8', +); type Heading = { readonly level: number; @@ -186,4 +194,18 @@ describe('architecture doc shape', () => { expect(typeof WarpApp.open).toBe('function'); expect(typeof WarpCore.open).toBe('function'); }); + + it('documents flat capability aliases as canonical and moment-scoped names as aliases', () => { + for (const markdown of [readme, architecture, vision]) { + expect(markdown).toContain('graph.patches'); + expect(markdown).toContain('graph.commitment.patches'); + expect(markdown).toContain('graph.query'); + expect(markdown).toContain('graph.revelation.query'); + expect(markdown).toContain('graph.checkpoint'); + expect(markdown).toContain('graph.folding.checkpoint'); + } + expect(readme).toContain('canonical user-facing form'); + expect(architecture).toContain('flat aliases are canonical'); + expect(vision).toContain('They are aliases for the same runtime objects'); + }); }); diff --git a/test/unit/scripts/audit-ambient-time-ratchet.test.ts b/test/unit/scripts/audit-ambient-time-ratchet.test.ts new file mode 100644 index 000000000..f25cda674 --- /dev/null +++ b/test/unit/scripts/audit-ambient-time-ratchet.test.ts @@ -0,0 +1,61 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = process.cwd(); + +const DOMAIN_TIME_BOUNDARIES = Object.freeze([ + 'src/domain/services/provenance/BTR.ts', + 'src/domain/services/audit/AuditReceiptService.ts', + 'src/domain/services/audit/AuditVerifierService.ts', + 'src/domain/services/sync/SyncAuthService.ts', +]); + +const AMBIENT_TIME_PATTERNS = Object.freeze([ + 'Date.now(', + 'new Date(', + 'Date(', + 'performance.now(', +]); + +const WALL_CLOCK_SUPPRESSION_PATTERN = + /eslint-disable-next-line[^\n]*(?:no-restricted-syntax|Date\.now|new Date|Date\(|performance\.now|wall-clock|ambient time)/; + +function source(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), 'utf8'); +} + +function domainSourceFiles(relativePath = 'src/domain'): string[] { + const absolutePath = join(repoRoot, relativePath); + const files: string[] = []; + for (const entry of readdirSync(absolutePath, { withFileTypes: true })) { + const entryPath = `${relativePath}/${entry.name}`; + if (entry.isDirectory()) { + files.push(...domainSourceFiles(entryPath)); + continue; + } + if (entry.name.endsWith('.ts')) { + files.push(entryPath); + } + } + return files.sort(); +} + +describe('domain ambient time ratchet', () => { + it('keeps audit, provenance, and sync auth from generating wall-clock time in domain code', () => { + for (const relativePath of DOMAIN_TIME_BOUNDARIES) { + const text = source(relativePath); + for (const pattern of AMBIENT_TIME_PATTERNS) { + expect(text, `${relativePath} must not contain ${pattern}`).not.toContain(pattern); + } + } + }); + + it('forbids wall-clock lint suppressions in domain code', () => { + for (const relativePath of domainSourceFiles()) { + const text = source(relativePath); + expect(text, `${relativePath} must not suppress wall-clock restrictions`) + .not.toMatch(WALL_CLOCK_SUPPRESSION_PATTERN); + } + }); +}); diff --git a/test/unit/scripts/cli-command-gap-closeout.test.ts b/test/unit/scripts/cli-command-gap-closeout.test.ts new file mode 100644 index 000000000..45077f04a --- /dev/null +++ b/test/unit/scripts/cli-command-gap-closeout.test.ts @@ -0,0 +1,32 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = new URL('../../../', import.meta.url); + +function repoPath(relativePath: string): URL { + return new URL(relativePath, REPO_ROOT); +} + +describe('CLI command gap closeout docs', () => { + it('documents the honest command families and the omitted boundaries', async () => { + const guide = await readFile(repoPath('docs/CLI_GUIDE.md'), 'utf8'); + + expect(guide).toContain('git warp checkpoint status'); + expect(guide).toContain('git warp gc status'); + expect(guide).toContain('git warp sync status'); + expect(guide).toContain('git warp serve'); + expect(guide).toContain('git warp fork'); + expect(guide).toContain('git warp watch'); + expect(guide).toContain('`export` / `import` and `upgrade` / `migrate` remain intentionally absent'); + expect(guide).toContain('explicit file-exchange and substrate'); + expect(guide).toContain('upgrade adapter boundaries'); + }); + + it('does not teach the removed view flag as a runnable workflow', async () => { + const guide = await readFile(repoPath('docs/CLI_GUIDE.md'), 'utf8'); + + expect(guide).toContain('The old `--view` flag has been removed.'); + expect(guide).not.toContain('git warp --view'); + }); +}); diff --git a/test/unit/scripts/cli-command-registry.test.ts b/test/unit/scripts/cli-command-registry.test.ts index 49f53b162..411c4a7ca 100644 --- a/test/unit/scripts/cli-command-registry.test.ts +++ b/test/unit/scripts/cli-command-registry.test.ts @@ -14,6 +14,28 @@ const REPRESENTATIVE_COMMANDS = [ 'strand', 'verify-audit', 'install-hooks', + 'sync', + 'serve', + 'fork', + 'checkpoint', + 'gc', + 'watch', +] as const; + +const CLI_GAP_CLOSEOUT_COMMANDS = [ + 'sync', + 'serve', + 'fork', + 'checkpoint', + 'gc', + 'watch', +] as const; + +const INTENTIONALLY_OMITTED_COMMANDS = [ + 'export', + 'import', + 'upgrade', + 'migrate', ] as const; describe('CLI command registry', () => { @@ -21,6 +43,16 @@ describe('CLI command registry', () => { expect([...KNOWN_COMMANDS].sort()).toStrictEqual([...COMMANDS.keys()].sort()); }); + it.each(CLI_GAP_CLOSEOUT_COMMANDS)('registers the #504 command family %s', (command) => { + expect(KNOWN_COMMANDS).toContain(command); + expect(COMMANDS.has(command)).toBe(true); + }); + + it.each(INTENTIONALLY_OMITTED_COMMANDS)('keeps %s omitted until its boundary exists', (command) => { + expect(KNOWN_COMMANDS).not.toContain(command); + expect(COMMANDS.has(command)).toBe(false); + }); + it.each(REPRESENTATIVE_COMMANDS)('parses %s as an executable command', (command) => { expect(parseArgs([command, '--help'])).toMatchObject({ command, diff --git a/test/unit/scripts/cli-help-shape.test.ts b/test/unit/scripts/cli-help-shape.test.ts index 05a8d1cf4..c1e163628 100644 --- a/test/unit/scripts/cli-help-shape.test.ts +++ b/test/unit/scripts/cli-help-shape.test.ts @@ -22,4 +22,18 @@ describe('CLI help text shape', () => { expect(HELP_TEXT).not.toContain(LEGACY_FLAG); expect(HELP_TEXT).not.toContain(LEGACY_LABEL); }); + + it('documents the command families added for the CLI gap closeout', () => { + expect(HELP_TEXT).toContain('sync Inspect sync status or sync with an HTTP peer'); + expect(HELP_TEXT).toContain('serve Serve the sync endpoint over HTTP'); + expect(HELP_TEXT).toContain('fork Create a graph fork at a writer patch'); + expect(HELP_TEXT).toContain('checkpoint Inspect or create checkpoint state'); + expect(HELP_TEXT).toContain('gc Inspect or run checkpoint garbage collection'); + expect(HELP_TEXT).toContain('watch Stream graph change notifications as NDJSON'); + }); + + it('marks the removed view flag as removed', () => { + expect(HELP_TEXT).toContain('--view [mode] Removed; use warp-ttd for visualization'); + expect(HELP_TEXT).not.toContain('Visual output (ascii, svg:FILE, html:FILE)'); + }); }); diff --git a/test/unit/scripts/conflict-pipeline-context.test.ts b/test/unit/scripts/conflict-pipeline-context.test.ts new file mode 100644 index 000000000..93ab42d89 --- /dev/null +++ b/test/unit/scripts/conflict-pipeline-context.test.ts @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = new URL('../../../', import.meta.url); + +const PIPELINE_FILES: readonly string[] = [ + 'src/domain/services/strand/ConflictFrameLoader.ts', + 'src/domain/services/strand/ConflictCandidateCollector.ts', + 'src/domain/services/strand/conflictCandidateAnalysis.ts', + 'src/domain/services/strand/conflictTargetIdentity.ts', + 'src/domain/services/strand/ConflictTraceAssembler.ts', +]; + +function repoPath(relativePath: string): URL { + return new URL(relativePath, REPO_ROOT); +} + +async function readSource(relativePath: string): Promise { + return await readFile(repoPath(relativePath), 'utf8'); +} + +describe('conflict pipeline context boundary', () => { + it('routes analyzer work through ConflictPipelineContext instead of the analyzer instance', async () => { + const source = await readSource('src/domain/services/strand/ConflictAnalyzerService.ts'); + + expect(source).toContain('new ConflictPipelineContext'); + expect(source).toContain('resolveAnalysisContext(context'); + expect(source).toContain('ConflictCandidateCollector.collect(context'); + expect(source).toContain('buildConflictTraces(context'); + expect(source).toContain('buildAnalysisSnapshotHash(context'); + expect(source).toContain('buildEmptySnapshotHash(context'); + expect(source).not.toContain('resolveAnalysisContext(this'); + expect(source).not.toContain('ConflictCandidateCollector.collect(this'); + expect(source).not.toContain('buildConflictTraces(this'); + expect(source).not.toContain('buildAnalysisSnapshotHash(this'); + expect(source).not.toContain('buildEmptySnapshotHash(this'); + }); + + it('keeps pipeline modules on the explicit context dependency', async () => { + for (const relativePath of PIPELINE_FILES) { + const source = await readSource(relativePath); + + expect(source).toContain('ConflictPipelineContext'); + expect(source).not.toContain('type AnalyzerService'); + expect(source).not.toContain('interface HashingService'); + expect(source).not.toContain('type HashingService'); + expect(source).not.toContain('service._graph'); + expect(source).not.toContain('service._hash'); + } + }); +}); diff --git a/test/unit/scripts/dependency-hygiene.test.ts b/test/unit/scripts/dependency-hygiene.test.ts new file mode 100644 index 000000000..03b7268d1 --- /dev/null +++ b/test/unit/scripts/dependency-hygiene.test.ts @@ -0,0 +1,46 @@ +import { readdir, readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = new URL('../../../', import.meta.url); + +const PATCH_PACKAGE_FILES: readonly string[] = [ + '@git-stunts+alfred+0.10.3.patch', + '@git-stunts+trailer-codec+2.1.1.patch', + '@mapbox+node-pre-gyp+2.0.3.patch', +]; + +const PATCH_PACKAGE_README_HEADINGS: readonly string[] = [ + '### `@git-stunts/alfred@0.10.3`', + '### `@git-stunts/trailer-codec@2.1.1`', + '### `@mapbox/node-pre-gyp@2.0.3`', +]; + +function repoPath(relativePath: string): URL { + return new URL(relativePath, REPO_ROOT); +} + +describe('dependency hygiene', () => { + it('keeps direct dependency policy explicit without stale overrides', async () => { + const packageJson = await readFile(repoPath('package.json'), 'utf8'); + + expect(packageJson).not.toMatch(/"overrides"\s*:\s*\{/); + expect(packageJson).not.toContain('"tar": "7.5.16"'); + expect(packageJson).toContain('"zod": "^3.24.1"'); + expect(packageJson).toContain('"patch-package": "^8.0.0"'); + expect(packageJson).toContain('"prepare": "patch-package && node scripts/setup-hooks.ts"'); + }); + + it('documents every patch-package mutation in the patch inventory', async () => { + const patchFiles = (await readdir(repoPath('patches'))) + .filter((fileName) => fileName.endsWith('.patch')) + .sort(); + const readme = await readFile(repoPath('patches/README.md'), 'utf8'); + + expect(patchFiles).toEqual(PATCH_PACKAGE_FILES); + + for (const heading of PATCH_PACKAGE_README_HEADINGS) { + expect(readme).toContain(heading); + } + }); +}); diff --git a/test/unit/scripts/docs-runtime-convergence-ratchet.test.ts b/test/unit/scripts/docs-runtime-convergence-ratchet.test.ts new file mode 100644 index 000000000..1a22e0ea0 --- /dev/null +++ b/test/unit/scripts/docs-runtime-convergence-ratchet.test.ts @@ -0,0 +1,59 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import MarkdownDocument from '../../helpers/MarkdownDocument.ts'; + +function readDoc(relativePath: string): MarkdownDocument { + return MarkdownDocument.fromFile(fileURLToPath(new URL(`../../../${relativePath}`, import.meta.url))); +} + +const ratchet = readDoc('docs/DOCTRINE_RUNTIME_ALIGNMENT.md'); +const docsIndex = readDoc('docs/README.md'); +const warpDrift = readDoc('docs/audits/WARP_DRIFT.md'); +const apiReference = readDoc('docs/API_REFERENCE.md'); +const conceptualOverview = readDoc('docs/CONCEPTUAL_OVERVIEW.md'); +const glossary = readDoc('docs/GLOSSARY.md'); + +describe('docs/runtime convergence ratchet', () => { + it('defines the allowed docs-ahead posture with status labels', () => { + expect(ratchet.hasHeading(1, 'Doctrine/runtime alignment ratchet')).toBe(true); + expect(ratchet.hasHeading(2, 'Status labels')).toBe(true); + expect(ratchet.hasHeading(2, 'Allowed docs-ahead posture')).toBe(true); + expect(ratchet.listItems().some((item) => item.startsWith('**shipped**:'))).toBe(true); + expect(ratchet.listItems().some((item) => item.startsWith('**transition**:'))).toBe(true); + expect(ratchet.listItems().some((item) => item.startsWith('**target**:'))).toBe(true); + }); + + it('requires runtime evidence before target doctrine becomes current truth', () => { + expect(ratchet.hasHeading(2, 'Runtime evidence')).toBe(true); + expect(ratchet.containsText('GitHub Issue')).toBe(true); + expect(ratchet.containsText('behavior tests or conformance tests')).toBe(true); + expect(ratchet.containsText('public API cost posture')).toBe(true); + expect(ratchet.containsText('docs may name the target')).toBe(true); + expect(ratchet.containsText('make the target current')).toBe(true); + }); + + it('connects the ratchet to the canonical noun and drift surfaces', () => { + expect(ratchet.hasLink('GLOSSARY.md', 'GLOSSARY.md')).toBe(true); + expect(ratchet.hasLink('WARP_DRIFT.md', 'audits/WARP_DRIFT.md')).toBe(true); + expect(warpDrift.hasLink( + 'doctrine/runtime alignment ratchet', + '../DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + expect(glossary.hasHeading(2, 'Status key')).toBe(true); + }); + + it('keeps the guardrail visible from high-traffic docs', () => { + expect(docsIndex.hasLink( + 'Doctrine/runtime Alignment Ratchet', + 'DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + expect(apiReference.hasLink( + 'Doctrine/runtime Alignment Ratchet', + 'DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + expect(conceptualOverview.hasLink( + 'Doctrine/runtime Alignment Ratchet', + 'DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + }); +}); diff --git a/test/unit/scripts/lane-coordinate-boundary.test.ts b/test/unit/scripts/lane-coordinate-boundary.test.ts new file mode 100644 index 000000000..d631d8ee5 --- /dev/null +++ b/test/unit/scripts/lane-coordinate-boundary.test.ts @@ -0,0 +1,33 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = new URL('../../../', import.meta.url); + +function repoPath(relativePath: string): URL { + return new URL(relativePath, REPO_ROOT); +} + +describe('lane coordinate capability boundary docs', () => { + it('documents the substrate-owned lane and coordinate nouns', async () => { + const doc = await readFile(repoPath('docs/specs/LANE_COORDINATE_CAPABILITY_BOUNDARY.md'), 'utf8'); + + expect(doc).toContain('`worldline`'); + expect(doc).toContain('`strand`'); + expect(doc).toContain('`braid`'); + expect(doc).toContain('`live`'); + expect(doc).toContain('`frontier`'); + expect(doc).toContain('`checkpoint`'); + expect(doc).toContain('`strand-base`'); + }); + + it('keeps debugger/session policy out of substrate authority', async () => { + const doc = await readFile(repoPath('docs/specs/LANE_COORDINATE_CAPABILITY_BOUNDARY.md'), 'utf8'); + + expect(doc).toContain('`worldline.live`'); + expect(doc).toContain('`coordinate.transfer-plan`'); + expect(doc).toContain('`debugger.cursor`'); + expect(doc).toContain('not substrate facts'); + expect(doc).toContain('the substrate boundary wins'); + }); +}); diff --git a/test/unit/scripts/non-ts-tail-shape.test.ts b/test/unit/scripts/non-ts-tail-shape.test.ts index 825709626..ca5e0d414 100644 --- a/test/unit/scripts/non-ts-tail-shape.test.ts +++ b/test/unit/scripts/non-ts-tail-shape.test.ts @@ -31,7 +31,6 @@ describe('non-TS tail shape', () => { expect(trackedNonTypeScriptTail()).toEqual([ 'src/globals.d.ts', 'test/type-check/runtime-declarations.d.ts', - 'test/type-check/trailer-codec.d.ts', ]); }); diff --git a/test/unit/scripts/observer-first-guide.test.ts b/test/unit/scripts/observer-first-guide.test.ts new file mode 100644 index 000000000..b39574fc0 --- /dev/null +++ b/test/unit/scripts/observer-first-guide.test.ts @@ -0,0 +1,33 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = new URL('../../../', import.meta.url); + +function repoPath(relativePath: string): URL { + return new URL(relativePath, REPO_ROOT); +} + +describe('observer-first guide posture', () => { + it('keeps the day-to-day guide centered on worldlines and observers before raw graph reads', async () => { + const guide = await readFile(repoPath('docs/GUIDE.md'), 'utf8'); + + expect(guide.indexOf('## Common read patterns')).toBeLessThan(guide.indexOf('## Common query patterns')); + expect(guide).toContain('Add an observer when the caller should not see everything.'); + expect(guide).toContain('worldlines, observers, optics, and query builders first'); + }); + + it('warns that observer redaction is not a cryptographic boundary', async () => { + const guide = await readFile(repoPath('docs/GUIDE.md'), 'utf8'); + const advanced = await readFile(repoPath('docs/ADVANCED_GUIDE.md'), 'utf8'); + + expect(guide).toContain('Observer redaction is application-layer filtering.'); + expect(guide).toContain('not a cryptographic'); + expect(guide).toContain('CasContentEncryptionPolicy'); + expect(guide).toContain('@git-stunts/vault'); + expect(guide).toContain('do not put graph encryption secrets in `.env` files'); + expect(advanced).toContain('Observer redaction is not encryption'); + expect(advanced).toContain('They do not rewrite patch history'); + expect(advanced).toContain('OS-native keychain'); + }); +}); diff --git a/test/unit/scripts/op-hydration-boundary-ratchet.test.ts b/test/unit/scripts/op-hydration-boundary-ratchet.test.ts new file mode 100644 index 000000000..af27b9e54 --- /dev/null +++ b/test/unit/scripts/op-hydration-boundary-ratchet.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = process.cwd(); + +function source(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), 'utf8'); +} + +describe('op hydration boundary ratchet', () => { + it('keeps decoded patch hydration at storage and provenance boundaries', () => { + const cborAdapter = source('src/infrastructure/adapters/CborPatchJournalAdapter.ts'); + const btrAdapter = source('src/infrastructure/adapters/BtrCodecAdapter.ts'); + const patchDiscovery = source('src/domain/services/controllers/PatchDiscovery.ts'); + + expect(cborAdapter).toContain("import { hydrateDecodedPatch } from '../../domain/services/PatchHydrator.ts';"); + expect(cborAdapter).toContain('return hydrateDecodedPatch(this._codec.decode(bytes));'); + + expect(btrAdapter).toContain("import { hydrateDecodedPatch } from '../../domain/services/PatchHydrator.ts';"); + expect(btrAdapter).toContain("patch: hydrateDecodedPatch(source['patch']),"); + + expect(patchDiscovery).toContain("import { hydrateDecodedPatch } from '../PatchHydrator.ts';"); + expect(patchDiscovery).toContain('const decoded = hydrateDecodedPatch(h._codec.decode(raw));'); + }); + + it('keeps decoded ops runtime-backed before patch construction', () => { + const hydrator = source('src/domain/services/PatchHydrator.ts'); + const normalizer = source('src/domain/services/OpNormalizer.ts'); + + expect(hydrator).toContain("import { hydrateKnownDecodedOp } from './OpNormalizer.ts';"); + expect(hydrator).toContain('normalized.push(hydrateKnownDecodedOp(normalizeDecodedOp(rawOp)));'); + expect(normalizer).toContain('if (isRuntimeOp(hydratedOp)) {'); + expect(normalizer).toContain("throw new PatchError(`Cannot hydrate unknown decoded op type '${rawOp.type}'`"); + }); +}); diff --git a/test/unit/scripts/runtime-noun-doc-graph.test.ts b/test/unit/scripts/runtime-noun-doc-graph.test.ts index 6c315acc2..ee1faad5a 100644 --- a/test/unit/scripts/runtime-noun-doc-graph.test.ts +++ b/test/unit/scripts/runtime-noun-doc-graph.test.ts @@ -2,7 +2,10 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { ApertureOpeningProof, + BoundedSupportRule, + CausalIndexPlan, Observer, + SupportFragmentPlan, WarpWorldlineCoordinate, } from '../../../index.ts'; import type { Aperture } from '../../../index.ts'; @@ -21,12 +24,12 @@ const runtimeTerms = Object.freeze([ { term: 'Observer', exportedName: 'Observer', status: 'transition' }, { term: 'Aperture', exportedName: 'Aperture', status: 'transition' }, { term: 'WarpStateSnapshot', exportedName: 'SnapshotWarpState', status: 'shipped' }, + { term: 'Causal index', exportedName: 'CausalIndexPlan', status: 'transition' }, + { term: 'Support fragment', exportedName: 'SupportFragmentPlan', status: 'transition' }, ]); const targetTerms = Object.freeze([ 'Optic', - 'Causal index', - 'Support fragment', ]); function glossaryRow(term: string) { @@ -75,6 +78,12 @@ describe('runtime noun documentation graph', () => { redact: ['secret'], }; const observer = new Observer({ name: 'public-users', config: aperture }); + const supportRule = BoundedSupportRule.entityRead({ + surface: 'query', + nodeIds: ['user:alice'], + }); + const causalIndexPlan = CausalIndexPlan.fromSupportRule(supportRule); + const supportFragmentPlan = SupportFragmentPlan.fromSupportRule(supportRule); const coordinate = new WarpWorldlineCoordinate({ worldlineName: 'events', checkpointSha: 'checkpoint-1', @@ -95,6 +104,8 @@ describe('runtime noun documentation graph', () => { expect(observer.name).toBe('public-users'); expect(observer.source?.kind).toBe('live'); + expect(causalIndexPlan.canUseCausalIndex()).toBe(true); + expect(supportFragmentPlan.canMaterializeSupportFragment()).toBe(true); expect(coordinate.source()).toEqual({ kind: 'coordinate', frontier: new Map([ diff --git a/test/unit/scripts/source-size-inventory-command.test.ts b/test/unit/scripts/source-size-inventory-command.test.ts index ac5da1b98..d55826269 100644 --- a/test/unit/scripts/source-size-inventory-command.test.ts +++ b/test/unit/scripts/source-size-inventory-command.test.ts @@ -1,34 +1,17 @@ -import { spawnSync, type SpawnSyncOptionsWithStringEncoding, type SpawnSyncReturns } from 'node:child_process'; -import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; -type InventoryEntry = { - readonly lines: number; - readonly path: string; -}; +import { + SOURCE_SIZE_RELAXATIONS, + checkSourceSizeGate, + collectSourceSizeInventory, +} from '../../../scripts/source-size-gate.ts'; -const COMMAND_TIMEOUT_MS = 120_000; const SOURCE_FILE_LOC_CEILING = 500; const TEST_FILE_LOC_CEILING = 800; -const LINE_INVENTORY_COMMAND = [ - "find src bin scripts test/unit test/conformance -path '*/node_modules/*' -prune -o", - "-type f \\( -name '*.ts' -o -name '*.js' -o -name '*.sh' \\) -print0", - '| xargs -0 wc -l', -].join(' '); - -type CommandRunner = ( - command: string, - args: readonly string[], - options: SpawnSyncOptionsWithStringEncoding, -) => SpawnSyncReturns; - -type SpawnCall = { - readonly command: string; - readonly args: readonly string[]; - readonly timeout: SpawnSyncOptionsWithStringEncoding['timeout']; - readonly killSignal: SpawnSyncOptionsWithStringEncoding['killSignal']; -}; - -const defaultCommandRunner: CommandRunner = (command, args, options) => spawnSync(command, [...args], options); +const TOOLING_FILE_LOC_CEILING = 300; const SOURCE_OVER_BUDGET_PATHS = Object.freeze([ 'src/domain/RuntimeHost.ts', @@ -41,119 +24,80 @@ const SOURCE_OVER_BUDGET_PATHS = Object.freeze([ 'src/domain/services/state/WarpState.ts', ]); -const STRAND_SERVICE_TEST_PATH = 'test/unit/domain/services/strand/StrandService.test.ts'; +const TOOLING_OVER_BUDGET_PATHS = Object.freeze([ + 'bin/cli/commands/doctor/checks.ts', + 'bin/cli/commands/seek.ts', + 'bin/cli/infrastructure.ts', + 'scripts/check-dts-surface.ts', + 'scripts/contamination-map.ts', + 'scripts/dead-export-report.ts', + 'scripts/issue-triage-report.ts', + 'scripts/lint-semgrep-with-quarantines.ts', + 'scripts/release-guard.sh', + 'scripts/v18.0.0/migrations/graph-model/V17GoldenGraphFixtureManifest.ts', +]); -function runLineInventory(runner: CommandRunner = defaultCommandRunner): readonly InventoryEntry[] { - const result = runner('sh', [ - '-c', - LINE_INVENTORY_COMMAND, - ], { - encoding: 'utf8', - timeout: COMMAND_TIMEOUT_MS, - killSignal: 'SIGKILL', - }); - expect(result.error).toBeUndefined(); - expect(result.status).toBe(0); - return result.stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.endsWith(' total')) - .map(parseInventoryLine); -} +const STRAND_SERVICE_TEST_PATH = 'test/unit/domain/services/strand/StrandService.test.ts'; +const tempDirs: string[] = []; -function parseInventoryLine(line: string): InventoryEntry { - const match = /^(\d+)\s+(.+)$/u.exec(line); - if (match === null) { - throw new SourceInventoryError(`unparseable inventory line: ${line}`); - } - const lineCount = Number(match[1]); - const path = match[2]; - if (!Number.isInteger(lineCount) || lineCount <= 0 || path === undefined) { - throw new SourceInventoryError(`invalid inventory line: ${line}`); +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop() ?? '', { force: true, recursive: true }); } - return { lines: lineCount, path }; -} - -class SourceInventoryError extends Error {} - -function successfulSpawnResult(stdout: string): SpawnSyncReturns { - return { - pid: 0, - output: [null, stdout, ''], - stdout, - stderr: '', - status: 0, - signal: null, - }; -} +}); -function byInventoryPath(a: InventoryEntry, b: InventoryEntry): number { - if (a.path < b.path) { - return -1; +function createTempRepo(): string { + const root = mkdtempSync(join(tmpdir(), 'git-warp-source-size-')); + tempDirs.push(root); + for (const directory of ['src', 'bin', 'scripts', 'test/unit', 'test/conformance']) { + mkdirSync(join(root, directory), { recursive: true }); } - if (a.path > b.path) { - return 1; - } - return 0; + return root; } -function requireInventoryEntry( - entries: readonly InventoryEntry[], - path: string, -): InventoryEntry { - const entry = entries.find((candidate) => candidate.path === path); - if (entry === undefined) { - throw new SourceInventoryError(`missing inventory entry: ${path}`); - } - return entry; +function writeLines(root: string, path: string, lines: number): void { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), Array.from({ length: lines }, (_, index) => `line ${index}`).join('\n')); } -describe('source size inventory command', () => { - it('bounds the inventory subprocess with a timeout', () => { - const calls: SpawnCall[] = []; - const recordingRunner: CommandRunner = (command, args, options) => { - calls.push({ - command, - args: [...args], - timeout: options.timeout, - killSignal: options.killSignal, - }); - return successfulSpawnResult('1 src/index.ts\n'); - }; - - runLineInventory(recordingRunner); - - expect(calls).toEqual([{ - command: 'sh', - args: ['-c', LINE_INVENTORY_COMMAND], - timeout: COMMAND_TIMEOUT_MS, - killSignal: 'SIGKILL', - }]); +describe('source size gate', () => { + it('reports the current source files over the 500 LOC ceiling as explicit relaxations', () => { + const report = checkSourceSizeGate(); + const sourceOverBudget = report.entries + .filter((entry) => entry.band === 'source' && entry.lines > SOURCE_FILE_LOC_CEILING) + .map((entry) => entry.path); + + expect(sourceOverBudget).toEqual(SOURCE_OVER_BUDGET_PATHS); + expect(report.violations).toEqual([]); + expect(report.staleRelaxations).toEqual([]); }); - it('reports the current source files over the 500 LOC ceiling', () => { - const entries = runLineInventory(); - const sourceOverBudget = entries - .filter((entry) => entry.path.startsWith('src/') && entry.lines > SOURCE_FILE_LOC_CEILING) - .sort(byInventoryPath); + it('keeps tooling overages visible against the 300 LOC ceiling', () => { + const entries = collectSourceSizeInventory(); + const toolingOverBudget = entries + .filter((entry) => entry.band === 'tooling' && entry.lines > TOOLING_FILE_LOC_CEILING) + .map((entry) => entry.path); - expect(sourceOverBudget.map((entry) => entry.path)).toEqual(SOURCE_OVER_BUDGET_PATHS); - for (const entry of sourceOverBudget) { - expect(entry.lines).toBeGreaterThan(SOURCE_FILE_LOC_CEILING); - } + expect(toolingOverBudget).toEqual(TOOLING_OVER_BUDGET_PATHS); }); it('keeps test-file overages visible as inventory, not closeout prose', () => { - const entries = runLineInventory(); - const testOverBudget = entries - .filter((entry) => entry.path.startsWith('test/') && entry.lines > TEST_FILE_LOC_CEILING) - .sort(byInventoryPath); - const strandServiceTest = requireInventoryEntry(testOverBudget, STRAND_SERVICE_TEST_PATH); + const entries = collectSourceSizeInventory(); + const strandServiceTest = entries.find((entry) => entry.path === STRAND_SERVICE_TEST_PATH); - expect(strandServiceTest.lines).toBeGreaterThan(TEST_FILE_LOC_CEILING); + expect(strandServiceTest?.lines).toBeGreaterThan(TEST_FILE_LOC_CEILING); + expect(SOURCE_SIZE_RELAXATIONS).toContain(STRAND_SERVICE_TEST_PATH); }); - it('rejects zero-line rows as invalid policy inventory evidence', () => { - expect(() => parseInventoryLine('0 src/empty.ts')).toThrow(SourceInventoryError); + it('fails new over-budget files and stale relaxations', () => { + const root = createTempRepo(); + writeLines(root, 'src/new-god.ts', SOURCE_FILE_LOC_CEILING + 1); + writeLines(root, 'bin/tool.ts', TOOLING_FILE_LOC_CEILING); + writeLines(root, 'test/unit/small.test.ts', TEST_FILE_LOC_CEILING); + + const report = checkSourceSizeGate(root); + + expect(report.violations.map((entry) => entry.path)).toEqual(['src/new-god.ts']); + expect(report.staleRelaxations).toEqual([...SOURCE_SIZE_RELAXATIONS].sort()); }); }); diff --git a/test/unit/scripts/tsc-zero-agent-audit.test.ts b/test/unit/scripts/tsc-zero-agent-audit.test.ts new file mode 100644 index 000000000..ebded64a2 --- /dev/null +++ b/test/unit/scripts/tsc-zero-agent-audit.test.ts @@ -0,0 +1,109 @@ +import { readFile } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = new URL('../../../', import.meta.url); +const AUDIT_PATH = 'docs/audit/2026-06-20_tsc-zero-agent-merge-audit.md'; +const RETRO_PATH = 'docs/archive/retrospectives/2026-04-01-tsc-zero-and-joinreducer-strategy.md'; + +const EXPECTED_REMERGE_PATHS: readonly string[] = [ + 'bin/cli/commands/bisect.js', + 'bin/cli/commands/debug/conflicts.js', + 'bin/cli/commands/query.js', + 'bin/cli/commands/strand/materialize.js', + 'bin/cli/commands/verify-audit.js', + 'bin/cli/commands/verify-index.js', + 'bin/presenters/index.js', + 'bin/presenters/text.js', + 'bin/warp-graph.js', + 'eslint.config.js', + 'src/domain/WarpRuntime.js', + 'src/domain/services/AdjacencyNeighborProvider.js', + 'src/domain/services/AnchorMessageCodec.js', + 'src/domain/services/AuditMessageCodec.js', + 'src/domain/services/BitmapIndexBuilder.js', + 'src/domain/services/BoundaryTransitionRecord.js', + 'src/domain/services/CheckpointMessageCodec.js', + 'src/domain/services/CheckpointSerializerV5.js', + 'src/domain/services/CheckpointService.js', + 'src/domain/services/ConflictAnalyzerService.js', + 'src/domain/services/HttpSyncServer.js', + 'src/domain/services/IncrementalIndexUpdater.js', + 'src/domain/services/IndexRebuildService.js', + 'src/domain/services/JoinReducer.js', + 'src/domain/services/PatchBuilderV2.js', + 'src/domain/services/PatchMessageCodec.js', + 'src/domain/services/QueryBuilder.js', + 'src/domain/services/StateReaderV5.js', + 'src/domain/services/StrandService.js', + 'src/domain/services/SyncAuthService.js', + 'src/domain/services/SyncController.js', + 'src/domain/services/TemporalQuery.js', + 'src/domain/services/WarpStateIndexBuilder.js', + 'src/domain/services/WormholeService.js', + 'src/domain/trust/TrustCanonical.js', + 'src/domain/trust/TrustEvaluator.js', + 'src/domain/trust/TrustRecordService.js', + 'src/domain/trust/TrustStateBuilder.js', + 'src/domain/types/DeliveryObservation.js', + 'src/domain/utils/MinHeap.js', + 'src/domain/warp/comparison.methods.js', + 'src/infrastructure/adapters/CasSeekCacheAdapter.js', + 'src/infrastructure/adapters/GitGraphAdapter.js', + 'src/visualization/renderers/ascii/path.js', + 'src/visualization/renderers/ascii/seek.js', + 'test/unit/domain/WarpCore.emit.test.js', + 'test/unit/domain/WarpGraph.audit.test.js', + 'test/unit/domain/services/AuditReceiptService.test.js', + 'test/unit/domain/services/AuditVerifierService.test.js', + 'test/unit/domain/services/LogicalBitmapIndexBuilder.test.js', + 'test/unit/domain/services/LogicalIndexBuildService.test.js', + 'test/unit/domain/services/MaterializedViewService.test.js', + 'test/unit/domain/trust/TrustAdversarial.test.js', + 'test/unit/domain/trust/TrustEvaluator.test.js', + 'test/unit/domain/trust/TrustRecordService.convergence.test.js', +]; + +function repoPath(relativePath: string): URL { + return new URL(relativePath, REPO_ROOT); +} + +function extractReconstructedPaths(markdown: string): string[] { + const startMarker = ''; + const endMarker = ''; + const start = markdown.indexOf(startMarker); + const end = markdown.indexOf(endMarker); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + + return markdown + .slice(start + startMarker.length, end) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('- `') && line.endsWith('`')) + .map((line) => line.slice(3, -1)); +} + +describe('TSC zero agent merge audit closeout', () => { + it('preserves the exact reconstructed PR 73 conflict-resolution scope', async () => { + const audit = await readFile(repoPath(AUDIT_PATH), 'utf8'); + const paths = extractReconstructedPaths(audit); + + expect(paths).toEqual(EXPECTED_REMERGE_PATHS); + expect(paths).toHaveLength(55); + expect(paths.filter((path) => path.startsWith('test/'))).toHaveLength(10); + expect(paths.filter((path) => path === 'eslint.config.js')).toHaveLength(1); + expect(paths.filter((path) => !path.startsWith('test/') && path !== 'eslint.config.js')).toHaveLength(44); + }); + + it('records the closeout verdict and links the original retrospective forward', async () => { + const audit = await readFile(repoPath(AUDIT_PATH), 'utf8'); + const retro = await readFile(repoPath(RETRO_PATH), 'utf8'); + + expect(audit).toContain('Retired. No revert is required.'); + expect(audit).toContain('No suspicious semantic drift remains'); + expect(audit).toContain('The original backlog card said 27 files'); + expect(retro).toContain('../../audit/2026-06-20_tsc-zero-agent-merge-audit.md'); + }); +}); diff --git a/test/unit/scripts/v17-public-doc-read-contract.test.ts b/test/unit/scripts/v17-public-doc-read-contract.test.ts index a1e600575..56c9c1df0 100644 --- a/test/unit/scripts/v17-public-doc-read-contract.test.ts +++ b/test/unit/scripts/v17-public-doc-read-contract.test.ts @@ -14,6 +14,8 @@ const docsIndex = readDoc('docs/README.md'); const gettingStarted = readDoc('docs/GETTING_STARTED.md'); const guide = readDoc('docs/GUIDE.md'); const apiReference = readDoc('docs/API_REFERENCE.md'); +const changelog = readDoc('CHANGELOG.md'); +const migrationGuide = readDoc('docs/migrations/v17.0.0.md'); describe('v17 public docs read contract', () => { it('keeps the materialization frontdoor out of first-use docs', () => { @@ -36,4 +38,16 @@ describe('v17 public docs read contract', () => { expect(doc).toContain('READINGS_AND_OPTICS.md'); } }); + + it('documents the substrate Plumbing to GitPlumbing rename as a v17 breaking change', () => { + expect(changelog).toContain('`@git-stunts/plumbing` class rename'); + expect(changelog).toContain("import GitPlumbing from '@git-stunts/plumbing';"); + + expect(migrationGuide).toContain('`@git-stunts/plumbing`: `Plumbing` → `GitPlumbing`'); + expect(migrationGuide).toContain("import GitPlumbing from '@git-stunts/plumbing';"); + expect(migrationGuide).toContain("import { Plumbing } from '@git-stunts/plumbing';"); + + expect(apiReference).toContain('Do not import a named `Plumbing` symbol'); + expect(apiReference).toContain('v17 treats\nthat substrate rename as a breaking change'); + }); }); diff --git a/test/unit/scripts/v18-content-property-closeout-audit.test.ts b/test/unit/scripts/v18-content-property-closeout-audit.test.ts index 09285c4d0..60cf14843 100644 --- a/test/unit/scripts/v18-content-property-closeout-audit.test.ts +++ b/test/unit/scripts/v18-content-property-closeout-audit.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from 'vitest'; // making the simple lookbehind miss it. const RAW_COMPATIBILITY_PATTERN = /decodePropKey|decodeEdgePropKey|state\.prop|(?:(? { expect(doc).toContain(`- \`${file}\` retired`); } }); + + it('presents typed content attachments as the primary storage-plane model', async () => { + const spec = await readFile(CONTENT_ATTACHMENT_SPEC, 'utf8'); + + expect(spec).toContain('Primary Runtime Model'); + expect(spec).toContain('ContentAttachmentRecord'); + expect(spec).toContain('GraphContentAttachmentSetOp'); + expect(spec).toContain('Legacy Storage Compatibility'); + expect(spec).not.toContain('A content attachment is represented as a **node property** with a well-known key.'); + expect(spec).not.toContain('| How content is referenced | `_content` property on nodes/edges (CAS SHA) |'); + }); }); async function findRawCompatibilityFiles(root: string): Promise { diff --git a/test/unit/scripts/v18-package-surface-audit.test.ts b/test/unit/scripts/v18-package-surface-audit.test.ts index d570868ec..c2f2b18e2 100644 --- a/test/unit/scripts/v18-package-surface-audit.test.ts +++ b/test/unit/scripts/v18-package-surface-audit.test.ts @@ -1,17 +1,24 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +function repoPath(relativePath: string): string { + return fileURLToPath(new URL(`../../../${relativePath}`, import.meta.url)); +} + function readText(relativePath: string): string { - return readFileSync( - fileURLToPath(new URL(`../../../${relativePath}`, import.meta.url)), - 'utf8', - ); + return readFileSync(repoPath(relativePath), 'utf8'); +} + +function lineCount(source: string): number { + return source.split('\n').length; } const packageJson = readText('package.json'); const jsrJson = readText('jsr.json'); +const tsconfigPublish = readText('tsconfig.publish.json'); const indexSource = readText('index.ts'); +const generatedIndexDeclarations = readText('dist/index.d.ts'); function packageModuleDoc(): string { const terminator = indexSource.indexOf('*/'); @@ -40,10 +47,19 @@ describe('v18 package surface audit', () => { expect(jsrJson).toContain('".": "./index.ts"'); }); + it('keeps the legacy root declaration monolith retired', () => { + expect(existsSync(repoPath('index.d.ts'))).toBe(false); + expect(packageJson).not.toContain('"types": "./index.d.ts"'); + expect(tsconfigPublish).toContain('"declaration": true'); + expect(lineCount(generatedIndexDeclarations)).toBeLessThanOrEqual(500); + }); + it('exports the Worldline-first opener, handle, and option types from the root', () => { expect(indexSource).toContain('import WarpWorldline, { openWarpWorldline }'); expect(indexSource).toContain('openWarpWorldline,'); expect(indexSource).toContain('WarpWorldline,'); + expect(indexSource).toContain('ProjectionHandle,'); + expect(indexSource).not.toMatch(/^\s+Worldline,$/m); expect(indexSource).toContain('WarpWorldlineOpenOptions,'); expect(indexSource).toContain('WarpWorldlinePatchBuild,'); }); diff --git a/test/unit/scripts/warp-doctrine-runtime-alignment.test.ts b/test/unit/scripts/warp-doctrine-runtime-alignment.test.ts new file mode 100644 index 000000000..e51573daa --- /dev/null +++ b/test/unit/scripts/warp-doctrine-runtime-alignment.test.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import MarkdownDocument from '../../helpers/MarkdownDocument.ts'; + +function readDoc(relativePath: string): MarkdownDocument { + return MarkdownDocument.fromFile(fileURLToPath(new URL(`../../../${relativePath}`, import.meta.url))); +} + +const readme = readDoc('README.md'); +const guide = readDoc('docs/GUIDE.md'); +const advancedGuide = readDoc('docs/ADVANCED_GUIDE.md'); +const drift = readDoc('docs/audits/WARP_DRIFT.md'); +const alignment = readDoc('docs/audits/WARP_DOCTRINE_RUNTIME_ALIGNMENT.md'); + +describe('WARP doctrine/runtime teaching alignment', () => { + it('keeps entry-point docs connected to the glossary and ratchet', () => { + expect(readme.hasHeading(2, 'Runtime posture')).toBe(true); + expect(readme.hasLink('GLOSSARY.md', 'docs/GLOSSARY.md')).toBe(true); + expect(readme.hasLink( + 'Doctrine/runtime Alignment Ratchet', + 'docs/DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + + expect(guide.hasHeading(2, 'Runtime posture')).toBe(true); + expect(guide.hasLink('GLOSSARY.md', 'GLOSSARY.md')).toBe(true); + expect(guide.hasLink( + 'Doctrine/runtime Alignment Ratchet', + 'DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + + expect(advancedGuide.hasHeading(2, 'Runtime posture')).toBe(true); + expect(advancedGuide.hasLink('GLOSSARY.md', 'GLOSSARY.md')).toBe(true); + expect(advancedGuide.hasLink( + 'Doctrine/runtime Alignment Ratchet', + 'DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + }); + + it('marks current teaching surfaces with shipped, transition, and target posture', () => { + for (const doc of [readme, guide, advancedGuide]) { + expect(doc.containsText('target doctrine')).toBe(true); + } + + expect(readme.containsText('shipped, transition, and target noun status')).toBe(true); + expect(guide.containsText('shipped and transition APIs')).toBe(true); + expect(advancedGuide.containsText('implementation')).toBe(true); + expect(advancedGuide.containsText('posture')).toBe(true); + }); + + it('pins active reconciliation hills to GitHub issues', () => { + expect(alignment.hasHeading(1, 'WARP doctrine/runtime teaching alignment')).toBe(true); + expect(alignment.hasHeading(2, 'Teaching surface matrix')).toBe(true); + expect(alignment.hasHeading(2, 'Active reconciliation hills')).toBe(true); + + for (const issueNumber of ['554', '557', '558', '559', '560', '561', '562', '563', '564']) { + expect(alignment.containsText(`github.com/git-stunts/git-warp/issues/${issueNumber}`)).toBe(true); + } + }); + + it('keeps the drift ledger connected to the teaching alignment audit', () => { + expect(drift.hasLink( + 'WARP doctrine/runtime teaching alignment', + 'WARP_DOCTRINE_RUNTIME_ALIGNMENT.md', + )).toBe(true); + }); +}); diff --git a/test/unit/scripts/warpserve-boundary-ratchet.test.ts b/test/unit/scripts/warpserve-boundary-ratchet.test.ts new file mode 100644 index 000000000..f1f76e34c --- /dev/null +++ b/test/unit/scripts/warpserve-boundary-ratchet.test.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repoRoot = process.cwd(); +const SOURCE_ROOT = 'src'; + +function sourceFiles(relativePath = SOURCE_ROOT): string[] { + const absolutePath = join(repoRoot, relativePath); + const files: string[] = []; + for (const entry of readdirSync(absolutePath, { withFileTypes: true })) { + const entryPath = `${relativePath}/${entry.name}`; + if (entry.isDirectory()) { + files.push(...sourceFiles(entryPath)); + continue; + } + if (entry.name.endsWith('.ts')) { + files.push(entryPath); + } + } + return files.sort(); +} + +function text(relativePath: string): string { + return readFileSync(join(repoRoot, relativePath), 'utf8'); +} + +describe('WarpServe boundary ratchet', () => { + it('keeps the retired WarpServeService out of active source', () => { + for (const relativePath of sourceFiles()) { + expect(relativePath, 'WarpServeService must not return as an active source path') + .not.toContain('WarpServeService'); + expect(text(relativePath), `${relativePath} must not define or import WarpServeService`) + .not.toContain('WarpServeService'); + } + }); + + it('keeps WebSocket server ports out of the domain boundary', () => { + for (const relativePath of sourceFiles('src/domain')) { + expect(text(relativePath), `${relativePath} must not depend on WebSocket server ports`) + .not.toContain('WebSocketServerPort'); + } + for (const relativePath of sourceFiles('src/ports')) { + expect(relativePath, 'WebSocketServerPort must not return as an active source port') + .not.toContain('WebSocketServerPort'); + } + }); +});