diff --git a/README.md b/README.md index a278dbad..3547772c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-

A Git-native runtime for shared causal history.

-

Write intents. Read timelines. Keep receipts.

+

Write intents. Observe lanes. Keep receipts.

+

A runtime for shared causal history.

Offline-first, multi-writer, deterministic, and built for provenance-aware applications.

@@ -16,7 +16,8 @@

-`git-warp` is a Git-native system for storing and reading **causal history**. +`git-warp` is a runtime for storing and observing **causal history**. Its +current durable storage substrate is Git. Instead of treating a graph as one big in-memory object, `git-warp` treats history as the source of truth. Reads are bounded views over that history, and writes are appended as patches. It is one runtime in the [Continuum](https://github.com/flyingrobots/continuum) stack ([more below](#continuum)). @@ -52,11 +53,13 @@ replay-backed so callers receive complete diff and provenance data. See [CHANGELOG.md](CHANGELOG.md) for the full in-repository release notes. -## v19 First-Use API +## Transitional v19 First-Use API -This is the public contract new application code should start from. The -v18 graph-first package exports are removed rather than carried as a second -compatibility API. +The currently implemented v19 facade below is transitional. The accepted +[`Runtime` / `Lane` / `Observer` vocabulary checkpoint](docs/topics/api/) is +the release target and must not be described as implemented until its runtime +and boundary tests land. The v18 graph-first package exports remain removed +rather than carried as a second compatibility API. ```typescript import { openWarp, intent, reading } from '@git-stunts/git-warp'; @@ -147,7 +150,7 @@ actually used through receipts. The removed `openWarpGraph()` and `openWarpWorldline()` paths are migration source material, not a second application API. -See the [v19 API reflection](docs/topics/api/), the +See the [v19 API vocabulary checkpoint](docs/topics/api/), the [v19 migration guide](docs/migrations/v19/), and [Optic reads](docs/topics/optic-reads.md) for the model. diff --git a/bin/cli/commands/trust.ts b/bin/cli/commands/trust.ts index f835c631..17b0a21f 100644 --- a/bin/cli/commands/trust.ts +++ b/bin/cli/commands/trust.ts @@ -11,10 +11,10 @@ import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.ts'; import { trustSchema } from '../schemas.ts'; import { createPersistence, resolveGraphName } from '../shared.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import AuditVerifierService from '../../../src/domain/services/audit/AuditVerifierService.ts'; import TrustCryptoAdapter from '../../../src/infrastructure/adapters/TrustCryptoAdapter.ts'; import WebCryptoAdapter from '../../../src/infrastructure/adapters/WebCryptoAdapter.ts'; -import type { CorePersistence } from '../../../src/domain/types/WarpPersistence.ts'; import type { CliOptions, Persistence } from '../types.ts'; const TRUST_OPTIONS = { @@ -53,11 +53,16 @@ async function discoverWriterIds(persistence: Persistence, graphName: string): P /** Handles the `git warp trust` command: evaluates writer trust against signed evidence. */ export default async function handleTrust({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { const { mode, trustPin } = parseTrustArgs(args); - const { persistence, createTrustChain } = await createPersistence(options.repo); + const { persistence, runtimeStorage, createTrustChain } = await createPersistence(options.repo); const graphName = await resolveGraphName(persistence, options.graph); + const storage = await runtimeStorage.createRuntimeStorageServices({ + timelineName: graphName, + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); const trustChain = createTrustChain(new WebCryptoAdapter()); const verifier = new AuditVerifierService({ - persistence: persistence as unknown as CorePersistence, + auditLog: storage.auditLog, codec: defaultCodec, trustChain, trustCrypto: new TrustCryptoAdapter(), diff --git a/bin/cli/commands/verify-audit.ts b/bin/cli/commands/verify-audit.ts index 58202ecc..316effc2 100644 --- a/bin/cli/commands/verify-audit.ts +++ b/bin/cli/commands/verify-audit.ts @@ -1,6 +1,6 @@ import AuditVerifierService from '../../../src/domain/services/audit/AuditVerifierService.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; -import type { CorePersistence } from '../../../src/domain/types/WarpPersistence.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.ts'; import { verifyAuditSchema } from '../schemas.ts'; import { createPersistence, resolveGraphName } from '../shared.ts'; @@ -47,10 +47,15 @@ export function parseVerifyAuditArgs(args: string[]): { since: string | undefine /** Handles the verify-audit command: verifies audit receipt chain integrity. */ export default async function handleVerifyAudit({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { const { since, writerFilter, trustMode, trustPin } = parseVerifyAuditArgs(args); - const { persistence } = await createPersistence(options.repo); + const { persistence, runtimeStorage } = await createPersistence(options.repo); const graphName = await resolveGraphName(persistence, options.graph); + const storage = await runtimeStorage.createRuntimeStorageServices({ + timelineName: graphName, + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); const verifier = new AuditVerifierService({ - persistence: persistence as unknown as CorePersistence, + auditLog: storage.auditLog, codec: defaultCodec, }); diff --git a/docs/migrations/v19/README.md b/docs/migrations/v19/README.md index 8f876ce1..1e5b6803 100644 --- a/docs/migrations/v19/README.md +++ b/docs/migrations/v19/README.md @@ -1,5 +1,12 @@ # v19 Public API Migration Plan +> **Transitional implementation note:** This guide describes the first v19 +> timeline facade that currently exists in source. The accepted +> [v19 public vocabulary checkpoint](../../topics/api/) supersedes it as the +> release target. Do not treat this guide as final v19 migration evidence until +> it is rewritten for Runtime, Lane, Observer, Observation, Reading, and +> Receipt. + This migration plan covers consumers moving from v18 or earlier public surfaces to the v19 public API. It is intentionally explicit because v19 is a major-version boundary: root exports become small and application diff --git a/docs/topics/README.md b/docs/topics/README.md index 9943834a..79dea4de 100644 --- a/docs/topics/README.md +++ b/docs/topics/README.md @@ -16,8 +16,8 @@ replay-backed. Operator workflows live outside the topic shelf in - [Getting started](getting-started.md): install the package, open a worldline, write a patch, read it back, and sync WARP refs. -- [v19 public API reflection](api/): understand the planned root API shift to - intents, readings, timelines, ticks, and receipts. +- [v19 public vocabulary checkpoint](api/): follow the accepted Runtime, Lane, + Intent, Observer, Observation, Reading, and Receipt contract. - [Querying](querying.md): choose between worldlines, observers, optic reads, query builders, and strand sources. diff --git a/docs/topics/api/README.md b/docs/topics/api/README.md index 97709935..980496d7 100644 --- a/docs/topics/api/README.md +++ b/docs/topics/api/README.md @@ -1,481 +1,575 @@ -# v19 Public API Reflection +# v19 Public Vocabulary Checkpoint -This note records the implemented v19 public API line. The target is -not to make `git-warp` less precise. The target is to stop making first-use -application code learn substrate language before it can do useful work. +> **Status:** Accepted target for `v19.0.0`. +> +> This document is the normative product vocabulary and public-surface design. +> It is not implementation evidence. Until the Runtime, Lane, Observer, and +> Observation contracts described here are implemented and covered by boundary +> tests, the currently exported timeline facade remains transitional. -The slogan is: +The product doctrine is: ```text -Write intents. Read timelines. Keep receipts. +Write intents. Observe lanes. Keep receipts. ``` -That is the public story. The architecture underneath can keep its exact WARP -terms, but the package boundary should be small, boring, and difficult to misuse. +That sentence is also the API test. Ordinary application work must not require +users to learn Git objects, refs, CAS retention, graph materialization, patches, +worldlines, braids, or holograms. -## Why Change +## Canonical Grammar -The v18 public story is already moving in the right direction. It describes -`git-warp` as causal history first, with bounded reads, append-only writes, -provenance, and deterministic multi-writer behavior. +The public programming model is: -The problem is that the first-use API still makes users meet too much of the -implementation too early: +```text +A Runtime owns access to causal history. +A Lane is one admitted or counterfactual line of causal progression. +An Intent proposes a write to a Lane. +An Observer runs against a Lane. +That execution is an Observation. +An Observation emits Readings and leaves a Receipt. +``` -- `openWarpWorldline()`; -- `GitGraphAdapter`; -- `commit((patch) => ...)`; -- `coordinate()`; -- `optic()`; -- `openWarpGraph()`; -- graph-shaped entities and operation builders exported from root. +The four observation nouns are not aliases: -Those names are not all wrong. Many are accurate. The issue is that they are -not the best package boundary for application authors, agents, and higher-level -runtimes such as XYPH and Continuum. +| Noun | Responsibility | +| --- | --- | +| `Observer` | Reusable immutable executable observation plan | +| `Observation` | One resource-bounded execution of an Observer against a Lane | +| `Reading` | One bounded semantic result emitted by an Observation | +| `Receipt` | Durable terminal record of an operation | -The root API should say what users are doing: +The canonical sentence is: -- write an intent; -- read a timeline; -- inspect a receipt. +> An Observer runs against a Lane, producing an Observation that emits +> Readings and leaves a Receipt. -The root API should not make users think in graph substrate terms. +## Disclosure Order -## Public Vocabulary +Documentation, generated APIs, CLI help, and MCP descriptions must disclose +concepts in this order: -The preferred v19 root vocabulary is: +### Beginner ```text -Warp -Timeline -Intent -Reading -Tick -Receipt -ReceiptOutcome +Runtime -> Lane -> Intent / Observation -> Receipt ``` -`Reader` and `Writer` remain useful role or port names, but they should not be -the headline README characters. Most users should not need to create a reader -and writer before they understand the simpler timeline model. - -The preferred first-use imports are: - -```typescript -import { openWarp, intent, reading } from '@git-stunts/git-warp'; -``` +### Application Author -Storage constructors belong behind an explicit storage subpath: - -```typescript -import { GitStorage } from '@git-stunts/git-warp/storage'; +```text +Observer -> Optic -> Reading -> Support ``` -Advanced WARP vocabulary remains available where it is genuinely the right -tool: +### Formal And Diagnostic ```text -Optic -Coordinate -Witness +Coordinate -> Witness -> Hologram -> Braid -> Transport ``` -Worldline, Strand, Braid, Hologram, Observer, and Continuum families remain -internal. They are not moved to `advanced` merely because they left root. +This is a dependency discipline, not merely a documentation preference. A +shallower operation must not require a deeper-layer noun unless that operation +actually needs the deeper concept. -## Root Shape +## Package Root -The v19 quick-start shape should look like this: +The only root runtime value is `Runtime`: ```typescript -import { openWarp, intent, reading } from '@git-stunts/git-warp'; -import { GitStorage } from '@git-stunts/git-warp/storage'; +import { Runtime } from '@git-stunts/git-warp'; -const storage = await GitStorage.open({ cwd: '.' }); -const warp = await openWarp({ - storage, +const runtime = await Runtime.open({ + at: '.', writer: 'agent-1', }); +``` -const timeline = await warp.timeline('events'); +The root may export the TypeScript types needed to describe the core contract, +but it must not export competing factories, adapters, graph substrate, or +generic intent and observer registries as runtime values. -await timeline.write( - intent.node.add({ - subject: 'user:alice', - }) -); +`Runtime.open()` is the production composition root. It constructs and owns the +default history and artifact adapters internally. Application code does not +construct Git plumbing, git-cas, cache, retention, or graph persistence +adapters. -const write = await timeline.write( - intent.property.set({ - subject: 'user:alice', - key: 'role', - value: 'admin', - }) -); +Internal architecture keeps those concerns behind semantic ports such as +`HistoryPort` and `ArtifactPort`. Test-specific dependency injection belongs on +the testing surface, not in the first-use constructor. -switch (write.outcome) { - case 'accepted': - console.log(write.evidence?.basis.id); - break; - case 'obstructed': - case 'conflicted': - case 'underdetermined': - case 'rejected': - console.log(write.reason ?? write.outcome); - break; -} +### Lifecycle -const role = await timeline.read( - reading.property({ - subject: 'user:alice', - key: 'role', - }) -); +The introductory contract for `Runtime.close()` is exactly: -if (role.receipt.outcome === 'accepted') { - console.log(role.value); - console.log(role.receipt.evidence); -} else { - console.log(role.receipt.reason, role.receipt.repairHints); -} +> Releases local resources only. + +In particular, `close()` does not delete lanes, rewrite history, revoke +receipts, remove retained artifacts, or perform causal admission. It stops new +local work, allows already admitted operations to reach a defined terminal +state, and releases process-owned resources. Repeated close requests are +idempotent. + +## Lanes + +`Lane` replaces `Timeline` in public vocabulary. A timeline suggests a total +order. A lane permits independent progression, concurrency, strands, and +worldlines without claiming that all history is globally linear. + +A lane has exactly one kind: + +```typescript +type LaneDescriptor = + | { + kind: 'worldline'; + name: string; + } + | { + kind: 'strand'; + name: string; + parent: LaneReference; + forkedAt: CoordinateReference; + }; ``` -The read path should return a result object, not a naked value. Provenance is -the normal path, not an afterthought: +The implementation must use runtime-backed lane classes or descriptors that +enforce this invariant. It must not model kind using overlapping booleans such +as `speculative`, `draft`, or `canonical`. A "speculative worldline" is not a +valid state. + +Single-lane operations live on `Lane`. Cross-lane operations live on `Runtime`. ```typescript -const result = await timeline.read( - reading.property({ +const events = await runtime.lane('events'); +const draft = await runtime.fork(events, { name: 'try-admin-role' }); +``` + +## Intents And Generated SDKs + +Application authors should normally use Wesley-generated domain SDKs: + +```typescript +import { users } from './generated/users.js'; + +const receipt = await events.write( + users.intents.assignRole({ subject: 'user:alice', - key: 'role', + role: 'admin', }) ); - -result.value; -result.receipt; ``` -A convenience method such as `readValue()` can exist, but it should be clearly -documented as the provenance-light path. +Generated builders return validated runtime-backed `Intent` values. They do +not return loose JSON envelopes that rely on conventions for correctness. + +Generic and graph-shaped builders may exist on explicit expert surfaces for +migration, tooling, and schema authorship. They are not the root tutorial. -## Opaque Evidence +## Observers And Optics -Receipts expose storage-neutral evidence handles: +Generated application code names reusable plans as observers: ```typescript -receipt.evidence?.basis.id; -receipt.evidence?.support.map((handle) => handle.id); -receipt.evidence?.tick; +const observer = users.observers.roleOf({ + subject: 'user:alice', +}); ``` -`basis` identifies the causal basis used for the operation. `support` contains -opaque handles for the supporting objects; the same support has the same ID -across write, preview, join, and read receipts, so callers can correlate it -without learning a substrate identifier. A historical read also carries the -exact public `Tick` used for that read. Live reads do not invent a historical -tick. +An Observer contains or references the formal machinery needed to execute the +request honestly: + +```text +Generated Observer + contains an Optic + declares an Aperture and bounds + requires Capabilities + obeys a Law +``` -Evidence IDs are deliberately opaque. Do not parse them. Operator code that -needs exact substrate provenance must call -`inspectReceipt(receipt, { storage })` from the diagnostics subpath with the -same storage handle that issued the receipt. +Application code asks to observe a role. It does not need to assemble the +optic, aperture, support plan, retention contract, and capability declaration +manually. `Optic` remains public for advanced composition, diagnostics, and +generated-code infrastructure without becoming first-use vocabulary. -## Intent Builders +## Observation Execution -Generic intent envelopes are useful, but they should not be the only happy -path. This is too easy to turn into typed JSON by convention: +`Lane.observe()` is synchronous. It performs only local construction and +invariant validation, then returns a dormant `Observation`: ```typescript -intent({ - type: 'user.role.assign', - subject: 'user:alice', - payload: { role: 'admin' }, -}); +const observation = events.observe(observer); ``` -The root should ship semantic builders for common causal writes: +An Observation starts when any of the following first demands execution: -```typescript -intent.property.set({ - subject: 'user:alice', - key: 'role', - value: 'admin', -}); +1. Its async iterator is advanced. +2. A lawful convenience consumer is invoked. +3. Its `receipt` is awaited. -intent.node.add({ - subject: 'user:alice', -}); +All three paths join the same execution. They must never start duplicate +runtime work. -intent.edge.add({ - from: 'user:alice', - to: 'team:ops', - label: 'memberOf', -}); -``` +The first demand also selects Reading delivery: -Domain-specific builders can be layered by application code, but the root API -should keep returning runtime-backed `Intent` values: +- iteration or a convenience consumer becomes the sole Reading consumer; +- awaiting `receipt` first selects drain-and-discard delivery; +- awaiting `receipt` after a Reading consumer starts waits for that consumer's + execution and does not steal or duplicate Readings; +- attempting to attach a Reading consumer after drain-and-discard begins is a + typed local lifecycle error. ```typescript -function assignRole(subject: string, role: string) { - return intent.property.set({ - subject, - key: 'role', - value: role, - }); +const observation = events.observe(observer); + +for await (const reading of observation) { + console.log(reading.value); } -await timeline.write(assignRole('user:alice', 'admin')); +const receipt = await observation.receipt; ``` -The builder output should be a runtime-backed value, not loose shape trust. +Awaiting `observation.receipt` without an existing Reading consumer starts the +operation and drains its readings with backpressure while discarding their +values. It must not collect the stream in memory. -## Readings And Optics +If no consumer advances the iterator, invokes a convenience consumer, or +awaits the receipt, the Observation remains dormant and owns no active runtime +operation. -The public API should lead with `reading`. +An Observation represents one causal operation, not a generic JavaScript +stream wrapper. Its contract owns: -```typescript -await timeline.read( - reading.property({ - subject: 'user:alice', - key: 'role', - }) -); -``` +- one execution identity; +- one declared or pinned basis; +- capability scope; +- resource budget; +- single-consumer reading delivery; +- cancellation and early-termination policy; +- terminal outcome; +- receipt production. -The formal model remains: +Operational uncertainty terminates through the receipt outcome. Immediate +throws are reserved for invalid local construction, corruption, violated +invariants, and implementation defects. -```text -A Reading is the bounded question. -An Optic is the formal execution and proof shape. +### Convenience Consumers + +Convenience consumers are capability-specific, not universal decorations on +every Observation: + +```typescript +await observation.one(); +await observation.first(); +await observation.all(); ``` -In v18, users often need to call `prepareOpticBasis()`, capture a coordinate, -and read through `optic()`. That discipline is correct, but it should be -internal ceremony for the first-use path. +Their semantics are strict: -The v19 `timeline.read(reading)` flow is the public shape. Advanced bounded -read paths can still lower readings to optics internally: +- `one()` proves that exactly one Reading was emitted. It is not an alias for + the first available result. +- `first()` is exposed only when the Observer law permits witnessed early + termination. +- `all()` is exposed only for finite Observers with a declared collection + bound and budget. -1. validate the `Reading`; -2. lower `Reading` to `Optic`; -3. check an optic basis without broad materialization; -4. capture the observer position; -5. execute the bounded read; -6. return `ReadingResult` and `ReadReceipt`. +The base v19 Observation contract does not promise all three methods. The +runtime and generated SDK expose only the consumers whose termination and +cardinality semantics they can represent honestly. Cardinality or budget +failure is typed operational failure and is recorded in the terminal Receipt. -Operational uncertainty should return receipt outcomes where possible. -Programmer errors, corruption, impossible states, and violated invariants may -still throw typed errors. +Stopping ordinary async iteration early follows the Observer's declared +cancellation policy. It must not silently turn a partial reading set into a +successful complete Observation. -## Receipts As Control Flow +## Readings -Receipts should become the normal control-flow object. +The canonical application property is `Reading.value`: ```typescript -const receipt = await timeline.write(assignRole('user:alice', 'admin')); - -switch (receipt.outcome) { - case 'accepted': - console.log(receipt.evidence?.basis.id); - break; - case 'obstructed': - case 'conflicted': - case 'underdetermined': - case 'rejected': - console.log(receipt.reason ?? receipt.outcome); - break; +for await (const reading of observation) { + consume(reading.value); } ``` -The receipt outcome axis should stay clean: +`payload` is reserved for encoded transport envelopes and adapter-level bytes. +A public Reading is a typed semantic result, not a packet. -```text -accepted -obstructed -conflicted -underdetermined -rejected +Conceptually, a Reading carries: + +```typescript +interface Reading { + readonly value: TValue; + readonly coordinate: Coordinate; + readonly support: SupportReport; + readonly witnessRefs: readonly WitnessReference[]; +} ``` -Do not mix operation types into outcomes. `joined`, `synced`, `staged`, and -`materialized` describe operations or states, not the outcome axis. +The concrete implementation must use runtime-backed domain objects rather than +trusting this illustrative interface shape. Introductory code needs only +`value`; coordinate, support, and witness references are progressively +disclosed when applications need provenance or formal inspection. -Use separate result classes (`WriteReceipt`, `ReadReceipt`, and later join -receipts), not one overloaded status string. +A Reading may contain a scalar, document, file tree, graph chart, debugger +snapshot, computed status, or another bounded materialized projection. `Chart` +is therefore an interpretation or subtype of Reading, not its replacement. -## Timelines, Drafts, And Joins +`ObservationPage` is not a public causal noun. Paging and batching are +transport framing. -Use `Timeline` publicly. +## Outcomes, Evidence, And Receipts -Internally: +The operational outcome algebra is exactly: ```text -Timeline -> public handle -Worldline -> committed causal history -Strand -> speculative lane -Braid -> deterministic lane composition +derived +plural +conflict +obstruction ``` -Public speculative work reads like this: - -```typescript -const draft = await timeline.draft('try-admin-role'); - -await draft.write( - intent.property.set({ - subject: 'user:alice', - key: 'role', - value: 'admin', - }) -); - -const preview = await timeline.previewJoin(draft, { - policy: 'deterministic', -}); +The operation axis remains separate: -if (preview.receipt.outcome === 'accepted') { - const joined = await timeline.join(draft); - console.log(joined.receipt); -} +```text +write +observe +settle +fork +sync ``` -Use `previewJoin()`, not `join({ dryRun: true })`. A dedicated preview method -avoids boolean-trap API design and makes the two phases explicit. +Epistemic support remains separate from both. A derived operation does not by +itself prove that an application claim is supported, and a supported claim does +not change a conflict into a derived operation. -`Braid` remains the expert term. Root users should see `join`. +Introductory documentation defines Receipt simply: -## Tick And Coordinate +> A receipt records what the runtime did. -Future ergonomic public time travel should use `Tick`: +The witness ladder, reintegration core, hologram references, translated +evidence, and residual support structure belong in application-author or +formal documentation. -```typescript -const tick = await timeline.tick(); +Human rendering visually separates operational and epistemic fields: -const roleAtTick = await timeline.at(tick).read( - reading.property({ - subject: 'user:alice', - key: 'role', - }) -); +```text +Receipt +------------------------------ +Operation write +Outcome derived +Lane events +Coordinate @842 + +Evidence +Witness retained (native) +Support supported +Residuals none ``` -Use `Coordinate` for formal evidence and proof posture on advanced surfaces. -Do not make first-use application examples import coordinate machinery. +A bare `Witness yes` or checkmark is insufficient because retained, native, +translated, and verified evidence are different claims. CLI text and custom +runtime inspection use the same renderer. `--json` and `--jsonl` emit canonical +machine-readable envelopes. + +## Settlement + +Settlement is a cross-lane Runtime operation. Preview presentation and the +admissible plan are distinct values: ```typescript -import { captureCoordinate } from '@git-stunts/git-warp/advanced'; +const preview = await runtime.previewSettlement({ + source: draft, + target: events, +}); -const coordinate = await captureCoordinate(timeline); -``` +inspect(preview); -```text -public: tick handle -advanced: coordinate evidence +const receipt = await runtime.settle(preview.plan); ``` -That split lets casual users keep a simple handle while advanced users and -receipts retain the exact observer position. +`SettlementPreview` is inspectable presentation plus evidence. Its `plan` is an +immutable, runtime-backed `SettlementPlan`. `Runtime.settle()` accepts only a +validated SettlementPlan, never an arbitrary object that resembles preview +output. -## What Leaves Root +Settlement revalidates the plan against the current source and target +frontiers. A stale or newly obstructed plan returns the appropriate Receipt; a +preview is not a promise that later settlement must derive. -The v19 root should not export graph substrate. +Use `previewSettlement()`, not `settle({ dryRun: true })`. Do not use `merge()` +as the first-use verb. Braid remains the formal implementation noun until the +runtime can honestly expose common-basis braid validation. -Remove from root: +## Derived Graph Charts -```text -GraphNode -GraphPersistencePort -GitGraphAdapter -InMemoryGraphAdapter -GraphOpAlgebraProjection -GraphDiff -openWarpGraph -PatchBuilder -PatchSession -createNodeAdd -createEdgeAdd -createPropSet -publicGraphSubstrate -``` +There is no `@git-stunts/git-warp/graph` public package. -Use explicit subpaths: +Graph-shaped observations live under the derived-view package: -```text -@git-stunts/git-warp/storage -@git-stunts/git-warp/advanced -@git-stunts/git-warp/diagnostics +```typescript +import { graph } from '@git-stunts/git-warp/charts'; + +const observation = events.observe( + graph.neighborhood({ + around: 'user:alice', + depth: 2, + }) +); ``` -The boundaries mean different things: +This surface may expose node, edge, neighborhood, topology, and graph-diff +Observers. It must describe their results as charts or readings, not as the +durable territory or a mutable graph store. -| Surface | Meaning | -| ------------- | --------------------------------------------------------- | -| Root | first-use product API | -| `storage` | supported opaque storage constructors | -| `advanced` | bounded coordinate capture, `Optic`, and `Witness` access | -| `diagnostics` | receipt inspection | +`/charts` is absent from the first-use README path. It exists for users who +actually need graph-shaped correlation and coordination. -Do not turn `advanced` into a junk drawer. Symbols that exist only for removed -graph-first consumers are not part of the v19 package boundary. +## Supported Package Surfaces -## Migration Map +The intended package families are: -Each old root symbol needs one explicit disposition: +| Surface | Role | +| --- | --- | +| root | `Runtime` plus type-only core contracts | +| `/charts` | Derived graph-shaped Observers and Readings | +| `/diagnostics` | `doctor`, repair planning, audit, and Receipt inspection | +| `/advanced` | Optics, Coordinates, Witnesses, Holograms, and formal composition | +| `/testing` | Explicit fake ports, fixtures, and Runtime harnesses | -| v18 root symbol | v19 disposition | -| ------------------------ | ---------------------------------------------------------- | -| `openWarpWorldline()` | `openWarp().timeline(name)` | -| `GitGraphAdapter` | `GitStorage.open({ cwd })` from `storage` | -| `InMemoryGraphAdapter` | removed; use `GitStorage` with a temporary repository | -| `commit((patch) => ...)` | `timeline.write(intent.*)` | -| `coordinate()` | `tick()` publicly; use advanced `captureCoordinate()` | -| `optic()` | `timeline.read(reading.*)` or `advanced` | -| `openWarpGraph()` | removed; replace diagnostics with explicit diagnostic APIs | -| `PatchBuilder` | removed; use intent builders | -| `GraphDiff` | removed until a public-handle comparison API exists | -| graph op creators | removed; use intent builders | +`/advanced` is not a holding area for everything removed from root. Legacy +graph-first APIs are removed rather than hidden under a new public contract. -The compatibility story should be honest: +WARP DRIVE, WARP-TTD, offline bundles, and other ecosystem products are not +advertised as shipped git-warp surfaces until their implementations and +conformance evidence exist. + +## CLI Grammar + +The CLI follows the same verbs and nouns: ```text -Root is clean. -Graph-first compatibility exports are gone. -Diagnostics are for operators. -Advanced is for formal WARP work. +git warp write +git warp observe +git warp fork +git warp settle preview +git warp settle apply +git warp receipt show +git warp doctor +git warp repair +git warp audit ``` -## Non-Goals +Each CLI invocation opens and closes local Runtime resources internally. CLI +help does not teach sessions, Git adapters, OIDs, graph stores, or cache +management for ordinary operations. + +Human output defaults to the shared Reading and Receipt renderers. `--json` +emits one canonical envelope; streaming commands support `--jsonl` without +renaming transport batches as pages. -Do not remove internal graph mechanics from the runtime in this API cut. The -runtime can still use graph-shaped storage and graph-shaped diagnostic -readings. The v19 line is about the public package boundary. +## MCP Grammar -Do not call receipts proofs unless the runtime really proves the claimed -relation. Prefer: +MCP exposes the same model through tools and resources rather than inventing a +second ontology. The target capability families are: ```text -receipt -evidence -witness -support +warp_lane_describe +warp_intent_write +warp_observation_start +warp_observation_read +warp_observation_cancel +warp_receipt_get +warp_settlement_preview +warp_settlement_apply +warp_doctor +warp_repair +warp_audit ``` -Reserve stronger words such as `proof`, `verified`, and `guaranteed` for -relations the implementation actually verifies. +Observation tools exchange Observation identities, Readings, terminal state, +and Receipt references. MCP cursors and batches are transport details. They do +not introduce public nouns such as `ObservationPage` or `QueryResultPage`. -Do not make users learn `Hologram` in the root API. Hologram is architecture -vocabulary. Receipt is product vocabulary. +Wesley may generate domain-specific MCP tools and schemas from the same +Observer and Intent definitions used by TypeScript SDKs. Generated descriptions +must use this vocabulary. -## Design Rule +## Vocabulary Conformance -The architecture can stay exact. The package boundary must be humane. +The accepted vocabulary should become one generated contract shared by: -The root API should make the unusual machinery feel inevitable: +- TypeDoc summaries; +- CLI command descriptions; +- MCP tool and resource descriptions; +- Wesley-generated SDK documentation; +- glossary pages; +- schema descriptions; +- public error messages; +- API and documentation boundary tests. + +The implementation should define the registry once in a deterministic Wesley +or GraphQL source and generate downstream artifacts. Hand-maintained duplicate +word lists are not the target architecture. + +Public-surface lint should reject these legacy terms outside explicitly marked +migration, substrate, or formal documentation: ```text -Write intents. -Read timelines. -Keep receipts. +timeline +merge +graph store +generic event +OID +dry run +session +query result page ``` + +The gate must be AST- or schema-aware. It must not reject legitimate prose in +migration tables, Git substrate diagnostics, quoted legacy API names, or +formal explanations. + +## Migration Direction + +The superseded pre-checkpoint v19 facade has these dispositions: + +| Transitional v19 symbol | Canonical disposition | +| --- | --- | +| `openWarp()` | `Runtime.open()` | +| `Warp` | `Runtime` | +| `Timeline` | `Lane` | +| `DraftTimeline` | `Lane` with `kind: 'strand'` | +| `timeline.read(reading)` | `lane.observe(observer)` | +| root `reading` builders | Wesley-generated `*.observers` or `/charts` | +| root `intent` builders | Wesley-generated `*.intents` | +| `previewJoin()` | `Runtime.previewSettlement()` | +| `join()` | `Runtime.settle(plan)` | +| `/storage` constructors | internal Runtime composition; testing injection under `/testing` | +| graph package proposals | `/charts` | + +The v18 graph-first API remains removed. This checkpoint does not revive +`browser`, `legacy`, `openWarpGraph`, `WarpApp`, `WarpCore`, patch builders, or +public Git adapters. + +## Acceptance Gates + +The checkpoint is implemented only when executable evidence proves all of the +following: + +1. Root runtime values contain exactly `Runtime`. +2. Ordinary open, close, lane, write, observe, and receipt workflows compile + without importing Git, storage, graph, or formal nouns. +3. Lane kinds are mutually exclusive runtime truths. +4. Generated Intent and Observer values are validated domain objects. +5. `Lane.observe()` is synchronous and all demand paths share one execution. +6. Awaiting a Receipt drains without materializing Reading streams. +7. `Reading.value` is canonical across SDK, CLI JSON, MCP, fixtures, and + serialized envelopes. +8. Outcome and support algebras cannot alias one another. +9. Settlement accepts only immutable validated plans and revalidates them. +10. `/graph` is absent and `/charts` is tested as a derived-view surface. +11. CLI and MCP capability names conform to the same vocabulary. +12. Legacy vocabulary is rejected from public surfaces with explicit migration + and substrate exceptions. + +Until those gates pass, this document is a frozen target and the v19 public API +goalpost remains open. diff --git a/docs/topics/getting-started.md b/docs/topics/getting-started.md index 0add454c..5ecc9ddf 100644 --- a/docs/topics/getting-started.md +++ b/docs/topics/getting-started.md @@ -1,6 +1,12 @@ -# Getting Started +# Getting Started With The Transitional Facade -The v19 application API opens timelines, writes intents, and returns receipts. +This page documents the currently implemented transitional v19 facade. The +accepted [v19 public vocabulary checkpoint](api/) replaces timelines and +direct readings with Runtime, Lane, Observer, Observation, Reading, and Receipt +before release. This page remains runtime-honest while that implementation is +in progress; it is not the final v19 API contract. + +The transitional facade opens timelines, writes intents, and returns receipts. It does not expose the graph-first compatibility API from earlier releases. ## Install diff --git a/policy/quarantines/0025A-casts.json b/policy/quarantines/0025A-casts.json index ec6e3fe3..7d672d9c 100644 --- a/policy/quarantines/0025A-casts.json +++ b/policy/quarantines/0025A-casts.json @@ -6,9 +6,7 @@ "ts-no-double-cast" ], "rationale": "Pre-existing `as unknown as` uses discovered at anti-sludge policy adoption time. Each entry grants file-level exemption from ts-no-double-cast ONLY. Graduate by removing the cast (prefer decoder / type guard / narrower port), not by adding an inline suppression unless the specific line has a documented reason that cannot be refactored away.", - "generated_at": "2026-06-28T19:29:18.370Z", + "generated_at": "2026-07-15T20:35:31.098Z", "generator": "scripts/contamination-map.ts v1", - "files": [ - "src/domain/services/controllers/IntentController.ts" - ] + "files": [] } diff --git a/policy/quarantines/0025B-boundary.json b/policy/quarantines/0025B-boundary.json index 2846d066..a992472b 100644 --- a/policy/quarantines/0025B-boundary.json +++ b/policy/quarantines/0025B-boundary.json @@ -11,10 +11,7 @@ "ts-no-process-env-in-core" ], "rationale": "Pre-existing raw-shape or I/O leaks in core (src/domain/** and src/ports/**). Root cause: the boundary did not decode into a runtime-backed domain type. Graduate by moving decode/encode to src/infrastructure/adapters/** and by introducing domain types for what was previously passed as `unknown` or `Record`.", - "generated_at": "2026-06-28T19:29:18.370Z", + "generated_at": "2026-07-15T20:35:31.098Z", "generator": "scripts/contamination-map.ts v1", - "files": [ - "src/domain/services/controllers/IntentController.ts", - "src/domain/types/WarpIntentDescriptor.ts" - ] + "files": [] } diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index 9fdcf763..0fe602e9 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -14,6 +14,18 @@ if [ -z "$ROOT" ]; then fi cd "$ROOT" +clear_git_repository_environment() { + git_local_env_vars="$(git rev-parse --local-env-vars 2>/dev/null)" + for git_local_env_var in $git_local_env_vars; do + unset "$git_local_env_var" + done +} + +# Git exports repository-local routing variables to hooks. Nested Git commands +# in tests must discover their own repositories instead of inheriting this one. +clear_git_repository_environment +echo "pre-push: cleared inherited Git repository environment" + command_exists() { launcher="$1" cmd="$2" diff --git a/scripts/migrations/v17.0.0/CheckpointSchemaUpgradeError.ts b/scripts/migrations/v17.0.0/CheckpointSchemaUpgradeError.ts new file mode 100644 index 00000000..eaacc389 --- /dev/null +++ b/scripts/migrations/v17.0.0/CheckpointSchemaUpgradeError.ts @@ -0,0 +1,7 @@ +/** Signals that a checkpoint cannot be migrated without losing causal data. */ +export default class CheckpointSchemaUpgradeError extends Error { + constructor(message: string) { + super(message); + this.name = 'CheckpointSchemaUpgradeError'; + } +} diff --git a/scripts/migrations/v17.0.0/LegacyCheckpointStorageReader.ts b/scripts/migrations/v17.0.0/LegacyCheckpointStorageReader.ts new file mode 100644 index 00000000..893cdb84 --- /dev/null +++ b/scripts/migrations/v17.0.0/LegacyCheckpointStorageReader.ts @@ -0,0 +1,116 @@ +import LegacyCheckpointArtifactAdapter from '../../../src/infrastructure/adapters/LegacyCheckpointArtifactAdapter.ts'; +import type AssetStoragePort from '../../../src/ports/AssetStoragePort.ts'; +import type CheckpointStorePort from '../../../src/ports/CheckpointStorePort.ts'; +import { + CHECKPOINT_STORAGE_FORMAT, + LEGACY_CHECKPOINT_STORAGE_FORMAT, + type CheckpointCommitMessage, +} from '../../../src/ports/CommitMessageCodecPort.ts'; +import { + type CheckpointMigrationHistory, + type CheckpointUpgradePayload, +} from './checkpoint-schema-upgrade.ts'; +import CheckpointSchemaUpgradeError from './CheckpointSchemaUpgradeError.ts'; + +/** Recovers schema-5 checkpoint payloads before v19 bundle republication. */ +export default class LegacyCheckpointStorageReader { + readonly #persistence: CheckpointMigrationHistory; + readonly #checkpointStore: CheckpointStorePort; + readonly #artifacts: LegacyCheckpointArtifactAdapter; + readonly #graphName: string; + + constructor(options: { + readonly persistence: CheckpointMigrationHistory; + readonly checkpointStore: CheckpointStorePort; + readonly assetStorage: AssetStoragePort; + readonly graphName: string; + }) { + this.#persistence = options.persistence; + this.#checkpointStore = options.checkpointStore; + this.#graphName = options.graphName; + this.#artifacts = new LegacyCheckpointArtifactAdapter({ + history: options.persistence, + assets: options.assetStorage, + }); + } + + async load(checkpointSha: string): Promise { + const loaded = await this.#checkpointStore.loadCheckpoint(checkpointSha, this.#graphName); + const rootTreeOid = await this.#persistence.getCommitTree(checkpointSha); + const rawTreeOids = await this.#persistence.readTreeOids(rootTreeOid); + const indexShardOids = await this.#readIndexShardOids(rawTreeOids); + const indexTree = await this.#readIndexTree(indexShardOids); + return { + state: loaded.state, + frontier: loaded.frontier, + ...(indexTree === undefined ? {} : { indexTree }), + ...(loaded.provenanceIndex === null || loaded.provenanceIndex === undefined + ? {} + : { provenanceIndex: loaded.provenanceIndex }), + }; + } + + async #readIndexShardOids( + rawTreeOids: Readonly>, + ): Promise> { + const flattened = Object.fromEntries( + Object.entries(rawTreeOids) + .filter(([path]) => path.startsWith('index/')) + .map(([path, oid]) => [path.slice('index/'.length), oid]), + ); + if (Object.keys(flattened).length > 0 || rawTreeOids['index'] === undefined) { + return flattened; + } + return await this.#persistence.readTreeOids(rawTreeOids['index']); + } + + async #readIndexTree( + indexShardOids: Readonly>, + ): Promise | undefined> { + const paths = Object.keys(indexShardOids).sort(); + if (paths.length === 0) { + return undefined; + } + const indexTree: Record = {}; + for (const path of paths) { + const oid = indexShardOids[path]; + if (oid === undefined || path.length === 0) { + throw new CheckpointSchemaUpgradeError( + `Invalid legacy checkpoint index member: ${path || '(empty)'}`, + ); + } + indexTree[path] = await this.#artifacts.read(oid); + } + return indexTree; + } +} + +export function hasCurrentCheckpointStorage(message: CheckpointCommitMessage): boolean { + return message.checkpointVersion === CHECKPOINT_STORAGE_FORMAT + && message.bundleHandle !== null; +} + +export function requireMigratableLegacyStorage( + checkpointSha: string, + message: CheckpointCommitMessage, +): void { + if (message.checkpointVersion === CHECKPOINT_STORAGE_FORMAT) { + throw new CheckpointSchemaUpgradeError( + `Checkpoint ${checkpointSha} declares ${CHECKPOINT_STORAGE_FORMAT} storage ` + + 'but has no bundle handle; refusing to reinterpret a malformed current checkpoint.', + ); + } + if (message.bundleHandle !== null) { + throw new CheckpointSchemaUpgradeError( + `Checkpoint ${checkpointSha} carries a bundle handle under unsupported storage ` + + `${message.checkpointVersion ?? '(unspecified)'}.`, + ); + } + if (message.checkpointVersion !== null + && message.checkpointVersion !== LEGACY_CHECKPOINT_STORAGE_FORMAT) { + throw new CheckpointSchemaUpgradeError( + `Checkpoint ${checkpointSha} uses unsupported storage ` + + `${message.checkpointVersion}; refusing to reinterpret it as legacy storage.`, + ); + } +} diff --git a/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts b/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts index 6b757b99..e65aeaf8 100644 --- a/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts +++ b/scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts @@ -1,8 +1,10 @@ import SubstrateCompatibilityPolicy from '../../../src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts'; export const V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY = new SubstrateCompatibilityPolicy({ + legacyAuditReceiptTreeReads: true, legacyContentBlobReads: true, legacyInlinePayloadReads: true, legacyPatchStorageReads: true, + legacyStrandDescriptorBlobReads: true, legacyTrustRecordBlobReads: true, }); diff --git a/scripts/migrations/v17.0.0/checkpoint-schema-upgrade.ts b/scripts/migrations/v17.0.0/checkpoint-schema-upgrade.ts index 5f8e0cad..8198c810 100644 --- a/scripts/migrations/v17.0.0/checkpoint-schema-upgrade.ts +++ b/scripts/migrations/v17.0.0/checkpoint-schema-upgrade.ts @@ -3,7 +3,6 @@ import { loadCheckpoint } from '../../../src/domain/services/state/checkpointLoa import { CURRENT_CHECKPOINT_SCHEMA, isCurrentCheckpointSchema, - partitionTreeOids, } from '../../../src/domain/services/state/checkpointHelpers.ts'; import { deserializeFullState, @@ -13,29 +12,45 @@ import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapte import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; import { buildCheckpointRef } from '../../../src/domain/utils/RefLayout.ts'; import { ProvenanceIndex } from '../../../src/domain/services/provenance/ProvenanceIndex.ts'; -import type GraphPersistencePort from '../../../src/ports/GraphPersistencePort.ts'; +import type AssetStoragePort from '../../../src/ports/AssetStoragePort.ts'; import type CodecPort from '../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../src/ports/CommitMessageCodecPort.ts'; +import { + CHECKPOINT_STORAGE_FORMAT, + type default as CommitMessageCodecPort, +} from '../../../src/ports/CommitMessageCodecPort.ts'; import type CryptoPort from '../../../src/ports/CryptoPort.ts'; +import type CheckpointStorePort from '../../../src/ports/CheckpointStorePort.ts'; +import LegacyCheckpointStorageReader, { + hasCurrentCheckpointStorage, + requireMigratableLegacyStorage, +} from './LegacyCheckpointStorageReader.ts'; +import CheckpointSchemaUpgradeError from './CheckpointSchemaUpgradeError.ts'; + +export { default as CheckpointSchemaUpgradeError } from './CheckpointSchemaUpgradeError.ts'; const RETIRED_CHECKPOINT_SCHEMAS = [2, 3, 4] as const; type UpgradeStatus = 'missing-checkpoint' | 'already-current' | 'would-upgrade' | 'upgraded'; -export class CheckpointSchemaUpgradeError extends Error { - constructor(message: string) { - super(message); - this.name = 'CheckpointSchemaUpgradeError'; - } +/** Legacy Git history surface required only by the retired-checkpoint migrator. */ +export interface CheckpointMigrationHistory { + readBlob(oid: string): Promise; + readTreeOids(treeOid: string): Promise>; + showNode(sha: string): Promise; + getNodeInfo(sha: string): Promise<{ readonly parents: string[] }>; + getCommitTree(sha: string): Promise; + readRef(ref: string): Promise; } export interface CheckpointSchemaUpgradeOptions { - readonly persistence: GraphPersistencePort; + readonly persistence: CheckpointMigrationHistory; readonly graphName: string; readonly dryRun?: boolean; readonly codec?: CodecPort; readonly commitMessageCodec?: CommitMessageCodecPort; readonly crypto?: CryptoPort; + readonly checkpointStore: CheckpointStorePort; + readonly assetStorage: AssetStoragePort; } export interface CheckpointSchemaUpgradeResult { @@ -46,9 +61,11 @@ export interface CheckpointSchemaUpgradeResult { readonly upgradedCheckpointSha: string | null; readonly previousSchema: number | null; readonly currentSchema: number; + readonly previousStorageVersion: string | null; + readonly currentStorageVersion: string; } -interface RetiredCheckpointPayload { +export interface CheckpointUpgradePayload { readonly state: ReturnType; readonly frontier: Map; readonly indexTree?: Record; @@ -60,6 +77,7 @@ export async function upgradeCheckpointSchema( ): Promise { const commitMessageCodec = options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC; const codec = options.codec ?? defaultCodec; + const checkpointStore = options.checkpointStore; const checkpointRef = buildCheckpointRef(options.graphName); const previousCheckpointSha = await options.persistence.readRef(checkpointRef); @@ -72,6 +90,8 @@ export async function upgradeCheckpointSchema( upgradedCheckpointSha: null, previousSchema: null, currentSchema: CURRENT_CHECKPOINT_SCHEMA, + previousStorageVersion: null, + currentStorageVersion: CHECKPOINT_STORAGE_FORMAT, }; } @@ -79,7 +99,9 @@ export async function upgradeCheckpointSchema( await options.persistence.showNode(previousCheckpointSha), ); - if (isCurrentCheckpointSchema(checkpointMessage.schema)) { + if (isCurrentCheckpointSchema(checkpointMessage.schema) + && hasCurrentCheckpointStorage(checkpointMessage)) { + await loadCheckpoint(checkpointStore, previousCheckpointSha, options.graphName); return { status: 'already-current', graphName: options.graphName, @@ -88,22 +110,33 @@ export async function upgradeCheckpointSchema( upgradedCheckpointSha: previousCheckpointSha, previousSchema: checkpointMessage.schema, currentSchema: CURRENT_CHECKPOINT_SCHEMA, + previousStorageVersion: checkpointMessage.checkpointVersion, + currentStorageVersion: CHECKPOINT_STORAGE_FORMAT, }; } - if (!isRetiredCheckpointSchema(checkpointMessage.schema)) { + if (isCurrentCheckpointSchema(checkpointMessage.schema)) { + requireMigratableLegacyStorage(previousCheckpointSha, checkpointMessage); + } else if (!isRetiredCheckpointSchema(checkpointMessage.schema)) { throw new CheckpointSchemaUpgradeError( `Checkpoint ${previousCheckpointSha} uses unsupported schema:${checkpointMessage.schema}. ` + `This migration can upgrade retired schemas ${RETIRED_CHECKPOINT_SCHEMAS.join(', ')} only.`, ); } - const retiredPayload = await loadRetiredCheckpointPayload({ - persistence: options.persistence, - indexOid: checkpointMessage.indexOid, - checkpointSha: previousCheckpointSha, - codec, - }); + const payload = isCurrentCheckpointSchema(checkpointMessage.schema) + ? await new LegacyCheckpointStorageReader({ + persistence: options.persistence, + checkpointStore, + assetStorage: options.assetStorage, + graphName: options.graphName, + }).load(previousCheckpointSha) + : await loadRetiredCheckpointPayload({ + persistence: options.persistence, + rootTreeOid: await options.persistence.getCommitTree(previousCheckpointSha), + checkpointSha: previousCheckpointSha, + codec, + }); if (options.dryRun === true) { return { @@ -114,27 +147,25 @@ export async function upgradeCheckpointSchema( upgradedCheckpointSha: null, previousSchema: checkpointMessage.schema, currentSchema: CURRENT_CHECKPOINT_SCHEMA, + previousStorageVersion: checkpointMessage.checkpointVersion, + currentStorageVersion: CHECKPOINT_STORAGE_FORMAT, }; } const upgradedCheckpointSha = await createCheckpointEnvelope({ - persistence: options.persistence, + checkpointStore, graphName: options.graphName, - state: retiredPayload.state, - frontier: retiredPayload.frontier, - parents: [previousCheckpointSha], - commitMessageCodec, + state: payload.state, + frontier: payload.frontier, + parents: (await options.persistence.getNodeInfo(previousCheckpointSha)).parents, + expectedCheckpointSha: previousCheckpointSha, codec, ...(options.crypto === undefined ? {} : { crypto: options.crypto }), - ...(retiredPayload.indexTree === undefined ? {} : { indexTree: retiredPayload.indexTree }), - ...(retiredPayload.provenanceIndex === undefined ? {} : { provenanceIndex: retiredPayload.provenanceIndex }), + ...(payload.indexTree === undefined ? {} : { indexTree: payload.indexTree }), + ...(payload.provenanceIndex === undefined ? {} : { provenanceIndex: payload.provenanceIndex }), }); - await loadCheckpoint(options.persistence, upgradedCheckpointSha, { - commitMessageCodec, - codec, - }); - await options.persistence.updateRef(checkpointRef, upgradedCheckpointSha); + await loadCheckpoint(checkpointStore, upgradedCheckpointSha, options.graphName); return { status: 'upgraded', @@ -144,6 +175,8 @@ export async function upgradeCheckpointSchema( upgradedCheckpointSha, previousSchema: checkpointMessage.schema, currentSchema: CURRENT_CHECKPOINT_SCHEMA, + previousStorageVersion: checkpointMessage.checkpointVersion, + currentStorageVersion: CHECKPOINT_STORAGE_FORMAT, }; } @@ -151,13 +184,32 @@ function isRetiredCheckpointSchema(schema: number): schema is typeof RETIRED_CHE return RETIRED_CHECKPOINT_SCHEMAS.some((retiredSchema) => retiredSchema === schema); } +function partitionTreeOids(rawOids: Record): { + treeOids: Record; + indexShardOids: Record; +} { + const treeOids = new Map(); + const indexShardOids = new Map(); + for (const [path, oid] of Object.entries(rawOids)) { + if (path.startsWith('index/')) { + indexShardOids.set(path.slice('index/'.length), oid); + } else { + treeOids.set(path, oid); + } + } + return { + treeOids: Object.fromEntries(treeOids), + indexShardOids: Object.fromEntries(indexShardOids), + }; +} + async function loadRetiredCheckpointPayload(options: { - readonly persistence: GraphPersistencePort; - readonly indexOid: string; + readonly persistence: CheckpointMigrationHistory; + readonly rootTreeOid: string; readonly checkpointSha: string; readonly codec?: CodecPort; -}): Promise { - const rawTreeOids = await options.persistence.readTreeOids(options.indexOid); +}): Promise { + const rawTreeOids = await options.persistence.readTreeOids(options.rootTreeOid); const { treeOids, indexShardOids } = partitionTreeOids(rawTreeOids); const codecOpt = options.codec === undefined ? {} : { codec: options.codec }; @@ -166,7 +218,10 @@ async function loadRetiredCheckpointPayload(options: { const state = deserializeFullState(await options.persistence.readBlob(stateOid), codecOpt); const frontier = deserializeFrontier(await options.persistence.readBlob(frontierOid), codecOpt); - const indexTree = await readIndexTree(options.persistence, indexShardOids); + const indexTree = await readIndexTree( + indexShardOids, + async (oid) => await options.persistence.readBlob(oid), + ); const provenanceIndex = await readProvenanceIndex(options.persistence, treeOids, codecOpt); return { @@ -188,8 +243,8 @@ function requireTreeOid(checkpointSha: string, treeOids: Record, } async function readIndexTree( - persistence: GraphPersistencePort, indexShardOids: Record, + readArtifact: (oid: string) => Promise, ): Promise | undefined> { const paths = Object.keys(indexShardOids).sort(); if (paths.length === 0) { @@ -202,13 +257,16 @@ async function readIndexTree( if (oid === undefined) { throw new CheckpointSchemaUpgradeError(`Missing retired checkpoint index OID for ${path}`); } - indexTree[path] = await persistence.readBlob(oid); + if (path.length === 0) { + throw new CheckpointSchemaUpgradeError('Retired checkpoint index path is empty'); + } + indexTree[path] = await readArtifact(oid); } return indexTree; } async function readProvenanceIndex( - persistence: GraphPersistencePort, + persistence: CheckpointMigrationHistory, treeOids: Record, codecOpt: { readonly codec?: CodecPort }, ): Promise { diff --git a/scripts/migrations/v17.0.0/migrate.ts b/scripts/migrations/v17.0.0/migrate.ts index 17d98a45..f85493c8 100644 --- a/scripts/migrations/v17.0.0/migrate.ts +++ b/scripts/migrations/v17.0.0/migrate.ts @@ -17,6 +17,7 @@ import { upgradeCheckpointSchema, type CheckpointSchemaUpgradeResult, } from './checkpoint-schema-upgrade.ts'; +import { openCheckpointMigrationStore } from './openCheckpointMigrationStore.ts'; class MigrationCliArgumentError extends Error { constructor(message: string) { @@ -101,18 +102,22 @@ function formatHumanResult(result: CheckpointSchemaUpgradeResult): string { return `No checkpoint found for graph ${result.graphName}; nothing to upgrade.`; } if (result.status === 'already-current') { - return `Checkpoint ${result.previousCheckpointSha ?? '(none)'} is already schema:${result.currentSchema}.`; + return `Checkpoint ${result.previousCheckpointSha ?? '(none)'} is already ` + + `schema:${result.currentSchema} storage:${result.currentStorageVersion}.`; } if (result.status === 'would-upgrade') { return [ `Dry run: checkpoint ${result.previousCheckpointSha ?? '(none)'} can be upgraded.`, - `Would write schema:${result.currentSchema} checkpoint and leave ${result.checkpointRef} unchanged.`, + `Would write schema:${result.currentSchema} storage:${result.currentStorageVersion} ` + + `and leave ${result.checkpointRef} unchanged.`, ].join('\n'); } return [ `Upgraded graph ${result.graphName} checkpoint.`, - `Previous: ${result.previousCheckpointSha ?? '(none)'} schema:${String(result.previousSchema)}`, - `Current: ${result.upgradedCheckpointSha ?? '(none)'} schema:${result.currentSchema}`, + `Previous: ${result.previousCheckpointSha ?? '(none)'} schema:${String(result.previousSchema)} ` + + `storage:${result.previousStorageVersion ?? '(unspecified)'}`, + `Current: ${result.upgradedCheckpointSha ?? '(none)'} schema:${result.currentSchema} ` + + `storage:${result.currentStorageVersion}`, `Updated: ${result.checkpointRef}`, ].join('\n'); } @@ -124,13 +129,15 @@ async function run(): Promise { return; } - const { persistence } = await createPersistence(args.repo); + const { persistence, runtimeStorage } = await createPersistence(args.repo); const graphName = await resolveGraphName(persistence, args.graph); + const migrationStorage = await openCheckpointMigrationStore(runtimeStorage, graphName); const result = await upgradeCheckpointSchema({ persistence, graphName, dryRun: args.dryRun, crypto: new NodeCryptoAdapter(), + ...migrationStorage, }); if (args.json) { diff --git a/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts b/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts new file mode 100644 index 00000000..a731fdd1 --- /dev/null +++ b/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts @@ -0,0 +1,26 @@ +import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import type AssetStoragePort from '../../../src/ports/AssetStoragePort.ts'; +import type CheckpointStorePort from '../../../src/ports/CheckpointStorePort.ts'; +import type RuntimeStorageProviderPort from '../../../src/ports/RuntimeStorageProviderPort.ts'; + +export interface CheckpointMigrationStorage { + readonly checkpointStore: CheckpointStorePort; + readonly assetStorage: AssetStoragePort; +} + +/** Resolves current semantic storage for one legacy checkpoint migration. */ +export async function openCheckpointMigrationStore( + runtimeStorage: RuntimeStorageProviderPort, + graphName: string, +): Promise { + const services = await runtimeStorage.createRuntimeStorageServices({ + timelineName: graphName, + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); + return Object.freeze({ + checkpointStore: services.checkpoints, + assetStorage: services.content, + }); +} diff --git a/scripts/run-stable-unit-tests.ts b/scripts/run-stable-unit-tests.ts index 1313ef64..4b0b6cd3 100644 --- a/scripts/run-stable-unit-tests.ts +++ b/scripts/run-stable-unit-tests.ts @@ -86,6 +86,7 @@ const UNIT_SHARDS: readonly Shard[] = [ ...listRecursiveTestFiles('test/unit/domain/orset'), ...listRecursiveTestFiles('test/unit/domain/properties'), ...listRecursiveTestFiles('test/unit/domain/stream'), + ...listRecursiveTestFiles('test/unit/domain/storage'), ...listRecursiveTestFiles('test/unit/domain/tree'), ...listRecursiveTestFiles('test/unit/domain/trust'), ...listRecursiveTestFiles('test/unit/domain/types'), diff --git a/scripts/source-size-gate.ts b/scripts/source-size-gate.ts index 885d0e32..28e3220b 100644 --- a/scripts/source-size-gate.ts +++ b/scripts/source-size-gate.ts @@ -46,14 +46,10 @@ export const SOURCE_SIZE_RELAXATIONS = Object.freeze([ '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/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', @@ -62,7 +58,6 @@ export const SOURCE_SIZE_RELAXATIONS = Object.freeze([ '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', diff --git a/scripts/source-version-name-policy.ts b/scripts/source-version-name-policy.ts index 45a11ce7..d0756f67 100644 --- a/scripts/source-version-name-policy.ts +++ b/scripts/source-version-name-policy.ts @@ -71,6 +71,7 @@ const sourceVersionNameExceptions: readonly SourceVersionNameException[] = Objec '|warp-v[0-9]+', '|full-v[0-9]+', '|git-cas-cbor-patch-v[0-9]+', + '|git-cas-asset-patch-v[0-9]+', '|cbor-v[0-9]+', '|(?:whole|framed|convergent)-v[0-9]+', '|wesley\\.realization\\.manifest\\.v[0-9]+', diff --git a/scripts/upgrade-v16-to-v17.ts b/scripts/upgrade-v16-to-v17.ts index 3a340347..da7ba935 100644 --- a/scripts/upgrade-v16-to-v17.ts +++ b/scripts/upgrade-v16-to-v17.ts @@ -16,15 +16,16 @@ import CliJsonFormatterAdapter from '../src/infrastructure/adapters/CliJsonForma import { upgradeCheckpointSchema, type CheckpointSchemaUpgradeResult, + type CheckpointMigrationHistory, } from './migrations/v17.0.0/checkpoint-schema-upgrade.ts'; -import type GraphPersistencePort from '../src/ports/GraphPersistencePort.ts'; import type CryptoPort from '../src/ports/CryptoPort.ts'; +import type RuntimeStorageProviderPort from '../src/ports/RuntimeStorageProviderPort.ts'; +import { openCheckpointMigrationStore } from './migrations/v17.0.0/openCheckpointMigrationStore.ts'; const LEGACY_REBUILDABLE_CACHE_REF_SUFFIXES = [ '/coverage/head', '/seek-cache', ] as const; - type CacheRefAction = 'absent' | 'would-delete' | 'deleted'; export class V16ToV17UpgradeArgumentError extends Error { @@ -64,24 +65,26 @@ export interface CacheRefMigrationResult { readonly action: CacheRefAction; readonly previousOid: string | null; } - export interface GraphV16ToV17UpgradeResult { readonly graphName: string; readonly checkpoint: CheckpointSchemaUpgradeResult; readonly cacheRefs: readonly CacheRefMigrationResult[]; } - export interface V16ToV17UpgradeResult { readonly dryRun: boolean; readonly graphCount: number; readonly graphs: readonly GraphV16ToV17UpgradeResult[]; } - export interface V16ToV17UpgradeOptions { - readonly persistence: GraphPersistencePort; + readonly persistence: V16ToV17MigrationHistory; readonly graphNames: readonly string[]; readonly dryRun?: boolean; readonly crypto?: CryptoPort; + readonly runtimeStorage: RuntimeStorageProviderPort; +} +export interface V16ToV17MigrationHistory extends CheckpointMigrationHistory { + deleteRef(ref: string): Promise; + listRefs(prefix?: string): Promise; } function usage(): string { @@ -151,11 +154,13 @@ export async function upgradeV16ToV17( const graphs: GraphV16ToV17UpgradeResult[] = []; for (const graphName of options.graphNames) { + const migrationStorage = await openCheckpointMigrationStore(options.runtimeStorage, graphName); const checkpoint = await upgradeCheckpointSchema({ persistence: options.persistence, graphName, dryRun, crypto, + ...migrationStorage, }); const cacheRefs = await migrateRebuildableCacheRefs({ persistence: options.persistence, @@ -173,7 +178,7 @@ export async function upgradeV16ToV17( } async function migrateRebuildableCacheRefs(options: { - readonly persistence: GraphPersistencePort; + readonly persistence: V16ToV17MigrationHistory; readonly graphName: string; readonly dryRun: boolean; }): Promise { @@ -198,16 +203,12 @@ async function migrateRebuildableCacheRefs(options: { } function checkpointLine(checkpoint: CheckpointSchemaUpgradeResult): string { - if (checkpoint.status === 'missing-checkpoint') { - return `checkpoint: none found at ${checkpoint.checkpointRef}`; - } - if (checkpoint.status === 'already-current') { - return `checkpoint: already schema:${checkpoint.currentSchema}`; - } - if (checkpoint.status === 'would-upgrade') { - return `checkpoint: would upgrade schema:${String(checkpoint.previousSchema)} -> schema:${checkpoint.currentSchema}`; - } - return `checkpoint: upgraded schema:${String(checkpoint.previousSchema)} -> schema:${checkpoint.currentSchema}`; + if (checkpoint.status === 'missing-checkpoint') return `checkpoint: none found at ${checkpoint.checkpointRef}`; + if (checkpoint.status === 'already-current') return `checkpoint: already schema:${checkpoint.currentSchema} storage:${checkpoint.currentStorageVersion}`; + const action = checkpoint.status === 'would-upgrade' ? 'would upgrade' : 'upgraded'; + return `checkpoint: ${action} schema:${String(checkpoint.previousSchema)} ` + + `storage:${checkpoint.previousStorageVersion ?? '(unspecified)'} -> ` + + `schema:${checkpoint.currentSchema} storage:${checkpoint.currentStorageVersion}`; } export function formatHumanResult(result: V16ToV17UpgradeResult): string { @@ -237,7 +238,7 @@ export function formatHumanResult(result: V16ToV17UpgradeResult): string { } async function resolveGraphNames( - persistence: GraphPersistencePort, + persistence: V16ToV17MigrationHistory, explicitGraphNames: readonly string[], ): Promise { if (explicitGraphNames.length > 0) { @@ -246,7 +247,7 @@ async function resolveGraphNames( return await discoverGraphNames(persistence); } -async function discoverGraphNames(persistence: GraphPersistencePort): Promise { +async function discoverGraphNames(persistence: V16ToV17MigrationHistory): Promise { const refs = await persistence.listRefs(REF_PREFIX); const prefix = `${REF_PREFIX}/`; const names: Set = new Set(); @@ -272,12 +273,13 @@ async function run(): Promise { return; } - const { persistence } = await createPersistence(args.repo); + const { persistence, runtimeStorage } = await createPersistence(args.repo); const graphNames = await resolveGraphNames(persistence, args.graphNames); const result = await upgradeV16ToV17({ persistence, graphNames, dryRun: args.dryRun, + runtimeStorage, }); if (args.json) { @@ -286,14 +288,9 @@ async function run(): Promise { } process.stdout.write(`${formatHumanResult(result)}\n`); } - -function errorMessage(err: Error): string { - return err.message; -} - if (process.argv[1] === fileURLToPath(import.meta.url)) { run().catch((err: Error) => { - process.stderr.write(`${errorMessage(err)}\n\n${usage()}\n`); + process.stderr.write(`${err.message}\n\n${usage()}\n`); process.exitCode = 1; }); } diff --git a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts index 62c9fe98..c8049576 100644 --- a/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts +++ b/scripts/v18.0.0/migrations/graph-model/GraphModelMigrationSourceInventoryCollector.ts @@ -145,7 +145,7 @@ function collectContentSources( .filter((fact) => fact.kind === V17_GOLDEN_CONTENT_FACT) .map((fact) => new GraphModelMigrationContentSource({ legacyContentKey: fact.key, - contentOid: `fixture-content:${fact.key}`, + contentHandle: `fixture-content:${fact.key}`, }))); } diff --git a/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts index 09cc0bca..905fd6d3 100644 --- a/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts +++ b/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts @@ -18,7 +18,7 @@ import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/G import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import type BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../../../src/ports/AssetStoragePort.ts'; import { runMigrationGit } from './GitMigrationCommandRunner.ts'; const CONTENT_ATTACHMENT_SUFFIX = `:${CONTENT_PROPERTY_KEY}`; @@ -126,7 +126,7 @@ class RuntimeContentOidResolver { private constructor( private readonly repositoryPath: string, private readonly shouldCleanup: boolean, - private readonly storage: BlobStoragePort, + private readonly storage: AssetStoragePort, ) { } @@ -162,11 +162,13 @@ class RuntimeContentOidResolver { readonly nodeId: string; }): Promise { const content = `migration-source:${options.contentKey}`; - return await this.storage.store(content, { + const bytes = new TextEncoder().encode(content); + const staged = await this.storage.stage(singleChunk(bytes), { slug: `${options.graphId}/${options.nodeId}`, - mime: 'text/plain', - size: new TextEncoder().encode(content).byteLength, + filename: 'content', + expectedSize: bytes.byteLength, }); + return staged.handle.toString(); } async close(): Promise { @@ -176,6 +178,10 @@ class RuntimeContentOidResolver { } } +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + async function gitText(repositoryPath: string, args: readonly string[]): Promise { const result = await runMigrationGit(repositoryPath, args, null); if (!result.ok()) { diff --git a/src/domain/RuntimeHost.ts b/src/domain/RuntimeHost.ts index e3ffd88c..adcf6383 100644 --- a/src/domain/RuntimeHost.ts +++ b/src/domain/RuntimeHost.ts @@ -55,11 +55,14 @@ import type CryptoPort from '../ports/CryptoPort.ts'; import type CodecPort from '../ports/CodecPort.ts'; import type TrustCryptoPort from '../ports/TrustCryptoPort.ts'; import type WarpStateCachePort from '../ports/WarpStateCachePort.ts'; -import type BlobStoragePort from '../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../ports/AssetStoragePort.ts'; +import type AuditLogPort from '../ports/AuditLogPort.ts'; import type PatchJournalPort from '../ports/PatchJournalPort.ts'; +import type StrandStorePort from '../ports/StrandStorePort.ts'; import type CommitMessageCodecPort from '../ports/CommitMessageCodecPort.ts'; import type CheckpointStorePort from '../ports/CheckpointStorePort.ts'; import type IndexStorePort from '../ports/IndexStorePort.ts'; +import type IntentStorePort from '../ports/IntentStorePort.ts'; import type RuntimeStorageProviderPort from '../ports/RuntimeStorageProviderPort.ts'; import type { EffectPipeline } from './services/EffectPipeline.ts'; import type WarpState from './services/state/WarpState.ts'; @@ -204,8 +207,8 @@ export default class RuntimeHost { _cachedCeiling: number | null; _cachedFrontier: Map | null; _stateCache: WarpStateCachePort | null; - _blobStorage: BlobStoragePort | null; - _patchBlobStorage: BlobStoragePort | null; + _assetStorage: AssetStoragePort; + _auditLog: AuditLogPort; _commitMessageCodec: CommitMessageCodecPort; _patchInProgress: boolean; _provenanceDegraded: boolean; @@ -232,8 +235,10 @@ export default class RuntimeHost { _indexDegraded: boolean; _effectPipeline: EffectPipeline | null; _patchJournal: PatchJournalPort; + _strandStore: StrandStorePort; _checkpointStore: CheckpointStorePort; _indexStore: IndexStorePort; + _intentStore: IntentStorePort; _stateHashService: StateHashService | null; _auditService: AuditReceiptService | null; @@ -259,13 +264,15 @@ export default class RuntimeHost { trustCrypto, stateCache, audit = false, - blobStorage, - patchBlobStorage, + assetStorage, + auditLog, commitMessageCodec, trust, patchJournal, + strandStore, checkpointStore, indexStore, + intentStore, viewService, stateHashService, auditService, @@ -304,8 +311,9 @@ export default class RuntimeHost { this._cachedCeiling = null; this._cachedFrontier = null; this._stateCache = stateCache || null; - this._blobStorage = blobStorage || null; - this._patchBlobStorage = patchBlobStorage || null; + this._assetStorage = assetStorage; + this._intentStore = intentStore; + this._auditLog = auditLog; this._commitMessageCodec = requireCommitMessageCodec(commitMessageCodec); this._patchInProgress = false; this._provenanceDegraded = false; @@ -376,12 +384,12 @@ export default class RuntimeHost { codec: this._codec, crypto: this._crypto, persistence: this._persistence, + checkpointStore, getStateCache: () => this._stateCache ?? null, ...(openStateSession === undefined ? {} : { openStateSession }), patches: new RuntimePatchCollector(this), graphCloner: new RuntimeDetachedFactory(this, async (detachedOptions) => await RuntimeHost.open(detachedOptions)), graphName: this._graphName, - commitMessageCodec: this._commitMessageCodec, }); this._viewService = viewService; this._logicalIndex = null; @@ -391,6 +399,7 @@ export default class RuntimeHost { this._indexDegraded = false; this._effectPipeline = effectPipeline || null; this._patchJournal = patchJournal; + this._strandStore = strandStore; this._checkpointStore = checkpointStore; this._indexStore = indexStore; this._auditService = auditService || null; @@ -593,6 +602,8 @@ export default class RuntimeHost { createPatch: PatchController['createPatch'] = (...args) => this._patchController.createPatch(...args); patch: PatchController['patch'] = (...args) => this._patchController.patch(...args); + patchWithEvidence: PatchController['patchWithEvidence'] = (...args) => + this._patchController.patchWithEvidence(...args); patchMany: PatchController['patchMany'] = (...args) => this._patchController.patchMany(...args); _nextLamport: PatchController['_nextLamport'] = (...args) => this._patchController._nextLamport(...args); _loadPatchChainFromSha: PatchController['_loadPatchChainFromSha'] = (...args) => this._patchController._loadPatchChainFromSha(...args); @@ -601,7 +612,7 @@ export default class RuntimeHost { _onPatchCommitted: PatchController['_onPatchCommitted'] = (...args) => this._patchController._onPatchCommitted(...args); writer: PatchController['writer'] = (...args) => this._patchController.writer(...args); _ensureFreshState: PatchController['_ensureFreshState'] = (...args) => this._patchController._ensureFreshState(...args); - _readPatchBlob: PatchController['_readPatchBlob'] = (...args) => this._patchController._readPatchBlob(...args); + _readPatch: PatchController['_readPatch'] = (...args) => this._patchController._readPatch(...args); discoverWriters: PatchController['discoverWriters'] = (...args) => this._patchController.discoverWriters(...args); discoverTicks: PatchController['discoverTicks'] = (...args) => this._patchController.discoverTicks(...args); join: PatchController['join'] = (...args) => this._patchController.join(...args); @@ -616,6 +627,8 @@ export default class RuntimeHost { patchesForStrand: StrandController['patchesForStrand'] = (...args) => this._strandController.patchesForStrand(...args); createStrandPatch: StrandController['createStrandPatch'] = (...args) => this._strandController.createStrandPatch(...args); patchStrand: StrandController['patchStrand'] = (...args) => this._strandController.patchStrand(...args); + patchStrandWithEvidence: StrandController['patchStrandWithEvidence'] = (...args) => + this._strandController.patchStrandWithEvidence(...args); queueStrandIntent: StrandController['queueStrandIntent'] = (...args) => this._strandController.queueStrandIntent(...args); async listStrandIntents(strandId: string) { return [...await this._strandController.listStrandIntents(strandId)]; @@ -635,10 +648,10 @@ export default class RuntimeHost { worldline: QueryCapability['worldline'] = (...args) => this._queryController.worldline(...args); observer: QueryCapability['observer'] = (...args) => this._queryController.observer(...args); translationCost: QueryCapability['translationCost'] = (...args) => this._queryController.translationCost(...args); - getContentOid: QueryCapability['getContentOid'] = (...args) => this._queryController.getContentOid(...args); + getContentHandle: QueryCapability['getContentHandle'] = (...args) => this._queryController.getContentHandle(...args); getContentMeta: QueryCapability['getContentMeta'] = (...args) => this._queryController.getContentMeta(...args); getContent: QueryCapability['getContent'] = (...args) => this._queryController.getContent(...args); - getEdgeContentOid: QueryCapability['getEdgeContentOid'] = (...args) => this._queryController.getEdgeContentOid(...args); + getEdgeContentHandle: QueryCapability['getEdgeContentHandle'] = (...args) => this._queryController.getEdgeContentHandle(...args); getEdgeContentMeta: QueryCapability['getEdgeContentMeta'] = (...args) => this._queryController.getEdgeContentMeta(...args); getEdgeContent: QueryCapability['getEdgeContent'] = (...args) => this._queryController.getEdgeContent(...args); getContentStream: QueryCapability['getContentStream'] = (...args) => this._queryController.getContentStream(...args); @@ -683,7 +696,7 @@ export default class RuntimeHost { */ _buildTrustGate(config: NormalizedTrustConfig): SyncTrustGate { const verifier = new AuditVerifierService({ - persistence: this._persistence, + auditLog: this._auditLog, codec: this._codec, ...(this._trustCrypto === null ? {} : { trustCrypto: this._trustCrypto }), ...(this._logger ? { logger: this._logger } : {}), diff --git a/src/domain/WarpApp.ts b/src/domain/WarpApp.ts index 868b6288..1c4d274b 100644 --- a/src/domain/WarpApp.ts +++ b/src/domain/WarpApp.ts @@ -42,11 +42,11 @@ type AppSurface = { watch(pattern: string | string[], options: WatchOptions): SubscriptionHandle; getContent(nodeId: string): Promise; getContentStream(nodeId: string): Promise | null>; - getContentOid(nodeId: string): Promise; + getContentHandle(nodeId: string): Promise; getContentMeta(nodeId: string): Promise; getEdgeContent(from: string, to: string, label: string): Promise; getEdgeContentStream(from: string, to: string, label: string): Promise | null>; - getEdgeContentOid(from: string, to: string, label: string): Promise; + getEdgeContentHandle(from: string, to: string, label: string): Promise; getEdgeContentMeta(from: string, to: string, label: string): Promise; }; @@ -193,8 +193,8 @@ export default class WarpApp { return await this._surface().getContentStream(nodeId); } - async getContentOid(nodeId: string): Promise { - return await this._surface().getContentOid(nodeId); + async getContentHandle(nodeId: string): Promise { + return await this._surface().getContentHandle(nodeId); } async getContentMeta(nodeId: string): Promise { @@ -209,8 +209,8 @@ export default class WarpApp { return await this._surface().getEdgeContentStream(from, to, label); } - async getEdgeContentOid(from: string, to: string, label: string): Promise { - return await this._surface().getEdgeContentOid(from, to, label); + async getEdgeContentHandle(from: string, to: string, label: string): Promise { + return await this._surface().getEdgeContentHandle(from, to, label); } async getEdgeContentMeta(from: string, to: string, label: string): Promise { diff --git a/src/domain/WarpCore.ts b/src/domain/WarpCore.ts index 5ec606a5..ca795c1f 100644 --- a/src/domain/WarpCore.ts +++ b/src/domain/WarpCore.ts @@ -43,11 +43,11 @@ export default class WarpCore { declare readonly observer: WarpCoreRuntimeSurface['observer']; declare readonly translationCost: WarpCoreRuntimeSurface['translationCost']; /** - * Returns the content blob OID attached to a node. + * Returns the opaque content asset handle attached to a node. */ - declare readonly getContentOid: WarpCoreRuntimeSurface['getContentOid']; + declare readonly getContentHandle: WarpCoreRuntimeSurface['getContentHandle']; /** - * Returns metadata for the content blob attached to a node. + * Returns metadata for the content asset attached to a node. */ declare readonly getContentMeta: WarpCoreRuntimeSurface['getContentMeta']; /** @@ -55,11 +55,11 @@ export default class WarpCore { */ declare readonly getContent: WarpCoreRuntimeSurface['getContent']; /** - * Returns the content blob OID attached to an edge. + * Returns the opaque content asset handle attached to an edge. */ - declare readonly getEdgeContentOid: WarpCoreRuntimeSurface['getEdgeContentOid']; + declare readonly getEdgeContentHandle: WarpCoreRuntimeSurface['getEdgeContentHandle']; /** - * Returns metadata for the content blob attached to an edge. + * Returns metadata for the content asset attached to an edge. */ declare readonly getEdgeContentMeta: WarpCoreRuntimeSurface['getEdgeContentMeta']; /** @@ -67,11 +67,11 @@ export default class WarpCore { */ declare readonly getEdgeContent: WarpCoreRuntimeSurface['getEdgeContent']; /** - * Opens a stream for the content blob attached to a node. + * Opens a stream for the content asset attached to a node. */ declare readonly getContentStream: WarpCoreRuntimeSurface['getContentStream']; /** - * Opens a stream for the content blob attached to an edge. + * Opens a stream for the content asset attached to an edge. */ declare readonly getEdgeContentStream: WarpCoreRuntimeSurface['getEdgeContentStream']; declare readonly createPatch: WarpCoreRuntimeSurface['createPatch']; diff --git a/src/domain/WarpGraph.ts b/src/domain/WarpGraph.ts index 19d98b8c..e2af3ca4 100644 --- a/src/domain/WarpGraph.ts +++ b/src/domain/WarpGraph.ts @@ -31,11 +31,7 @@ import type { CorePersistence } from './types/WarpPersistence.ts'; import type LoggerPort from '../ports/LoggerPort.ts'; import type CryptoPort from '../ports/CryptoPort.ts'; import type CodecPort from '../ports/CodecPort.ts'; -import type BlobStoragePort from '../ports/BlobStoragePort.ts'; -import type PatchJournalPort from '../ports/PatchJournalPort.ts'; import type CommitMessageCodecPort from '../ports/CommitMessageCodecPort.ts'; -import type CheckpointStorePort from '../ports/CheckpointStorePort.ts'; -import type IndexStorePort from '../ports/IndexStorePort.ts'; import type EffectSinkPort from '../ports/EffectSinkPort.ts'; import type { EffectPipeline } from './services/EffectPipeline.ts'; import type { ExternalizationPolicy } from './types/ExternalizationPolicy.ts'; @@ -133,7 +129,7 @@ type TrustMode = 'off' | 'log-only' | 'enforce'; * - governing policy (trust, GC, checkpoint, onDeleteWithData) * - witness infrastructure (crypto, codec, audit) * - revelation regime (logger, effectSinks, externalizationPolicy) - * - optional semantic storage services (blobStorage, indexStore) + * - one coherent semantic storage provider */ export interface WarpGraphDeps { // Substrate @@ -164,12 +160,6 @@ export interface WarpGraphDeps { readonly effectSinks?: EffectSinkPort[]; readonly externalizationPolicy?: ExternalizationPolicy; - // Semantic storage services (optional — composed when absent) - readonly blobStorage?: BlobStoragePort; - readonly patchBlobStorage?: BlobStoragePort; - readonly patchJournal?: PatchJournalPort | null; - readonly checkpointStore?: CheckpointStorePort | null; - readonly indexStore?: IndexStorePort | null; } // --------------------------------------------------------------------------- @@ -210,8 +200,8 @@ function bindQueryCapability(runtime: WarpGraphRuntimeSurface): QueryCapability 'hasNode', 'getNodeProps', 'getEdgeProps', 'neighbors', 'getStateSnapshot', 'getNodes', 'getEdges', 'getPropertyCount', 'query', 'worldline', 'observer', 'translationCost', - 'getContentOid', 'getContentMeta', 'getContent', - 'getEdgeContentOid', 'getEdgeContentMeta', 'getEdgeContent', + 'getContentHandle', 'getContentMeta', 'getContent', + 'getEdgeContentHandle', 'getEdgeContentMeta', 'getEdgeContent', 'getContentStream', 'getEdgeContentStream', ]); return Object.freeze({ @@ -227,10 +217,10 @@ function bindQueryCapability(runtime: WarpGraphRuntimeSurface): QueryCapability worldline: runtime.worldline.bind(runtime), observer: runtime.observer.bind(runtime), translationCost: runtime.translationCost.bind(runtime), - getContentOid: runtime.getContentOid.bind(runtime), + getContentHandle: runtime.getContentHandle.bind(runtime), getContentMeta: runtime.getContentMeta.bind(runtime), getContent: runtime.getContent.bind(runtime), - getEdgeContentOid: runtime.getEdgeContentOid.bind(runtime), + getEdgeContentHandle: runtime.getEdgeContentHandle.bind(runtime), getEdgeContentMeta: runtime.getEdgeContentMeta.bind(runtime), getEdgeContent: runtime.getEdgeContent.bind(runtime), getContentStream: runtime.getContentStream.bind(runtime), @@ -240,12 +230,13 @@ function bindQueryCapability(runtime: WarpGraphRuntimeSurface): QueryCapability function bindPatchCapability(runtime: WarpGraphRuntimeSurface): PatchCapability { requireCapability(runtime, 'patch', [ - 'createPatch', 'patch', 'patchMany', 'getWriterPatches', + 'createPatch', 'patch', 'patchWithEvidence', 'patchMany', 'getWriterPatches', 'writer', 'discoverWriters', 'discoverTicks', 'join', ]); return Object.freeze({ createPatch: runtime.createPatch.bind(runtime), patch: runtime.patch.bind(runtime), + patchWithEvidence: runtime.patchWithEvidence.bind(runtime), patchMany: runtime.patchMany.bind(runtime), getWriterPatches: runtime.getWriterPatches.bind(runtime), writer: runtime.writer.bind(runtime), @@ -281,7 +272,7 @@ function bindStrandCapability(runtime: WarpGraphRuntimeSurface): StrandCapabilit requireCapability(runtime, 'strand', [ 'createStrand', 'braidStrand', 'getStrand', 'listStrands', 'dropStrand', 'materializeStrand', 'getStrandPatches', 'patchesForStrand', - 'createStrandPatch', 'patchStrand', 'queueStrandIntent', + 'createStrandPatch', 'patchStrand', 'patchStrandWithEvidence', 'queueStrandIntent', 'listStrandIntents', 'tickStrand', 'analyzeConflicts', ]); return Object.freeze({ @@ -295,6 +286,7 @@ function bindStrandCapability(runtime: WarpGraphRuntimeSurface): StrandCapabilit patchesForStrand: runtime.patchesForStrand.bind(runtime), createStrandPatch: runtime.createStrandPatch.bind(runtime), patchStrand: runtime.patchStrand.bind(runtime), + patchStrandWithEvidence: runtime.patchStrandWithEvidence.bind(runtime), queueStrandIntent: runtime.queueStrandIntent.bind(runtime), listStrandIntents: runtime.listStrandIntents.bind(runtime), tickStrand: runtime.tickStrand.bind(runtime), diff --git a/src/domain/WarpWorldline.ts b/src/domain/WarpWorldline.ts index 0e8d90f7..6aa77c1c 100644 --- a/src/domain/WarpWorldline.ts +++ b/src/domain/WarpWorldline.ts @@ -19,6 +19,7 @@ import type WorldlineOptic from './services/optic/WorldlineOptic.ts'; import CheckpointTailBasisVerifier from './services/optic/CheckpointTailBasisVerifier.ts'; import createBoundedMemoryCapabilityReport from './memory/createBoundedMemoryCapabilityReport.ts'; import type { WarpIntentDescriptor, WarpIntentOutcome } from './types/WarpIntentDescriptor.ts'; +import type { PatchCommitResult } from './types/PatchCommitResult.ts'; export type WarpWorldlineOpenOptions = Omit & { readonly worldlineName: string; @@ -30,16 +31,21 @@ export type WarpWorldlinePatchBuild = ( ) => void | Promise; type CommitPatch = (build: WarpWorldlinePatchBuild) => Promise; +type CommitPatchWithEvidence = (build: WarpWorldlinePatchBuild) => Promise; type CreateDraft = (name: string) => Promise; type WorldlineOptions = Parameters[0]; type CreateWorldline = (options?: WorldlineOptions) => ProjectionHandle; type PatchDraft = (name: string, build: WarpWorldlinePatchBuild) => Promise; +type PatchDraftWithEvidence = ( + name: string, + build: WarpWorldlinePatchBuild, +) => Promise; type PreviewDraftJoin = (name: string) => Promise; type RuntimeGraph = Awaited>; type PrepareOpticBasis = () => Promise; type DraftWorldlineOptions = Pick< WarpWorldlineConstructionOptions, - 'createDraft' | 'patchDraft' | 'previewDraftJoin' + 'createDraft' | 'patchDraft' | 'patchDraftWithEvidence' | 'previewDraftJoin' >; type GetFrontier = () => Promise>; type ReadOpticBasis = () => WarpWorldlineOpticBasis | null; @@ -50,9 +56,11 @@ type WarpWorldlineConstructionOptions = { readonly worldlineName: string; readonly writerId: string; readonly commitPatch: CommitPatch; + readonly commitPatchWithEvidence?: CommitPatchWithEvidence; readonly createDraft?: CreateDraft; readonly createWorldline: CreateWorldline; readonly patchDraft?: PatchDraft; + readonly patchDraftWithEvidence?: PatchDraftWithEvidence; readonly previewDraftJoin?: PreviewDraftJoin; readonly prepareOpticBasis?: PrepareOpticBasis; readonly getFrontier?: GetFrontier; @@ -65,9 +73,11 @@ export default class WarpWorldline { readonly worldlineName: string; readonly writerId: string; private readonly _commitPatch: CommitPatch; + private readonly _commitPatchWithEvidence: CommitPatchWithEvidence | null; private readonly _createDraft: CreateDraft | null; private readonly _createWorldline: CreateWorldline; private readonly _patchDraft: PatchDraft | null; + private readonly _patchDraftWithEvidence: PatchDraftWithEvidence | null; private readonly _previewDraftJoin: PreviewDraftJoin | null; private readonly _prepareOpticBasis: PrepareOpticBasis | null; private readonly _getFrontier: GetFrontier | null; @@ -81,9 +91,11 @@ export default class WarpWorldline { this.worldlineName = options.worldlineName; this.writerId = options.writerId; this._commitPatch = options.commitPatch; + this._commitPatchWithEvidence = optionalPort(options.commitPatchWithEvidence); this._createDraft = optionalPort(options.createDraft); this._createWorldline = options.createWorldline; this._patchDraft = optionalPort(options.patchDraft); + this._patchDraftWithEvidence = optionalPort(options.patchDraftWithEvidence); this._previewDraftJoin = optionalPort(options.previewDraftJoin); this._prepareOpticBasis = optionalPort(options.prepareOpticBasis); this._getFrontier = optionalPort(options.getFrontier); @@ -97,6 +109,16 @@ export default class WarpWorldline { return await this._commitPatch(build); } + async commitWithEvidence(build: WarpWorldlinePatchBuild): Promise { + if (this._commitPatchWithEvidence === null) { + throw new WarpError( + 'WarpWorldline was not opened with storage evidence support', + 'E_WARP_WORLDLINE_STORAGE_EVIDENCE_UNAVAILABLE', + ); + } + return await this._commitPatchWithEvidence(build); + } + async admitIntent(descriptor: WarpIntentDescriptor): Promise { return await this._admitIntent(descriptor); } @@ -115,6 +137,19 @@ export default class WarpWorldline { return await this._patchDraft(name, build); } + async patchDraftWithEvidence( + name: string, + build: WarpWorldlinePatchBuild, + ): Promise { + if (this._patchDraftWithEvidence === null) { + throw new WarpError( + 'WarpWorldline was not opened with draft storage evidence support', + 'E_WARP_WORLDLINE_STORAGE_EVIDENCE_UNAVAILABLE', + ); + } + return await this._patchDraftWithEvidence(name, build); + } + async previewDraftJoin(name: string): Promise { if (this._previewDraftJoin === null) { throw new WarpError('WarpWorldline was not opened with draft support', 'E_WARP_WORLDLINE_DRAFT_UNAVAILABLE'); @@ -223,6 +258,7 @@ function createWarpWorldline(worldlineName: string, graph: RuntimeGraph): WarpWo worldlineName, writerId: graph.writerId, commitPatch: async (build) => await graph.patch(build), + commitPatchWithEvidence: async (build) => await graph.patchWithEvidence(build), ...draftWorldlineOptions(graph), createWorldline: (worldlineOptions) => graph.worldline(worldlineOptions), prepareOpticBasis: async () => { @@ -248,6 +284,8 @@ function draftWorldlineOptions(graph: RuntimeGraph): DraftWorldlineOptions { }); }, patchDraft: async (name, build) => await graph.patchStrand(name, build), + patchDraftWithEvidence: async (name, build) => + await graph.patchStrandWithEvidence(name, build), previewDraftJoin: async (name) => { await graph.materializeStrand(name, { receipts: true }); return (await graph.getStrandPatches(name)).map((entry) => entry.sha); diff --git a/src/domain/api/DraftTimelineRuntime.ts b/src/domain/api/DraftTimelineRuntime.ts index 97003482..a6f7ab09 100644 --- a/src/domain/api/DraftTimelineRuntime.ts +++ b/src/domain/api/DraftTimelineRuntime.ts @@ -238,8 +238,9 @@ async function writeDraftIntent(fields: DraftWriteFields): Promise context: fields.state.context, intent: fields.intent, commit: async (build) => { - draftPatchSha = await fields.runtime.patchDraft(fields.draftName, build); - return draftPatchSha; + const publication = await fields.runtime.patchDraftWithEvidence(fields.draftName, build); + draftPatchSha = publication.sha; + return publication; }, }); if (receipt.outcome !== 'accepted') { diff --git a/src/domain/api/Evidence.ts b/src/domain/api/Evidence.ts index 14056517..fae2c036 100644 --- a/src/domain/api/Evidence.ts +++ b/src/domain/api/Evidence.ts @@ -1,4 +1,5 @@ import type Tick from './Tick.ts'; +import type RetentionEvidence from './RetentionEvidence.ts'; /** Opaque, storage-neutral handle for one item of causal support. */ export type EvidenceHandle = Readonly<{ @@ -6,9 +7,10 @@ export type EvidenceHandle = Readonly<{ }>; /** Public causal evidence without substrate object identities. */ -type Evidence = Readonly<{ +export type Evidence = Readonly<{ readonly basis: EvidenceHandle; readonly support: readonly EvidenceHandle[]; + readonly retention?: readonly RetentionEvidence[]; readonly tick?: Tick; }>; diff --git a/src/domain/api/EvidenceRuntime.ts b/src/domain/api/EvidenceRuntime.ts index 4d4635dc..ecc8b021 100644 --- a/src/domain/api/EvidenceRuntime.ts +++ b/src/domain/api/EvidenceRuntime.ts @@ -3,8 +3,9 @@ import WarpError from '../errors/WarpError.ts'; import type ReadIdentity from '../services/optic/ReadIdentity.ts'; import { requireNonEmptyString } from '../utils/scalarValidation.ts'; import type { ApiRuntimeContext, OpaqueIdPart } from './ApiRuntimeContext.ts'; -import type Evidence from './Evidence.ts'; -import type { EvidenceHandle } from './Evidence.ts'; +import type { Evidence, EvidenceHandle } from './Evidence.ts'; +import type StorageRetentionWitness from '../storage/StorageRetentionWitness.ts'; +import RetentionEvidence from './RetentionEvidence.ts'; import Tick from './Tick.ts'; type JoinEvidenceFields = { @@ -13,19 +14,26 @@ type JoinEvidenceFields = { readonly patchShas: readonly string[]; }; +type WriteEvidenceFields = { + readonly runtime: WarpWorldline; + readonly context: ApiRuntimeContext; + readonly patchSha: string; + readonly retentionWitness?: StorageRetentionWitness; +}; + const WRITE_EVIDENCE = 'write'; const JOIN_EVIDENCE = 'join'; const READ_EVIDENCE = 'read'; const PATCH_SUPPORT = 'patch'; const INDEX_SUPPORT = 'index'; const RECOVERY_EVIDENCE = 'recovery'; +const RETENTION_SUPPORT = 'retention'; export async function createWriteEvidence( - runtime: WarpWorldline, - context: ApiRuntimeContext, - patchSha: string + fields: WriteEvidenceFields, ): Promise { - return freezeCreatedEvidence({ + const { runtime, context, patchSha, retentionWitness } = fields; + const evidence = { basis: await createHandle(context, [ WRITE_EVIDENCE, runtime.worldlineName, @@ -33,6 +41,13 @@ export async function createWriteEvidence( patchSha, ]), support: [await createHandle(context, [PATCH_SUPPORT, patchSha])], + }; + if (retentionWitness === undefined) { + return freezeCreatedEvidence(evidence); + } + return freezeCreatedEvidence({ + ...evidence, + retention: [await createRetentionEvidence(context, retentionWitness)], }); } @@ -100,8 +115,10 @@ export function freezeEvidence(evidence: Evidence, field: string): Evidence { assertEvidenceObject(evidence, field); const basis = freezeHandle(evidence.basis, `${field}.basis`); const support = freezeSupport(evidence.support, `${field}.support`); + const retention = freezeRetentionEvidence(evidence.retention, `${field}.retention`); const tick = validateTick(evidence.tick, `${field}.tick`); - return freezeCreatedEvidence(tick === undefined ? { basis, support } : { basis, support, tick }); + const base = retention === undefined ? { basis, support } : { basis, support, retention }; + return freezeCreatedEvidence(tick === undefined ? base : { ...base, tick }); } export function freezeOptionalEvidence( @@ -158,6 +175,67 @@ function freezeSupport( return Object.freeze(support.map((handle, index) => freezeHandle(handle, `${field}[${index}]`))); } +function freezeRetentionEvidence( + retention: readonly RetentionEvidence[] | undefined, + field: string, +): readonly RetentionEvidence[] | undefined { + if (retention === undefined) { + return undefined; + } + assertRetentionEvidenceArray(retention, field); + return freezeRetentionEvidenceEntries(retention, field); +} + +function assertRetentionEvidenceArray( + retention: readonly RetentionEvidence[], + field: string, +): void { + if (!Array.isArray(retention)) { + throw new WarpError(`${field} must be an array`, 'E_RECEIPT_EVIDENCE'); + } +} + +function freezeRetentionEvidenceEntries( + retention: readonly RetentionEvidence[], + field: string, +): readonly RetentionEvidence[] { + return Object.freeze(retention.map((entry, index) => { + const itemField = `${field}[${index}]`; + if (entry === null || typeof entry !== 'object') { + throw new WarpError(`${itemField} must be retention evidence`, 'E_RECEIPT_EVIDENCE'); + } + return new RetentionEvidence({ + witness: freezeHandle(entry.witness, `${itemField}.witness`), + policy: entry.policy, + reachability: entry.reachability, + rootKind: entry.rootKind, + }); + })); +} + +async function createRetentionEvidence( + context: ApiRuntimeContext, + witness: StorageRetentionWitness, +): Promise { + return new RetentionEvidence({ + witness: await createHandle(context, [ + RETENTION_SUPPORT, + witness.handle.toString(), + witness.policy, + witness.reachability, + witness.root.kind, + witness.root.namespace, + witness.root.locator, + witness.root.generation, + witness.root.path, + witness.observedAt, + ]), + policy: witness.policy, + reachability: witness.reachability, + rootKind: witness.root.kind, + }); +} + function validateTick(tick: Tick | undefined, field: string): Tick | undefined { if (tick !== undefined && !(tick instanceof Tick)) { throw new WarpError(`${field} must be a Tick`, 'E_RECEIPT_EVIDENCE'); @@ -186,6 +264,7 @@ function freezeCreatedEvidence(evidence: Evidence): Evidence { readonly basis: EvidenceHandle; readonly support: readonly EvidenceHandle[]; tick?: Tick; + retention?: readonly RetentionEvidence[]; } = { basis: evidence.basis, support: Object.freeze([...evidence.support]), @@ -193,5 +272,8 @@ function freezeCreatedEvidence(evidence: Evidence): Evidence { if (evidence.tick !== undefined) { result.tick = evidence.tick; } + if (evidence.retention !== undefined) { + result.retention = Object.freeze([...evidence.retention]); + } return Object.freeze(result); } diff --git a/src/domain/api/RetentionEvidence.ts b/src/domain/api/RetentionEvidence.ts new file mode 100644 index 00000000..676c2321 --- /dev/null +++ b/src/domain/api/RetentionEvidence.ts @@ -0,0 +1,75 @@ +import WarpError from '../errors/WarpError.ts'; +import type { + StorageReachability, + StorageRetentionPolicy, + StorageRetentionRootKind, +} from '../storage/StorageRetentionWitness.ts'; +import type { EvidenceHandle } from './Evidence.ts'; + +export type RetentionEvidenceOptions = Readonly<{ + witness: EvidenceHandle; + policy: StorageRetentionPolicy; + reachability: StorageReachability; + rootKind: StorageRetentionRootKind; +}>; + +/** Public, storage-neutral projection of a concrete retention witness. */ +export default class RetentionEvidence { + readonly witness: EvidenceHandle; + readonly policy: StorageRetentionPolicy; + readonly reachability: StorageReachability; + readonly rootKind: StorageRetentionRootKind; + + constructor(options: RetentionEvidenceOptions) { + requireOptions(options); + this.witness = freezeWitness(options.witness); + this.policy = requirePolicy(options.policy); + this.reachability = requireReachability(options.reachability); + this.rootKind = requireRootKind(options.rootKind); + Object.freeze(this); + } +} + +function requireOptions(options: RetentionEvidenceOptions): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw evidenceError('options must be an object'); + } +} + +function freezeWitness(witness: EvidenceHandle): EvidenceHandle { + if (witness === null || typeof witness !== 'object') { + throw evidenceError('witness must be an evidence handle'); + } + if (typeof witness.id !== 'string' || witness.id.length === 0) { + throw evidenceError('witness.id must be non-empty'); + } + return Object.freeze({ id: witness.id }); +} + +function requirePolicy(policy: StorageRetentionPolicy): StorageRetentionPolicy { + if (policy !== 'pinned' && policy !== 'evictable') { + throw evidenceError('policy is invalid'); + } + return policy; +} + +function requireReachability(reachability: StorageReachability): StorageReachability { + if (reachability !== 'anchored' && reachability !== 'orphaned' && reachability !== 'volatile') { + throw evidenceError('reachability is invalid'); + } + return reachability; +} + +function requireRootKind(rootKind: StorageRetentionRootKind): StorageRetentionRootKind { + if (rootKind !== 'root-set' + && rootKind !== 'publication' + && rootKind !== 'cache-set' + && rootKind !== 'expiring-set') { + throw evidenceError('rootKind is invalid'); + } + return rootKind; +} + +function evidenceError(message: string): WarpError { + return new WarpError(`Retention evidence ${message}`, 'E_RECEIPT_EVIDENCE'); +} diff --git a/src/domain/api/TimelineRuntime.ts b/src/domain/api/TimelineRuntime.ts index 4479c8fa..9383f516 100644 --- a/src/domain/api/TimelineRuntime.ts +++ b/src/domain/api/TimelineRuntime.ts @@ -35,7 +35,7 @@ export function createTimeline(runtime: WarpWorldline, context: ApiRuntimeContex runtime, context, intent, - commit: runtime.commit.bind(runtime), + commit: runtime.commitWithEvidence.bind(runtime), }), }); timelineRuntimes.set(timeline, runtime); diff --git a/src/domain/api/WriteRuntime.ts b/src/domain/api/WriteRuntime.ts index 225153d6..56f316ec 100644 --- a/src/domain/api/WriteRuntime.ts +++ b/src/domain/api/WriteRuntime.ts @@ -8,8 +8,9 @@ import { applyIntentToPatch } from './IntentRuntime.ts'; import type { WriteOutcome } from './ReceiptOutcome.ts'; import type { RepairHint } from './ReceiptSupport.ts'; import WriteReceipt from './WriteReceipt.ts'; +import type { PatchCommitResult } from '../types/PatchCommitResult.ts'; -type IntentCommit = (build: WarpWorldlinePatchBuild) => Promise; +type IntentCommit = (build: WarpWorldlinePatchBuild) => Promise; type IntentWriteFields = { readonly runtime: WarpWorldline; @@ -19,7 +20,7 @@ type IntentWriteFields = { }; type AcceptedWriteFields = Omit & { - readonly patchSha: string; + readonly publication: PatchCommitResult; readonly recoveryEvidence: Evidence; }; @@ -38,9 +39,9 @@ const MATERIALIZE_HINT = Object.freeze([ export async function executeIntentWrite(fields: IntentWriteFields): Promise { const { runtime, context, intent, commit } = fields; const recoveryEvidence = await createWriteRecoveryEvidence(runtime, context); - let patchSha: string; + let publication: PatchCommitResult; try { - patchSha = await commit((patch) => { + publication = await commit((patch) => { applyIntentToPatch(intent, patch); }); } catch (error) { @@ -60,11 +61,11 @@ export async function executeIntentWrite(fields: IntentWriteFields): Promise { - const { runtime, context, intent, patchSha } = fields; + const { runtime, context, intent, publication } = fields; const receipt = new WriteReceipt({ timeline: runtime.worldlineName, writer: runtime.writerId, @@ -72,13 +73,18 @@ async function acceptedWriteReceipt(fields: AcceptedWriteFields): Promise { try { - return await createWriteEvidence(fields.runtime, fields.context, fields.patchSha); + return await createWriteEvidence({ + runtime: fields.runtime, + context: fields.context, + patchSha: fields.publication.sha, + retentionWitness: fields.publication.retention, + }); } catch { // The patch is durable; return an honest basis without claiming correlated support. return fields.recoveryEvidence; diff --git a/src/domain/capabilities/PatchCapability.ts b/src/domain/capabilities/PatchCapability.ts index 2b8600c6..c7f3e666 100644 --- a/src/domain/capabilities/PatchCapability.ts +++ b/src/domain/capabilities/PatchCapability.ts @@ -8,6 +8,7 @@ import type { PatchBuilder } from '../services/PatchBuilder.ts'; import type Patch from '../types/Patch.ts'; import type { Writer } from '../warp/Writer.ts'; import type { WarpState } from '../services/JoinReducer.ts'; +import type { PatchCommitResult } from '../types/PatchCommitResult.ts'; /** Receipt from a CRDT state merge (join). */ export type JoinReceipt = { @@ -46,6 +47,11 @@ export default abstract class PatchCapability { /** Build and commit one patch with the current writer. */ abstract patch(_build: (_p: PatchBuilder) => void | Promise): Promise; + /** Build and commit one patch while retaining publication evidence. */ + abstract patchWithEvidence( + _build: (_p: PatchBuilder) => void | Promise, + ): Promise; + /** Build and commit multiple patches in order. */ abstract patchMany(..._builds: Array<(_p: PatchBuilder) => void | Promise>): Promise; diff --git a/src/domain/capabilities/PatchCollector.ts b/src/domain/capabilities/PatchCollector.ts index 3d463c2d..ca307c54 100644 --- a/src/domain/capabilities/PatchCollector.ts +++ b/src/domain/capabilities/PatchCollector.ts @@ -12,7 +12,6 @@ export type CheckpointData = { stateHash: string; schema: number; provenanceIndex?: ProvenanceIndex | undefined; - indexShardOids?: Record | null | undefined; }; async function collectPatchEntries(source: AsyncIterable): Promise { diff --git a/src/domain/capabilities/QueryCapability.ts b/src/domain/capabilities/QueryCapability.ts index 76be4c03..839dafdb 100644 --- a/src/domain/capabilities/QueryCapability.ts +++ b/src/domain/capabilities/QueryCapability.ts @@ -108,8 +108,8 @@ export default abstract class QueryCapability { _configB: ObserverConfig, ): Promise; - /** Return the content blob OID attached to a node, if any. */ - abstract getContentOid(_nodeId: string): Promise; + /** Return the opaque content asset handle attached to a node, if any. */ + abstract getContentHandle(_nodeId: string): Promise; /** Return content metadata attached to a node, if any. */ abstract getContentMeta(_nodeId: string): Promise; @@ -117,8 +117,8 @@ export default abstract class QueryCapability { /** Return content bytes attached to a node, if any. */ abstract getContent(_nodeId: string): Promise; - /** Return the content blob OID attached to an edge, if any. */ - abstract getEdgeContentOid(_from: string, _to: string, _label: string): Promise; + /** Return the opaque content asset handle attached to an edge, if any. */ + abstract getEdgeContentHandle(_from: string, _to: string, _label: string): Promise; /** Return content metadata attached to an edge, if any. */ abstract getEdgeContentMeta(_from: string, _to: string, _label: string): Promise; diff --git a/src/domain/capabilities/StrandCapability.ts b/src/domain/capabilities/StrandCapability.ts index 10d93af2..6c4f3cf0 100644 --- a/src/domain/capabilities/StrandCapability.ts +++ b/src/domain/capabilities/StrandCapability.ts @@ -17,6 +17,7 @@ import type { } from '../types/StrandDescriptor.ts'; import type ConflictAnalysis from '../types/conflict/ConflictAnalysis.ts'; import type { ConflictAnalyzeOptions as AnalyzeConflictsOptions } from '../services/strand/ConflictAnalysisRequest.ts'; +import type { PatchCommitResult } from '../types/PatchCommitResult.ts'; /** Patch with its content-addressable SHA. */ export type StrandPatchEntry = { @@ -63,6 +64,11 @@ export default abstract class StrandCapability { /** Build and commit one patch to a strand. */ abstract patchStrand(_strandId: string, _build: (_p: PatchBuilder) => void | Promise): Promise; + abstract patchStrandWithEvidence( + _strandId: string, + _build: (_p: PatchBuilder) => void | Promise, + ): Promise; + /** Queue a strand intent for later ticking. */ abstract queueStrandIntent(_strandId: string, _build: (_p: PatchBuilder) => void | Promise): Promise; diff --git a/src/domain/errors/AssetSizeMismatchError.ts b/src/domain/errors/AssetSizeMismatchError.ts new file mode 100644 index 00000000..e5baf1bd --- /dev/null +++ b/src/domain/errors/AssetSizeMismatchError.ts @@ -0,0 +1,17 @@ +import WarpError from './WarpError.ts'; + +/** The staged byte count did not match the caller's declared asset size. */ +export default class AssetSizeMismatchError extends WarpError { + readonly expectedSize: number; + readonly actualSize: number; + + constructor(expectedSize: number, actualSize: number) { + super( + `Expected ${expectedSize} asset bytes but staged ${actualSize}`, + 'E_ASSET_SIZE_MISMATCH', + { context: { expectedSize, actualSize } }, + ); + this.expectedSize = expectedSize; + this.actualSize = actualSize; + } +} diff --git a/src/domain/errors/AuditPublicationConflictError.ts b/src/domain/errors/AuditPublicationConflictError.ts new file mode 100644 index 00000000..b8a4575b --- /dev/null +++ b/src/domain/errors/AuditPublicationConflictError.ts @@ -0,0 +1,21 @@ +import AuditError from './AuditError.ts'; + +/** Provider-neutral signal that an audit publication lost a head comparison. */ +export default class AuditPublicationConflictError extends AuditError { + static readonly CODE = 'E_AUDIT_PUBLICATION_CONFLICT'; + + readonly expectedHead: string | null; + readonly observedHead: string | null; + + constructor(expectedHead: string | null, observedHead: string | null) { + super( + `Audit publication conflict: expected ${String(expectedHead)}, observed ${String(observedHead)}`, + { + code: AuditPublicationConflictError.CODE, + context: { expectedHead, observedHead }, + }, + ); + this.expectedHead = expectedHead; + this.observedHead = observedHead; + } +} diff --git a/src/domain/errors/EncryptionError.ts b/src/domain/errors/EncryptionError.ts index 5b2f928f..497fa104 100644 --- a/src/domain/errors/EncryptionError.ts +++ b/src/domain/errors/EncryptionError.ts @@ -1,14 +1,13 @@ import WarpError, { type WarpErrorOptions } from './WarpError.ts'; /** - * Error thrown when a patch requires decryption but no patchBlobStorage - * (with encryption key) is configured. + * Error thrown when configured asset decryption cannot satisfy a read. * * ## Error Codes * * | Code | Description | * |------|-------------| - * | `E_ENCRYPTED_PATCH` | Patch is encrypted but no decryption key is available | + * | `E_ENCRYPTED_PATCH` | Encrypted asset cannot be read with configured keys | */ export default class EncryptionError extends WarpError { constructor(message: string, options: WarpErrorOptions = {}) { diff --git a/src/domain/errors/PatchPublicationConflictError.ts b/src/domain/errors/PatchPublicationConflictError.ts new file mode 100644 index 00000000..69a97dee --- /dev/null +++ b/src/domain/errors/PatchPublicationConflictError.ts @@ -0,0 +1,14 @@ +import PersistenceError from './PersistenceError.ts'; + +/** Typed adapter-boundary failure for a conflicting patch publication. */ +export default class PatchPublicationConflictError extends PersistenceError { + static readonly CODE = 'E_PATCH_PUBLICATION_CONFLICT'; + + constructor(cause?: Error) { + super( + 'Patch publication conflicted with the current writer head', + PatchPublicationConflictError.CODE, + cause === undefined ? {} : { cause }, + ); + } +} diff --git a/src/domain/graph/ContentAttachmentHandle.ts b/src/domain/graph/ContentAttachmentHandle.ts new file mode 100644 index 00000000..8ed9c4a7 --- /dev/null +++ b/src/domain/graph/ContentAttachmentHandle.ts @@ -0,0 +1,4 @@ +import AssetHandle from '../storage/AssetHandle.ts'; + +/** Runtime-backed immutable asset handle used by a content attachment. */ +export default class ContentAttachmentHandle extends AssetHandle {} diff --git a/src/domain/graph/ContentAttachmentOid.ts b/src/domain/graph/ContentAttachmentOid.ts deleted file mode 100644 index 2ff943ed..00000000 --- a/src/domain/graph/ContentAttachmentOid.ts +++ /dev/null @@ -1,37 +0,0 @@ -import WarpError from '../errors/WarpError.ts'; - -const FIELD_SEPARATOR = '\x00'; - -/** Runtime-backed reference to content storage used by a content attachment. */ -export default class ContentAttachmentOid { - private readonly value: string; - - constructor(value: string) { - this.value = requireOidValue(value); - Object.freeze(this); - } - - /** Returns the stable content storage reference string. */ - toString(): string { - return this.value; - } - - /** Compares two content storage references by runtime value. */ - equals(other: ContentAttachmentOid | null | undefined): boolean { - if (!(other instanceof ContentAttachmentOid)) { - return false; - } - return this.value === other.value; - } -} - -/** Validates a content attachment storage reference. */ -function requireOidValue(value: string): string { - if (typeof value !== 'string' || value.length === 0) { - throw new WarpError('ContentAttachmentOid must be a non-empty string', 'E_VALIDATION'); - } - if (value.includes(FIELD_SEPARATOR)) { - throw new WarpError('ContentAttachmentOid must not contain NUL bytes', 'E_VALIDATION'); - } - return value; -} diff --git a/src/domain/graph/ContentAttachmentPayload.ts b/src/domain/graph/ContentAttachmentPayload.ts index 34289047..4e4f323a 100644 --- a/src/domain/graph/ContentAttachmentPayload.ts +++ b/src/domain/graph/ContentAttachmentPayload.ts @@ -1,23 +1,23 @@ import ContentAttachmentMime from './ContentAttachmentMime.ts'; -import ContentAttachmentOid from './ContentAttachmentOid.ts'; +import ContentAttachmentHandle from './ContentAttachmentHandle.ts'; import ContentAttachmentSize from './ContentAttachmentSize.ts'; import WarpError from '../errors/WarpError.ts'; export type ContentAttachmentPayloadFields = { - readonly oid: ContentAttachmentOid; + readonly handle: ContentAttachmentHandle; readonly mime: ContentAttachmentMime | null; readonly size: ContentAttachmentSize | null; }; /** Runtime-backed content attachment payload metadata. */ export default class ContentAttachmentPayload { - readonly oid: ContentAttachmentOid; + readonly handle: ContentAttachmentHandle; readonly mime: ContentAttachmentMime | null; readonly size: ContentAttachmentSize | null; constructor(fields: ContentAttachmentPayloadFields) { const checkedFields = requireFields(fields); - this.oid = requireOid(checkedFields.oid); + this.handle = requireHandle(checkedFields.handle); this.mime = requireMime(checkedFields.mime); this.size = requireSize(checkedFields.size); Object.freeze(this); @@ -45,9 +45,12 @@ function requireFields( } /** Requires a runtime-backed content storage reference. */ -function requireOid(value: ContentAttachmentOid): ContentAttachmentOid { - if (!(value instanceof ContentAttachmentOid)) { - throw new WarpError('ContentAttachmentPayload oid must be a ContentAttachmentOid', 'E_VALIDATION'); +function requireHandle(value: ContentAttachmentHandle): ContentAttachmentHandle { + if (!(value instanceof ContentAttachmentHandle)) { + throw new WarpError( + 'ContentAttachmentPayload handle must be a ContentAttachmentHandle', + 'E_VALIDATION', + ); } return value; } diff --git a/src/domain/graph/ContentAttachmentWriteIntent.ts b/src/domain/graph/ContentAttachmentWriteIntent.ts index 761677bc..15a09f73 100644 --- a/src/domain/graph/ContentAttachmentWriteIntent.ts +++ b/src/domain/graph/ContentAttachmentWriteIntent.ts @@ -3,6 +3,7 @@ import EdgeRecord from './EdgeRecord.ts'; import NodeRecord from './NodeRecord.ts'; import WarpError from '../errors/WarpError.ts'; import type ContentAttachmentPayload from './ContentAttachmentPayload.ts'; +import type ContentAttachmentHandle from './ContentAttachmentHandle.ts'; export type ContentAttachmentEdgeWriteTarget = { readonly from: string; @@ -59,8 +60,8 @@ export default class ContentAttachmentWriteIntent { } /** Returns the validated content storage reference. */ - oid(): string { - return this.record.payload.oid.toString(); + handle(): ContentAttachmentHandle { + return this.record.payload.handle; } /** Returns the validated MIME hint, when present. */ diff --git a/src/domain/graph/publicGraphSubstrate.ts b/src/domain/graph/publicGraphSubstrate.ts index fd7bf0de..4c91eef0 100644 --- a/src/domain/graph/publicGraphSubstrate.ts +++ b/src/domain/graph/publicGraphSubstrate.ts @@ -2,7 +2,7 @@ export { default as AttachmentKey } from './AttachmentKey.ts'; export { default as AttachmentRecord } from './AttachmentRecord.ts'; export { default as AttachmentSchemaVersion } from './AttachmentSchemaVersion.ts'; export { default as ContentAttachmentMime } from './ContentAttachmentMime.ts'; -export { default as ContentAttachmentOid } from './ContentAttachmentOid.ts'; +export { default as ContentAttachmentHandle } from './ContentAttachmentHandle.ts'; export { default as ContentAttachmentPayload } from './ContentAttachmentPayload.ts'; export { default as ContentAttachmentRecord } from './ContentAttachmentRecord.ts'; export { default as ContentAttachmentSize } from './ContentAttachmentSize.ts'; diff --git a/src/domain/migrations/GraphModelMigrationContentSource.ts b/src/domain/migrations/GraphModelMigrationContentSource.ts index 1829a2ee..d675f1fa 100644 --- a/src/domain/migrations/GraphModelMigrationContentSource.ts +++ b/src/domain/migrations/GraphModelMigrationContentSource.ts @@ -2,18 +2,18 @@ import WarpError from '../errors/WarpError.ts'; export type GraphModelMigrationContentSourceFields = { readonly legacyContentKey: string; - readonly contentOid: string; + readonly contentHandle: string; }; /** Runtime-backed source blob fact needed by migration planning. */ export default class GraphModelMigrationContentSource { readonly legacyContentKey: string; - readonly contentOid: string; + readonly contentHandle: string; constructor(fields: GraphModelMigrationContentSourceFields) { const checkedFields = requireFields(fields); this.legacyContentKey = requireNonEmptyString(checkedFields.legacyContentKey, 'legacyContentKey'); - this.contentOid = requireNonEmptyString(checkedFields.contentOid, 'contentOid'); + this.contentHandle = requireNonEmptyString(checkedFields.contentHandle, 'contentHandle'); Object.freeze(this); } } diff --git a/src/domain/services/ContentAttachmentProjection.ts b/src/domain/services/ContentAttachmentProjection.ts index 8bbada77..5834d860 100644 --- a/src/domain/services/ContentAttachmentProjection.ts +++ b/src/domain/services/ContentAttachmentProjection.ts @@ -1,5 +1,5 @@ import ContentAttachmentMime from '../graph/ContentAttachmentMime.ts'; -import ContentAttachmentOid from '../graph/ContentAttachmentOid.ts'; +import ContentAttachmentHandle from '../graph/ContentAttachmentHandle.ts'; import ContentAttachmentPayload from '../graph/ContentAttachmentPayload.ts'; import ContentAttachmentRecord from '../graph/ContentAttachmentRecord.ts'; import ContentAttachmentSize from '../graph/ContentAttachmentSize.ts'; @@ -129,7 +129,7 @@ function contentRecordFromRegisters( return new ContentAttachmentRecord({ owner, payload: new ContentAttachmentPayload({ - oid: new ContentAttachmentOid(registers.content.value), + handle: new ContentAttachmentHandle(registers.content.value), mime: contentMimeFromRegister(registers.content, registers.mime), size: contentSizeFromRegister(registers.content, registers.size), }), @@ -140,7 +140,7 @@ function contentRecordFromRegisters( function isProjectableContentRegister(register: Register | null | undefined): register is ContentRegister { return register !== null && register !== undefined - && isProjectableContentOidValue(register.value); + && isProjectableContentHandleValue(register.value); } /** Filters edge registers hidden by edge rebirth. */ @@ -218,7 +218,7 @@ function isProjectableMimeValue(value: PropValue): value is string { } /** Returns true when a value can become an attachment storage reference. */ -function isProjectableContentOidValue(value: PropValue): value is string { +function isProjectableContentHandleValue(value: PropValue): value is string { return typeof value === 'string' && value.length > 0 && !value.includes('\0'); } diff --git a/src/domain/services/CoordinateFactExport.ts b/src/domain/services/CoordinateFactExport.ts index 35016ec4..a90cbadb 100644 --- a/src/domain/services/CoordinateFactExport.ts +++ b/src/domain/services/CoordinateFactExport.ts @@ -118,7 +118,7 @@ function serializeNodeContentOp(op: VisibleStateTransferOperation): VisibleState return { op: op.op, nodeId: op['nodeId'], - contentOid: op['contentOid'], + contentHandle: op['contentHandle'], mime: op['mime'] ?? null, size: op['size'] ?? null, }; @@ -133,7 +133,7 @@ function serializeEdgeContentOp(op: VisibleStateTransferOperation): VisibleState from: op['from'], to: op['to'], label: op['label'], - contentOid: op['contentOid'], + contentHandle: op['contentHandle'], mime: op['mime'] ?? null, size: op['size'] ?? null, }; diff --git a/src/domain/services/MaterializedViewHelpers.ts b/src/domain/services/MaterializedViewHelpers.ts index 4874e7fa..970157de 100644 --- a/src/domain/services/MaterializedViewHelpers.ts +++ b/src/domain/services/MaterializedViewHelpers.ts @@ -1,7 +1,7 @@ /** * Low-level helpers for MaterializedViewService. * - * Provides in-memory property reader construction, shard OID partitioning, + * Provides in-memory property reader construction, shard-handle partitioning, * and the legacy shard-to-tree-entry mapper. * * P5-LEGACY: _shardToEntry and buildInMemoryPropertyReader exist to support @@ -19,8 +19,8 @@ import { ReceiptShard } from '../artifacts/ReceiptShard.ts'; import IndexError from '../errors/IndexError.ts'; import PropertyIndexReader from './index/PropertyIndexReader.ts'; import type CodecPort from '../../ports/CodecPort.ts'; -import type IndexStoragePort from '../../ports/IndexStoragePort.ts'; import type { IndexShard } from '../artifacts/IndexShard.ts'; +import type AssetHandle from '../storage/AssetHandle.ts'; /** Prefix for property shard paths in the index tree. */ export const PROPS_PREFIX = 'props_'; @@ -34,48 +34,35 @@ export function buildInMemoryPropertyReader( tree: Record, codec: CodecPort, ): PropertyIndexReader { - const propShardOids = new Map(); - for (const path of Object.keys(tree)) { - if (path.startsWith(PROPS_PREFIX)) { - propShardOids.set(path, path); - } - } - - // PropertyIndexReader is a .js file with JSDoc types; only `readBlob` is - // called at runtime. The narrower in-memory object satisfies the runtime - // contract of IndexStoragePort, so this seam cast is safe. - const storage = { - /** Reads a shard blob from the in-memory tree map. */ - readBlob: (oid: string): Promise => Promise.resolve(tree[oid] as Uint8Array), - } as unknown as IndexStoragePort; // nosemgrep: ts-no-double-cast -- 0025A; nosemgrep: ts-no-unknown-outside-adapters -- 0025B - - const reader = new PropertyIndexReader({ storage, codec }); - reader.setup(Object.fromEntries(propShardOids)); + const reader = new PropertyIndexReader({ codec }); + reader.setupTree(tree); return reader; } -// ── Shard OID partitioning ──────────────────────────────────────────────────── +// ── Shard handle partitioning ───────────────────────────────────────────────── /** - * Partitions shard OID entries into index vs property buckets. + * Partitions shard handles into index vs property buckets. */ -export function partitionShardOids(shardOids: Record): { - indexOids: Record; - propOids: Record; +export function partitionShardHandles( + shardHandles: Readonly>, +): { + indexHandles: Readonly>; + propHandles: Readonly>; } { - const indexOids = new Map(); - const propOids = new Map(); + const indexHandles = new Map(); + const propHandles = new Map(); - for (const [path, oid] of Object.entries(shardOids)) { + for (const [path, handle] of Object.entries(shardHandles)) { if (path.startsWith(PROPS_PREFIX)) { - propOids.set(path, oid); + propHandles.set(path, handle); } else { - indexOids.set(path, oid); + indexHandles.set(path, handle); } } return { - indexOids: Object.fromEntries(indexOids), - propOids: Object.fromEntries(propOids), + indexHandles: Object.freeze(Object.fromEntries(indexHandles)), + propHandles: Object.freeze(Object.fromEntries(propHandles)), }; } diff --git a/src/domain/services/MaterializedViewService.ts b/src/domain/services/MaterializedViewService.ts index 5f55482e..d9124321 100644 --- a/src/domain/services/MaterializedViewService.ts +++ b/src/domain/services/MaterializedViewService.ts @@ -2,10 +2,8 @@ * Orchestrates building, persisting, and loading a MaterializedView * composed of a LogicalIndex + PropertyIndexReader. * - * Five entry points: + * Three entry points: * - `build(state)` — from a WarpState (in-memory) - * - `persistIndexTree(tree, persistence)` — write shards to Git storage - * - `loadFromOids(shardOids, storage)` — hydrate from blob OIDs * - `applyDiff(existingTree, diff, state)` — incremental update from PatchDiff * - `verifyIndex({ state, logicalIndex, options })` — cross-provider verification * @@ -14,16 +12,12 @@ import LogicalIndexBuildService from './index/LogicalIndexBuildService.ts'; import LogicalIndexReader from './index/LogicalIndexReader.ts'; -import PropertyIndexReader from './index/PropertyIndexReader.ts'; +import type PropertyIndexReader from './index/PropertyIndexReader.ts'; import IncrementalIndexUpdater from './index/IncrementalIndexUpdater.ts'; -import WarpError from '../errors/WarpError.ts'; import { requireCodec } from './codec/CodecRequirement.ts'; -import { buildInMemoryPropertyReader, partitionShardOids, shardToEntry } from './MaterializedViewHelpers.ts'; +import { buildInMemoryPropertyReader, shardToEntry } from './MaterializedViewHelpers.ts'; import { verifyIndex, type VerifyResult, type VerifyIndexParams } from './MaterializedViewVerifier.ts'; import type CodecPort from '../../ports/CodecPort.ts'; -import type LoggerPort from '../../ports/LoggerPort.ts'; -import type IndexStorePort from '../../ports/IndexStorePort.ts'; -import type IndexStoragePort from '../../ports/IndexStoragePort.ts'; import type WarpState from './state/WarpState.ts'; import type { PatchDiff } from '../types/PatchDiff.ts'; import type { IndexShard } from '../artifacts/IndexShard.ts'; @@ -40,27 +34,18 @@ export interface BuildResult { receipt: Record; // nosemgrep: ts-no-record-string-unknown-outside-adapters -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B } -export interface LoadResult { - logicalIndex: LogicalIndex; - propertyReader: PropertyIndexReader; -} - // ── Service ─────────────────────────────────────────────────────────────────── export interface MaterializedViewServiceOptions { codec?: CodecPort; - logger?: LoggerPort; - indexStore?: IndexStorePort; } export default class MaterializedViewService { private readonly _codec: CodecPort; - private readonly _indexStore: IndexStorePort | null; constructor(options?: MaterializedViewServiceOptions) { - const { codec, indexStore } = options ?? {}; + const { codec } = options ?? {}; this._codec = requireCodec(codec, 'MaterializedViewService'); - this._indexStore = indexStore ?? null; } /** @@ -94,78 +79,6 @@ export default class MaterializedViewService { }; } - /** - * Writes each shard as a blob and creates a Git tree object. - * - * @returns tree OID - */ - async persistIndexTree( - tree: Record, - persistence: { - writeBlob(buf: Uint8Array): Promise; - writeTree(entries: string[]): Promise; - }, - ): Promise { - const paths = Object.keys(tree).sort(); - const oids = await Promise.all( - paths.map((p) => { - const blob = tree[p]; - if (!blob) { - throw new WarpError( - `Missing blob for path: ${p}`, - 'E_MATERIALIZED_VIEW_MISSING_BLOB', - { context: { path: p } }, - ); - } - return persistence.writeBlob(blob); - }), - ); - - const entries = paths.map((path, i) => { - const oid = oids[i]; - if (oid === undefined) { - throw new WarpError( - `Missing blob OID for path: ${path}`, - 'E_MATERIALIZED_VIEW_MISSING_BLOB_OID', - { context: { path } }, - ); - } - return `100644 blob ${oid}\t${path}`; - }); - return await persistence.writeTree(entries); - } - - /** - * Hydrates a LogicalIndex + PropertyIndexReader from blob OIDs. - * - * @param shardOids - path to blob OID - * @param storage - blob storage backend - */ - async loadFromOids( - shardOids: Record, - storage: { readBlob(oid: string): Promise }, - ): Promise { - const { indexOids, propOids } = partitionShardOids(shardOids); - - const reader = new LogicalIndexReader({ - codec: this._codec, - ...(this._indexStore ? { indexStore: this._indexStore } : {}), - }); - await reader.loadFromOids(indexOids, storage); - const logicalIndex = reader.toLogicalIndex(); - - // PropertyIndexReader is a .js file that only calls `readBlob` at runtime. - // The caller's narrower type satisfies the runtime contract. - const propertyReader = new PropertyIndexReader({ - storage: storage as unknown as IndexStoragePort, // nosemgrep: ts-no-double-cast -- 0025A; nosemgrep: ts-no-unknown-outside-adapters -- 0025B - codec: this._codec, - ...(this._indexStore ? { indexStore: this._indexStore } : {}), - }); - propertyReader.setup(propOids); - - return { logicalIndex, propertyReader }; - } - /** * Applies a PatchDiff incrementally to an existing index tree. */ diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index f69ca836..2befc7a1 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -39,12 +39,14 @@ import { } from './PatchBuilderContent.ts'; import { requireCommitMessageCodec } from './codec/CommitMessageCodecRequirement.ts'; import { commitPatch } from './PatchCommitter.ts'; +import type { PatchCommitResult } from '../types/PatchCommitResult.ts'; import type { WarpState } from './JoinReducer.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 AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; +import type AssetHandle from '../storage/AssetHandle.ts'; type DeletePolicy = 'reject' | 'cascade' | 'warn'; @@ -57,12 +59,12 @@ type PatchBuilderOptions = { getCurrentState: () => WarpState | null; expectedParentSha?: string | null; targetRefPath?: string; - onCommitSuccess?: ((result: { patch: Patch; sha: string }) => void | Promise) | null; + onCommitSuccess?: ((result: PatchCommitResult) => void | Promise) | null; onDeleteWithData?: DeletePolicy; patchJournal?: PatchJournalPort; commitMessageCodec?: CommitMessageCodecPort; logger?: LoggerPort; - blobStorage?: BlobStoragePort; + assetStorage?: AssetStoragePort; }; export class PatchBuilder { @@ -74,18 +76,18 @@ export class PatchBuilder { private readonly _vv: VersionVector; private readonly _getCurrentState: () => WarpState | null; private readonly _expectedParentSha: string | null; - private readonly _onCommitSuccess: ((result: { patch: Patch; sha: string }) => void | Promise) | null; + private readonly _onCommitSuccess: ((result: PatchCommitResult) => void | Promise) | null; private readonly _onDeleteWithData: DeletePolicy; private readonly _patchJournal: PatchJournalPort | null; private readonly _commitMessageCodec: CommitMessageCodecPort | null; private readonly _logger: LoggerPort; - private readonly _blobStorage: BlobStoragePort | null; + private readonly _assetStorage: AssetStoragePort | null; private readonly _ops: PatchOp[] = []; private readonly _nodesAdded = new Set(); private readonly _edgesAdded = new Set(); private readonly _observedOperands = new Set(); private readonly _writes = new Set(); - private readonly _contentBlobs: string[] = []; + private readonly _contentAssets: AssetHandle[] = []; private _snapshotState: WarpState | null | undefined = undefined; private _hasEdgeProps = false; private _committed = false; @@ -106,7 +108,7 @@ export class PatchBuilder { this._patchJournal = options.patchJournal ?? null; this._commitMessageCodec = options.commitMessageCodec ?? null; this._logger = options.logger ?? nullLogger; - this._blobStorage = options.blobStorage ?? null; + this._assetStorage = options.assetStorage ?? null; } // ── State access ─────────────────────────────────────────────────── @@ -281,19 +283,19 @@ export class PatchBuilder { assertNoReservedBytes(nodeId, 'nodeId'); assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); this._assertNodeExistsForContent(nodeId); - if (!this._blobStorage) { - throw new WriterError('Cannot attach content without blob storage; configure content storage when opening the runtime', { code: 'NO_BLOB_STORAGE' }); + if (this._assetStorage === null) { + throw new WriterError('Cannot attach content without asset storage', { code: 'NO_ASSET_STORAGE' }); } const slug = `${this._graphName}/${nodeId}`; const payload = await storeContentAttachmentPayload({ - blobStorage: this._blobStorage, + assetStorage: this._assetStorage, content, metadata, slug, }); const intent = ContentAttachmentWriteIntent.forNode(nodeId, payload); this._lowerNodeContentIntent(intent); - this._contentBlobs.push(intent.oid()); + this._contentAssets.push(intent.handle()); return this; } @@ -319,19 +321,19 @@ export class PatchBuilder { assertNoReservedBytes(label, 'label'); assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); this._assertEdgeExists(from, to, label); - if (!this._blobStorage) { - throw new WriterError('Cannot attach content without blob storage; configure content storage when opening the runtime', { code: 'NO_BLOB_STORAGE' }); + if (this._assetStorage === null) { + throw new WriterError('Cannot attach content without asset storage', { code: 'NO_ASSET_STORAGE' }); } const slug = `${this._graphName}/${from}/${to}/${label}`; const payload = await storeContentAttachmentPayload({ - blobStorage: this._blobStorage, + assetStorage: this._assetStorage, content, metadata, slug, }); const intent = ContentAttachmentWriteIntent.forEdge({ from, to, label }, payload); this._lowerEdgeContentIntent(intent); - this._contentBlobs.push(intent.oid()); + this._contentAssets.push(intent.handle()); return this; } @@ -350,14 +352,20 @@ export class PatchBuilder { private _lowerNodeContentIntent(intent: ContentAttachmentWriteIntent): void { const nodeId = intent.nodeId(); - this.setProperty(nodeId, CONTENT_PROPERTY_KEY, intent.oid()); + this.setProperty(nodeId, CONTENT_PROPERTY_KEY, intent.handle().toString()); this.setProperty(nodeId, CONTENT_SIZE_PROPERTY_KEY, intent.size()); this.setProperty(nodeId, CONTENT_MIME_PROPERTY_KEY, intent.mime()); } private _lowerEdgeContentIntent(intent: ContentAttachmentWriteIntent): void { const target = intent.edgeTarget(); - this.setEdgeProperty(target.from, target.to, target.label, CONTENT_PROPERTY_KEY, intent.oid()); + this.setEdgeProperty( + target.from, + target.to, + target.label, + CONTENT_PROPERTY_KEY, + intent.handle().toString(), + ); this.setEdgeProperty(target.from, target.to, target.label, CONTENT_SIZE_PROPERTY_KEY, intent.size()); this.setEdgeProperty(target.from, target.to, target.label, CONTENT_MIME_PROPERTY_KEY, intent.mime()); } @@ -425,10 +433,15 @@ export class PatchBuilder { } async commit(): Promise { + return (await this.commitWithEvidence()).sha; + } + + /** Commits and returns the storage-retention evidence for the causal patch. */ + async commitWithEvidence(): Promise { this._assertNotCommitted(); this._committing = true; try { - const sha = await commitPatch({ + const result = await commitPatch({ persistence: this._persistence, graphName: this._graphName, writerId: this._writerId, @@ -440,14 +453,14 @@ export class PatchBuilder { hasEdgeProps: this._hasEdgeProps, expectedParentSha: this._expectedParentSha, targetRefPath: this._targetRefPath, - contentBlobs: this._contentBlobs, + contentAssets: this._contentAssets, patchJournal: this._patchJournal, commitMessageCodec: requireCommitMessageCodec(this._commitMessageCodec), logger: this._logger, onCommitSuccess: this._onCommitSuccess, }); this._committed = true; - return sha; + return result; } finally { this._committing = false; } @@ -461,9 +474,7 @@ export class PatchBuilder { get writes(): ReadonlySet { return new Set(this._writes); } /** - * Content-blob OIDs captured via `attachNodeContent` / `attachEdgeContent`. - * Exposed for strand-overlay queued-intent assembly, which needs the - * snapshot of blob OIDs to persist alongside the patch entry. + * Asset handles captured via content attachment operations. */ - get contentBlobs(): readonly string[] { return [...this._contentBlobs]; } + get contentAssets(): readonly AssetHandle[] { return [...this._contentAssets]; } } diff --git a/src/domain/services/PatchBuilderContent.ts b/src/domain/services/PatchBuilderContent.ts index 50faa2e5..671ece1e 100644 --- a/src/domain/services/PatchBuilderContent.ts +++ b/src/domain/services/PatchBuilderContent.ts @@ -1,33 +1,28 @@ -import type { BlobStorageOptions } from '../../ports/BlobStoragePort.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; -import PatchError from '../errors/PatchError.ts'; +import ContentAttachmentHandle from '../graph/ContentAttachmentHandle.ts'; import ContentAttachmentMime from '../graph/ContentAttachmentMime.ts'; -import ContentAttachmentOid from '../graph/ContentAttachmentOid.ts'; import ContentAttachmentPayload from '../graph/ContentAttachmentPayload.ts'; import ContentAttachmentSize from '../graph/ContentAttachmentSize.ts'; +import PatchError from '../errors/PatchError.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import type { AssetWriteOptions } from '../../ports/AssetStoragePort.ts'; import { isPropValue, type PropValue } from '../types/PropValue.ts'; import { isStreamingInput, normalizeToAsyncIterable } from '../utils/streamUtils.ts'; import { normalizeContentMetadata } from './PatchBuilderValidation.ts'; -export type ContentInput = AsyncIterable | ReadableStream | Uint8Array | string; +export type ContentInput = + | AsyncIterable + | ReadableStream + | Uint8Array + | string; export type ContentMetadataInput = { mime?: string | null; size?: number | null }; export type StoreContentAttachmentPayloadOptions = { - readonly blobStorage: BlobStoragePort; + readonly assetStorage: AssetStoragePort; readonly content: ContentInput; readonly metadata: ContentMetadataInput | undefined; readonly slug: string; }; -type BufferedContentAttachmentPayloadOptions = Omit & { - readonly content: Uint8Array | string; -}; - -type StreamingContentMetadata = { - readonly mime: ContentAttachmentMime | null; - readonly size: ContentAttachmentSize | null; -}; - /** Validates public patch property values before intent construction. */ export function requirePatchPropertyValue(value: T): PropValue { if (isPropValue(value)) { @@ -41,91 +36,57 @@ export function requirePatchPropertyValue(value: T): PropValue { export async function storeContentAttachmentPayload( options: StoreContentAttachmentPayloadOptions, ): Promise { - if (isBufferedContent(options.content)) { - return await storeBufferedContentAttachmentPayload({ - blobStorage: options.blobStorage, - content: options.content, - metadata: options.metadata, - slug: options.slug, - }); - } - return await storeStreamingContentAttachmentPayload(options); -} - -async function storeBufferedContentAttachmentPayload( - options: BufferedContentAttachmentPayloadOptions, -): Promise { - const normalizedMeta = normalizeContentMetadata(options.content, options.metadata); - const storageOptions = contentStorageOptions(options.slug, normalizedMeta.mime, normalizedMeta.size); - const oid = await options.blobStorage.store(options.content, storageOptions); - return contentAttachmentPayload(oid, normalizedMeta.mime, normalizedMeta.size); -} - -async function storeStreamingContentAttachmentPayload( - options: StoreContentAttachmentPayloadOptions, -): Promise { - const metadata = streamingContentMetadata(options.metadata); - const storageOptions = contentStorageOptions( - options.slug, - contentAttachmentMimeString(metadata.mime), - contentAttachmentSizeNumber(metadata.size), - ); - const oid = await options.blobStorage.storeStream( + const metadata = contentMetadata(options.content, options.metadata); + const staged = await options.assetStorage.stage( normalizeToAsyncIterable(options.content), - storageOptions, + assetWriteOptions(options.slug, metadata.expectedSize), ); return new ContentAttachmentPayload({ - oid: new ContentAttachmentOid(oid), - mime: metadata.mime, - size: metadata.size, + handle: new ContentAttachmentHandle(staged.handle.toString()), + mime: metadata.mime === null ? null : new ContentAttachmentMime(metadata.mime), + size: new ContentAttachmentSize(staged.size), }); } +function contentMetadata( + content: ContentInput, + metadata: ContentMetadataInput | undefined, +): { readonly mime: string | null; readonly expectedSize: number | null } { + if (!isStreamingInput(content)) { + const normalized = normalizeContentMetadata(content, metadata); + return { mime: normalized.mime, expectedSize: normalized.size }; + } + return streamingContentMetadata(metadata); +} + function streamingContentMetadata( metadata: ContentMetadataInput | undefined, -): StreamingContentMetadata { +): { readonly mime: string | null; readonly expectedSize: number | null } { return { - mime: contentAttachmentMime(metadata?.mime ?? null), - size: contentAttachmentSize(metadata?.size ?? null), + mime: optionalMime(metadataMime(metadata)), + expectedSize: optionalSize(metadataSize(metadata)), }; } -function contentAttachmentMimeString(mime: ContentAttachmentMime | null): string | null { - return mime === null ? null : mime.toString(); +function metadataMime(metadata: ContentMetadataInput | undefined): string | null { + return metadata?.mime ?? null; } -function contentAttachmentSizeNumber(size: ContentAttachmentSize | null): number | null { - return size === null ? null : size.toNumber(); -} - -function isBufferedContent(content: ContentInput): content is Uint8Array | string { - return !isStreamingInput(content); -} - -function contentAttachmentPayload( - oid: string, - mime: string | null, - size: number | null, -): ContentAttachmentPayload { - return new ContentAttachmentPayload({ - oid: new ContentAttachmentOid(oid), - mime: contentAttachmentMime(mime), - size: contentAttachmentSize(size), - }); +function metadataSize(metadata: ContentMetadataInput | undefined): number | null { + return metadata?.size ?? null; } -function contentAttachmentMime(mime: string | null): ContentAttachmentMime | null { - return mime === null ? null : new ContentAttachmentMime(mime); +function optionalMime(value: string | null): string | null { + return value === null ? null : new ContentAttachmentMime(value).toString(); } -function contentAttachmentSize(size: number | null): ContentAttachmentSize | null { - return size === null ? null : new ContentAttachmentSize(size); +function optionalSize(value: number | null): number | null { + return value === null ? null : new ContentAttachmentSize(value).toNumber(); } -function contentStorageOptions( +function assetWriteOptions( slug: string, - mime: string | null, - size: number | null, -): BlobStorageOptions { - return { slug, mime, size }; + expectedSize: number | null, +): AssetWriteOptions { + return { slug, filename: 'content', expectedSize }; } diff --git a/src/domain/services/PatchCommitter.ts b/src/domain/services/PatchCommitter.ts index 18bbacad..bc24c5d6 100644 --- a/src/domain/services/PatchCommitter.ts +++ b/src/domain/services/PatchCommitter.ts @@ -10,19 +10,20 @@ import VersionVector from '../crdt/VersionVector.ts'; import Patch from '../types/Patch.ts'; +import type { PatchCommitResult } from '../types/PatchCommitResult.ts'; import { lowerCanonicalOp } from './OpNormalizer.ts'; import { buildWriterRef } from '../utils/RefLayout.ts'; import WriterError from '../errors/WriterError.ts'; import PatchError from '../errors/PatchError.ts'; import PersistenceError from '../errors/PersistenceError.ts'; +import PatchPublicationConflictError from '../errors/PatchPublicationConflictError.ts'; import type { PatchOp, CanonicalPatchOp } from '../types/ops/unions.ts'; import type WarpKernelPort from '../../ports/WarpKernelPort.ts'; import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; +import type { PublishedPatch } from '../../ports/PatchJournalPort.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; -import { - isGitCasPatchStorage, - type default as CommitMessageCodecPort, -} from '../../ports/CommitMessageCodecPort.ts'; +import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; +import type AssetHandle from '../storage/AssetHandle.ts'; export type CommitState = { persistence: WarpKernelPort; @@ -36,11 +37,11 @@ export type CommitState = { hasEdgeProps: boolean; expectedParentSha: string | null; targetRefPath: string | null; - contentBlobs: string[]; + contentAssets: AssetHandle[]; patchJournal: PatchJournalPort | null; commitMessageCodec: CommitMessageCodecPort; logger: LoggerPort; - onCommitSuccess: ((result: { patch: Patch; sha: string }) => void | Promise) | null; + onCommitSuccess: ((result: PatchCommitResult) => void | Promise) | null; }; /** @@ -49,7 +50,7 @@ export type CommitState = { * Steps: CAS check → lamport resolution → build Patch → persist blob → * build tree → create commit → update ref → invoke callback. */ -export async function commitPatch(state: CommitState): Promise { +export async function commitPatch(state: CommitState): Promise { if (state.ops.length === 0) { throw new PatchError('Cannot commit empty patch: no operations added', { code: 'E_PATCH_EMPTY' }); } @@ -104,53 +105,57 @@ export async function commitPatch(state: CommitState): Promise { writes: [...state.writes].sort(), }); - // Persist patch blob + // Publish one storage-owned bundle rooted by the causal writer ref. if (state.patchJournal === null || state.patchJournal === undefined) { throw new PersistenceError('patchJournal is required for committing patches', 'E_MISSING_JOURNAL'); } - const patchBlobOid = await state.patchJournal.writePatch(patch); - const patchStorage = state.patchJournal.writeStorage; - - // Build tree with patch blob + content blobs - const treeEntries = [isGitCasPatchStorage(patchStorage) - ? `040000 tree ${patchBlobOid}\tpatch` - : `100644 blob ${patchBlobOid}\tpatch.cbor`]; - const uniqueBlobs = [...new Set(state.contentBlobs)]; - for (const blobOid of uniqueBlobs) { - treeEntries.push(`040000 tree ${blobOid}\t_content_${blobOid}`); - } - const treeOid = await state.persistence.writeTree(treeEntries); - - // Create commit - const message = state.commitMessageCodec.encodePatch({ - kind: 'patch', - graph: state.graphName, - writer: state.writerId, - lamport, - patchOid: patchBlobOid, - schema, - storage: patchStorage, - }); - const parents = (parentCommit !== null && parentCommit !== '') ? [parentCommit] : []; - const newCommitSha = await state.persistence.commitNodeWithTree({ - treeOid, parents, message, - }); - - // Atomically update writer ref - await compareAndSwapWriterRef(state.persistence, writerRef, newCommitSha, state.expectedParentSha); - await assertWriterRefVisible(state.persistence, writerRef, newCommitSha); + const published = await publishPatch( + state, + state.patchJournal, + writerRef, + parentCommit, + patch, + ); + const result: PatchCommitResult = Object.freeze({ patch, ...published }); // Invoke success callback if (state.onCommitSuccess) { try { - await state.onCommitSuccess({ patch, sha: newCommitSha }); + await state.onCommitSuccess(result); } catch (err) { const errValue = err instanceof Error ? err : String(err); - state.logger.warn(`[warp] onCommitSuccess callback failed (sha=${newCommitSha}):`, { error: errValue }); + state.logger.warn(`[warp] onCommitSuccess callback failed (sha=${result.sha}):`, { error: errValue }); } } - return newCommitSha; + return result; +} + +async function publishPatch( + state: CommitState, + patchJournal: PatchJournalPort, + writerRef: string, + parentCommit: string | null, + patch: Patch, +): Promise { + try { + return await patchJournal.appendPatch({ + patch, + graph: state.graphName, + writer: state.writerId, + targetRef: writerRef, + expectedHead: state.expectedParentSha, + parent: parentCommit, + attachments: state.contentAssets, + }); + } catch (error) { + const actualSha = await state.persistence.readRef(writerRef); + if (actualSha !== state.expectedParentSha + || error instanceof PatchPublicationConflictError) { + throw buildWriterCasConflict(state.expectedParentSha, actualSha); + } + throw error; + } } /** Builds a WriterError that preserves expected and actual writer-ref heads. */ @@ -163,48 +168,3 @@ function buildWriterCasConflict(expectedSha: string | null, actualSha: string | err.actualSha = actualSha; return err; } - -/** Advances a writer ref atomically and translates stale-head failures. */ -async function compareAndSwapWriterRef( - persistence: WarpKernelPort, - writerRef: string, - newCommitSha: string, - expectedSha: string | null, -): Promise { - try { - await persistence.compareAndSwapRef(writerRef, newCommitSha, expectedSha); - } catch (err) { - const actualSha = await persistence.readRef(writerRef); - if (actualSha === newCommitSha) { - return; - } - if (actualSha !== expectedSha) { - throw buildWriterCasConflict(expectedSha, actualSha); - } - throw err; - } -} - -/** Verifies that a successful commit is immediately visible at the writer ref. */ -async function assertWriterRefVisible( - persistence: WarpKernelPort, - writerRef: string, - expectedSha: string, -): Promise { - const actualSha = await persistence.readRef(writerRef); - if (actualSha === expectedSha) { - return; - } - - throw new PersistenceError( - `Commit ${expectedSha} is not visible at writer ref ${writerRef}`, - PersistenceError.E_REF_IO, - { - context: { - writerRef, - expectedSha, - actualSha: actualSha ?? '(missing)', - }, - }, - ); -} diff --git a/src/domain/services/WormholeService.ts b/src/domain/services/WormholeService.ts index 61e011eb..36553d4c 100644 --- a/src/domain/services/WormholeService.ts +++ b/src/domain/services/WormholeService.ts @@ -22,19 +22,14 @@ import ProvenancePayload from './provenance/ProvenancePayload.ts'; import WormholeError from '../errors/WormholeError.ts'; -import EncryptionError from '../errors/EncryptionError.ts'; -import PersistenceError from '../errors/PersistenceError.ts'; import { requireCommitMessageCodec } from './codec/CommitMessageCodecRequirement.ts'; -import { requireCodec } from './codec/CodecRequirement.ts'; import type CommitPort from '../../ports/CommitPort.ts'; -import type BlobPort from '../../ports/BlobPort.ts'; -import type CodecPort from '../../ports/CodecPort.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; +import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; import type Patch from '../types/Patch.ts'; import type WarpState from './state/WarpState.ts'; -type PersistencePort = CommitPort & BlobPort; +type PersistencePort = CommitPort; /** * Represents a compressed range of patches (wormhole). @@ -102,15 +97,13 @@ interface ProcessCommitOptions { graphName: string; expectedWriter: string | null; commitMessageCodec: CommitMessageCodecPort; - codec?: CodecPort; - blobStorage?: BlobStoragePort; - patchBlobStorage?: BlobStoragePort; + patchJournal: PatchJournalPort; } /** * Processes a single commit in the wormhole chain. * @throws {WormholeError} On validation errors - * @throws {EncryptionError} If the patch is encrypted but no patchBlobStorage is provided + * @throws {EncryptionError} If configured asset decryption cannot read a patch */ async function processCommit({ persistence, @@ -118,11 +111,8 @@ async function processCommit({ graphName, expectedWriter, commitMessageCodec, - codec: codecOpt, - blobStorage, - patchBlobStorage, + patchJournal, }: ProcessCommitOptions): Promise { - const codec = requireCodec(codecOpt, 'WormholeService.processCommit'); const messageCodec = requireCommitMessageCodec(commitMessageCodec); const nodeInfo = await persistence.getNodeInfo(sha); const { message, parents } = nodeInfo; @@ -152,32 +142,7 @@ async function processCommit({ }); } - let patchBuffer: Uint8Array; - if (patchMeta.storage.strategy === 'git-cas') { - if (!blobStorage) { - throw new EncryptionError( - 'This graph contains git-cas patches; provide blobStorage for CAS restore', - ); - } - patchBuffer = await blobStorage.retrieve(patchMeta.patchOid); - } else if (patchMeta.storage.strategy === 'legacy-external-storage') { - if (!patchBlobStorage) { - throw new EncryptionError( - 'This graph contains encrypted patches in legacy external storage; provide patchBlobStorage with an encryption key', - ); - } - patchBuffer = await patchBlobStorage.retrieve(patchMeta.patchOid); - } else { - patchBuffer = await persistence.readBlob(patchMeta.patchOid); - } - if (patchBuffer === null || patchBuffer === undefined) { - throw new PersistenceError( - `Patch blob not found: ${patchMeta.patchOid}`, - PersistenceError.E_MISSING_OBJECT, - { context: { oid: patchMeta.patchOid } }, - ); - } - const patch = codec.decode(patchBuffer); + const patch = await patchJournal.readPatch(patchMeta); return { patch, @@ -193,9 +158,7 @@ interface CollectPatchRangeOptions { fromSha: string; toSha: string; commitMessageCodec: CommitMessageCodecPort; - codec?: CodecPort; - blobStorage?: BlobStoragePort; - patchBlobStorage?: BlobStoragePort; + patchJournal: PatchJournalPort; } /** @@ -213,9 +176,7 @@ async function collectPatchRange({ fromSha, toSha, commitMessageCodec, - codec, - blobStorage, - patchBlobStorage, + patchJournal, }: CollectPatchRangeOptions): Promise> { const patches: Array<{ patch: Patch; sha: string; writerId: string }> = []; let currentSha: string | null = toSha; @@ -228,9 +189,7 @@ async function collectPatchRange({ graphName, expectedWriter: writerId, commitMessageCodec, - ...(codec !== undefined ? { codec } : {}), - ...(blobStorage !== undefined ? { blobStorage } : {}), - ...(patchBlobStorage !== undefined ? { patchBlobStorage } : {}), + patchJournal, }); writerId = result.writerId; patches.push({ patch: result.patch, sha: result.sha, writerId: result.writerId }); @@ -271,9 +230,7 @@ interface CreateWormholeOptions { fromSha: string; toSha: string; commitMessageCodec: CommitMessageCodecPort; - codec?: CodecPort; - blobStorage?: BlobStoragePort; - patchBlobStorage?: BlobStoragePort; + patchJournal: PatchJournalPort; } /** @@ -287,7 +244,7 @@ interface CreateWormholeOptions { * @throws {WormholeError} If fromSha is not an ancestor of toSha (E_WORMHOLE_INVALID_RANGE) * @throws {WormholeError} If commits span multiple writers (E_WORMHOLE_MULTI_WRITER) * @throws {WormholeError} If a commit is not a patch commit (E_WORMHOLE_NOT_PATCH) - * @throws {EncryptionError} If patches are encrypted but no patchBlobStorage is provided + * @throws {EncryptionError} If configured asset decryption cannot read a patch */ export async function createWormhole({ persistence, @@ -295,9 +252,7 @@ export async function createWormhole({ fromSha, toSha, commitMessageCodec, - codec, - blobStorage, - patchBlobStorage, + patchJournal, }: CreateWormholeOptions): Promise { validateSha(fromSha, 'fromSha'); validateSha(toSha, 'toSha'); @@ -310,9 +265,7 @@ export async function createWormhole({ fromSha, toSha, commitMessageCodec, - ...(codec !== undefined ? { codec } : {}), - ...(blobStorage !== undefined ? { blobStorage } : {}), - ...(patchBlobStorage !== undefined ? { patchBlobStorage } : {}), + patchJournal, }); // Reverse to get oldest-first order (as required by ProvenancePayload) diff --git a/src/domain/services/audit/AuditChainVerifier.ts b/src/domain/services/audit/AuditChainVerifier.ts index 460fc8c1..8786f547 100644 --- a/src/domain/services/audit/AuditChainVerifier.ts +++ b/src/domain/services/audit/AuditChainVerifier.ts @@ -1,10 +1,8 @@ /** Chain verification for tamper-evident audit receipt chains. */ import type CodecPort from '../../../ports/CodecPort.ts'; -import type CommitPort from '../../../ports/CommitPort.ts'; -import type RefPort from '../../../ports/RefPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; +import type AuditLogPort from '../../../ports/AuditLogPort.ts'; +import type { AuditLogEntry } from '../../../ports/AuditLogPort.ts'; import { buildAuditRef } from '../../utils/RefLayout.ts'; import { decodeAuditMessage } from '../codec/AuditMessageCodec.ts'; @@ -55,8 +53,6 @@ type AuditReceipt = { timestamp: number; }; -type Persistence = CommitPort & RefPort & BlobPort & TreePort; - function validateOidFormat(value: string): { valid: boolean; normalized: string; error?: string } { if (typeof value !== 'string') { return { valid: false, normalized: '', error: 'not a string' }; @@ -138,11 +134,11 @@ function validateTrailerConsistency( * Verifies a single writer's audit receipt chain from tip to genesis. */ export default class AuditChainVerifier { - private readonly _persistence: Persistence; + private readonly _auditLog: AuditLogPort; private readonly _codec: CodecPort; - constructor(persistence: Persistence, codec: CodecPort) { - this._persistence = persistence; + constructor(auditLog: AuditLogPort, codec: CodecPort) { + this._auditLog = auditLog; this._codec = codec; } @@ -168,8 +164,14 @@ export default class AuditChainVerifier { let tip: string | null; try { - tip = await this._persistence.readRef(ref); - } catch { + tip = await this._auditLog.readHead(graphName, writerId); + } catch (error) { + this._addError( + result, + 'AUDIT_HEAD_UNAVAILABLE', + `Cannot read audit head: ${error instanceof Error ? error.message : String(error)}`, + null, + ); return result; } if (typeof tip !== 'string' || tip.length === 0) { @@ -180,7 +182,7 @@ export default class AuditChainVerifier { result.tipAtStart = tip; await this._walkChain(graphName, writerId, tip, since, result); - await this._checkTipMoved(ref, result); + await this._checkTipMoved(graphName, writerId, result); return result; } @@ -199,16 +201,16 @@ export default class AuditChainVerifier { while (current !== null && current.length > 0) { result.receiptsScanned++; - let commitInfo; + let commitInfo: AuditLogEntry; try { - commitInfo = await this._persistence.getNodeInfo(current); + commitInfo = await this._auditLog.readEntry(current); } catch (err) { this._addError(result, 'MISSING_RECEIPT_BLOB', `Cannot read commit ${current}: ${err instanceof Error ? err.message : String(err)}`, current); return; } - const receiptResult = await this._readReceipt(current, commitInfo, result); + const receiptResult = this._readReceipt(current, commitInfo, result); if (!receiptResult) { return; } const { receipt, decodedTrailers } = receiptResult; @@ -307,7 +309,7 @@ export default class AuditChainVerifier { private _handleGenesisOrContinuation( receipt: AuditReceipt, - commitInfo: { parents: string[] }, + commitInfo: { parents: readonly string[] }, since: string | null, chainOidLen: number, current: string, @@ -354,55 +356,14 @@ export default class AuditChainVerifier { return true; } - private async _readReceipt( + private _readReceipt( commitSha: string, - commitInfo: { message: string }, + commitInfo: AuditLogEntry, result: ChainResult, - ): Promise<{ receipt: AuditReceipt; decodedTrailers: { graph: string; writer: string; dataCommit: string; opsDigest: string; schema: number } } | null> { - let treeOid: string; - try { - treeOid = await this._persistence.getCommitTree(commitSha); - } catch (err) { - this._addError(result, 'MISSING_RECEIPT_BLOB', - `Cannot read tree for ${commitSha}: ${err instanceof Error ? err.message : String(err)}`, commitSha); - return null; - } - - let treeEntries: Record; - try { - treeEntries = await this._persistence.readTreeOids(treeOid); - } catch (err) { - this._addError(result, 'RECEIPT_TREE_INVALID', - `Cannot read tree ${treeOid}: ${err instanceof Error ? err.message : String(err)}`, commitSha); - return null; - } - - const entryNames = Object.keys(treeEntries); - if (entryNames.length !== 1 || entryNames[0] !== 'receipt.cbor') { - this._addError(result, 'RECEIPT_TREE_INVALID', - `Expected exactly one entry 'receipt.cbor', got [${entryNames.join(', ')}]`, commitSha); - result.status = STATUS_BROKEN_CHAIN; - return null; - } - - const blobOid = treeEntries['receipt.cbor']; - if (blobOid === undefined) { - this._addError(result, 'MISSING_RECEIPT_BLOB', 'receipt.cbor entry missing from audit tree', commitSha); - return null; - } - - let blobContent: Uint8Array; - try { - blobContent = await this._persistence.readBlob(blobOid); - } catch (err) { - this._addError(result, 'MISSING_RECEIPT_BLOB', - `Cannot read receipt blob ${blobOid}: ${err instanceof Error ? err.message : String(err)}`, commitSha); - return null; - } - + ): { receipt: AuditReceipt; decodedTrailers: { graph: string; writer: string; dataCommit: string; opsDigest: string; schema: number } } | null { let receipt: AuditReceipt; try { - receipt = this._codec.decode(blobContent); + receipt = this._codec.decode(commitInfo.receipt); } catch (err) { this._addError(result, 'CBOR_DECODE_FAILED', `CBOR decode failed: ${err instanceof Error ? err.message : String(err)}`, commitSha); @@ -475,9 +436,13 @@ export default class AuditChainVerifier { return true; } - private async _checkTipMoved(ref: string, result: ChainResult): Promise { + private async _checkTipMoved( + graphName: string, + writerId: string, + result: ChainResult, + ): Promise { try { - const currentTip = await this._persistence.readRef(ref); + const currentTip = await this._auditLog.readHead(graphName, writerId); if (typeof currentTip === 'string' && currentTip.length > 0 && currentTip !== result.tipAtStart) { result.warnings.push({ code: 'TIP_MOVED_DURING_VERIFY', diff --git a/src/domain/services/audit/AuditReceiptService.ts b/src/domain/services/audit/AuditReceiptService.ts index 5e169569..ee9883a7 100644 --- a/src/domain/services/audit/AuditReceiptService.ts +++ b/src/domain/services/audit/AuditReceiptService.ts @@ -1,16 +1,14 @@ /** AuditReceiptService — persistent, chained, tamper-evident audit receipts. */ import AuditError from '../../errors/AuditError.ts'; -import { buildAuditRef } from '../../utils/RefLayout.ts'; +import AuditPublicationConflictError from '../../errors/AuditPublicationConflictError.ts'; import { encodeAuditMessage } from '../codec/AuditMessageCodec.ts'; import type { OpOutcome, TickReceipt } from '../../types/TickReceipt.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type RefPort from '../../../ports/RefPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; -import type CommitPort from '../../../ports/CommitPort.ts'; +import type AuditLogPort from '../../../ports/AuditLogPort.ts'; +import type { PublishedAuditRecord } from '../../../ports/AuditLogPort.ts'; // Constants @@ -191,11 +189,8 @@ export function buildReceiptRecord(fields: AuditReceiptFields): AuditReceipt { }); } -/** Combined persistence interface required by AuditReceiptService. */ -export type AuditPersistence = RefPort & BlobPort & TreePort & CommitPort; - export interface AuditReceiptServiceOptions { - persistence: AuditPersistence; + auditLog: AuditLogPort; graphName: string; writerId: string; codec: CodecPort; @@ -224,13 +219,12 @@ export interface AuditStats { * are detectable by M4 verification. */ export class AuditReceiptService { - private readonly _persistence: AuditPersistence; + private readonly _auditLog: AuditLogPort; private readonly _graphName: string; private readonly _writerId: string; private readonly _codec: CodecPort; private readonly _crypto: CryptoPort; private readonly _logger: LoggerPort | null; - private readonly _auditRef: string; /** Previous audit commit SHA (null = genesis) */ private _prevAuditCommit: string | null; @@ -249,14 +243,13 @@ export class AuditReceiptService { private _failed: number; /** Constructs an AuditReceiptService for the given writer audit chain. */ - constructor({ persistence, graphName, writerId, codec, crypto, logger }: AuditReceiptServiceOptions) { - this._persistence = persistence; + constructor({ auditLog, graphName, writerId, codec, crypto, logger }: AuditReceiptServiceOptions) { + this._auditLog = auditLog; this._graphName = graphName; this._writerId = writerId; this._codec = codec; this._crypto = crypto; this._logger = logger ?? null; - this._auditRef = buildAuditRef(graphName, writerId); this._prevAuditCommit = null; this._expectedOldRef = null; @@ -274,7 +267,7 @@ export class AuditReceiptService { */ async init(): Promise { try { - const tip = await this._persistence.readRef(this._auditRef); + const tip = await this._auditLog.readHead(this._graphName, this._writerId); if (tip !== null && tip !== undefined && tip.length > 0) { this._prevAuditCommit = tip; this._expectedOldRef = tip; @@ -286,7 +279,6 @@ export class AuditReceiptService { this._logger?.warn('[warp:audit]', { code: 'AUDIT_INIT_READ_FAILED', writerId: this._writerId, - ref: this._auditRef, }); this._prevAuditCommit = null; this._expectedOldRef = null; @@ -301,9 +293,9 @@ export class AuditReceiptService { * has a gap. This is acceptable by design in M3 — gaps are detected * by M4 verification coverage rules (receipt count vs data commit count). * - * @returns The audit commit SHA, or null on failure + * @returns The published audit record, or null on failure */ - async commit(tickReceipt: TickReceipt): Promise { + async commit(tickReceipt: TickReceipt): Promise { if (this._degraded) { this._skipped++; this._logger?.warn('[warp:audit]', { @@ -340,7 +332,7 @@ export class AuditReceiptService { * Inner commit logic. Throws on failure (caught by `commit()`). * @private */ - private async _commitInner(tickReceipt: TickReceipt): Promise { + private async _commitInner(tickReceipt: TickReceipt): Promise { const { patchSha, writer, lamport, ops } = tickReceipt; // Guard: reject cross-writer attribution @@ -380,37 +372,6 @@ export class AuditReceiptService { timestamp, }); - // Encode to CBOR - const cborBytes = this._codec.encode(receipt); - - // Write blob - let blobOid: string; - try { - blobOid = await this._persistence.writeBlob(cborBytes); - } catch (err) { - this._logger?.warn('[warp:audit]', { - code: 'AUDIT_WRITE_BLOB_FAILED', - writerId: this._writerId, - error: err instanceof Error ? err.message : String(err), - }); - throw err; - } - - // Write tree - let treeOid: string; - try { - treeOid = await this._persistence.writeTree([ - `100644 blob ${blobOid}\treceipt.cbor`, - ]); - } catch (err) { - this._logger?.warn('[warp:audit]', { - code: 'AUDIT_WRITE_TREE_FAILED', - writerId: this._writerId, - error: err instanceof Error ? err.message : String(err), - }); - throw err; - } - // Encode commit message with trailers const message = encodeAuditMessage({ graph: this._graphName, @@ -419,52 +380,50 @@ export class AuditReceiptService { opsDigest, }); - // Determine parents - const parents = (this._prevAuditCommit !== null && this._prevAuditCommit.length > 0) ? [this._prevAuditCommit] : []; - - // Create commit - const commitSha = await this._persistence.commitNodeWithTree({ - treeOid, - parents, - message, - }); - - // CAS ref update + let publication: PublishedAuditRecord; try { - await this._persistence.compareAndSwapRef( - this._auditRef, - commitSha, - this._expectedOldRef, - ); - } catch { + publication = await this._auditLog.append({ + graphName: this._graphName, + writerId: this._writerId, + expectedHead: this._expectedOldRef, + parent: this._prevAuditCommit, + message, + receipt: this._codec.encode(receipt), + }); + } catch (error) { + if (!(error instanceof AuditPublicationConflictError)) { + throw error; + } if (this._retrying) { // Second CAS failure during retry → degrade - throw new AuditError('CAS failed during retry', { code: AuditError.E_AUDIT_CAS_FAILED, context: { writerId: this._writerId, ref: this._auditRef } }); + throw new AuditError('CAS failed during retry', { + code: AuditError.E_AUDIT_CAS_FAILED, + context: { writerId: this._writerId }, + }); } // CAS mismatch — retry once with refreshed tip - return await this._retryAfterCasConflict(commitSha, tickReceipt); + return await this._retryAfterCasConflict(tickReceipt); } // Success — update cached state - this._prevAuditCommit = commitSha; - this._expectedOldRef = commitSha; + this._prevAuditCommit = publication.sha; + this._expectedOldRef = publication.sha; this._committed++; - return commitSha; + return publication; } /** * Retry-once after CAS conflict. Reads fresh tip, rebuilds receipt, retries. * @private */ - private async _retryAfterCasConflict(_failedCommitSha: string, tickReceipt: TickReceipt): Promise { + private async _retryAfterCasConflict(tickReceipt: TickReceipt): Promise { this._logger?.warn('[warp:audit]', { code: 'AUDIT_REF_CAS_CONFLICT', writerId: this._writerId, - ref: this._auditRef, }); // Read fresh tip - const freshTip = await this._persistence.readRef(this._auditRef); + const freshTip = await this._auditLog.readHead(this._graphName, this._writerId); this._prevAuditCommit = freshTip; this._expectedOldRef = freshTip; @@ -473,8 +432,11 @@ export class AuditReceiptService { try { const result = await this._commitInner(tickReceipt); return result; - } catch { - // Second failure → degraded mode + } catch (error) { + if (!(error instanceof AuditError) || error.code !== AuditError.E_AUDIT_CAS_FAILED) { + throw error; + } + // Second publication conflict → degraded mode this._degraded = true; this._logger?.warn('[warp:audit]', { code: 'AUDIT_DEGRADED_ACTIVE', diff --git a/src/domain/services/audit/AuditVerifierService.ts b/src/domain/services/audit/AuditVerifierService.ts index ab863568..2a69bd65 100644 --- a/src/domain/services/audit/AuditVerifierService.ts +++ b/src/domain/services/audit/AuditVerifierService.ts @@ -9,14 +9,10 @@ */ import type CodecPort from '../../../ports/CodecPort.ts'; -import type CommitPort from '../../../ports/CommitPort.ts'; -import type RefPort from '../../../ports/RefPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; +import type AuditLogPort from '../../../ports/AuditLogPort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; import type TrustChainPort from '../../../ports/TrustChainPort.ts'; import type TrustCryptoPort from '../../../ports/TrustCryptoPort.ts'; -import { buildAuditPrefix } from '../../utils/RefLayout.ts'; import AuditChainVerifier, { type ChainResult } from './AuditChainVerifier.ts'; import TrustEvaluationService, { type TrustEvaluationOptions } from './TrustEvaluationService.ts'; import type { TrustAssessment } from '../../trust/TrustAssessment.ts'; @@ -35,24 +31,22 @@ type VerifyResult = { trustWarning: TrustWarning | null; }; -type Persistence = CommitPort & RefPort & BlobPort & TreePort; - export default class AuditVerifierService { private readonly _chainVerifier: AuditChainVerifier; private readonly _trustService: TrustEvaluationService; - private readonly _persistence: Persistence; + private readonly _auditLog: AuditLogPort; readonly logger: LoggerPort | null; constructor(opts: { - persistence: Persistence; + auditLog: AuditLogPort; codec: CodecPort; logger?: LoggerPort; trustCrypto?: TrustCryptoPort; trustChain?: TrustChainPort; }) { - this._persistence = opts.persistence; + this._auditLog = opts.auditLog; this.logger = opts.logger ?? null; - this._chainVerifier = new AuditChainVerifier(opts.persistence, opts.codec); + this._chainVerifier = new AuditChainVerifier(opts.auditLog, opts.codec); this._trustService = new TrustEvaluationService({ listWriterIds: (graphName) => this._listWriterIds(graphName), ...(opts.trustChain !== undefined ? { trustChain: opts.trustChain } : {}), @@ -116,11 +110,7 @@ export default class AuditVerifierService { } private async _listWriterIds(graphName: string): Promise { - const prefix = buildAuditPrefix(graphName); - const refs: string[] = await this._persistence.listRefs(prefix); - return refs - .map((ref) => ref.slice(prefix.length)) - .filter((id) => id.length > 0); + return await this._auditLog.listWriterIds(graphName); } } diff --git a/src/domain/services/controllers/CheckpointController.ts b/src/domain/services/controllers/CheckpointController.ts index 28260152..95c4688c 100644 --- a/src/domain/services/controllers/CheckpointController.ts +++ b/src/domain/services/controllers/CheckpointController.ts @@ -10,14 +10,14 @@ import QueryError from '../../errors/QueryError.ts'; import PersistenceError from '../../errors/PersistenceError.ts'; import { SchemaUnsupportedError } from '../../errors/index.ts'; -import { buildWriterRef, buildCheckpointRef, buildCoverageRef } from '../../utils/RefLayout.ts'; +import { buildWriterRef } from '../../utils/RefLayout.ts'; import { createFrontier, updateFrontier, frontierFingerprint } from '../Frontier.ts'; import { CURRENT_CHECKPOINT_SCHEMA, isCurrentCheckpointSchema, } from '../state/checkpointHelpers.ts'; -import { loadCheckpoint, type LoadedCheckpoint, type LoadPersistence } from '../state/checkpointLoad.ts'; -import { create as createCheckpointCommit, type CheckpointPersistence } from '../state/checkpointCreate.ts'; +import { loadCheckpoint, type LoadedCheckpoint } from '../state/checkpointLoad.ts'; +import { create as createCheckpointCommit } from '../state/checkpointCreate.ts'; import executeGC from '../executeGC.ts'; import GCMetrics from '../GCMetrics.ts'; import { computeAppliedVV } from '../state/CheckpointSerializer.ts'; @@ -47,15 +47,13 @@ type CheckpointHost = { _graphName: string; _persistence: { readRef(ref: string): Promise; - updateRef(ref: string, oid: string): Promise; - commitNode(options: { message: string; parents: string[] }): Promise; getNodeInfo(sha: string): Promise<{ message: string }>; }; _cachedState: WarpState | null; _stateDirty: boolean; _checkpointing: boolean; _viewService: MaterializedViewService | null; - _checkpointStore: CheckpointStorePort | null; + _checkpointStore: CheckpointStorePort; _stateHashService: StateHashService | null; _provenanceIndex: ProvenanceIndex | null; _materializedGraph?: object | null; @@ -73,36 +71,10 @@ type CheckpointHost = { _lastFrontier: Map | null; _cachedViewHash: string | null; _stateCache: WarpStateCachePort | null; - _readPatchBlob(patchMeta: ReturnType): Promise; + _readPatch(patchMeta: ReturnType): Promise; discoverWriters(): Promise; }; -/** - * Narrows codec-decode output to `object | null`. CodecPort.decode - * currently returns a loose type (0025B1); wrapping the call in a - * dedicated narrowing function keeps that looseness inside this helper. - */ -function codecDecodeAsObject( - codec: CodecPort, - bytes: Uint8Array, -): object | null { - const out = codec.decode(bytes); - if (out === null || out === undefined || typeof out !== 'object') { return null; } - return out; -} - -/** - * Narrows a decoded patch to just the schema marker this controller - * needs. `in` check walks the shape defensively — callers branch on a - * typed `schema` field. - */ -function decodePatchSchema(decoded: object | null): { schema?: number } { - if (decoded === null) { return {}; } - if (!('schema' in decoded)) { return {}; } - const { schema } = decoded as { schema: number | string | boolean | null }; - return typeof schema === 'number' ? { schema } : {}; -} - /** * CheckpointController expects the host to expose the patch-loader and * compatibility-mixin surfaces below. The assertions keep that contract @@ -187,12 +159,9 @@ export default class CheckpointController { } } - const persistence = h._persistence; - this._assertCheckpointCreatePersistence(persistence); - const checkpointStore = h._checkpointStore ?? undefined; const stateHashService = h._stateHashService ?? null; const checkpointSha = await createCheckpointCommit({ - persistence, + checkpointStore: h._checkpointStore, graphName: h._graphName, state, frontier, @@ -200,20 +169,14 @@ export default class CheckpointController { ...(h._provenanceIndex ? { provenanceIndex: h._provenanceIndex } : {}), crypto: h._crypto, codec: h._codec, - commitMessageCodec: h._commitMessageCodec, ...(indexTree ? { indexTree } : {}), - ...(checkpointStore ? { checkpointStore } : {}), ...(stateHashService ? { stateHashService } : {}), }); - - const checkpointRef = buildCheckpointRef(h._graphName); - await h._persistence.updateRef(checkpointRef, checkpointSha); - return checkpointSha; } async _readCheckpointSha(): Promise { - return await this._host._persistence.readRef(buildCheckpointRef(this._host._graphName)); + return await this._host._checkpointStore.resolveHead(this._host._graphName); } private _requireCheckpointReadingState(): WarpState { @@ -257,18 +220,6 @@ export default class CheckpointController { return `frontier:${frontierFingerprint(coordinate.frontier)}:${coordinate.ceiling === null ? 'head' : String(coordinate.ceiling)}`; } - private _assertCheckpointCreatePersistence( - persistence: CheckpointHost['_persistence'], - ): asserts persistence is CheckpointHost['_persistence'] & CheckpointPersistence { - void persistence; - } - - private _assertLoadPersistence( - persistence: CheckpointHost['_persistence'], - ): asserts persistence is CheckpointHost['_persistence'] & LoadPersistence { - void persistence; - } - async syncCoverage(): Promise { const h = this._host; const writers = await h.discoverWriters(); @@ -286,15 +237,7 @@ export default class CheckpointController { if (parents.length === 0) { return; } - const message = h._commitMessageCodec.encodeAnchor({ - kind: 'anchor', - graph: h._graphName, - schema: 2, - }); - const anchorSha = await h._persistence.commitNode({ message, parents }); - - const coverageRef = buildCoverageRef(h._graphName); - await h._persistence.updateRef(coverageRef, anchorSha); + await h._checkpointStore.publishCoverage({ graphName: h._graphName, parents }); } async _loadLatestCheckpoint(): Promise { @@ -309,38 +252,18 @@ export default class CheckpointController { stateHash: snapshotHead.stateHash, schema: CURRENT_CHECKPOINT_SCHEMA, appliedVV: null, - indexShardOids: null, + indexShardHandles: null, }; } } - const checkpointRef = buildCheckpointRef(h._graphName); - const checkpointSha = await h._persistence.readRef(checkpointRef); + const checkpointSha = await h._checkpointStore.resolveHead(h._graphName); if (typeof checkpointSha !== 'string' || checkpointSha.length === 0) { return null; } - try { - const checkpointStore = h._checkpointStore ?? undefined; - this._assertLoadPersistence(h._persistence); - return await loadCheckpoint(h._persistence, checkpointSha, { - codec: h._codec, - ...(checkpointStore ? { checkpointStore } : {}), - commitMessageCodec: h._commitMessageCodec, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : ''; - if ( - msg.includes('missing') || - msg.includes('not found') || - msg.includes('ENOENT') || - msg.includes('non-empty string') - ) { - return null; - } - throw err; - } + return await loadCheckpoint(h._checkpointStore, checkpointSha, h._graphName); } async _loadPatchesSince(checkpoint: CheckpointFrontier): Promise> { @@ -383,16 +306,10 @@ export default class CheckpointController { if (typeof checkpointSha !== 'string' || checkpointSha.length === 0) { return false; } - const persistence = this._host._persistence; - this._assertLoadPersistence(persistence); - const message = await persistence.showNode(checkpointSha); - if (typeof message !== 'string' || message.length === 0) { - throw invalidCheckpointReference(checkpointSha, 'empty-checkpoint-message'); - } - if (this._host._commitMessageCodec.detectKind(message) !== 'checkpoint') { - throw invalidCheckpointReference(checkpointSha, 'non-checkpoint-message'); - } - const checkpoint = this._host._commitMessageCodec.decodeCheckpoint(message); + const checkpoint = await this._host._checkpointStore.readMetadata( + checkpointSha, + this._host._graphName, + ); if (isCurrentCheckpointSchema(checkpoint.schema)) { return true; } @@ -420,12 +337,7 @@ export default class CheckpointController { if (kind === 'patch') { const patchMeta = h._commitMessageCodec.decodePatch(nodeInfo.message); - // Runtime-narrow the decoded patch shape here rather than trusting - // the loose CodecPort return surface. - const patchBuffer = await h._readPatchBlob(patchMeta); - const decoded = decodePatchSchema(codecDecodeAsObject(h._codec, patchBuffer)); - - if (decoded.schema === 1 || decoded.schema === undefined) { + if (patchMeta.schema === 1) { return true; } } @@ -600,11 +512,3 @@ export default class CheckpointController { }; } } - -function invalidCheckpointReference(checkpointSha: string, reason: string): PersistenceError { - return new PersistenceError( - `Checkpoint ref resolved to invalid object ${checkpointSha}: ${reason}`, - 'E_CHECKPOINT_REF_INVALID', - { context: { checkpointSha, reason } }, - ); -} diff --git a/src/domain/services/controllers/ComparisonEngine.ts b/src/domain/services/controllers/ComparisonEngine.ts index 8ce113ae..c6199212 100644 --- a/src/domain/services/controllers/ComparisonEngine.ts +++ b/src/domain/services/controllers/ComparisonEngine.ts @@ -9,7 +9,9 @@ */ import QueryError from '../../errors/QueryError.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; import { computeChecksum } from '../../utils/checksumUtils.ts'; +import { collectAsyncIterable } from '../../utils/streamUtils.ts'; import { buildCoordinateComparisonFact, buildCoordinateTransferPlanFact, @@ -34,6 +36,7 @@ import type { PlanCoordinateTransferOptions, } from '../../capabilities/ComparisonCapability.ts'; import type Patch from '../../types/Patch.ts'; +import type { ContentMeta } from '../../types/ContentMeta.ts'; import { type ComparisonHost, type ComparisonSelectorContext, @@ -204,16 +207,13 @@ function normalizeIntoSelector( // ── Blob reading ───────────────────────────────────────────────────── -async function readContentBlobByOid(graph: ComparisonHost, oid: string): Promise { - const buf = (graph._blobStorage !== null && graph._blobStorage !== undefined) - ? await graph._blobStorage.retrieve(oid) - : await graph._persistence.readBlob(oid); - if (!(buf instanceof Uint8Array)) { - throw new QueryError(`content blob '${oid}' is missing from the object store`, { - code: 'invalid_coordinate', context: { oid }, +async function readContentByHandle(graph: ComparisonHost, handle: string): Promise { + if (graph._assetStorage === null || graph._assetStorage === undefined) { + throw new QueryError('content asset storage is unavailable', { + code: 'invalid_coordinate', context: { handle }, }); } - return buf; + return await collectAsyncIterable(graph._assetStorage.open(new AssetHandle(handle))); } // ── Core comparison ────────────────────────────────────────────────── @@ -379,13 +379,13 @@ export async function planCoordinateTransferImpl( }); const sourceSide = await normalizedSource.resolve(selectorContext, scope, liveFrontier); const targetSide = await normalizedTarget.resolve(selectorContext, scope, liveFrontier); - const loadNodeContent = async (_nodeId: string, meta: { oid: string }) => - await readContentBlobByOid(graph, meta.oid); + const loadNodeContent = async (_nodeId: string, meta: ContentMeta) => + await readContentByHandle(graph, meta.handle); const loadEdgeContent = async ( _edge: { from: string; to: string; label: string }, - meta: { oid: string }, + meta: ContentMeta, ) => - await readContentBlobByOid(graph, meta.oid); + await readContentByHandle(graph, meta.handle); const transfer = await planVisibleStateTransfer( createStateReader(sourceSide.state), createStateReader(targetSide.state), diff --git a/src/domain/services/controllers/ComparisonSelectorSupport.ts b/src/domain/services/controllers/ComparisonSelectorSupport.ts index 9716fdb3..2cec6f8d 100644 --- a/src/domain/services/controllers/ComparisonSelectorSupport.ts +++ b/src/domain/services/controllers/ComparisonSelectorSupport.ts @@ -15,6 +15,7 @@ import type { } from '../../types/CoordinateComparison.ts'; import type { StrandDescriptor } from '../../types/StrandDescriptor.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; +import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; export type PatchEntry = { patch: Patch; sha: string }; @@ -58,8 +59,7 @@ export type MaterializeStrandOptions = { * ComparisonCoordinateSideReadPort instead. */ export type ComparisonHost = ComparisonDigestHost & { - _blobStorage: { retrieve(oid: string): Promise } | null; - _persistence: { readBlob(oid: string): Promise }; + _assetStorage: AssetStoragePort | null; _materializeStrandGraph(strandId: string, options?: MaterializeStrandOptions): Promise; }; diff --git a/src/domain/services/controllers/ForkController.ts b/src/domain/services/controllers/ForkController.ts index afc58585..1ca90664 100644 --- a/src/domain/services/controllers/ForkController.ts +++ b/src/domain/services/controllers/ForkController.ts @@ -23,9 +23,10 @@ import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.t import type CodecPort from '../../../ports/CodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; import type GCPolicy from '../GCPolicy.ts'; import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts'; +import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; const HEX_CHARS = '0123456789abcdef'; type ForkRuntimeOpenOptions = RuntimeHostOpenOptions; @@ -54,8 +55,8 @@ type ForkHost = { _logger: LoggerPort | null; _crypto: CryptoPort; _codec: CodecPort; - _blobStorage: BlobStoragePort | null; - _patchBlobStorage: BlobStoragePort | null; + _assetStorage: AssetStoragePort | null; + _patchJournal: PatchJournalPort; _commitMessageCodec: CommitMessageCodecPort; discoverWriters(): Promise; }; @@ -186,9 +187,7 @@ export default class ForkController { fromSha, toSha, commitMessageCodec: host._commitMessageCodec, - codec: host._codec, - ...(host._blobStorage ? { blobStorage: host._blobStorage } : {}), - ...(host._patchBlobStorage ? { patchBlobStorage: host._patchBlobStorage } : {}), + patchJournal: host._patchJournal, }); } diff --git a/src/domain/services/controllers/IntentController.ts b/src/domain/services/controllers/IntentController.ts index 82213812..48ebe805 100644 --- a/src/domain/services/controllers/IntentController.ts +++ b/src/domain/services/controllers/IntentController.ts @@ -5,24 +5,30 @@ */ import type IntentCapability from '../../capabilities/IntentCapability.ts'; -import type { WarpIntentDescriptor, WarpIntentOutcome } from '../../types/WarpIntentDescriptor.ts'; +import type { QueryPropertyBag } from '../../capabilities/QueryCapability.ts'; +import type { + PrecommitGuard, + WarpIntentDescriptor, + WarpIntentOutcome, +} from '../../types/WarpIntentDescriptor.ts'; import type ProjectionHandle from '../ProjectionHandle.ts'; +import type IntentStorePort from '../../../ports/IntentStorePort.ts'; export type IntentHost = { _graphName: string; _writerId: string; + _intentStore: IntentStorePort; worldline: () => ProjectionHandle; }; +type IntentObstruction = Extract['obstruction']; +type StatusGuard = Extract; +type AgentGuard = Extract; + export default class IntentController implements IntentCapability { _host: IntentHost; - private _queuedIntents: Map; - private _admitCounter: number; - constructor(host: IntentHost) { this._host = host; - this._queuedIntents = new Map(); - this._admitCounter = 0; } async admitIntent(descriptor: WarpIntentDescriptor): Promise { @@ -34,15 +40,24 @@ export default class IntentController implements IntentCapability { return { admitted: false, obstruction, intentId: descriptor.intentId }; } } - this._admitCounter += 1; - const sha = `intent:${descriptor.intentId}:${this._host._writerId}:${this._admitCounter}`; - return { admitted: true, sha, intentId: descriptor.intentId }; + const published = await this._host._intentStore.publish({ + graphName: this._host._graphName, + channel: 'admitted', + ownerId: this._host._writerId, + descriptor, + }); + return { + admitted: true, + sha: published.sha, + intentId: descriptor.intentId, + retention: published.retention, + }; } private _checkGuard( - guard: WarpIntentDescriptor['precommitGuards'][number], - nodeProps: Readonly<{ [key: string]: unknown }> | null, - ) { + guard: PrecommitGuard, + nodeProps: QueryPropertyBag | null, + ): IntentObstruction | null { if (guard.op === 'nodeStatus') { return this._checkStatusGuard(guard, nodeProps); } @@ -53,46 +68,48 @@ export default class IntentController implements IntentCapability { } private _checkStatusGuard( - guard: WarpIntentDescriptor['precommitGuards'][number], - nodeProps: Readonly<{ [key: string]: unknown }> | null, - ) { + guard: StatusGuard, + nodeProps: QueryPropertyBag | null, + ): IntentObstruction | null { const raw = nodeProps ? nodeProps['status'] : 'ABSENT'; const actualStatus = typeof raw === 'string' ? raw : 'ABSENT'; - const { expected } = guard as unknown as { expected: string }; - if (actualStatus !== expected) { + if (actualStatus !== guard.expected) { return { tag: guard.failureTag, nodeId: guard.nodeId, actual: actualStatus }; } return null; } private _checkAgentGuard( - guard: WarpIntentDescriptor['precommitGuards'][number], - nodeProps: Readonly<{ [key: string]: unknown }> | null, - ) { + guard: AgentGuard, + nodeProps: QueryPropertyBag | null, + ): IntentObstruction | null { if (!nodeProps) { return null; } const raw = nodeProps['agentId']; if (typeof raw !== 'string') { return null; } - const { agentId } = guard as unknown as { agentId: string }; - if (raw !== agentId) { + if (raw !== guard.agentId) { return { tag: guard.failureTag, nodeId: guard.nodeId, actual: raw }; } return null; } async queueIntent(strandId: string, descriptor: WarpIntentDescriptor): Promise { - await Promise.resolve(); - const list = this._queuedIntents.get(strandId) ?? []; - list.push(descriptor); - this._queuedIntents.set(strandId, list); + const published = await this._host._intentStore.publish({ + graphName: this._host._graphName, + channel: 'queued', + ownerId: strandId, + descriptor, + }); return { admitted: true, - sha: `queued:${strandId}:${descriptor.intentId}`, + sha: published.sha, intentId: descriptor.intentId, + retention: published.retention, }; } async getWriterIntents(writerId: string): Promise { - await Promise.resolve(); - return this._queuedIntents.get(writerId) ?? []; + return await this._host._intentStore + .scan(this._host._graphName, 'queued', writerId) + .collect(); } } diff --git a/src/domain/services/controllers/MaterializeCheckpointStrategy.ts b/src/domain/services/controllers/MaterializeCheckpointStrategy.ts index a1b51bd5..4ef03cbc 100644 --- a/src/domain/services/controllers/MaterializeCheckpointStrategy.ts +++ b/src/domain/services/controllers/MaterializeCheckpointStrategy.ts @@ -24,13 +24,11 @@ export default class MaterializeCheckpointStrategy { await this.runtime.deps.patches.loadPatchChain(to, from); const state = await materializeIncremental({ - persistence: this.runtime.loadPersistence(), + checkpointStore: this.runtime.deps.checkpointStore, graphName: this.runtime.deps.graphName, checkpointSha, targetFrontier: frontier, patchLoader, - codec: this.runtime.deps.codec, - commitMessageCodec: this.runtime.deps.commitMessageCodec, }); return await this.runtime.wrapState(state, null, null); } diff --git a/src/domain/services/controllers/MaterializeController.ts b/src/domain/services/controllers/MaterializeController.ts index 7f486818..4ff0aec2 100644 --- a/src/domain/services/controllers/MaterializeController.ts +++ b/src/domain/services/controllers/MaterializeController.ts @@ -11,7 +11,6 @@ */ import { reducePatches as reduceJoinedPatches, createEmptyState } from '../JoinReducer.ts'; -import { type LoadPersistence } from '../state/checkpointLoad.ts'; import { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; import { computeStateHash } from '../state/StateSerializer.ts'; import { @@ -40,7 +39,7 @@ import { import type LoggerPort from '../../../ports/LoggerPort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; -import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; +import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; import type WarpStateCachePort from '../../../ports/WarpStateCachePort.ts'; import type PatchCollector from '../../capabilities/PatchCollector.ts'; import type { PatchWithSha } from '../../capabilities/PatchCollector.ts'; @@ -56,9 +55,6 @@ import type { export type MaterializePersistence = { readRef(ref: string): Promise; - showNode(sha: string): Promise; - readTreeOids(treeOid: string): Promise>; - readBlob(oid: string): Promise; }; // ── Deps ──────────────────────────────────────────────────────────── @@ -69,12 +65,12 @@ export type MaterializeDeps = { codec: CodecPort; crypto: CryptoPort; persistence: MaterializePersistence; + checkpointStore: CheckpointStorePort; getStateCache?: () => WarpStateCachePort | null; openStateSession?: MaterializeSessionOpener; patches: PatchCollector; graphCloner: DetachedGraphFactory; graphName: string; - commitMessageCodec: CommitMessageCodecPort; }; // ── Result types ──────────────────────────────────────────────────── @@ -207,18 +203,6 @@ export default class MaterializeController { return await this._checkpointStrategy.materializeAt(checkpointSha); } - private _assertLoadPersistence( - persistence: MaterializePersistence, - ): asserts persistence is MaterializePersistence & LoadPersistence { - void persistence; - } - - private _loadPersistence(): MaterializePersistence & LoadPersistence { - const {persistence} = this._deps; - this._assertLoadPersistence(persistence); - return persistence; - } - // ── Result building ─────────────────────────────────────────────── private async _emptyResult( @@ -342,7 +326,6 @@ export default class MaterializeController { await this._reducePatchStream(stream, base, opts, provenanceBase), buildResult: async (params) => await this._buildResult(params), buildProvenance: (patches, base) => buildProvenance(patches, base), - loadPersistence: () => this._loadPersistence(), }; } diff --git a/src/domain/services/controllers/MaterializeStrategyRuntime.ts b/src/domain/services/controllers/MaterializeStrategyRuntime.ts index c3fe484b..65b86a4a 100644 --- a/src/domain/services/controllers/MaterializeStrategyRuntime.ts +++ b/src/domain/services/controllers/MaterializeStrategyRuntime.ts @@ -1,4 +1,3 @@ -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'; @@ -10,7 +9,6 @@ import type { MaterializePatchSummary } from './MaterializePatchSummary.ts'; import type { MaterializeSnapshotPublicationOptions } from './MaterializeSnapshotPublication.ts'; import type { MaterializeDeps, - MaterializePersistence, MaterializeResult, MaterializeReduceOutput, } from './MaterializeController.ts'; @@ -65,5 +63,4 @@ export type MaterializeStrategyRuntime = { ): Promise; buildResult(params: MaterializeResultBuildInput): Promise; buildProvenance(patches: PatchWithSha[], base?: ProvenanceIndex): ProvenanceIndex; - loadPersistence(): MaterializePersistence & LoadPersistence; }; diff --git a/src/domain/services/controllers/PatchController.ts b/src/domain/services/controllers/PatchController.ts index 1bcfa879..32feace8 100644 --- a/src/domain/services/controllers/PatchController.ts +++ b/src/domain/services/controllers/PatchController.ts @@ -15,8 +15,6 @@ import { joinStates, applyWithDiff, applyWithReceipt, type WarpState } from '../ import { buildWriterRef } from '../../utils/RefLayout.ts'; import { Writer } from '../../warp/Writer.ts'; import { resolveWriterId } from '../../utils/WriterId.ts'; -import EncryptionError from '../../errors/EncryptionError.ts'; -import PersistenceError from '../../errors/PersistenceError.ts'; import PatchError from '../../errors/PatchError.ts'; import QueryError from '../../errors/QueryError.ts'; import { @@ -29,14 +27,15 @@ import { import type AdjacencyMap from '../../capabilities/AdjacencyMap.ts'; import type VersionVector from '../../crdt/VersionVector.ts'; import type Patch from '../../types/Patch.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; -import type { PatchStorageRoute } from '../../../ports/CommitMessageCodecPort.ts'; import type ConfigPort from '../../../ports/ConfigPort.ts'; import type { PatchDiff } from '../../types/PatchDiff.ts'; import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { LogicalIndex } from '../index/logicalIndexHelpers.ts'; import type PropertyIndexReader from '../index/PropertyIndexReader.ts'; +import type { PatchCommitResult } from '../../types/PatchCommitResult.ts'; +import type { AuditReceiptService } from '../audit/AuditReceiptService.ts'; import { E_NO_STATE_MSG, E_STALE_STATE_MSG } from './QueryStateMessages.ts'; // ── PatchHost ───────────────────────────────────────────────────────────────── @@ -71,16 +70,13 @@ export interface PatchHost extends PatchDiscoveryHost { _patchInProgress: boolean; _patchesSinceCheckpoint: number; _onDeleteWithData: DeletePolicy; - _blobStorage: BlobStoragePort | null | undefined; - _patchBlobStorage: BlobStoragePort | null | undefined; + _assetStorage: AssetStoragePort | null | undefined; _commitMessageCodec: CommitMessageCodecPort; _provenanceIndex: { addPatch: (sha: string, reads: string[] | undefined, writes: string[] | undefined) => void; } | null | undefined; _lastFrontier: Map | null | undefined; - _auditService: { - commit: (receipt: TickReceipt) => Promise; - } | null | undefined; + _auditService: Pick | null | undefined; _auditSkipCount: number; _cachedViewHash: string | null; _materializedGraph: { state: WarpState; stateHash: string | null; adjacency: AdjacencyMap } | null; @@ -158,8 +154,8 @@ export default class PatchController { if (h._logger !== null && h._logger !== undefined) { opts.logger = h._logger; } - if (h._blobStorage !== null && h._blobStorage !== undefined) { - opts.blobStorage = h._blobStorage; + if (h._assetStorage !== null && h._assetStorage !== undefined) { + opts.assetStorage = h._assetStorage; } return new PatchBuilder(opts); @@ -169,6 +165,13 @@ export default class PatchController { * Convenience wrapper: creates a patch, runs the callback, and commits. */ async patch(build: (p: PatchBuilder) => void | Promise): Promise { + return (await this.patchWithEvidence(build)).sha; + } + + /** Builds and publishes one patch while preserving storage evidence. */ + async patchWithEvidence( + build: (p: PatchBuilder) => void | Promise, + ): Promise { const h = this._host; if (h._patchInProgress) { throw new PatchError( @@ -180,7 +183,7 @@ export default class PatchController { try { const p = await this.createPatch(); await build(p); - return await p.commit(); + return await p.commitWithEvidence(); } finally { h._patchInProgress = false; } @@ -341,8 +344,8 @@ export default class PatchController { if (h._logger !== null && h._logger !== undefined) { writerOpts.logger = h._logger; } - if (h._blobStorage !== null && h._blobStorage !== undefined) { - writerOpts.blobStorage = h._blobStorage; + if (h._assetStorage !== null && h._assetStorage !== undefined) { + writerOpts.assetStorage = h._assetStorage; } return new Writer(writerOpts); } @@ -363,43 +366,15 @@ export default class PatchController { return Promise.resolve(); } - /** - * Reads a patch blob, using patchBlobStorage for encrypted patches. - */ - async _readPatchBlob( - patchMeta: { patchOid: string; storage?: PatchStorageRoute; encrypted?: boolean }, - ): Promise { - const h = this._host; - const storage = patchMeta.storage ?? ( - patchMeta.encrypted === true - ? { strategy: 'legacy-external-storage', version: null, schema: null, encrypted: true } - : { strategy: 'legacy-git-blob', version: null, schema: null, encrypted: false } - ); - if (storage.strategy === 'git-cas') { - if (!h._blobStorage) { - throw new EncryptionError( - 'This graph contains git-cas patches; provide blobStorage for CAS restore', - ); - } - return await h._blobStorage.retrieve(patchMeta.patchOid); - } - if (storage.strategy === 'legacy-external-storage') { - if (!h._patchBlobStorage) { - throw new EncryptionError( - 'This graph contains encrypted patches in legacy external storage; provide patchBlobStorage with an encryption key', - ); - } - return await h._patchBlobStorage.retrieve(patchMeta.patchOid); - } - const blob = await h._persistence.readBlob(patchMeta.patchOid); - if (blob === null || blob === undefined) { - throw new PersistenceError( - `Patch blob not found: ${patchMeta.patchOid}`, - PersistenceError.E_MISSING_OBJECT, - { context: { oid: patchMeta.patchOid } }, - ); + /** Reads a patch through the semantic journal locator. */ + async _readPatch( + patchMeta: ReturnType, + ): Promise { + const journal = this._host._patchJournal; + if (journal === null || journal === undefined) { + throw new PatchError('patchJournal is required for patch reads', { code: 'E_MISSING_JOURNAL' }); } - return blob; + return await journal.readPatch(patchMeta); } // ── CRDT join ─────────────────────────────────────────────────────────────── diff --git a/src/domain/services/controllers/PatchDiscovery.ts b/src/domain/services/controllers/PatchDiscovery.ts index 7609272a..9a0d9e85 100644 --- a/src/domain/services/controllers/PatchDiscovery.ts +++ b/src/domain/services/controllers/PatchDiscovery.ts @@ -9,15 +9,11 @@ */ import { buildWriterRef, buildWritersPrefix, parseWriterIdFromRef } from '../../utils/RefLayout.ts'; -import { hydrateDecodedPatch } from '../PatchHydrator.ts'; import PatchError from '../../errors/PatchError.ts'; -import EncryptionError from '../../errors/EncryptionError.ts'; import type Patch from '../../types/Patch.ts'; import type { CorePersistence } from '../../types/WarpPersistence.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; // ── PatchDiscoveryHost ──────────────────────────────────────────────────────── @@ -28,20 +24,13 @@ import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.t * Documents the exact WarpRuntime fields accessed during patch-chain * traversal, enabling lightweight mocks in unit tests. * - * TODO(0025B1): `_codec` points at `CodecPort` which today parameterizes - * decode loosely. When cycle 0025B1 lands `CodecPort` / - * `DecoderPort`, the downstream callers that hydrate a Patch out - * of the decoded value can drop their parser indirection. */ export interface PatchDiscoveryHost { readonly _graphName: string; readonly _persistence: CorePersistence; readonly _maxObservedLamport: number; - readonly _codec: CodecPort; readonly _logger: LoggerPort | null; readonly _patchJournal: PatchJournalPort | null | undefined; - readonly _blobStorage: BlobStoragePort | null | undefined; - readonly _patchBlobStorage: BlobStoragePort | null | undefined; readonly _commitMessageCodec: CommitMessageCodecPort; } @@ -152,26 +141,11 @@ export class PatchDiscovery { const patchMeta = h._commitMessageCodec.decodePatch(message); const journal = h._patchJournal; if (journal === null || journal === undefined) { - let raw: Uint8Array; - if (patchMeta.storage.strategy === 'git-cas') { - if (h._blobStorage === null || h._blobStorage === undefined) { - throw new EncryptionError('This graph contains git-cas patches; provide blobStorage for CAS restore'); - } - raw = await h._blobStorage.retrieve(patchMeta.patchOid); - } else if (patchMeta.storage.strategy === 'legacy-external-storage') { - if (h._patchBlobStorage === null || h._patchBlobStorage === undefined) { - throw new EncryptionError('This graph contains encrypted patches in legacy external storage; provide patchBlobStorage with an encryption key'); - } - raw = await h._patchBlobStorage.retrieve(patchMeta.patchOid); - } else { - raw = await h._persistence.readBlob(patchMeta.patchOid); - } - const decoded = hydrateDecodedPatch(h._codec.decode(raw)); - patches.push({ patch: decoded, sha: currentSha }); - } else { - const decoded = await journal.readPatch(patchMeta.patchOid, { storage: patchMeta.storage }); - patches.push({ patch: decoded, sha: currentSha }); + throw new PatchError('patchJournal is required for patch discovery', { + code: 'E_MISSING_JOURNAL', + }); } + patches.push({ patch: await journal.readPatch(patchMeta), sha: currentSha }); if (Array.isArray(nodeInfo.parents) && nodeInfo.parents.length > 0) { currentSha = nodeInfo.parents[0] ?? ''; diff --git a/src/domain/services/controllers/ProvenanceController.ts b/src/domain/services/controllers/ProvenanceController.ts index a1744249..84b8e464 100644 --- a/src/domain/services/controllers/ProvenanceController.ts +++ b/src/domain/services/controllers/ProvenanceController.ts @@ -10,7 +10,6 @@ import QueryError from '../../errors/QueryError.ts'; import { createEmptyState, reducePatches, type WarpState } from '../JoinReducer.ts'; import { ProvenancePayload } from '../provenance/ProvenancePayload.ts'; -import { hydrateDecodedPatch } from '../PatchHydrator.ts'; import type Patch from '../../types/Patch.ts'; import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { ProvenanceReadHost } from './ReadGraphHost.ts'; @@ -149,8 +148,7 @@ export default class ProvenanceController { } const patchMeta = host._commitMessageCodec.decodePatch(nodeInfo.message); - const patchBuffer = await host._readPatchBlob(patchMeta); - return hydrateDecodedPatch(host._codec.decode(patchBuffer)); + return await host._readPatch(patchMeta); } async _loadPatchesBySha(shas: string[]): Promise> { diff --git a/src/domain/services/controllers/QueryContent.ts b/src/domain/services/controllers/QueryContent.ts index d32192d1..67d43f56 100644 --- a/src/domain/services/controllers/QueryContent.ts +++ b/src/domain/services/controllers/QueryContent.ts @@ -11,6 +11,8 @@ import type ContentAttachmentRecord from '../../graph/ContentAttachmentRecord.ts import type { ContentMeta } from '../../types/ContentMeta.ts'; import type WarpState from '../state/WarpState.ts'; import type { QueryContentHost } from './ReadGraphHost.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; +import { collectAsyncIterable } from '../../utils/streamUtils.ts'; // ── Types ─────────────────────────────────────────────────────────── @@ -27,14 +29,14 @@ export type EdgeId = { function contentMetaFromRecord(record: ContentAttachmentRecord): ContentMeta { return { - oid: record.payload.oid.toString(), + handle: record.payload.handle.toString(), mime: record.payload.mime?.toString() ?? null, size: record.payload.size?.toNumber() ?? null, }; } -function contentOidFromRecord(record: ContentAttachmentRecord): string { - return record.payload.oid.toString(); +function contentHandleFromRecord(record: ContentAttachmentRecord): string { + return record.payload.handle.toString(); } function nodeContentAttachment(state: WarpState, nodeId: string): ContentAttachmentRecord | null { @@ -47,33 +49,15 @@ function edgeContentAttachment(state: WarpState, edge: EdgeId): ContentAttachmen // ── Blob resolution ───────────────────────────────────────────────── -async function resolveBlob(host: QueryContentHost, oid: string): Promise { - if (host._blobStorage) { - return await host._blobStorage.retrieve(oid); - } - return await host._persistence.readBlob(oid); +async function resolveAsset(host: QueryContentHost, handle: string): Promise { + return await collectAsyncIterable(resolveAssetStream(host, handle)); } -function resolveBlobStream(host: QueryContentHost, oid: string): AsyncIterable | null { - if (host._blobStorage && typeof host._blobStorage.retrieveStream === 'function') { - return host._blobStorage.retrieveStream(oid); +function resolveAssetStream(host: QueryContentHost, handle: string): AsyncIterable { + if (host._assetStorage === null) { + throw new QueryError('Content asset storage is unavailable', { code: 'E_CONTENT_STORAGE' }); } - return null; -} - -export function singleChunkIterable(buf: Uint8Array): AsyncIterable { - return { - [Symbol.asyncIterator](): AsyncIterator { - let done = false; - return { - next(): Promise> { - if (done) { return Promise.resolve({ value: undefined, done: true }); } - done = true; - return Promise.resolve({ value: buf, done: false }); - }, - }; - }, - }; + return host._assetStorage.open(new AssetHandle(handle)); } // ── Host-dependent node content ───────────────────────────────────── @@ -86,10 +70,10 @@ async function ensureAndGetState(host: QueryContentHost): Promise { return host._cachedState; } -export async function getContentOidImpl(host: QueryContentHost, nodeId: string): Promise { +export async function getContentHandleImpl(host: QueryContentHost, nodeId: string): Promise { const state = await ensureAndGetState(host); const record = nodeContentAttachment(state, nodeId); - return record === null ? null : contentOidFromRecord(record); + return record === null ? null : contentHandleFromRecord(record); } export async function getContentMetaImpl(host: QueryContentHost, nodeId: string): Promise { @@ -102,25 +86,22 @@ export async function getContentImpl(host: QueryContentHost, nodeId: string): Pr const state = await ensureAndGetState(host); const record = nodeContentAttachment(state, nodeId); if (record === null) { return null; } - return await resolveBlob(host, contentOidFromRecord(record)); + return await resolveAsset(host, contentHandleFromRecord(record)); } export async function getContentStreamImpl(host: QueryContentHost, nodeId: string): Promise | null> { const state = await ensureAndGetState(host); const record = nodeContentAttachment(state, nodeId); if (record === null) { return null; } - const oid = contentOidFromRecord(record); - const stream = resolveBlobStream(host, oid); - if (stream) { return stream; } - return singleChunkIterable(await resolveBlob(host, oid)); + return resolveAssetStream(host, contentHandleFromRecord(record)); } // ── Host-dependent edge content ───────────────────────────────────── -export async function getEdgeContentOidImpl(host: QueryContentHost, edge: EdgeId): Promise { +export async function getEdgeContentHandleImpl(host: QueryContentHost, edge: EdgeId): Promise { const state = await ensureAndGetState(host); const record = edgeContentAttachment(state, edge); - return record === null ? null : contentOidFromRecord(record); + return record === null ? null : contentHandleFromRecord(record); } export async function getEdgeContentMetaImpl(host: QueryContentHost, edge: EdgeId): Promise { @@ -133,15 +114,12 @@ export async function getEdgeContentImpl(host: QueryContentHost, edge: EdgeId): const state = await ensureAndGetState(host); const record = edgeContentAttachment(state, edge); if (record === null) { return null; } - return await resolveBlob(host, contentOidFromRecord(record)); + return await resolveAsset(host, contentHandleFromRecord(record)); } export async function getEdgeContentStreamImpl(host: QueryContentHost, edge: EdgeId): Promise | null> { const state = await ensureAndGetState(host); const record = edgeContentAttachment(state, edge); if (record === null) { return null; } - const oid = contentOidFromRecord(record); - const stream = resolveBlobStream(host, oid); - if (stream) { return stream; } - return singleChunkIterable(await resolveBlob(host, oid)); + return resolveAssetStream(host, contentHandleFromRecord(record)); } diff --git a/src/domain/services/controllers/QueryController.ts b/src/domain/services/controllers/QueryController.ts index 457adb3f..a54629e3 100644 --- a/src/domain/services/controllers/QueryController.ts +++ b/src/domain/services/controllers/QueryController.ts @@ -40,8 +40,8 @@ import { } from './QueryReads.ts'; import { - getContentOidImpl, getContentMetaImpl, getContentImpl, getContentStreamImpl, - getEdgeContentOidImpl, getEdgeContentMetaImpl, getEdgeContentImpl, getEdgeContentStreamImpl, + getContentHandleImpl, getContentMetaImpl, getContentImpl, getContentStreamImpl, + getEdgeContentHandleImpl, getEdgeContentMetaImpl, getEdgeContentImpl, getEdgeContentStreamImpl, } from './QueryContent.ts'; // ── Observer source helpers ───────────────────────────────────────── @@ -264,10 +264,10 @@ export default class QueryController { declare worldline: QueryCapability['worldline']; declare observer: QueryCapability['observer']; declare translationCost: QueryCapability['translationCost']; - declare getContentOid: QueryCapability['getContentOid']; + declare getContentHandle: QueryCapability['getContentHandle']; declare getContentMeta: QueryCapability['getContentMeta']; declare getContent: QueryCapability['getContent']; - declare getEdgeContentOid: QueryCapability['getEdgeContentOid']; + declare getEdgeContentHandle: QueryCapability['getEdgeContentHandle']; declare getEdgeContentMeta: QueryCapability['getEdgeContentMeta']; declare getEdgeContent: QueryCapability['getEdgeContent']; declare getContentStream: QueryCapability['getContentStream']; @@ -323,10 +323,9 @@ function hasWorldlineOpticSource( graph: MaterializableHost, ): graph is MaterializableHost & CheckpointTailOpticSource { return typeof graph.graphName === 'string' - && graph._persistence !== undefined && graph._codec !== undefined - && graph._blobStorage !== undefined - && graph._commitMessageCodec !== undefined + && graph._checkpointStore !== undefined + && graph._indexStore !== undefined && typeof graph.discoverWriters === 'function' && typeof graph._readCheckpointSha === 'function' && typeof graph._loadPatchChainFromSha === 'function' @@ -368,11 +367,11 @@ wire('getEdges', function (this: QueryController) { return getEdgesImpl(host(thi wire('getPropertyCount', function (this: QueryController) { return getPropertyCountImpl(host(this)); }); // QueryContent delegates -wire('getContentOid', function (this: QueryController, nodeId: string) { return getContentOidImpl(host(this), nodeId); }); +wire('getContentHandle', function (this: QueryController, nodeId: string) { return getContentHandleImpl(host(this), nodeId); }); wire('getContentMeta', function (this: QueryController, nodeId: string) { return getContentMetaImpl(host(this), nodeId); }); wire('getContent', function (this: QueryController, nodeId: string) { return getContentImpl(host(this), nodeId); }); wire('getContentStream', function (this: QueryController, nodeId: string) { return getContentStreamImpl(host(this), nodeId); }); -wireEdge('getEdgeContentOid', getEdgeContentOidImpl); +wireEdge('getEdgeContentHandle', getEdgeContentHandleImpl); wireEdge('getEdgeContentMeta', getEdgeContentMetaImpl); wireEdge('getEdgeContent', getEdgeContentImpl); wireEdge('getEdgeContentStream', getEdgeContentStreamImpl); diff --git a/src/domain/services/controllers/ReadGraphHost.ts b/src/domain/services/controllers/ReadGraphHost.ts index 27940bbc..8d9a66f5 100644 --- a/src/domain/services/controllers/ReadGraphHost.ts +++ b/src/domain/services/controllers/ReadGraphHost.ts @@ -1,13 +1,13 @@ -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; +import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; -import type GraphPersistencePort from '../../../ports/GraphPersistencePort.ts'; +import type CommitPort from '../../../ports/CommitPort.ts'; import type NeighborProviderPort from '../../../ports/NeighborProviderPort.ts'; import type { NeighborEdge } from '../../../ports/NeighborProviderPort.ts'; import type WarpState from '../state/WarpState.ts'; import type PropertyIndexReader from '../index/PropertyIndexReader.ts'; import type { LogicalIndex } from '../index/logicalIndexHelpers.ts'; import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; +import type Patch from '../../types/Patch.ts'; export type ReadAdjacencyMaps = { outgoing: Map | ReadonlyMap; @@ -34,19 +34,17 @@ export type QueryReadHost = FreshStateHost & { }; export type QueryContentHost = FreshStateHost & { - _blobStorage: BlobStoragePort | null; - _persistence: Pick; + _assetStorage: AssetStoragePort | null; }; -export type PatchBlobReadHost = { - _persistence: Pick; +export type PatchReadHost = { + _persistence: Pick; _commitMessageCodec: Pick; - _readPatchBlob(patchMeta: ReturnType): Promise; - _codec: CodecPort; + _readPatch(patchMeta: ReturnType): Promise; }; export type ProvenanceReadHost = FreshStateHost & - PatchBlobReadHost & { + PatchReadHost & { _provenanceDegraded: boolean; _provenanceIndex: ProvenanceIndex | null; }; diff --git a/src/domain/services/controllers/StrandController.ts b/src/domain/services/controllers/StrandController.ts index d3350f11..1eb33b79 100644 --- a/src/domain/services/controllers/StrandController.ts +++ b/src/domain/services/controllers/StrandController.ts @@ -18,6 +18,7 @@ import type SnapshotWarpState from '../snapshot/SnapshotWarpState.ts'; import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { PatchBuilder } from '../PatchBuilder.ts'; import type Patch from '../../types/Patch.ts'; +import type { PatchCommitResult } from '../../types/PatchCommitResult.ts'; export type StrandHost = StrandCoordinatorGraphRuntime & { _loadWriterPatches(writerId: string): Promise>; @@ -86,6 +87,13 @@ export default class StrandController { return await this._strandService.patch(strandId, build); } + async patchStrandWithEvidence( + strandId: string, + build: (p: PatchBuilder) => void | Promise, + ): Promise { + return await this._strandService.patchWithEvidence(strandId, build); + } + // ── Speculative intents ───────────────────────────────────────────────── async queueStrandIntent(strandId: string, build: (p: PatchBuilder) => void | Promise): Promise { diff --git a/src/domain/services/controllers/SyncControllerTypes.ts b/src/domain/services/controllers/SyncControllerTypes.ts index ff3efa63..abf9da92 100644 --- a/src/domain/services/controllers/SyncControllerTypes.ts +++ b/src/domain/services/controllers/SyncControllerTypes.ts @@ -7,7 +7,6 @@ import type CodecPort from '../../../ports/CodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; import type SyncTrustGate from '../sync/SyncTrustGate.ts'; import type SyncCapability from '../../capabilities/SyncCapability.ts'; import type SnapshotWarpState from '../snapshot/SnapshotWarpState.ts'; @@ -30,7 +29,6 @@ export interface SyncHost { _crypto: CryptoPort; _logger: LoggerPort | null; _patchJournal?: PatchJournalPort | null; - _patchBlobStorage?: BlobStoragePort | null; _patchesSinceCheckpoint: number; _maxObservedLamport: number; materialize: () => Promise; diff --git a/src/domain/services/controllers/detachedOpen.ts b/src/domain/services/controllers/detachedOpen.ts index 5de31df4..5c5834d6 100644 --- a/src/domain/services/controllers/detachedOpen.ts +++ b/src/domain/services/controllers/detachedOpen.ts @@ -4,13 +4,9 @@ * Used by QueryController and MaterializeController for snapshot * isolation. Will be replaced by DetachedGraphFactory once DI is wired. */ -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; -import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; -import type IndexStorePort from '../../../ports/IndexStorePort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; import type { CorePersistence } from '../../types/WarpPersistence.ts'; import type { NormalizedTrustConfig } from '../../runtimeHelpers.ts'; import type GCPolicy from '../GCPolicy.ts'; @@ -30,12 +26,7 @@ export type DetachedOpenOptions = { audit: false; checkpointPolicy?: { every: number }; logger?: LoggerPort; - blobStorage?: BlobStoragePort; - patchBlobStorage?: BlobStoragePort; trust?: NormalizedTrustConfig; - patchJournal?: PatchJournalPort; - checkpointStore?: CheckpointStorePort; - indexStore?: IndexStorePort; }; export type DetachedGraphOpen = (options: DetachedOpenOptions) => Promise; @@ -48,12 +39,7 @@ export type DetachedOpenHost = { _gcPolicy: GCPolicy; _checkpointPolicy: { every: number } | null; _logger: LoggerPort | null; - _blobStorage: BlobStoragePort | null; - _patchBlobStorage: BlobStoragePort | null; _trustConfig: NormalizedTrustConfig; - _patchJournal: PatchJournalPort; - _checkpointStore: CheckpointStorePort; - _indexStore: IndexStorePort; _onDeleteWithData: 'reject' | 'cascade' | 'warn'; _crypto: CryptoPort; _codec: CodecPort; @@ -79,19 +65,8 @@ function addReadPolicy(opts: DetachedOpenOptions, g: DetachedOpenHost): void { if (g._logger) { opts.logger = g._logger; } } -function addStoragePorts(opts: DetachedOpenOptions, g: DetachedOpenHost): void { - if (g._blobStorage) { opts.blobStorage = g._blobStorage; } - if (g._patchBlobStorage) { opts.patchBlobStorage = g._patchBlobStorage; } -} - function addConfigPorts(opts: DetachedOpenOptions, g: DetachedOpenHost): void { if (g._trustConfig !== undefined && g._trustConfig !== null) { opts.trust = g._trustConfig; } - if (g._patchJournal !== undefined && g._patchJournal !== null) { opts.patchJournal = g._patchJournal; } -} - -function addStoresPorts(opts: DetachedOpenOptions, g: DetachedOpenHost): void { - if (g._checkpointStore !== undefined && g._checkpointStore !== null) { opts.checkpointStore = g._checkpointStore; } - if (g._indexStore !== undefined && g._indexStore !== null) { opts.indexStore = g._indexStore; } } /** Opens a detached read-only clone for snapshot queries. */ @@ -101,8 +76,6 @@ export async function openDetachedGraph( ): Promise { const opts = coreOptions(graph); addReadPolicy(opts, graph); - addStoragePorts(opts, graph); addConfigPorts(opts, graph); - addStoresPorts(opts, graph); return await open(opts); } diff --git a/src/domain/services/index/BitmapIndexReader.ts b/src/domain/services/index/BitmapIndexReader.ts index 782155ff..799ef877 100644 --- a/src/domain/services/index/BitmapIndexReader.ts +++ b/src/domain/services/index/BitmapIndexReader.ts @@ -2,11 +2,12 @@ import { IndexError, ShardLoadError, ShardCorruptionError } from '../../errors/i import nullLogger from '../../utils/nullLogger.ts'; import LRUCache from '../../utils/LRUCache.ts'; import { getRoaringBitmap32 } from '../../utils/roaring.ts'; -import { isValidShardOid } from '../../utils/validateShardOid.ts'; import { requireCodec } from '../codec/CodecRequirement.ts'; -import type IndexStoragePort from '../../../ports/IndexStoragePort.ts'; +import type IndexStorePort from '../../../ports/IndexStorePort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; +import { collectAsyncIterable } from '../../utils/streamUtils.ts'; type LoadedShard = Record | Record; @@ -51,36 +52,36 @@ function isChunkedVariant(path: string, basePath: string): boolean { * const parents = await reader.getParents('abc123...'); */ export default class BitmapIndexReader { - private readonly storage: IndexStoragePort; + private readonly indexStore: IndexStorePort; private readonly strict: boolean; private readonly logger: LoggerPort; private readonly _codec: CodecPort | null; readonly maxCachedShards: number; - private shardOids: Map; + private shardHandles: Map; private readonly loadedShards: LRUCache; private _idToShaCache: string[] | null; constructor(options: { - storage: IndexStoragePort; + indexStore: IndexStorePort; strict?: boolean; logger?: LoggerPort; maxCachedShards?: number; codec?: CodecPort; }) { - const { storage, strict = true, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, codec } = options; - BitmapIndexReader._assertStorage(storage); - this.storage = storage; + const { indexStore, strict = true, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, codec } = options; + BitmapIndexReader._assertStorage(indexStore); + this.indexStore = indexStore; this.strict = strict; this.logger = logger; this._codec = codec ?? null; this.maxCachedShards = maxCachedShards; - this.shardOids = new Map(); + this.shardHandles = new Map(); this.loadedShards = new LRUCache(maxCachedShards); this._idToShaCache = null; } - private static _assertStorage(storage: IndexStoragePort | null | undefined): void { - if (storage === null || storage === undefined) { + private static _assertStorage(indexStore: IndexStorePort | null | undefined): void { + if (indexStore === null || indexStore === undefined) { throw new IndexError('BitmapIndexReader requires a storage adapter', { code: 'E_INDEX_STORAGE_REQUIRED', }); @@ -88,30 +89,29 @@ export default class BitmapIndexReader { } /** - * Configures the reader with shard OID mappings for lazy loading. + * Configures the reader with opaque shard handles for lazy loading. */ - setup(shardOids: Record): void { - const entries = Object.entries(shardOids); - const validEntries: [string, string][] = []; - for (const [path, oid] of entries) { - if (isValidShardOid(oid)) { - validEntries.push([path, oid]); + setup(shardHandles: Readonly>): void { + const validEntries: Array<[string, AssetHandle]> = []; + for (const [path, handle] of Object.entries(shardHandles)) { + if (handle instanceof AssetHandle) { + validEntries.push([path, handle]); } else if (this.strict) { - throw new ShardCorruptionError('Invalid shard OID', { + throw new ShardCorruptionError('Invalid shard handle', { shardPath: path, - oid, - reason: 'invalid_oid', + oid: String(handle), + reason: 'invalid_handle', }); } else { - this.logger.warn('Skipping shard with invalid OID', { + this.logger.warn('Skipping shard with invalid handle', { operation: 'setup', shardPath: path, - oid, - reason: 'invalid_oid', + oid: String(handle), + reason: 'invalid_handle', }); } } - this.shardOids = new Map(validEntries); + this.shardHandles = new Map(validEntries); this._idToShaCache = null; this.loadedShards.clear(); } @@ -185,8 +185,8 @@ export default class BitmapIndexReader { const bitmap = RoaringBitmap32.deserialize(bitmapBytes, true); return bitmap.toArray(); } catch (err) { - const oid = this.shardOids.get(shardPath); - const shardOid = isNonEmptyString(oid) ? oid : shardPath; + const handle = this.shardHandles.get(shardPath)?.toString(); + const shardOid = isNonEmptyString(handle) ? handle : shardPath; const corruptionError = new ShardCorruptionError('Failed to deserialize bitmap', { shardPath, oid: shardOid, @@ -212,7 +212,7 @@ export default class BitmapIndexReader { const cache: string[] = []; this._idToShaCache = cache; - for (const [path] of this.shardOids) { + for (const [path] of this.shardHandles) { if (!isMetaShardPath(path)) { continue; } @@ -242,17 +242,17 @@ export default class BitmapIndexReader { if (cached !== undefined) { return cached; } - const oid = this.shardOids.get(path); - if (!isNonEmptyString(oid)) { + const handle = this.shardHandles.get(path); + if (handle === undefined) { return {}; } - const buffer = await this._loadShardBuffer(path, oid); - return this._decodeAndCacheShard(buffer, path, oid); + const buffer = await this._loadShardBuffer(path, handle); + return this._decodeAndCacheShard(buffer, path, handle.toString()); } private _resolveShardPaths(basePath: string): string[] { - const exact = this.shardOids.has(basePath) ? [basePath] : []; - const chunked = Array.from(this.shardOids.keys()) + const exact = this.shardHandles.has(basePath) ? [basePath] : []; + const chunked = Array.from(this.shardHandles.keys()) .filter((path) => isChunkedVariant(path, basePath)) .sort(); return [...exact, ...chunked]; @@ -308,8 +308,8 @@ export default class BitmapIndexReader { } private _handleInvalidBitmapValue(path: string, sha: string): void { - const oid = this.shardOids.get(path); - const shardOid = isNonEmptyString(oid) ? oid : path; + const handle = this.shardHandles.get(path)?.toString(); + const shardOid = isNonEmptyString(handle) ? handle : path; const corruptionError = new ShardCorruptionError('Invalid bitmap value', { shardPath: path, oid: shardOid, @@ -328,14 +328,20 @@ export default class BitmapIndexReader { }); } - private async _loadShardBuffer(path: string, oid: string): Promise { + private async _loadShardBuffer(path: string, handle: AssetHandle): Promise { try { - return await this.storage.readBlob(oid); + return await collectAsyncIterable(this.indexStore.openShard(handle)); } catch (cause) { + const errorCause = cause instanceof Error + ? cause + : new IndexError('Shard storage threw a non-error value', { + code: 'E_INDEX_STORAGE_THROWABLE', + context: { value: String(cause) }, + }); throw new ShardLoadError('Failed to load shard from storage', { shardPath: path, - oid, - cause: cause as Error, + oid: handle.toString(), + cause: errorCause, }); } } diff --git a/src/domain/services/index/LogicalIndexReader.ts b/src/domain/services/index/LogicalIndexReader.ts index fcd6c605..d6b63b97 100644 --- a/src/domain/services/index/LogicalIndexReader.ts +++ b/src/domain/services/index/LogicalIndexReader.ts @@ -1,6 +1,6 @@ /** - * Reads a serialized logical bitmap index from a tree (in-memory buffers) - * or lazily from OID→blob storage, and produces a LogicalIndex interface. + * Reads a serialized logical bitmap index from in-memory buffers or opaque + * shard handles and produces a LogicalIndex interface. * * Extracted from test/helpers/fixtureDsl.js so that production code can * hydrate indexes stored inside checkpoints (Phase 3). @@ -17,13 +17,17 @@ import IndexError from '../../errors/IndexError.ts'; import { requireCodec } from '../codec/CodecRequirement.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type IndexStorePort from '../../../ports/IndexStorePort.ts'; +import type AssetHandle from '../../storage/AssetHandle.ts'; +import type BundleHandle from '../../storage/BundleHandle.ts'; import type { IndexShard } from '../../artifacts/IndexShard.ts'; +import type CodecValue from '../../types/codec/CodecValue.ts'; import { buildLogicalIndex, - classifyDecoded, classifyShards, type ClassifiedDecoded, type DecodedItem, + isEdgeShard, + isMetaShard, type LogicalIndex, type ShardItem, } from './logicalIndexHelpers.ts'; @@ -71,72 +75,71 @@ export default class LogicalIndexReader { * Eagerly decodes all shards from an in-memory tree (Record). */ loadFromTree(tree: Record): this { - this._resetState(); + const replacement = this._emptyReplacement(); const items: ShardItem[] = Object.entries(tree).map(([path, buf]) => ({ path, buf })); - this._processShards(items); + replacement._processShards(items); + this._replaceState(replacement); return this; } /** - * Loads all shards from OID→blob storage (async). + * Loads shards through opaque asset handles. Decoding remains behind + * the configured IndexStorePort. */ - async loadFromOids( - shardOids: Record, - storage: { readBlob(oid: string): Promise }, + async loadFromHandles( + shardHandles: Readonly>, ): Promise { - this._resetState(); - const entries = Object.entries(shardOids); - if (this._indexStore) { - const indexStore = this._indexStore; - const decoded: DecodedItem[] = await Promise.all( - entries.map(async ([path, oid]) => ({ path, data: await indexStore.decodeShard(oid) })), - ); - this._processDecoded(decoded); - } else { - const items: ShardItem[] = await Promise.all( - entries.map(async ([path, oid]) => ({ path, buf: await storage.readBlob(oid) })), + if (this._indexStore === null) { + throw new IndexError( + 'LogicalIndexReader: loadFromHandles() requires an indexStore', + { code: 'E_INDEX_NO_STORE' }, ); - this._processShards(items); } + const indexStore = this._indexStore; + const replacement = new LogicalIndexReader({ indexStore }); + const Ctor = getRoaringBitmap32(); + for (const [path, handle] of Object.entries(shardHandles)) { + replacement._loadDecodedItem(path, await indexStore.decodeShard(handle), Ctor); + } + this._replaceState(replacement); return this; } /** * Populates the reader directly from IndexShard domain objects. * - * This is the codec-free alternative to loadFromTree() and loadFromOids(). + * This is the codec-free alternative to loadFromTree() and loadFromHandles(). * No CBOR decoding is needed — the shards already carry decoded data. */ loadFromShards(shards: Iterable): this { - this._resetState(); + const replacement = this._emptyReplacement(); const Ctor = getRoaringBitmap32(); for (const shard of shards) { - if (shard instanceof MetaShard) { - this._loadMetaShard(shard, Ctor); - } else if (shard instanceof LabelShard) { - this._loadLabelShard(shard); - } else if (shard instanceof EdgeShard) { - this._loadEdgeShard(shard, Ctor); - } + replacement._loadShard(shard, Ctor); } + this._replaceState(replacement); return this; } /** * Loads all shards from an IndexStorePort via scanShards (codec-free). * - * The adapter reads, decodes, and classifies blobs into IndexShard + * The adapter reads, decodes, and classifies asset bytes into IndexShard * domain objects. The reader consumes them without touching any codec. */ - async loadFromStore(treeOid: string): Promise { + async loadFromStore(indexHandle: BundleHandle): Promise { if (!this._indexStore) { throw new IndexError( 'LogicalIndexReader: loadFromStore() requires an indexStore', { code: 'E_INDEX_NO_STORE' }, ); } - const shards = await this._indexStore.scanShards(treeOid).collect(); - this.loadFromShards(shards); + const replacement = this._emptyReplacement(); + const Ctor = getRoaringBitmap32(); + for await (const shard of this._indexStore.scanShards(indexHandle)) { + replacement._loadShard(shard, Ctor); + } + this._replaceState(replacement); return this; } @@ -188,12 +191,6 @@ export default class LogicalIndexReader { } } - /** Processes pre-decoded shards (port path — no codec needed). */ - private _processDecoded(items: DecodedItem[]): void { - const classified = classifyDecoded(items); - this._loadClassified(classified); - } - /** Populates node-to-global and alive bitmap maps from decoded meta data. */ private _loadDecodedMeta(path: string, raw: unknown, Ctor: RoaringCtor): void { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B const meta = raw as { @@ -227,17 +224,47 @@ export default class LogicalIndexReader { } } - /** Clears all decoded state so the reader can be reused safely. */ - private _resetState(): void { - this._nodeToGlobal.clear(); - this._globalToNode.clear(); - this._aliveBitmaps.clear(); - this._labelRegistry.clear(); - this._idToLabel.clear(); - this._edgeFwd.clear(); - this._edgeRev.clear(); - this._edgeByOwnerFwd.clear(); - this._edgeByOwnerRev.clear(); + /** Replaces every decoded map only after a candidate index loads completely. */ + private _replaceState(replacement: LogicalIndexReader): void { + this._nodeToGlobal = replacement._nodeToGlobal; + this._globalToNode = replacement._globalToNode; + this._aliveBitmaps = replacement._aliveBitmaps; + this._labelRegistry = replacement._labelRegistry; + this._idToLabel = replacement._idToLabel; + this._edgeFwd = replacement._edgeFwd; + this._edgeRev = replacement._edgeRev; + this._edgeByOwnerFwd = replacement._edgeByOwnerFwd; + this._edgeByOwnerRev = replacement._edgeByOwnerRev; + } + + /** Creates an empty reader with the same decoding and storage dependencies. */ + private _emptyReplacement(): LogicalIndexReader { + return new LogicalIndexReader({ + ...(this._codec === null ? {} : { codec: this._codec }), + ...(this._indexStore === null ? {} : { indexStore: this._indexStore }), + }); + } + + /** Loads one decoded shard without retaining the other decoded payloads. */ + private _loadDecodedItem(path: string, data: CodecValue, Ctor: RoaringCtor): void { + if (isMetaShard(path)) { + this._loadDecodedMeta(path, data, Ctor); + } else if (path === 'labels.cbor') { + this._loadDecodedLabels(data); + } else if (isEdgeShard(path)) { + this._loadDecodedEdges(path.startsWith('fwd_') ? 'fwd' : 'rev', data, Ctor); + } + } + + /** Loads one classified shard without collecting the surrounding stream. */ + private _loadShard(shard: IndexShard, Ctor: RoaringCtor): void { + if (shard instanceof MetaShard) { + this._loadMetaShard(shard, Ctor); + } else if (shard instanceof LabelShard) { + this._loadLabelShard(shard); + } else if (shard instanceof EdgeShard) { + this._loadEdgeShard(shard, Ctor); + } } /** Populates edge stores from decoded edge shard data. */ diff --git a/src/domain/services/index/PropertyIndexReader.ts b/src/domain/services/index/PropertyIndexReader.ts index 6890256c..a368d872 100644 --- a/src/domain/services/index/PropertyIndexReader.ts +++ b/src/domain/services/index/PropertyIndexReader.ts @@ -1,7 +1,7 @@ /** * Reads property index shards lazily with LRU caching. * - * Loads `props_XX.cbor` shards on demand via IndexStoragePort.readBlob. + * Loads `props_XX.cbor` shards on demand through IndexStorePort. * * @module domain/services/index/PropertyIndexReader */ @@ -10,9 +10,9 @@ import computeShardKey from '../../utils/shardKey.ts'; import LRUCache from '../../utils/LRUCache.ts'; import IndexError from '../../errors/IndexError.ts'; import { requireCodec } from '../codec/CodecRequirement.ts'; -import type IndexStoragePort from '../../../ports/IndexStoragePort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type IndexStorePort from '../../../ports/IndexStorePort.ts'; +import type AssetHandle from '../../storage/AssetHandle.ts'; import { isPropValue, type PropValue } from '../../types/PropValue.ts'; import type CodecValue from '../../types/codec/CodecValue.ts'; @@ -39,31 +39,38 @@ function isPropertyShardEntry(value: CodecValue): value is PropertyShardEntry { } export default class PropertyIndexReader { - private readonly _storage: IndexStoragePort | undefined; private readonly _codec: CodecPort | null; private readonly _indexStore: IndexStorePort | null; - private _shardOids: Map; + private _shardHandles: Map; + private _inMemoryShards: Map; private readonly _cache: LRUCache; constructor(options?: { - storage?: IndexStoragePort; codec?: CodecPort; indexStore?: IndexStorePort; maxCachedShards?: number; }) { - const { storage, codec, indexStore, maxCachedShards = 64 } = options ?? {}; - this._storage = storage; + const { codec, indexStore, maxCachedShards = 64 } = options ?? {}; this._codec = codec ?? null; this._indexStore = indexStore ?? null; - this._shardOids = new Map(); + this._shardHandles = new Map(); + this._inMemoryShards = new Map(); this._cache = new LRUCache(maxCachedShards); } - /** - * Configures OID mappings for lazy loading. - */ - setup(shardOids: Record): void { - this._shardOids = new Map(Object.entries(shardOids)); + /** Configures opaque shard handles for lazy loading. */ + setupHandles(shardHandles: Readonly>): void { + this._shardHandles = new Map(Object.entries(shardHandles)); + this._inMemoryShards.clear(); + this._cache.clear(); + } + + /** Configures encoded in-memory shards for a freshly built view. */ + setupTree(tree: Readonly>): void { + this._shardHandles.clear(); + this._inMemoryShards = new Map( + Object.entries(tree).filter(([path]) => path.startsWith('props_')), + ); this._cache.clear(); } @@ -98,40 +105,41 @@ export default class PropertyIndexReader { return cached; } - const oid = this._resolveOid(path); - if (oid === null) { - return null; - } - - return await this._fetchAndDecode(oid, path); - } - - private _resolveOid(path: string): string | null { - const oid = this._shardOids.get(path); - if (oid === undefined || oid === '') { - return null; - } - if (!this._storage && !this._indexStore) { + const handle = this._shardHandles.get(path); + const inMemory = this._inMemoryShards.get(path); + if (handle === undefined && inMemory === undefined) { return null; } - return oid; + return await this._fetchAndDecode({ path, handle, inMemory }); } - private async _fetchAndDecode(oid: string, path: string): Promise { - if (this._indexStore) { - const decoded = await this._indexStore.decodeShard(oid); - return this._parseShard(decoded, path); + private async _fetchAndDecode(options: { + path: string; + handle: AssetHandle | undefined; + inMemory: Uint8Array | undefined; + }): Promise { + if (options.handle !== undefined) { + if (this._indexStore === null) { + throw new IndexError( + `PropertyIndexReader: no index store for '${options.path}'`, + { code: 'E_INDEX_NO_STORE', context: { path: options.path } }, + ); + } + return this._parseShard( + await this._indexStore.decodeShard(options.handle), + options.path, + ); } - const storage = this._storage as { readBlob(oid: string): Promise }; - const buffer = await storage.readBlob(oid); - if (buffer === null || buffer === undefined) { + if (options.inMemory === undefined) { throw new IndexError( - `PropertyIndexReader: missing blob for OID '${oid}' (${path})`, - { code: 'E_INDEX_SHARD_MISSING', context: { oid, path } }, + `PropertyIndexReader: missing shard '${options.path}'`, + { code: 'E_INDEX_SHARD_MISSING', context: { path: options.path } }, ); } - const decoded = requireCodec(this._codec, 'PropertyIndexReader').decode(buffer); - return this._parseShard(decoded, path); + return this._parseShard( + requireCodec(this._codec, 'PropertyIndexReader').decode(options.inMemory), + options.path, + ); } private _parseShard(decoded: CodecValue, path: string): PropertyShard { diff --git a/src/domain/services/optic/CheckpointBasisFactTypes.ts b/src/domain/services/optic/CheckpointBasisFactTypes.ts index e5ad13e6..1e41d4f6 100644 --- a/src/domain/services/optic/CheckpointBasisFactTypes.ts +++ b/src/domain/services/optic/CheckpointBasisFactTypes.ts @@ -66,7 +66,7 @@ export type CheckpointProvenanceFactTransport = { export type CheckpointContentAnchorFactTransport = { readonly kind: 'content-anchor'; readonly owner: string; - readonly contentOid: string; + readonly contentHandle: string; readonly retainedPayloadByteHash: string | null; readonly eventId: CheckpointFactEventTransport; }; diff --git a/src/domain/services/optic/CheckpointContentAnchorFact.ts b/src/domain/services/optic/CheckpointContentAnchorFact.ts index b051f565..a62c17b5 100644 --- a/src/domain/services/optic/CheckpointContentAnchorFact.ts +++ b/src/domain/services/optic/CheckpointContentAnchorFact.ts @@ -1,4 +1,6 @@ import type { EventId } from '../../utils/EventId.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; +import WarpError from '../../errors/WarpError.ts'; import computeShardKey from '../../utils/shardKey.ts'; import { CheckpointBasisFact } from './CheckpointBasisFactBase.ts'; import { @@ -15,19 +17,19 @@ import type { export class CheckpointContentAnchorFact extends CheckpointBasisFact { readonly kind = 'content-anchor' as const; readonly owner: string; - readonly contentOid: string; + readonly contentHandle: AssetHandle; readonly retainedPayloadByteHash: string | null; readonly eventId: EventId; constructor(options: { readonly owner: string; - readonly contentOid: string; + readonly contentHandle: AssetHandle; readonly retainedPayloadByteHash?: string | null; readonly eventId: EventId; }) { super(); this.owner = validateText(options.owner, 'owner'); - this.contentOid = validateText(options.contentOid, 'contentOid'); + this.contentHandle = requireAssetHandle(options.contentHandle); this.retainedPayloadByteHash = options.retainedPayloadByteHash === undefined || options.retainedPayloadByteHash === null ? null @@ -45,16 +47,26 @@ export class CheckpointContentAnchorFact extends CheckpointBasisFact { } sortKey(): string { - return `${this.owner}:${this.contentOid}:${eventSortKey(this.eventId)}`; + return `${this.owner}:${this.contentHandle.toString()}:${eventSortKey(this.eventId)}`; } toTransport(): CheckpointBasisFactTransport { return { kind: this.kind, owner: this.owner, - contentOid: this.contentOid, + contentHandle: this.contentHandle.toString(), retainedPayloadByteHash: this.retainedPayloadByteHash, eventId: eventTransport(this.eventId), }; } } + +function requireAssetHandle(value: AssetHandle): AssetHandle { + if (value instanceof AssetHandle) { + return value; + } + throw new WarpError( + 'Checkpoint content anchor requires an AssetHandle', + 'E_CHECKPOINT_CONTENT_HANDLE', + ); +} diff --git a/src/domain/services/optic/CheckpointFactResolver.ts b/src/domain/services/optic/CheckpointFactResolver.ts index 1c754f6c..59af722e 100644 --- a/src/domain/services/optic/CheckpointFactResolver.ts +++ b/src/domain/services/optic/CheckpointFactResolver.ts @@ -1,6 +1,7 @@ import MemoryBudgetError from '../../errors/MemoryBudgetError.ts'; import WarpMemoryPool from '../../memory/WarpMemoryPool.ts'; import type { PropValue } from '../../types/PropValue.ts'; +import type AssetHandle from '../../storage/AssetHandle.ts'; import { compareEventIds } from '../../utils/EventId.ts'; import { CheckpointContentAnchorFact, @@ -95,10 +96,10 @@ export default class CheckpointFactResolver { return latest === null ? Object.freeze({ found: false, value: null }) : Object.freeze({ found: true, value: latest.value }); } - async resolveContentOid( + async resolveContentHandle( facts: AsyncIterable, owner: string, - ): Promise { + ): Promise { let latest: CheckpointContentAnchorFact | null = null; for await (const fact of facts) { const lease = this._pool.acquire({ scope: 'checkpoint.fact.content-anchor', amount: 1 }); @@ -108,7 +109,7 @@ export default class CheckpointFactResolver { lease.release(); } } - return latest?.contentOid ?? null; + return latest?.contentHandle ?? null; } } diff --git a/src/domain/services/optic/CheckpointNeighborhoodPageReader.ts b/src/domain/services/optic/CheckpointNeighborhoodPageReader.ts index 189f4682..c147d5dd 100644 --- a/src/domain/services/optic/CheckpointNeighborhoodPageReader.ts +++ b/src/domain/services/optic/CheckpointNeighborhoodPageReader.ts @@ -25,7 +25,7 @@ import type { ReadIdentityIndexShard } from './ReadIdentity.ts'; const LABELS_PATH = 'labels.cbor'; -type ReadBlob = (path: string, oid: string) => Promise; +type ReadShard = (path: string, handle: string) => Promise; type DecodedMetaShard = { readonly nodeToGlobal: Array<[string, number]> | Record; @@ -42,7 +42,7 @@ type LoadedMetaShard = { type NeighborhoodPageReadContext = { readonly basis: CheckpointTailIndexBasis; readonly options: CheckpointShardNeighborhoodReadOptions; - readonly readBlob: ReadBlob; + readonly readShard: ReadShard; readonly source: CheckpointTailOpticSource; }; @@ -79,19 +79,19 @@ class NeighborhoodShardLoader { private readonly _basis: CheckpointTailIndexBasis; private readonly _codec: CheckpointTailOpticSource['_codec']; private readonly _evidence: ShardEvidence; - private readonly _readBlob: ReadBlob; + private readonly _readShard: ReadShard; private _loadedMeta: { readonly path: string; readonly value: LoadedMetaShard } | null = null; constructor(options: { readonly basis: CheckpointTailIndexBasis; readonly codec: CheckpointTailOpticSource['_codec']; readonly evidence: ShardEvidence; - readonly readBlob: ReadBlob; + readonly readShard: ReadShard; }) { this._basis = options.basis; this._codec = options.codec; this._evidence = options.evidence; - this._readBlob = options.readBlob; + this._readShard = options.readShard; } hasAdjacency(direction: Direction, nodeId: string): boolean { @@ -165,7 +165,7 @@ class NeighborhoodShardLoader { } private async _read(path: string, oid: string): Promise { - const bytes = await this._readBlob(path, oid); + const bytes = await this._readShard(path, oid); this._evidence.add(path, oid); return bytes; } @@ -173,11 +173,11 @@ class NeighborhoodShardLoader { export default class CheckpointNeighborhoodPageReader { private readonly _source: CheckpointTailOpticSource; - private readonly _readBlob: ReadBlob; + private readonly _readShard: ReadShard; - constructor(options: { readonly source: CheckpointTailOpticSource; readonly readBlob: ReadBlob }) { + constructor(options: { readonly source: CheckpointTailOpticSource; readonly readShard: ReadShard }) { this._source = options.source; - this._readBlob = options.readBlob; + this._readShard = options.readShard; Object.freeze(this); } @@ -188,7 +188,7 @@ export default class CheckpointNeighborhoodPageReader { return await readCheckpointNeighborhoodPage({ basis, options, - readBlob: this._readBlob, + readShard: this._readShard, source: this._source, }); } @@ -205,7 +205,7 @@ async function readCheckpointNeighborhoodPage( basis: options.basis, codec: options.source._codec, evidence, - readBlob: options.readBlob, + readShard: options.readShard, }); const sourceGlobalId = await loader.readSourceGlobalId(options.options.nodeId); if (sourceGlobalId === null diff --git a/src/domain/services/optic/CheckpointPatchFactStream.ts b/src/domain/services/optic/CheckpointPatchFactStream.ts index 7760eba8..cd79eb6f 100644 --- a/src/domain/services/optic/CheckpointPatchFactStream.ts +++ b/src/domain/services/optic/CheckpointPatchFactStream.ts @@ -12,6 +12,7 @@ import { isPropValue } from '../../types/PropValue.ts'; import { EventId } from '../../utils/EventId.ts'; import MemoryBudgetError from '../../errors/MemoryBudgetError.ts'; import WarpMemoryPool from '../../memory/WarpMemoryPool.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; import { normalizeRawOp } from '../OpNormalizer.ts'; import { CheckpointAdjacencyFact, @@ -320,7 +321,7 @@ function factsForContentOperation( return factsWithProvenance([ new CheckpointContentAnchorFact({ owner: op.node, - contentOid: op.oid, + contentHandle: new AssetHandle(op.oid), eventId, }), ], op.node, eventId); diff --git a/src/domain/services/optic/CheckpointShardFactReader.ts b/src/domain/services/optic/CheckpointShardFactReader.ts index 9077595d..b515c14b 100644 --- a/src/domain/services/optic/CheckpointShardFactReader.ts +++ b/src/domain/services/optic/CheckpointShardFactReader.ts @@ -12,6 +12,8 @@ import CheckpointNeighborhoodPageReader, { import type { CheckpointTailIndexBasis } from './CheckpointTailBasisLoader.ts'; import type CheckpointTailOpticSource from './CheckpointTailOpticSource.ts'; import type { ReadIdentityIndexShard } from './ReadIdentity.ts'; +import type AssetHandle from '../../storage/AssetHandle.ts'; +import { collectAsyncIterable } from '../../utils/streamUtils.ts'; export type { CheckpointShardNeighborhoodPage, @@ -37,19 +39,9 @@ type CheckpointShardReadFailureContext = { export default class CheckpointShardFactReader { private readonly _source: CheckpointTailOpticSource; - private readonly _neighborhoodReader: CheckpointNeighborhoodPageReader; constructor(options: { readonly source: CheckpointTailOpticSource }) { this._source = options.source; - this._neighborhoodReader = new CheckpointNeighborhoodPageReader({ - source: options.source, - readBlob: async (path, oid) => await readShardBlob({ - graphName: options.source.graphName, - persistence: options.source._persistence, - path, - oid, - }), - }); Object.freeze(this); } @@ -58,19 +50,21 @@ export default class CheckpointShardFactReader { nodeId: string, ): Promise { const path = this._metaPath(nodeId); - const oid = basis.manifest.livenessRoots.get(path); - if (oid === undefined) { + const token = basis.manifest.livenessRoots.get(path); + if (token === undefined) { return false; } + const handle = requireBoundShardHandle(basis, path, token); try { - const reader = await new LogicalIndexReader({ codec: this._source._codec }) - .loadFromOids({ [path]: oid }, this._source._persistence); + const reader = await new LogicalIndexReader({ indexStore: this._source._indexStore }) + .loadFromHandles({ [path]: handle }); return reader.toLogicalIndex().isAlive(nodeId); } catch (error) { - const context = { graphName: this._source.graphName, path, oid }; - const failure = error instanceof Error ? checkpointLogicalShardReadFailure(error, context) : null; - if (failure !== null) { throw failure; } - throw error; + if (!(error instanceof Error)) { + throw error; + } + const context = { graphName: this._source.graphName, path, oid: token }; + return rethrowLogicalShardReadFailure(error, context); } } @@ -80,23 +74,24 @@ export default class CheckpointShardFactReader { propertyKey: string, ): Promise { const path = this._propertyPath(nodeId); - const oid = basis.manifest.propertyRoots.get(path); - if (oid === undefined) { + const token = basis.manifest.propertyRoots.get(path); + if (token === undefined) { return undefined; } + const handle = requireBoundShardHandle(basis, path, token); const reader = new PropertyIndexReader({ - storage: this._source._persistence, - codec: this._source._codec, + indexStore: this._source._indexStore, maxCachedShards: MAX_CACHED_CHECKPOINT_PROPERTY_SHARDS, }); - reader.setup({ [path]: oid }); + reader.setupHandles({ [path]: handle }); try { return await reader.getProperty(nodeId, propertyKey); } catch (error) { - const context = { graphName: this._source.graphName, path, oid }; - const failure = error instanceof Error ? checkpointShardReadFailure(error, context) : null; - if (failure !== null) { throw failure; } - throw error; + if (!(error instanceof Error)) { + throw error; + } + const context = { graphName: this._source.graphName, path, oid: token }; + return rethrowPropertyShardReadFailure(error, context); } } @@ -104,13 +99,23 @@ export default class CheckpointShardFactReader { basis: CheckpointTailIndexBasis, options: CheckpointShardNeighborhoodReadOptions, ): Promise { + const neighborhoodReader = new CheckpointNeighborhoodPageReader({ + source: this._source, + readShard: async (path, token) => await readShardAsset({ + graphName: this._source.graphName, + indexStore: this._source._indexStore, + path, + handle: requireBoundShardHandle(basis, path, token), + }), + }); try { - return await this._neighborhoodReader.read(basis, options); + return await neighborhoodReader.read(basis, options); } catch (error) { + if (!(error instanceof Error)) { + throw error; + } const context = firstShardFailureContext(this._source.graphName); - const failure = error instanceof Error ? checkpointLogicalShardReadFailure(error, context) : null; - if (failure !== null) { throw failure; } - throw error; + return rethrowLogicalShardReadFailure(error, context); } } @@ -140,20 +145,73 @@ export default class CheckpointShardFactReader { } -async function readShardBlob(options: { +function rethrowLogicalShardReadFailure( + error: Error, + context: CheckpointShardReadFailureContext, +): never { + const failure = checkpointLogicalShardReadFailure(error, context); + if (failure !== null) { + throw failure; + } + throw error; +} + +function rethrowPropertyShardReadFailure( + error: Error, + context: CheckpointShardReadFailureContext, +): never { + const failure = checkpointShardReadFailure(error, context); + if (failure !== null) { + throw failure; + } + throw error; +} + +function requireBoundShardHandle( + basis: CheckpointTailIndexBasis, + path: string, + manifestToken: string, +): AssetHandle { + const handle = basis.indexHandles[path] ?? basis.propHandles[path]; + if (handle === undefined) { + throwShardIdentityMismatch(path, manifestToken, null); + } + if (handle.toString() !== manifestToken) { + throwShardIdentityMismatch(path, manifestToken, handle.toString()); + } + return handle; +} + +function throwShardIdentityMismatch( + path: string, + manifestToken: string, + basisToken: string | null, +): never { + throw new QueryError('Checkpoint shard identity does not match its bounded basis.', { + code: 'E_OPTIC_NO_BOUNDED_BASIS', + context: { + reason: CHECKPOINT_SHARD_INVALID_CAUSE, + path, + manifestHandle: manifestToken, + basisHandle: basisToken, + }, + }); +} + +async function readShardAsset(options: { readonly graphName: string; - readonly persistence: CheckpointTailOpticSource['_persistence']; + readonly indexStore: CheckpointTailOpticSource['_indexStore']; readonly path: string; - readonly oid: string; + readonly handle: AssetHandle; }): Promise { try { - return await options.persistence.readBlob(options.oid); + return await collectAsyncIterable(options.indexStore.openShard(options.handle)); } catch (error) { const failure = error instanceof Error ? checkpointLogicalShardReadFailure(error, { graphName: options.graphName, path: options.path, - oid: options.oid, + oid: options.handle.toString(), }) : null; if (failure !== null) { diff --git a/src/domain/services/optic/CheckpointTailBasisLoader.ts b/src/domain/services/optic/CheckpointTailBasisLoader.ts index 6c8e7d97..8886f3c0 100644 --- a/src/domain/services/optic/CheckpointTailBasisLoader.ts +++ b/src/domain/services/optic/CheckpointTailBasisLoader.ts @@ -1,12 +1,7 @@ -import type { CheckpointCommitMessage } from '../../../ports/CommitMessageCodecPort.ts'; import QueryError from '../../errors/QueryError.ts'; -import { textDecode, textEncode } from '../../utils/bytes.ts'; -import { deserializeFrontier } from '../Frontier.ts'; -import { partitionShardOids } from '../MaterializedViewHelpers.ts'; -import { - isCurrentCheckpointSchema, - partitionTreeOids, -} from '../state/checkpointHelpers.ts'; +import type AssetHandle from '../../storage/AssetHandle.ts'; +import { partitionShardHandles } from '../MaterializedViewHelpers.ts'; +import { isCurrentCheckpointSchema } from '../state/checkpointHelpers.ts'; import CheckpointBasisManifest, { CheckpointBasisChunking, CheckpointBasisCompleteness, @@ -16,10 +11,7 @@ import CheckpointBasisManifest, { } from './CheckpointBasisManifest.ts'; import type CheckpointTailOpticSource from './CheckpointTailOpticSource.ts'; -const CAS_POINTER_PREFIX = 'git-warp:cas-pointer:v1:'; -const CAS_POINTER_PREFIX_BYTES = textEncode(CAS_POINTER_PREFIX); - -export type CheckpointTailShardOidMap = { +export type CheckpointTailShardIdentityMap = { readonly [path: string]: string; }; @@ -28,8 +20,8 @@ export type CheckpointTailIndexBasis = { readonly schema: number; readonly frontier: Map; readonly manifest: CheckpointBasisManifest; - readonly indexOids: CheckpointTailShardOidMap; - readonly propOids: CheckpointTailShardOidMap; + readonly indexHandles: Readonly>; + readonly propHandles: Readonly>; }; type CheckpointTailManifestRoots = { @@ -50,31 +42,30 @@ export default class CheckpointTailBasisLoader { async load(): Promise { const checkpointSha = await this._readCheckpointSha(); - const checkpointMessage = await this._decodeCheckpointMessage(checkpointSha); - if (!isCurrentCheckpointSchema(checkpointMessage.schema)) { + const basis = await this._source._checkpointStore.loadBasis(checkpointSha, this._source.graphName); + if (!isCurrentCheckpointSchema(basis.schema)) { throwNoBoundedBasis(this._source.graphName, 'checkpoint-without-index-tree'); } - const indexShardOids = await this._loadCheckpointIndexShardOids(checkpointMessage.indexOid); - const frontierBytes = await this._readCheckpointPayloadBlob(checkpointMessage.frontierOid); - const frontier = deserializeFrontier(frontierBytes, { codec: this._source._codec }); - const { indexOids, propOids } = partitionShardOids(indexShardOids); - if (Object.keys(indexOids).length === 0 && Object.keys(propOids).length === 0) { + const { indexHandles, propHandles } = partitionShardHandles(basis.indexShardHandles); + if (Object.keys(indexHandles).length === 0 && Object.keys(propHandles).length === 0) { throwNoBoundedBasis(this._source.graphName, 'checkpoint-missing-index-shards'); } + const indexIdentities = handleIdentities(indexHandles); + const propIdentities = handleIdentities(propHandles); return { checkpointSha, - schema: checkpointMessage.schema, - frontier, + schema: basis.schema, + frontier: basis.frontier, manifest: createManifest({ graphName: this._source.graphName, checkpointSha, - frontier, - schema: checkpointMessage.schema, - indexOids, - propOids, + frontier: basis.frontier, + schema: basis.schema, + indexOids: indexIdentities, + propOids: propIdentities, }), - indexOids, - propOids, + indexHandles, + propHandles, }; } @@ -86,38 +77,6 @@ export default class CheckpointTailBasisLoader { return checkpointSha; } - private async _decodeCheckpointMessage(checkpointSha: string): Promise { - const commitMessage = await this._source._persistence.showNode(checkpointSha); - return this._source._commitMessageCodec.decodeCheckpoint(commitMessage); - } - - private async _readCheckpointPayloadBlob(oid: string): Promise { - const bytes = await this._source._persistence.readBlob(oid); - const storageOid = decodeCasPayloadPointer(bytes); - if (storageOid === null) { - return bytes; - } - if (this._source._blobStorage === null) { - throwNoBoundedBasis(this._source.graphName, 'checkpoint-payload-pointer-without-storage'); - } - return await this._source._blobStorage.retrieve(storageOid); - } - - private async _loadCheckpointIndexShardOids( - checkpointTreeOid: string, - ): Promise { - const rawTreeOids = await this._source._persistence.readTreeOids(checkpointTreeOid); - const { treeOids, indexShardOids } = partitionTreeOids(rawTreeOids); - if (Object.keys(indexShardOids).length > 0) { - return indexShardOids; - } - - const indexTreeOid = treeOids['index']; - if (indexTreeOid === undefined) { - return indexShardOids; - } - return await this._source._persistence.readTreeOids(indexTreeOid); - } } function createManifest(options: { @@ -125,8 +84,8 @@ function createManifest(options: { readonly checkpointSha: string; readonly frontier: Map; readonly schema: number; - readonly indexOids: CheckpointTailShardOidMap; - readonly propOids: CheckpointTailShardOidMap; + readonly indexOids: CheckpointTailShardIdentityMap; + readonly propOids: CheckpointTailShardIdentityMap; }): CheckpointBasisManifest { const roots = createManifestRoots(options.indexOids, options.propOids); const shardCount = manifestShardCount(roots); @@ -148,8 +107,8 @@ function createManifest(options: { } function createManifestRoots( - indexOids: CheckpointTailShardOidMap, - propOids: CheckpointTailShardOidMap, + indexOids: CheckpointTailShardIdentityMap, + propOids: CheckpointTailShardIdentityMap, ): CheckpointTailManifestRoots { return { livenessRoots: rootsForPrefix('node-liveness', indexOids, 'meta_'), @@ -189,7 +148,7 @@ function checkpointChunking(shardCount: number): CheckpointBasisChunking { function rootsForPrefix( family: 'node-liveness' | 'outgoing-adjacency' | 'incoming-adjacency', - source: CheckpointTailShardOidMap, + source: CheckpointTailShardIdentityMap, prefix: string, ): CheckpointBasisShardRootMap { const roots = new Map(); @@ -201,7 +160,7 @@ function rootsForPrefix( return new CheckpointBasisShardRootMap({ family, roots }); } -function edgeFactRootsFromIndex(source: CheckpointTailShardOidMap): CheckpointBasisShardRootMap { +function edgeFactRootsFromIndex(source: CheckpointTailShardIdentityMap): CheckpointBasisShardRootMap { const roots = new Map(); for (const [path, oid] of Object.entries(source)) { if (!path.startsWith('meta_') && !path.startsWith('fwd_') && !path.startsWith('rev_')) { @@ -211,7 +170,7 @@ function edgeFactRootsFromIndex(source: CheckpointTailShardOidMap): CheckpointBa return new CheckpointBasisShardRootMap({ family: 'edge-fact', roots }); } -function shardOidMapToMap(source: CheckpointTailShardOidMap): Map { +function shardOidMapToMap(source: CheckpointTailShardIdentityMap): Map { return new Map(Object.entries(source).sort(([left], [right]) => left.localeCompare(right))); } @@ -223,31 +182,12 @@ function appliedVersionVectorFromFrontier(frontier: Map): Map>, +): CheckpointTailShardIdentityMap { + return Object.freeze(Object.fromEntries( + Object.entries(handles).map(([path, handle]) => [path, handle.toString()]), + )); } function throwNoBoundedBasis(graphName: string, reason: string): never { diff --git a/src/domain/services/optic/CheckpointTailBasisVerifier.ts b/src/domain/services/optic/CheckpointTailBasisVerifier.ts index 1ea21428..e1c2f82a 100644 --- a/src/domain/services/optic/CheckpointTailBasisVerifier.ts +++ b/src/domain/services/optic/CheckpointTailBasisVerifier.ts @@ -1,21 +1,13 @@ -import type { CheckpointCommitMessage } from '../../../ports/CommitMessageCodecPort.ts'; -import TreeEntryFound from '../../tree/TreeEntryFound.ts'; -import TreeEntryLimit from '../../tree/TreeEntryLimit.ts'; -import TreeEntryPath from '../../tree/TreeEntryPath.ts'; -import type TreeEntryProbePort from '../../../ports/TreeEntryProbePort.ts'; import QueryError from '../../errors/QueryError.ts'; import { isCurrentCheckpointSchema } from '../state/checkpointHelpers.ts'; import type CheckpointTailOpticSource from './CheckpointTailOpticSource.ts'; +import type { CheckpointBasis } from '../../../ports/CheckpointStorePort.ts'; export type CheckpointTailBasisVerification = { readonly checkpointSha: string; }; -const FRONTIER_ENTRY_PATH = new TreeEntryPath('frontier.cbor'); -const INDEX_SUBTREE_PATH = new TreeEntryPath('index'); -const INDEX_SHARD_PREFIX = new TreeEntryPath('index/'); -const INDEX_SHARD_EVIDENCE_LIMIT = new TreeEntryLimit(1); - +/** Verifies bounded checkpoint support through the semantic checkpoint store. */ export default class CheckpointTailBasisVerifier { private readonly _source: CheckpointTailOpticSource; @@ -25,79 +17,51 @@ export default class CheckpointTailBasisVerifier { } async verify(): Promise { - const checkpointSha = await this._readCheckpointSha(); - const checkpointMessage = await this._decodeCheckpointMessage(checkpointSha); - this._verifyCheckpointSchema(checkpointMessage); - await this._verifyCheckpointTree(checkpointMessage); + const checkpointSha = requireCheckpointSha( + this._source.graphName, + await this._source._readCheckpointSha(), + ); + await verifyCheckpointBasis(this._source, checkpointSha); return Object.freeze({ checkpointSha }); } +} - private async _readCheckpointSha(): Promise { - const checkpointSha = await this._source._readCheckpointSha(); - if (checkpointSha === null) { - throwNoBoundedBasis(this._source.graphName, 'missing-checkpoint'); - } - return checkpointSha; - } - - private async _decodeCheckpointMessage(checkpointSha: string): Promise { - const commitMessage = await this._source._persistence.showNode(checkpointSha); - return this._source._commitMessageCodec.decodeCheckpoint(commitMessage); +function requireCheckpointSha(graphName: string, checkpointSha: string | null): string { + if (checkpointSha === null) { + throwNoBoundedBasis(graphName, 'missing-checkpoint'); } + return checkpointSha; +} - private _verifyCheckpointSchema(checkpointMessage: CheckpointCommitMessage): void { - if (!isCurrentCheckpointSchema(checkpointMessage.schema)) { - throwNoBoundedBasis(this._source.graphName, 'checkpoint-without-index-tree'); - } - } - - private async _verifyCheckpointTree( - checkpointMessage: CheckpointCommitMessage, - ): Promise { - const treeEntryProbe = this._treeEntryProbePort(); - const frontierEntry = await treeEntryProbe.readTreeEntryOid( - checkpointMessage.indexOid, - FRONTIER_ENTRY_PATH, - ); - if (!(frontierEntry instanceof TreeEntryFound)) { - throwNoBoundedBasis(this._source.graphName, 'checkpoint-missing-frontier'); - } - const indexEntry = await treeEntryProbe.readTreeEntryOid( - checkpointMessage.indexOid, - INDEX_SUBTREE_PATH, - ); - if (indexEntry instanceof TreeEntryFound) { - return; - } - const indexShardEvidence = await treeEntryProbe.readTreeEntryPrefix( - checkpointMessage.indexOid, - INDEX_SHARD_PREFIX, - INDEX_SHARD_EVIDENCE_LIMIT, - ); - if (!indexShardEvidence.hasEntries()) { - throwNoBoundedBasis(this._source.graphName, 'checkpoint-missing-index-shards'); +async function verifyCheckpointBasis( + source: CheckpointTailOpticSource, + checkpointSha: string, +): Promise { + try { + const basis = await source._checkpointStore.loadBasis(checkpointSha, source.graphName); + requireCurrentSchema(source.graphName, basis.schema); + requireIndexShards(source.graphName, basis.indexShardHandles); + } catch (error) { + if (error instanceof QueryError && error.code === 'E_OPTIC_NO_BOUNDED_BASIS') { + throw error; } + throwNoBoundedBasis(source.graphName, 'checkpoint-basis-unavailable'); } +} - private _treeEntryProbePort(): TreeEntryProbePort { - if (!hasTreeEntryProbePort(this._source._persistence)) { - throwNoBoundedBasis(this._source.graphName, 'tree-entry-probe-unavailable'); - } - return this._source._persistence; +function requireCurrentSchema(graphName: string, schema: number): void { + if (!isCurrentCheckpointSchema(schema)) { + throwNoBoundedBasis(graphName, 'checkpoint-without-index-tree'); } } -type CheckpointTailPersistence = CheckpointTailOpticSource['_persistence']; - -function hasTreeEntryProbePort( - persistence: CheckpointTailPersistence, -): persistence is CheckpointTailPersistence & TreeEntryProbePort { - return ( - 'readTreeEntryOid' in persistence && - typeof persistence.readTreeEntryOid === 'function' && - 'readTreeEntryPrefix' in persistence && - typeof persistence.readTreeEntryPrefix === 'function' - ); +function requireIndexShards( + graphName: string, + handles: CheckpointBasis['indexShardHandles'], +): void { + if (Object.keys(handles).length === 0) { + throwNoBoundedBasis(graphName, 'checkpoint-missing-index-shards'); + } } function throwNoBoundedBasis(graphName: string, reason: string): never { diff --git a/src/domain/services/optic/CheckpointTailOpticSource.ts b/src/domain/services/optic/CheckpointTailOpticSource.ts index 076c5430..92e2e296 100644 --- a/src/domain/services/optic/CheckpointTailOpticSource.ts +++ b/src/domain/services/optic/CheckpointTailOpticSource.ts @@ -1,7 +1,6 @@ import type CodecPort from '../../../ports/CodecPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; -import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; -import type { CorePersistence } from '../../types/WarpPersistence.ts'; +import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; +import type IndexStorePort from '../../../ports/IndexStorePort.ts'; import type Patch from '../../types/Patch.ts'; export type CheckpointTailPatchEntry = { @@ -16,10 +15,9 @@ export type CheckpointTailCheckpointFrontier = { export default abstract class CheckpointTailOpticSource { abstract readonly graphName: string; - abstract readonly _persistence: CorePersistence; abstract readonly _codec: CodecPort; - abstract readonly _blobStorage: BlobStoragePort | null; - abstract readonly _commitMessageCodec: CommitMessageCodecPort; + abstract readonly _checkpointStore: CheckpointStorePort; + abstract readonly _indexStore: IndexStorePort; abstract discoverWriters(): Promise; diff --git a/src/domain/services/optic/CoordinateCheckpointTailOpticSource.ts b/src/domain/services/optic/CoordinateCheckpointTailOpticSource.ts index 792dbe7c..fb9dd50e 100644 --- a/src/domain/services/optic/CoordinateCheckpointTailOpticSource.ts +++ b/src/domain/services/optic/CoordinateCheckpointTailOpticSource.ts @@ -3,10 +3,9 @@ import CheckpointTailOpticSource, { type CheckpointTailPatchEntry, } from './CheckpointTailOpticSource.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; -import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; +import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; +import type IndexStorePort from '../../../ports/IndexStorePort.ts'; import WarpError from '../../errors/WarpError.ts'; -import type { CorePersistence } from '../../types/WarpPersistence.ts'; export type CoordinateCheckpointTailOpticSourceOptions = { readonly source: CheckpointTailOpticSource; @@ -16,10 +15,9 @@ export type CoordinateCheckpointTailOpticSourceOptions = { export default class CoordinateCheckpointTailOpticSource extends CheckpointTailOpticSource { readonly graphName: string; - readonly _persistence: CorePersistence; readonly _codec: CodecPort; - readonly _blobStorage: BlobStoragePort | null; - readonly _commitMessageCodec: CommitMessageCodecPort; + readonly _checkpointStore: CheckpointStorePort; + readonly _indexStore: IndexStorePort; private readonly _source: CheckpointTailOpticSource; private readonly _checkpointSha: string; private readonly _frontier: Map; @@ -29,10 +27,9 @@ export default class CoordinateCheckpointTailOpticSource extends CheckpointTailO assertSource(options.source); assertFrontier(options.frontier); this.graphName = options.source.graphName; - this._persistence = options.source._persistence; this._codec = options.source._codec; - this._blobStorage = options.source._blobStorage; - this._commitMessageCodec = options.source._commitMessageCodec; + this._checkpointStore = options.source._checkpointStore; + this._indexStore = options.source._indexStore; this._source = options.source; assertNonEmpty(options.checkpointSha, 'checkpointSha'); this._checkpointSha = options.checkpointSha; @@ -102,10 +99,9 @@ function hasSourceIdentity(source: CheckpointTailOpticSource): boolean { } function hasSourcePorts(source: CheckpointTailOpticSource): boolean { - return hasPersistencePort(source._persistence) - && hasCodecPort(source._codec) - && hasOptionalBlobStoragePort(source._blobStorage) - && hasCommitMessageCodecPort(source._commitMessageCodec); + return hasCodecPort(source._codec) + && hasCheckpointStorePort(source._checkpointStore) + && hasIndexStorePort(source._indexStore); } function hasSourceMethods(source: CheckpointTailOpticSource): boolean { @@ -119,13 +115,10 @@ function hasSourceMethods(source: CheckpointTailOpticSource): boolean { return methodChecks.every((methodExists) => methodExists); } -function hasPersistencePort(persistence: CorePersistence): boolean { +function hasCheckpointStorePort(store: CheckpointStorePort): boolean { const methodChecks = [ - typeof persistence.showNode === 'function', - typeof persistence.getNodeInfo === 'function', - typeof persistence.readBlob === 'function', - typeof persistence.readTreeOids === 'function', - typeof persistence.readRef === 'function', + typeof store.resolveHead === 'function', + typeof store.loadBasis === 'function', ] as const; return methodChecks.every((methodExists) => methodExists); } @@ -138,22 +131,10 @@ function hasCodecPort(codec: CodecPort): boolean { return methodChecks.every((methodExists) => methodExists); } -function hasOptionalBlobStoragePort(blobStorage: BlobStoragePort | null): boolean { - if (blobStorage === null) { - return true; - } - const methodChecks = [ - typeof blobStorage.retrieve === 'function', - typeof blobStorage.retrieveStream === 'function', - ] as const; - return methodChecks.every((methodExists) => methodExists); -} - -function hasCommitMessageCodecPort(commitMessageCodec: CommitMessageCodecPort): boolean { +function hasIndexStorePort(indexStore: IndexStorePort): boolean { const methodChecks = [ - typeof commitMessageCodec.decodeCheckpoint === 'function', - typeof commitMessageCodec.decodePatch === 'function', - typeof commitMessageCodec.detectKind === 'function', + typeof indexStore.openShard === 'function', + typeof indexStore.decodeShard === 'function', ] as const; return methodChecks.every((methodExists) => methodExists); } diff --git a/src/domain/services/state/StateReaderContext.ts b/src/domain/services/state/StateReaderContext.ts index 1ac31585..8337d642 100644 --- a/src/domain/services/state/StateReaderContext.ts +++ b/src/domain/services/state/StateReaderContext.ts @@ -20,7 +20,7 @@ import WarpState from './WarpState.ts'; // ── Public types ──────────────────────────────────────────────────────────── -export type ContentMeta = { oid: string; mime: string | null; size: number | null }; +export type ContentMeta = { handle: string; mime: string | null; size: number | null }; export type NeighborEntry = { nodeId: string; label: string; direction: 'outgoing' | 'incoming' }; type OutgoingNeighborEntry = { nodeId: string; label: string; direction: 'outgoing' }; type IncomingNeighborEntry = { nodeId: string; label: string; direction: 'incoming' }; @@ -397,7 +397,7 @@ function edgeKeyFromRecord(record: EdgeRecord): string { /** Converts a typed content attachment record into public reader metadata. */ function contentMetaFromRecord(record: ContentAttachmentRecord): ContentMeta { return { - oid: record.payload.oid.toString(), + handle: record.payload.handle.toString(), mime: record.payload.mime?.toString() ?? null, size: record.payload.size?.toNumber() ?? null, }; diff --git a/src/domain/services/state/checkpointCreate.ts b/src/domain/services/state/checkpointCreate.ts index 99191040..1b600fc6 100644 --- a/src/domain/services/state/checkpointCreate.ts +++ b/src/domain/services/state/checkpointCreate.ts @@ -1,121 +1,55 @@ -/** - * Checkpoint creation logic for WARP multi-writer graph database. - * - * Supports current checkpoint envelope creation with optional index tree. - * - * @module domain/services/state/checkpointCreate - * @see WARP Spec Section 10 - */ - +/** Checkpoint preparation independent of its physical storage layout. */ import { computeStateHash } from './StateSerializer.ts'; -import { - computeAppliedVV, - serializeAppliedVV, - serializeCheckpointStateEnvelope, -} from './CheckpointSerializer.ts'; -import { serializeFrontier } from '../Frontier.ts'; +import { computeAppliedVV } from './CheckpointSerializer.ts'; import { requireCodec } from '../codec/CodecRequirement.ts'; import { requireCrypto } from '../crypto/CryptoRequirement.ts'; -import { requireCommitMessageCodec } from '../codec/CommitMessageCodecRequirement.ts'; import { cloneState } from '../JoinReducer.ts'; -import { - writeIndexSubtree, - collectContentAnchorEntries, - compareTreeEntriesByPath, - CURRENT_CHECKPOINT_SCHEMA, - type ContentAnchorObjectType, -} from './checkpointHelpers.ts'; import type WarpState from './WarpState.ts'; -import type CommitPort from '../../../ports/CommitPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; -import type { - CheckpointRecord, - CheckpointWriteResult, -} from '../../../ports/CheckpointStorePort.ts'; import type StateHashService from './StateHashService.ts'; import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; -/** Combined persistence surface needed by checkpoint creation. */ -export type CheckpointPersistence = CommitPort & BlobPort & TreePort & { - readObjectType?(_oid: string): Promise; -}; - -/** Options for creating the current checkpoint envelope. */ export interface CreateCheckpointOptions { - persistence: CheckpointPersistence; + checkpointStore: CheckpointStorePort; graphName: string; state: WarpState; frontier: Map; parents?: string[]; + expectedCheckpointSha?: string | null; compact?: boolean; provenanceIndex?: ProvenanceIndex; codec?: CodecPort; - commitMessageCodec: CommitMessageCodecPort; crypto?: CryptoPort; - indexTree?: Record; - checkpointStore?: CheckpointStorePort; + indexTree?: Readonly>; stateHashService?: StateHashService; } -/** - * Creates a checkpoint commit containing a state envelope and frontier. - * - * Tree structure: - * ``` - * / - * ├── state/ # AUTHORITATIVE: state envelope - * ├── frontier.cbor # Writer frontiers - * ├── appliedVV.cbor # Version vector of dots in state - * └── provenanceIndex.cbor # Optional: node-to-patchSha index (HG/IO/2) - * ``` - * - * @returns The checkpoint commit SHA - */ export async function create(options: CreateCheckpointOptions): Promise { return await createCheckpointEnvelope(options); } /** - * Creates a checkpoint commit with full ORSet state envelope. - * - * Checkpoint Tree Structure: - * ``` - * / - * ├── state/ # AUTHORITATIVE: state envelope - * ├── frontier.cbor # Writer frontiers - * ├── appliedVV.cbor # Version vector of dots in state - * └── provenanceIndex.cbor # Optional: node-to-patchSha index (HG/IO/2) - * ``` - * - * @returns The checkpoint commit SHA + * Compacts and hashes checkpoint state, then delegates publication to the + * configured checkpoint store. The domain never observes the resulting + * object layout. */ export async function createCheckpointEnvelope({ - persistence, + checkpointStore, graphName, state, frontier, parents = [], + expectedCheckpointSha, compact = true, provenanceIndex, codec, - commitMessageCodec, crypto, indexTree, - checkpointStore, stateHashService, }: CreateCheckpointOptions): Promise { - const messageCodec = requireCommitMessageCodec(commitMessageCodec); - // 1. Compute appliedVV from actual state dots const appliedVV = computeAppliedVV(state); - - // 2. Optionally compact (only tombstoned dots <= appliedVV). - // When compact=false, checkpointState aliases the caller's state but the - // remaining path is read-only (serialize + hash), so no clone is needed. let checkpointState = state; if (compact) { checkpointState = cloneState(state); @@ -123,154 +57,23 @@ export async function createCheckpointEnvelope({ checkpointState.edgeAlive.compact(appliedVV); } - // 3–6. Serialize and write current state envelope, frontier, appliedVV. - // Runtime callers route artifact encoding through CheckpointStorePort so the - // domain path no longer needs to know the concrete checkpoint blob layout. - let stateHash: string; - - // Compute stateHash first via StateHashService (preferred) or direct fallback. - if (stateHashService !== undefined && stateHashService !== null) { - stateHash = await stateHashService.compute(checkpointState); - } else { - stateHash = await computeStateHash(checkpointState, { + const stateHash = stateHashService !== undefined && stateHashService !== null + ? await stateHashService.compute(checkpointState) + : await computeStateHash(checkpointState, { codec: requireCodec(codec, 'createCheckpointEnvelope'), crypto: requireCrypto(crypto, 'createCheckpointEnvelope'), }); - } - const checkpointRecord: CheckpointRecord = { + const published = await checkpointStore.publishCheckpoint({ + graphName, state: checkpointState, frontier, appliedVV, stateHash, - ...(provenanceIndex !== undefined ? { provenanceIndex } : {}), - }; - const checkpointWrite = checkpointStore !== undefined && checkpointStore !== null - ? await checkpointStore.writeCheckpoint(checkpointRecord) - : await writeFallbackCheckpointArtifacts( - persistence, - checkpointRecord, - requireCodec(codec, 'createCheckpointEnvelope'), - ); - const { - nodeAliveBlobOid: nodeAliveOid, - edgeAliveBlobOid: edgeAliveOid, - propBlobOid: propOid, - observedFrontierBlobOid: observedFrontierOid, - edgeBirthEventBlobOid: edgeBirthEventOid, - frontierBlobOid, - appliedVVBlobOid, - provenanceIndexBlobOid, - } = checkpointWrite; - - // 6c. Collect content storage OIDs from state properties for GC anchoring. - // If patch commits are ever pruned, content trees remain reachable via - // the checkpoint tree. Without this, git gc would nuke content trees - // whose only anchor was the (now-pruned) patch commit tree. - // - // O(P) scan over all properties — acceptable because checkpoint creation - // is infrequent. The property key format is deterministic (encodePropKey / - // encodeEdgePropKey), but content keys are interleaved with regular keys - // so no prefix filter can skip non-content entries without decoding. - // 7. Create the state subtree and outer envelope tree with sorted entries. - const stateTreeEntries = [ - `100644 blob ${edgeAliveOid}\tedgeAlive`, - `100644 blob ${edgeBirthEventOid}\tedgeBirthEvent.cbor`, - `100644 blob ${nodeAliveOid}\tnodeAlive`, - `100644 blob ${observedFrontierOid}\tobservedFrontier.cbor`, - `100644 blob ${propOid}\tprop.cbor`, - ]; - stateTreeEntries.sort(compareTreeEntriesByPath); - const stateTreeOid = await persistence.writeTree(stateTreeEntries); - - // 7b. Optionally write index subtree. - let indexSubtreeOid: string | null = null; - if (indexTree) { - indexSubtreeOid = await writeIndexSubtree(indexTree, persistence); - } - - const treeEntries = await collectContentAnchorEntries( - checkpointState.allPropEntries(), - persistence.readObjectType?.bind(persistence), - ); - treeEntries.push( - `100644 blob ${appliedVVBlobOid}\tappliedVV.cbor`, - `100644 blob ${frontierBlobOid}\tfrontier.cbor`, - `040000 tree ${stateTreeOid}\tstate`, - ); - - // Add provenance index if present - if (provenanceIndexBlobOid !== null) { - treeEntries.push(`100644 blob ${provenanceIndexBlobOid}\tprovenanceIndex.cbor`); - } - - // Add index subtree if present. - if (indexSubtreeOid !== null) { - treeEntries.push(`040000 tree ${indexSubtreeOid}\tindex`); - } - - // Sort entries by filename for deterministic tree (git requires sorted entries by path) - treeEntries.sort(compareTreeEntriesByPath); - - const treeOid = await persistence.writeTree(treeEntries); - - // 8. Create checkpoint commit message. - const message = messageCodec.encodeCheckpoint({ - kind: 'checkpoint', - graph: graphName, - stateHash, - frontierOid: frontierBlobOid, - indexOid: treeOid, - schema: CURRENT_CHECKPOINT_SCHEMA, - checkpointVersion: null, - }); - - // 9. Create the checkpoint commit - const checkpointSha = await persistence.commitNodeWithTree({ - treeOid, parents, - message, + ...(expectedCheckpointSha === undefined ? {} : { expectedCheckpointSha }), + ...(provenanceIndex === undefined ? {} : { provenanceIndex }), + ...(indexTree === undefined ? {} : { indexShards: indexTree }), }); - - return checkpointSha; -} - -async function writeFallbackCheckpointArtifacts( - persistence: CheckpointPersistence, - record: CheckpointRecord, - codec: CodecPort, -): Promise { - const codecOpt = { codec }; - const envelope = serializeCheckpointStateEnvelope(record.state, codecOpt); - const [ - nodeAliveBlobOid, - edgeAliveBlobOid, - propBlobOid, - observedFrontierBlobOid, - edgeBirthEventBlobOid, - frontierBlobOid, - appliedVVBlobOid, - provenanceIndexBlobOid, - ] = await Promise.all([ - persistence.writeBlob(envelope.nodeAlive), - persistence.writeBlob(envelope.edgeAlive), - persistence.writeBlob(envelope.prop), - persistence.writeBlob(envelope.observedFrontier), - persistence.writeBlob(envelope.edgeBirthEvent), - persistence.writeBlob(serializeFrontier(record.frontier, codecOpt)), - persistence.writeBlob(serializeAppliedVV(record.appliedVV, codecOpt)), - record.provenanceIndex === undefined || record.provenanceIndex === null - ? Promise.resolve(null) - : persistence.writeBlob(record.provenanceIndex.serialize(codecOpt)), - ]); - return { - nodeAliveBlobOid, - edgeAliveBlobOid, - propBlobOid, - observedFrontierBlobOid, - edgeBirthEventBlobOid, - frontierBlobOid, - appliedVVBlobOid, - provenanceIndexBlobOid, - }; + return published.checkpointSha; } diff --git a/src/domain/services/state/checkpointHelpers.ts b/src/domain/services/state/checkpointHelpers.ts index 25ecb3b8..75d75a0c 100644 --- a/src/domain/services/state/checkpointHelpers.ts +++ b/src/domain/services/state/checkpointHelpers.ts @@ -1,219 +1,10 @@ -/** - * Internal helpers and constants shared by checkpoint creation and loading. - * - * @module domain/services/state/checkpointHelpers - */ - -import { CONTENT_PROPERTY_KEY, decodePropKey, isEdgePropKey, decodeEdgePropKey } from '../KeyCodec.ts'; -import WarpError from '../../errors/WarpError.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; - /** Current shipped checkpoint schema: envelope tree with state subtree. */ export const CURRENT_CHECKPOINT_SCHEMA = 5; /** Supported shipped runtime checkpoint schemas. */ export const SUPPORTED_CHECKPOINT_SCHEMAS = [CURRENT_CHECKPOINT_SCHEMA] as const; -/** - * Returns true when the schema number identifies the current runtime - * checkpoint envelope. - */ +/** Returns true for the checkpoint schema supported by this runtime. */ export function isCurrentCheckpointSchema(schema: number | undefined | null): boolean { return schema === CURRENT_CHECKPOINT_SCHEMA; } - -/** - * Number of unique content storage OIDs to hold before folding a batch into the - * accumulated sorted anchor list. This keeps checkpoint creation from building - * one monolithic Set of every content reference before tree serialization. - */ -const CONTENT_ANCHOR_BATCH_SIZE = 256; - -export type ContentAnchorObjectType = 'blob' | 'tree'; - -export type ContentAnchorObjectTypeReader = (oid: string) => Promise; - -// ============================================================================ -// Internal Helpers -// ============================================================================ - -/** Minimal persistence surface needed by writeIndexSubtree. */ -export type IndexSubtreePersistence = Pick & Pick; - -/** - * Writes index tree shards as blobs and creates a subtree. - */ -export async function writeIndexSubtree( - indexTree: Record, - persistence: IndexSubtreePersistence, -): Promise { - const paths = Object.keys(indexTree).sort(); - const oids = await Promise.all( - paths.map((path) => { - const blob = indexTree[path]; - if (blob === undefined) { - throw new WarpError( - `Missing index blob for path: ${path}`, - 'E_CHECKPOINT_MISSING_INDEX_BLOB', - { context: { path } }, - ); - } - return persistence.writeBlob(blob); - }), - ); - - const entries = paths.map((path, i) => { - const oid = oids[i]; - if (oid === undefined) { - throw new WarpError( - `Missing index blob OID for path: ${path}`, - 'E_CHECKPOINT_MISSING_INDEX_BLOB_OID', - { context: { path } }, - ); - } - return `100644 blob ${oid}\t${path}`; - }); - return await persistence.writeTree(entries); -} - -/** - * Partitions readTreeOids output into core entries and index shard OIDs. - * - * Entries prefixed with `index/` are stripped and collected separately. - */ -export function partitionTreeOids(rawOids: Record): { - treeOids: Record; - indexShardOids: Record; -} { - const treeOids = new Map(); - const indexShardOids = new Map(); - - for (const [path, oid] of Object.entries(rawOids)) { - if (path.startsWith('index/')) { - indexShardOids.set(path.slice(6), oid); - } else { - treeOids.set(path, oid); - } - } - return { - treeOids: Object.fromEntries(treeOids), - indexShardOids: Object.fromEntries(indexShardOids), - }; -} - -/** - * Compares git tree entry lines by path segment (content after the tab). - */ -export function compareTreeEntriesByPath(left: string, right: string): number { - const leftPath = left.slice(left.indexOf('\t') + 1); - const rightPath = right.slice(right.indexOf('\t') + 1); - return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0; -} - -/** - * Merges two sorted string arrays into one sorted unique array. - */ -export function mergeSortedUniqueStrings(existing: string[], incoming: string[]): string[] { - const merged: string[] = []; - let i = 0; - let j = 0; - - while (i < existing.length && j < incoming.length) { - const left = existing[i] as string; - const right = incoming[j] as string; - if (left === right) { - merged.push(left); - i++; - j++; - continue; - } - if (left < right) { - merged.push(left); - i++; - continue; - } - merged.push(right); - j++; - } - - while (i < existing.length) { - merged.push(existing[i++] as string); - } - while (j < incoming.length) { - merged.push(incoming[j++] as string); - } - - return merged; -} - -/** - * Collects sorted, de-duplicated content storage anchor entries for a - * checkpoint tree without holding all content OIDs in one monolithic Set at - * once. Current content storage OIDs identify CAS trees, so checkpoint trees - * must anchor them as tree entries. - */ -export async function collectContentAnchorEntries( - propMap: Iterable, // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - readObjectType: ContentAnchorObjectTypeReader = defaultContentAnchorObjectType, -): Promise { - let sortedOids: string[] = []; - let batch: Set = new Set(); - - const flushBatch = (): void => { - if (batch.size === 0) { - return; - } - const sortedBatch = Array.from(batch).sort(); - batch = new Set(); - sortedOids = mergeSortedUniqueStrings(sortedOids, sortedBatch); - }; - - for (const [propKey, register] of propMap) { - const { propKey: decodedKey } = isEdgePropKey(propKey) - ? decodeEdgePropKey(propKey) - : decodePropKey(propKey); - if (decodedKey !== CONTENT_PROPERTY_KEY || typeof register.value !== 'string') { - continue; - } - batch.add(register.value); - if (batch.size >= CONTENT_ANCHOR_BATCH_SIZE) { - flushBatch(); - } - } - - flushBatch(); - - const anchorEntries: string[] = []; - for (const oid of sortedOids) { - const objectType = await readObjectType(oid); - anchorEntries.push(contentAnchorEntry(oid, objectType)); - } - - return anchorEntries; -} - -function defaultContentAnchorObjectType(): Promise { - return Promise.resolve('tree'); -} - -function contentAnchorEntry(oid: string, objectType: ContentAnchorObjectType): string { - if (objectType === 'blob') { - return `100644 blob ${oid}\t_content_${oid}`; - } - if (objectType === 'tree') { - return `040000 tree ${oid}\t_content_${oid}`; - } - return unsupportedContentAnchorObjectType(oid, objectType); -} - -function unsupportedContentAnchorObjectType( - oid: string, - objectType: never, -): never { - throw new WarpError( - 'Unsupported content anchor object type', - 'E_CHECKPOINT_CONTENT_ANCHOR_OBJECT_TYPE', - { context: { oid, objectType } }, - ); -} diff --git a/src/domain/services/state/checkpointLoad.ts b/src/domain/services/state/checkpointLoad.ts index ae2f75dc..c825eedc 100644 --- a/src/domain/services/state/checkpointLoad.ts +++ b/src/domain/services/state/checkpointLoad.ts @@ -8,13 +8,6 @@ * @see WARP Spec Section 10 */ -import { - deserializeAppliedVV, - deserializeCheckpointStateEnvelope, - type CheckpointStateEnvelopeBuffers, -} from './CheckpointSerializer.ts'; -import { deserializeFrontier } from '../Frontier.ts'; -import { requireCommitMessageCodec } from '../codec/CommitMessageCodecRequirement.ts'; import ORSet from '../../crdt/ORSet.ts'; import { Dot } from '../../crdt/Dot.ts'; import VersionVector from '../../crdt/VersionVector.ts'; @@ -24,23 +17,10 @@ import { reducePatches } from '../JoinReducer.ts'; import WarpState from './WarpState.ts'; import { encodeEdgeKey, encodePropKey } from '../KeyCodec.ts'; import type { PropValue } from '../../types/PropValue.ts'; -import { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; -import PersistenceError from '../../errors/PersistenceError.ts'; -import { - CURRENT_CHECKPOINT_SCHEMA, - isCurrentCheckpointSchema, - partitionTreeOids, -} from './checkpointHelpers.ts'; -import type CommitPort from '../../../ports/CommitPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; +import type AssetHandle from '../../storage/AssetHandle.ts'; import type Patch from '../../types/Patch.ts'; - -/** Combined persistence surface needed for checkpoint loading. */ -export type LoadPersistence = CommitPort & BlobPort & TreePort; +import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; /** The result of loading a checkpoint. */ export interface LoadedCheckpoint { @@ -50,14 +30,7 @@ export interface LoadedCheckpoint { schema: number; appliedVV: VersionVector | null; provenanceIndex?: ProvenanceIndex; - indexShardOids: Record | null; -} - -/** Options for loadCheckpoint. */ -export interface LoadCheckpointOptions { - codec?: CodecPort; - checkpointStore?: CheckpointStorePort; - commitMessageCodec: CommitMessageCodecPort; + indexShardHandles: Readonly> | null; } /** @@ -74,172 +47,28 @@ export interface LoadCheckpointOptions { * @throws {PersistenceError} If checkpoint schema is unsupported */ export async function loadCheckpoint( - persistence: LoadPersistence, + checkpointStore: CheckpointStorePort, checkpointSha: string, - { codec, checkpointStore, commitMessageCodec }: LoadCheckpointOptions, + expectedGraphName?: string, ): Promise { - // 1. Read commit message and decode - const messageCodec = requireCommitMessageCodec(commitMessageCodec); - const message = await persistence.showNode(checkpointSha); - const decoded = messageCodec.decodeCheckpoint(message); - - // 2. Reject unsupported schemas; migration tooling owns retired readers. - if (!isCurrentCheckpointSchema(decoded.schema)) { - throw unsupportedCheckpointSchema(checkpointSha, decoded.schema); - } - - // Build codec option object once for exactOptionalPropertyTypes compliance - const loadCodecOpt = codec !== undefined && codec !== null ? { codec } : {}; - - // 3. Read tree entries via the indexOid from the message (points to the tree) - const rawTreeOids = await persistence.readTreeOids(decoded.indexOid); - - // 3b. Partition: entries with 'index/' prefix are bitmap index shards - const partitionedTree = partitionTreeOids(rawTreeOids); - const treeOids = await expandCheckpointStateSubtree(persistence, partitionedTree.treeOids); - const indexShardOids = await expandCheckpointIndexSubtree( - persistence, - treeOids, - partitionedTree.indexShardOids, - ); - - if (checkpointStore !== undefined && checkpointStore !== null) { - const checkpoint = await checkpointStore.readCheckpoint(treeOids); - const result: LoadedCheckpoint = { - state: checkpoint.state, - frontier: checkpoint.frontier, - stateHash: decoded.stateHash, - schema: decoded.schema, - appliedVV: checkpoint.appliedVV, - indexShardOids: Object.keys(indexShardOids).length > 0 - ? indexShardOids - : checkpoint.indexShardOids, - }; - if (checkpoint.provenanceIndex !== null && checkpoint.provenanceIndex !== undefined) { - result.provenanceIndex = checkpoint.provenanceIndex; - } - return result; - } - - // Current path: read each envelope blob individually. - const frontierOid = treeOids['frontier.cbor']; - if (frontierOid === undefined) { - throw new PersistenceError( - `Checkpoint ${checkpointSha} missing frontier.cbor in tree`, - 'E_CHECKPOINT_MISSING_FRONTIER', - { context: { checkpointSha } }, - ); - } - const frontierBuffer = await persistence.readBlob(frontierOid); - const frontier = deserializeFrontier(frontierBuffer, loadCodecOpt); - - const state = deserializeCheckpointStateEnvelope( - await readCheckpointStateEnvelope(persistence, checkpointSha, treeOids), - loadCodecOpt, - ); - - // Load appliedVV if present - let appliedVV: VersionVector | null = null; - const appliedVVOid = treeOids['appliedVV.cbor']; - if (appliedVVOid !== undefined) { - const appliedVVBuffer = await persistence.readBlob(appliedVVOid); - appliedVV = deserializeAppliedVV(appliedVVBuffer, loadCodecOpt); - } - - // Load provenanceIndex if present (HG/IO/2) - let provenanceIndex: ProvenanceIndex | null = null; - const provenanceIndexOid = treeOids['provenanceIndex.cbor']; - if (provenanceIndexOid !== undefined) { - const provenanceIndexBuffer = await persistence.readBlob(provenanceIndexOid); - provenanceIndex = ProvenanceIndex.deserialize(provenanceIndexBuffer, loadCodecOpt); - } - + const checkpoint = await checkpointStore.loadCheckpoint(checkpointSha, expectedGraphName); const result: LoadedCheckpoint = { - state, - frontier, - stateHash: decoded.stateHash, - schema: decoded.schema, - appliedVV, - indexShardOids: Object.keys(indexShardOids).length > 0 ? indexShardOids : null, + state: checkpoint.state, + frontier: checkpoint.frontier, + stateHash: checkpoint.stateHash, + schema: checkpoint.schema, + appliedVV: checkpoint.appliedVV, + indexShardHandles: checkpoint.indexShardHandles, }; - if (provenanceIndex !== null) { - result.provenanceIndex = provenanceIndex; + if (checkpoint.provenanceIndex !== null && checkpoint.provenanceIndex !== undefined) { + result.provenanceIndex = checkpoint.provenanceIndex; } return result; } -function unsupportedCheckpointSchema(checkpointSha: string, schema: number): PersistenceError { - return new PersistenceError( - `Checkpoint ${checkpointSha} is schema:${schema}. ` + - `Only schema:${CURRENT_CHECKPOINT_SCHEMA} checkpoints are supported by the shipped runtime. ` + - 'Run `npm run upgrade -- --graph ` before loading this graph.', - 'E_CHECKPOINT_UNSUPPORTED_SCHEMA', - { context: { checkpointSha, schema } }, - ); -} - -async function readCheckpointStateEnvelope( - persistence: LoadPersistence, - checkpointSha: string, - treeOids: Record, -): Promise { - return { - nodeAlive: await persistence.readBlob(requireCheckpointTreeOid(checkpointSha, treeOids, 'state/nodeAlive')), - edgeAlive: await persistence.readBlob(requireCheckpointTreeOid(checkpointSha, treeOids, 'state/edgeAlive')), - prop: await persistence.readBlob(requireCheckpointTreeOid(checkpointSha, treeOids, 'state/prop.cbor')), - observedFrontier: await persistence.readBlob(requireCheckpointTreeOid(checkpointSha, treeOids, 'state/observedFrontier.cbor')), - edgeBirthEvent: await persistence.readBlob(requireCheckpointTreeOid(checkpointSha, treeOids, 'state/edgeBirthEvent.cbor')), - }; -} - -async function expandCheckpointStateSubtree( - persistence: LoadPersistence, - treeOids: Record, -): Promise> { - if (treeOids['state/nodeAlive'] !== undefined || treeOids['state'] === undefined) { - return treeOids; - } - - const stateTreeOid = treeOids['state']; - const stateTreeOids = await persistence.readTreeOids(stateTreeOid); - const expanded = { ...treeOids }; - for (const [path, oid] of Object.entries(stateTreeOids)) { - expanded[`state/${path}`] = oid; - } - return expanded; -} - -async function expandCheckpointIndexSubtree( - persistence: LoadPersistence, - treeOids: Record, - indexShardOids: Record, -): Promise> { - if (Object.keys(indexShardOids).length > 0 || treeOids['index'] === undefined) { - return indexShardOids; - } - - return await persistence.readTreeOids(treeOids['index']); -} - -function requireCheckpointTreeOid( - checkpointSha: string, - treeOids: Record, - path: string, -): string { - const oid = treeOids[path]; - if (oid !== undefined) { - return oid; - } - throw new PersistenceError( - `Checkpoint ${checkpointSha} missing ${path} in tree`, - 'E_CHECKPOINT_MISSING_STATE', - { context: { checkpointSha, path } }, - ); -} - /** Options for materializeIncremental. */ export interface MaterializeIncrementalOptions { - persistence: LoadPersistence; + checkpointStore: CheckpointStorePort; graphName: string; checkpointSha: string; targetFrontier: Map; @@ -248,8 +77,6 @@ export interface MaterializeIncrementalOptions { fromSha: string | null, toSha: string, ) => Promise>; - codec?: CodecPort; - commitMessageCodec: CommitMessageCodecPort; } /** @@ -265,20 +92,13 @@ export interface MaterializeIncrementalOptions { * @throws {PersistenceError} If checkpoint is missing required envelope blobs */ export async function materializeIncremental({ - persistence, - graphName: _graphName, + checkpointStore, + graphName, checkpointSha, targetFrontier, patchLoader, - codec, - commitMessageCodec, }: MaterializeIncrementalOptions): Promise { - // 1. Load checkpoint state and frontier from the current envelope. - const loadOpts: LoadCheckpointOptions = { - ...(codec !== undefined && codec !== null ? { codec } : {}), - commitMessageCodec, - }; - const checkpoint = await loadCheckpoint(persistence, checkpointSha, loadOpts); + const checkpoint = await loadCheckpoint(checkpointStore, checkpointSha, graphName); const checkpointFrontier = checkpoint.frontier; // 2. Use checkpoint state directly. diff --git a/src/domain/services/strand/StrandCoordinator.ts b/src/domain/services/strand/StrandCoordinator.ts index 0e888a3d..da2cd9e8 100644 --- a/src/domain/services/strand/StrandCoordinator.ts +++ b/src/domain/services/strand/StrandCoordinator.ts @@ -226,22 +226,15 @@ export default class StrandCoordinator { } async get(strandId: string): Promise { - const ref = this._deps.descriptors.buildRef(strandId); - const oid = await this._deps.persistence.readRef(ref); - if (oid === null || oid === undefined) { + const descriptor = await this._deps.descriptors.readDescriptor(strandId); + if (descriptor === null) { return null; } - const descriptor = await this._deps.descriptors.readDescriptorByOid(oid, strandId); return await this._deps.descriptors.hydrateDescriptor(descriptor); } async list(): Promise { - const prefix = buildStrandsPrefix(this._deps.graphName); - const refs = await this._deps.persistence.listRefs(prefix); - const ids = refs - .map((ref) => ref.slice(prefix.length)) - .filter(Boolean) - .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const ids = await this._deps.descriptors.listStrandIds(); const descriptors: StrandDescriptor[] = []; for (const id of ids) { @@ -254,15 +247,13 @@ export default class StrandCoordinator { } async drop(strandId: string): Promise { - const ref = this._deps.descriptors.buildRef(strandId); const overlayRef = this._deps.descriptors.buildOverlayRef(strandId); const braidPrefix = this._deps.descriptors.buildBraidPrefix(strandId); - const oid = await this._deps.persistence.readRef(ref); + const hasDescriptor = await this._deps.descriptors.hasDescriptor(strandId); const overlayHeadSha = await this._deps.persistence.readRef(overlayRef); const braidRefs = await this._deps.persistence.listRefs(braidPrefix); - const hasOid = oid !== null && oid !== undefined; const hasOverlaySha = overlayHeadSha !== null && overlayHeadSha !== undefined; - if (!hasOid && !hasOverlaySha && braidRefs.length === 0) { + if (!hasDescriptor && !hasOverlaySha && braidRefs.length === 0) { return false; } for (const braidRef of braidRefs) { @@ -271,8 +262,8 @@ export default class StrandCoordinator { if (hasOverlaySha) { await this._deps.persistence.deleteRef(overlayRef); } - if (hasOid) { - await this._deps.persistence.deleteRef(ref); + if (hasDescriptor) { + await this._deps.descriptors.deleteDescriptor(strandId); } return true; } @@ -343,6 +334,13 @@ export default class StrandCoordinator { return await this._deps.patches.patch(strandId, build); } + patchWithEvidence( + strandId: string, + build: (p: PatchBuilder) => void | Promise, + ): ReturnType { + return this._deps.patches.patchWithEvidence(strandId, build); + } + async getPatchEntries(strandId: string, options: { ceiling?: number | null } = {}): Promise { const descriptor = await this.getOrThrow(strandId); const ceiling = normalizeLamportCeiling(options.ceiling); @@ -387,9 +385,7 @@ export default class StrandCoordinator { // ── Private ───────────────────────────────────────────────────── private async _assertStrandDoesNotExist(strandId: string): Promise { - const ref = this._deps.descriptors.buildRef(strandId); - const existing = await this._deps.persistence.readRef(ref); - if (existing !== null && existing !== undefined) { + if (await this._deps.descriptors.hasDescriptor(strandId)) { throw new StrandError(`Strand '${strandId}' already exists`, { code: 'E_STRAND_ALREADY_EXISTS', context: { graphName: this._deps.graphName, strandId }, diff --git a/src/domain/services/strand/StrandDescriptorStore.ts b/src/domain/services/strand/StrandDescriptorStore.ts index 0680bdbe..4697d211 100644 --- a/src/domain/services/strand/StrandDescriptorStore.ts +++ b/src/domain/services/strand/StrandDescriptorStore.ts @@ -17,6 +17,7 @@ import { } from '../../utils/RefLayout.ts'; import { parseStrandBlob, type StrandDescriptor as ParsedStrandDescriptor } from '../../utils/parseStrandBlob.ts'; import { textEncode } from '../../utils/bytes.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; import { isRawBag, normalizeReadOverlays, @@ -39,6 +40,7 @@ import { } from './descriptorNormalization.ts'; import type GraphPersistencePort from '../../../ports/GraphPersistencePort.ts'; +import type StrandStorePort from '../../../ports/StrandStorePort.ts'; import type Patch from '../../types/Patch.ts'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -63,6 +65,7 @@ type BaseObservation = ParsedStrandDescriptor['baseObservation']; type StrandDescriptorStoreGraph = { _graphName: string; _persistence: GraphPersistencePort; + _strandStore: StrandStorePort; _loadPatchChainFromSha: (sha: string) => Promise; }; @@ -150,15 +153,31 @@ export default class StrandDescriptorStore { // ── Descriptor I/O ─────────────────────────────────────────────────────── - async readDescriptorByOid(oid: string, strandId: string): Promise { - const buf = await this._graph._persistence.readBlob(oid); - if (buf === null || buf === undefined) { - throw new StrandError(`Strand '${strandId}' points to a missing blob`, { + async readDescriptor(strandId: string): Promise { + this.buildRef(strandId); + const buf = await this.readDescriptorBytes(strandId); + return buf === null ? null : this.parseDescriptor(strandId, buf); + } + + private async readDescriptorBytes(strandId: string): Promise { + try { + return await this._graph._strandStore.readDescriptor( + this._graph._graphName, + strandId, + ); + } catch (err) { + throw new StrandError(`Strand '${strandId}' descriptor could not be read`, { code: 'E_STRAND_MISSING_OBJECT', - context: { graphName: this._graph._graphName, strandId, oid }, + context: { + graphName: this._graph._graphName, + strandId, + cause: (err as Error).message, + }, }); } + } + private parseDescriptor(strandId: string, buf: Uint8Array): ParsedStrandDescriptor { try { const descriptor = parseStrandBlob(buf, `strand '${strandId}'`); if (descriptor.graphName !== this._graph._graphName) { @@ -173,7 +192,6 @@ export default class StrandDescriptorStore { context: { graphName: this._graph._graphName, strandId, - oid, cause: (err as Error).message, }, }); @@ -181,11 +199,29 @@ export default class StrandDescriptorStore { } async writeDescriptor(descriptor: StrandDescriptor): Promise { - const ref = this.buildRef(descriptor.strandId); - const oid = await this._graph._persistence.writeBlob( - textEncode(JSON.stringify(descriptor)), // nosemgrep: ts-no-json-stringify-in-core -- 0025B + const attachments = descriptor.intentQueue.intents.flatMap((intent) => + intent.contentAssetHandles.map((handle) => new AssetHandle(handle)) ); - await this._graph._persistence.updateRef(ref, oid); + await this._graph._strandStore.publishDescriptor({ + graphName: this._graph._graphName, + strandId: descriptor.strandId, + descriptor: textEncode(JSON.stringify(descriptor)), // nosemgrep: ts-no-json-stringify-in-core -- 0025B + attachments, + }); + } + + async listStrandIds(): Promise { + return await this._graph._strandStore.listStrandIds(this._graph._graphName); + } + + async hasDescriptor(strandId: string): Promise { + this.buildRef(strandId); + return await this._graph._strandStore.hasDescriptor(this._graph._graphName, strandId); + } + + async deleteDescriptor(strandId: string): Promise { + this.buildRef(strandId); + return await this._graph._strandStore.deleteDescriptor(this._graph._graphName, strandId); } // ── Overlay helpers ────────────────────────────────────────────────────── diff --git a/src/domain/services/strand/StrandIntentService.ts b/src/domain/services/strand/StrandIntentService.ts index 8619bc71..884de474 100644 --- a/src/domain/services/strand/StrandIntentService.ts +++ b/src/domain/services/strand/StrandIntentService.ts @@ -6,6 +6,7 @@ import { } from './strandShared.ts'; import type { PatchBuilder } from '../PatchBuilder.ts'; import type Patch from '../../types/Patch.ts'; +import type { PatchCommitResult } from '../../types/PatchCommitResult.ts'; import type { StrandDescriptor, StrandIntentQueue, @@ -47,9 +48,9 @@ type ServiceOptions = { overlayId: string; parentSha: string | null; patch: Patch; - contentBlobOids: string[]; + contentAssetHandles: string[]; lamport: number; - }) => Promise<{ sha: string; patch: Patch }>; + }) => Promise; collectPatchEntries: ( descriptor: StrandDescriptor, options: { ceiling: number | null }, @@ -196,7 +197,7 @@ export default class StrandIntentService { overlayId: descriptor.overlay.overlayId, parentSha: overlayHeadPatchSha, patch: intent.patch, - contentBlobOids: intent.contentBlobOids, + contentAssetHandles: intent.contentAssetHandles, lamport: maxLamport, }); overlayHeadPatchSha = committed.sha; @@ -268,7 +269,7 @@ export default class StrandIntentService { ...intent, reads: [...intent.reads], writes: [...intent.writes], - contentBlobOids: [...intent.contentBlobOids], + contentAssetHandles: [...intent.contentAssetHandles], })); } diff --git a/src/domain/services/strand/StrandPatchService.ts b/src/domain/services/strand/StrandPatchService.ts index 56ed5483..417fa92e 100644 --- a/src/domain/services/strand/StrandPatchService.ts +++ b/src/domain/services/strand/StrandPatchService.ts @@ -1,4 +1,6 @@ import StrandError from '../../errors/StrandError.ts'; +import PersistenceError from '../../errors/PersistenceError.ts'; +import AssetHandle from '../../storage/AssetHandle.ts'; import { PatchBuilder } from '../PatchBuilder.ts'; import { maxPatchLamport, @@ -7,26 +9,20 @@ import { import type Patch from '../../types/Patch.ts'; import type { TickReceipt } from '../../types/TickReceipt.ts'; import type { WarpState } from '../JoinReducer.ts'; -import type { HashablePayload } from '../../types/conflict/HashablePayload.ts'; import type { StrandDescriptor, StrandIntentQueue, StrandQueuedIntent } from './strandTypes.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; import type GraphPersistencePort from '../../../ports/GraphPersistencePort.ts'; import type VersionVector from '../../crdt/VersionVector.ts'; -import { - isGitCasPatchStorage, - LEGACY_EXTERNAL_PATCH_STORAGE, - LEGACY_GIT_BLOB_PATCH_STORAGE, - type PatchStorageRoute, - type default as CommitMessageCodecPort, -} from '../../../ports/CommitMessageCodecPort.ts'; +import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; +import type { PatchCommitResult } from '../../types/PatchCommitResult.ts'; export type CommittedPatchResult = { patch: Patch; sha: string }; export type PatchCommitSuccessHandler = (result: CommittedPatchResult) => Promise; -function readBuilderContentBlobs(builder: PatchBuilder): readonly string[] { - return builder.contentBlobs; +function readBuilderContentHandles(builder: PatchBuilder): readonly string[] { + return builder.contentAssets.map((handle) => handle.toString()); } /** @@ -58,11 +54,9 @@ type WarpRuntime = { _cachedFrontier: Map | null; _cachedState: WarpState | null; _patchJournal: PatchJournalPort | null | undefined; - _patchBlobStorage: BlobStoragePort | null | undefined; - _blobStorage: BlobStoragePort | null | undefined; + _assetStorage: AssetStoragePort | null | undefined; _commitMessageCodec: CommitMessageCodecPort; _logger: LoggerPort | null | undefined; - _codec: { encode(v: HashablePayload): Uint8Array }; _onDeleteWithData: 'reject' | 'cascade' | 'warn'; }; @@ -121,6 +115,13 @@ export default class StrandPatchService { * Build and commit a patch within a reentrancy guard. */ async patch(strandId: string, build: (p: PatchBuilder) => void | Promise): Promise { + return (await this.patchWithEvidence(strandId, build)).sha; + } + + async patchWithEvidence( + strandId: string, + build: (p: PatchBuilder) => void | Promise, + ): Promise { if (this._graph._patchInProgress) { throw new StrandError( 'graph.patchStrand() is not reentrant. Use createStrandPatch() for nested or concurrent patches.', @@ -131,7 +132,7 @@ export default class StrandPatchService { try { const builder = await this.createPatchBuilder(strandId); await build(builder); - return await builder.commit(); + return await builder.commitWithEvidence(); } finally { this._graph._patchInProgress = false; } @@ -190,39 +191,38 @@ export default class StrandPatchService { overlayId, parentSha, patch, - contentBlobOids, + contentAssetHandles, lamport, }: { strandId: string; overlayId: string; parentSha: string | null; patch: Patch; - contentBlobOids: string[]; + contentAssetHandles: string[]; lamport: number; - }): Promise<{ sha: string; patch: Patch }> { + }): Promise { const committedPatch: Patch = { ...patch, writer: overlayId, lamport, }; - const patchBlobOid = await this._writeCommittedPatchBlob(committedPatch, overlayId); - const patchStorage = this._resolveCommittedPatchStorage(); - const treeEntries = this._buildPatchTreeEntries(patchBlobOid, contentBlobOids, patchStorage); - const treeOid = await this._graph._persistence.writeTree(treeEntries); - const sha = await this._commitPatchTree({ - treeOid, - overlayId, - lamport, - patchBlobOid, - patchStorage, - schema: committedPatch.schema, - parentSha, - }); - await this._graph._persistence.updateRef(this._buildOverlayRef(strandId), sha); - return { - sha, + const journal = this._graph._patchJournal; + if (journal === null || journal === undefined) { + throw new PersistenceError( + 'patchJournal is required for committing queued strand patches', + 'E_MISSING_JOURNAL', + ); + } + const published = await journal.appendPatch({ patch: committedPatch, - }; + graph: this._graph._graphName, + writer: overlayId, + targetRef: this._buildOverlayRef(strandId), + expectedHead: parentSha, + parent: parentSha, + attachments: contentAssetHandles.map((handle) => new AssetHandle(handle)), + }); + return Object.freeze({ patch: committedPatch, ...published }); } private async _createPatchBuilderForDescriptor(descriptor: StrandDescriptor): Promise { @@ -307,79 +307,7 @@ export default class StrandPatchService { patch, reads: normalizeStringArray(patch.reads, 'reads[]'), writes: normalizeStringArray(patch.writes, 'writes[]'), - contentBlobOids: normalizeStringArray(readBuilderContentBlobs(builder), 'contentBlobOids[]'), - }); - } - - private async _writeCommittedPatchBlob(committedPatch: Patch, overlayId: string): Promise { - const journal = this._graph._patchJournal; - if (journal !== undefined && journal !== null) { - return await journal.writePatch(committedPatch); - } - const patchCbor = this._graph._codec.encode(committedPatch); - return this._graph._patchBlobStorage - ? await this._graph._patchBlobStorage.store(patchCbor, { - slug: `${this._graph._graphName}/${overlayId}/patch`, - }) - : await this._graph._persistence.writeBlob(patchCbor); - } - - private _resolveCommittedPatchStorage(): PatchStorageRoute { - const journal = this._graph._patchJournal; - if (journal !== undefined && journal !== null) { - return journal.writeStorage ?? LEGACY_GIT_BLOB_PATCH_STORAGE; - } - return this._graph._patchBlobStorage - ? LEGACY_EXTERNAL_PATCH_STORAGE - : LEGACY_GIT_BLOB_PATCH_STORAGE; - } - - private _buildPatchTreeEntries( - patchBlobOid: string, - contentBlobOids: string[], - patchStorage: PatchStorageRoute, - ): string[] { - const treeEntries = [isGitCasPatchStorage(patchStorage) - ? `040000 tree ${patchBlobOid}\tpatch` - : `100644 blob ${patchBlobOid}\tpatch.cbor`]; - const uniqueBlobOids = [...new Set(contentBlobOids)]; - for (const blobOid of uniqueBlobOids) { - treeEntries.push(`040000 tree ${blobOid}\t_content_${blobOid}`); - } - return treeEntries; - } - - private async _commitPatchTree({ - treeOid, - overlayId, - lamport, - patchBlobOid, - patchStorage, - schema, - parentSha, - }: { - treeOid: string; - overlayId: string; - lamport: number; - patchBlobOid: string; - patchStorage: PatchStorageRoute; - schema: number; - parentSha: string | null; - }): Promise { - const commitMessage = this._graph._commitMessageCodec.encodePatch({ - kind: 'patch', - graph: this._graph._graphName, - writer: overlayId, - lamport, - patchOid: patchBlobOid, - schema, - storage: patchStorage, - }); - const parents = parentSha !== null ? [parentSha] : []; - return await this._graph._persistence.commitNodeWithTree({ - treeOid, - parents, - message: commitMessage, + contentAssetHandles: normalizeStringArray(readBuilderContentHandles(builder), 'contentAssetHandles[]'), }); } @@ -387,7 +315,7 @@ export default class StrandPatchService { this._attachOptionalPatchJournal(pbOpts); this._attachOptionalCommitMessageCodec(pbOpts); this._attachOptionalLogger(pbOpts); - this._attachOptionalBlobStorage(pbOpts); + this._attachOptionalAssetStorage(pbOpts); } private _buildOverlayPatchBuilderOptions( @@ -428,9 +356,9 @@ export default class StrandPatchService { } } - private _attachOptionalBlobStorage(pbOpts: MutablePatchBuilderCtorOptions): void { - if (this._graph._blobStorage !== null && this._graph._blobStorage !== undefined) { - pbOpts.blobStorage = this._graph._blobStorage; + private _attachOptionalAssetStorage(pbOpts: MutablePatchBuilderCtorOptions): void { + if (this._graph._assetStorage !== null && this._graph._assetStorage !== undefined) { + pbOpts.assetStorage = this._graph._assetStorage; } } } diff --git a/src/domain/services/strand/createStrandCoordinator.ts b/src/domain/services/strand/createStrandCoordinator.ts index 06f4d7a0..49167223 100644 --- a/src/domain/services/strand/createStrandCoordinator.ts +++ b/src/domain/services/strand/createStrandCoordinator.ts @@ -17,8 +17,9 @@ import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; import type { WarpState } from '../JoinReducer.ts'; import type Patch from '../../types/Patch.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; +import type StrandStorePort from '../../../ports/StrandStorePort.ts'; import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; import type GraphPersistencePort from '../../../ports/GraphPersistencePort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; @@ -75,8 +76,8 @@ export type StrandCoordinatorGraphRuntime = { _cachedViewHash: string | null; _cachedState: WarpState | null; _patchJournal: PatchJournalPort | null | undefined; - _patchBlobStorage: BlobStoragePort | null | undefined; - _blobStorage: BlobStoragePort | null | undefined; + _strandStore: StrandStorePort; + _assetStorage: AssetStoragePort | null | undefined; _commitMessageCodec: CommitMessageCodecPort; _logger: LoggerPort | null | undefined; _codec: { encode(v: HashablePayload): Uint8Array }; diff --git a/src/domain/services/strand/descriptorNormalization.ts b/src/domain/services/strand/descriptorNormalization.ts index 92cb24aa..d3688417 100644 --- a/src/domain/services/strand/descriptorNormalization.ts +++ b/src/domain/services/strand/descriptorNormalization.ts @@ -243,7 +243,7 @@ export type StrandQueuedIntent = { patch: Patch; reads: string[]; writes: string[]; - contentBlobOids: string[]; + contentAssetHandles: string[]; }; export type StrandIntentQueue = { @@ -366,12 +366,24 @@ function buildQueuedIntentFromIdentity( intentId, enqueuedAt, patch, - reads: normalizeStringArray(candidate['reads'] ?? patchReads ?? null, 'reads[]'), - writes: normalizeStringArray(candidate['writes'] ?? patchWrites ?? null, 'writes[]'), - contentBlobOids: normalizeStringArray(candidate['contentBlobOids'], 'contentBlobOids[]'), + reads: normalizeStringArray(queuedIntentReads(candidate, patchReads), 'reads[]'), + writes: normalizeStringArray(queuedIntentWrites(candidate, patchWrites), 'writes[]'), + contentAssetHandles: normalizeStringArray(queuedIntentContentHandles(candidate), 'contentAssetHandles[]'), }; } +function queuedIntentReads(candidate: RawBag, fallback: readonly string[] | null): RawValue { + return candidate['reads'] ?? fallback; +} + +function queuedIntentWrites(candidate: RawBag, fallback: readonly string[] | null): RawValue { + return candidate['writes'] ?? fallback; +} + +function queuedIntentContentHandles(candidate: RawBag): RawValue { + return candidate['contentAssetHandles'] ?? candidate['contentBlobOids']; +} + function readsFromPatch(patch: Patch): readonly string[] | null { return Array.isArray(patch.reads) ? patch.reads : null; } diff --git a/src/domain/services/strand/strandTypes.ts b/src/domain/services/strand/strandTypes.ts index f8ed825e..94e7acd7 100644 --- a/src/domain/services/strand/strandTypes.ts +++ b/src/domain/services/strand/strandTypes.ts @@ -19,7 +19,7 @@ export type StrandQueuedIntent = { patch: Patch; reads: string[]; writes: string[]; - contentBlobOids: string[]; + contentAssetHandles: string[]; }; export type StrandRejectedCounterfactual = { diff --git a/src/domain/services/sync/syncPatchLoader.ts b/src/domain/services/sync/syncPatchLoader.ts index 2747f55d..c56c1dd3 100644 --- a/src/domain/services/sync/syncPatchLoader.ts +++ b/src/domain/services/sync/syncPatchLoader.ts @@ -15,7 +15,6 @@ import VersionVector from '../../crdt/VersionVector.ts'; import Patch from '../../types/Patch.ts'; import type { PatchOp } from '../../types/ops/unions.ts'; import type CommitPort from '../../../ports/CommitPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; @@ -77,21 +76,21 @@ export function normalizePatch(patch: DecodedPatch): DecodedPatch { /** * Loads a patch from a commit. * - * WARP stores patches as Git blobs, with the blob OID embedded in the - * commit message. This function: + * WARP stores patch assets behind opaque handles carried by the commit + * message. This function: * 1. Reads the commit message via `showNode()` - * 2. Decodes the message to extract the patch blob OID - * 3. Reads the blob and CBOR-decodes it via `patchJournal.readPatch()` + * 2. Decodes the message to extract the patch asset handle + * 3. Opens and CBOR-decodes the asset via `patchJournal.readPatch()` * 4. Normalizes the patch (converts context to VersionVector) * - * @param persistence - Git persistence layer (CommitPort + BlobPort) + * @param persistence - Causal commit history port * @param sha - The 40-character commit SHA to load the patch from * @param options - Options including optional patchJournal * @throws {PersistenceError} If patchJournal is not provided - * @throws {Error} If the commit or patch blob cannot be read or decoded + * @throws {Error} If the commit or patch asset cannot be read or decoded */ export async function loadPatchFromCommit( - persistence: CommitPort & BlobPort, + persistence: CommitPort, sha: string, { patchJournal, commitMessageCodec }: LoadPatchRangeOptions = {}, ): Promise { @@ -103,13 +102,13 @@ export async function loadPatchFromCommit( ); } - // Read commit message to extract patch OID and encrypted flag + // Read commit metadata to locate the patch asset. const messageCodec = requireCommitMessageCodec(commitMessageCodec); const message = await persistence.showNode(sha); const decoded = messageCodec.decodePatch(message); // Read and decode the patch blob via PatchJournalPort (adapter owns the codec) - const patch = await patchJournal.readPatch(decoded.patchOid, { storage: decoded.storage }); + const patch = await patchJournal.readPatch(decoded); return normalizePatch(patch); } @@ -147,7 +146,7 @@ export async function loadPatchFromCommit( * const patches = await loadPatchRange(persistence, 'events', 'new-writer', null, tipSha); */ export async function loadPatchRange( - persistence: CommitPort & BlobPort, + persistence: CommitPort, _graphName: string, writerId: string, fromSha: string | null, diff --git a/src/domain/services/sync/syncRequestResponse.ts b/src/domain/services/sync/syncRequestResponse.ts index 2304f949..76109117 100644 --- a/src/domain/services/sync/syncRequestResponse.ts +++ b/src/domain/services/sync/syncRequestResponse.ts @@ -30,7 +30,6 @@ import { } 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'; @@ -202,7 +201,7 @@ export function createSyncRequest( export async function processSyncRequest( request: SyncRequest, localFrontier: Map, - persistence: CommitPort & BlobPort, + persistence: CommitPort, graphName: string, { patchJournal, logger, observedLatencyMs }: ProcessSyncRequestOptions = {}, ): Promise { @@ -233,7 +232,7 @@ export async function processSyncRequest( // Pre-check ancestry to avoid expensive chain walk (B107 / S3 fix). // If the persistence layer provides isAncestor, use it to detect // divergence early without walking the full commit chain. - const persistenceWithIsAncestor = persistence as CommitPort & BlobPort & { isAncestor?: (a: string, b: string) => Promise }; + const persistenceWithIsAncestor = persistence as CommitPort & { isAncestor?: (a: string, b: string) => Promise }; const hasIsAncestor = typeof persistenceWithIsAncestor.isAncestor === 'function'; if (range.from !== null && range.from !== undefined && range.from.length > 0 && hasIsAncestor) { const isAnc = await persistenceWithIsAncestor.isAncestor!(range.from, range.to); diff --git a/src/domain/services/transfer/transferOps.ts b/src/domain/services/transfer/transferOps.ts index 301aa0f8..49866c99 100644 --- a/src/domain/services/transfer/transferOps.ts +++ b/src/domain/services/transfer/transferOps.ts @@ -158,7 +158,7 @@ export function buildNodeAttach( op: TRANSFER_OP_ATTACH_NODE_CONTENT, nodeId, content, - contentOid: meta.oid, + contentHandle: meta.handle, mime: meta.mime, size: meta.size, } as VisibleStateTransferOperation; @@ -239,7 +239,7 @@ export function buildEdgeAttach( to: edge.to, label: edge.label, content, - contentOid: meta.oid, + contentHandle: meta.handle, mime: meta.mime, size: meta.size, } as VisibleStateTransferOperation; diff --git a/src/domain/storage/AssetHandle.ts b/src/domain/storage/AssetHandle.ts new file mode 100644 index 00000000..4ff17a96 --- /dev/null +++ b/src/domain/storage/AssetHandle.ts @@ -0,0 +1,4 @@ +import StorageHandle from './StorageHandle.ts'; + +/** Opaque locator for one immutable byte asset managed by configured storage. */ +export default class AssetHandle extends StorageHandle {} diff --git a/src/domain/storage/BundleHandle.ts b/src/domain/storage/BundleHandle.ts new file mode 100644 index 00000000..fed1f3af --- /dev/null +++ b/src/domain/storage/BundleHandle.ts @@ -0,0 +1,4 @@ +import StorageHandle from './StorageHandle.ts'; + +/** Opaque locator for one immutable ordered bundle managed by configured storage. */ +export default class BundleHandle extends StorageHandle {} diff --git a/src/domain/storage/StorageHandle.ts b/src/domain/storage/StorageHandle.ts new file mode 100644 index 00000000..3c630129 --- /dev/null +++ b/src/domain/storage/StorageHandle.ts @@ -0,0 +1,42 @@ +import WarpError from '../errors/WarpError.ts'; + +const MAX_HANDLE_LENGTH = 4096; + +/** Opaque locator for immutable application storage managed outside the domain. */ +export default class StorageHandle { + readonly #token: string; + + constructor(token: string) { + requireHandleToken(token); + this.#token = token; + Object.freeze(this); + } + + toString(): string { + return this.#token; + } + + equals(other: StorageHandle | null | undefined): boolean { + return other instanceof StorageHandle && other.#token === this.#token; + } +} + +function requireHandleToken(token: string): void { + if (typeof token !== 'string' || token.length === 0) { + throw new WarpError('StorageHandle must contain a non-empty token', 'E_STORAGE_HANDLE'); + } + requireBoundedToken(token); + requireSingleLineToken(token); +} + +function requireBoundedToken(token: string): void { + if (token.length > MAX_HANDLE_LENGTH) { + throw new WarpError('StorageHandle token exceeds the maximum length', 'E_STORAGE_HANDLE'); + } +} + +function requireSingleLineToken(token: string): void { + if (token.includes('\0') || /[\r\n]/u.test(token)) { + throw new WarpError('StorageHandle token has an invalid shape', 'E_STORAGE_HANDLE'); + } +} diff --git a/src/domain/storage/StorageRetentionWitness.ts b/src/domain/storage/StorageRetentionWitness.ts new file mode 100644 index 00000000..73935aea --- /dev/null +++ b/src/domain/storage/StorageRetentionWitness.ts @@ -0,0 +1,131 @@ +import WarpError from '../errors/WarpError.ts'; +import StorageHandle from './StorageHandle.ts'; + +const CANONICAL_ISO_TIMESTAMP = /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/u; + +export type StorageRetentionPolicy = 'pinned' | 'evictable'; +export type StorageReachability = 'anchored' | 'orphaned' | 'volatile'; +export type StorageRetentionRootKind = + | 'root-set' + | 'publication' + | 'cache-set' + | 'expiring-set'; + +export type StorageRetentionRootOptions = Readonly<{ + kind: StorageRetentionRootKind; + namespace: string; + locator: string; + generation: string; + path: string; +}>; + +/** Runtime-backed identity of the root retaining one storage handle. */ +export class StorageRetentionRoot { + readonly kind: StorageRetentionRootKind; + readonly namespace: string; + /** Opaque provider-owned locator for the retaining root. */ + readonly locator: string; + readonly generation: string; + readonly path: string; + + constructor(options: StorageRetentionRootOptions) { + requireOptions(options); + requireRootKind(options.kind); + requireString(options.namespace, 'root.namespace'); + requireString(options.locator, 'root.locator'); + requireString(options.generation, 'root.generation'); + requireString(options.path, 'root.path'); + this.kind = options.kind; + this.namespace = options.namespace; + this.locator = options.locator; + this.generation = options.generation; + this.path = options.path; + Object.freeze(this); + } +} + +/** Immutable domain evidence that a storage handle was retained by a concrete root. */ +export default class StorageRetentionWitness { + readonly handle: StorageHandle; + readonly policy: StorageRetentionPolicy; + readonly reachability: StorageReachability; + readonly root: StorageRetentionRoot; + readonly observedAt: string; + + constructor(options: { + readonly handle: StorageHandle; + readonly policy: StorageRetentionPolicy; + readonly reachability: StorageReachability; + readonly root: StorageRetentionRoot; + readonly observedAt: string; + }) { + requireOptions(options); + requireHandle(options.handle); + requirePolicy(options.policy); + requireReachability(options.reachability); + requireRoot(options.root); + requireTimestamp(options.observedAt); + this.handle = options.handle; + this.policy = options.policy; + this.reachability = options.reachability; + this.root = options.root; + this.observedAt = options.observedAt; + Object.freeze(this); + } +} + +function requireOptions(options: object): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw retentionError('options must be an object'); + } +} + +function requireHandle(handle: StorageHandle): void { + if (!(handle instanceof StorageHandle)) { + throw retentionError('handle must be a StorageHandle'); + } +} + +function requirePolicy(policy: StorageRetentionPolicy): void { + if (policy !== 'pinned' && policy !== 'evictable') { + throw retentionError('policy is invalid'); + } +} + +function requireReachability(reachability: StorageReachability): void { + if (reachability !== 'anchored' && reachability !== 'orphaned' && reachability !== 'volatile') { + throw retentionError('reachability is invalid'); + } +} + +function requireRoot(root: StorageRetentionRoot): void { + if (!(root instanceof StorageRetentionRoot)) { + throw retentionError('root must be a StorageRetentionRoot'); + } +} + +function requireRootKind(kind: StorageRetentionRootKind): void { + if (kind !== 'root-set' && kind !== 'publication' && kind !== 'cache-set' && kind !== 'expiring-set') { + throw retentionError('root.kind is invalid'); + } +} + +function requireTimestamp(value: string): void { + requireString(value, 'observedAt'); + if (!CANONICAL_ISO_TIMESTAMP.test(value)) { + throw retentionError('observedAt must be a canonical ISO timestamp'); + } +} + +function requireString(value: string, field: string): void { + if (typeof value !== 'string' || value.length === 0) { + throw retentionError(`${field} must be non-empty`); + } +} + +function retentionError(message: string): WarpError { + return new WarpError( + `Storage retention witness ${message}`, + 'E_STORAGE_RETENTION_WITNESS', + ); +} diff --git a/src/domain/stream/Sink.ts b/src/domain/stream/Sink.ts index 0319cf70..10d90247 100644 --- a/src/domain/stream/Sink.ts +++ b/src/domain/stream/Sink.ts @@ -8,7 +8,7 @@ import WarpError from '../errors/WarpError.ts'; * via `_finalize()`. Sinks do not yield values — they end the pipeline. * * Examples: TreeAssemblerSink accumulates [path, oid] entries and - * calls writeTree() in finalize(). ArraySink collects all items. + * publishes its aggregate in finalize(). ArraySink collects all items. */ export default class Sink { /** diff --git a/src/domain/tree/TreeEntryProbeResult.ts b/src/domain/tree/TreeEntryProbeResult.ts new file mode 100644 index 00000000..51f7ad8d --- /dev/null +++ b/src/domain/tree/TreeEntryProbeResult.ts @@ -0,0 +1,5 @@ +import type TreeEntryFound from './TreeEntryFound.ts'; +import type TreeEntryMissing from './TreeEntryMissing.ts'; + +/** Result of probing one exact tree entry through the history boundary. */ +export type TreeEntryProbeResult = TreeEntryFound | TreeEntryMissing; diff --git a/src/domain/trust/TrustRecordService.ts b/src/domain/trust/TrustRecordService.ts index cf8484e3..fcf885c1 100644 --- a/src/domain/trust/TrustRecordService.ts +++ b/src/domain/trust/TrustRecordService.ts @@ -10,6 +10,7 @@ */ import type TrustChainPort from '../../ports/TrustChainPort.ts'; +import type { TrustRecordPublication } from '../../ports/TrustChainPort.ts'; import type { TrustRecord } from './TrustRecord.ts'; import TrustError from '../errors/TrustError.ts'; @@ -19,9 +20,14 @@ type AppendOptions = { readonly skipSignatureVerify?: boolean; }; +export type TrustRetryTip = Readonly<{ + tipSha: string | null; + recordId: string | null; +}>; + type RetryOptions = { readonly maxRetries?: number; - readonly resign?: ((record: TrustRecord) => Promise) | null; + readonly resign?: ((record: TrustRecord, tip: TrustRetryTip) => Promise) | null; readonly skipSignatureVerify?: boolean; }; @@ -48,7 +54,7 @@ class TrustRecordService { graphName: string, record: TrustRecord, options: AppendOptions = {}, - ): Promise<{ commitSha: string }> { + ): Promise { // 1. Signature envelope check (structural, not cryptographic) if (options.skipSignatureVerify !== true) { verifySignatureEnvelope(record); @@ -67,8 +73,7 @@ class TrustRecordService { // 3. Persist via port const parentTipSha = tip?.tipSha ?? null; - const commitSha = await this._chain.persistRecord(graphName, record, parentTipSha); - return { commitSha }; + return await this._chain.persistRecord(graphName, record, parentTipSha); } /** @@ -81,7 +86,7 @@ class TrustRecordService { graphName: string, record: TrustRecord, options: RetryOptions = {}, - ): Promise<{ commitSha: string; attempts: number }> { + ): Promise { const { maxRetries = 3, resign = null, skipSignatureVerify = false } = options; let currentRecord = record; let attempts = 0; @@ -92,7 +97,10 @@ class TrustRecordService { const result = await this.appendRecord(graphName, currentRecord, { skipSignatureVerify }); return { ...result, attempts }; } catch (err) { - if (!(err instanceof TrustError) || err.code !== 'E_TRUST_CAS_CONFLICT') { + if (!(err instanceof TrustError)) { + throw err; + } + if (!isRetryableConflict(err, resign)) { throw err; } @@ -103,9 +111,16 @@ class TrustRecordService { ); } - // Re-sign if signer is provided - if (resign) { - currentRecord = await resign(currentRecord); + const freshTip = await this._chain.readTip(graphName); + const retryTip = Object.freeze({ + tipSha: freshTip?.tipSha ?? null, + recordId: freshTip?.recordId ?? null, + }); + if (resign !== null) { + currentRecord = await resign(currentRecord, retryTip); + requireRetryPrev(currentRecord, retryTip.recordId); + } else if (currentRecord.prev !== retryTip.recordId) { + throw prevMismatch(currentRecord.prev, retryTip.recordId); } } } @@ -132,6 +147,27 @@ class TrustRecordService { // -- Helpers ------------------------------------------------------------------ +function isRetryableConflict( + error: TrustError, + resign: RetryOptions['resign'], +): boolean { + return error.code === 'E_TRUST_CAS_CONFLICT' + || (error.code === 'E_TRUST_PREV_MISMATCH' && resign !== null); +} + +function requireRetryPrev(record: TrustRecord, expectedPrev: string | null): void { + if (record.prev !== expectedPrev) { + throw prevMismatch(record.prev, expectedPrev); + } +} + +function prevMismatch(actual: string | null, expected: string | null): TrustError { + return new TrustError( + `Prev-link mismatch: record.prev=${String(actual)}, chain tip=${String(expected)}`, + { code: 'E_TRUST_PREV_MISMATCH' }, + ); +} + function verifySignatureEnvelope(record: TrustRecord): void { if (record.signature.alg !== 'ed25519') { throw new TrustError( diff --git a/src/domain/types/ContentMeta.ts b/src/domain/types/ContentMeta.ts index 552a4cec..ee11b368 100644 --- a/src/domain/types/ContentMeta.ts +++ b/src/domain/types/ContentMeta.ts @@ -2,7 +2,7 @@ * Metadata for content attached to a node or edge. */ export type ContentMeta = { - oid: string; + handle: string; mime: string | null; size: number | null; }; diff --git a/src/domain/types/CoordinateComparison.ts b/src/domain/types/CoordinateComparison.ts index a4f4fa65..5a84cf09 100644 --- a/src/domain/types/CoordinateComparison.ts +++ b/src/domain/types/CoordinateComparison.ts @@ -171,9 +171,9 @@ export type VisibleStateTransferOperation = | { op: 'add_edge'; from: string; to: string; label: string } | { op: 'remove_edge'; from: string; to: string; label: string } | { op: 'set_edge_property'; from: string; to: string; label: string; key: string; value: unknown } // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - | { op: 'attach_node_content'; nodeId: string; content: Uint8Array; contentOid: string; mime?: string | null; size?: number | null } + | { op: 'attach_node_content'; nodeId: string; content: Uint8Array; contentHandle: string; mime?: string | null; size?: number | null } | { op: 'clear_node_content'; nodeId: string } - | { op: 'attach_edge_content'; from: string; to: string; label: string; content: Uint8Array; contentOid: string; mime?: string | null; size?: number | null } + | { op: 'attach_edge_content'; from: string; to: string; label: string; content: Uint8Array; contentHandle: string; mime?: string | null; size?: number | null } | { op: 'clear_edge_content'; from: string; to: string; label: string }; export type CoordinateTransferPlanSide = CoordinateComparisonSide; diff --git a/src/domain/types/PatchCommitResult.ts b/src/domain/types/PatchCommitResult.ts new file mode 100644 index 00000000..e1eee667 --- /dev/null +++ b/src/domain/types/PatchCommitResult.ts @@ -0,0 +1,5 @@ +import type { PublishedPatch } from '../../ports/PatchJournalPort.ts'; +import type Patch from './Patch.ts'; + +/** Evidence-bearing result of one causally published patch. */ +export type PatchCommitResult = PublishedPatch & Readonly<{ patch: Patch }>; diff --git a/src/domain/types/StrandDescriptor.ts b/src/domain/types/StrandDescriptor.ts index 602cddcd..8ce21fad 100644 --- a/src/domain/types/StrandDescriptor.ts +++ b/src/domain/types/StrandDescriptor.ts @@ -21,7 +21,7 @@ export type StrandIntentDescriptor = { patch: Patch; reads: string[]; writes: string[]; - contentBlobOids: string[]; + contentAssetHandles: string[]; }; export type StrandTickCounterfactual = { diff --git a/src/domain/types/WarpIntentDescriptor.ts b/src/domain/types/WarpIntentDescriptor.ts index 105cb38f..c136f894 100644 --- a/src/domain/types/WarpIntentDescriptor.ts +++ b/src/domain/types/WarpIntentDescriptor.ts @@ -2,17 +2,30 @@ * Unmaterialized intent descriptor and admission outcome types. */ -export type PrecommitGuard = { - readonly op: 'nodeStatus' | 'nodeUnassignedOrSelf' | 'edgeExists'; +import type StorageRetentionWitness from '../storage/StorageRetentionWitness.ts'; +import type CodecValue from './codec/CodecValue.ts'; + +type PrecommitGuardBase = { readonly nodeId: string; - readonly expected?: string; - readonly agentId?: string; readonly failureTag: string; }; +export type PrecommitGuard = + | (PrecommitGuardBase & { + readonly op: 'nodeStatus'; + readonly expected: string; + }) + | (PrecommitGuardBase & { + readonly op: 'nodeUnassignedOrSelf'; + readonly agentId: string; + }) + | (PrecommitGuardBase & { + readonly op: 'edgeExists'; + }); + export type SuffixTransform = { readonly op: string; - readonly payload: Record; + readonly payload: Readonly<{ readonly [key: string]: CodecValue }>; }; export type IntentNutritionLabel = { @@ -33,6 +46,7 @@ export type WarpIntentOutcome = { readonly admitted: true; readonly sha: string; readonly intentId: string; + readonly retention: StorageRetentionWitness; } | { readonly admitted: false; readonly obstruction: { diff --git a/src/domain/types/WarpPersistence.ts b/src/domain/types/WarpPersistence.ts index e41e9e26..7a9f962a 100644 --- a/src/domain/types/WarpPersistence.ts +++ b/src/domain/types/WarpPersistence.ts @@ -4,30 +4,15 @@ * Instead of casting to `any` when accessing persistence methods, * use these narrow types to document which port methods are actually needed. * - * NOTE: CommitPort, BlobPort, TreePort, and RefPort each contain both - * read and write methods. True read/write separation would require - * splitting each port, which is deferred. For now, the role-named - * aliases below are identical — they exist to document *intent* at - * each call site, not to enforce access restrictions. + * Payload and cache storage is intentionally absent. Domain services use + * AssetStoragePort, CheckpointStorePort, and IndexStorePort for those roles. * * @module domain/types/WarpPersistence */ -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 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). + * Standard WARP causal history surface — commit and ref operations only. */ export type CorePersistence = WarpKernelPort; - -/** - * Index storage — blob reads/writes, tree reads/writes, ref reads/writes. - * Matches the dynamically-composed IndexStoragePort interface. - */ -export type IndexStorage = BlobPort & TreePort & RefPort; diff --git a/src/domain/utils/RefLayout.ts b/src/domain/utils/RefLayout.ts index ad0ef486..3b10d41a 100644 --- a/src/domain/utils/RefLayout.ts +++ b/src/domain/utils/RefLayout.ts @@ -55,6 +55,7 @@ export const RESERVED_GRAPH_NAME_SEGMENTS: Set = new Set([ 'strand-braids', 'audit', 'trust', + 'intents', 'seek-cache', 'state-cache', ]); @@ -310,6 +311,17 @@ export function buildStrandsPrefix(graphName: string): string { return `${REF_PREFIX}/${graphName}/strands/`; } +/** Builds an admitted or queued intent-journal ref. */ +export function buildIntentRef( + graphName: string, + channel: 'admitted' | 'queued', + ownerId: string, +): string { + validateGraphName(graphName); + validateWriterId(ownerId); + return `${REF_PREFIX}/${graphName}/intents/${channel}/${ownerId}`; +} + /** * Builds a strand overlay ref path for the given graph and id. * diff --git a/src/domain/utils/parseStrandBlob.ts b/src/domain/utils/parseStrandBlob.ts index d707ac3d..481dcfc1 100644 --- a/src/domain/utils/parseStrandBlob.ts +++ b/src/domain/utils/parseStrandBlob.ts @@ -198,6 +198,9 @@ function validateIntentEntry(intent: Record, label: string): vo if (intent['writes'] !== undefined) { validateStringArray(intent['writes'], `Corrupted ${label}: intentQueue.intents[].writes must be a string array when provided`); } + if (intent['contentAssetHandles'] !== undefined) { + validateStringArray(intent['contentAssetHandles'], `Corrupted ${label}: intentQueue.intents[].contentAssetHandles must be a string array when provided`); + } if (intent['contentBlobOids'] !== undefined) { validateStringArray(intent['contentBlobOids'], `Corrupted ${label}: intentQueue.intents[].contentBlobOids must be a string array when provided`); } diff --git a/src/domain/utils/streamUtils.ts b/src/domain/utils/streamUtils.ts index 12ca7852..de873115 100644 --- a/src/domain/utils/streamUtils.ts +++ b/src/domain/utils/streamUtils.ts @@ -9,19 +9,26 @@ const _encoder = new TextEncoder(); +type StreamInput = AsyncIterable + | ReadableStream + | Uint8Array + | string; +type StreamCandidate = StreamInput | object; + /** * Returns true when the value is an async iterable (has Symbol.asyncIterator). */ -function isAsyncIterable(value: unknown): value is AsyncIterable { +function isAsyncIterable(value: StreamCandidate): value is AsyncIterable { return value !== null && typeof value === 'object' - && Symbol.asyncIterator in value; + && Symbol.asyncIterator in value + && typeof value[Symbol.asyncIterator] === 'function'; } /** * Returns true when the value is a ReadableStream (Web Streams API). */ -function isReadableStream(value: unknown): value is ReadableStream { +function isReadableStream(value: StreamCandidate): value is ReadableStream { return typeof ReadableStream !== 'undefined' && value instanceof ReadableStream; } @@ -30,7 +37,9 @@ function isReadableStream(value: unknown): value is ReadableStream { * Returns true when the content is a streaming input type * (AsyncIterable or ReadableStream) rather than a buffered value. */ -export function isStreamingInput(content: unknown): boolean { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B +export function isStreamingInput( + content: StreamCandidate, +): content is AsyncIterable | ReadableStream { // Buffered types are never streaming, even if a polyfill adds Symbol.asyncIterator if (content instanceof Uint8Array || typeof content === 'string') { return false; @@ -47,7 +56,7 @@ export function isStreamingInput(content: unknown): boolean { // nosemgrep: ts-n * - `Uint8Array` — wrapped as single-element async iterable * - `string` — encoded to UTF-8, wrapped as single-element async iterable */ -export function normalizeToAsyncIterable(content: AsyncIterable | ReadableStream | Uint8Array | string): AsyncIterable { +export function normalizeToAsyncIterable(content: StreamInput): AsyncIterable { if (isAsyncIterable(content)) { return content; } diff --git a/src/domain/warp/RuntimeHostBoot.ts b/src/domain/warp/RuntimeHostBoot.ts index f89d5d50..1b2ffc89 100644 --- a/src/domain/warp/RuntimeHostBoot.ts +++ b/src/domain/warp/RuntimeHostBoot.ts @@ -25,11 +25,14 @@ import type LoggerPort from '../../ports/LoggerPort.ts'; import type CryptoPort from '../../ports/CryptoPort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import type WarpStateCachePort from '../../ports/WarpStateCachePort.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import type AuditLogPort from '../../ports/AuditLogPort.ts'; import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; +import type StrandStorePort from '../../ports/StrandStorePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; import type CheckpointStorePort from '../../ports/CheckpointStorePort.ts'; import type IndexStorePort from '../../ports/IndexStorePort.ts'; +import type IntentStorePort from '../../ports/IntentStorePort.ts'; import type EffectSinkPort from '../../ports/EffectSinkPort.ts'; import type RuntimeStorageProviderPort from '../../ports/RuntimeStorageProviderPort.ts'; import type SchedulerPort from '../../ports/SchedulerPort.ts'; @@ -57,13 +60,15 @@ export type RuntimeHostConstructionOptions = { trustCrypto?: TrustCryptoPort; stateCache?: WarpStateCachePort | null; audit?: boolean; - blobStorage?: BlobStoragePort; - patchBlobStorage?: BlobStoragePort; + assetStorage: AssetStoragePort; + auditLog: AuditLogPort; commitMessageCodec: CommitMessageCodecPort; trust?: { mode?: TrustMode; pin?: string | null }; patchJournal: PatchJournalPort; + strandStore: StrandStorePort; checkpointStore: CheckpointStorePort; indexStore: IndexStorePort; + intentStore: IntentStorePort; viewService: MaterializedViewService; stateHashService?: StateHashService; auditService?: AuditReceiptService; @@ -87,12 +92,7 @@ export type RuntimeHostOpenOptions = { trustCrypto?: TrustCryptoPort; stateCache?: WarpStateCachePort | null; audit?: boolean; - blobStorage?: BlobStoragePort; - patchBlobStorage?: BlobStoragePort; commitMessageCodec?: CommitMessageCodecPort; - patchJournal?: PatchJournalPort | null; - checkpointStore?: CheckpointStorePort | null; - indexStore?: IndexStorePort | null; trust?: { mode?: TrustMode; pin?: string | null }; effectPipeline?: EffectPipeline; effectSinks?: readonly EffectSinkPort[]; @@ -116,12 +116,7 @@ export class WarpOpenOptions { readonly trustCrypto?: TrustCryptoPort; readonly stateCache?: WarpStateCachePort | null; 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[]; @@ -166,12 +161,7 @@ export class WarpOpenOptions { 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)); } @@ -278,12 +268,7 @@ export async function resolveRuntimeHostConstructionOptions( trustCrypto, stateCache, audit, - blobStorage, - patchBlobStorage, commitMessageCodec, - patchJournal, - checkpointStore, - indexStore, trust, effectPipeline, effectSinks, @@ -304,14 +289,8 @@ export async function resolveRuntimeHostConstructionOptions( codec: resolvedCodec, commitMessageCodec: resolvedCommitMessageCodec, ...(logger === undefined ? {} : { logger }), - ...(blobStorage === undefined ? {} : { contentOverride: blobStorage }), - ...(patchBlobStorage === undefined - ? {} - : { patchContentOverride: patchBlobStorage }), }); - const resolvedBlobStorage = storageServices.content; - const resolvedPatchJournal = patchJournal ?? storageServices.patchJournal; - const resolvedCheckpointStore = checkpointStore ?? storageServices.checkpoints; + const resolvedAssetStorage = storageServices.content; let resolvedStateCache: WarpStateCachePort | undefined; if (stateCache !== undefined && stateCache !== null) { @@ -320,7 +299,7 @@ export async function resolveRuntimeHostConstructionOptions( resolvedStateCache = storageServices.stateSnapshots; } - const resolvedIndexStore = indexStore ?? storageServices.indexes; + const resolvedIndexStore = storageServices.indexes; const resolvedStateHashService = new StateHashService({ codec: resolvedCodec, @@ -329,14 +308,12 @@ export async function resolveRuntimeHostConstructionOptions( const resolvedViewService = new MaterializedViewService({ codec: resolvedCodec, - ...(logger !== undefined ? { logger } : {}), - indexStore: resolvedIndexStore, }); let resolvedAuditService: AuditReceiptService | undefined; if (audit === true) { resolvedAuditService = new AuditReceiptService({ - persistence, + auditLog: storageServices.auditLog, graphName, writerId, codec: resolvedCodec, @@ -388,13 +365,15 @@ export async function resolveRuntimeHostConstructionOptions( ...(resolvedTrustCrypto !== undefined ? { trustCrypto: resolvedTrustCrypto } : {}), ...(resolvedStateCache !== undefined ? { stateCache: resolvedStateCache } : {}), ...(audit !== undefined ? { audit } : {}), - blobStorage: resolvedBlobStorage, - ...(patchBlobStorage !== undefined ? { patchBlobStorage } : {}), + assetStorage: resolvedAssetStorage, + auditLog: storageServices.auditLog, commitMessageCodec: resolvedCommitMessageCodec, ...(trust !== undefined ? { trust } : {}), - patchJournal: resolvedPatchJournal, - checkpointStore: resolvedCheckpointStore, + patchJournal: storageServices.patchJournal, + strandStore: storageServices.strands, + checkpointStore: storageServices.checkpoints, indexStore: resolvedIndexStore, + intentStore: storageServices.intents, viewService: resolvedViewService, stateHashService: resolvedStateHashService, ...(resolvedAuditService !== undefined ? { auditService: resolvedAuditService } : {}), diff --git a/src/domain/warp/RuntimeHostProduct.ts b/src/domain/warp/RuntimeHostProduct.ts index fb61bc48..558cfea3 100644 --- a/src/domain/warp/RuntimeHostProduct.ts +++ b/src/domain/warp/RuntimeHostProduct.ts @@ -1,8 +1,10 @@ -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CryptoPort from '../../ports/CryptoPort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import type RuntimeStorageProviderPort from '../../ports/RuntimeStorageProviderPort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; +import type CheckpointStorePort from '../../ports/CheckpointStorePort.ts'; +import type IndexStorePort from '../../ports/IndexStorePort.ts'; import type { NeighborEdge } from '../../ports/NeighborProviderPort.ts'; import type QueryCapability from '../capabilities/QueryCapability.ts'; import type PatchCapability from '../capabilities/PatchCapability.ts'; @@ -142,7 +144,9 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & { _maxObservedLamport: number; _checkpointPolicy: { every: number } | null; _autoMaterialize: boolean; - _blobStorage: BlobStoragePort | null; + _assetStorage: AssetStoragePort; + _checkpointStore: CheckpointStorePort; + _indexStore: IndexStorePort; readonly _viewService: MaterializedViewService; readonly _syncController: SyncController; readonly provenanceIndex: ProvenanceIndex | null; diff --git a/src/domain/warp/RuntimePatchCollector.ts b/src/domain/warp/RuntimePatchCollector.ts index be586b13..1eeb1ff3 100644 --- a/src/domain/warp/RuntimePatchCollector.ts +++ b/src/domain/warp/RuntimePatchCollector.ts @@ -14,7 +14,6 @@ type RuntimeCheckpointData = { stateHash: string; schema: number; provenanceIndex?: object | null; - indexShardOids?: Record | null | undefined; }; type RuntimePatchCollectorHost = { @@ -50,7 +49,6 @@ function toCheckpointData(checkpoint: RuntimeCheckpointData | null): CheckpointD ...(isProvenanceIndexShape(checkpoint.provenanceIndex) ? { provenanceIndex: checkpoint.provenanceIndex } : {}), - ...(checkpoint.indexShardOids !== undefined ? { indexShardOids: checkpoint.indexShardOids } : {}), }; } @@ -61,7 +59,6 @@ function toRuntimeCheckpointData(checkpoint: CheckpointData): RuntimeCheckpointD stateHash: checkpoint.stateHash, schema: checkpoint.schema, ...(checkpoint.provenanceIndex !== undefined ? { provenanceIndex: checkpoint.provenanceIndex } : {}), - ...(checkpoint.indexShardOids !== undefined ? { indexShardOids: checkpoint.indexShardOids } : {}), }; } diff --git a/src/domain/warp/WarpCoreRuntimeProduct.ts b/src/domain/warp/WarpCoreRuntimeProduct.ts index 34973667..3e9cf7fc 100644 --- a/src/domain/warp/WarpCoreRuntimeProduct.ts +++ b/src/domain/warp/WarpCoreRuntimeProduct.ts @@ -55,16 +55,17 @@ export function buildWarpCoreRuntimeSurface(runtime: RuntimeHostProduct): WarpCo worldline: runtime.worldline.bind(runtime), observer: runtime.observer.bind(runtime), translationCost: runtime.translationCost.bind(runtime), - getContentOid: runtime.getContentOid.bind(runtime), + getContentHandle: runtime.getContentHandle.bind(runtime), getContentMeta: runtime.getContentMeta.bind(runtime), getContent: runtime.getContent.bind(runtime), - getEdgeContentOid: runtime.getEdgeContentOid.bind(runtime), + getEdgeContentHandle: runtime.getEdgeContentHandle.bind(runtime), getEdgeContentMeta: runtime.getEdgeContentMeta.bind(runtime), getEdgeContent: runtime.getEdgeContent.bind(runtime), getContentStream: runtime.getContentStream.bind(runtime), getEdgeContentStream: runtime.getEdgeContentStream.bind(runtime), createPatch: runtime.createPatch.bind(runtime), patch: runtime.patch.bind(runtime), + patchWithEvidence: runtime.patchWithEvidence.bind(runtime), patchMany: runtime.patchMany.bind(runtime), getWriterPatches: runtime.getWriterPatches.bind(runtime), writer: runtime.writer.bind(runtime), @@ -95,6 +96,7 @@ export function buildWarpCoreRuntimeSurface(runtime: RuntimeHostProduct): WarpCo patchesForStrand: runtime.patchesForStrand.bind(runtime), createStrandPatch: runtime.createStrandPatch.bind(runtime), patchStrand: runtime.patchStrand.bind(runtime), + patchStrandWithEvidence: runtime.patchStrandWithEvidence.bind(runtime), queueStrandIntent: runtime.queueStrandIntent.bind(runtime), listStrandIntents: runtime.listStrandIntents.bind(runtime), tickStrand: runtime.tickStrand.bind(runtime), diff --git a/src/domain/warp/WarpGraphRuntimeProduct.ts b/src/domain/warp/WarpGraphRuntimeProduct.ts index c9e9acd8..d21f2942 100644 --- a/src/domain/warp/WarpGraphRuntimeProduct.ts +++ b/src/domain/warp/WarpGraphRuntimeProduct.ts @@ -26,16 +26,17 @@ export function buildWarpGraphRuntimeSurface(runtime: RuntimeGraphHostProduct): worldline: runtime.worldline.bind(runtime), observer: runtime.observer.bind(runtime), translationCost: runtime.translationCost.bind(runtime), - getContentOid: runtime.getContentOid.bind(runtime), + getContentHandle: runtime.getContentHandle.bind(runtime), getContentMeta: runtime.getContentMeta.bind(runtime), getContent: runtime.getContent.bind(runtime), - getEdgeContentOid: runtime.getEdgeContentOid.bind(runtime), + getEdgeContentHandle: runtime.getEdgeContentHandle.bind(runtime), getEdgeContentMeta: runtime.getEdgeContentMeta.bind(runtime), getEdgeContent: runtime.getEdgeContent.bind(runtime), getContentStream: runtime.getContentStream.bind(runtime), getEdgeContentStream: runtime.getEdgeContentStream.bind(runtime), createPatch: runtime.createPatch.bind(runtime), patch: runtime.patch.bind(runtime), + patchWithEvidence: runtime.patchWithEvidence.bind(runtime), patchMany: runtime.patchMany.bind(runtime), getWriterPatches: runtime.getWriterPatches.bind(runtime), writer: runtime.writer.bind(runtime), @@ -63,6 +64,7 @@ export function buildWarpGraphRuntimeSurface(runtime: RuntimeGraphHostProduct): patchesForStrand: runtime.patchesForStrand.bind(runtime), createStrandPatch: runtime.createStrandPatch.bind(runtime), patchStrand: runtime.patchStrand.bind(runtime), + patchStrandWithEvidence: runtime.patchStrandWithEvidence.bind(runtime), queueStrandIntent: runtime.queueStrandIntent.bind(runtime), listStrandIntents: runtime.listStrandIntents.bind(runtime), tickStrand: runtime.tickStrand.bind(runtime), diff --git a/src/domain/warp/Writer.ts b/src/domain/warp/Writer.ts index 790555a8..2d0c34c0 100644 --- a/src/domain/warp/Writer.ts +++ b/src/domain/warp/Writer.ts @@ -25,7 +25,7 @@ import type Patch from '../types/Patch.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 AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CommitMessageCodecPort from '../../ports/CommitMessageCodecPort.ts'; import type { WarpState } from '../services/JoinReducer.ts'; @@ -68,7 +68,7 @@ interface WriterOptions { patchJournal: PatchJournalPort; commitMessageCodec?: CommitMessageCodecPort; logger?: LoggerPort; - blobStorage?: BlobStoragePort; + assetStorage?: AssetStoragePort; } /** @@ -85,7 +85,7 @@ export class Writer { private _patchJournal: PatchJournalPort; private _commitMessageCodec: CommitMessageCodecPort; private _logger: LoggerPort; - private _blobStorage: BlobStoragePort | null; + private _assetStorage: AssetStoragePort | null; private _commitInProgress: boolean; constructor(opts: WriterOptions) { @@ -101,7 +101,7 @@ export class Writer { this._patchJournal = opts.patchJournal; this._commitMessageCodec = requireCommitMessageCodec(opts.commitMessageCodec); this._logger = opts.logger ?? nullLogger; - this._blobStorage = opts.blobStorage ?? null; + this._assetStorage = opts.assetStorage ?? null; this._commitInProgress = false; } @@ -165,7 +165,7 @@ export class Writer { logger: this._logger, }; if (this._onCommitSuccess) { opts.onCommitSuccess = this._onCommitSuccess; } - if (this._blobStorage) { opts.blobStorage = this._blobStorage; } + if (this._assetStorage) { opts.assetStorage = this._assetStorage; } return opts; } diff --git a/src/infrastructure/adapters/AdapterDependencyGuard.ts b/src/infrastructure/adapters/AdapterDependencyGuard.ts new file mode 100644 index 00000000..82ee9e30 --- /dev/null +++ b/src/infrastructure/adapters/AdapterDependencyGuard.ts @@ -0,0 +1,8 @@ +import WarpError from '../../domain/errors/WarpError.ts'; + +/** Fails closed when a required adapter dependency is absent at runtime. */ +export function requireAdapterDependency(value: unknown, name: string): void { + if (value === null || value === undefined) { + throw new WarpError(`Adapter requires ${name}`, 'E_INVALID_DEPENDENCY'); + } +} diff --git a/src/infrastructure/adapters/CasBlobAdapter.ts b/src/infrastructure/adapters/CasBlobAdapter.ts deleted file mode 100644 index e0550450..00000000 --- a/src/infrastructure/adapters/CasBlobAdapter.ts +++ /dev/null @@ -1,312 +0,0 @@ -/** - * CasBlobAdapter — stores content blobs via git-cas. - * - * Content is chunked (CDC by default), optionally encrypted, and stored - * as a CAS tree in the Git object store. The tree OID serves as the - * storage identifier. - * - * 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 - */ - -import BlobStoragePort from '../../ports/BlobStoragePort.ts'; -import PersistenceError from '../../domain/errors/PersistenceError.ts'; -import { - CURRENT_SUBSTRATE_ONLY_POLICY, - type SubstrateCompatibilityPolicyValue, -} from './SubstrateCompatibilityPolicy.ts'; -import CasContentEncryptionPolicy, { - type CasRestoreEncryptionArguments, - type CasStoreEncryptionOptions, - mapCasContentEncryptionError, -} from './CasContentEncryptionPolicy.ts'; -import { Readable } from 'node:stream'; -import type ContentAddressableStore from '@git-stunts/git-cas'; -import type { Manifest } from '@git-stunts/git-cas'; - -type CasStore = Pick< - ContentAddressableStore, - 'readManifest' | 'restore' | 'restoreStream' | 'store' | 'createTree' ->; - -export interface BlobPersistence { - readBlob(oid: string): Promise; -} - -function normalizeToUint8Array(buffer: Uint8Array): Uint8Array { - if (buffer instanceof Uint8Array && buffer.constructor !== Uint8Array) { - return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - } - return buffer; -} - -/** - * Error codes from `@git-stunts/git-cas` that indicate the OID is not - * a CAS manifest (i.e. it's a legacy raw Git blob written before the - * CAS migration). - * - * - `MANIFEST_NOT_FOUND` — tree exists but contains no manifest entry - * - `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']); -const CAS_NOT_FOUND_CODES = new Set(['MANIFEST_NOT_FOUND', 'GIT_OBJECT_NOT_FOUND']); - -function isCasNotFoundError(err: unknown): boolean { - return ( - typeof err === 'object' - && err !== null - && 'code' in err - && typeof err.code === 'string' - && CAS_NOT_FOUND_CODES.has(err.code) - ); -} - -function isLegacyBlobError(err: unknown): err is Error { - if (err instanceof Error && 'code' in err) { - const coded = err as Error & { code: unknown }; - if (typeof coded.code === 'string') { - return LEGACY_BLOB_CODES.has(coded.code); - } - } - const msg = err instanceof Error ? err.message : ''; - return hasLegacyBlobMessage(msg); -} - -function hasLegacyBlobMessage(msg: string): boolean { - return msg.includes('not a tree') - || msg.includes('bad object') - || msg.includes('does not exist'); -} - -function missingGitObjectError(oid: string, cause: Error): PersistenceError { - return new PersistenceError( - `Missing Git object: ${oid}`, - PersistenceError.E_MISSING_OBJECT, - { cause, context: { oid } }, - ); -} - -export default class CasBlobAdapter extends BlobStoragePort { - private readonly _cas: CasStore; - private readonly _persistence: BlobPersistence; - private readonly _contentEncryption: CasContentEncryptionPolicy; - private readonly _compatibilityPolicy: SubstrateCompatibilityPolicyValue; - - constructor({ cas, persistence, encryptionKey, contentEncryption, compatibilityPolicy }: { - cas: CasStore; - persistence: BlobPersistence; - encryptionKey?: Uint8Array; - contentEncryption?: CasContentEncryptionPolicy; - compatibilityPolicy?: SubstrateCompatibilityPolicyValue; - }) { - super(); - this._cas = cas; - this._persistence = persistence; - this._contentEncryption = resolveContentEncryption(contentEncryption, encryptionKey); - this._compatibilityPolicy = compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; - } - - override async store(content: Uint8Array | string, options?: { slug?: string; mime?: string | null; size?: number | null }): Promise { - const cas = this._cas; - const buf = typeof content === 'string' - ? new TextEncoder().encode(content) - : content; - const source = Readable.from([buf]); - - const storeOpts: { source: AsyncIterable; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { - source, - slug: options?.slug ?? `blob-${Date.now().toString(36)}`, - filename: 'content', - ...this._contentEncryption.toStoreOptions(), - }; - - const manifest = await cas.store(storeOpts); - return await cas.createTree({ manifest }); - } - - override async has(oid: string): Promise { - const cas = this._cas; - try { - await cas.readManifest({ treeOid: oid }); - return true; - } catch (err) { - if (isCasNotFoundError(err)) { - return false; - } - throw err; - } - } - - override async retrieve(oid: string): Promise { - const cas = this._cas; - - 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._readLegacyContentBlobCandidate(oid, err); - } - } - - private async _restoreFromCas(cas: CasStore, oid: string): Promise { - const manifest = await cas.readManifest({ treeOid: oid }); - const restoreOpts = this._buildRestoreOpts(manifest); - const { buffer } = await cas.restore(restoreOpts); - return normalizeToUint8Array(buffer); - } - - private async _fallbackReadBlob(oid: string): Promise { - const blob = await this._persistence.readBlob(oid); - if (blob === null || blob === undefined) { - throw new PersistenceError( - `Missing Git object: ${oid}`, - PersistenceError.E_MISSING_OBJECT, - { context: { oid } }, - ); - } - return blob; - } - - private _buildRestoreOpts(manifest: Manifest): { manifest: Manifest } & CasRestoreEncryptionArguments { - return { manifest, ...this._contentEncryption.toRestoreOptions() }; - } - - override async storeStream(source: AsyncIterable, options?: { slug?: string; mime?: string | null; size?: number | null }): Promise { - const cas = this._cas; - const readable = Readable.from(source); - - const storeOpts: { source: AsyncIterable; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { - source: readable, - slug: options?.slug ?? `blob-${Date.now().toString(36)}`, - filename: 'content', - ...this._contentEncryption.toStoreOptions(), - }; - - const manifest = await cas.store(storeOpts); - return await cas.createTree({ manifest }); - } - - override retrieveStream(oid: string): AsyncIterable { - const self = this; - return { - [Symbol.asyncIterator](): AsyncIterator { - let inner: AsyncIterator | null = null; - return { - async next(): Promise> { - if (inner === null) { - inner = await self._resolveStreamIterator(oid); - } - return await inner.next(); - }, - async return(): Promise> { - if (inner !== null && typeof inner.return === 'function') { - return await inner.return(); - } - return doneIteratorResult(); - }, - }; - }, - }; - } - - private async _resolveStreamIterator(oid: string): Promise> { - const cas = this._cas; - - 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._readLegacyContentBlobCandidate(oid, err); - return singleChunkIterator(blob); - } - } - - private async _streamFromCas(cas: CasStore, oid: string): Promise> { - const manifest = await cas.readManifest({ treeOid: oid }); - const restoreOpts = this._buildRestoreOpts(manifest); - - return cas.restoreStream(restoreOpts)[Symbol.asyncIterator](); - } - - private async _readLegacyContentBlobCandidate(oid: string, error: Error): 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', - { - 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 { - let done = false; - return { - next(): Promise> { - if (done) { - return Promise.resolve(doneIteratorResult()); - } - done = true; - return Promise.resolve({ value: buf, done: false }); - }, - return(): Promise> { - done = true; - return Promise.resolve(doneIteratorResult()); - }, - }; -} - -function doneIteratorResult(): IteratorResult { - return { value: new Uint8Array(0), done: true }; -} diff --git a/src/infrastructure/adapters/CasContentEncryptionPolicy.ts b/src/infrastructure/adapters/CasContentEncryptionPolicy.ts index 01d322da..c8bc6c3b 100644 --- a/src/infrastructure/adapters/CasContentEncryptionPolicy.ts +++ b/src/infrastructure/adapters/CasContentEncryptionPolicy.ts @@ -64,7 +64,34 @@ interface EnabledFieldInput { readonly diagnostics: CasContentEncryptionDiagnostics | undefined; } -type CasContentEncryptionErrorKind = 'legacy-scheme' | 'wrong-passphrase' | 'missing-vault-metadata' | 'none'; +type CasContentEncryptionErrorKind = + | 'legacy-scheme' + | 'wrong-passphrase' + | 'missing-vault-metadata' + | 'decryption-integrity' + | 'none'; + +const CONTENT_ENCRYPTION_ERRORS: Readonly, + Readonly<{ message: string; code: string }> +>> = Object.freeze({ + 'legacy-scheme': Object.freeze({ + message: 'Legacy git-cas encryption schemes require migration before git-warp can restore this CAS content', + code: 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + }), + 'wrong-passphrase': Object.freeze({ + message: 'git-cas vault passphrase verification failed while resolving CAS content encryption', + code: 'E_CAS_VAULT_PASSPHRASE_FAILED', + }), + 'missing-vault-metadata': Object.freeze({ + message: 'git-cas vault metadata is missing or invalid for encrypted CAS content', + code: 'E_CAS_VAULT_METADATA_MISSING', + }), + 'decryption-integrity': Object.freeze({ + message: 'git-cas could not authenticate or decrypt encrypted CAS content', + code: 'E_CAS_CONTENT_DECRYPTION_FAILED', + }), +}); const LEGACY_SCHEME_VALUES = new Set([ 'whole-v1', @@ -179,35 +206,26 @@ export default class CasContentEncryptionPolicy { } } -export function mapCasContentEncryptionError(error: unknown, surface: string): EncryptionError | null { +export function mapCasContentEncryptionError( + error: unknown, + surface: string, + encryptedContent = false, +): 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; + const kind = classifyCasContentEncryptionError(code, message, encryptedContent); + if (kind === 'none') { + return null; + } + const mapped = CONTENT_ENCRYPTION_ERRORS[kind]; + return encryptionPolicyError(mapped.message, mapped.code, { + surface, + upstreamCode: code, + upstreamMessage: message, + }); } function enabledFields(input: EnabledFieldInput): CasContentEncryptionPolicyFields { @@ -386,6 +404,7 @@ function isMissingVaultMetadataMessage(message: string): boolean { function classifyCasContentEncryptionError( code: string | null, message: string, + encryptedContent: boolean, ): CasContentEncryptionErrorKind { if (isLegacyCasEncryptionError(code, message)) { return 'legacy-scheme'; @@ -396,7 +415,14 @@ function classifyCasContentEncryptionError( if (isMissingVaultMetadataError(code, message)) { return 'missing-vault-metadata'; } - return 'none'; + return encryptedContent ? classifyEncryptedContentIntegrity(code) : 'none'; +} + +function classifyEncryptedContentIntegrity(code: string | null): CasContentEncryptionErrorKind { + const isIntegrityFailure = code === 'INTEGRITY_ERROR' + || code === 'MANIFEST_INTEGRITY_ERROR' + || code === 'DECRYPTION_BUFFER_EXCEEDED'; + return isIntegrityFailure ? 'decryption-integrity' : 'none'; } function isLegacyCasEncryptionError(code: string | null, message: string): boolean { diff --git a/src/infrastructure/adapters/CasPayloadPointer.ts b/src/infrastructure/adapters/CasPayloadPointer.ts deleted file mode 100644 index 39836d53..00000000 --- a/src/infrastructure/adapters/CasPayloadPointer.ts +++ /dev/null @@ -1,115 +0,0 @@ -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); - -interface BlobReader { - readBlob(oid: string): Promise; -} - -interface BlobWriter { - writeBlob(content: Uint8Array | string): Promise; -} - -type PayloadBlobWriteRequest = { - readonly blobPort: BlobWriter; - readonly blobStorage: BlobStoragePort | null | undefined; - readonly bytes: Uint8Array; - 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; - } - for (let index = 0; index < POINTER_PREFIX_BYTES.length; index += 1) { - if (bytes[index] !== POINTER_PREFIX_BYTES[index]) { - return false; - } - } - return true; -} - -export function encodeCasPayloadPointer(storageOid: string): Uint8Array { - if (storageOid.length === 0) { - throw new WarpError('CAS payload pointer requires a storage OID', 'E_INVALID_INPUT'); - } - return textEncode(`${POINTER_PREFIX}${storageOid}`); -} - -export function decodeCasPayloadPointer(bytes: Uint8Array): string | null { - if (!hasPointerPrefix(bytes)) { - return null; - } - const decoded = textDecode(bytes); - if (!decoded.startsWith(POINTER_PREFIX)) { - return null; - } - const storageOid = decoded.slice(POINTER_PREFIX.length); - if (storageOid.length === 0) { - throw new WarpError('CAS payload pointer is missing its storage OID', 'E_INVALID_INPUT'); - } - return storageOid; -} - -export async function writePayloadBlob(request: PayloadBlobWriteRequest): Promise { - const { blobPort, blobStorage, bytes, options } = request; - if (blobStorage === null || blobStorage === undefined) { - return await blobPort.writeBlob(bytes); - } - const storageOid = await blobStorage.store(bytes, options); - return await blobPort.writeBlob(encodeCasPayloadPointer(storageOid)); -} - -export async function readPayloadBlob(request: PayloadBlobReadRequest): Promise { - const bytes = await request.blobPort.readBlob(request.oid); - const storageOid = decodeCasPayloadPointer(bytes); - if (storageOid === null) { - return inlinePayloadBytes(request, bytes); - } - if (request.blobStorage === null || request.blobStorage === undefined) { - throw new WarpError( - `Blob ${request.oid} is a CAS payload pointer but no blobStorage is configured`, - 'E_INVALID_DEPENDENCY', - ); - } - 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/CborCheckpointStoreAdapter.ts b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts index cf29db87..9c2bfa75 100644 --- a/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts +++ b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts @@ -1,277 +1,490 @@ -import CheckpointStorePort, { type CheckpointRecord, type CheckpointWriteResult, type CheckpointData } from '../../ports/CheckpointStorePort.ts'; -import type BlobPort from '../../ports/BlobPort.ts'; +import type { + BundleCapability, + PublicationCapability, +} from '@git-stunts/git-cas'; +import CheckpointStorePort, { + type CheckpointBasis, + type CheckpointData, + type CheckpointMetadata, + type CheckpointRecord, + type PublishedCheckpoint, +} from '../../ports/CheckpointStorePort.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; -import WarpError from '../../domain/errors/WarpError.ts'; +import { + CHECKPOINT_STORAGE_FORMAT, + type default as CommitMessageCodecPort, +} from '../../ports/CommitMessageCodecPort.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import BundleHandle from '../../domain/storage/BundleHandle.ts'; +import PersistenceError from '../../domain/errors/PersistenceError.ts'; import VersionVector from '../../domain/crdt/VersionVector.ts'; -import type WarpState from '../../domain/services/state/WarpState.ts'; import { ProvenanceIndex } from '../../domain/services/provenance/ProvenanceIndex.ts'; import { deserializeCheckpointStateEnvelope, serializeCheckpointStateEnvelope, type CheckpointStateEnvelopeBuffers, } from '../../domain/services/state/CheckpointSerializer.ts'; - -interface CheckpointWritePromises { - nodeAliveBlobOid: Promise; - edgeAliveBlobOid: Promise; - propBlobOid: Promise; - observedFrontierBlobOid: Promise; - edgeBirthEventBlobOid: Promise; - frontierBlobOid: Promise; - appliedVVBlobOid: Promise; - provenanceIndexBlobOid: Promise; +import { + CURRENT_CHECKPOINT_SCHEMA, + isCurrentCheckpointSchema, +} from '../../domain/services/state/checkpointHelpers.ts'; +import { buildCheckpointRef, buildCoverageRef } from '../../domain/utils/RefLayout.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; +import { stageCheckpointBundleArtifacts } from './CheckpointBundleArtifactStager.ts'; +import LegacyCheckpointArtifactAdapter from './LegacyCheckpointArtifactAdapter.ts'; +import { classifyCheckpointStorage } from './CheckpointStorageFormatClassifier.ts'; +import { requireAdapterDependency } from './AdapterDependencyGuard.ts'; + +interface CheckpointHistory { + readBlob(oid: string): Promise; + readTreeOids(treeOid: string): Promise>; + commitNode(options: { message: string; parents: string[] }): Promise; + showNode(sha: string): Promise; + getCommitTree(sha: string): Promise; + readRef(ref: string): Promise; + compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise; } -interface CheckpointReadPromises { - nodeAlive: Promise; - edgeAlive: Promise; - prop: Promise; - observedFrontier: Promise; - edgeBirthEvent: Promise; - frontier: Promise; - appliedVV: Promise; - provenanceIndex: Promise; -} +export type GitCasCheckpointFacade = { + readonly bundles: Pick; + readonly publications: Pick; +}; -interface CheckpointReadBuffers { - nodeAlive: Uint8Array; - edgeAlive: Uint8Array; - prop: Uint8Array; - observedFrontier: Uint8Array; - edgeBirthEvent: Uint8Array; - frontier: Uint8Array; - appliedVV: Uint8Array | null; - provenanceIndex: Uint8Array | null; -} +type CheckpointArtifact = + | Readonly<{ kind: 'asset'; handle: AssetHandle }> + | Readonly<{ kind: 'legacy-object'; oid: string }>; + +type CheckpointLayout = { + readonly metadata: ReturnType; + readonly artifacts: Readonly>; + readonly indexShardHandles: Readonly>; +}; /** - * CBOR-backed implementation of CheckpointStorePort. - * - * Owns the codec and raw blob persistence. Domain services call - * writeCheckpoint(record) with domain objects; the adapter internally - * encodes each artifact and writes blobs. + * Publishes current checkpoints as retained git-cas bundles and reads the + * retired schema-5 Git tree layout behind an explicit adapter boundary. */ export class CborCheckpointStoreAdapter extends CheckpointStorePort { private readonly _codec: CodecPort; - private readonly _blobPort: BlobPort; + private readonly _messageCodec: CommitMessageCodecPort; + private readonly _history: CheckpointHistory; + private readonly _assets: AssetStoragePort; + private readonly _cas: GitCasCheckpointFacade; + private readonly _legacyArtifacts: LegacyCheckpointArtifactAdapter; - constructor({ codec, blobPort }: { + constructor(options: { codec: CodecPort; - blobPort: BlobPort; + commitMessageCodec: CommitMessageCodecPort; + history: CheckpointHistory; + assetStorage: AssetStoragePort; + cas: GitCasCheckpointFacade; }) { super(); - if (codec === null || codec === undefined) { - throw new WarpError('CborCheckpointStoreAdapter requires a codec', 'E_INVALID_DEPENDENCY'); - } - if (blobPort === null || blobPort === undefined) { - throw new WarpError('CborCheckpointStoreAdapter requires a blobPort', 'E_INVALID_DEPENDENCY'); - } - this._codec = codec; - this._blobPort = blobPort; + requireAdapterDependency(options.codec, 'codec'); + requireAdapterDependency(options.commitMessageCodec, 'commitMessageCodec'); + requireAdapterDependency(options.history, 'history'); + requireAdapterDependency(options.assetStorage, 'assetStorage'); + requireAdapterDependency(options.cas, 'cas'); + this._codec = options.codec; + this._messageCodec = options.commitMessageCodec; + this._history = options.history; + this._assets = options.assetStorage; + this._cas = options.cas; + this._legacyArtifacts = new LegacyCheckpointArtifactAdapter({ + history: options.history, + assets: options.assetStorage, + }); } - override async writeCheckpoint(record: CheckpointRecord): Promise { - const stateEnvelope = this._encodeStateEnvelope(record.state); - const frontierBytes = this._encodeFrontier(record.frontier); - const appliedVVBytes = this._encodeAppliedVV(record.appliedVV); + override async publishCheckpoint(record: CheckpointRecord): Promise { + const checkpointRef = buildCheckpointRef(record.graphName); + const expectedHead = record.expectedCheckpointSha === undefined + ? await this._history.readRef(checkpointRef) + : record.expectedCheckpointSha; + const stateEnvelope = serializeCheckpointStateEnvelope(record.state, { codec: this._codec }); + const bundle = await this._cas.bundles.putOrdered({ + members: stageCheckpointBundleArtifacts({ + assets: this._assets, + codec: this._codec, + envelope: stateEnvelope, + record, + }), + }); + const bundleHandle = new BundleHandle(bundle.handle.toString()); + const message = this._messageCodec.encodeCheckpoint({ + kind: 'checkpoint', + graph: record.graphName, + stateHash: record.stateHash, + schema: CURRENT_CHECKPOINT_SCHEMA, + checkpointVersion: CHECKPOINT_STORAGE_FORMAT, + bundleHandle, + }); + const publication = await this._cas.publications.commit({ + root: bundle.handle, + commit: { parents: record.parents, message }, + ref: { name: checkpointRef, expected: expectedHead }, + }); + requirePublishedBundle(publication.root.toString(), bundleHandle); + const retention = adaptGitCasRetentionWitness(publication.witness.toJSON()); + requireRetainedBundle(retention.handle.toString(), bundleHandle); + return Object.freeze({ + checkpointSha: publication.commitId, + bundleHandle, + retention, + }); + } - let provenanceBytes: Uint8Array | null = null; - if (record.provenanceIndex !== null && record.provenanceIndex !== undefined) { - provenanceBytes = record.provenanceIndex.serialize({ codec: this._codec }); - } + override async resolveHead(graphName: string): Promise { + return await this._history.readRef(buildCheckpointRef(graphName)); + } - const writes: CheckpointWritePromises = { - nodeAliveBlobOid: this._writeCheckpointBlob(stateEnvelope.nodeAlive), - edgeAliveBlobOid: this._writeCheckpointBlob(stateEnvelope.edgeAlive), - propBlobOid: this._writeCheckpointBlob(stateEnvelope.prop), - observedFrontierBlobOid: this._writeCheckpointBlob(stateEnvelope.observedFrontier), - edgeBirthEventBlobOid: this._writeCheckpointBlob(stateEnvelope.edgeBirthEvent), - frontierBlobOid: this._writeCheckpointBlob(frontierBytes), - appliedVVBlobOid: this._writeCheckpointBlob(appliedVVBytes), - provenanceIndexBlobOid: provenanceBytes !== null - ? this._writeCheckpointBlob(provenanceBytes) - : Promise.resolve(null), + override async loadCheckpoint( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const layout = await this._readLayout(checkpointSha, expectedGraphName); + const state = deserializeCheckpointStateEnvelope( + await this._readStateEnvelope(checkpointSha, layout.artifacts), + { codec: this._codec }, + ); + const frontier = await this._readFrontier(checkpointSha, layout.artifacts); + const appliedVV = await this._readAppliedVV(layout.artifacts); + const provenanceIndex = await this._readProvenanceIndex(layout.artifacts); + const result: CheckpointData = { + state, + frontier, + stateHash: layout.metadata.stateHash, + schema: layout.metadata.schema, + appliedVV, + indexShardHandles: hasEntries(layout.indexShardHandles) + ? layout.indexShardHandles + : null, }; - - return await this._resolveCheckpointWrites(writes); + if (provenanceIndex !== null) { + result.provenanceIndex = provenanceIndex; + } + return result; } - override async readCheckpoint(treeOids: Record): Promise { - const frontierOid = treeOids['frontier.cbor']; - const appliedVVOid = treeOids['appliedVV.cbor']; - const provenanceOid = treeOids['provenanceIndex.cbor']; - - if (frontierOid === undefined) { - throw new WarpError('Checkpoint missing frontier.cbor', 'E_MISSING_ARTIFACT'); + override async readMetadata( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const metadata = this._messageCodec.decodeCheckpoint( + await this._history.showNode(checkpointSha), + ); + requireCheckpointGraph(checkpointSha, metadata.graph, expectedGraphName); + if (!isCurrentCheckpointSchema(metadata.schema)) { + throw unsupportedCheckpointSchema(checkpointSha, metadata.schema); } + classifyCheckpointStorage(checkpointSha, metadata); + return Object.freeze({ + checkpointSha, + stateHash: metadata.stateHash, + schema: metadata.schema, + }); + } - const reads: CheckpointReadPromises = { - nodeAlive: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/nodeAlive')), - edgeAlive: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/edgeAlive')), - prop: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/prop.cbor')), - observedFrontier: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/observedFrontier.cbor')), - edgeBirthEvent: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/edgeBirthEvent.cbor')), - frontier: this._readCheckpointBlob(frontierOid), - appliedVV: appliedVVOid !== undefined - ? this._readCheckpointBlob(appliedVVOid) - : Promise.resolve(null), - provenanceIndex: provenanceOid !== undefined - ? this._readCheckpointBlob(provenanceOid) - : Promise.resolve(null), - }; + override async loadBasis( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const layout = await this._readLayout(checkpointSha, expectedGraphName); + if (!hasEntries(layout.indexShardHandles)) { + throw new PersistenceError( + `Checkpoint ${checkpointSha} has no bounded index basis`, + 'E_CHECKPOINT_MISSING_INDEX', + { context: { checkpointSha } }, + ); + } + return Object.freeze({ + checkpointSha, + stateHash: layout.metadata.stateHash, + schema: layout.metadata.schema, + frontier: await this._readFrontier(checkpointSha, layout.artifacts), + indexShardHandles: layout.indexShardHandles, + }); + } - const buffers = await this._resolveCheckpointReads(reads); - const state = this._decodeStateEnvelope({ - nodeAlive: buffers.nodeAlive, - edgeAlive: buffers.edgeAlive, - prop: buffers.prop, - observedFrontier: buffers.observedFrontier, - edgeBirthEvent: buffers.edgeBirthEvent, + override async publishCoverage(options: { + graphName: string; + parents: string[]; + }): Promise { + const ref = buildCoverageRef(options.graphName); + const expectedHead = await this._history.readRef(ref); + const message = this._messageCodec.encodeAnchor({ + kind: 'anchor', + graph: options.graphName, + schema: 2, }); - const frontier = this._decodeFrontier(buffers.frontier); + const sha = await this._history.commitNode({ message, parents: options.parents }); + await this._history.compareAndSwapRef(ref, sha, expectedHead); + return sha; + } - let appliedVV: VersionVector | null = null; - if (buffers.appliedVV !== null) { - appliedVV = this._decodeAppliedVV(buffers.appliedVV); + private async _readLayout( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const metadata = this._messageCodec.decodeCheckpoint( + await this._history.showNode(checkpointSha), + ); + requireCheckpointGraph(checkpointSha, metadata.graph, expectedGraphName); + if (!isCurrentCheckpointSchema(metadata.schema)) { + throw unsupportedCheckpointSchema(checkpointSha, metadata.schema); } - - let provenanceIndex: ProvenanceIndex | null = null; - if (buffers.provenanceIndex !== null) { - provenanceIndex = ProvenanceIndex.deserialize(buffers.provenanceIndex, { codec: this._codec }); + const storage = classifyCheckpointStorage(checkpointSha, metadata); + if (storage.kind === 'bundle') { + return await this._readBundleLayout(metadata, storage.handle); } + const rawTreeOids = await this._history.readTreeOids( + await this._history.getCommitTree(checkpointSha), + ); + const treeOids = await this._expandLegacyStateTree(rawTreeOids); + const indexOids = await this._readLegacyIndexOids(treeOids, rawTreeOids); + const artifacts = Object.fromEntries( + Object.entries(treeOids).map(([path, oid]) => [ + path, + legacyCheckpointArtifact(oid), + ]), + ); + return { + metadata, + artifacts: Object.freeze(artifacts), + indexShardHandles: Object.freeze(Object.fromEntries( + Object.entries(indexOids).map(([path, oid]) => [path, new AssetHandle(oid)]), + )), + }; + } - let indexShardOids: Record | null = null; - const shardEntries = Object.entries(treeOids).filter(([p]) => p.startsWith('index/')); - if (shardEntries.length > 0) { - indexShardOids = Object.fromEntries(shardEntries.map(([p, o]) => [p.slice('index/'.length), o])); + private async _readBundleLayout( + metadata: CheckpointLayout['metadata'], + bundleHandle: BundleHandle, + ): Promise { + const artifacts = new Map(); + const indexShardHandles = new Map(); + for await (const member of this._cas.bundles.iterateMembers({ + handle: bundleHandle.toString(), + })) { + if (member.handle.kind !== 'asset') { + throw new PersistenceError( + `Checkpoint bundle member is not an asset: ${member.path}`, + 'E_CHECKPOINT_INVALID_BUNDLE_MEMBER', + { context: { path: member.path, kind: member.handle.kind } }, + ); + } + if (artifacts.has(member.path)) { + throw new PersistenceError( + `Checkpoint bundle contains a duplicate member: ${member.path}`, + 'E_CHECKPOINT_DUPLICATE_BUNDLE_MEMBER', + { context: { path: member.path } }, + ); + } + const handle = new AssetHandle(member.handle.toString()); + artifacts.set(member.path, Object.freeze({ kind: 'asset', handle })); + if (member.path.startsWith('index/')) { + const indexPath = member.path.slice('index/'.length); + if (indexPath.length === 0) { + throw new PersistenceError( + 'Checkpoint bundle contains an empty index member path', + 'E_CHECKPOINT_INVALID_BUNDLE_MEMBER', + { context: { path: member.path } }, + ); + } + indexShardHandles.set(indexPath, handle); + } } - return { - state, - frontier, - appliedVV, - ...(provenanceIndex !== null ? { provenanceIndex } : {}), - indexShardOids, + metadata, + artifacts: Object.freeze(Object.fromEntries(artifacts)), + indexShardHandles: Object.freeze(Object.fromEntries(indexShardHandles)), }; } - // ── Encode Helpers ────────────────────────────────────────────────── - - private _encodeStateEnvelope(state: WarpState): CheckpointStateEnvelopeBuffers { - return serializeCheckpointStateEnvelope(state, { codec: this._codec }); + private async _expandLegacyStateTree( + treeOids: Record, + ): Promise> { + if (treeOids['state/nodeAlive'] !== undefined || treeOids['state'] === undefined) { + return treeOids; + } + const expanded = { ...treeOids }; + for (const [path, oid] of Object.entries( + await this._history.readTreeOids(treeOids['state']), + )) { + expanded[`state/${path}`] = oid; + } + return expanded; } - private _encodeFrontier(frontier: Map): Uint8Array { - const obj: Record = {}; - for (const key of Array.from(frontier.keys()).sort()) { - obj[key] = frontier.get(key); + private async _readLegacyIndexOids( + treeOids: Record, + rawTreeOids: Record, + ): Promise> { + const flattened = Object.fromEntries( + Object.entries(rawTreeOids) + .filter(([path]) => path.startsWith('index/')) + .map(([path, oid]) => [path.slice('index/'.length), oid]), + ); + if (hasEntries(flattened) || treeOids['index'] === undefined) { + return flattened; } - return this._codec.encode(obj); + return await this._history.readTreeOids(treeOids['index']); } - private _encodeAppliedVV(vv: VersionVector): Uint8Array { - return this._codec.encode(VersionVector.serialize(vv)); + private async _readStateEnvelope( + checkpointSha: string, + artifacts: Readonly>, + ): Promise { + return { + nodeAlive: await this._readPayload(requireArtifact(checkpointSha, artifacts, 'state/nodeAlive')), + edgeAlive: await this._readPayload(requireArtifact(checkpointSha, artifacts, 'state/edgeAlive')), + prop: await this._readPayload(requireArtifact(checkpointSha, artifacts, 'state/prop.cbor')), + observedFrontier: await this._readPayload( + requireArtifact(checkpointSha, artifacts, 'state/observedFrontier.cbor'), + ), + edgeBirthEvent: await this._readPayload( + requireArtifact(checkpointSha, artifacts, 'state/edgeBirthEvent.cbor'), + ), + }; } - // ── Decode Helpers ────────────────────────────────────────────────── + private async _readFrontier( + checkpointSha: string, + artifacts: Readonly>, + ): Promise> { + const bytes = await this._readPayload( + requireArtifact(checkpointSha, artifacts, 'frontier.cbor'), + ); + return decodeFrontier(this._codec.decode(bytes), checkpointSha); + } - private _decodeStateEnvelope(envelope: CheckpointStateEnvelopeBuffers): WarpState { - return deserializeCheckpointStateEnvelope(envelope, { codec: this._codec }); + private async _readAppliedVV( + artifacts: Readonly>, + ): Promise { + const artifact = artifacts['appliedVV.cbor']; + if (artifact === undefined) { + return null; + } + return VersionVector.from( + this._codec.decode>(await this._readPayload(artifact)), + ); } - private _decodeFrontier(buffer: Uint8Array): Map { - const obj = this._codec.decode>(buffer); - const frontier = new Map(); - for (const [k, v] of Object.entries(obj)) { - frontier.set(k, v); + private async _readProvenanceIndex( + artifacts: Readonly>, + ): Promise { + const artifact = artifacts['provenanceIndex.cbor']; + if (artifact === undefined) { + return null; } - return frontier; + return ProvenanceIndex.deserialize(await this._readPayload(artifact), { codec: this._codec }); } - private _decodeAppliedVV(buffer: Uint8Array): VersionVector { - const obj = this._codec.decode>(buffer); - return VersionVector.from(obj); + private async _readPayload(artifact: CheckpointArtifact): Promise { + if (artifact.kind === 'asset') { + return await collectAsyncIterable(this._assets.open(artifact.handle)); + } + return await this._legacyArtifacts.read(artifact.oid); } - private async _resolveCheckpointWrites(writes: CheckpointWritePromises): Promise { - const [ - nodeAliveBlobOid, - edgeAliveBlobOid, - propBlobOid, - observedFrontierBlobOid, - edgeBirthEventBlobOid, - frontierBlobOid, - appliedVVBlobOid, - provenanceIndexBlobOid, - ] = await Promise.all([ - writes.nodeAliveBlobOid, - writes.edgeAliveBlobOid, - writes.propBlobOid, - writes.observedFrontierBlobOid, - writes.edgeBirthEventBlobOid, - writes.frontierBlobOid, - writes.appliedVVBlobOid, - writes.provenanceIndexBlobOid, - ]); +} - return { - nodeAliveBlobOid, - edgeAliveBlobOid, - propBlobOid, - observedFrontierBlobOid, - edgeBirthEventBlobOid, - frontierBlobOid, - appliedVVBlobOid, - provenanceIndexBlobOid, - }; - } +function legacyCheckpointArtifact(oid: string): CheckpointArtifact { + return Object.freeze({ kind: 'legacy-object', oid }); +} - private async _resolveCheckpointReads(reads: CheckpointReadPromises): Promise { - const [ - nodeAlive, - edgeAlive, - prop, - observedFrontier, - edgeBirthEvent, - frontier, - appliedVV, - provenanceIndex, - ] = await Promise.all([ - reads.nodeAlive, - reads.edgeAlive, - reads.prop, - reads.observedFrontier, - reads.edgeBirthEvent, - reads.frontier, - reads.appliedVV, - reads.provenanceIndex, - ]); +function requireArtifact( + checkpointSha: string, + artifacts: Readonly>, + path: string, +): CheckpointArtifact { + const artifact = artifacts[path]; + if (artifact !== undefined) { + return artifact; + } + throw new PersistenceError( + `Checkpoint ${checkpointSha} missing ${path}`, + 'E_CHECKPOINT_MISSING_ARTIFACT', + { context: { checkpointSha, path } }, + ); +} - return { - nodeAlive, - edgeAlive, - prop, - observedFrontier, - edgeBirthEvent, - frontier, - appliedVV, - provenanceIndex, - }; +function requirePublishedBundle(publishedToken: string, expected: BundleHandle): void { + if (publishedToken !== expected.toString()) { + throw new PersistenceError( + 'Checkpoint publication returned a different bundle handle', + 'E_CHECKPOINT_PUBLICATION_MISMATCH', + { context: { expected: expected.toString(), actual: publishedToken } }, + ); } +} - private _writeCheckpointBlob(bytes: Uint8Array): Promise { - return this._blobPort.writeBlob(bytes); +function requireRetainedBundle(retainedToken: string, expected: BundleHandle): void { + if (retainedToken !== expected.toString()) { + throw new PersistenceError( + 'Checkpoint retention evidence names a different bundle handle', + 'E_CHECKPOINT_RETENTION_MISMATCH', + { context: { expected: expected.toString(), actual: retainedToken } }, + ); } +} + +function unsupportedCheckpointSchema(checkpointSha: string, schema: number): PersistenceError { + return new PersistenceError( + `Checkpoint ${checkpointSha} is schema:${schema}. ` + + `Only schema:${CURRENT_CHECKPOINT_SCHEMA} checkpoints are supported by the shipped runtime. ` + + 'Run `npm run upgrade -- --graph ` before loading this graph.', + 'E_CHECKPOINT_UNSUPPORTED_SCHEMA', + { context: { checkpointSha, schema } }, + ); +} - private _readCheckpointBlob(oid: string): Promise { - return this._blobPort.readBlob(oid); +function requireCheckpointGraph( + checkpointSha: string, + actualGraphName: string, + expectedGraphName: string | undefined, +): void { + if (expectedGraphName === undefined || actualGraphName === expectedGraphName) { + return; } + throw new PersistenceError( + `Checkpoint ${checkpointSha} belongs to graph ${actualGraphName}, not ${expectedGraphName}`, + 'E_CHECKPOINT_GRAPH_MISMATCH', + { context: { checkpointSha, actualGraphName, expectedGraphName } }, + ); +} + +function hasEntries(record: Readonly>): boolean { + return Object.keys(record).length > 0; +} - private _requireTreeOid(treeOids: Record, path: string): string { - const oid = treeOids[path]; - if (oid === undefined) { - throw new WarpError(`Checkpoint missing ${path}`, 'E_MISSING_ARTIFACT'); +function decodeFrontier(value: unknown, checkpointSha: string): Map { + if (value === null + || typeof value !== 'object' + || Array.isArray(value) + || !isRecordPrototype(Object.getPrototypeOf(value))) { + throw invalidFrontier(checkpointSha); + } + const frontier = new Map(); + for (const [writerId, sha] of Object.entries(value)) { + if (writerId.length === 0 || typeof sha !== 'string' || sha.length === 0) { + throw invalidFrontier(checkpointSha); } - return oid; + frontier.set(writerId, sha); } + return frontier; +} + +function isRecordPrototype(value: object | null): boolean { + return value === Object.prototype || value === null; +} + +function invalidFrontier(checkpointSha: string): PersistenceError { + return new PersistenceError( + `Checkpoint ${checkpointSha} has an invalid frontier`, + 'E_CHECKPOINT_INVALID_FRONTIER', + { context: { checkpointSha } }, + ); } diff --git a/src/infrastructure/adapters/CborIndexStoreAdapter.ts b/src/infrastructure/adapters/CborIndexStoreAdapter.ts index dc500533..cc356f5a 100644 --- a/src/infrastructure/adapters/CborIndexStoreAdapter.ts +++ b/src/infrastructure/adapters/CborIndexStoreAdapter.ts @@ -1,4 +1,6 @@ +import type { BundleCapability } from '@git-stunts/git-cas'; import IndexStorePort from '../../ports/IndexStorePort.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import type CodecValue from '../../domain/types/codec/CodecValue.ts'; import WarpError from '../../domain/errors/WarpError.ts'; @@ -10,18 +12,14 @@ import { PropertyShard } from '../../domain/artifacts/PropertyShard.ts'; import { ReceiptShard } from '../../domain/artifacts/ReceiptShard.ts'; import type { IndexShard } from '../../domain/artifacts/IndexShard.ts'; import { IndexShardEncodeTransform } from './IndexShardEncodeTransform.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; -import { readPayloadBlob, writePayloadBlob } from './CasPayloadPointer.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import BundleHandle from '../../domain/storage/BundleHandle.ts'; +import IndexError from '../../domain/errors/IndexError.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; -interface BlobPort { - readBlob(oid: string): Promise; - writeBlob(content: Uint8Array | string): Promise; -} - -interface TreePort { - readTreeOids(treeOid: string): Promise>; - writeTree(entries: string[]): Promise; -} +export type GitCasIndexFacade = { + readonly bundles: Pick; +}; function classifyMeta(match: RegExpMatchArray, data: unknown): MetaShard { const d = data as { nodeToGlobal: Array<[string, number]>; nextLocalId: number; alive: Uint8Array }; @@ -75,97 +73,125 @@ const SHARD_CLASSIFIERS: ReadonlyArray<{ pattern: RegExp; classify: (match: RegE /** * CBOR-backed implementation of IndexStorePort. * - * Owns the codec and raw Git persistence. Domain services produce - * IndexShard streams; the adapter encodes, writes blobs, and - * assembles Git trees. On read, the adapter decodes blobs and + * Owns the codec while configured asset and bundle capabilities own + * persistence. Domain services produce IndexShard streams; the adapter + * encodes and stages assets, then assembles their opaque handles into + * ordered bundles. On read, the adapter decodes assets and * constructs IndexShard subclass instances. * * Write pipeline reuses existing infrastructure transforms: * WarpStream * → IndexShardEncodeTransform → [path, bytes] - * → GitBlobWriteTransform → [path, oid] - * → TreeAssemblerSink → tree OID + * → AssetStoragePort.stage → [path, AssetHandle] + * → bundles.putOrdered → BundleHandle */ export class CborIndexStoreAdapter extends IndexStorePort { private readonly _codec: CodecPort; - private readonly _blobPort: BlobPort; - private readonly _treePort: TreePort; - private readonly _blobStorage: BlobStoragePort | null; + private readonly _assets: AssetStoragePort; + private readonly _cas: GitCasIndexFacade; - constructor({ codec, blobPort, treePort, blobStorage }: { + constructor({ codec, assetStorage, cas }: { codec: CodecPort; - blobPort: BlobPort; - treePort: TreePort; - blobStorage?: BlobStoragePort | null; + assetStorage: AssetStoragePort; + cas: GitCasIndexFacade; }) { super(); _requireDep(codec, 'codec'); - _requireDep(blobPort, 'blobPort'); - _requireDep(treePort, 'treePort'); + _requireDep(assetStorage, 'assetStorage'); + _requireDep(cas, 'cas'); this._codec = codec; - this._blobPort = blobPort; - this._treePort = treePort; - this._blobStorage = blobStorage ?? null; + this._assets = assetStorage; + this._cas = cas; } - override async writeShards(shardStream: WarpStream): Promise { - const entries: string[] = []; - await shardStream - .pipe(new IndexShardEncodeTransform(this._codec)) - .forEach(async ([path, bytes]) => { - const oid = await writePayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - bytes, - options: { - slug: path, - mime: 'application/cbor', - size: bytes.length, - }, - }); - entries.push(`100644 blob ${oid}\t${path}`); + override async writeShards(shardStream: WarpStream): Promise { + const members: Array<[string, string]> = []; + for await (const [path, bytes] of shardStream.pipe(new IndexShardEncodeTransform(this._codec))) { + const staged = await this._assets.stage(WarpStream.from([bytes]), { + slug: `index-shard-${path}`, + filename: path, + expectedSize: bytes.byteLength, }); - entries.sort(); - return await this._treePort.writeTree(entries); + members.push([path, staged.handle.toString()]); + } + members.sort(([left], [right]) => left.localeCompare(right)); + const bundle = await this._cas.bundles.putOrdered({ members }); + return new BundleHandle(bundle.handle.toString()); } - override scanShards(treeOid: string): WarpStream { + override scanShards(indexHandle: BundleHandle): WarpStream { const adapter = this; return WarpStream.from((async function* () { - const oids = await adapter._treePort.readTreeOids(treeOid); - const paths = Object.keys(oids).sort(); - - for (const path of paths) { - const shard = tryClassifyPath(path); + const seenPaths = new Set(); + for await (const member of adapter._cas.bundles.iterateMembers({ + handle: indexHandle.toString(), + })) { + requireUniqueBundleMember(seenPaths, member.path); + const handle = requireAssetMember( + member.path, + member.handle.kind, + member.handle.toString(), + ); + const shard = tryClassifyPath(member.path); if (shard === null) { continue; } - const blobOid = oids[path] as string; - const bytes = await readPayloadBlob({ - blobPort: adapter._blobPort, - blobStorage: adapter._blobStorage, - oid: blobOid, - }); + const bytes = await collectAsyncIterable(adapter._assets.open(handle)); const data = adapter._codec.decode(bytes); yield shard(data); } })()); } - override async readShardOids(treeOid: string): Promise> { - return await this._treePort.readTreeOids(treeOid); + override async readShardHandles( + indexHandle: BundleHandle, + ): Promise>> { + const entries: Array<[string, AssetHandle]> = []; + const seenPaths = new Set(); + for await (const member of this._cas.bundles.iterateMembers({ + handle: indexHandle.toString(), + })) { + requireUniqueBundleMember(seenPaths, member.path); + entries.push([ + member.path, + requireAssetMember(member.path, member.handle.kind, member.handle.toString()), + ]); + } + return Object.freeze(Object.fromEntries(entries)); } - override async decodeShard(blobOid: string): Promise { - const bytes = await readPayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - oid: blobOid, - }); + override openShard(shardHandle: AssetHandle): AsyncIterable { + return this._assets.open(shardHandle); + } + + override async decodeShard( + shardHandle: AssetHandle, + ): Promise { + const bytes = await collectAsyncIterable(this._assets.open(shardHandle)); return this._codec.decode(bytes); } } +function requireAssetMember(path: string, kind: string, token: string): AssetHandle { + if (kind !== 'asset') { + throw new IndexError(`Index bundle member is not an asset: ${path}`, { + code: 'E_INDEX_INVALID_BUNDLE_MEMBER', + context: { path, kind }, + }); + } + return new AssetHandle(token); +} + +function requireUniqueBundleMember(seenPaths: Set, path: string): void { + if (seenPaths.has(path)) { + throw new IndexError(`Index bundle contains a duplicate member: ${path}`, { + code: 'E_INDEX_DUPLICATE_BUNDLE_MEMBER', + context: { path }, + }); + } + seenPaths.add(path); +} + function tryClassifyPath(path: string): ((data: unknown) => IndexShard) | null { for (const { pattern, classify } of SHARD_CLASSIFIERS) { const match = path.match(pattern); diff --git a/src/infrastructure/adapters/CborPatchJournalAdapter.ts b/src/infrastructure/adapters/CborPatchJournalAdapter.ts index 6451cf67..a6bb1289 100644 --- a/src/infrastructure/adapters/CborPatchJournalAdapter.ts +++ b/src/infrastructure/adapters/CborPatchJournalAdapter.ts @@ -1,203 +1,204 @@ -import PatchJournalPort, { type ReadPatchOptions } from '../../ports/PatchJournalPort.ts'; -import WarpError from '../../domain/errors/WarpError.ts'; -import WarpStream from '../../domain/stream/WarpStream.ts'; +import type { + BundleCapability, + PublicationCapability, +} from '@git-stunts/git-cas'; import PatchEntry from '../../domain/artifacts/PatchEntry.ts'; -import { hydrateDecodedPatch } from '../../domain/services/PatchHydrator.ts'; +import PatchPublicationConflictError from '../../domain/errors/PatchPublicationConflictError.ts'; import SyncError from '../../domain/errors/SyncError.ts'; -import EncryptionError from '../../domain/errors/EncryptionError.ts'; +import WarpError from '../../domain/errors/WarpError.ts'; +import { hydrateDecodedPatch } from '../../domain/services/PatchHydrator.ts'; +import type AssetHandle from '../../domain/storage/AssetHandle.ts'; +import BundleHandle from '../../domain/storage/BundleHandle.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; import type Patch from '../../domain/types/Patch.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import { - DEFAULT_COMMIT_MESSAGE_CODEC, -} from './TrailerCommitMessageCodecAdapter.ts'; -import { - LEGACY_EXTERNAL_PATCH_STORAGE, - LEGACY_GIT_BLOB_PATCH_STORAGE, - type PatchStorageRoute, + createGitCasPatchStorage, + type PatchCommitMessage, type default as CommitMessageCodecPort, } from '../../ports/CommitMessageCodecPort.ts'; +import PatchJournalPort, { + type AppendPatchRequest, + type PublishedPatch, +} from '../../ports/PatchJournalPort.ts'; import { CURRENT_SUBSTRATE_ONLY_POLICY, type SubstrateCompatibilityPolicyValue, } from './SubstrateCompatibilityPolicy.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from './TrailerCommitMessageCodecAdapter.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; +import { requireAdapterDependency } from './AdapterDependencyGuard.ts'; +import { readGitCasErrorCode } from './GitCasErrorCode.ts'; -interface BlobPort { - readBlob(oid: string): Promise; - writeBlob(content: Uint8Array | string): Promise; -} +type CommitInfo = { + sha: string; + message: string; + author: string; + date: string; + parents: string[]; +}; -interface CommitPort { - getNodeInfo(sha: string): Promise<{ sha: string; message: string; author: string; date: string; parents: string[] }>; -} +type CommitReader = { + getNodeInfo(sha: string): Promise; +}; -/** - * CBOR-backed implementation of PatchJournalPort. - * - * Owns the codec and raw blob persistence. Domain services pass Patch - * objects in and get Patch objects back — no bytes leak across the - * port boundary. - * - * Supports both plain Git blob storage (BlobPort) and encrypted external - * storage (BlobStoragePort) via the optional `patchBlobStorage` parameter. - */ +export type GitCasPatchFacade = { + readonly bundles: Pick; + readonly publications: Pick; +}; + +/** CBOR patch codec over git-cas asset, bundle, and publication capabilities. */ export class CborPatchJournalAdapter extends PatchJournalPort { - private readonly _codec: CodecPort; - private readonly _blobPort: BlobPort; - private readonly _commitPort: CommitPort | null; - private readonly _blobStorage: BlobStoragePort | null; - private readonly _legacyPatchBlobStorage: BlobStoragePort | null; - private readonly _writeStorage: PatchStorageRoute; - private readonly _commitMessageCodec: CommitMessageCodecPort; - private readonly _compatibilityPolicy: SubstrateCompatibilityPolicyValue; + readonly #assetStorage: AssetStoragePort; + readonly #cas: GitCasPatchFacade; + readonly #codec: CodecPort; + readonly #commitMessageCodec: CommitMessageCodecPort; + readonly #commitReader: CommitReader; + readonly #compatibilityPolicy: SubstrateCompatibilityPolicyValue; + readonly #encrypted: boolean; - constructor({ codec, blobPort, commitPort, patchBlobStorage, blobStorage, legacyPatchBlobStorage, writeStorage, commitMessageCodec, compatibilityPolicy }: { - codec: CodecPort; - blobPort: BlobPort; - commitPort?: CommitPort; - patchBlobStorage?: BlobStoragePort | null; - blobStorage?: BlobStoragePort | null; - legacyPatchBlobStorage?: BlobStoragePort | null; - writeStorage?: PatchStorageRoute; - commitMessageCodec?: CommitMessageCodecPort; - compatibilityPolicy?: SubstrateCompatibilityPolicyValue; + constructor(options: { + readonly assetStorage: AssetStoragePort; + readonly cas: GitCasPatchFacade; + readonly codec: CodecPort; + readonly commitReader: CommitReader; + readonly commitMessageCodec?: CommitMessageCodecPort; + readonly compatibilityPolicy?: SubstrateCompatibilityPolicyValue; + readonly encrypted?: boolean; }) { super(); - if (codec === null || codec === undefined) { - throw new WarpError('CborPatchJournalAdapter requires a codec', 'E_INVALID_DEPENDENCY'); - } - if (blobPort === null || blobPort === undefined) { - throw new WarpError('CborPatchJournalAdapter requires a blobPort', 'E_INVALID_DEPENDENCY'); - } - this._codec = codec; - this._blobPort = blobPort; - this._commitPort = commitPort ?? null; - this._blobStorage = blobStorage ?? null; - this._legacyPatchBlobStorage = legacyPatchBlobStorage ?? patchBlobStorage ?? null; - this._writeStorage = writeStorage ?? (patchBlobStorage !== null && patchBlobStorage !== undefined - ? LEGACY_EXTERNAL_PATCH_STORAGE - : LEGACY_GIT_BLOB_PATCH_STORAGE); - this._commitMessageCodec = commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC; - this._compatibilityPolicy = compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; + requireAdapterDependency(options.assetStorage, 'assetStorage'); + requireAdapterDependency(options.cas, 'cas'); + requireAdapterDependency(options.codec, 'codec'); + requireAdapterDependency(options.commitReader, 'commitReader'); + this.#assetStorage = options.assetStorage; + this.#cas = options.cas; + this.#codec = options.codec; + this.#commitReader = options.commitReader; + this.#commitMessageCodec = options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC; + this.#compatibilityPolicy = options.compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; + this.#encrypted = options.encrypted ?? false; } - override async writePatch(patch: Patch): Promise { - const bytes = this._codec.encode(patch); - if (this._writeStorage.strategy === 'git-cas') { - if (this._blobStorage === null) { - throw new WarpError('CborPatchJournalAdapter requires blobStorage for git-cas patch writes', 'E_INVALID_DEPENDENCY'); - } - return await this._blobStorage.store(bytes, { - slug: `patch-${patch.writer}-${patch.lamport}`, + override async appendPatch(request: AppendPatchRequest): Promise { + const stagedPatch = await this.#assetStorage.stage(WarpStream.from([ + this.#codec.encode(request.patch), + ]), { + slug: `patch-${request.writer}-${request.patch.lamport}`, + filename: 'patch.cbor', + }); + const bundle = await this.#cas.bundles.putOrdered({ + members: patchBundleMembers(stagedPatch.handle, request.attachments), + }); + const message = this.#commitMessageCodec.encodePatch({ + kind: 'patch', + graph: request.graph, + writer: request.writer, + lamport: request.patch.lamport, + patchHandle: stagedPatch.handle, + schema: request.patch.schema, + storage: createGitCasPatchStorage({ encrypted: this.#encrypted }), + }); + const publication = await this.#publishBundle(bundle.handle, message, request); + return Object.freeze({ + sha: publication.commitId, + bundleHandle: new BundleHandle(publication.root.toString()), + stagedPatch, + retention: adaptGitCasRetentionWitness(publication.witness.toJSON()), + }); + } + + async #publishBundle( + root: Parameters[0]['root'], + message: string, + request: AppendPatchRequest, + ): Promise>> { + try { + return await this.#cas.publications.commit({ + root, + commit: { + message, + parents: request.parent === null ? [] : [request.parent], + }, + ref: { name: request.targetRef, expected: request.expectedHead }, }); - } - if (this._writeStorage.strategy === 'legacy-external-storage') { - if (this._legacyPatchBlobStorage === null) { - throw new WarpError('CborPatchJournalAdapter requires legacyPatchBlobStorage for external patch writes', 'E_INVALID_DEPENDENCY'); + } catch (error) { + if (readGitCasErrorCode(error) !== 'PUBLICATION_CONFLICT') { + throw error; } - return await this._legacyPatchBlobStorage.store(bytes); + throw new PatchPublicationConflictError( + error instanceof Error ? error : undefined, + ); } - return await this._blobPort.writeBlob(bytes); } - override async readPatch( - patchOid: string, - { storage, encrypted = false }: ReadPatchOptions = {}, - ): Promise { - 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) { - throw new EncryptionError( - `Patch ${patchOid} is stored via git-cas but no blobStorage is configured`, - ); + override async readPatch(message: PatchCommitMessage): Promise { + this.#requireReadableStorage(message); + const handle = message.patchHandle; + const bytes = await collectAsyncIterable(this.#assetStorage.open(handle)); + return hydrateDecodedPatch(this.#codec.decode(bytes)); + } + + override scanPatchRange( + writerId: string, + fromSha: string | null, + toSha: string, + ): WarpStream { + const adapter = this; + return WarpStream.from((async function* (): AsyncGenerator { + const stack: Array<{ sha: string; message: PatchCommitMessage }> = []; + let current: string | null = toSha; + while (current !== null && current !== fromSha) { + const node = await adapter.#commitReader.getNodeInfo(current); + if (adapter.#commitMessageCodec.detectKind(node.message) !== 'patch') { + break; + } + stack.push({ sha: current, message: adapter.#commitMessageCodec.decodePatch(node.message) }); + current = node.parents[0] ?? null; } - bytes = await this._blobStorage.retrieve(patchOid); - } else if (resolvedStorage.strategy === 'legacy-external-storage') { - if (this._legacyPatchBlobStorage === null) { - throw new EncryptionError( - `Patch ${patchOid} is encrypted but no legacy patchBlobStorage is configured`, + if (fromSha !== null && current !== fromSha) { + throw new SyncError( + `Divergence detected: ${toSha} does not descend from ${fromSha} for writer ${writerId}`, + { code: 'E_SYNC_DIVERGENCE', context: { writerId, fromSha, toSha } }, ); } - bytes = await this._legacyPatchBlobStorage.retrieve(patchOid); - } else { - bytes = await this._blobPort.readBlob(patchOid); - } - return hydrateDecodedPatch(this._codec.decode(bytes)); + for (let index = stack.length - 1; index >= 0; index--) { + const entry = stack[index]; + if (entry !== undefined) { + yield new PatchEntry({ patch: await adapter.readPatch(entry.message), sha: entry.sha }); + } + } + })()); } - private _requireReadableStorage(storage: PatchStorageRoute): void { - if (storage.strategy === 'git-cas' || this._isConfiguredWriteRoute(storage)) { + #requireReadableStorage(message: PatchCommitMessage): void { + if (message.storage.strategy === 'git-cas-asset') { return; } - if (this._compatibilityPolicy.legacyPatchStorageReads) { + if (this.#compatibilityPolicy.legacyPatchStorageReads) { return; } throw new WarpError( - `Legacy patch storage reads require the substrate migration compatibility policy: ${storage.strategy}`, + `Legacy patch storage reads require the substrate migration compatibility policy: ${message.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; - } - - /** - * Scans patches in a writer's chain between two SHAs, yielding - * PatchEntry instances in chronological order (oldest first). - */ - scanPatchRange(writerId: string, fromSha: string | null, toSha: string): WarpStream { - const adapter = this; - return WarpStream.from( - (async function* (): AsyncGenerator { - if (adapter._commitPort === null) { - throw new SyncError('scanPatchRange requires commitPort on the adapter', { - code: 'E_MISSING_COMMIT_PORT', - context: { writerId }, - }); - } - const commitPort = adapter._commitPort; - - const stack: Array<{ sha: string; patchOid: string; storage: PatchStorageRoute }> = []; - let cur: string | null = toSha; - - while (cur !== null && cur !== fromSha) { - const nodeInfo = await commitPort.getNodeInfo(cur); - const kind = adapter._commitMessageCodec.detectKind(nodeInfo.message); - if (kind !== 'patch') { - break; - } - const meta = adapter._commitMessageCodec.decodePatch(nodeInfo.message); - stack.push({ sha: cur, patchOid: meta.patchOid, storage: meta.storage }); - - const parent = Array.isArray(nodeInfo.parents) && nodeInfo.parents.length > 0 - ? (nodeInfo.parents[0] ?? null) - : null; - cur = parent; - } - - if (fromSha !== null && fromSha !== undefined && fromSha.length > 0 && cur === null) { - throw new SyncError( - `Divergence detected: ${toSha} does not descend from ${fromSha} for writer ${writerId}`, - { code: 'E_SYNC_DIVERGENCE', context: { writerId, fromSha, toSha } }, - ); - } - - for (let i = stack.length - 1; i >= 0; i--) { - const entry = stack[i]; - if (entry === undefined) { - continue; - } - const patch = await adapter.readPatch(entry.patchOid, { storage: entry.storage }); - yield new PatchEntry({ patch, sha: entry.sha }); - } - })(), - ); +function patchBundleMembers( + patch: AssetHandle, + attachments: readonly AssetHandle[], +): WarpStream<[string, string]> { + const members: Array<[string, string]> = []; + const unique = [...new Set(attachments.map((handle) => handle.toString()))].sort(); + for (let index = 0; index < unique.length; index++) { + const handle = unique[index]; + if (handle !== undefined) { + members.push([`attachments/${String(index).padStart(8, '0')}`, handle]); + } } + members.push(['patch', patch.toString()]); + return WarpStream.from(members); } diff --git a/src/infrastructure/adapters/CheckpointBundleArtifactStager.ts b/src/infrastructure/adapters/CheckpointBundleArtifactStager.ts new file mode 100644 index 00000000..e4b64add --- /dev/null +++ b/src/infrastructure/adapters/CheckpointBundleArtifactStager.ts @@ -0,0 +1,83 @@ +import VersionVector from '../../domain/crdt/VersionVector.ts'; +import WarpError from '../../domain/errors/WarpError.ts'; +import type { CheckpointStateEnvelopeBuffers } from '../../domain/services/state/CheckpointSerializer.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import type { CheckpointRecord } from '../../ports/CheckpointStorePort.ts'; +import type CodecPort from '../../ports/CodecPort.ts'; + +/** Stages deterministic checkpoint members without retaining artifact bytes in the adapter. */ +export async function* stageCheckpointBundleArtifacts(options: { + readonly assets: AssetStoragePort; + readonly codec: CodecPort; + readonly envelope: CheckpointStateEnvelopeBuffers; + readonly record: CheckpointRecord; +}): AsyncGenerator<[string, string]> { + for (const [path, bytes] of checkpointArtifacts(options)) { + const staged = await options.assets.stage(WarpStream.from([bytes]), { + slug: checkpointArtifactSlug(options.record.graphName, path), + filename: path, + expectedSize: bytes.byteLength, + }); + yield [path, staged.handle.toString()]; + } +} + +function checkpointArtifacts(options: { + readonly codec: CodecPort; + readonly envelope: CheckpointStateEnvelopeBuffers; + readonly record: CheckpointRecord; +}): readonly [string, Uint8Array][] { + const { codec, envelope, record } = options; + const artifacts: Array<[string, Uint8Array]> = [ + ['appliedVV.cbor', codec.encode(VersionVector.serialize(record.appliedVV))], + ['frontier.cbor', encodeFrontier(record.frontier, codec)], + ['state/edgeAlive', envelope.edgeAlive], + ['state/edgeBirthEvent.cbor', envelope.edgeBirthEvent], + ['state/nodeAlive', envelope.nodeAlive], + ['state/observedFrontier.cbor', envelope.observedFrontier], + ['state/prop.cbor', envelope.prop], + ]; + appendProvenanceArtifact(artifacts, record, codec); + appendIndexArtifacts(artifacts, record); + return Object.freeze(artifacts.sort(([left], [right]) => left.localeCompare(right))); +} + +function appendProvenanceArtifact( + artifacts: Array<[string, Uint8Array]>, + record: CheckpointRecord, + codec: CodecPort, +): void { + if (record.provenanceIndex !== null && record.provenanceIndex !== undefined) { + artifacts.push(['provenanceIndex.cbor', record.provenanceIndex.serialize({ codec })]); + } +} + +function appendIndexArtifacts( + artifacts: Array<[string, Uint8Array]>, + record: CheckpointRecord, +): void { + const { indexShards } = record; + if (indexShards === null || indexShards === undefined) { + return; + } + for (const path of Object.keys(indexShards).sort()) { + const bytes = indexShards[path]; + if (bytes === undefined) { + throw new WarpError( + `Missing index shard for path: ${path}`, + 'E_CHECKPOINT_MISSING_INDEX_SHARD', + ); + } + artifacts.push([`index/${path}`, bytes]); + } +} + +function encodeFrontier(frontier: Map, codec: CodecPort): Uint8Array { + const entries = [...frontier.entries()].sort(([left], [right]) => left.localeCompare(right)); + return codec.encode(Object.fromEntries(entries)); +} + +function checkpointArtifactSlug(graphName: string, path: string): string { + return `checkpoint-${graphName}-${path}`.replace(/[^A-Za-z0-9._-]/gu, '-'); +} diff --git a/src/infrastructure/adapters/CheckpointCommitMessageBundleCodec.ts b/src/infrastructure/adapters/CheckpointCommitMessageBundleCodec.ts new file mode 100644 index 00000000..85b0947b --- /dev/null +++ b/src/infrastructure/adapters/CheckpointCommitMessageBundleCodec.ts @@ -0,0 +1,55 @@ +import BundleHandle from '../../domain/storage/BundleHandle.ts'; +import MessageCodecError from '../../domain/errors/MessageCodecError.ts'; +import type { CheckpointCommitMessage } from '../../ports/CommitMessageCodecPort.ts'; + +export type EncodedCheckpointCommitMessage = Omit & { + readonly bundleHandle: string | null; +}; + +/** Lowers a storage-neutral checkpoint message into trailer-safe scalar fields. */ +export function encodeCheckpointBundleHandle( + message: CheckpointCommitMessage, + checkpointVersion: string, +): EncodedCheckpointCommitMessage { + const encoded = { + ...message, + checkpointVersion: message.checkpointVersion ?? checkpointVersion, + bundleHandle: message.bundleHandle?.toString() ?? null, + }; + requireCheckpointBundleBinding(encoded, checkpointVersion); + return encoded; +} + +/** Adds the optional bundle locator without emitting an empty trailer. */ +export function checkpointBundleTrailer( + key: string, + handle: string | null, +): Readonly> { + return handle === null ? Object.freeze({}) : Object.freeze({ [key]: handle }); +} + +/** Restores the opaque bundle handle after scalar trailer validation. */ +export function decodeCheckpointBundleHandle( + message: EncodedCheckpointCommitMessage, +): CheckpointCommitMessage { + return { + ...message, + bundleHandle: message.bundleHandle === null ? null : new BundleHandle(message.bundleHandle), + }; +} + +function requireCheckpointBundleBinding( + message: EncodedCheckpointCommitMessage, + currentVersion: string, +): void { + if (message.checkpointVersion === currentVersion && message.bundleHandle === null) { + throw invalidBinding(`${currentVersion} checkpoint storage requires a bundle handle`); + } + if (message.bundleHandle !== null && message.checkpointVersion !== currentVersion) { + throw invalidBinding('Checkpoint bundle handles require the current storage version'); + } +} + +function invalidBinding(message: string): MessageCodecError { + return new MessageCodecError(message, { code: 'E_MESSAGE_CODEC' }); +} diff --git a/src/infrastructure/adapters/CheckpointStorageFormatClassifier.ts b/src/infrastructure/adapters/CheckpointStorageFormatClassifier.ts new file mode 100644 index 00000000..79e68b81 --- /dev/null +++ b/src/infrastructure/adapters/CheckpointStorageFormatClassifier.ts @@ -0,0 +1,62 @@ +import PersistenceError from '../../domain/errors/PersistenceError.ts'; +import type BundleHandle from '../../domain/storage/BundleHandle.ts'; +import { + CHECKPOINT_STORAGE_FORMAT, + LEGACY_CHECKPOINT_STORAGE_FORMAT, + type CheckpointCommitMessage, +} from '../../ports/CommitMessageCodecPort.ts'; + +const READABLE_LEGACY_STORAGE = new Set([ + null, + LEGACY_CHECKPOINT_STORAGE_FORMAT, +]); + +export type CheckpointStorageLayout = + | Readonly<{ kind: 'bundle'; handle: BundleHandle }> + | Readonly<{ kind: 'legacy' }>; + +const LEGACY_CHECKPOINT_STORAGE: Readonly<{ kind: 'legacy' }> = Object.freeze({ + kind: 'legacy', +}); + +/** Classifies supported checkpoint storage without opening any payloads. */ +export function classifyCheckpointStorage( + checkpointSha: string, + metadata: CheckpointCommitMessage, +): CheckpointStorageLayout { + if (metadata.bundleHandle !== null) { + if (metadata.checkpointVersion !== CHECKPOINT_STORAGE_FORMAT) { + throw unsupportedCheckpointStorage(checkpointSha, metadata.checkpointVersion); + } + return Object.freeze({ kind: 'bundle', handle: metadata.bundleHandle }); + } + return classifyCheckpointWithoutBundle(checkpointSha, metadata.checkpointVersion); +} + +function classifyCheckpointWithoutBundle( + checkpointSha: string, + storageVersion: string | null, +): Readonly<{ kind: 'legacy' }> { + if (storageVersion === CHECKPOINT_STORAGE_FORMAT) { + throw new PersistenceError( + `Checkpoint ${checkpointSha} is missing its bundle handle`, + 'E_CHECKPOINT_MISSING_BUNDLE_HANDLE', + { context: { checkpointSha } }, + ); + } + if (READABLE_LEGACY_STORAGE.has(storageVersion)) { + return LEGACY_CHECKPOINT_STORAGE; + } + throw unsupportedCheckpointStorage(checkpointSha, storageVersion); +} + +function unsupportedCheckpointStorage( + checkpointSha: string, + storageVersion: string | null, +): PersistenceError { + return new PersistenceError( + `Checkpoint ${checkpointSha} uses unsupported storage:${storageVersion ?? '(unspecified)'}`, + 'E_CHECKPOINT_UNSUPPORTED_STORAGE', + { context: { checkpointSha, storageVersion } }, + ); +} diff --git a/src/infrastructure/adapters/GitCasAssetStorageAdapter.ts b/src/infrastructure/adapters/GitCasAssetStorageAdapter.ts new file mode 100644 index 00000000..37a5748f --- /dev/null +++ b/src/infrastructure/adapters/GitCasAssetStorageAdapter.ts @@ -0,0 +1,188 @@ +import { + AssetHandle as GitCasAssetHandle, + type AssetCapability, + type AssetPutOptions, + type StagedAsset as GitCasStagedAsset, +} from '@git-stunts/git-cas'; +import PersistenceError from '../../domain/errors/PersistenceError.ts'; +import AssetSizeMismatchError from '../../domain/errors/AssetSizeMismatchError.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; +import AssetStoragePort, { + type AssetWriteOptions, + type StagedAsset, +} from '../../ports/AssetStoragePort.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; +import CasContentEncryptionPolicy, { + mapCasContentEncryptionError, +} from './CasContentEncryptionPolicy.ts'; +import { readGitCasErrorCode } from './GitCasErrorCode.ts'; + +const OID_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u; +const LEGACY_REFERENCE_CODES = new Set([ + 'GIT_ERROR', + 'GIT_OBJECT_NOT_FOUND', + 'HANDLE_TARGET_MISSING', + 'MANIFEST_NOT_FOUND', +]); + +export type GitCasAssetFacade = { + readonly assets: AssetCapability; +}; + +export type LegacyAssetReader = { + readBlob(oid: string): Promise; +}; + +/** Delegates immutable asset lifecycle to the high-level git-cas asset API. */ +export default class GitCasAssetStorageAdapter extends AssetStoragePort { + readonly #cas: GitCasAssetFacade; + readonly #contentEncryption: CasContentEncryptionPolicy; + readonly #legacyReader: LegacyAssetReader; + readonly #compatibilityPolicy: SubstrateCompatibilityPolicyValue; + + constructor(options: { + readonly cas: GitCasAssetFacade; + readonly legacyReader: LegacyAssetReader; + readonly contentEncryption?: CasContentEncryptionPolicy; + readonly compatibilityPolicy?: SubstrateCompatibilityPolicyValue; + }) { + super(); + this.#cas = options.cas; + this.#legacyReader = options.legacyReader; + this.#contentEncryption = options.contentEncryption ?? CasContentEncryptionPolicy.disabled(); + this.#compatibilityPolicy = options.compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; + } + + override async stage( + source: AsyncIterable, + options: AssetWriteOptions, + ): Promise { + const putOptions: AssetPutOptions = { + source, + slug: options.slug, + filename: options.filename ?? 'content', + ...this.#contentEncryption.toStoreOptions(), + }; + const staged = await this.#cas.assets.put(putOptions); + requireExpectedSize(staged.asset.size, options.expectedSize); + return stagedAsset(staged); + } + + override async *open(handle: AssetHandle): AsyncIterable { + try { + yield* await this.#openResolved(handle); + } catch (error) { + const encryptionError = mapCasContentEncryptionError( + error, + 'asset-open', + this.#contentEncryption.enabled, + ); + if (encryptionError !== null) { + throw encryptionError; + } + throw error; + } + } + + async #openResolved(handle: AssetHandle): Promise> { + const token = handle.toString(); + if (!OID_PATTERN.test(token)) { + GitCasAssetHandle.parse(token); + return this.#cas.assets.open({ + handle: token, + ...this.#contentEncryption.toRestoreOptions(), + }); + } + return await this.#openLegacyReference(token); + } + + async #openLegacyReference(oid: string): Promise> { + let adopted: GitCasStagedAsset; + try { + adopted = await this.#cas.assets.adopt({ treeOid: oid }); + } catch (adoptionError) { + rethrowUnlessLegacyReference(adoptionError); + return await this.#openLegacyBlob(oid, adoptionError); + } + return this.#cas.assets.open({ + handle: adopted.handle, + ...this.#contentEncryption.toRestoreOptions(), + }); + } + + async #openLegacyBlob(oid: string, adoptionError: unknown): Promise> { + if (!this.#compatibilityPolicy.legacyContentBlobReads) { + throw new PersistenceError( + `Legacy raw blob reads require the substrate migration compatibility policy: ${oid}`, + 'E_LEGACY_SUBSTRATE_DISABLED', + { context: { oid } }, + ); + } + const bytes = await this.#readLegacyCandidate(oid, adoptionError); + return singleChunk(bytes); + } + + async #readLegacyCandidate(oid: string, cause: unknown): Promise { + const bytes = await this.#tryReadLegacyBlob(oid); + if (bytes !== null) { + return bytes; + } + throw missingObject(oid, cause); + } + + async #tryReadLegacyBlob(oid: string): Promise { + try { + const bytes = await this.#legacyReader.readBlob(oid); + return bytes ?? null; + } catch (error) { + rethrowUnexpectedLegacyReadError(error); + return null; + } + } +} + +function rethrowUnexpectedLegacyReadError(error: unknown): void { + if (!(error instanceof PersistenceError) || error.code !== PersistenceError.E_MISSING_OBJECT) { + throw error; + } +} + +function rethrowUnlessLegacyReference(error: unknown): void { + if (!LEGACY_REFERENCE_CODES.has(readGitCasErrorCode(error) ?? '')) { + throw error; + } +} + +function missingObject(oid: string, cause: unknown): PersistenceError { + return new PersistenceError( + `Missing Git object: ${oid}`, + PersistenceError.E_MISSING_OBJECT, + cause instanceof Error ? { context: { oid }, cause } : { context: { oid } }, + ); +} + +function stagedAsset(staged: GitCasStagedAsset): StagedAsset { + return Object.freeze({ + handle: new AssetHandle(staged.handle.toString()), + size: staged.asset.size, + observedAt: staged.observedAt, + retention: Object.freeze({ + reachability: staged.retention.reachability, + protection: staged.retention.protection, + }), + }); +} + +function requireExpectedSize(actualSize: number, expectedSize: number | null | undefined): void { + if (expectedSize !== null && expectedSize !== undefined && actualSize !== expectedSize) { + throw new AssetSizeMismatchError(expectedSize, actualSize); + } +} + +function singleChunk(bytes: Uint8Array): AsyncIterable { + return WarpStream.from([bytes]); +} diff --git a/src/infrastructure/adapters/GitCasAuditLogAdapter.ts b/src/infrastructure/adapters/GitCasAuditLogAdapter.ts new file mode 100644 index 00000000..8eb21e73 --- /dev/null +++ b/src/infrastructure/adapters/GitCasAuditLogAdapter.ts @@ -0,0 +1,196 @@ +import type { + AssetCapability, + PublicationCapability, +} from '@git-stunts/git-cas'; +import AuditError from '../../domain/errors/AuditError.ts'; +import AuditPublicationConflictError from '../../domain/errors/AuditPublicationConflictError.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; +import { buildAuditPrefix, buildAuditRef } from '../../domain/utils/RefLayout.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import AuditLogPort, { + type AppendAuditRecordRequest, + type AuditLogEntry, + type PublishedAuditRecord, +} from '../../ports/AuditLogPort.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; +import { readGitCasErrorCode } from './GitCasErrorCode.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; + +type AuditHistory = { + readRef(ref: string): Promise; + listRefs(prefix: string): Promise; + getNodeInfo(sha: string): Promise<{ + sha: string; + message: string; + parents: string[]; + }>; + getCommitTree(sha: string): Promise; + readTreeOids(treeOid: string): Promise>; + readBlob(oid: string): Promise; +}; + +type AuditCas = { + readonly assets: Pick; + readonly publications: Pick; +}; + +type AuditPublication = Awaited>; + +/** git-cas-backed audit receipt publication and legacy-read adapter. */ +export default class GitCasAuditLogAdapter extends AuditLogPort { + readonly #history: AuditHistory; + readonly #cas: AuditCas; + readonly #assets: AssetStoragePort; + readonly #compatibilityPolicy: SubstrateCompatibilityPolicyValue; + + constructor(options: { + readonly history: AuditHistory; + readonly cas: AuditCas; + readonly assets: AssetStoragePort; + readonly compatibilityPolicy?: SubstrateCompatibilityPolicyValue; + }) { + super(); + this.#history = options.history; + this.#cas = options.cas; + this.#assets = options.assets; + this.#compatibilityPolicy = options.compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; + } + + override async readHead(graphName: string, writerId: string): Promise { + return await this.#history.readRef(buildAuditRef(graphName, writerId)); + } + + override async listWriterIds(graphName: string): Promise { + const prefix = buildAuditPrefix(graphName); + const refs = await this.#history.listRefs(prefix); + return refs + .filter((ref) => ref.startsWith(prefix)) + .map((ref) => ref.slice(prefix.length)) + .filter((writerId) => writerId.length > 0); + } + + override async append(request: AppendAuditRecordRequest): Promise { + const stagedReceipt = await this.#assets.stage(WarpStream.from([request.receipt]), { + slug: `audit-${request.graphName}-${request.writerId}`, + filename: 'receipt.cbor', + expectedSize: request.receipt.byteLength, + }); + const publication = await publishAuditRecord( + this.#cas, + request, + stagedReceipt.handle.toString(), + ); + return Object.freeze({ + sha: publication.commitId, + stagedReceipt, + retention: adaptGitCasRetentionWitness(publication.witness.toJSON()), + }); + } + + override async readEntry(sha: string): Promise { + const node = await this.#history.getNodeInfo(sha); + const treeOid = await this.#history.getCommitTree(sha); + return Object.freeze({ + sha, + message: node.message, + parents: Object.freeze([...node.parents]), + receipt: await this.#readReceiptRoot(treeOid), + }); + } + + async #readReceiptRoot(treeOid: string): Promise { + try { + const staged = await this.#cas.assets.adopt({ treeOid }); + return await collectAsyncIterable( + this.#assets.open(new AssetHandle(staged.handle.toString())), + ); + } catch (assetError) { + rethrowUnlessLegacyReceiptTree(assetError); + return await this.#readLegacyReceiptTree(treeOid, assetError); + } + } + + async #readLegacyReceiptTree(treeOid: string, cause: unknown): Promise { + if (!this.#compatibilityPolicy.legacyAuditReceiptTreeReads) { + throw new AuditError( + `Legacy audit receipt tree reads require the substrate migration compatibility policy: ${treeOid}`, + { + code: 'E_LEGACY_SUBSTRATE_DISABLED', + context: { treeOid }, + }, + ); + } + const entries = await this.#history.readTreeOids(treeOid); + const paths = Object.keys(entries); + const receiptOid = entries['receipt.cbor']; + if (paths.length !== 1 || receiptOid === undefined) { + throw new AuditError( + `Expected exactly one audit receipt entry in ${treeOid}`, + { + code: 'E_AUDIT_RECEIPT_TREE', + context: { + treeOid, + paths, + cause: cause instanceof Error ? cause.message : String(cause), + }, + }, + ); + } + return await this.#history.readBlob(receiptOid); + } +} + +function rethrowUnlessLegacyReceiptTree(error: unknown): void { + if (readGitCasErrorCode(error) !== 'MANIFEST_NOT_FOUND') { + throw error; + } +} + +function observedPublicationHead(error: unknown): string | null { + const meta = publicationErrorMeta(error); + if (typeof meta !== 'object' || meta === null || !('observed' in meta)) { + return null; + } + const { observed } = meta; + return typeof observed === 'string' ? observed : null; +} + +function publicationErrorMeta(error: unknown): unknown { + if (typeof error !== 'object' || error === null || !('meta' in error)) { + return null; + } + return error.meta; +} + +async function publishAuditRecord( + cas: AuditCas, + request: AppendAuditRecordRequest, + root: string, +): Promise { + try { + return await cas.publications.commit({ + root, + commit: { + message: request.message, + parents: request.parent === null ? [] : [request.parent], + }, + ref: { + name: buildAuditRef(request.graphName, request.writerId), + expected: request.expectedHead, + }, + }); + } catch (error) { + if (readGitCasErrorCode(error) !== 'PUBLICATION_CONFLICT') { + throw error; + } + throw new AuditPublicationConflictError( + request.expectedHead, + observedPublicationHead(error), + ); + } +} diff --git a/src/infrastructure/adapters/GitCasErrorCode.ts b/src/infrastructure/adapters/GitCasErrorCode.ts new file mode 100644 index 00000000..7d2d8538 --- /dev/null +++ b/src/infrastructure/adapters/GitCasErrorCode.ts @@ -0,0 +1,7 @@ +/** Reads a machine code from an untrusted git-cas failure. */ +export function readGitCasErrorCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return null; + } + return typeof error.code === 'string' ? error.code : null; +} diff --git a/src/infrastructure/adapters/GitCasIntentStoreAdapter.ts b/src/infrastructure/adapters/GitCasIntentStoreAdapter.ts new file mode 100644 index 00000000..a34310ca --- /dev/null +++ b/src/infrastructure/adapters/GitCasIntentStoreAdapter.ts @@ -0,0 +1,351 @@ +import type { + PublicationCapability, +} from '@git-stunts/git-cas'; +import WarpError from '../../domain/errors/WarpError.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; +import type CodecValue from '../../domain/types/codec/CodecValue.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; +import type { + IntentNutritionLabel, + PrecommitGuard, + SuffixTransform, + WarpIntentDescriptor, +} from '../../domain/types/WarpIntentDescriptor.ts'; +import { buildIntentRef } from '../../domain/utils/RefLayout.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import type CodecPort from '../../ports/CodecPort.ts'; +import IntentStorePort, { + type IntentChannel, + type PublishedIntent, + type PublishIntentRequest, +} from '../../ports/IntentStorePort.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; + +const CHANNEL_TRAILER = 'eg-intent-channel'; +const DESCRIPTOR_TRAILER = 'eg-intent-descriptor-handle'; +const GRAPH_TRAILER = 'eg-graph'; +const OWNER_TRAILER = 'eg-intent-owner'; + +type IntentHistory = { + readRef(ref: string): Promise; + getNodeInfo(sha: string): Promise<{ message: string; parents: string[] }>; +}; + +type IntentCas = { + readonly publications: Pick; +}; + +/** git-cas-backed append-only intent descriptor journal. */ +export default class GitCasIntentStoreAdapter extends IntentStorePort { + readonly #history: IntentHistory; + readonly #cas: IntentCas; + readonly #assets: AssetStoragePort; + readonly #codec: CodecPort; + + constructor(options: { + readonly history: IntentHistory; + readonly cas: IntentCas; + readonly assets: AssetStoragePort; + readonly codec: CodecPort; + }) { + super(); + this.#history = options.history; + this.#cas = options.cas; + this.#assets = options.assets; + this.#codec = options.codec; + } + + override async publish(request: PublishIntentRequest): Promise { + const ref = buildIntentRef(request.graphName, request.channel, request.ownerId); + const expectedHead = await this.#history.readRef(ref); + const bytes = this.#codec.encode(request.descriptor); + const descriptorAsset = await this.#assets.stage(WarpStream.from([bytes]), { + slug: `intent-${request.graphName}-${request.channel}-${request.ownerId}`, + filename: 'intent.cbor', + expectedSize: bytes.byteLength, + }); + const publication = await this.#cas.publications.commit({ + root: descriptorAsset.handle.toString(), + commit: { + message: encodeIntentMessage({ + graphName: request.graphName, + channel: request.channel, + ownerId: request.ownerId, + descriptorHandle: descriptorAsset.handle.toString(), + }), + parents: expectedHead === null ? [] : [expectedHead], + }, + ref: { name: ref, expected: expectedHead }, + }); + return Object.freeze({ + sha: publication.commitId, + descriptorAsset, + retention: adaptGitCasRetentionWitness(publication.witness.toJSON()), + }); + } + + override scan( + graphName: string, + channel: IntentChannel, + ownerId: string, + ): WarpStream { + const identity = Object.freeze({ graphName, channel, ownerId }); + const handles = collectIntentHandles( + this.#history, + buildIntentRef(graphName, channel, ownerId), + identity, + ); + return WarpStream.from(streamIntentDescriptors(handles, this.#assets, this.#codec)); + } +} + +type IntentJournalIdentity = Readonly<{ + graphName: string; + channel: IntentChannel; + ownerId: string; +}>; + +async function collectIntentHandles( + history: IntentHistory, + ref: string, + identity: IntentJournalIdentity, +): Promise { + let sha = await history.readRef(ref); + const handles: AssetHandle[] = []; + const seen = new Set(); + while (sha !== null) { + assertUnseenIntentPublication(seen, sha); + const node = await history.getNodeInfo(sha); + assertLinearIntentPublication(node.parents, sha); + const message = decodeIntentMessage(node.message); + assertIntentIdentity(message, identity); + handles.push(new AssetHandle(message.descriptorHandle)); + sha = node.parents[0] ?? null; + } + return Object.freeze(handles); +} + +function assertLinearIntentPublication(parents: readonly string[], sha: string): void { + if (parents.length <= 1) { + return; + } + throw new WarpError( + 'Intent journal publication must have at most one parent', + 'E_INTENT_JOURNAL_NON_LINEAR', + { context: { sha, parentCount: parents.length } }, + ); +} + +async function* streamIntentDescriptors( + handlesPromise: Promise, + assets: AssetStoragePort, + codec: CodecPort, +): AsyncGenerator { + const handles = await handlesPromise; + for (let index = handles.length - 1; index >= 0; index -= 1) { + const handle = handles[index]; + if (handle !== undefined) { + yield decodeIntentDescriptor(codec.decode(await collectAsyncIterable(assets.open(handle)))); + } + } +} + +function assertUnseenIntentPublication(seen: Set, sha: string): void { + if (seen.has(sha)) { + throw new WarpError('Intent journal contains a parent cycle', 'E_INTENT_JOURNAL_CYCLE'); + } + seen.add(sha); +} + +function assertIntentIdentity( + actual: IntentJournalIdentity & { descriptorHandle: string }, + expected: IntentJournalIdentity, +): void { + if (actual.graphName !== expected.graphName + || actual.channel !== expected.channel + || actual.ownerId !== expected.ownerId) { + throw new WarpError( + 'Intent journal publication identity mismatch', + 'E_INTENT_JOURNAL_IDENTITY', + ); + } +} + +function encodeIntentMessage(value: { + graphName: string; + channel: IntentChannel; + ownerId: string; + descriptorHandle: string; +}): string { + return [ + 'warp:intent', + '', + `${GRAPH_TRAILER}: ${value.graphName}`, + `${CHANNEL_TRAILER}: ${value.channel}`, + `${OWNER_TRAILER}: ${value.ownerId}`, + `${DESCRIPTOR_TRAILER}: ${value.descriptorHandle}`, + ].join('\n'); +} + +function decodeIntentMessage(message: string): { + graphName: string; + channel: IntentChannel; + ownerId: string; + descriptorHandle: string; +} { + const trailers = new Map(); + for (const line of message.split('\n')) { + const separator = line.indexOf(': '); + if (separator > 0) { + trailers.set(line.slice(0, separator), line.slice(separator + 2)); + } + } + const channel = requireTrailer(trailers, CHANNEL_TRAILER); + if (channel !== 'admitted' && channel !== 'queued') { + throw new WarpError('Intent journal channel is invalid', 'E_INTENT_JOURNAL_MESSAGE'); + } + return { + graphName: requireTrailer(trailers, GRAPH_TRAILER), + channel, + ownerId: requireTrailer(trailers, OWNER_TRAILER), + descriptorHandle: requireTrailer(trailers, DESCRIPTOR_TRAILER), + }; +} + +function decodeIntentDescriptor(value: unknown): WarpIntentDescriptor { + const candidate = requireDescriptorRecord(value); + return Object.freeze({ + intentId: requireDescriptorString(candidate, 'intentId'), + nutritionLabel: decodeNutritionLabel(candidate['nutritionLabel']), + precommitGuards: decodePrecommitGuards(candidate['precommitGuards']), + suffixTransform: decodeSuffixTransform(candidate['suffixTransform']), + }); +} + +function decodeNutritionLabel(value: unknown): IntentNutritionLabel { + const label = requireDescriptorRecord(value); + return Object.freeze({ + bundleHash: requireDescriptorString(label, 'bundleHash'), + coreHash: requireDescriptorString(label, 'coreHash'), + profile: requireDescriptorString(label, 'profile'), + budget: requireDescriptorString(label, 'budget'), + }); +} + +function decodePrecommitGuards(value: unknown): readonly PrecommitGuard[] { + if (!isUnknownArray(value)) { + throw invalidDescriptor(); + } + return Object.freeze(value.map((guard) => decodePrecommitGuard(guard))); +} + +function decodePrecommitGuard(value: unknown): PrecommitGuard { + const guard = requireDescriptorRecord(value); + const base = { + nodeId: requireDescriptorString(guard, 'nodeId'), + failureTag: requireDescriptorString(guard, 'failureTag'), + }; + const operation = requireGuardOperation(guard['op']); + if (operation === 'nodeStatus') { + return Object.freeze({ + ...base, + op: operation, + expected: requireDescriptorString(guard, 'expected'), + }); + } + if (operation === 'nodeUnassignedOrSelf') { + return Object.freeze({ + ...base, + op: operation, + agentId: requireDescriptorString(guard, 'agentId'), + }); + } + return Object.freeze({ ...base, op: operation }); +} + +function decodeSuffixTransform(value: unknown): SuffixTransform { + const transform = requireDescriptorRecord(value); + return Object.freeze({ + op: requireDescriptorString(transform, 'op'), + payload: decodeCodecRecord(transform['payload']), + }); +} + +function decodeCodecRecord(value: unknown): Readonly<{ readonly [key: string]: CodecValue }> { + const record = requireDescriptorRecord(value); + return Object.freeze(Object.fromEntries( + Object.entries(record).map(([key, member]) => [key, decodeCodecValue(member)]), + )); +} + +function decodeCodecValue(value: unknown): CodecValue { + if (isCodecScalar(value) || isCodecNative(value)) { + return value; + } + if (isUnknownArray(value)) { + return Object.freeze(value.map((member) => decodeCodecValue(member))); + } + return decodeCodecRecord(value); +} + +function isCodecScalar( + value: unknown, +): value is string | number | boolean | bigint | null | undefined { + return value === null + || value === undefined + || ['string', 'number', 'boolean', 'bigint'].includes(typeof value); +} + +function isCodecNative(value: unknown): value is Uint8Array | Date { + return value instanceof Uint8Array || value instanceof Date; +} + +function requireGuardOperation(value: unknown): PrecommitGuard['op'] { + if (value !== 'nodeStatus' && value !== 'nodeUnassignedOrSelf' && value !== 'edgeExists') { + throw invalidDescriptor(); + } + return value; +} + +function requireDescriptorString( + record: Readonly>, + key: string, +): string { + const value = record[key]; + if (typeof value !== 'string' || value.length === 0) { + throw invalidDescriptor(); + } + return value; +} + +function requireDescriptorRecord(value: unknown): Readonly> { + if (!isDescriptorRecord(value)) { + throw invalidDescriptor(); + } + return value; +} + +function isDescriptorRecord(value: unknown): value is Readonly> { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Reflect.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function invalidDescriptor(): WarpError { + return new WarpError('Intent descriptor asset is invalid', 'E_INTENT_DESCRIPTOR_ASSET'); +} + +function requireTrailer(trailers: ReadonlyMap, key: string): string { + const value = trailers.get(key); + if (value === undefined || value.length === 0) { + throw new WarpError(`Intent journal publication is missing ${key}`, 'E_INTENT_JOURNAL_MESSAGE'); + } + return value; +} diff --git a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts index 4f183845..e2f6f132 100644 --- a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts +++ b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts @@ -1,6 +1,10 @@ -import ContentAddressableStore, { CborCodec } from '@git-stunts/git-cas'; -import { createGitCasPatchStorage } from '../../ports/CommitMessageCodecPort.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; +import ContentAddressableStore, { + CborCodec, + type AssetCapability, + type BundleCapability, + type PublicationCapability, +} from '@git-stunts/git-cas'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; import type CryptoPort from '../../ports/CryptoPort.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import type RuntimeStorageProviderPort from '../../ports/RuntimeStorageProviderPort.ts'; @@ -8,7 +12,10 @@ import type { RuntimeStorageRequest, RuntimeStorageServices, } from '../../ports/RuntimeStorageProviderPort.ts'; -import CasBlobAdapter from './CasBlobAdapter.ts'; +import GitCasAssetStorageAdapter from './GitCasAssetStorageAdapter.ts'; +import GitCasAuditLogAdapter from './GitCasAuditLogAdapter.ts'; +import GitCasStrandStoreAdapter from './GitCasStrandStoreAdapter.ts'; +import GitCasIntentStoreAdapter from './GitCasIntentStoreAdapter.ts'; import type CasContentEncryptionPolicy from './CasContentEncryptionPolicy.ts'; import { CborCheckpointStoreAdapter } from './CborCheckpointStoreAdapter.ts'; import { CborIndexStoreAdapter } from './CborIndexStoreAdapter.ts'; @@ -27,8 +34,15 @@ type GitCasPolicy = { export type GitCasFacade = Pick< ContentAddressableStore, - 'readManifest' | 'restore' | 'restoreStream' | 'store' | 'createTree' + | 'createTree' + | 'readManifest' + | 'restore' + | 'restoreStream' + | 'store' > & { + readonly assets: Pick; + readonly bundles: Pick; + readonly publications: Pick; readonly rootSets: { open(options: { readonly ref: string }): Promise; }; @@ -59,6 +73,7 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo ContentAddressableStore.createCbor({ plumbing: options.plumbing, chunking: { strategy: 'cdc' }, + applicationRefPrefixes: ['refs/warp/'], ...(options.policy === undefined ? {} : { policy: options.policy }), ...(options.logger === undefined ? {} @@ -69,15 +84,15 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo } createRuntimeStorageServices(request: RuntimeStorageRequest): Promise { - const content = request.contentOverride ?? this._createContentStorage(); + const content = this._createContentStorage(); return Promise.resolve( Object.freeze({ content, + auditLog: this._createAuditLog(content), + strands: this._createStrandStore(content), + intents: this._createIntentStore(request, content), patchJournal: this._createPatchJournal(request, content), - checkpoints: new CborCheckpointStoreAdapter({ - codec: request.codec, - blobPort: this._history, - }), + checkpoints: this._createCheckpointStore(request, content), indexes: this._createIndexStore(request, content), stateSnapshots: this._createStateSnapshots(request), trie: new GitTrieStoreAdapter({ plumbing: this._plumbing }), @@ -85,6 +100,47 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo ); } + private _createAuditLog(content: AssetStoragePort): GitCasAuditLogAdapter { + return new GitCasAuditLogAdapter({ + history: this._history, + cas: this._cas, + assets: content, + }); + } + + private _createStrandStore(content: AssetStoragePort): GitCasStrandStoreAdapter { + return new GitCasStrandStoreAdapter({ + history: this._history, + cas: this._cas, + assets: content, + }); + } + + private _createIntentStore( + request: RuntimeStorageRequest, + content: AssetStoragePort, + ): GitCasIntentStoreAdapter { + return new GitCasIntentStoreAdapter({ + history: this._history, + cas: this._cas, + assets: content, + codec: request.codec, + }); + } + + private _createCheckpointStore( + request: RuntimeStorageRequest, + content: AssetStoragePort, + ): CborCheckpointStoreAdapter { + return new CborCheckpointStoreAdapter({ + codec: request.codec, + commitMessageCodec: request.commitMessageCodec, + history: this._history, + assetStorage: content, + cas: this._cas, + }); + } + private _createStateSnapshots(request: RuntimeStorageRequest): GitCasWarpStateCacheAdapter { return new GitCasWarpStateCacheAdapter({ cas: this._cas, @@ -99,30 +155,26 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo private _createPatchJournal( request: RuntimeStorageRequest, - content: BlobStoragePort + content: AssetStoragePort ): CborPatchJournalAdapter { return new CborPatchJournalAdapter({ + assetStorage: content, + cas: this._cas, codec: request.codec, - blobPort: this._history, - commitPort: this._history, + commitReader: this._history, commitMessageCodec: request.commitMessageCodec, - blobStorage: content, - ...(request.patchContentOverride === undefined - ? {} - : { legacyPatchBlobStorage: request.patchContentOverride }), - writeStorage: createGitCasPatchStorage({ encrypted: false }), + encrypted: this._contentEncryption?.enabled ?? false, }); } private _createIndexStore( request: RuntimeStorageRequest, - content: BlobStoragePort + content: AssetStoragePort, ): CborIndexStoreAdapter { return new CborIndexStoreAdapter({ codec: request.codec, - blobPort: this._history, - treePort: this._history, - blobStorage: content, + assetStorage: content, + cas: this._cas, }); } @@ -135,10 +187,10 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo }); } - private _createContentStorage(): CasBlobAdapter { - return new CasBlobAdapter({ + private _createContentStorage(): GitCasAssetStorageAdapter { + return new GitCasAssetStorageAdapter({ cas: this._cas, - persistence: this._history, + legacyReader: this._history, ...(this._contentEncryption === undefined ? {} : { contentEncryption: this._contentEncryption }), diff --git a/src/infrastructure/adapters/GitCasRetentionWitnessAdapter.ts b/src/infrastructure/adapters/GitCasRetentionWitnessAdapter.ts new file mode 100644 index 00000000..44327166 --- /dev/null +++ b/src/infrastructure/adapters/GitCasRetentionWitnessAdapter.ts @@ -0,0 +1,24 @@ +import type { RetentionWitnessData } from '@git-stunts/git-cas'; +import StorageHandle from '../../domain/storage/StorageHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../domain/storage/StorageRetentionWitness.ts'; + +/** Converts git-cas retention evidence into runtime-backed domain evidence. */ +export function adaptGitCasRetentionWitness( + witness: RetentionWitnessData, +): StorageRetentionWitness { + return new StorageRetentionWitness({ + handle: new StorageHandle(witness.handle), + policy: witness.policy, + reachability: witness.reachability, + root: new StorageRetentionRoot({ + kind: witness.root.kind, + namespace: witness.root.namespace, + locator: witness.root.ref, + generation: witness.root.generation, + path: witness.root.path, + }), + observedAt: witness.observedAt, + }); +} diff --git a/src/infrastructure/adapters/GitCasStrandStoreAdapter.ts b/src/infrastructure/adapters/GitCasStrandStoreAdapter.ts new file mode 100644 index 00000000..ad57f73b --- /dev/null +++ b/src/infrastructure/adapters/GitCasStrandStoreAdapter.ts @@ -0,0 +1,241 @@ +import type { + BundleCapability, + PublicationCapability, +} from '@git-stunts/git-cas'; +import StrandError from '../../domain/errors/StrandError.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import BundleHandle from '../../domain/storage/BundleHandle.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; +import { buildStrandRef, buildStrandsPrefix } from '../../domain/utils/RefLayout.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; +import StrandStorePort, { + type PublishedStrandDescriptor, + type PublishStrandDescriptorRequest, +} from '../../ports/StrandStorePort.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; +import { + CURRENT_SUBSTRATE_ONLY_POLICY, + type SubstrateCompatibilityPolicyValue, +} from './SubstrateCompatibilityPolicy.ts'; + +const DESCRIPTOR_HANDLE_TRAILER = 'eg-strand-descriptor-handle'; +const GRAPH_TRAILER = 'eg-graph'; +const STRAND_TRAILER = 'eg-strand'; + +type StrandHistory = { + readRef(ref: string): Promise; + listRefs(prefix: string): Promise; + compareAndDeleteRef(ref: string, expectedOid: string): Promise; + readObjectType(oid: string): Promise; + getNodeInfo(sha: string): Promise<{ message: string }>; + readBlob(oid: string): Promise; +}; + +type StrandCas = { + readonly bundles: Pick; + readonly publications: Pick; +}; + +/** Retains strand descriptors and queued assets as one causal bundle. */ +export default class GitCasStrandStoreAdapter extends StrandStorePort { + readonly #history: StrandHistory; + readonly #cas: StrandCas; + readonly #assets: AssetStoragePort; + readonly #compatibilityPolicy: SubstrateCompatibilityPolicyValue; + + constructor(options: { + readonly history: StrandHistory; + readonly cas: StrandCas; + readonly assets: AssetStoragePort; + readonly compatibilityPolicy?: SubstrateCompatibilityPolicyValue; + }) { + super(); + this.#history = options.history; + this.#cas = options.cas; + this.#assets = options.assets; + this.#compatibilityPolicy = options.compatibilityPolicy ?? CURRENT_SUBSTRATE_ONLY_POLICY; + } + + override async readDescriptor(graphName: string, strandId: string): Promise { + const revision = await this.#history.readRef(buildStrandRef(graphName, strandId)); + if (revision === null) { + return null; + } + const objectType = await this.#history.readObjectType(revision); + if (objectType === 'blob') { + return await this.#readLegacyDescriptor({ graphName, strandId, revision }); + } + if (objectType !== 'commit') { + throw new StrandError('strand descriptor ref must target a blob or publication commit', { + code: 'E_STRAND_CORRUPT', + context: { graphName, strandId, revision, objectType }, + }); + } + const node = await this.#history.getNodeInfo(revision); + const trailers = decodeDescriptorMessage(node.message); + requireDescriptorIdentity(trailers, { graphName, strandId, revision }); + return await collectAsyncIterable( + this.#assets.open(new AssetHandle(trailers.descriptorHandle)), + ); + } + + async #readLegacyDescriptor(options: { + readonly graphName: string; + readonly strandId: string; + readonly revision: string; + }): Promise { + if (!this.#compatibilityPolicy.legacyStrandDescriptorBlobReads) { + throw new StrandError( + `Legacy strand descriptor blob reads require the substrate migration compatibility policy: ${options.revision}`, + { code: 'E_LEGACY_SUBSTRATE_DISABLED', context: options }, + ); + } + return await this.#history.readBlob(options.revision); + } + + override async publishDescriptor( + request: PublishStrandDescriptorRequest, + ): Promise { + const ref = buildStrandRef(request.graphName, request.strandId); + const expectedHead = await this.#history.readRef(ref); + const parent = await retainedDescriptorParent(this.#history, expectedHead); + const descriptorAsset = await stageDescriptor(this.#assets, request); + const bundle = await this.#cas.bundles.putOrdered({ + members: descriptorBundleMembers(descriptorAsset.handle, request.attachments), + }); + const publication = await this.#cas.publications.commit({ + root: bundle.handle, + commit: { + message: encodeDescriptorMessage({ + graphName: request.graphName, + strandId: request.strandId, + descriptorHandle: descriptorAsset.handle.toString(), + }), + parents: parent === null ? [] : [parent], + }, + ref: { name: ref, expected: expectedHead }, + }); + return Object.freeze({ + revision: publication.commitId, + descriptorAsset, + bundleHandle: new BundleHandle(publication.root.toString()), + retention: adaptGitCasRetentionWitness(publication.witness.toJSON()), + }); + } + + override async listStrandIds(graphName: string): Promise { + const prefix = buildStrandsPrefix(graphName); + const refs = await this.#history.listRefs(prefix); + return refs + .filter((ref) => ref.startsWith(prefix)) + .map((ref) => ref.slice(prefix.length)) + .filter((strandId) => strandId.length > 0 && !strandId.includes('/')) + .sort(); + } + + override async hasDescriptor(graphName: string, strandId: string): Promise { + return await this.#history.readRef(buildStrandRef(graphName, strandId)) !== null; + } + + override async deleteDescriptor(graphName: string, strandId: string): Promise { + const ref = buildStrandRef(graphName, strandId); + const revision = await this.#history.readRef(ref); + if (revision === null) { + return false; + } + return await this.#history.compareAndDeleteRef(ref, revision); + } +} + +async function retainedDescriptorParent( + history: StrandHistory, + expectedHead: string | null, +): Promise { + if (expectedHead === null) { + return null; + } + return await history.readObjectType(expectedHead) === 'commit' ? expectedHead : null; +} + +async function stageDescriptor( + assets: AssetStoragePort, + request: PublishStrandDescriptorRequest, +) { + return await assets.stage(WarpStream.from([request.descriptor]), { + slug: `strand-${request.graphName}-${request.strandId}`, + filename: 'descriptor.json', + expectedSize: request.descriptor.byteLength, + }); +} + +function descriptorBundleMembers( + descriptor: AssetHandle, + attachments: readonly AssetHandle[], +): WarpStream<[string, string]> { + const members: Array<[string, string]> = [['descriptor', descriptor.toString()]]; + const unique = [...new Set(attachments.map((handle) => handle.toString()))].sort(); + for (let index = 0; index < unique.length; index++) { + const handle = unique[index]; + if (handle !== undefined) { + members.push([`attachments/${String(index).padStart(8, '0')}`, handle]); + } + } + return WarpStream.from(members); +} + +function encodeDescriptorMessage(value: { + graphName: string; + strandId: string; + descriptorHandle: string; +}): string { + return [ + 'warp:strand-descriptor', + '', + `${GRAPH_TRAILER}: ${value.graphName}`, + `${STRAND_TRAILER}: ${value.strandId}`, + `${DESCRIPTOR_HANDLE_TRAILER}: ${value.descriptorHandle}`, + ].join('\n'); +} + +function decodeDescriptorMessage(message: string): { + graphName: string; + strandId: string; + descriptorHandle: string; +} { + const trailers = new Map(); + for (const line of message.split('\n')) { + const separator = line.indexOf(': '); + if (separator > 0) { + trailers.set(line.slice(0, separator), line.slice(separator + 2)); + } + } + return { + graphName: requireTrailer(trailers, GRAPH_TRAILER), + strandId: requireTrailer(trailers, STRAND_TRAILER), + descriptorHandle: requireTrailer(trailers, DESCRIPTOR_HANDLE_TRAILER), + }; +} + +function requireTrailer(trailers: ReadonlyMap, key: string): string { + const value = trailers.get(key); + if (value === undefined || value.length === 0) { + throw new StrandError(`strand descriptor publication is missing ${key}`, { + code: 'E_STRAND_CORRUPT', + }); + } + return value; +} + +function requireDescriptorIdentity( + trailers: { readonly graphName: string; readonly strandId: string }, + expected: { readonly graphName: string; readonly strandId: string; readonly revision: string }, +): void { + if (trailers.graphName === expected.graphName && trailers.strandId === expected.strandId) { + return; + } + throw new StrandError('strand descriptor publication identity mismatch', { + code: 'E_STRAND_CORRUPT', + context: expected, + }); +} diff --git a/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts b/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts index c71ce148..4b58ebb7 100644 --- a/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts +++ b/src/infrastructure/adapters/GitRecursiveTreeOidReaderAdapter.ts @@ -4,7 +4,7 @@ import type TreeEntryLimit from '../../domain/tree/TreeEntryLimit.ts'; import TreeEntryMissing from '../../domain/tree/TreeEntryMissing.ts'; import TreeEntryPath from '../../domain/tree/TreeEntryPath.ts'; import TreeEntryPrefixBatch from '../../domain/tree/TreeEntryPrefixBatch.ts'; -import type { TreeEntryProbeResult } from '../../ports/TreeEntryProbePort.ts'; +import type { TreeEntryProbeResult } from '../../domain/tree/TreeEntryProbeResult.ts'; import { validateOid } from './adapterValidation.ts'; import { type GitPlumbing, diff --git a/src/infrastructure/adapters/GitTimelineHistoryAdapter.ts b/src/infrastructure/adapters/GitTimelineHistoryAdapter.ts index 6a0ea5a5..56eb5a82 100644 --- a/src/infrastructure/adapters/GitTimelineHistoryAdapter.ts +++ b/src/infrastructure/adapters/GitTimelineHistoryAdapter.ts @@ -9,7 +9,6 @@ import { GitPersistenceAdapter } from '@git-stunts/git-cas'; import type { CommitLogChunk, CommitNodeOptions, - CommitNodeWithTreeOptions, LogNodesOptions, NodeInfo, PingResult, @@ -18,7 +17,7 @@ import type { ListRefsOptions } from '../../ports/RefPort.ts'; import type TreeEntryLimit from '../../domain/tree/TreeEntryLimit.ts'; import type TreeEntryPath from '../../domain/tree/TreeEntryPath.ts'; import type TreeEntryPrefixBatch from '../../domain/tree/TreeEntryPrefixBatch.ts'; -import type { TreeEntryProbeResult } from '../../ports/TreeEntryProbePort.ts'; +import type { TreeEntryProbeResult } from '../../domain/tree/TreeEntryProbeResult.ts'; import AdapterValidationError from '../../domain/errors/AdapterValidationError.ts'; import PersistenceError from '../../domain/errors/PersistenceError.ts'; import GraphPersistencePort from '../../ports/GraphPersistencePort.ts'; @@ -28,7 +27,6 @@ import GitRecursiveTreeOidReaderAdapter from './GitRecursiveTreeOidReaderAdapter 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'; import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.ts'; import { type GitPlumbing, @@ -49,6 +47,14 @@ export interface GitTimelineHistoryAdapterOptions { readonly retryOptions?: Partial; readonly policy?: OperationPolicyPort; } + +/** Infrastructure-only tree-backed commit operation. */ +export interface GitTreeCommitOptions { + treeOid: string; + parents?: string[]; + message: string; + sign?: boolean; +} interface GitCasPolicy { execute(operation: () => Promise): Promise; } @@ -77,16 +83,6 @@ function createGitCasRetryPolicy( }); } -function parseContentAnchorObjectType(value: string, oid: string): ContentAnchorObjectType { - if (value === 'blob' || value === 'tree') { - return value; - } - throw new PersistenceError( - `Unsupported Git object type for content anchor ${oid}: ${value}`, - 'E_UNSUPPORTED_CONTENT_ANCHOR_OBJECT_TYPE', - { context: { oid, objectType: value } } - ); -} function buildListRefsArgs(prefix: string, limit: number | null | undefined): string[] { const args = ['for-each-ref', '--format=%(refname)']; @@ -194,7 +190,7 @@ export default class GitTimelineHistoryAdapter extends GraphPersistencePort { parents = [], message, sign = false, - }: CommitNodeWithTreeOptions): Promise { + }: GitTreeCommitOptions): Promise { validateOid(treeOid); return await this._createCommit({ tree: treeOid, parents, message, sign }); } @@ -327,15 +323,13 @@ export default class GitTimelineHistoryAdapter extends GraphPersistencePort { } } - async readObjectType(oid: string): Promise { + async readObjectType(oid: string): Promise { validateOid(oid); - let output: string; try { - output = await this._executeWithRetry({ args: ['cat-file', '-t', oid] }); + return (await this._executeWithRetry({ args: ['cat-file', '-t', oid] })).trim(); } catch (raw) { throw wrapGitError(toGitError(raw), { oid }); } - return parseContentAnchorObjectType(output.trim(), oid); } async updateRef(ref: string, oid: string): Promise { @@ -380,6 +374,20 @@ export default class GitTimelineHistoryAdapter extends GraphPersistencePort { } } + async compareAndDeleteRef(ref: string, expectedOid: string): Promise { + validateRef(ref); + validateOid(expectedOid); + try { + await this.plumbing.execute({ args: ['update-ref', '-d', ref, expectedOid] }); + return true; + } catch (raw) { + if (await this.readRef(ref) !== expectedOid) { + return false; + } + throw wrapGitError(toGitError(raw), { ref, oid: expectedOid }); + } + } + async deleteRef(ref: string): Promise { validateRef(ref); try { diff --git a/src/infrastructure/adapters/GitTrustChainAdapter.ts b/src/infrastructure/adapters/GitTrustChainAdapter.ts index 6d611377..e5914ea7 100644 --- a/src/infrastructure/adapters/GitTrustChainAdapter.ts +++ b/src/infrastructure/adapters/GitTrustChainAdapter.ts @@ -1,36 +1,50 @@ /** * Git-backed trust chain adapter. * - * Uses git-cas for CBOR blob storage (chunked, content-addressed, - * streaming) and @git-stunts/plumbing for commit chain traversal - * and ref management. + * Uses git-cas assets and causal publications for writes, with plumbing + * restricted to commit-chain traversal and legacy reads. * * Handles all encoding/decoding at the boundary: * - On READ: CBOR decode, recordId hash verification, signaturePayload * precomputation, TrustRecord.fromDecoded() - * - On WRITE: CBOR encode via git-cas, commit + tree, ref CAS + * - On WRITE: CBOR encode, stage asset, publish causal commit atomically * * @module infrastructure/adapters/GitTrustChainAdapter */ -import TrustChainPort, { type TrustChainTip } from '../../ports/TrustChainPort.ts'; +import TrustChainPort, { + type TrustChainTip, + type TrustRecordPublication, +} from '../../ports/TrustChainPort.ts'; import { TrustRecord } from '../../domain/trust/TrustRecord.ts'; import { recordIdPayload, signaturePayload } from '../../domain/trust/canonical.ts'; import { textEncode } from '../../domain/utils/bytes.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; import { buildTrustRecordRef } from '../../domain/utils/RefLayout.ts'; import TrustError from '../../domain/errors/TrustError.ts'; +import WarpStream from '../../domain/stream/WarpStream.ts'; import { CURRENT_SUBSTRATE_ONLY_POLICY, type SubstrateCompatibilityPolicyValue, } from './SubstrateCompatibilityPolicy.ts'; import type CryptoPort from '../../ports/CryptoPort.ts'; -import { Readable } from 'node:stream'; -import type ContentAddressableStore from '@git-stunts/git-cas'; -import type { CborCodec } from '@git-stunts/git-cas'; +import type { + AssetCapability, + CborCodec, + PublicationCapability, + PublicationResult, +} from '@git-stunts/git-cas'; +import { readGitCasErrorCode } from './GitCasErrorCode.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; +import PersistenceError from '../../domain/errors/PersistenceError.ts'; +import { + getExitCode, + toGitError, + wrapGitError, +} from './gitErrorClassification.ts'; // -- Constants ---------------------------------------------------------------- -const MAX_TRANSIENT_CAS_ATTEMPTS = 3; const RECORD_BLOB_NAME = 'record.cbor'; // -- Plumbing type (minimal contract) ----------------------------------------- @@ -41,10 +55,10 @@ type Plumbing = { // -- CAS types (minimal contract from git-cas) -------------------------------- -type CasStore = Pick< - ContentAddressableStore, - 'store' | 'restore' | 'readManifest' | 'createTree' ->; +type CasStore = { + readonly assets: Pick; + readonly publications: Pick; +}; // -- Adapter deps ------------------------------------------------------------- @@ -60,10 +74,19 @@ type GitTrustChainDeps = { async function resolveRef(plumbing: Plumbing, ref: string): Promise { try { - const sha = await plumbing.execute({ args: ['rev-parse', '--verify', ref] }); + const sha = await plumbing.execute({ args: ['rev-parse', '--verify', '--quiet', ref] }); return sha.trim(); - } catch { - return null; + } catch (raw) { + const error = toGitError(raw); + if (getExitCode(error) === 1) { + return null; + } + const classified = wrapGitError(error, { ref }); + if (classified instanceof PersistenceError + && classified.code === PersistenceError.E_REF_NOT_FOUND) { + return null; + } + throw classified; } } @@ -100,40 +123,24 @@ async function readTreeEntries( } const tabIdx = line.indexOf('\t'); if (tabIdx < 0) { - continue; + throw malformedTrustTreeEntry(line); } const name = line.slice(tabIdx + 1); const parts = line.slice(0, tabIdx).split(' '); const oid = parts[2] ?? ''; + if (name.length === 0 || parts.length !== 3 || oid.length === 0) { + throw malformedTrustTreeEntry(line); + } entries.set(name, oid); } return entries; } -async function createCommit( - plumbing: Plumbing, - treeSha: string, - parentSha: string | null, - message: string, -): Promise { - const args = ['commit-tree', treeSha, '-m', message]; - if (parentSha !== null) { - args.push('-p', parentSha); - } - const sha = await plumbing.execute({ args }); - return sha.trim(); -} - -async function compareAndSwapRef( - plumbing: Plumbing, - ref: string, - newSha: string, - expectedSha: string | null, -): Promise { - const args = (expectedSha !== null && expectedSha.length > 0) - ? ['update-ref', ref, newSha, expectedSha] - : ['update-ref', ref, newSha]; - await plumbing.execute({ args }); +function malformedTrustTreeEntry(entry: string): TrustError { + return new TrustError('Malformed legacy trust tree entry', { + code: 'E_TRUST_LEGACY_TREE_INVALID', + context: { entry }, + }); } // -- Hash helpers (boundary concern) ------------------------------------------ @@ -187,22 +194,17 @@ export default class GitTrustChainAdapter extends TrustChainPort { } private async _readRecordIdFromCommit(commitSha: string): Promise { - const cas = this._cas; const info = await readCommitInfo(this._plumbing, commitSha); try { - const manifest = await cas.readManifest({ treeOid: info.treeSha }); - const restored = await cas.restore({ manifest }); - const decoded = this._cbor.decode(restored.buffer) as Record; + const decoded = this._cbor.decode(await this._readAssetTree(info.treeSha)) as Record; return decoded['recordId'] ?? null; - } catch { + } catch (error) { + rethrowUnlessLegacyTrustTree(error); 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); - if (manifestOid === undefined) { - return null; - } - return await this._readRecordIdRawFallback(manifestOid); + return await this._readRecordIdRawFallback( + requireLegacyRecordBlob(entries, commitSha), + ); } } @@ -246,23 +248,17 @@ export default class GitTrustChainAdapter extends TrustChainPort { private async _decodeRecordFromCommit(commitSha: string): Promise { const info = await readCommitInfo(this._plumbing, commitSha); - const cas = this._cas; const cbor = this._cbor; let rawRecord: Record; try { - const manifest = await cas.readManifest({ treeOid: info.treeSha }); - const restored = await cas.restore({ manifest }); - rawRecord = cbor.decode(restored.buffer) as typeof rawRecord; - } catch { + rawRecord = cbor.decode(await this._readAssetTree(info.treeSha)) as typeof rawRecord; + } catch (error) { + rethrowUnlessLegacyTrustTree(error); this._requireLegacyTrustRecordPolicy(commitSha); - // Fallback: pre-CAS raw blob const entries = await readTreeEntries(this._plumbing, info.treeSha); - const blobOid = entries.get(RECORD_BLOB_NAME); - if (blobOid === undefined) { - return null; - } + const blobOid = requireLegacyRecordBlob(entries, commitSha); const raw = await this._plumbing.execute({ args: ['cat-file', 'blob', blobOid] }); rawRecord = cbor.decode(Buffer.from(raw, 'binary')) as typeof rawRecord; } @@ -304,18 +300,23 @@ export default class GitTrustChainAdapter extends TrustChainPort { ); } + private async _readAssetTree(treeOid: string): Promise { + const staged = await this._cas.assets.adopt({ treeOid }); + return await collectAsyncIterable(this._cas.assets.open({ handle: staged.handle })); + } + // -- Port implementation: persistRecord ------------------------------------- async persistRecord( graphName: string, record: TrustRecord, parentTipSha: string | null, - ): Promise { + ): Promise { const ref = buildTrustRecordRef(graphName); const cas = this._cas; const cbor = this._cbor; - // Encode record as CBOR → store via git-cas + // Encode and stage one immutable trust record asset. const recordObj = { schemaVersion: record.schemaVersion, recordType: record.recordType, @@ -328,66 +329,79 @@ export default class GitTrustChainAdapter extends TrustChainPort { signature: record.signature, }; const encoded = cbor.encode(recordObj); - const source = Readable.from([encoded]); - const manifest = await cas.store({ - source, + const staged = await cas.assets.put({ + source: WarpStream.from([encoded]), slug: `trust-${record.recordId.slice(0, 12)}`, filename: RECORD_BLOB_NAME, }); - const treeOid = await cas.createTree({ manifest }); - - // Create commit const message = `trust: ${record.recordType} ${record.recordId.slice(0, 12)}`; - const commitSha = await createCommit(this._plumbing, treeOid, parentTipSha, message); - - // CAS ref update with transient retry - await this._casUpdateRef(ref, commitSha, parentTipSha, graphName); - - return commitSha; + let publication: PublicationResult; + try { + publication = await cas.publications.commit({ + root: staged.handle, + commit: { + message, + parents: parentTipSha === null ? [] : [parentTipSha], + }, + ref: { name: ref, expected: parentTipSha }, + }); + } catch (error) { + return await this._rethrowPublicationConflict(ref, parentTipSha, error); + } + return Object.freeze({ + commitSha: publication.commitId, + retention: adaptGitCasRetentionWitness(publication.witness.toJSON()), + }); } - private async _casUpdateRef( + private async _rethrowPublicationConflict( ref: string, - commitSha: string, expectedSha: string | null, - _graphName: string, - ): Promise { - for (let attempt = 1; attempt <= MAX_TRANSIENT_CAS_ATTEMPTS; attempt++) { - try { - await compareAndSwapRef(this._plumbing, ref, commitSha, expectedSha); - return; - } catch { - // Distinguish transient vs real conflict - const freshTipSha = await resolveRef(this._plumbing, ref); - - if (freshTipSha === expectedSha) { - // Transient failure — retry - if (attempt === MAX_TRANSIENT_CAS_ATTEMPTS) { - throw new TrustError( - `Trust CAS exhausted after ${MAX_TRANSIENT_CAS_ATTEMPTS} attempts`, - { code: 'E_TRUST_CAS_EXHAUSTED' }, - ); - } - continue; - } - - // Real conflict — chain advanced - const freshRecordId = freshTipSha !== null - ? await this._readRecordIdFromCommit(freshTipSha) - : null; - - throw new TrustError( - `Trust CAS conflict: chain advanced from ${String(expectedSha)} to ${String(freshTipSha)}`, - { - code: 'E_TRUST_CAS_CONFLICT', - context: { - expectedTipSha: expectedSha, - actualTipSha: freshTipSha, - actualTipRecordId: freshRecordId, - }, - }, - ); - } + error: unknown, + ): Promise { + const freshTipSha = await resolveRef(this._plumbing, ref); + if (freshTipSha === expectedSha && readGitCasErrorCode(error) !== 'PUBLICATION_CONFLICT') { + throw error; } + const freshRecordId = freshTipSha !== null + ? await this._readRecordIdFromCommit(freshTipSha) + : null; + throw new TrustError( + `Trust CAS conflict: chain advanced from ${String(expectedSha)} to ${String(freshTipSha)}`, + { + code: 'E_TRUST_CAS_CONFLICT', + context: { + expectedTipSha: expectedSha, + actualTipSha: freshTipSha, + actualTipRecordId: freshRecordId, + }, + }, + ); + } +} + +function requireLegacyRecordBlob( + entries: ReadonlyMap, + commitSha: string, +): string { + const blobOid = entries.get(RECORD_BLOB_NAME); + if (entries.size === 1 && blobOid !== undefined && blobOid.length > 0) { + return blobOid; + } + throw new TrustError( + `Legacy trust record tree is malformed: ${commitSha}`, + { + code: 'E_TRUST_LEGACY_TREE_INVALID', + context: { + commitSha, + paths: [...entries.keys()].sort(), + }, + }, + ); +} + +function rethrowUnlessLegacyTrustTree(error: unknown): void { + if (readGitCasErrorCode(error) !== 'MANIFEST_NOT_FOUND') { + throw error; } } diff --git a/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts b/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts index f9943bfc..ff548aa8 100644 --- a/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts +++ b/src/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.ts @@ -144,14 +144,29 @@ function readPatchDescriptors(source: JsonObject): readonly GraphModelMigrationP function readContentSources(source: JsonObject): readonly GraphModelMigrationContentSource[] { return readObjectArray(source, 'contentSources').map((content, index) => { const label = `contentSources[${index}]`; - rejectUnknownKeys(content, ['legacyContentKey', 'contentOid'], label); + rejectUnknownKeys(content, ['legacyContentKey', 'contentHandle', 'contentOid'], label); + const contentHandle = coalesceContentHandle(content, label); return new GraphModelMigrationContentSource({ legacyContentKey: readRequiredString(content, `${label}.legacyContentKey`, 'legacyContentKey'), - contentOid: readRequiredString(content, `${label}.contentOid`, 'contentOid'), + contentHandle: readRequiredString( + { contentHandle }, + `${label}.contentHandle`, + 'contentHandle', + ), }); }); } +function coalesceContentHandle(content: JsonObject, label: string): unknown { + const { contentHandle, contentOid } = content; + if (contentHandle !== undefined && contentOid !== undefined && contentHandle !== contentOid) { + throw new AdapterValidationError( + `${label}.contentHandle and ${label}.contentOid must match when both are present`, + ); + } + return contentHandle ?? contentOid; +} + /** Reads node mappings from the request envelope. */ function readNodeMappings(source: JsonObject): readonly GraphModelMigrationNodeMapping[] { return readObjectArray(source, 'nodeMappings').map((mapping, index) => { diff --git a/src/infrastructure/adapters/LegacyCheckpointArtifactAdapter.ts b/src/infrastructure/adapters/LegacyCheckpointArtifactAdapter.ts new file mode 100644 index 00000000..7098aa85 --- /dev/null +++ b/src/infrastructure/adapters/LegacyCheckpointArtifactAdapter.ts @@ -0,0 +1,54 @@ +import AssetHandle from '../../domain/storage/AssetHandle.ts'; +import PersistenceError from '../../domain/errors/PersistenceError.ts'; +import { collectAsyncIterable } from '../../domain/utils/streamUtils.ts'; +import { textDecode, textEncode } from '../../domain/utils/bytes.ts'; +import type AssetStoragePort from '../../ports/AssetStoragePort.ts'; + +const CAS_POINTER_PREFIX = 'git-warp:cas-pointer:v1:'; +const CAS_POINTER_PREFIX_BYTES = textEncode(CAS_POINTER_PREFIX); + +export type LegacyCheckpointArtifactHistory = { + readBlob(oid: string): Promise; +}; + +/** Reads retired checkpoint blobs and follows their optional git-cas pointer. */ +export default class LegacyCheckpointArtifactAdapter { + readonly #history: LegacyCheckpointArtifactHistory; + readonly #assets: AssetStoragePort; + + constructor(options: { + readonly history: LegacyCheckpointArtifactHistory; + readonly assets: AssetStoragePort; + }) { + this.#history = options.history; + this.#assets = options.assets; + } + + async read(oid: string): Promise { + const bytes = await this.#history.readBlob(oid); + const assetToken = decodeLegacyCasPointer(bytes); + if (assetToken === null) { + return bytes; + } + return await collectAsyncIterable(this.#assets.open(new AssetHandle(assetToken))); + } +} + +function decodeLegacyCasPointer(bytes: Uint8Array): string | null { + if (bytes.length < CAS_POINTER_PREFIX_BYTES.length) { + return null; + } + for (let index = 0; index < CAS_POINTER_PREFIX_BYTES.length; index += 1) { + if (bytes[index] !== CAS_POINTER_PREFIX_BYTES[index]) { + return null; + } + } + const token = textDecode(bytes).slice(CAS_POINTER_PREFIX.length); + if (token.length === 0) { + throw new PersistenceError( + 'Legacy checkpoint CAS pointer is empty', + 'E_CHECKPOINT_EMPTY_CAS_POINTER', + ); + } + return token; +} diff --git a/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts b/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts index d7b6dd5c..64e62754 100644 --- a/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts +++ b/src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts @@ -1,21 +1,27 @@ type SubstrateCompatibilityPolicyFields = { + readonly legacyAuditReceiptTreeReads?: boolean; readonly legacyContentBlobReads?: boolean; readonly legacyInlinePayloadReads?: boolean; readonly legacyPatchStorageReads?: boolean; + readonly legacyStrandDescriptorBlobReads?: boolean; readonly legacyTrustRecordBlobReads?: boolean; }; /** Explicit adapter boundary for retired substrate read compatibility. */ export default class SubstrateCompatibilityPolicy { + readonly legacyAuditReceiptTreeReads: boolean; readonly legacyContentBlobReads: boolean; readonly legacyInlinePayloadReads: boolean; readonly legacyPatchStorageReads: boolean; + readonly legacyStrandDescriptorBlobReads: boolean; readonly legacyTrustRecordBlobReads: boolean; constructor(fields: SubstrateCompatibilityPolicyFields = {}) { + this.legacyAuditReceiptTreeReads = fields.legacyAuditReceiptTreeReads === true; this.legacyContentBlobReads = fields.legacyContentBlobReads === true; this.legacyInlinePayloadReads = fields.legacyInlinePayloadReads === true; this.legacyPatchStorageReads = fields.legacyPatchStorageReads === true; + this.legacyStrandDescriptorBlobReads = fields.legacyStrandDescriptorBlobReads === true; this.legacyTrustRecordBlobReads = fields.legacyTrustRecordBlobReads === true; Object.freeze(this); } diff --git a/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts b/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts index 76e7605a..60be5b42 100644 --- a/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts +++ b/src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts @@ -4,9 +4,13 @@ import CommitMessageCodecPort, { type AnchorCommitMessage, type CheckpointCommitMessage, CHECKPOINT_STORAGE_FORMAT, + LEGACY_CHECKPOINT_STORAGE_FORMAT, + createLegacyGitCasPatchStorage, createGitCasPatchStorage, type CommitMessageKind, LEGACY_EXTERNAL_PATCH_STORAGE, + LEGACY_GIT_CAS_PATCH_STORAGE_FORMAT, + LEGACY_GIT_CAS_PATCH_STORAGE_SCHEMA, LEGACY_GIT_BLOB_PATCH_STORAGE, type PatchCommitMessage, PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH, @@ -14,7 +18,9 @@ import CommitMessageCodecPort, { type PatchStorageRoute, } from '../../ports/CommitMessageCodecPort.ts'; import MessageCodecError from '../../domain/errors/MessageCodecError.ts'; +import AssetHandle from '../../domain/storage/AssetHandle.ts'; import { validateGraphName, validateWriterId } from '../../domain/utils/RefLayout.ts'; +import { checkpointBundleTrailer, decodeCheckpointBundleHandle, encodeCheckpointBundleHandle } from './CheckpointCommitMessageBundleCodec.ts'; export type { AnchorCommitMessage, @@ -31,11 +37,13 @@ export const TRAILER_KEYS = Object.freeze({ writer: 'eg-writer', lamport: 'eg-lamport', patchOid: 'eg-patch-oid', + patchHandle: 'eg-patch-handle', stateHash: 'eg-state-hash', frontierOid: 'eg-frontier-oid', indexOid: 'eg-index-oid', schema: 'eg-schema', checkpointVersion: 'eg-checkpoint', + checkpointHandle: 'eg-checkpoint-handle', storageVersion: 'eg-storage-version', storageSchema: 'eg-storage-schema', encrypted: 'eg-encrypted', @@ -99,36 +107,53 @@ const legacyExternalStorageSchema = z.object({ }); const gitCasStorageSchema = z.object({ - strategy: z.literal('git-cas'), + strategy: z.literal('git-cas-asset'), version: z.literal(PATCH_STORAGE_FORMAT), schema: z.literal(PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH), encrypted: z.boolean(), }); -const patchStorageSchema = z.union([ - legacyGitBlobStorageSchema, - legacyExternalStorageSchema, - gitCasStorageSchema, -]); +const legacyGitCasStorageSchema = z.object({ + strategy: z.literal('legacy-git-cas'), + version: z.literal(LEGACY_GIT_CAS_PATCH_STORAGE_FORMAT), + schema: z.literal(LEGACY_GIT_CAS_PATCH_STORAGE_SCHEMA), + encrypted: z.boolean(), +}); -const patchCommitMessageSchema = z.object({ +const patchCommitMessageBaseSchema = z.object({ kind: z.literal('patch'), graph: graphNameSchema, writer: writerIdSchema, lamport: positiveIntegerSchema, - patchOid: oidSchema, schema: positiveIntegerSchema, - storage: patchStorageSchema, }); +const currentPatchCommitMessageSchema = patchCommitMessageBaseSchema.extend({ + patchHandle: z.string().min(1), + storage: gitCasStorageSchema, +}); + +const legacyPatchCommitMessageSchema = patchCommitMessageBaseSchema.extend({ + patchOid: oidSchema, + storage: z.union([ + legacyGitBlobStorageSchema, + legacyExternalStorageSchema, + legacyGitCasStorageSchema, + ]), +}); + +const patchCommitMessageSchema = z.union([ + currentPatchCommitMessageSchema, + legacyPatchCommitMessageSchema, +]); + const checkpointCommitMessageSchema = z.object({ kind: z.literal('checkpoint'), graph: graphNameSchema, stateHash: sha256Schema, - frontierOid: oidSchema, - indexOid: oidSchema, schema: positiveIntegerSchema, checkpointVersion: z.string().nullable(), + bundleHandle: z.string().nullable(), }); const anchorCommitMessageSchema = z.object({ @@ -228,16 +253,19 @@ function parsePatchStorageRoute(trailers: Record): PatchStorageR if (parsed.data.version === null) { return parsed.data.encrypted ? LEGACY_EXTERNAL_PATCH_STORAGE : LEGACY_GIT_BLOB_PATCH_STORAGE; } - const storage = gitCasStorageSchema.safeParse({ - strategy: 'git-cas', - version: parsed.data.version, - schema: parsed.data.schema, - encrypted: parsed.data.encrypted, - }); - if (!storage.success) { - throw messageCodecError(storage.error.issues[0]?.message ?? 'invalid git-cas patch storage trailers'); + if ( + parsed.data.version === LEGACY_GIT_CAS_PATCH_STORAGE_FORMAT + && parsed.data.schema === LEGACY_GIT_CAS_PATCH_STORAGE_SCHEMA + ) { + return createLegacyGitCasPatchStorage({ encrypted: parsed.data.encrypted }); + } + if ( + parsed.data.version === PATCH_STORAGE_FORMAT + && parsed.data.schema === PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH + ) { + return createGitCasPatchStorage({ encrypted: parsed.data.encrypted }); } - return createGitCasPatchStorage({ encrypted: storage.data.encrypted }); + throw messageCodecError('invalid git-cas patch storage trailers'); } export class TrailerCommitMessageCodecAdapter extends CommitMessageCodecPort { @@ -249,7 +277,14 @@ export class TrailerCommitMessageCodecAdapter extends CommitMessageCodecPort { } override encodePatch(message: PatchCommitMessage): string { - const parsed = patchCommitMessageSchema.safeParse(message); + const serializable = message.storage.strategy === 'git-cas-asset' + ? { ...message, patchHandle: message.patchHandle.toString() } + : { + ...message, + patchHandle: undefined, + patchOid: message.patchHandle.toString(), + }; + const parsed = patchCommitMessageSchema.safeParse(serializable); if (!parsed.success) { throw messageCodecError(parsed.error.issues[0]?.message ?? 'invalid patch commit message'); } @@ -258,12 +293,18 @@ export class TrailerCommitMessageCodecAdapter extends CommitMessageCodecPort { [TRAILER_KEYS.graph]: parsed.data.graph, [TRAILER_KEYS.writer]: parsed.data.writer, [TRAILER_KEYS.lamport]: String(parsed.data.lamport), - [TRAILER_KEYS.patchOid]: parsed.data.patchOid, [TRAILER_KEYS.schema]: String(parsed.data.schema), }; - if (parsed.data.storage.strategy === 'git-cas') { + if ('patchHandle' in parsed.data) { + trailers[TRAILER_KEYS.patchHandle] = parsed.data.patchHandle; trailers[TRAILER_KEYS.storageVersion] = parsed.data.storage.version; trailers[TRAILER_KEYS.storageSchema] = parsed.data.storage.schema; + } else { + trailers[TRAILER_KEYS.patchOid] = parsed.data.patchOid; + if (parsed.data.storage.strategy === 'legacy-git-cas') { + trailers[TRAILER_KEYS.storageVersion] = parsed.data.storage.version; + trailers[TRAILER_KEYS.storageSchema] = parsed.data.storage.schema; + } } if (parsed.data.storage.encrypted) { trailers[TRAILER_KEYS.encrypted] = 'true'; @@ -279,41 +320,51 @@ export class TrailerCommitMessageCodecAdapter extends CommitMessageCodecPort { if (trailers[TRAILER_KEYS.kind] !== 'patch') { throw messageCodecError(`${TRAILER_KEYS.kind} must be 'patch'`); } - const parsed = patchCommitMessageSchema.safeParse({ - kind: 'patch', - graph: requireTrailer(trailers, TRAILER_KEYS.graph), - writer: requireTrailer(trailers, TRAILER_KEYS.writer), - lamport: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.lamport), - patchOid: parseOidTrailer(trailers, TRAILER_KEYS.patchOid, 'patchOid'), - schema: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.schema), - storage: parsePatchStorageRoute(trailers), - }); + const storage = parsePatchStorageRoute(trailers); + const candidate = storage.strategy === 'git-cas-asset' + ? { + kind: 'patch' as const, + graph: requireTrailer(trailers, TRAILER_KEYS.graph), + writer: requireTrailer(trailers, TRAILER_KEYS.writer), + lamport: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.lamport), + patchHandle: requireTrailer(trailers, TRAILER_KEYS.patchHandle), + schema: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.schema), + storage, + } + : { + kind: 'patch' as const, + graph: requireTrailer(trailers, TRAILER_KEYS.graph), + writer: requireTrailer(trailers, TRAILER_KEYS.writer), + lamport: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.lamport), + patchOid: parseOidTrailer(trailers, TRAILER_KEYS.patchOid, 'patchOid'), + schema: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.schema), + storage, + }; + const parsed = patchCommitMessageSchema.safeParse(candidate); if (!parsed.success) { throw messageCodecError(parsed.error.issues[0]?.message ?? 'invalid patch commit message'); } - return parsed.data; + if ('patchHandle' in parsed.data) { + return { ...parsed.data, patchHandle: new AssetHandle(parsed.data.patchHandle) }; + } + const { patchOid, ...legacy } = parsed.data; + return { ...legacy, patchHandle: new AssetHandle(patchOid) }; } override encodeCheckpoint(message: CheckpointCommitMessage): string { - const parsed = checkpointCommitMessageSchema.safeParse({ - ...message, - checkpointVersion: message.checkpointVersion ?? CHECKPOINT_STORAGE_FORMAT, - }); + const parsed = checkpointCommitMessageSchema.safeParse(encodeCheckpointBundleHandle(message, CHECKPOINT_STORAGE_FORMAT)); if (!parsed.success) { throw messageCodecError(parsed.error.issues[0]?.message ?? 'invalid checkpoint commit message'); } - return this._codec.encode({ - title: 'warp:checkpoint', - trailers: { - [TRAILER_KEYS.kind]: 'checkpoint', - [TRAILER_KEYS.graph]: parsed.data.graph, - [TRAILER_KEYS.stateHash]: parsed.data.stateHash, - [TRAILER_KEYS.frontierOid]: parsed.data.frontierOid, - [TRAILER_KEYS.indexOid]: parsed.data.indexOid, - [TRAILER_KEYS.schema]: String(parsed.data.schema), - [TRAILER_KEYS.checkpointVersion]: parsed.data.checkpointVersion ?? CHECKPOINT_STORAGE_FORMAT, - }, - }); + const trailers: Record = { + [TRAILER_KEYS.kind]: 'checkpoint', + [TRAILER_KEYS.graph]: parsed.data.graph, + [TRAILER_KEYS.stateHash]: parsed.data.stateHash, + [TRAILER_KEYS.schema]: String(parsed.data.schema), + [TRAILER_KEYS.checkpointVersion]: parsed.data.checkpointVersion ?? CHECKPOINT_STORAGE_FORMAT, + ...checkpointBundleTrailer(TRAILER_KEYS.checkpointHandle, parsed.data.bundleHandle), + }; + return this._codec.encode({ title: 'warp:checkpoint', trailers }); } override decodeCheckpoint(message: string): CheckpointCommitMessage { @@ -325,15 +376,14 @@ export class TrailerCommitMessageCodecAdapter extends CommitMessageCodecPort { kind: 'checkpoint', graph: requireTrailer(trailers, TRAILER_KEYS.graph), stateHash: parseSha256Trailer(trailers, TRAILER_KEYS.stateHash, 'stateHash'), - frontierOid: parseOidTrailer(trailers, TRAILER_KEYS.frontierOid, 'frontierOid'), - indexOid: parseOidTrailer(trailers, TRAILER_KEYS.indexOid, 'indexOid'), schema: parsePositiveIntegerTrailer(trailers, TRAILER_KEYS.schema), checkpointVersion: trailers[TRAILER_KEYS.checkpointVersion] ?? null, + bundleHandle: trailers[TRAILER_KEYS.checkpointHandle] ?? null, }); if (!parsed.success) { throw messageCodecError(parsed.error.issues[0]?.message ?? 'invalid checkpoint commit message'); } - return parsed.data; + return decodeCheckpointBundleHandle(parsed.data); } override encodeAnchor(message: AnchorCommitMessage): string { @@ -394,14 +444,18 @@ function resolveCompatPatchStorage(params: EncodePatchCompatParams): PatchStorag } export function encodePatchMessage(params: EncodePatchCompatParams): string { + const storage = resolveCompatPatchStorage(params); + if (storage.strategy === 'git-cas-asset') { + throw messageCodecError('encodePatchMessage compatibility helper cannot encode asset handles'); + } return DEFAULT_COMMIT_MESSAGE_CODEC.encodePatch({ kind: 'patch', graph: params.graph, writer: params.writer, lamport: params.lamport, - patchOid: params.patchOid, + patchHandle: new AssetHandle(params.patchOid), schema: params.schema ?? 2, - storage: resolveCompatPatchStorage(params), + storage, }); } @@ -418,10 +472,9 @@ export function encodeCheckpointMessage(params: EncodeCheckpointCompatParams): s kind: 'checkpoint', graph: params.graph, stateHash: params.stateHash, - frontierOid: params.frontierOid, - indexOid: params.indexOid, schema: params.schema ?? 2, - checkpointVersion: params.checkpointVersion ?? CHECKPOINT_STORAGE_FORMAT, + checkpointVersion: params.checkpointVersion ?? LEGACY_CHECKPOINT_STORAGE_FORMAT, + bundleHandle: null, }); } diff --git a/src/ports/AssetStoragePort.ts b/src/ports/AssetStoragePort.ts new file mode 100644 index 00000000..ab99c89c --- /dev/null +++ b/src/ports/AssetStoragePort.ts @@ -0,0 +1,27 @@ +import type AssetHandle from '../domain/storage/AssetHandle.ts'; + +export type AssetWriteOptions = { + readonly slug: string; + readonly filename?: string; + readonly expectedSize?: number | null; +}; + +export type StagedAsset = Readonly<{ + handle: AssetHandle; + size: number; + observedAt: string; + retention: Readonly<{ + reachability: 'unanchored'; + protection: 'not-established'; + }>; +}>; + +/** Streaming storage boundary for immutable application assets. */ +export default abstract class AssetStoragePort { + abstract stage( + _source: AsyncIterable, + _options: AssetWriteOptions, + ): Promise; + + abstract open(_handle: AssetHandle): AsyncIterable; +} diff --git a/src/ports/AuditLogPort.ts b/src/ports/AuditLogPort.ts new file mode 100644 index 00000000..b871d829 --- /dev/null +++ b/src/ports/AuditLogPort.ts @@ -0,0 +1,35 @@ +import type StorageRetentionWitness from '../domain/storage/StorageRetentionWitness.ts'; +import type { StagedAsset } from './AssetStoragePort.ts'; + +export type AppendAuditRecordRequest = Readonly<{ + graphName: string; + writerId: string; + expectedHead: string | null; + parent: string | null; + message: string; + receipt: Uint8Array; +}>; + +export type PublishedAuditRecord = Readonly<{ + sha: string; + stagedReceipt: StagedAsset; + retention: StorageRetentionWitness; +}>; + +export type AuditLogEntry = Readonly<{ + sha: string; + message: string; + parents: readonly string[]; + receipt: Uint8Array; +}>; + +/** Semantic persistence boundary for append-only audit receipt chains. */ +export default abstract class AuditLogPort { + abstract readHead(_graphName: string, _writerId: string): Promise; + + abstract listWriterIds(_graphName: string): Promise; + + abstract append(_request: AppendAuditRecordRequest): Promise; + + abstract readEntry(_sha: string): Promise; +} diff --git a/src/ports/BlobPort.ts b/src/ports/BlobPort.ts deleted file mode 100644 index 15bce923..00000000 --- a/src/ports/BlobPort.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Port for Git blob operations. - * - * Defines the contract for writing and reading Git blob objects. - * This is one of five focused ports extracted from GraphPersistencePort. - * - * @see GraphPersistencePort - Composite port implementing all five focused ports - */ - -/** Port for Git blob operations. */ -export default abstract class BlobPort { - /** Writes content as a Git blob and returns its OID. */ - abstract writeBlob(_content: Uint8Array | string): Promise; - - /** Reads the content of a Git blob by OID. */ - abstract readBlob(_oid: string): Promise; -} diff --git a/src/ports/BlobStoragePort.ts b/src/ports/BlobStoragePort.ts deleted file mode 100644 index 3a677d55..00000000 --- a/src/ports/BlobStoragePort.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Port interface for content blob storage operations. - * - * Abstracts how large binary content is stored and retrieved. - * Concrete adapters may use git-cas (chunked, CDC-deduped, optionally - * encrypted) or raw Git blobs. - */ - -export interface BlobStorageOptions { - slug?: string; - mime?: string | null; - size?: number | null; -} - -/** Port for content blob storage operations. */ -export default abstract class BlobStoragePort { - /** Stores content and returns a storage identifier (e.g. CAS tree OID). */ - abstract store(_content: Uint8Array | string, _options?: BlobStorageOptions): Promise; - - /** Retrieves content by its storage identifier. */ - abstract retrieve(_oid: string): Promise; - - /** Stores content from a streaming source and returns a storage identifier. */ - abstract storeStream( - _source: AsyncIterable, - _options?: BlobStorageOptions, - ): Promise; - - /** Retrieves content as an async iterable of chunks. */ - abstract retrieveStream(_oid: string): AsyncIterable; - - /** Checks if the content exists in CAS storage. */ - async has?(_oid: string): Promise { - return false; - } -} diff --git a/src/ports/CheckpointStorePort.ts b/src/ports/CheckpointStorePort.ts index cbf0b622..f1177565 100644 --- a/src/ports/CheckpointStorePort.ts +++ b/src/ports/CheckpointStorePort.ts @@ -1,56 +1,86 @@ import type WarpState from '../domain/services/state/WarpState.ts'; import type VersionVector from '../domain/crdt/VersionVector.ts'; import type { ProvenanceIndex } from '../domain/services/provenance/ProvenanceIndex.ts'; +import type AssetHandle from '../domain/storage/AssetHandle.ts'; +import type BundleHandle from '../domain/storage/BundleHandle.ts'; +import type StorageRetentionWitness from '../domain/storage/StorageRetentionWitness.ts'; -/** - * Port for checkpoint persistence. - * - * A checkpoint is one domain event with multiple persistence artifacts. - * The port speaks one semantic operation (writeCheckpoint, readCheckpoint), - * not individual blob writes. The adapter internally fans artifacts out - * through the stream pipeline. - * - * @see CborCheckpointStoreAdapter - Reference implementation - */ - +/** Immutable checkpoint state and optional bounded-read index payloads. */ export interface CheckpointRecord { + graphName: string; state: WarpState; frontier: Map; appliedVV: VersionVector; stateHash: string; + parents: string[]; + expectedCheckpointSha?: string | null; provenanceIndex?: ProvenanceIndex | null; + indexShards?: Readonly> | null; } -export interface CheckpointWriteResult { - nodeAliveBlobOid: string; - edgeAliveBlobOid: string; - propBlobOid: string; - observedFrontierBlobOid: string; - edgeBirthEventBlobOid: string; - frontierBlobOid: string; - appliedVVBlobOid: string; - provenanceIndexBlobOid: string | null; +/** Causal identity returned after a checkpoint is published. */ +export interface PublishedCheckpoint { + checkpointSha: string; + bundleHandle: BundleHandle; + retention: StorageRetentionWitness; } +/** Complete state loaded from an immutable checkpoint publication. */ export interface CheckpointData { state: WarpState; frontier: Map; + stateHash: string; + schema: number; appliedVV: VersionVector | null; provenanceIndex?: ProvenanceIndex | null; - indexShardOids: Record | null; + indexShardHandles: Readonly> | null; } -/** Port for checkpoint persistence. */ +/** Bounded checkpoint support needed to prepare an optic basis. */ +export interface CheckpointBasis { + checkpointSha: string; + stateHash: string; + schema: number; + frontier: Map; + indexShardHandles: Readonly>; +} + +/** Metadata readable without opening checkpoint state or index payloads. */ +export interface CheckpointMetadata { + checkpointSha: string; + stateHash: string; + schema: number; +} + +/** + * Storage-neutral checkpoint lifecycle. + * + * Implementations own checkpoint encoding, object layout, causal commit + * publication, and compatibility reads. Domain services never assemble or + * traverse the physical storage tree. + */ export default abstract class CheckpointStorePort { - /** - * Persists a complete checkpoint and returns write results. - * - * The adapter internally encodes and writes state, frontier, - * appliedVV (and optionally provenanceIndex) as separate blobs, - * assembles a Git tree, and returns the OIDs. - */ - abstract writeCheckpoint(_record: CheckpointRecord): Promise; - - /** Reads a checkpoint from a tree of OIDs. */ - abstract readCheckpoint(_treeOids: Record): Promise; + abstract publishCheckpoint(_record: CheckpointRecord): Promise; + + abstract resolveHead(_graphName: string): Promise; + + abstract loadCheckpoint( + _checkpointSha: string, + _expectedGraphName?: string, + ): Promise; + + abstract readMetadata( + _checkpointSha: string, + _expectedGraphName?: string, + ): Promise; + + abstract loadBasis( + _checkpointSha: string, + _expectedGraphName?: string, + ): Promise; + + abstract publishCoverage(_options: { + graphName: string; + parents: string[]; + }): Promise; } diff --git a/src/ports/CommitMessageCodecPort.ts b/src/ports/CommitMessageCodecPort.ts index beb31bd0..ac209deb 100644 --- a/src/ports/CommitMessageCodecPort.ts +++ b/src/ports/CommitMessageCodecPort.ts @@ -1,6 +1,12 @@ -export const PATCH_STORAGE_FORMAT = 'v17'; -export const PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH = 'git-cas-cbor-patch-v1'; -export const CHECKPOINT_STORAGE_FORMAT = 'v5'; +import type AssetHandle from '../domain/storage/AssetHandle.ts'; +import type BundleHandle from '../domain/storage/BundleHandle.ts'; + +export const PATCH_STORAGE_FORMAT = 'v19'; +export const PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH = 'git-cas-asset-patch-v1'; +export const LEGACY_GIT_CAS_PATCH_STORAGE_FORMAT = 'v17'; +export const LEGACY_GIT_CAS_PATCH_STORAGE_SCHEMA = 'git-cas-cbor-patch-v1'; +export const CHECKPOINT_STORAGE_FORMAT = 'v19'; +export const LEGACY_CHECKPOINT_STORAGE_FORMAT = 'v5'; export type CommitMessageKind = 'patch' | 'checkpoint' | 'anchor' | 'audit'; @@ -18,17 +24,25 @@ export interface LegacyExternalPatchStorage { encrypted: true; } -export interface GitCasPatchStorage { - strategy: 'git-cas'; +export interface GitCasAssetPatchStorage { + strategy: 'git-cas-asset'; version: typeof PATCH_STORAGE_FORMAT; schema: typeof PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH; encrypted: boolean; } +export interface LegacyGitCasPatchStorage { + strategy: 'legacy-git-cas'; + version: typeof LEGACY_GIT_CAS_PATCH_STORAGE_FORMAT; + schema: typeof LEGACY_GIT_CAS_PATCH_STORAGE_SCHEMA; + encrypted: boolean; +} + export type PatchStorageRoute = | LegacyGitBlobPatchStorage | LegacyExternalPatchStorage - | GitCasPatchStorage; + | LegacyGitCasPatchStorage + | GitCasAssetPatchStorage; export const LEGACY_GIT_BLOB_PATCH_STORAGE: LegacyGitBlobPatchStorage = Object.freeze({ strategy: 'legacy-git-blob', @@ -50,37 +64,58 @@ export type GitCasPatchStorageOptions = { export function createGitCasPatchStorage( options: GitCasPatchStorageOptions, -): GitCasPatchStorage { +): GitCasAssetPatchStorage { return Object.freeze({ - strategy: 'git-cas', + strategy: 'git-cas-asset', version: PATCH_STORAGE_FORMAT, schema: PATCH_STORAGE_SCHEMA_GIT_CAS_CBOR_PATCH, encrypted: options.encrypted, }); } -export function isGitCasPatchStorage(storage: PatchStorageRoute): storage is GitCasPatchStorage { - return storage.strategy === 'git-cas'; +export function createLegacyGitCasPatchStorage( + options: GitCasPatchStorageOptions, +): LegacyGitCasPatchStorage { + return Object.freeze({ + strategy: 'legacy-git-cas', + version: LEGACY_GIT_CAS_PATCH_STORAGE_FORMAT, + schema: LEGACY_GIT_CAS_PATCH_STORAGE_SCHEMA, + encrypted: options.encrypted, + }); +} + +export function isGitCasPatchStorage( + storage: PatchStorageRoute, +): storage is GitCasAssetPatchStorage { + return storage.strategy === 'git-cas-asset'; +} + +export function isLegacyGitCasPatchStorage( + storage: PatchStorageRoute, +): storage is LegacyGitCasPatchStorage { + return storage.strategy === 'legacy-git-cas'; } -export interface PatchCommitMessage { +type PatchCommitMessageBase = { kind: 'patch'; graph: string; writer: string; lamport: number; - patchOid: string; schema: number; +}; + +export type PatchCommitMessage = PatchCommitMessageBase & { + patchHandle: AssetHandle; storage: PatchStorageRoute; -} +}; export interface CheckpointCommitMessage { kind: 'checkpoint'; graph: string; stateHash: string; - frontierOid: string; - indexOid: string; schema: number; checkpointVersion: string | null; + bundleHandle: BundleHandle | null; } export interface AnchorCommitMessage { diff --git a/src/ports/CommitPort.ts b/src/ports/CommitPort.ts index a00284c9..115616c2 100644 --- a/src/ports/CommitPort.ts +++ b/src/ports/CommitPort.ts @@ -26,13 +26,6 @@ export interface CommitNodeOptions { sign?: boolean; } -export interface CommitNodeWithTreeOptions { - treeOid: string; - parents?: string[]; - message: string; - sign?: boolean; -} - export interface LogNodesOptions { ref: string; limit?: number; @@ -72,18 +65,9 @@ export default abstract class CommitPort { /** Counts nodes reachable from a ref without loading them into memory. */ abstract countNodes(_ref: string): Promise; - /** - * Creates a commit pointing to a specified tree (not the empty tree). - * Used by CheckpointService and PatchBuilder for tree-backed commits. - */ - abstract commitNodeWithTree(_options: CommitNodeWithTreeOptions): Promise; - /** Checks whether a commit exists in the repository. */ abstract nodeExists(_sha: string): Promise; - /** Retrieves the tree OID for a given commit SHA. */ - abstract getCommitTree(_sha: string): Promise; - /** Pings the repository to verify accessibility. */ abstract ping(): Promise; } diff --git a/src/ports/GraphPersistencePort.ts b/src/ports/GraphPersistencePort.ts index d5e2d55f..d3a055cd 100644 --- a/src/ports/GraphPersistencePort.ts +++ b/src/ports/GraphPersistencePort.ts @@ -2,7 +2,6 @@ import type WarpStream from '../domain/stream/WarpStream.ts'; import type { CommitLogChunk, CommitNodeOptions, - CommitNodeWithTreeOptions, LogNodesOptions, NodeInfo, PingResult, @@ -16,16 +15,13 @@ import type { ListRefsOptions } from './RefPort.ts'; * storage layer. Concrete adapters (e.g., GitTimelineHistoryAdapter) implement this * interface to provide actual Git operations. * - * This is a **composite port** that implements the union of four focused ports: + * This is the causal timeline history boundary: * * - CommitPort -- commit creation, reading, logging, counting, ping - * - BlobPort -- blob read/write - * - TreePort -- tree read/write, emptyTree getter * - RefPort -- ref update/read/delete * - * Domain services should document which focused port(s) they actually depend on, - * even though they accept the full GraphPersistencePort at runtime. - * This enables future narrowing without breaking backward compatibility. + * Immutable assets and materialized indexes use their dedicated semantic + * storage ports. Raw object plumbing is infrastructure-only. */ /** Composite port for graph persistence operations. */ @@ -50,49 +46,12 @@ export default abstract class GraphPersistencePort { /** Counts nodes reachable from a ref without loading them into memory. */ abstract countNodes(_ref: string): Promise; - /** - * Creates a commit pointing to a specified tree (not the empty tree). - * Used by CheckpointService and PatchBuilder for tree-backed commits. - */ - abstract commitNodeWithTree(_options: CommitNodeWithTreeOptions): Promise; - /** Checks whether a commit exists in the repository. */ abstract nodeExists(_sha: string): Promise; - /** Retrieves the tree OID for a given commit SHA. */ - abstract getCommitTree(_sha: string): Promise; - /** Pings the repository to verify accessibility. */ abstract ping(): Promise; - // -- BlobPort surface -- - - /** Writes content as a Git blob and returns its OID. */ - abstract writeBlob(_content: Uint8Array | string): Promise; - - /** Reads the content of a Git blob by OID. */ - abstract readBlob(_oid: string): Promise; - - // -- TreePort surface -- - - /** Creates a Git tree from mktree-formatted entries. */ - abstract writeTree(_entries: string[]): Promise; - - /** Reads a tree and returns a map of path to content. */ - abstract readTree(_treeOid: string): Promise>; - - /** - * Reads a tree and returns a map of path to blob OID. - * Useful for lazy-loading shards without reading all blob contents. - */ - abstract readTreeOids(_treeOid: string): Promise>; - - /** - * The well-known SHA for Git's empty tree object. - * All WARP graph commits point to this tree so that no files appear in the working directory. - */ - abstract get emptyTree(): string; - // -- RefPort surface -- /** Updates a ref to point to an OID. */ diff --git a/src/ports/IndexStoragePort.ts b/src/ports/IndexStoragePort.ts deleted file mode 100644 index ba994256..00000000 --- a/src/ports/IndexStoragePort.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Port interface for bitmap index storage operations. - * - * This port defines the contract for persisting and retrieving - * the sharded bitmap index data. Adapters implement this interface - * to store indexes in different backends (Git, filesystem, etc.). - * - * This port is a subset of the focused ports: it uses methods from - * BlobPort (writeBlob, readBlob), TreePort (writeTree, readTreeOids), - * and RefPort (updateRef, readRef). - */ - -/** Port for bitmap index storage operations. */ -export default abstract class IndexStoragePort { - // -- BlobPort subset -- - - /** Writes content as a Git blob and returns its OID. */ - abstract writeBlob(_content: Uint8Array | string): Promise; - - /** Reads the content of a Git blob by OID. */ - abstract readBlob(_oid: string): Promise; - - // -- TreePort subset -- - - /** Creates a Git tree from mktree-formatted entries. */ - abstract writeTree(_entries: string[]): Promise; - - /** - * Reads a tree and returns a map of path to blob OID. - * Useful for lazy-loading shards without reading all blob contents. - */ - abstract readTreeOids(_treeOid: string): Promise>; - - // -- RefPort subset -- - - /** Updates a ref to point to an OID. */ - abstract updateRef(_ref: string, _oid: string): Promise; - - /** Reads the OID a ref points to, or null if the ref does not exist. */ - abstract readRef(_ref: string): Promise; -} diff --git a/src/ports/IndexStorePort.ts b/src/ports/IndexStorePort.ts index 5fba641b..020878ce 100644 --- a/src/ports/IndexStorePort.ts +++ b/src/ports/IndexStorePort.ts @@ -1,23 +1,25 @@ import type WarpStream from '../domain/stream/WarpStream.ts'; import type { IndexShard } from '../domain/artifacts/IndexShard.ts'; import type CodecValue from '../domain/types/codec/CodecValue.ts'; +import type AssetHandle from '../domain/storage/AssetHandle.ts'; +import type BundleHandle from '../domain/storage/BundleHandle.ts'; /** * IndexStorePort — domain-facing port for index shard persistence. * - * Speaks `IndexShard` domain objects and `WarpStream`. No bytes - * cross this boundary. The adapter owns the codec and talks to raw - * Git ports (BlobPort, TreePort) internally. + * Speaks `IndexShard` domain objects, opaque storage handles, and + * `WarpStream`. The adapter owns encoding and delegates immutable + * asset and bundle lifecycles to configured storage. * * Two-stage persistence boundary (P5 compliance): * Domain Service → IndexStorePort (domain objects) - * → Adapter (codec + raw Git ports) → Git + * → Adapter (codec + asset/bundle capabilities) → storage * * Returns are typed via `CodecValue` (the structured-codec transport * union) when the payload is heterogeneous — e.g. `decodeShard` is * called for meta, edge, label, property, and receipt shards that * share no common class. Callers narrow via per-call generic - * specialization: `decodeShard(oid)`. + * specialization: `decodeShard(handle)`. * * @see CborIndexStoreAdapter - reference implementation * @@ -27,43 +29,48 @@ import type CodecValue from '../domain/types/codec/CodecValue.ts'; /** Port for index shard persistence. */ export default abstract class IndexStorePort { /** - * Persists a stream of `IndexShard` records as a Git tree. + * Stages a stream of `IndexShard` records as an ordered bundle. * - * The adapter internally encodes each shard, writes blobs, and - * assembles a sorted tree. Returns the tree OID. + * The adapter internally encodes and stages each shard as an asset, + * then assembles the opaque handles into a deterministic bundle. */ - abstract writeShards(_shardStream: WarpStream): Promise; + abstract writeShards(_shardStream: WarpStream): Promise; /** - * Scans all shards in an index tree, yielding `IndexShard` + * Scans all shards in an index bundle, yielding `IndexShard` * records. * * Unbounded streaming alternative to reading all blobs at once. - * The adapter reads tree entries, decodes blobs, classifies by + * The adapter reads bundle members, decodes assets, classifies by * path pattern, and constructs the appropriate `IndexShard` * subclass. */ - abstract scanShards(_treeOid: string): WarpStream; + abstract scanShards(_indexHandle: BundleHandle): WarpStream; /** - * Reads the path-to-OID mapping from an index tree. + * Reads the path-to-handle mapping from an index bundle. * - * Bounded operation — returns the tree directory listing without - * reading or decoding any blob contents. + * Bounded operation — returns member descriptors without reading or + * decoding any shard contents. */ - abstract readShardOids(_treeOid: string): Promise>; + abstract readShardHandles( + _indexHandle: BundleHandle, + ): Promise>>; + + /** Streams one encoded shard without opening unrelated index members. */ + abstract openShard(_shardHandle: AssetHandle): AsyncIterable; /** - * Reads and decodes a single shard blob by OID. + * Reads and decodes a single shard asset by opaque handle. * * Bounded operation — reads one blob and returns the decoded * structured value. The default return type is the * structured-codec transport union `CodecValue`; callers that * know which shard class the blob was encoded from specialize * the per-call generic parameter, e.g. - * `decodeShard(oid)`. + * `decodeShard(handle)`. */ abstract decodeShard( - _blobOid: string, + _shardHandle: AssetHandle, ): Promise; } diff --git a/src/ports/IntentStorePort.ts b/src/ports/IntentStorePort.ts new file mode 100644 index 00000000..78cf3a87 --- /dev/null +++ b/src/ports/IntentStorePort.ts @@ -0,0 +1,30 @@ +import type WarpStream from '../domain/stream/WarpStream.ts'; +import type StorageRetentionWitness from '../domain/storage/StorageRetentionWitness.ts'; +import type { WarpIntentDescriptor } from '../domain/types/WarpIntentDescriptor.ts'; +import type { StagedAsset } from './AssetStoragePort.ts'; + +export type IntentChannel = 'admitted' | 'queued'; + +export type PublishIntentRequest = Readonly<{ + graphName: string; + channel: IntentChannel; + ownerId: string; + descriptor: WarpIntentDescriptor; +}>; + +export type PublishedIntent = Readonly<{ + sha: string; + descriptorAsset: StagedAsset; + retention: StorageRetentionWitness; +}>; + +/** Durable journal for unmaterialized intent descriptors. */ +export default abstract class IntentStorePort { + abstract publish(_request: PublishIntentRequest): Promise; + + abstract scan( + _graphName: string, + _channel: IntentChannel, + _ownerId: string, + ): WarpStream; +} diff --git a/src/ports/PatchJournalPort.ts b/src/ports/PatchJournalPort.ts index ff48cc94..9eaf233e 100644 --- a/src/ports/PatchJournalPort.ts +++ b/src/ports/PatchJournalPort.ts @@ -1,61 +1,38 @@ -import type Patch from '../domain/types/Patch.ts'; -import type WarpStream from '../domain/stream/WarpStream.ts'; import type PatchEntry from '../domain/artifacts/PatchEntry.ts'; -import { - LEGACY_GIT_BLOB_PATCH_STORAGE, - type PatchStorageRoute, -} from './CommitMessageCodecPort.ts'; +import type AssetHandle from '../domain/storage/AssetHandle.ts'; +import type BundleHandle from '../domain/storage/BundleHandle.ts'; +import type StorageRetentionWitness from '../domain/storage/StorageRetentionWitness.ts'; +import type WarpStream from '../domain/stream/WarpStream.ts'; +import type Patch from '../domain/types/Patch.ts'; +import type { PatchCommitMessage } from './CommitMessageCodecPort.ts'; +import type { StagedAsset } from './AssetStoragePort.ts'; -/** - * Port for patch journal persistence. - * - * Domain-facing port that speaks Patch domain objects. No bytes cross - * this boundary. The adapter implementation owns the codec and talks to - * the raw Git ports (BlobPort, BlobStoragePort) internally. - * - * This is part of the two-stage persistence boundary (P5 compliance): - * Domain Service -> PatchJournalPort (domain objects) - * -> Adapter (codec + raw Git ports) -> Git - * - * @see CborPatchJournalAdapter - Reference implementation - */ +export type AppendPatchRequest = Readonly<{ + patch: Patch; + graph: string; + writer: string; + targetRef: string; + expectedHead: string | null; + parent: string | null; + attachments: readonly AssetHandle[]; +}>; -export interface ReadPatchOptions { - storage?: PatchStorageRoute; - encrypted?: boolean; -} +export type PublishedPatch = Readonly<{ + sha: string; + bundleHandle: BundleHandle; + stagedPatch: StagedAsset; + retention: StorageRetentionWitness; +}>; -/** Port for patch journal persistence. */ +/** Semantic persistence boundary for causal patch history. */ export default abstract class PatchJournalPort { - /** Persists a patch and returns its storage OID. */ - abstract writePatch(_patch: Patch): Promise; - - /** Reads a patch by its storage OID. */ - abstract readPatch(_patchOid: string, _options?: ReadPatchOptions): Promise; - - /** Describes the storage route used for newly written patches. */ - get writeStorage(): PatchStorageRoute { - return LEGACY_GIT_BLOB_PATCH_STORAGE; - } + /** Stages, bundles, and causally publishes one patch plus its attachments. */ + abstract appendPatch(_request: AppendPatchRequest): Promise; - /** - * Whether this journal uses external blob storage. - * - * When true, readers must use the `encrypted` flag in the commit - * message trailer to retrieve blobs via BlobStoragePort rather than - * reading them directly from Git. - */ - get usesExternalStorage(): boolean { - return this.writeStorage.strategy !== 'legacy-git-blob'; - } + /** Reads a patch through its decoded commit locator, including legacy routes. */ + abstract readPatch(_message: PatchCommitMessage): Promise; - /** - * Scans patches in a writer's chain between two SHAs, yielding - * PatchEntry instances in chronological order (oldest first). - * - * This is the unbounded streaming alternative to the legacy - * loadPatchRange() which returns a whole array. - */ + /** Streams a writer's chronological patch range. */ abstract scanPatchRange( _writerId: string, _fromSha: string | null, diff --git a/src/ports/RuntimeStorageProviderPort.ts b/src/ports/RuntimeStorageProviderPort.ts index aea0afd9..dc497cf5 100644 --- a/src/ports/RuntimeStorageProviderPort.ts +++ b/src/ports/RuntimeStorageProviderPort.ts @@ -1,11 +1,14 @@ import type TrieStorePort from '../domain/orset/trie/TrieStorePort.ts'; -import type BlobStoragePort from './BlobStoragePort.ts'; +import type AssetStoragePort from './AssetStoragePort.ts'; +import type AuditLogPort from './AuditLogPort.ts'; import type CheckpointStorePort from './CheckpointStorePort.ts'; import type CodecPort from './CodecPort.ts'; import type CommitMessageCodecPort from './CommitMessageCodecPort.ts'; import type IndexStorePort from './IndexStorePort.ts'; +import type IntentStorePort from './IntentStorePort.ts'; import type LoggerPort from './LoggerPort.ts'; import type PatchJournalPort from './PatchJournalPort.ts'; +import type StrandStorePort from './StrandStorePort.ts'; import type WarpStateCachePort from './WarpStateCachePort.ts'; import type WarpStateCacheRetentionPort from './WarpStateCacheRetentionPort.ts'; @@ -14,15 +17,16 @@ export type RuntimeStorageRequest = { readonly codec: CodecPort; readonly commitMessageCodec: CommitMessageCodecPort; readonly logger?: LoggerPort; - readonly contentOverride?: BlobStoragePort; - readonly patchContentOverride?: BlobStoragePort; }; export type RuntimeStorageServices = { - readonly content: BlobStoragePort; + readonly content: AssetStoragePort; + readonly auditLog: AuditLogPort; readonly patchJournal: PatchJournalPort; + readonly strands: StrandStorePort; readonly checkpoints: CheckpointStorePort; readonly indexes: IndexStorePort; + readonly intents: IntentStorePort; readonly stateSnapshots?: WarpStateCachePort & WarpStateCacheRetentionPort; readonly trie?: TrieStorePort; }; diff --git a/src/ports/StrandStorePort.ts b/src/ports/StrandStorePort.ts new file mode 100644 index 00000000..a7e98c52 --- /dev/null +++ b/src/ports/StrandStorePort.ts @@ -0,0 +1,33 @@ +import type AssetHandle from '../domain/storage/AssetHandle.ts'; +import type BundleHandle from '../domain/storage/BundleHandle.ts'; +import type StorageRetentionWitness from '../domain/storage/StorageRetentionWitness.ts'; +import type { StagedAsset } from './AssetStoragePort.ts'; + +export type PublishStrandDescriptorRequest = Readonly<{ + graphName: string; + strandId: string; + descriptor: Uint8Array; + attachments: readonly AssetHandle[]; +}>; + +export type PublishedStrandDescriptor = Readonly<{ + revision: string; + descriptorAsset: StagedAsset; + bundleHandle: BundleHandle; + retention: StorageRetentionWitness; +}>; + +/** Semantic persistence boundary for retained strand descriptor state. */ +export default abstract class StrandStorePort { + abstract readDescriptor(_graphName: string, _strandId: string): Promise; + + abstract publishDescriptor( + _request: PublishStrandDescriptorRequest, + ): Promise; + + abstract listStrandIds(_graphName: string): Promise; + + abstract hasDescriptor(_graphName: string, _strandId: string): Promise; + + abstract deleteDescriptor(_graphName: string, _strandId: string): Promise; +} diff --git a/src/ports/TreeEntryProbePort.ts b/src/ports/TreeEntryProbePort.ts deleted file mode 100644 index 74ebd193..00000000 --- a/src/ports/TreeEntryProbePort.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type TreeEntryFound from '../domain/tree/TreeEntryFound.ts'; -import type TreeEntryLimit from '../domain/tree/TreeEntryLimit.ts'; -import type TreeEntryMissing from '../domain/tree/TreeEntryMissing.ts'; -import type TreeEntryPath from '../domain/tree/TreeEntryPath.ts'; -import type TreeEntryPrefixBatch from '../domain/tree/TreeEntryPrefixBatch.ts'; - -export type TreeEntryProbeResult = TreeEntryFound | TreeEntryMissing; - -export default abstract class TreeEntryProbePort { - abstract readTreeEntryOid( - _treeOid: string, - _path: TreeEntryPath, - ): Promise; - - abstract readTreeEntryPrefix( - _treeOid: string, - _prefix: TreeEntryPath, - _limit: TreeEntryLimit, - ): Promise; -} diff --git a/src/ports/TreePort.ts b/src/ports/TreePort.ts deleted file mode 100644 index c9cb3489..00000000 --- a/src/ports/TreePort.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Port for Git tree operations. - * - * Defines the contract for writing and reading Git tree objects. - * This is one of five focused ports extracted from GraphPersistencePort. - * - * @see GraphPersistencePort - Composite port implementing all five focused ports - */ - -/** Port for Git tree operations. */ -export default abstract class TreePort { - /** Creates a Git tree from mktree-formatted entries. */ - abstract writeTree(_entries: string[]): Promise; - - /** Reads a tree and returns a map of path to content. */ - abstract readTree(_treeOid: string): Promise>; - - /** - * Reads a tree and returns a map of path to blob OID. - * Useful for lazy-loading shards without reading all blob contents. - */ - abstract readTreeOids(_treeOid: string): Promise>; - - /** - * The well-known SHA for Git's empty tree object. - * All WARP graph commits point to this tree so that no files appear in the working directory. - */ - abstract get emptyTree(): string; -} diff --git a/src/ports/TrustChainPort.ts b/src/ports/TrustChainPort.ts index 9089e93a..9a7bc96f 100644 --- a/src/ports/TrustChainPort.ts +++ b/src/ports/TrustChainPort.ts @@ -11,6 +11,7 @@ */ import type { TrustRecord } from '../domain/trust/TrustRecord.ts'; +import type StorageRetentionWitness from '../domain/storage/StorageRetentionWitness.ts'; /** Result of reading the chain tip. */ type TrustChainTip = { @@ -27,6 +28,11 @@ type CasConflictDetail = { readonly actualTipRecordId: string | null; }; +export type TrustRecordPublication = Readonly<{ + commitSha: string; + retention: StorageRetentionWitness; +}>; + /** Port for trust record chain persistence. */ export default abstract class TrustChainPort { /** @@ -63,7 +69,7 @@ export default abstract class TrustChainPort { graphName: string, record: TrustRecord, parentTipSha: string | null, - ): Promise; + ): Promise; } export type { TrustChainTip, CasConflictDetail }; diff --git a/src/ports/WarpKernelPort.ts b/src/ports/WarpKernelPort.ts index df821645..d0663204 100644 --- a/src/ports/WarpKernelPort.ts +++ b/src/ports/WarpKernelPort.ts @@ -1,14 +1,10 @@ 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. + * `WarpKernelPort` names causal commit and ref history. Immutable payloads, + * checkpoints, and indexes use separate semantic storage ports. */ -export default interface WarpKernelPort extends CommitPort, BlobPort, TreePort, RefPort {} +export default interface WarpKernelPort extends CommitPort, RefPort {} diff --git a/test/benchmark/logicalIndex.benchmark.ts b/test/benchmark/logicalIndex.benchmark.ts index 396f6cb0..7204c53f 100644 --- a/test/benchmark/logicalIndex.benchmark.ts +++ b/test/benchmark/logicalIndex.benchmark.ts @@ -135,20 +135,13 @@ describe('Logical Index Benchmarks', () => { } const shards = /** @type {Array} */ ([...builder.yieldShards()]); - // Create mock storage by encoding PropertyShard entries via CBOR - const blobs = new Map(); - /** @type {Record} */ - const oids = {}; - let oidCounter = 0; + const tree: Record = {}; for (const shard of shards) { const path = `props_${shard.shardKey}.cbor`; - const oid = `oid_${oidCounter++}`; - blobs.set(oid, codec.encode(shard.entries)); - oids[path] = oid; + tree[path] = codec.encode(shard.entries); } - const storage = { readBlob: async (oid: string) => blobs.get(oid) } as unknown as import('../../src/ports/IndexStoragePort.ts').default; - const reader = new PropertyIndexReader({ storage }); - reader.setup(oids); + const reader = new PropertyIndexReader({ codec }); + reader.setupTree(tree); const { median: lookupMs } = await runBenchmark( async () => { diff --git a/test/conformance/comparisonLiveCoordinateSeam.test.ts b/test/conformance/comparisonLiveCoordinateSeam.test.ts index fce51878..2c9c176d 100644 --- a/test/conformance/comparisonLiveCoordinateSeam.test.ts +++ b/test/conformance/comparisonLiveCoordinateSeam.test.ts @@ -190,16 +190,18 @@ function poisonHost(): ComparisonHost { _crypto: defaultCrypto, _codec: defaultCodec, _stateHashService: null, - _blobStorage: { - retrieve: vi.fn(async () => failPoisonHostCall()), - }, - _persistence: { - readBlob: vi.fn(async () => failPoisonHostCall()), + _assetStorage: { + stage: vi.fn(async () => failPoisonHostCall()), + open: vi.fn(() => poisonAssetStream()), }, _materializeStrandGraph: vi.fn(async () => failPoisonHostCall()), }; } +async function* poisonAssetStream(): AsyncGenerator { + yield failPoisonHostCall(); +} + function failPoisonHostCall(): never { throw new Error('coordinate-backed selector touched the host seam'); } diff --git a/test/conformance/fixtures/V17CheckpointBasisArtifactFixture.ts b/test/conformance/fixtures/V17CheckpointBasisArtifactFixture.ts deleted file mode 100644 index f926443a..00000000 --- a/test/conformance/fixtures/V17CheckpointBasisArtifactFixture.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { buildCheckpointRef } from '../../../src/domain/utils/RefLayout.ts'; -import type { OpticFixtureGraph } from './V17CheckpointTailOpticGraphFixture.ts'; -import V17CheckpointTailOpticFixtureError from './V17CheckpointTailOpticFixtureError.ts'; - -export default class V17CheckpointBasisArtifactFixture { - private readonly frontierOidValue: string; - - private constructor(options: { - readonly frontierOid: string; - }) { - if (options.frontierOid.length === 0) { - throw new V17CheckpointTailOpticFixtureError('checkpoint frontier oid must be non-empty'); - } - - this.frontierOidValue = options.frontierOid; - Object.freeze(this); - } - - static async load(graph: OpticFixtureGraph): Promise { - const checkpointSha = await graph._persistence.readRef(buildCheckpointRef(graph.graphName)); - if (checkpointSha === null) { - throw new V17CheckpointTailOpticFixtureError('indexed checkpoint fixture must publish a checkpoint ref'); - } - - const checkpointMessage = graph._commitMessageCodec.decodeCheckpoint( - await graph._persistence.showNode(checkpointSha), - ); - return new V17CheckpointBasisArtifactFixture({ - frontierOid: checkpointMessage.frontierOid, - }); - } - - frontierOid(): string { - return this.frontierOidValue; - } -} diff --git a/test/conformance/fixtures/V17CheckpointIndexTreeFixture.ts b/test/conformance/fixtures/V17CheckpointIndexTreeFixture.ts index 21a50048..4d7de634 100644 --- a/test/conformance/fixtures/V17CheckpointIndexTreeFixture.ts +++ b/test/conformance/fixtures/V17CheckpointIndexTreeFixture.ts @@ -1,87 +1,48 @@ -import type { CheckpointCommitMessage } from '../../../src/ports/CommitMessageCodecPort.ts'; -import { CURRENT_CHECKPOINT_SCHEMA } from '../../../src/domain/services/state/checkpointHelpers.ts'; -import { buildCheckpointRef } from '../../../src/domain/utils/RefLayout.ts'; +import { vi } from 'vitest'; +import type { CheckpointBasis } from '../../../src/ports/CheckpointStorePort.ts'; import type { OpticFixtureGraph } from './V17CheckpointTailOpticGraphFixture.ts'; import V17CheckpointTailOpticFixtureError from './V17CheckpointTailOpticFixtureError.ts'; +/** Injects bounded-basis failures through the semantic checkpoint port. */ export default class V17CheckpointIndexTreeFixture { private readonly graph: OpticFixtureGraph; - private readonly checkpointMessage: CheckpointCommitMessage; - private readonly checkpointParents: readonly string[]; + private readonly checkpointSha: string; private constructor(options: { readonly graph: OpticFixtureGraph; readonly checkpointSha: string; - readonly checkpointMessage: CheckpointCommitMessage; - readonly checkpointParents: readonly string[]; }) { - if (options.checkpointSha.length === 0) { - throw new V17CheckpointTailOpticFixtureError('checkpoint sha must be non-empty'); - } - this.graph = options.graph; - this.checkpointMessage = options.checkpointMessage; - this.checkpointParents = Object.freeze([...options.checkpointParents]); + this.checkpointSha = options.checkpointSha; Object.freeze(this); } static async load(graph: OpticFixtureGraph): Promise { - const checkpointSha = await graph._persistence.readRef(buildCheckpointRef(graph.graphName)); + const checkpointSha = await graph._checkpointStore.resolveHead(graph.graphName); if (checkpointSha === null) { - throw new V17CheckpointTailOpticFixtureError('indexed checkpoint fixture must publish a checkpoint ref'); + throw new V17CheckpointTailOpticFixtureError( + 'indexed checkpoint fixture must publish a checkpoint', + ); } - - return new V17CheckpointIndexTreeFixture({ - graph, - checkpointSha, - checkpointMessage: graph._commitMessageCodec.decodeCheckpoint( - await graph._persistence.showNode(checkpointSha), - ), - checkpointParents: (await graph._persistence.getNodeInfo(checkpointSha)).parents, - }); + return new V17CheckpointIndexTreeFixture({ graph, checkpointSha }); } async replaceWithEmptyIndexTree(): Promise { - const rootTreeOids = await this.graph._persistence.readTreeOids(this.checkpointMessage.indexOid); - const emptyIndexTreeOid = await this.graph._persistence.writeTree([]); - const replacementRootTreeOid = await this.graph._persistence.writeTree([ - ...nonIndexRootBlobEntries(rootTreeOids), - `040000 tree ${emptyIndexTreeOid}\tindex`, - ].sort()); - const replacementSha = await this.graph._persistence.commitNodeWithTree({ - treeOid: replacementRootTreeOid, - parents: [...this.checkpointParents], - message: this.graph._commitMessageCodec.encodeCheckpoint({ - ...this.checkpointMessage, - schema: CURRENT_CHECKPOINT_SCHEMA, - indexOid: replacementRootTreeOid, - }), - }); - await this.graph._persistence.updateRef(buildCheckpointRef(this.graph.graphName), replacementSha); - } - - async replaceWithCheckpointWithoutIndexTree(): Promise { - const rootTreeOids = await this.graph._persistence.readTreeOids(this.checkpointMessage.indexOid); - const replacementRootTreeOid = await this.graph._persistence.writeTree([ - ...nonIndexRootBlobEntries(rootTreeOids), - ].sort()); - const replacementSha = await this.graph._persistence.commitNodeWithTree({ - treeOid: replacementRootTreeOid, - parents: [...this.checkpointParents], - message: this.graph._commitMessageCodec.encodeCheckpoint({ - ...this.checkpointMessage, - schema: CURRENT_CHECKPOINT_SCHEMA, - indexOid: replacementRootTreeOid, - }), + const store = this.graph._checkpointStore; + const originalLoadBasis = store.loadBasis.bind(store); + const basis = await originalLoadBasis(this.checkpointSha); + vi.spyOn(store, 'loadBasis').mockImplementation(async (checkpointSha: string) => { + if (checkpointSha !== this.checkpointSha) { + return await originalLoadBasis(checkpointSha); + } + return emptyIndexBasis(basis); }); - await this.graph._persistence.updateRef(buildCheckpointRef(this.graph.graphName), replacementSha); } } -function nonIndexRootBlobEntries(rootTreeOids: Record): readonly string[] { - return Object.freeze( - Object.entries(rootTreeOids) - .filter(([path]) => path !== 'index' && !path.startsWith('index/')) - .map(([path, oid]) => `100644 blob ${oid}\t${path}`), - ); +function emptyIndexBasis(basis: CheckpointBasis): CheckpointBasis { + return Object.freeze({ + ...basis, + indexShardHandles: Object.freeze({}), + }); } diff --git a/test/conformance/fixtures/V17CheckpointPayloadBlobFixture.ts b/test/conformance/fixtures/V17CheckpointPayloadBlobFixture.ts deleted file mode 100644 index bcba9b43..00000000 --- a/test/conformance/fixtures/V17CheckpointPayloadBlobFixture.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { vi } from 'vitest'; -import { textEncode } from '../../../src/domain/utils/bytes.ts'; -import type { OpticFixtureGraph } from './V17CheckpointTailOpticGraphFixture.ts'; -import V17CheckpointTailOpticFixtureError from './V17CheckpointTailOpticFixtureError.ts'; - -const EMPTY_CAS_POINTER = 'git-warp:cas-pointer:v1:'; - -export default class V17CheckpointPayloadBlobFixture { - private readonly graph: OpticFixtureGraph; - private readonly payloadOid: string; - - constructor(options: { - readonly graph: OpticFixtureGraph; - readonly payloadOid: string; - }) { - if (options.payloadOid.length === 0) { - throw new V17CheckpointTailOpticFixtureError('checkpoint payload oid must be non-empty'); - } - - this.graph = options.graph; - this.payloadOid = options.payloadOid; - Object.freeze(this); - } - - makeEmptyCasPointer(): void { - const originalReadBlob = this.graph._persistence.readBlob.bind(this.graph._persistence); - vi.spyOn(this.graph._persistence, 'readBlob').mockImplementation(async (oid: string) => { - if (oid === this.payloadOid) { - return textEncode(EMPTY_CAS_POINTER); - } - - return await originalReadBlob(oid); - }); - } -} diff --git a/test/conformance/fixtures/V17CheckpointShardIndexFixture.ts b/test/conformance/fixtures/V17CheckpointShardIndexFixture.ts index d3d83443..61257679 100644 --- a/test/conformance/fixtures/V17CheckpointShardIndexFixture.ts +++ b/test/conformance/fixtures/V17CheckpointShardIndexFixture.ts @@ -1,68 +1,53 @@ -import { partitionShardOids } from '../../../src/domain/services/MaterializedViewHelpers.ts'; -import { partitionTreeOids } from '../../../src/domain/services/state/checkpointHelpers.ts'; -import { buildCheckpointRef } from '../../../src/domain/utils/RefLayout.ts'; +import { partitionShardHandles } from '../../../src/domain/services/MaterializedViewHelpers.ts'; import computeShardKey from '../../../src/domain/utils/shardKey.ts'; +import type AssetHandle from '../../../src/domain/storage/AssetHandle.ts'; import type { OpticFixtureGraph } from './V17CheckpointTailOpticGraphFixture.ts'; import V17CheckpointTailOpticFixtureError from './V17CheckpointTailOpticFixtureError.ts'; export default class V17CheckpointShardIndexFixture { - private readonly indexOids: Record; - private readonly propOids: Record; + private readonly indexHandles: Readonly>; + private readonly propHandles: Readonly>; private constructor(options: { - readonly indexOids: Record; - readonly propOids: Record; + readonly indexHandles: Readonly>; + readonly propHandles: Readonly>; }) { - this.indexOids = Object.freeze({ ...options.indexOids }); - this.propOids = Object.freeze({ ...options.propOids }); + this.indexHandles = options.indexHandles; + this.propHandles = options.propHandles; Object.freeze(this); } static async load(graph: OpticFixtureGraph): Promise { - const checkpointSha = await graph._persistence.readRef(buildCheckpointRef(graph.graphName)); + const checkpointSha = await graph._checkpointStore.resolveHead(graph.graphName); if (checkpointSha === null) { - throw new V17CheckpointTailOpticFixtureError('indexed checkpoint fixture must publish a checkpoint ref'); + throw new V17CheckpointTailOpticFixtureError( + 'indexed checkpoint fixture must publish a checkpoint', + ); } - - const checkpointMessage = graph._commitMessageCodec.decodeCheckpoint( - await graph._persistence.showNode(checkpointSha), - ); - const { treeOids, indexShardOids } = partitionTreeOids( - await graph._persistence.readTreeOids(checkpointMessage.indexOid), + const basis = await graph._checkpointStore.loadBasis(checkpointSha); + return new V17CheckpointShardIndexFixture( + partitionShardHandles(basis.indexShardHandles), ); - const shardOids = Object.keys(indexShardOids).length > 0 - ? indexShardOids - : await V17CheckpointShardIndexFixture.nestedIndexShardOids(graph, treeOids); - const { indexOids, propOids } = partitionShardOids(shardOids); - return new V17CheckpointShardIndexFixture({ indexOids, propOids }); } - nodeLivenessShardOid(nodeId: string): string { - return this.requireShardOid(this.indexOids, `meta_${computeShardKey(nodeId)}.cbor`); + nodeLivenessShardOid(nodeId: string): AssetHandle { + return this.requireShardHandle(this.indexHandles, `meta_${computeShardKey(nodeId)}.cbor`); } - propertyShardOid(nodeId: string): string { - return this.requireShardOid(this.propOids, `props_${computeShardKey(nodeId)}.cbor`); + propertyShardOid(nodeId: string): AssetHandle { + return this.requireShardHandle(this.propHandles, `props_${computeShardKey(nodeId)}.cbor`); } - private requireShardOid(shardOids: Record, path: string): string { - const oid = shardOids[path]; - if (oid === undefined) { - throw new V17CheckpointTailOpticFixtureError(`indexed checkpoint fixture must include ${path}`); + private requireShardHandle( + shardHandles: Readonly>, + path: string, + ): AssetHandle { + const handle = shardHandles[path]; + if (handle === undefined) { + throw new V17CheckpointTailOpticFixtureError( + `indexed checkpoint fixture must include ${path}`, + ); } - - return oid; - } - - private static async nestedIndexShardOids( - graph: OpticFixtureGraph, - treeOids: Record, - ): Promise> { - const indexTreeOid = treeOids['index']; - if (indexTreeOid === undefined) { - throw new V17CheckpointTailOpticFixtureError('indexed checkpoint fixture must include index subtree'); - } - - return await graph._persistence.readTreeOids(indexTreeOid); + return handle; } } diff --git a/test/conformance/fixtures/V17CheckpointTargetShardFixture.ts b/test/conformance/fixtures/V17CheckpointTargetShardFixture.ts index 3aed1655..19c84ff2 100644 --- a/test/conformance/fixtures/V17CheckpointTargetShardFixture.ts +++ b/test/conformance/fixtures/V17CheckpointTargetShardFixture.ts @@ -1,47 +1,49 @@ import { vi } from 'vitest'; import PersistenceError from '../../../src/domain/errors/PersistenceError.ts'; +import type AssetHandle from '../../../src/domain/storage/AssetHandle.ts'; +import type CodecValue from '../../../src/domain/types/codec/CodecValue.ts'; import type { OpticFixtureGraph } from './V17CheckpointTailOpticGraphFixture.ts'; -import V17CheckpointTailOpticFixtureError from './V17CheckpointTailOpticFixtureError.ts'; +/** Injects one-shard failures through IndexStorePort. */ export default class V17CheckpointTargetShardFixture { private readonly graph: OpticFixtureGraph; - private readonly shardOid: string; + private readonly shardHandle: AssetHandle; constructor(options: { readonly graph: OpticFixtureGraph; - readonly shardOid: string; + readonly shardOid: AssetHandle; }) { - if (options.shardOid.length === 0) { - throw new V17CheckpointTailOpticFixtureError('target checkpoint shard oid must be non-empty'); - } - this.graph = options.graph; - this.shardOid = options.shardOid; + this.shardHandle = options.shardOid; Object.freeze(this); } makeUnavailable(): void { - const originalReadBlob = this.graph._persistence.readBlob.bind(this.graph._persistence); - vi.spyOn(this.graph._persistence, 'readBlob').mockImplementation(async (oid: string) => { - if (oid === this.shardOid) { + const store = this.graph._indexStore; + const originalDecode = store.decodeShard.bind(store); + vi.spyOn(store, 'decodeShard').mockImplementation(async < + TDecoded extends CodecValue = CodecValue, + >(handle: AssetHandle): Promise => { + if (handle.equals(this.shardHandle)) { throw new PersistenceError( - `Blob not found: ${oid}`, + `Shard not found: ${handle.toString()}`, PersistenceError.E_MISSING_OBJECT, ); } - - return await originalReadBlob(oid); + return await originalDecode(handle); }); } makeInvalid(): void { - const originalReadBlob = this.graph._persistence.readBlob.bind(this.graph._persistence); - vi.spyOn(this.graph._persistence, 'readBlob').mockImplementation(async (oid: string) => { - if (oid === this.shardOid) { - return this.graph._codec.encode(Object.freeze({ invalid: true })); + const store = this.graph._indexStore; + const originalDecode = store.decodeShard.bind(store); + vi.spyOn(store, 'decodeShard').mockImplementation(async < + TDecoded extends CodecValue = CodecValue, + >(handle: AssetHandle): Promise => { + if (handle.equals(this.shardHandle)) { + return Object.freeze({ invalid: true }) as unknown as TDecoded; } - - return await originalReadBlob(oid); + return await originalDecode(handle); }); } } diff --git a/test/conformance/v17CheckpointTailOpticReadBasis.test.ts b/test/conformance/v17CheckpointTailOpticReadBasis.test.ts index 8d47174c..83ec11ce 100644 --- a/test/conformance/v17CheckpointTailOpticReadBasis.test.ts +++ b/test/conformance/v17CheckpointTailOpticReadBasis.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; -import V17CheckpointBasisArtifactFixture from './fixtures/V17CheckpointBasisArtifactFixture.ts'; import V17CheckpointIndexTreeFixture from './fixtures/V17CheckpointIndexTreeFixture.ts'; import V17CheckpointNodeLivenessShardFixture from './fixtures/V17CheckpointNodeLivenessShardFixture.ts'; -import V17CheckpointPayloadBlobFixture from './fixtures/V17CheckpointPayloadBlobFixture.ts'; import V17CheckpointPropertyShardFixture from './fixtures/V17CheckpointPropertyShardFixture.ts'; import { CHECKPOINT_NODE_ID, @@ -88,32 +86,6 @@ describe('v17 checkpoint-tail optic read basis', () => { materialization.expectUnused(); }); - it('requires empty checkpoint payload pointers to fail closed without materialization', async () => { - const graphName = 'v17-optic-empty-payload-pointer-red'; - const fixture = await V17CheckpointTailOpticGraphFixture.openIndexedCheckpoint(graphName); - const graph = fixture.graph; - const basisArtifact = await V17CheckpointBasisArtifactFixture.load(graph); - new V17CheckpointPayloadBlobFixture({ - graph, - payloadOid: basisArtifact.frontierOid(), - }).makeEmptyCasPointer(); - const materialization = new V17MaterializationFallbackTrap( - graph, - 'empty checkpoint payload pointer must not fall back to materialization', - ); - const readPath = new V17PublicOpticReadPath(graph.worldline()); - - await failures.expectNoBoundedBasisFailure({ - read: readPath.readNode(CHECKPOINT_NODE_ID), - graphName, - opticKind: 'node', - target: { nodeId: CHECKPOINT_NODE_ID }, - cause: 'checkpoint-payload-pointer-empty', - recoveryHints: [], - }); - materialization.expectUnused(); - }); - it('requires schema:5 checkpoints without index shards to fail closed without materialization', async () => { const graphName = 'v17-optic-missing-index-shards-red'; const fixture = await V17CheckpointTailOpticGraphFixture.openIndexedCheckpoint(graphName); @@ -136,28 +108,6 @@ describe('v17 checkpoint-tail optic read basis', () => { materialization.expectUnused(); }); - it('requires schema:5 checkpoints without index tree entries to fail closed without materialization', async () => { - const graphName = 'v17-optic-without-index-tree-red'; - const fixture = await V17CheckpointTailOpticGraphFixture.openIndexedCheckpoint(graphName); - const graph = fixture.graph; - const indexTree = await V17CheckpointIndexTreeFixture.load(graph); - await indexTree.replaceWithCheckpointWithoutIndexTree(); - const materialization = new V17MaterializationFallbackTrap( - graph, - 'non-index-tree checkpoint must not fall back to materialization', - ); - const readPath = new V17PublicOpticReadPath(graph.worldline()); - - await failures.expectNoBoundedBasisFailure({ - read: readPath.readNode(CHECKPOINT_NODE_ID), - graphName, - opticKind: 'node', - target: { nodeId: CHECKPOINT_NODE_ID }, - cause: 'checkpoint-missing-index-shards', - }); - materialization.expectUnused(); - }); - it('requires tail node removes to fail closed without materialization', async () => { const graphName = 'v17-optic-node-remove-tail-red'; const fixture = await V17CheckpointTailOpticGraphFixture.openIndexedCheckpoint(graphName); diff --git a/test/conformance/v18FirstUseOpticsHonesty.test.ts b/test/conformance/v18FirstUseOpticsHonesty.test.ts index 45f954d4..4a4ab3d4 100644 --- a/test/conformance/v18FirstUseOpticsHonesty.test.ts +++ b/test/conformance/v18FirstUseOpticsHonesty.test.ts @@ -1,16 +1,20 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; + import { inspectReceipt } from '../../diagnostics.ts'; import { openWarp, reading } from '../../index.ts'; import WarpStorage from '../../src/application/WarpStorage.ts'; import { bindWarpStorage } from '../../src/application/WarpStorageRegistry.ts'; import { openWarpWorldline } from '../../src/domain/WarpWorldline.ts'; -import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../helpers/MemoryRuntimeHost.ts'; +import type RuntimeStorageProviderPort from '../../src/ports/RuntimeStorageProviderPort.ts'; +import type { + RuntimeStorageRequest, + RuntimeStorageServices, +} from '../../src/ports/RuntimeStorageProviderPort.ts'; import InMemoryGraphAdapter from '../../test/helpers/InMemoryGraphAdapter.ts'; import MemoryRuntimeStorageAdapter from '../../test/helpers/MemoryRuntimeStorageAdapter.ts'; -import type CommitMessageCodecPort from '../../src/ports/CommitMessageCodecPort.ts'; -import type { CommitNodeOptions, CommitNodeWithTreeOptions } from '../../src/ports/CommitPort.ts'; +import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../helpers/MemoryRuntimeHost.ts'; const NODE_ID = 'event:honesty'; const PROPERTY_KEY = 'status'; @@ -18,121 +22,111 @@ const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); class ForbiddenFirstUseOpticsOperationError extends Error { constructor(operation: string) { - super(`first-use Optics path attempted forbidden full-residency operation: ${operation}`); + super(`first-use optics path attempted forbidden operation: ${operation}`); } } -class FirstUseOpticsTrapAdapter extends InMemoryGraphAdapter { - private readonly _forbiddenPatchBlobOids = new Set(); - private readonly _forbiddenStateBlobOids = new Set(); - private readonly _forbiddenOperations: string[] = []; - private _forbidWrites = false; - private _forbidTreeOidMapReads = false; - - async forbidFullResidencyOperations(options: { - readonly checkpointSha: string; - readonly patchSha: string; - readonly commitMessageCodec: CommitMessageCodecPort; - }): Promise { - const patchMessage = options.commitMessageCodec.decodePatch( - await this.showNode(options.patchSha) - ); - this._forbiddenPatchBlobOids.add(patchMessage.patchOid); - await this._forbidCheckpointStateBlobReads(options); - this._forbidWrites = true; - } - - forbidTreeOidMapReads(): void { - this._forbidTreeOidMapReads = true; - } - - allowTreeOidMapReads(): void { - this._forbidTreeOidMapReads = false; - } - - override async readBlob(oid: string): Promise { - if (this._forbiddenStateBlobOids.has(oid)) { - this._recordForbiddenOperation(`checkpoint-state-blob-read:${oid}`); - } - if (this._forbiddenPatchBlobOids.has(oid)) { - this._recordForbiddenOperation(`patch-blob-read:${oid}`); - } - return await super.readBlob(oid); - } - - override async readTreeOids(treeOid: string): Promise> { - if (this._forbidTreeOidMapReads) { - this._recordForbiddenOperation(`readTreeOids:${treeOid}`); - } - return await super.readTreeOids(treeOid); - } - - override async writeBlob(content: Uint8Array | string): Promise { - this._forbidWriteOperation('writeBlob'); - return await super.writeBlob(content); - } - - override async writeTree(entries: string[]): Promise { - this._forbidWriteOperation('writeTree'); - return await super.writeTree(entries); - } +class FirstUseOpticsTrapStorage implements RuntimeStorageProviderPort { + readonly #base: RuntimeStorageProviderPort; + readonly #forbiddenOperations: string[] = []; + #forbidFullResidency = false; - override async commitNode(options: CommitNodeOptions): Promise { - this._forbidWriteOperation('commitNode'); - return await super.commitNode(options); + constructor(base: RuntimeStorageProviderPort) { + this.#base = base; } - override async commitNodeWithTree(options: CommitNodeWithTreeOptions): Promise { - this._forbidWriteOperation('commitNodeWithTree'); - return await super.commitNodeWithTree(options); - } - - override async updateRef(ref: string, oid: string): Promise { - this._forbidWriteOperation(`updateRef:${ref}`); - await super.updateRef(ref, oid); + forbidFullResidencyOperations(): void { + this.#forbidFullResidency = true; } forbiddenOperations(): readonly string[] { - return this._forbiddenOperations; - } - - private async _forbidCheckpointStateBlobReads(options: { - readonly checkpointSha: string; - readonly commitMessageCodec: CommitMessageCodecPort; - }): Promise { - const checkpointMessage = options.commitMessageCodec.decodeCheckpoint( - await this.showNode(options.checkpointSha) - ); - const rootTreeOids = await this.readTreeOids(checkpointMessage.indexOid); - const stateTreeOid = rootTreeOids['state']; - if (stateTreeOid === undefined) { - throw new Error('checkpoint fixture must include a state subtree'); - } - const stateTreeOids = await this.readTreeOids(stateTreeOid); - for (const oid of Object.values(stateTreeOids)) { - this._forbiddenStateBlobOids.add(oid); - } - } - - private _forbidWriteOperation(operation: string): void { - if (this._forbidWrites) { - this._recordForbiddenOperation(operation); - } - } - - private _recordForbiddenOperation(operation: string): never { - this._forbiddenOperations.push(operation); - throw new ForbiddenFirstUseOpticsOperationError(operation); + return Object.freeze([...this.#forbiddenOperations]); + } + + async createRuntimeStorageServices( + request: RuntimeStorageRequest, + ): Promise { + const services = await this.#base.createRuntimeStorageServices(request); + const trap = (operation: string): void => { + if (!this.#forbidFullResidency) { + return; + } + this.#forbiddenOperations.push(operation); + throw new ForbiddenFirstUseOpticsOperationError(operation); + }; + return Object.freeze({ + content: trapService(services.content, 'content', ['stage'], trap), + auditLog: trapService(services.auditLog, 'auditLog', ['append'], trap), + patchJournal: trapService( + services.patchJournal, + 'patchJournal', + ['appendPatch', 'scanPatchRange'], + trap, + ), + strands: trapService( + services.strands, + 'strands', + ['publishDescriptor', 'deleteDescriptor'], + trap, + ), + checkpoints: trapService( + services.checkpoints, + 'checkpoints', + ['publishCheckpoint', 'publishCoverage', 'loadCheckpoint'], + trap, + ), + indexes: trapService( + services.indexes, + 'indexes', + ['writeShards', 'scanShards'], + trap, + ), + intents: trapService(services.intents, 'intents', ['publish'], trap), + ...(services.stateSnapshots === undefined + ? {} + : { + stateSnapshots: trapService( + services.stateSnapshots, + 'stateSnapshots', + ['put', 'pin', 'publishCheckpointHead', 'pruneEvictable'], + trap, + ), + }), + ...(services.trie === undefined ? {} : { trie: services.trie }), + }); } } class FirstUseOpticsStorage extends WarpStorage { - constructor(history: FirstUseOpticsTrapAdapter, runtimeStorage: MemoryRuntimeStorageAdapter) { + constructor(history: InMemoryGraphAdapter, runtimeStorage: RuntimeStorageProviderPort) { super(); bindWarpStorage(this, { history, runtimeStorage }); } } +function trapService( + service: TService, + serviceName: string, + forbiddenMethods: readonly string[], + trap: (operation: string) => void, +): TService { + const forbidden = new Set(forbiddenMethods); + return new Proxy(service, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') { + return value; + } + return (...args: unknown[]) => { + if (typeof property === 'string' && forbidden.has(property)) { + trap(`${serviceName}.${property}`); + } + return Reflect.apply(value, target, args); + }; + }, + }); +} + function readRepoFile(path: string): string { return readFileSync(`${REPO_ROOT}${path}`, 'utf8'); } @@ -147,17 +141,19 @@ function prepareOpticBasisImplementation(): string { return source.slice(start, end); } -describe('v18 first-use Optics honesty gate', () => { - it('verifies an existing checkpoint-tail basis without full-residency operations', async () => { - const persistence = new FirstUseOpticsTrapAdapter(); - const runtimeStorage = new MemoryRuntimeStorageAdapter({ history: persistence }); +describe('v18 first-use optics honesty gate', () => { + it('reads an existing checkpoint-tail basis without full residency or writes', async () => { + const history = new InMemoryGraphAdapter(); + const runtimeStorage = new FirstUseOpticsTrapStorage( + new MemoryRuntimeStorageAdapter({ history }), + ); const runtime = await openRuntimeHostProduct({ - persistence, + persistence: history, runtimeStorage, graphName: 'v18-first-use-optics-honesty', writerId: 'app', }); - const patchSha = await runtime.patch((patch) => { + await runtime.patch((patch) => { patch.addNode(NODE_ID); patch.setProperty(NODE_ID, PROPERTY_KEY, 'open'); }); @@ -165,31 +161,21 @@ describe('v18 first-use Optics honesty gate', () => { const checkpointSha = await runtime.createCheckpoint(); const events = await openWarpWorldline({ - persistence, + persistence: history, runtimeStorage, worldlineName: 'v18-first-use-optics-honesty', writerId: 'app', }); - await persistence.forbidFullResidencyOperations({ - checkpointSha, - patchSha, - commitMessageCodec: runtime._commitMessageCodec, - }); + runtimeStorage.forbidFullResidencyOperations(); - const storage = new FirstUseOpticsStorage(persistence, runtimeStorage); + const storage = new FirstUseOpticsStorage(history, runtimeStorage); const warp = await openWarp({ storage, writer: 'app' }); const timeline = await warp.timeline('v18-first-use-optics-honesty'); const property = await timeline.read( - reading.property({ - subject: NODE_ID, - key: PROPERTY_KEY, - }) + reading.property({ subject: NODE_ID, key: PROPERTY_KEY }), ); const inspection = inspectReceipt(property.receipt, { storage }); - - persistence.forbidTreeOidMapReads(); const basis = await events.prepareOpticBasis(); - persistence.allowTreeOidMapReads(); const coordinate = await events.coordinate(); const node = await coordinate.optic().node(NODE_ID).read(); @@ -209,14 +195,16 @@ describe('v18 first-use Optics honesty gate', () => { operation: 'read', identity: { checkpointSha }, }); - expect(persistence.forbiddenOperations()).toEqual([]); + expect(runtimeStorage.forbiddenOperations()).toEqual([]); }); - it('keeps prepareOpticBasis source off known full-residency helpers', () => { + it('keeps basis preparation source on semantic bounded-read ports', () => { const implementation = prepareOpticBasisImplementation(); const verifier = readRepoFile('src/domain/services/optic/CheckpointTailBasisVerifier.ts'); const checkedSources = `${implementation}\n${verifier}`; + expect(implementation).toContain('new CheckpointTailBasisVerifier'); + expect(verifier).toContain('_checkpointStore.loadBasis'); expect(checkedSources).not.toMatch(/\bmaterialize\s*\(/u); expect(checkedSources).not.toContain('_materializeGraph'); expect(checkedSources).not.toContain('_setMaterializedState'); @@ -226,6 +214,8 @@ describe('v18 first-use Optics honesty gate', () => { expect(checkedSources).not.toContain('getEdges'); expect(checkedSources).not.toContain('observer('); expect(checkedSources).not.toContain('cloneState'); + expect(checkedSources).not.toContain('_persistence'); expect(checkedSources).not.toContain('readTreeOids'); + expect(checkedSources).not.toContain('readBlob'); }); }); diff --git a/test/helpers/FixturePatchJournal.ts b/test/helpers/FixturePatchJournal.ts new file mode 100644 index 00000000..ad541bf2 --- /dev/null +++ b/test/helpers/FixturePatchJournal.ts @@ -0,0 +1,83 @@ +import PatchEntry from '../../src/domain/artifacts/PatchEntry.ts'; +import SyncError from '../../src/domain/errors/SyncError.ts'; +import WarpStream from '../../src/domain/stream/WarpStream.ts'; +import type Patch from '../../src/domain/types/Patch.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import type CommitMessageCodecPort from '../../src/ports/CommitMessageCodecPort.ts'; +import type { PatchCommitMessage } from '../../src/ports/CommitMessageCodecPort.ts'; +import PatchJournalPort from '../../src/ports/PatchJournalPort.ts'; +import type { + AppendPatchRequest, + PublishedPatch, +} from '../../src/ports/PatchJournalPort.ts'; + +export type FixturePatchCommit = Readonly<{ + message: string; + parents: readonly string[]; +}>; + +/** Semantic patch history fixture for sync tests. */ +export default class FixturePatchJournal extends PatchJournalPort { + readonly #commits: Readonly>; + readonly #patches: Readonly>; + readonly #messageCodec: CommitMessageCodecPort; + + constructor(options: { + readonly commits: Readonly>; + readonly patches: Readonly>; + readonly messageCodec?: CommitMessageCodecPort; + }) { + super(); + this.#commits = options.commits; + this.#patches = options.patches; + this.#messageCodec = options.messageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC; + } + + override appendPatch(_request: AppendPatchRequest): Promise { + throw new Error('FixturePatchJournal does not publish patches'); + } + + override readPatch(message: PatchCommitMessage): Promise { + const handle = message.patchHandle.toString(); + const patch = this.#patches[handle]; + if (patch === undefined) { + throw new Error(`Fixture patch not found: ${handle}`); + } + return Promise.resolve(patch); + } + + override scanPatchRange( + _writerId: string, + fromSha: string | null, + toSha: string, + ): WarpStream { + const journal = this; + return WarpStream.from((async function* (): AsyncGenerator { + const reverse: Array<{ readonly sha: string; readonly message: PatchCommitMessage }> = []; + let current: string | null = toSha; + while (current !== null && current !== fromSha) { + const commit: FixturePatchCommit | undefined = journal.#commits[current]; + if (commit === undefined) { + throw new Error(`Fixture commit not found: ${current}`); + } + if (journal.#messageCodec.detectKind(commit.message) !== 'patch') { + break; + } + reverse.push({ sha: current, message: journal.#messageCodec.decodePatch(commit.message) }); + current = commit.parents[0] ?? null; + } + if (fromSha !== null && current !== fromSha) { + throw new SyncError( + `Divergence detected: ${toSha} does not descend from ${fromSha}`, + { code: 'E_SYNC_DIVERGENCE' }, + ); + } + for (let index = reverse.length - 1; index >= 0; index -= 1) { + const entry = reverse[index]; + if (entry !== undefined) { + yield new PatchEntry({ sha: entry.sha, patch: await journal.readPatch(entry.message) }); + } + } + })()); + } +} diff --git a/test/helpers/InMemoryAuditLogAdapter.ts b/test/helpers/InMemoryAuditLogAdapter.ts new file mode 100644 index 00000000..54a8c5c5 --- /dev/null +++ b/test/helpers/InMemoryAuditLogAdapter.ts @@ -0,0 +1,114 @@ +import AssetHandle from '../../src/domain/storage/AssetHandle.ts'; +import AuditPublicationConflictError from '../../src/domain/errors/AuditPublicationConflictError.ts'; +import AuditLogPort, { + type AppendAuditRecordRequest, + type AuditLogEntry, + type PublishedAuditRecord, +} from '../../src/ports/AuditLogPort.ts'; +import { testRetentionWitness } from './storageRetention.ts'; + +type AuditHeadKey = `${string}\0${string}`; + +/** Semantic append-only audit log used by audit service tests. */ +export default class InMemoryAuditLogAdapter extends AuditLogPort { + readonly #heads = new Map(); + readonly #entries = new Map(); + #sequence = 0; + #readFailure: Error | null = null; + #appendFailure: Error | null = null; + + failReadsWith(error: Error | null): void { + this.#readFailure = error; + } + + failAppendsWith(error: Error | null): void { + this.#appendFailure = error; + } + + forceHead(graphName: string, writerId: string, sha: string): void { + this.#heads.set(key(graphName, writerId), sha); + } + + replaceEntry(sha: string, entry: AuditLogEntry): void { + if (!this.#entries.has(sha)) { + throw new Error(`Audit entry not found: ${sha}`); + } + this.#entries.set(sha, Object.freeze({ + ...entry, + parents: Object.freeze([...entry.parents]), + receipt: entry.receipt.slice(), + })); + } + + removeEntry(sha: string): void { + this.#entries.delete(sha); + } + + override async readHead(graphName: string, writerId: string): Promise { + if (this.#readFailure !== null) { + throw this.#readFailure; + } + return this.#heads.get(key(graphName, writerId)) ?? null; + } + + override async listWriterIds(graphName: string): Promise { + this.#throwReadFailure(); + const prefix = `${graphName}\0`; + return [...this.#heads.keys()] + .filter((entry) => entry.startsWith(prefix)) + .map((entry) => entry.slice(prefix.length)) + .sort(); + } + + override async append(request: AppendAuditRecordRequest): Promise { + if (this.#appendFailure !== null) { + throw this.#appendFailure; + } + const headKey = key(request.graphName, request.writerId); + const current = this.#heads.get(headKey) ?? null; + if (current !== request.expectedHead) { + throw new AuditPublicationConflictError(request.expectedHead, current); + } + const sha = (++this.#sequence).toString(16).padStart(40, '0'); + this.#entries.set(sha, Object.freeze({ + sha, + message: request.message, + parents: Object.freeze(request.parent === null ? [] : [request.parent]), + receipt: request.receipt.slice(), + })); + this.#heads.set(headKey, sha); + const retention = testRetentionWitness(sha); + return Object.freeze({ + sha, + stagedReceipt: Object.freeze({ + handle: new AssetHandle(retention.handle.toString()), + size: request.receipt.byteLength, + observedAt: retention.observedAt, + retention: Object.freeze({ + reachability: 'unanchored', + protection: 'not-established', + }), + }), + retention, + }); + } + + override async readEntry(sha: string): Promise { + this.#throwReadFailure(); + const entry = this.#entries.get(sha); + if (entry === undefined) { + throw new Error(`Audit entry not found: ${sha}`); + } + return entry; + } + + #throwReadFailure(): void { + if (this.#readFailure !== null) { + throw this.#readFailure; + } + } +} + +function key(graphName: string, writerId: string): AuditHeadKey { + return `${graphName}\0${writerId}`; +} diff --git a/test/helpers/InMemoryBlobStorageAdapter.ts b/test/helpers/InMemoryBlobStorageAdapter.ts index 43b5c273..0342dc98 100644 --- a/test/helpers/InMemoryBlobStorageAdapter.ts +++ b/test/helpers/InMemoryBlobStorageAdapter.ts @@ -1,92 +1,123 @@ -/** - * In-memory blob storage adapter for tests. - * - * Implements BlobStoragePort with Map-based storage. Content-addressed: - * identical content always produces the same OID. No CDC chunking — - * its purpose is port conformance, not chunking behavior. - * - * @module test/helpers/InMemoryBlobStorageAdapter - */ - +import { AssetHandle as GitCasAssetHandle } from '@git-stunts/git-cas'; +import AssetHandle from '../../src/domain/storage/AssetHandle.ts'; +import AssetSizeMismatchError from '../../src/domain/errors/AssetSizeMismatchError.ts'; import StorageError from '../../src/domain/errors/StorageError.ts'; -import BlobStoragePort from '../../src/ports/BlobStoragePort.ts'; import { hexEncode } from '../../src/domain/utils/bytes.ts'; import { collectAsyncIterable } from '../../src/domain/utils/streamUtils.ts'; +import AssetStoragePort, { + type AssetWriteOptions, + type StagedAsset, +} from '../../src/ports/AssetStoragePort.ts'; -const _encoder = new TextEncoder(); +const encoder = new TextEncoder(); -/** - * Simple content-addressed hash using Web Crypto SHA-256. - */ async function contentHash(bytes: Uint8Array): Promise { - const buffer = new Uint8Array(bytes).buffer; - const digest = await globalThis.crypto.subtle.digest('SHA-256', buffer); + const exactBytes = Uint8Array.from(bytes); + const digest = await globalThis.crypto.subtle.digest('SHA-256', exactBytes.buffer); return hexEncode(new Uint8Array(digest)); } -interface StorageOptions { - slug?: string; - mime?: string | null; - size?: number | null; -} +/** Streaming, content-addressed asset storage used by in-memory tests. */ +export default class InMemoryBlobStorageAdapter extends AssetStoragePort { + readonly #store = new Map(); + + override async stage( + source: AsyncIterable, + options: AssetWriteOptions, + ): Promise { + const bytes = await collectAsyncIterable(source); + requireExpectedSize(bytes.byteLength, options.expectedSize); + const oid = await contentHash(bytes); + const casHandle = new GitCasAssetHandle({ + codec: 'raw', + hashAlgorithm: 'sha256', + oid, + }); + const handle = new AssetHandle(casHandle.toString()); + this.#store.set(handle.toString(), bytes.slice()); + this.#store.set(oid, bytes.slice()); + return Object.freeze({ + handle, + size: bytes.byteLength, + observedAt: new Date(0).toISOString(), + retention: Object.freeze({ + reachability: 'unanchored', + protection: 'not-established', + }), + }); + } -/** - * In-memory content-addressed blob storage for tests. - */ -export default class InMemoryBlobStorageAdapter extends BlobStoragePort { - private readonly _store: Map; + override async *open(handle: AssetHandle): AsyncIterable { + yield this.#requireBytes(handle.toString()).slice(); + } - /** Creates an empty in-memory blob store. */ - constructor() { - super(); - this._store = new Map(); + /** Convenience helper for tests that stage one value. */ + async store( + content: Uint8Array | string, + options: Partial = {}, + ): Promise { + const bytes = typeof content === 'string' ? encoder.encode(content) : content; + const staged = await this.stage(singleChunk(bytes), { + slug: options.slug ?? 'test-asset', + filename: options.filename ?? 'content', + expectedSize: options.expectedSize ?? bytes.byteLength, + }); + return staged.handle; } - /** @override */ - async store(content: Uint8Array | string, _options?: StorageOptions): Promise { - const bytes = typeof content === 'string' - ? _encoder.encode(content) - : content; - const oid = await contentHash(bytes); - this._store.set(oid, bytes); - return oid; + /** Convenience helper for tests that collect one asset. */ + async retrieve(handle: AssetHandle | string): Promise { + const token = typeof handle === 'string' ? handle : handle.toString(); + return this.#requireBytes(token).slice(); } - /** @override */ - retrieve(oid: string): Promise { - const bytes = this._store.get(oid); - if (!bytes) { - return Promise.reject(new StorageError(`InMemoryBlobStorageAdapter: unknown OID '${oid}'`, { operation: 'retrieve', oid })); // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + /** Replaces staged bytes so boundary tests can exercise corrupt persisted assets. */ + replace(handle: AssetHandle | string, content: Uint8Array): void { + const token = typeof handle === 'string' ? handle : handle.toString(); + const bytes = content.slice(); + this.#store.set(token, bytes); + try { + this.#store.set(GitCasAssetHandle.parse(token).oid, bytes); + } catch { + // Legacy test handles do not necessarily use the git-cas token grammar. } - return Promise.resolve(bytes); } - /** @override */ - async storeStream(source: AsyncIterable, _options?: StorageOptions): Promise { - const bytes = await collectAsyncIterable(source); - return await this.store(bytes); + async storeStream( + source: AsyncIterable, + options: Partial = {}, + ): Promise { + const staged = await this.stage(source, { + slug: options.slug ?? 'test-asset', + filename: options.filename ?? 'content', + expectedSize: options.expectedSize ?? null, + }); + return staged.handle; + } + + async *retrieveStream(handle: AssetHandle | string): AsyncIterable { + const token = typeof handle === 'string' ? handle : handle.toString(); + yield this.#requireBytes(token).slice(); } - /** @override */ - retrieveStream(oid: string): AsyncIterable { - const bytes = this._store.get(oid); - if (!bytes) { - throw new StorageError(`InMemoryBlobStorageAdapter: unknown OID '${oid}'`, { operation: 'retrieveStream', oid }); // nosemgrep: ts-no-unknown-outside-adapters -- 0025B + #requireBytes(token: string): Uint8Array { + const bytes = this.#store.get(token); + if (bytes === undefined) { + throw new StorageError( + `InMemoryBlobStorageAdapter: unknown asset '${token}'`, + { operation: 'asset-open', context: { handle: token } }, + ); } - const chunk = bytes; - return { - [Symbol.asyncIterator](): AsyncIterator { - let done = false; - return { - next(): Promise> { - if (done) { - return Promise.resolve({ value: undefined, done: true }); - } - done = true; - return Promise.resolve({ value: chunk, done: false }); - }, - }; - }, - }; + return bytes; + } +} + +function requireExpectedSize(actualSize: number, expectedSize: number | null | undefined): void { + if (expectedSize !== null && expectedSize !== undefined && actualSize !== expectedSize) { + throw new AssetSizeMismatchError(expectedSize, actualSize); } } + +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} diff --git a/test/helpers/InMemoryCheckpointStore.ts b/test/helpers/InMemoryCheckpointStore.ts new file mode 100644 index 00000000..26dfb761 --- /dev/null +++ b/test/helpers/InMemoryCheckpointStore.ts @@ -0,0 +1,132 @@ +import AssetHandle from '../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../src/domain/storage/BundleHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../src/domain/storage/StorageRetentionWitness.ts'; +import CheckpointStorePort, { + type CheckpointBasis, + type CheckpointData, + type CheckpointMetadata, + type CheckpointRecord, + type PublishedCheckpoint, +} from '../../src/ports/CheckpointStorePort.ts'; + +/** Storage-neutral checkpoint fixture for domain service tests. */ +export default class InMemoryCheckpointStore extends CheckpointStorePort { + readonly #checkpoints = new Map(); + readonly #checkpointGraphs = new Map(); + readonly #heads = new Map(); + #sequence = 0; + lastPublished: CheckpointRecord | null = null; + + override async publishCheckpoint(record: CheckpointRecord): Promise { + const currentHead = this.#heads.get(record.graphName) ?? null; + if (record.expectedCheckpointSha !== undefined + && record.expectedCheckpointSha !== currentHead) { + throw new Error( + `Checkpoint head mismatch: expected ${record.expectedCheckpointSha ?? ''}, ` + + `found ${currentHead ?? ''}`, + ); + } + const checkpointSha = (++this.#sequence).toString(16).padStart(40, '0'); + const indexShardHandles = record.indexShards === null || record.indexShards === undefined + ? null + : Object.freeze(Object.fromEntries( + Object.keys(record.indexShards).sort().map((path) => [ + path, + new AssetHandle(`checkpoint-shard:${checkpointSha}:${path}`), + ]), + )); + this.lastPublished = record; + this.#checkpoints.set(checkpointSha, { + state: record.state, + frontier: new Map(record.frontier), + stateHash: record.stateHash, + schema: 5, + appliedVV: record.appliedVV, + indexShardHandles, + ...(record.provenanceIndex === undefined || record.provenanceIndex === null + ? {} + : { provenanceIndex: record.provenanceIndex }), + }); + this.#checkpointGraphs.set(checkpointSha, record.graphName); + this.#heads.set(record.graphName, checkpointSha); + const bundleHandle = new BundleHandle(`checkpoint-bundle:${checkpointSha}`); + return Object.freeze({ + checkpointSha, + bundleHandle, + retention: new StorageRetentionWitness({ + handle: bundleHandle, + policy: 'pinned', + reachability: 'anchored', + root: new StorageRetentionRoot({ + kind: 'publication', + namespace: record.graphName, + locator: `checkpoint:${record.graphName}`, + generation: checkpointSha, + path: '/', + }), + observedAt: new Date(0).toISOString(), + }), + }); + } + + override async resolveHead(graphName: string): Promise { + return this.#heads.get(graphName) ?? null; + } + + override async loadCheckpoint( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const checkpoint = this.#checkpoints.get(checkpointSha); + if (checkpoint === undefined) { + throw new Error(`Checkpoint not found: ${checkpointSha}`); + } + const actualGraphName = this.#checkpointGraphs.get(checkpointSha); + if (expectedGraphName !== undefined && actualGraphName !== expectedGraphName) { + throw new Error( + `Checkpoint ${checkpointSha} belongs to graph ${actualGraphName ?? ''}, ` + + `not ${expectedGraphName}`, + ); + } + return checkpoint; + } + + override async readMetadata( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const checkpoint = await this.loadCheckpoint(checkpointSha, expectedGraphName); + return Object.freeze({ + checkpointSha, + stateHash: checkpoint.stateHash, + schema: checkpoint.schema, + }); + } + + override async loadBasis( + checkpointSha: string, + expectedGraphName?: string, + ): Promise { + const checkpoint = await this.loadCheckpoint(checkpointSha, expectedGraphName); + if (checkpoint.indexShardHandles === null) { + throw new Error(`Checkpoint has no index basis: ${checkpointSha}`); + } + return Object.freeze({ + checkpointSha, + stateHash: checkpoint.stateHash, + schema: checkpoint.schema, + frontier: new Map(checkpoint.frontier), + indexShardHandles: checkpoint.indexShardHandles, + }); + } + + override async publishCoverage(_options: { + graphName: string; + parents: string[]; + }): Promise { + const suffix = (++this.#sequence).toString(16).padStart(40, '0'); + return suffix; + } +} diff --git a/test/helpers/InMemoryGitCasFacade.ts b/test/helpers/InMemoryGitCasFacade.ts new file mode 100644 index 00000000..35255511 --- /dev/null +++ b/test/helpers/InMemoryGitCasFacade.ts @@ -0,0 +1,346 @@ +import { + AssetHandle as GitCasAssetHandle, + BundleHandle, + PageHandle, + RetentionWitness, + StagedAsset, + StagedBundle, + type ApplicationHandle, + type ApplicationHandleInput, + type AssetHandleInput, + type AssetCapability, + type BundleHandleInput, + type BundleCapability, + type BundleMember, + type PageHandleInput, + type PublicationCapability, +} from '@git-stunts/git-cas'; +import AssetHandle from '../../src/domain/storage/AssetHandle.ts'; +import { collectAsyncIterable } from '../../src/domain/utils/streamUtils.ts'; +import type { GitTreeCommitOptions } from '../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; +import InMemoryBlobStorageAdapter from './InMemoryBlobStorageAdapter.ts'; + +type PublicationHistory = { + readRef(ref: string): Promise; + compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise; + commitNodeWithTree(options: GitTreeCommitOptions): Promise; + writeBlob(content: Uint8Array | string): Promise; + writeTree(entries: string[]): Promise; + readObjectType(oid: string): Promise; +}; + +const BUNDLE_LIMITS = Object.freeze({ + maxMembers: 100_000, + maxMemberPathBytes: 4_096, + maxDescriptorBytes: 16_777_216, + maxFanoutEntries: 1_024, + maxFanoutDepth: 16, +}); +const ENCRYPTED_ASSET_MAGIC = new Uint8Array([0x47, 0x57, 0x45, 0x43]); +const ENCRYPTED_ASSET_NONCE_BYTES = 12; + +/** Minimal high-level git-cas facade used to exercise production adapters in memory. */ +export default class InMemoryGitCasFacade { + readonly assets: Pick; + readonly bundles: Pick; + readonly publications: Pick; + + readonly #history: PublicationHistory; + readonly #storage: InMemoryBlobStorageAdapter; + readonly #stagedAssetsByOid = new Map(); + readonly #bundleMembers = new Map(); + readonly #publicationRoots = new Map(); + + constructor(options: { + history: PublicationHistory; + storage: InMemoryBlobStorageAdapter; + }) { + this.#history = options.history; + this.#storage = options.storage; + this.assets = Object.freeze({ + put: async (request) => await this.#putAsset(request), + adopt: async ({ treeOid }) => await this.#adoptAsset(treeOid), + open: (request) => this.#openAsset(request), + }); + this.bundles = Object.freeze({ + putOrdered: async (request) => await this.#putBundle(request.members), + iterateMembers: (request) => this.#iterateBundleMembers(request.handle), + }); + this.publications = Object.freeze({ + commit: async (request) => await this.#publish(request), + }); + } + + readBundleMembers(handle: string): readonly [string, string][] { + return this.#bundleMembers.get(handle) ?? Object.freeze([]); + } + + readPublicationRoot(commitId: string): string | null { + return this.#publicationRoots.get(commitId) ?? null; + } + + async readStoredAsset(handle: string): Promise { + return await this.#readStoredAsset(GitCasAssetHandle.parse(handle)); + } + + replaceStoredAsset(handle: string, bytes: Uint8Array): void { + this.#storage.replace(new AssetHandle(handle), bytes); + } + + async #putAsset( + request: Parameters[0], + ): Promise { + const sourceBytes = await collectAsyncIterable(request.source); + const storedBytes = request.encryptionKey === undefined + ? sourceBytes + : await encryptAsset(sourceBytes, request.encryptionKey); + const staged = await this.#storage.stage(singleChunk(storedBytes), { + slug: request.slug, + filename: request.filename ?? 'content', + }); + const asset = new StagedAsset({ + handle: GitCasAssetHandle.parse(staged.handle.toString()), + slug: request.slug, + filename: request.filename ?? 'content', + size: sourceBytes.byteLength, + observedAt: staged.observedAt, + }); + this.#stagedAssetsByOid.set(asset.handle.oid, asset); + return asset; + } + + async #adoptAsset(treeOid: string): Promise { + const existing = this.#stagedAssetsByOid.get(treeOid); + if (existing !== undefined) { + return existing; + } + if (await this.#history.readObjectType(treeOid) !== 'tree') { + throw Object.assign( + new Error(`Cannot adopt non-tree object as asset: ${treeOid}`), + { code: 'GIT_ERROR' }, + ); + } + const handle = new GitCasAssetHandle({ + codec: 'raw', + hashAlgorithm: treeOid.length === 64 ? 'sha256' : 'sha1', + oid: treeOid, + }); + return new StagedAsset({ + handle, + slug: 'adopted', + filename: 'content', + size: 0, + observedAt: new Date(0).toISOString(), + }); + } + + async *#openAsset(request: Parameters[0]): AsyncIterable { + const handle = GitCasAssetHandle.from(request.handle); + const storedBytes = await this.#readStoredAsset(handle); + if (!hasEncryptedAssetMagic(storedBytes)) { + yield storedBytes; + return; + } + if (request.encryptionKey === undefined) { + throw encryptedAssetIntegrityError(); + } + yield await decryptAsset(storedBytes, request.encryptionKey); + } + + async #readStoredAsset(handle: GitCasAssetHandle): Promise { + return await this.#storage.retrieve(handle.toString()).catch( + async () => await this.#storage.retrieve(handle.oid), + ); + } + + async #putBundle( + members: Parameters[0]['members'], + ): Promise { + const lines: string[] = []; + const recordedMembers: Array<[string, string]> = []; + for await (const [path, member] of members) { + const token = String(member); + lines.push(`${path}\0${token}`); + recordedMembers.push([path, token]); + } + const descriptorOid = await this.#history.writeBlob(lines.join('\n')); + const oid = await this.#history.writeTree([ + `100644 blob ${descriptorOid}\tbundle.members`, + ]); + const handle = new BundleHandle({ + codec: 'ordered-test-bundle', + hashAlgorithm: oid.length === 64 ? 'sha256' : 'sha1', + oid, + }); + this.#bundleMembers.set(handle.toString(), Object.freeze(recordedMembers)); + return new StagedBundle({ + handle, + memberCount: lines.length, + indexDepth: 1, + descriptorBytes: lines.join('\n').length, + limits: BUNDLE_LIMITS, + observedAt: new Date(0).toISOString(), + }); + } + + async *#iterateBundleMembers( + handleInput: BundleHandleInput, + ): AsyncGenerator { + const handle = BundleHandle.from(handleInput); + const members = this.#bundleMembers.get(handle.toString()); + if (members === undefined) { + throw Object.assign(new Error(`Unknown bundle: ${handle.toString()}`), { + code: 'BUNDLE_NOT_FOUND', + }); + } + for (const [path, token] of members) { + const memberHandle = parseApplicationHandle(token); + const asset = memberHandle instanceof GitCasAssetHandle + ? this.#stagedAssetsByOid.get(memberHandle.oid) + : undefined; + yield Object.freeze({ + version: 1, + path, + handle: memberHandle, + type: memberHandle instanceof PageHandle ? 'blob' : 'tree', + size: asset?.asset.size ?? null, + logicalBytes: asset?.asset.size ?? 0, + }); + } + } + + async #publish( + request: Parameters[0], + ): Promise>> { + const root = parseApplicationHandle(request.root); + const current = await this.#history.readRef(request.ref.name); + if (current !== request.ref.expected) { + // Delegate conflict construction to the history fake's verified CAS path. + await this.#history.compareAndSwapRef( + request.ref.name, + current ?? root.oid, + request.ref.expected, + ); + } + const commitId = await this.#history.commitNodeWithTree({ + treeOid: root.oid, + parents: request.commit.parents ?? [], + message: request.commit.message, + }); + await this.#history.compareAndSwapRef(request.ref.name, commitId, request.ref.expected); + const witness = new RetentionWitness({ + handle: root, + policy: 'pinned', + reachability: 'anchored', + root: { + kind: 'publication', + namespace: request.ref.name, + ref: request.ref.name, + generation: commitId, + path: '/', + }, + observedAt: new Date(0).toISOString(), + }); + this.#publicationRoots.set(commitId, root.toString()); + return Object.freeze({ + operation: 'publication', + commitId, + ref: request.ref.name, + root, + witness, + }); + } +} + +async function encryptAsset(bytes: Uint8Array, keyBytes: Uint8Array): Promise { + const key = await importAesKey(keyBytes); + const nonce = globalThis.crypto.getRandomValues(new Uint8Array(ENCRYPTED_ASSET_NONCE_BYTES)); + const ciphertext = new Uint8Array(await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + key, + exactBytes(bytes), + )); + const envelope = new Uint8Array( + ENCRYPTED_ASSET_MAGIC.byteLength + nonce.byteLength + ciphertext.byteLength, + ); + envelope.set(ENCRYPTED_ASSET_MAGIC, 0); + envelope.set(nonce, ENCRYPTED_ASSET_MAGIC.byteLength); + envelope.set(ciphertext, ENCRYPTED_ASSET_MAGIC.byteLength + nonce.byteLength); + return envelope; +} + +async function decryptAsset(envelope: Uint8Array, keyBytes: Uint8Array): Promise { + const nonceStart = ENCRYPTED_ASSET_MAGIC.byteLength; + const ciphertextStart = nonceStart + ENCRYPTED_ASSET_NONCE_BYTES; + try { + const key = await importAesKey(keyBytes); + return new Uint8Array(await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: envelope.slice(nonceStart, ciphertextStart) }, + key, + envelope.slice(ciphertextStart), + )); + } catch { + throw encryptedAssetIntegrityError(); + } +} + +async function importAesKey(keyBytes: Uint8Array): Promise { + return await globalThis.crypto.subtle.importKey( + 'raw', + exactBytes(keyBytes), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'], + ); +} + +function exactBytes(bytes: Uint8Array): Uint8Array { + const exact = new Uint8Array(bytes.byteLength); + exact.set(bytes); + return exact; +} + +function hasEncryptedAssetMagic(bytes: Uint8Array): boolean { + return bytes.byteLength >= ENCRYPTED_ASSET_MAGIC.byteLength + ENCRYPTED_ASSET_NONCE_BYTES + && ENCRYPTED_ASSET_MAGIC.every((value, index) => bytes[index] === value); +} + +function encryptedAssetIntegrityError(): Error & { readonly code: 'INTEGRITY_ERROR' } { + return Object.assign( + new Error('Decryption failed: Integrity check error'), + { code: 'INTEGRITY_ERROR' }, + ); +} + +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function parseApplicationHandle(input: ApplicationHandleInput): ApplicationHandle { + if (input instanceof GitCasAssetHandle || input instanceof BundleHandle || input instanceof PageHandle) { + return input; + } + if (typeof input === 'string') { + try { + return GitCasAssetHandle.parse(input); + } catch { + try { + return BundleHandle.parse(input); + } catch { + return PageHandle.parse(input); + } + } + } + if (input.kind === 'asset' || input.format === 'manifest-tree') { + return GitCasAssetHandle.from(input as AssetHandleInput); + } + if (input.kind === 'bundle' || input.format === 'fanout-tree') { + return BundleHandle.from(input as BundleHandleInput); + } + return PageHandle.from(input as PageHandleInput); +} + +/** Converts a git-cas asset handle for assertions against WARP ports. */ +export function warpAssetHandle(handle: GitCasAssetHandle): AssetHandle { + return new AssetHandle(handle.toString()); +} diff --git a/test/helpers/InMemoryGraphAdapter.ts b/test/helpers/InMemoryGraphAdapter.ts index 25aad3e4..1c7d95eb 100644 --- a/test/helpers/InMemoryGraphAdapter.ts +++ b/test/helpers/InMemoryGraphAdapter.ts @@ -4,7 +4,7 @@ * Implements the same GraphPersistencePort contract as GitTimelineHistoryAdapter * but stores all data in Maps. Designed for fast unit/integration tests. */ -import type { CommitLogChunk, CommitNodeOptions, CommitNodeWithTreeOptions, LogNodesOptions, NodeInfo, PingResult } from '../../src/ports/CommitPort.ts'; +import type { CommitLogChunk, CommitNodeOptions, LogNodesOptions, NodeInfo, PingResult } from '../../src/ports/CommitPort.ts'; import type { ListRefsOptions } from '../../src/ports/RefPort.ts'; import GraphPersistencePort from '../../src/ports/GraphPersistencePort.ts'; import WarpStream from '../../src/domain/stream/WarpStream.ts'; @@ -15,7 +15,8 @@ import type TreeEntryLimit from '../../src/domain/tree/TreeEntryLimit.ts'; import TreeEntryMissing from '../../src/domain/tree/TreeEntryMissing.ts'; import TreeEntryPath from '../../src/domain/tree/TreeEntryPath.ts'; import TreeEntryPrefixBatch from '../../src/domain/tree/TreeEntryPrefixBatch.ts'; -import type { TreeEntryProbeResult } from '../../src/ports/TreeEntryProbePort.ts'; +import type { TreeEntryProbeResult } from '../../src/domain/tree/TreeEntryProbeResult.ts'; +import type { GitTreeCommitOptions } from '../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; import { validateOid, validateRef, validateLimit, validateConfigKey } from '../../src/infrastructure/adapters/adapterValidation.ts'; import { type HashFn, @@ -199,6 +200,20 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { return buf; } + async readObjectType(oid: string): Promise<'blob' | 'tree' | 'commit'> { + validateOid(oid); + if (this._blobs.has(oid)) { + return 'blob'; + } + if (oid === EMPTY_TREE_OID || this._trees.has(oid)) { + return 'tree'; + } + if (this._commits.has(oid)) { + return 'commit'; + } + throw new PersistenceError(`Object not found: ${oid}`, PersistenceError.E_MISSING_OBJECT); + } + // -- CommitPort ----------------------------------------------------------- async commitNode({ message, parents = [] }: CommitNodeOptions): Promise { @@ -208,7 +223,7 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { return await this._createCommit(EMPTY_TREE_OID, parents, message); } - async commitNodeWithTree({ treeOid, parents = [], message }: CommitNodeWithTreeOptions): Promise { + async commitNodeWithTree({ treeOid, parents = [], message }: GitTreeCommitOptions): Promise { validateOid(treeOid); for (const p of parents) { validateOid(p); @@ -305,6 +320,16 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort { this._refs.delete(ref); } + async compareAndDeleteRef(ref: string, expectedOid: string): Promise { + validateRef(ref); + validateOid(expectedOid); + if ((this._refs.get(ref) ?? null) !== expectedOid) { + return false; + } + this._refs.delete(ref); + return true; + } + async compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise { validateRef(ref); validateOid(newOid); diff --git a/test/helpers/MemoryRuntimeHost.ts b/test/helpers/MemoryRuntimeHost.ts index 3a87635f..5bf57a3b 100644 --- a/test/helpers/MemoryRuntimeHost.ts +++ b/test/helpers/MemoryRuntimeHost.ts @@ -7,9 +7,12 @@ import MemoryRuntimeStorageAdapter from '../../test/helpers/MemoryRuntimeStorage import type { CorePersistence } from '../../src/domain/types/WarpPersistence.ts'; import type RuntimeStorageProviderPort from '../../src/ports/RuntimeStorageProviderPort.ts'; +import type InMemoryGraphAdapter from './InMemoryGraphAdapter.ts'; type RuntimeHostOpenInput = Parameters[0]; +const STORAGE_BY_HISTORY = new WeakMap(); + function withMemoryRuntimeStorage { - const content = request.contentOverride ?? this._content; - return Promise.resolve( - Object.freeze({ - content, - patchJournal: this._createPatchJournal(request), - checkpoints: new CborCheckpointStoreAdapter({ - codec: request.codec, - blobPort: this._history, - }), - indexes: new CborIndexStoreAdapter({ - codec: request.codec, - blobPort: this._history, - treePort: this._history, - blobStorage: content, - }), - }) - ); + return Promise.resolve(Object.freeze({ + content: this.#content, + auditLog: new GitCasAuditLogAdapter({ + history: this.#history, + cas: this.#cas, + assets: this.#content, + compatibilityPolicy: TEST_COMPATIBILITY_POLICY, + }), + strands: new GitCasStrandStoreAdapter({ + history: this.#history, + cas: this.#cas, + assets: this.#content, + compatibilityPolicy: TEST_COMPATIBILITY_POLICY, + }), + intents: new GitCasIntentStoreAdapter({ + history: this.#history, + cas: this.#cas, + assets: this.#content, + codec: request.codec, + }), + patchJournal: new CborPatchJournalAdapter({ + assetStorage: this.#content, + cas: this.#cas, + codec: request.codec, + commitReader: this.#history, + commitMessageCodec: request.commitMessageCodec, + compatibilityPolicy: TEST_COMPATIBILITY_POLICY, + encrypted: this.#encrypted, + }), + checkpoints: new CborCheckpointStoreAdapter({ + codec: request.codec, + commitMessageCodec: request.commitMessageCodec, + history: this.#history, + assetStorage: this.#content, + cas: this.#cas, + }), + indexes: new CborIndexStoreAdapter({ + codec: request.codec, + assetStorage: this.#content, + cas: this.#cas, + }), + })); } +} - private _createPatchJournal(request: RuntimeStorageRequest): CborPatchJournalAdapter { - const patchContent: BlobStoragePort | undefined = request.patchContentOverride; - return new CborPatchJournalAdapter({ - codec: request.codec, - blobPort: this._history, - commitPort: this._history, - commitMessageCodec: request.commitMessageCodec, - ...(patchContent === undefined - ? { writeStorage: LEGACY_GIT_BLOB_PATCH_STORAGE } - : { - legacyPatchBlobStorage: patchContent, - writeStorage: LEGACY_EXTERNAL_PATCH_STORAGE, - }), +function resolveContentEncryption( + options: MemoryRuntimeStorageAdapterOptions, +): CasContentEncryptionPolicy { + if (options.encryptionKey !== undefined) { + return CasContentEncryptionPolicy.fromInternalResolvedKey({ + encryptionKey: options.encryptionKey, + }); + } + if (options.encrypted === true) { + return CasContentEncryptionPolicy.fromInternalResolvedKey({ + encryptionKey: new Uint8Array(32).fill(0x19), }); } + return CasContentEncryptionPolicy.disabled(); +} + +const TEST_COMPATIBILITY_POLICY = new SubstrateCompatibilityPolicy({ + legacyAuditReceiptTreeReads: true, + legacyContentBlobReads: true, + legacyInlinePayloadReads: true, + legacyPatchStorageReads: true, + legacyStrandDescriptorBlobReads: true, + legacyTrustRecordBlobReads: true, +}); + +function withFixtureObjectTypeProbe(history: InMemoryGraphAdapter): InMemoryGraphAdapter { + if (typeof history.readObjectType === 'function') { + return history; + } + const publicationCommits = new Set(); + const readObjectType = (oid: string): Promise => Promise.resolve( + publicationCommits.has(oid) ? 'commit' : 'blob', + ); + const commitNodeWithTree = async ( + options: Parameters[0], + ): Promise => { + const oid = await history.commitNodeWithTree(options); + publicationCommits.add(oid); + return oid; + }; + return new Proxy(history, { + get(target, property): unknown { + if (property === 'readObjectType') { + return readObjectType; + } + if (property === 'commitNodeWithTree') { + return commitNodeWithTree; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); } diff --git a/test/helpers/MockBlobPort.ts b/test/helpers/MockBlobPort.ts index 08abd955..a5938053 100644 --- a/test/helpers/MockBlobPort.ts +++ b/test/helpers/MockBlobPort.ts @@ -1,5 +1,4 @@ import { vi } from 'vitest'; -import BlobPort from '../../src/ports/BlobPort.ts'; /** * In-memory BlobPort for tests. @@ -7,7 +6,7 @@ import BlobPort from '../../src/ports/BlobPort.ts'; * Stores blobs in a Map and returns deterministic OIDs. * Methods are Vitest spies so callers can assert on calls. */ -export default class MockBlobPort extends BlobPort { +export default class MockBlobPort { /** @type {Map} */ store = new Map(); diff --git a/test/helpers/MockIndexStorage.ts b/test/helpers/MockIndexStorage.ts index 8f847154..a8273b17 100644 --- a/test/helpers/MockIndexStorage.ts +++ b/test/helpers/MockIndexStorage.ts @@ -1,62 +1,72 @@ import { vi } from 'vitest'; -import IndexStoragePort from '../../src/ports/IndexStoragePort.ts'; +import type { IndexShard } from '../../src/domain/artifacts/IndexShard.ts'; +import IndexError from '../../src/domain/errors/IndexError.ts'; +import AssetHandle from '../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../src/domain/storage/BundleHandle.ts'; +import WarpStream from '../../src/domain/stream/WarpStream.ts'; +import type CodecValue from '../../src/domain/types/codec/CodecValue.ts'; +import defaultCodec from '../../src/infrastructure/codecs/CborCodec.ts'; +import IndexStorePort from '../../src/ports/IndexStorePort.ts'; +import { IndexShardEncodeTransform } from '../../src/infrastructure/adapters/IndexShardEncodeTransform.ts'; -function cloneBytes(bytes: Uint8Array): Uint8Array { - return new Uint8Array(bytes); -} - -const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; +/** Test-only semantic index store with directly writable encoded shards. */ +export default class MockIndexStorage extends IndexStorePort { + readonly #blobs = new Map(); + readonly #indexes = new Map>>(); + #counter = 0; + readonly openedShardHandles: string[] = []; + readonly decodedShardHandles: string[] = []; -/** Test-only in-memory implementation of the live bitmap index storage port. */ -export default class MockIndexStorage extends IndexStoragePort { - private readonly blobStore = new Map(); - private readonly treeStore = new Map>([[EMPTY_TREE_OID, {}]]); - private readonly refs = new Map(); - private blobCounter = 0; - private treeCounter = 0; - - readonly writeBlob = vi.fn(async (content: Uint8Array | string) => { - const oid = String(this.blobCounter++).padStart(40, '0'); + readonly writeBlob = vi.fn(async (content: Uint8Array | string): Promise => { + const handle = new AssetHandle(`test-index-shard:${String(this.#counter++).padStart(8, '0')}`); const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; - this.blobStore.set(oid, cloneBytes(bytes)); - return oid; + this.#blobs.set(handle.toString(), bytes.slice()); + return handle; }); - readonly readBlob = vi.fn(async (oid: string) => { - const bytes = this.blobStore.get(oid); - if (bytes === undefined) { - throw new Error(`Blob not found: ${oid}`); + override async writeShards(shardStream: WarpStream): Promise { + const entries: Array<[string, AssetHandle]> = []; + for await (const [path, bytes] of shardStream.pipe(new IndexShardEncodeTransform(defaultCodec))) { + entries.push([path, await this.writeBlob(bytes)]); } - return cloneBytes(bytes); - }); + const handle = new BundleHandle(`test-index:${String(this.#counter++).padStart(8, '0')}`); + this.#indexes.set(handle.toString(), Object.freeze(Object.fromEntries(entries))); + return handle; + } - readonly writeTree = vi.fn(async (entries: string[]) => { - const oid = `tree_${String(this.treeCounter++).padStart(40, '0')}`; - const oidMap: Record = {}; - for (const entry of entries) { - const tabIndex = entry.indexOf('\t'); - const path = entry.slice(tabIndex + 1); - const blobOid = entry.slice(0, tabIndex).split(' ')[2]; - if (blobOid === undefined) { - throw new Error(`Invalid tree entry: ${entry}`); - } - oidMap[path] = blobOid; - } - this.treeStore.set(oid, oidMap); - return oid; - }); + override scanShards(_indexHandle: BundleHandle): WarpStream { + return WarpStream.of(); + } - readonly readTreeOids = vi.fn(async (treeOid: string) => { - const tree = this.treeStore.get(treeOid); - if (tree === undefined) { - throw new Error(`Tree not found: ${treeOid}`); - } - return { ...tree }; - }); + override async readShardHandles( + indexHandle: BundleHandle, + ): Promise>> { + return this.#indexes.get(indexHandle.toString()) ?? Object.freeze({}); + } - readonly updateRef = vi.fn(async (ref: string, oid: string) => { - this.refs.set(ref, oid); - }); + override async *openShard(handle: AssetHandle): AsyncIterable { + this.openedShardHandles.push(handle.toString()); + const bytes = this.#blobs.get(handle.toString()); + if (bytes === undefined) { + throw new IndexError(`Shard not found: ${handle.toString()}`, { + code: 'E_INDEX_SHARD_MISSING', + context: { handle: handle.toString() }, + }); + } + yield bytes.slice(); + } - readonly readRef = vi.fn(async (ref: string) => this.refs.get(ref) ?? null); + override async decodeShard( + handle: AssetHandle, + ): Promise { + this.decodedShardHandles.push(handle.toString()); + const bytes = this.#blobs.get(handle.toString()); + if (bytes === undefined) { + throw new IndexError(`Shard not found: ${handle.toString()}`, { + code: 'E_INDEX_SHARD_MISSING', + context: { handle: handle.toString() }, + }); + } + return defaultCodec.decode(bytes); + } } diff --git a/test/helpers/MockTreePort.ts b/test/helpers/MockTreePort.ts index d1c81355..10629117 100644 --- a/test/helpers/MockTreePort.ts +++ b/test/helpers/MockTreePort.ts @@ -4,7 +4,6 @@ import type TreeEntryLimit from '../../src/domain/tree/TreeEntryLimit.ts'; import TreeEntryMissing from '../../src/domain/tree/TreeEntryMissing.ts'; import TreeEntryPath from '../../src/domain/tree/TreeEntryPath.ts'; import TreeEntryPrefixBatch from '../../src/domain/tree/TreeEntryPrefixBatch.ts'; -import TreePort from '../../src/ports/TreePort.ts'; /** * In-memory TreePort for tests. @@ -13,7 +12,7 @@ import TreePort from '../../src/ports/TreePort.ts'; * writeTree (mktree-formatted entries) and readTreeOids. * Methods are Vitest spies so callers can assert on calls. */ -export default class MockTreePort extends TreePort { +export default class MockTreePort { store: Map> = new Map(); _counter: number = 0; diff --git a/test/helpers/MockTrustChainPort.ts b/test/helpers/MockTrustChainPort.ts index 388ca33e..467d83fc 100644 --- a/test/helpers/MockTrustChainPort.ts +++ b/test/helpers/MockTrustChainPort.ts @@ -8,6 +8,10 @@ import TrustChainPort from '../../src/ports/TrustChainPort.ts'; import type { TrustChainTip } from '../../src/ports/TrustChainPort.ts'; import { TrustRecord } from '../../src/domain/trust/TrustRecord.ts'; +import StorageHandle from '../../src/domain/storage/StorageHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../src/domain/storage/StorageRetentionWitness.ts'; class MockTrustChainPort extends TrustChainPort { private _records: TrustRecord[] = []; @@ -43,12 +47,31 @@ class MockTrustChainPort extends TrustChainPort { } async persistRecord( - _graphName: string, + graphName: string, record: TrustRecord, _parentTipSha: string | null, - ): Promise { + ): Promise> { this._records.push(record); - return `mock-sha-${record.recordId.slice(0, 8)}`; + const commitSha = `mock-sha-${record.recordId.slice(0, 8)}`; + return Object.freeze({ + commitSha, + retention: new StorageRetentionWitness({ + handle: new StorageHandle(`mock-trust:${record.recordId}`), + policy: 'pinned', + reachability: 'anchored', + root: new StorageRetentionRoot({ + kind: 'publication', + namespace: graphName, + locator: `refs/warp/${graphName}/trust`, + generation: commitSha, + path: '/', + }), + observedAt: new Date(0).toISOString(), + }), + }); } } diff --git a/test/helpers/WarpGraphMockPersistence.ts b/test/helpers/WarpGraphMockPersistence.ts index 9b14175f..1cb51354 100644 --- a/test/helpers/WarpGraphMockPersistence.ts +++ b/test/helpers/WarpGraphMockPersistence.ts @@ -1,11 +1,18 @@ import { vi } from 'vitest'; -import { encode } from '../../src/infrastructure/codecs/CborCodec.ts'; import { encodePatchMessage } from '../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import PatchEntry from '../../src/domain/artifacts/PatchEntry.ts'; +import WarpStream from '../../src/domain/stream/WarpStream.ts'; +import PatchJournalPort, { + type AppendPatchRequest, + type PublishedPatch, +} from '../../src/ports/PatchJournalPort.ts'; +import type Patch from '../../src/domain/types/Patch.ts'; +import type { PatchCommitMessage } from '../../src/ports/CommitMessageCodecPort.ts'; import { generateOidFromNumber } from './WarpGraphObjectIds.ts'; type PopulatedCommit = { readonly index: number; - readonly patch: object; + readonly patch: Patch; readonly parentIndex: number | null; readonly writerId: string; readonly lamport: number; @@ -74,8 +81,9 @@ class WarpGraphMockPersistence { class PopulatedWarpGraphMockPersistence extends WarpGraphMockPersistence { readonly #commitMap = new Map(); - readonly #blobMap = new Map(); + readonly #patchMap = new Map(); readonly #shaMap = new Map(); + readonly patchJournal: PatchJournalPort; override readonly nodeExists = vi.fn(async (sha: string) => this.#commitMap.has(sha)); override readonly getNodeInfo = vi.fn(async (sha: string) => { @@ -91,19 +99,12 @@ class PopulatedWarpGraphMockPersistence extends WarpGraphMockPersistence { parents: commit.parents, }; }); - override readonly readBlob = vi.fn(async (oid: string) => { - const blob = this.#blobMap.get(oid); - if (!blob) { - throw new MockPersistenceFixtureError(`Blob not found: ${oid}`); - } - return blob; - }); - constructor(commits: readonly PopulatedCommit[], graphName: string) { super(); for (const commit of commits) { this.#storeCommit(commit, graphName); } + this.patchJournal = new PopulatedPersistencePatchJournal(this); } #storeCommit(commit: PopulatedCommit, graphName: string): void { @@ -119,7 +120,7 @@ class PopulatedWarpGraphMockPersistence extends WarpGraphMockPersistence { }); this.#shaMap.set(commit.index, sha); - this.#blobMap.set(patchOid, encode(commit.patch)); + this.#patchMap.set(patchOid, commit.patch); this.#commitMap.set(sha, { sha, message, @@ -137,6 +138,39 @@ class PopulatedWarpGraphMockPersistence extends WarpGraphMockPersistence { } return sha; } + + readFixturePatch(handle: string): Patch { + const patch = this.#patchMap.get(handle); + if (patch === undefined) { + throw new MockPersistenceFixtureError(`Patch not found: ${handle}`); + } + return patch; + } +} + +class PopulatedPersistencePatchJournal extends PatchJournalPort { + readonly #persistence: PopulatedWarpGraphMockPersistence; + + constructor(persistence: PopulatedWarpGraphMockPersistence) { + super(); + this.#persistence = persistence; + } + + override appendPatch(_request: AppendPatchRequest): Promise { + throw new MockPersistenceFixtureError('appendPatch is outside this fixture'); + } + + override readPatch(message: PatchCommitMessage): Promise { + return Promise.resolve(this.#persistence.readFixturePatch(message.patchHandle.toString())); + } + + override scanPatchRange( + _writerId: string, + _fromSha: string | null, + _toSha: string, + ): WarpStream { + return WarpStream.from([]); + } } export function createMockPersistence(): WarpGraphMockPersistence { diff --git a/test/helpers/storageRetention.ts b/test/helpers/storageRetention.ts new file mode 100644 index 00000000..46c1b486 --- /dev/null +++ b/test/helpers/storageRetention.ts @@ -0,0 +1,23 @@ +import StorageHandle from '../../src/domain/storage/StorageHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../src/domain/storage/StorageRetentionWitness.ts'; + +/** Stable anchored publication evidence for semantic storage tests. */ +export function testRetentionWitness( + generation = 'test-generation', +): StorageRetentionWitness { + return new StorageRetentionWitness({ + handle: new StorageHandle(`test-asset:${generation}`), + policy: 'pinned', + reachability: 'anchored', + root: new StorageRetentionRoot({ + kind: 'publication', + namespace: 'test', + locator: 'refs/warp/test/publications', + generation, + path: '/', + }), + observedAt: '1970-01-01T00:00:00.000Z', + }); +} diff --git a/test/integration/api/checkpoint.test.ts b/test/integration/api/checkpoint.test.ts index fc008fa3..81c860a4 100644 --- a/test/integration/api/checkpoint.test.ts +++ b/test/integration/api/checkpoint.test.ts @@ -1,4 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execSync } from 'node:child_process'; +import ContentAddressableStore, { + AssetHandle as GitCasAssetHandle, + BundleHandle as GitCasBundleHandle, + type BundleMember, +} from '@git-stunts/git-cas'; import { createTestRepo } from './helpers/setup.ts'; import { decodeCheckpointMessage } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import SchemaUnsupportedError from '../../../src/domain/errors/SchemaUnsupportedError.ts'; @@ -6,9 +12,24 @@ import SchemaUnsupportedError from '../../../src/domain/errors/SchemaUnsupported async function readCheckpointArtifacts(repo, checkpointSha) { const message = await repo.persistence.showNode(checkpointSha); const decoded = decodeCheckpointMessage(message); - const treeOid = await repo.persistence.getCommitTree(checkpointSha); - const treeOids = await repo.persistence.readTreeOids(treeOid); - return { decoded, treeOids }; + if (decoded.bundleHandle === null) { + throw new Error('expected current checkpoint bundle handle'); + } + const cas = ContentAddressableStore.createCbor({ + plumbing: repo.plumbing, + chunking: { strategy: 'cdc' }, + applicationRefPrefixes: ['refs/warp/'], + }); + const members: BundleMember[] = []; + for await (const member of cas.bundles.iterateMembers({ + handle: decoded.bundleHandle.toString(), + })) { + members.push(member); + } + const memberHandles = Object.fromEntries( + members.map((member) => [member.path, member.handle.toString()]), + ); + return { decoded, members, memberHandles }; } describe('API: Checkpoint', () => { @@ -32,11 +53,12 @@ describe('API: Checkpoint', () => { const sha = await graph.createCheckpoint(); expect(sha).toMatch(/^[0-9a-f]{40}$/); - const { decoded, treeOids } = await readCheckpointArtifacts(repo, sha); + const { decoded, memberHandles } = await readCheckpointArtifacts(repo, sha); expect(decoded.schema).toBe(5); - expect(treeOids['state/nodeAlive']).toBeDefined(); - expect(treeOids['state/edgeAlive']).toBeDefined(); - expect(treeOids['state.cbor']).toBeUndefined(); + expect(decoded.bundleHandle).not.toBeNull(); + expect(memberHandles['state/nodeAlive']).toBeDefined(); + expect(memberHandles['state/edgeAlive']).toBeDefined(); + expect(memberHandles['state.cbor']).toBeUndefined(); }); it('materializeAt rejects session-backed runtime checkpoints', async () => { @@ -47,11 +69,11 @@ describe('API: Checkpoint', () => { await graph.materialize(); const sha = await graph.createCheckpoint(); - const { decoded, treeOids } = await readCheckpointArtifacts(repo, sha); + const { decoded, memberHandles } = await readCheckpointArtifacts(repo, sha); expect(decoded.schema).toBe(5); - expect(treeOids['state/nodeAlive']).toBeDefined(); - expect(treeOids['state/edgeAlive']).toBeDefined(); - expect(treeOids['state.cbor']).toBeUndefined(); + expect(memberHandles['state/nodeAlive']).toBeDefined(); + expect(memberHandles['state/edgeAlive']).toBeDefined(); + expect(memberHandles['state.cbor']).toBeUndefined(); await expect(graph.materializeAt(sha)).rejects.toBeInstanceOf(SchemaUnsupportedError); await expect(graph.materializeAt(sha)).rejects.toMatchObject({ @@ -76,9 +98,39 @@ describe('API: Checkpoint', () => { expect(sha2).toMatch(/^[0-9a-f]{40}$/); expect(checkpoint1.decoded.schema).toBe(5); expect(checkpoint2.decoded.schema).toBe(5); - expect(checkpoint1.treeOids['state/nodeAlive']).toBeDefined(); - expect(checkpoint2.treeOids['state/nodeAlive']).toBeDefined(); - expect(checkpoint1.treeOids['state.cbor']).toBeUndefined(); - expect(checkpoint2.treeOids['state.cbor']).toBeUndefined(); + expect(checkpoint1.memberHandles['state/nodeAlive']).toBeDefined(); + expect(checkpoint2.memberHandles['state/nodeAlive']).toBeDefined(); + expect(checkpoint1.memberHandles['state.cbor']).toBeUndefined(); + expect(checkpoint2.memberHandles['state.cbor']).toBeUndefined(); + }); + + it('keeps the checkpoint bundle and every asset out of immediate-prune output', async () => { + const graph = await repo.openGraph('test', 'writer1', { stateCache: null }); + await (await graph.createPatch()).addNode('n1').commit(); + await graph.materialize(); + + const sha = await graph.createCheckpoint(); + const { decoded, members } = await readCheckpointArtifacts(repo, sha); + if (decoded.bundleHandle === null) { + throw new Error('expected current checkpoint bundle handle'); + } + const retainedOids = [ + GitCasBundleHandle.parse(decoded.bundleHandle.toString()).oid, + ...members.map((member) => { + if (member.handle.kind !== 'asset') { + throw new Error(`expected checkpoint asset member: ${member.path}`); + } + return GitCasAssetHandle.from(member.handle).oid; + }), + ]; + const prunable = execSync('git prune -n --expire=now', { + cwd: repo.tempDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + for (const oid of retainedOids) { + expect(prunable).not.toContain(oid); + } }); }); diff --git a/test/integration/api/content-attachment.test.ts b/test/integration/api/content-attachment.test.ts index eb67f62a..c0f047b5 100644 --- a/test/integration/api/content-attachment.test.ts +++ b/test/integration/api/content-attachment.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execSync } from 'node:child_process'; +import { AssetHandle as GitCasAssetHandle } from '@git-stunts/git-cas'; import { createTestRepo } from './helpers/setup.ts'; -import PersistenceError from '../../../src/domain/errors/PersistenceError.ts'; +import { + DEFAULT_COMMIT_MESSAGE_CODEC, +} from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; describe('API: Content Attachment', () => { let repo; @@ -28,7 +31,7 @@ describe('API: Content Attachment', () => { expect(new TextDecoder().decode(content)).toBe('# Hello World\n\nThis is content.'); }); - it('getContentOid returns hex OID', async () => { + it('getContentHandle returns an opaque storage handle', async () => { const graph = await repo.openGraph('test', 'alice'); const patch = await graph.createPatch(); @@ -37,11 +40,9 @@ describe('API: Content Attachment', () => { await patch.commit(); await graph.materialize(); - const oid = await graph.getContentOid('doc:1'); - expect(oid).not.toBeNull(); - // Support both SHA-1 (40 chars) and SHA-256 (64 chars) - expect(oid).toMatch(/^[0-9a-f]+$/); - expect(oid.length).toBeGreaterThanOrEqual(40); + const handle = await graph.getContentHandle('doc:1'); + expect(handle).toEqual(expect.any(String)); + expect(handle).not.toBe(''); }); it('persists and reads content metadata for nodes', async () => { @@ -63,7 +64,7 @@ describe('API: Content Attachment', () => { mime: 'text/markdown', size: 8, }); - expect(meta?.oid).toMatch(/^[0-9a-f]+$/); + expect(meta?.handle).toEqual(expect.any(String)); }); it('returns null when no content attached', async () => { @@ -75,7 +76,7 @@ describe('API: Content Attachment', () => { await graph.materialize(); expect(await graph.getContent('doc:1')).toBeNull(); - expect(await graph.getContentOid('doc:1')).toBeNull(); + expect(await graph.getContentHandle('doc:1')).toBeNull(); }); it('returns null for nonexistent node', async () => { @@ -83,7 +84,7 @@ describe('API: Content Attachment', () => { await graph.materialize(); expect(await graph.getContent('nonexistent')).toBeNull(); - expect(await graph.getContentOid('nonexistent')).toBeNull(); + expect(await graph.getContentHandle('nonexistent')).toBeNull(); }); it('clearContent removes node content and metadata through the public patch API', async () => { @@ -110,7 +111,7 @@ describe('API: Content Attachment', () => { await graph.materialize(); expect(await graph.getContent('doc:1')).toBeNull(); - expect(await graph.getContentOid('doc:1')).toBeNull(); + expect(await graph.getContentHandle('doc:1')).toBeNull(); expect(await graph.getContentMeta('doc:1')).toBeNull(); }); @@ -127,8 +128,9 @@ describe('API: Content Attachment', () => { expect(content).not.toBeNull(); expect(new TextDecoder().decode(content)).toBe('edge payload'); - const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); - expect(oid).toMatch(/^[0-9a-f]+$/); + const handle = await graph.getEdgeContentHandle('a', 'b', 'rel'); + expect(handle).toEqual(expect.any(String)); + expect(handle).not.toBe(''); }); it('persists and reads content metadata for edges', async () => { @@ -144,7 +146,7 @@ describe('API: Content Attachment', () => { const meta = await graph.getEdgeContentMeta('a', 'b', 'rel'); expect(meta).toEqual({ - oid: expect.stringMatching(/^[0-9a-f]+$/), + handle: expect.any(String), mime: null, size: binary.byteLength, }); @@ -174,7 +176,7 @@ describe('API: Content Attachment', () => { await graph.materialize(); expect(await graph.getEdgeContent('a', 'b', 'rel')).toBeNull(); - expect(await graph.getEdgeContentOid('a', 'b', 'rel')).toBeNull(); + expect(await graph.getEdgeContentHandle('a', 'b', 'rel')).toBeNull(); expect(await graph.getEdgeContentMeta('a', 'b', 'rel')).toBeNull(); }); @@ -281,6 +283,40 @@ describe('API: Content Attachment', () => { expect(new TextDecoder().decode(content)).toBe('must survive gc'); }); + it('causal publication keeps every referenced asset out of immediate-prune output', async () => { + const graph = await repo.openGraph('test', 'alice'); + const patch = await graph.createPatch(); + patch.addNode('doc:retained'); + await patch.attachContent('doc:retained', 'retained content'); + await patch.commit(); + await graph.materialize(); + + const contentHandle = await graph.getContentHandle('doc:retained'); + const head = await repo.persistence.readRef('refs/warp/test/writers/alice'); + expect(contentHandle).not.toBeNull(); + expect(head).not.toBeNull(); + if (contentHandle === null || head === null) { + throw new Error('Expected retained content and a causal publication head'); + } + const message = DEFAULT_COMMIT_MESSAGE_CODEC.decodePatch( + await repo.persistence.showNode(head), + ); + const retainedOids = [ + GitCasAssetHandle.parse(contentHandle).oid, + GitCasAssetHandle.parse(message.patchHandle.toString()).oid, + await repo.persistence.getCommitTree(head), + ]; + const prunable = execSync('git prune -n --expire=now', { + cwd: repo.tempDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + for (const oid of retainedOids) { + expect(prunable).not.toContain(oid); + } + }); + it('checkpoint anchoring: content survives GC after checkpoint', async () => { const graph = await repo.openGraph('test', 'alice'); @@ -321,7 +357,7 @@ describe('API: Content Attachment', () => { expect(content).toEqual(binary); }); - it('throws when _content points at a missing blob OID', async () => { + it('rejects a legacy raw node-content handle when compatibility is disabled', async () => { const graph = await repo.openGraph('test', 'alice'); const patch = await graph.createPatch(); @@ -338,16 +374,16 @@ describe('API: Content Attachment', () => { await graph.materialize(); await expect(graph.getContentMeta('doc:1')).resolves.toEqual({ - oid: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + handle: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', mime: null, size: null, }); await expect(graph.getContent('doc:1')) - .rejects.toMatchObject({ code: PersistenceError.E_MISSING_OBJECT }); + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); }); - it('throws when edge _content points at a missing blob OID', async () => { + it('rejects a legacy raw edge-content handle when compatibility is disabled', async () => { const graph = await repo.openGraph('test', 'alice'); const patch = await graph.createPatch(); @@ -364,12 +400,12 @@ describe('API: Content Attachment', () => { await graph.materialize(); await expect(graph.getEdgeContentMeta('a', 'b', 'rel')).resolves.toEqual({ - oid: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + handle: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', mime: null, size: null, }); await expect(graph.getEdgeContent('a', 'b', 'rel')) - .rejects.toMatchObject({ code: PersistenceError.E_MISSING_OBJECT }); + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); }); }); diff --git a/test/integration/api/helpers/setup.ts b/test/integration/api/helpers/setup.ts index e8cd2945..9ce9a2b5 100644 --- a/test/integration/api/helpers/setup.ts +++ b/test/integration/api/helpers/setup.ts @@ -18,7 +18,7 @@ import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; * Creates a temporary git repository with persistence adapter, codec, and crypto. * * @param {string} [label='api-test'] - Label for the temp directory prefix - * @returns {Promise<{persistence: Object, tempDir: string, codec: typeof defaultCodec, crypto: WebCryptoAdapter, cleanup: () => Promise, openGraph: (graphName: string, writerId: string, opts?: Object) => Promise}>} + * @returns {Promise<{persistence: Object, plumbing: Object, tempDir: string, codec: typeof defaultCodec, crypto: WebCryptoAdapter, cleanup: () => Promise, openGraph: (graphName: string, writerId: string, opts?: Object) => Promise}>} */ export async function createTestRepo(label = 'api-test') { const tempDir = await mkdtemp(join(tmpdir(), `warp-${label}-`)); @@ -60,6 +60,7 @@ export async function createTestRepo(label = 'api-test') { return { persistence, + plumbing, tempDir, codec, crypto, diff --git a/test/unit/cli/verify-audit.test.ts b/test/unit/cli/verify-audit.test.ts index 76d9f53d..1036b841 100644 --- a/test/unit/cli/verify-audit.test.ts +++ b/test/unit/cli/verify-audit.test.ts @@ -4,6 +4,7 @@ import type { CliOptions } from '../../../bin/cli/types.ts'; const sharedMocks = vi.hoisted(() => ({ createPersistence: vi.fn(), + createRuntimeStorageServices: vi.fn(), resolveGraphName: vi.fn(), })); @@ -39,7 +40,15 @@ const CLI_OPTIONS: CliOptions = { describe('verify-audit command', () => { beforeEach(() => { vi.clearAllMocks(); - sharedMocks.createPersistence.mockResolvedValue({ persistence: { kind: 'persistence' } }); + sharedMocks.createRuntimeStorageServices.mockResolvedValue({ + auditLog: { kind: 'audit-log' }, + }); + sharedMocks.createPersistence.mockResolvedValue({ + persistence: { kind: 'persistence' }, + runtimeStorage: { + createRuntimeStorageServices: sharedMocks.createRuntimeStorageServices, + }, + }); sharedMocks.resolveGraphName.mockResolvedValue('demo'); verifierMocks.verifyAll.mockResolvedValue({ graph: 'demo', diff --git a/test/unit/domain/DraftTimelineRuntime.test.ts b/test/unit/domain/DraftTimelineRuntime.test.ts index 452c7cfd..b75cf934 100644 --- a/test/unit/domain/DraftTimelineRuntime.test.ts +++ b/test/unit/domain/DraftTimelineRuntime.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import WarpWorldline, { type WarpWorldlinePatchBuild } from '../../../src/domain/WarpWorldline.ts'; +import AssetHandle from '../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../src/domain/storage/BundleHandle.ts'; +import Patch from '../../../src/domain/types/Patch.ts'; import type { ApiRuntimeContext, ReceiptProvenance, @@ -11,6 +14,8 @@ import { previewDraftJoin, } from '../../../src/domain/api/DraftTimelineRuntime.ts'; import { intent } from '../../../src/domain/api/IntentBuilders.ts'; +import type { PatchCommitResult } from '../../../src/domain/types/PatchCommitResult.ts'; +import { testRetentionWitness } from '../../helpers/storageRetention.ts'; type RuntimeOptions = { readonly commitPatch?: (build: WarpWorldlinePatchBuild) => Promise; @@ -40,20 +45,50 @@ function createRuntimeContext(options: RuntimeContextOptions = {}): { } function createRuntime(options: RuntimeOptions = {}): WarpWorldline { + const commitPatch = options.commitPatch ?? (async () => 'commit-1'); + const patchDraft = options.patchDraft ?? (async (name) => `${name}-draft-patch`); return new WarpWorldline({ worldlineName: 'events', writerId: 'agent-1', - commitPatch: options.commitPatch ?? (async () => 'commit-1'), + commitPatch, + commitPatchWithEvidence: async (build) => testPatchPublication(await commitPatch(build)), createDraft: async () => undefined, createWorldline: () => { throw new Error('ProjectionHandle is not used by DraftTimelineRuntime tests'); }, - patchDraft: options.patchDraft ?? (async (name) => `${name}-draft-patch`), + patchDraft, + patchDraftWithEvidence: async (name, build) => + testPatchPublication(await patchDraft(name, build)), previewDraftJoin: options.previewDraftJoin ?? (async (name) => [`${name}-preview-patch`]), admitIntent: async (descriptor) => ({ admitted: true, sha: 'intent-sha', intentId: descriptor.intentId, + retention: testRetentionWitness('intent-sha'), + }), + }); +} + +function testPatchPublication(sha: string): PatchCommitResult { + const retention = testRetentionWitness(sha); + return Object.freeze({ + sha, + bundleHandle: new BundleHandle(`test-bundle:${sha}`), + stagedPatch: Object.freeze({ + handle: new AssetHandle(`test-asset:${sha}`), + size: 0, + observedAt: retention.observedAt, + retention: Object.freeze({ + reachability: 'unanchored', + protection: 'not-established', + }), + }), + retention, + patch: new Patch({ + writer: 'agent-1', + lamport: 0, + context: {}, + ops: [], }), }); } diff --git a/test/unit/domain/RetentionEvidence.test.ts b/test/unit/domain/RetentionEvidence.test.ts new file mode 100644 index 00000000..e3f0b6d9 --- /dev/null +++ b/test/unit/domain/RetentionEvidence.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import RetentionEvidence from '../../../src/domain/api/RetentionEvidence.ts'; + +describe('RetentionEvidence', () => { + it('establishes and freezes the public retention projection', () => { + const evidence = new RetentionEvidence({ + witness: { id: 'evidence:retention:1' }, + policy: 'pinned', + reachability: 'anchored', + rootKind: 'publication', + }); + + expect(evidence).toBeInstanceOf(RetentionEvidence); + expect(evidence).toEqual({ + witness: { id: 'evidence:retention:1' }, + policy: 'pinned', + reachability: 'anchored', + rootKind: 'publication', + }); + expect(Object.isFrozen(evidence)).toBe(true); + expect(Object.isFrozen(evidence.witness)).toBe(true); + }); + + it.each([ + ['evictable', 'orphaned', 'root-set'], + ['evictable', 'volatile', 'cache-set'], + ['pinned', 'anchored', 'expiring-set'], + ])('accepts canonical policy %s, reachability %s, and root %s', (policy, reachability, rootKind) => { + expect(Reflect.construct(RetentionEvidence, [{ + witness: { id: 'evidence:retention:variant' }, + policy, + reachability, + rootKind, + }])).toBeInstanceOf(RetentionEvidence); + }); + + it.each([ + [null, 'options'], + [[], 'options'], + [1, 'options'], + [{ witness: null, policy: 'pinned', reachability: 'anchored', rootKind: 'publication' }, 'witness'], + [{ witness: 1, policy: 'pinned', reachability: 'anchored', rootKind: 'publication' }, 'witness'], + [{ witness: { id: '' }, policy: 'pinned', reachability: 'anchored', rootKind: 'publication' }, 'witness.id'], + [{ witness: { id: 'w' }, policy: 'forever', reachability: 'anchored', rootKind: 'publication' }, 'policy'], + [{ witness: { id: 'w' }, policy: 'pinned', reachability: 'reachable', rootKind: 'publication' }, 'reachability'], + [{ witness: { id: 'w' }, policy: 'pinned', reachability: 'anchored', rootKind: 'branch' }, 'rootKind'], + ])('rejects invalid runtime input for %s', (options, field) => { + expect(() => Reflect.construct(RetentionEvidence, [options])).toThrow( + expect.objectContaining({ code: 'E_RECEIPT_EVIDENCE', message: expect.stringContaining(field) }), + ); + }); +}); diff --git a/test/unit/domain/WarpApp.delegation.test.ts b/test/unit/domain/WarpApp.delegation.test.ts index 2ce1f4e7..0f60f277 100644 --- a/test/unit/domain/WarpApp.delegation.test.ts +++ b/test/unit/domain/WarpApp.delegation.test.ts @@ -21,12 +21,12 @@ function createMockRuntime() { // Content methods — accessed via callInternalRuntimeMethod prototype chain getContent: vi.fn(async () => new Uint8Array([1, 2, 3])), getContentStream: vi.fn(async function* () { yield new Uint8Array([1]); }), - getContentOid: vi.fn(async () => 'a'.repeat(40)), - getContentMeta: vi.fn(async () => ({ oid: 'a'.repeat(40), mime: 'text/plain', size: 42 })), + getContentHandle: vi.fn(async () => 'asset:node'), + getContentMeta: vi.fn(async () => ({ handle: 'asset:node', mime: 'text/plain', size: 42 })), getEdgeContent: vi.fn(async () => new Uint8Array([4, 5, 6])), getEdgeContentStream: vi.fn(async function* () { yield new Uint8Array([2]); }), - getEdgeContentOid: vi.fn(async () => 'b'.repeat(40)), - getEdgeContentMeta: vi.fn(async () => ({ oid: 'b'.repeat(40), mime: null, size: 10 })), + getEdgeContentHandle: vi.fn(async () => 'asset:edge'), + getEdgeContentMeta: vi.fn(async () => ({ handle: 'asset:edge', mime: null, size: 10 })), }; } @@ -259,12 +259,12 @@ describe('WarpApp delegation', () => { }); }); - describe('getContentOid', () => { - it('delegates to runtime getContentOid', async () => { - const result = await app.getContentOid('node:1'); + describe('getContentHandle', () => { + it('delegates to runtime getContentHandle', async () => { + const result = await app.getContentHandle('node:1'); - expect(mockRuntime.getContentOid).toHaveBeenCalledWith('node:1'); - expect(result).toBe('a'.repeat(40)); + expect(mockRuntime.getContentHandle).toHaveBeenCalledWith('node:1'); + expect(result).toBe('asset:node'); }); }); @@ -273,7 +273,7 @@ describe('WarpApp delegation', () => { const result = await app.getContentMeta('node:1'); expect(mockRuntime.getContentMeta).toHaveBeenCalledWith('node:1'); - expect(result).toEqual({ oid: 'a'.repeat(40), mime: 'text/plain', size: 42 }); + expect(result).toEqual({ handle: 'asset:node', mime: 'text/plain', size: 42 }); }); }); @@ -297,12 +297,12 @@ describe('WarpApp delegation', () => { }); }); - describe('getEdgeContentOid', () => { - it('delegates to runtime getEdgeContentOid', async () => { - const result = await app.getEdgeContentOid('a', 'b', 'knows'); + describe('getEdgeContentHandle', () => { + it('delegates to runtime getEdgeContentHandle', async () => { + const result = await app.getEdgeContentHandle('a', 'b', 'knows'); - expect(mockRuntime.getEdgeContentOid).toHaveBeenCalledWith('a', 'b', 'knows'); - expect(result).toBe('b'.repeat(40)); + expect(mockRuntime.getEdgeContentHandle).toHaveBeenCalledWith('a', 'b', 'knows'); + expect(result).toBe('asset:edge'); }); }); @@ -311,7 +311,7 @@ describe('WarpApp delegation', () => { const result = await app.getEdgeContentMeta('a', 'b', 'knows'); expect(mockRuntime.getEdgeContentMeta).toHaveBeenCalledWith('a', 'b', 'knows'); - expect(result).toEqual({ oid: 'b'.repeat(40), mime: null, size: 10 }); + expect(result).toEqual({ handle: 'asset:edge', mime: null, size: 10 }); }); }); diff --git a/test/unit/domain/WarpCore.blobAutoConstruct.test.ts b/test/unit/domain/WarpCore.blobAutoConstruct.test.ts index 134cea9a..8980c919 100644 --- a/test/unit/domain/WarpCore.blobAutoConstruct.test.ts +++ b/test/unit/domain/WarpCore.blobAutoConstruct.test.ts @@ -1,112 +1,57 @@ -import { describe, it, expect, vi } from 'vitest'; -import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import type { CorePersistence } from '../../../src/domain/types/WarpPersistence.ts'; -import MemoryRuntimeStorageAdapter from '../../../test/helpers/MemoryRuntimeStorageAdapter.ts'; - -/** - * Spec tests for runtime content storage composition. - * - * Runtime storage is an explicit sibling of timeline history. The provider - * owns adapter construction and the domain consumes only semantic services. - */ - -type MockPersistence = CorePersistence & { - configGet: ReturnType; - configSet: ReturnType; -}; - -function makeMockPersistence(): MockPersistence { - return { - commitNode: vi.fn(async () => 'c'.repeat(40)), - showNode: vi.fn(async () => ''), - readRef: vi.fn(async () => null), - listRefs: vi.fn(async () => []), - updateRef: vi.fn(async () => undefined), - deleteRef: vi.fn(async () => undefined), - compareAndSwapRef: vi.fn(async () => undefined), - logNodes: vi.fn(async () => ''), - logNodesStream: vi.fn(), - countNodes: vi.fn(async () => 0), - configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(undefined), - readBlob: vi.fn(async () => new Uint8Array()), - writeBlob: vi.fn(async () => 'a'.repeat(40)), - readTree: vi.fn(async () => ({})), - getNodeInfo: vi.fn(async () => ({ message: '', parents: [], sha: 'a'.repeat(40), author: '', date: '' })), - nodeExists: vi.fn(async () => true), - getCommitTree: vi.fn(async () => 'b'.repeat(40)), - readTreeOids: vi.fn(async () => ({})), - writeTree: vi.fn(async () => 'a'.repeat(40)), - commitNodeWithTree: vi.fn(async () => 'd'.repeat(40)), - ping: vi.fn(async () => ({ ok: true, latencyMs: 0 })), - emptyTree: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', - }; -} - -describe('WarpCore runtime content storage composition', () => { - it('obtains content storage from the injected runtime provider', async () => { - const persistence = makeMockPersistence(); - const graph = await openRuntimeHostProduct({ - persistence, - runtimeStorage: new MemoryRuntimeStorageAdapter({ history: persistence }), - graphName: 'test', - writerId: 'w1', +import { describe, expect, it } from 'vitest'; +import { openRuntimeHostProduct } from '../../../src/domain/warp/RuntimeHostProduct.ts'; +import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import { openMemoryRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; +import InMemoryGraphAdapter from '../../helpers/InMemoryGraphAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../helpers/MemoryRuntimeStorageAdapter.ts'; + +describe('runtime storage composition', () => { + it('obtains semantic content storage from the repository provider', async () => { + const history = new InMemoryGraphAdapter(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ history }); + const services = await runtimeStorage.createRuntimeStorageServices({ + timelineName: 'events', + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); - expect(graph._blobStorage).not.toBeNull(); - }); - - it('provides streaming content methods without persistence capability reflection', async () => { - const persistence = makeMockPersistence(); - const graph = await openRuntimeHostProduct({ - persistence, - runtimeStorage: new MemoryRuntimeStorageAdapter({ history: persistence }), - graphName: 'test', - writerId: 'w1', + const runtime = await openMemoryRuntimeHostProduct({ + persistence: history, + runtimeStorage, + graphName: 'events', + writerId: 'writer-1', }); - const content = graph._blobStorage; - expect(content).not.toBeNull(); - if (content === null) { - throw new Error('runtime content storage must be configured'); - } - expect(typeof content.storeStream).toBe('function'); - expect(typeof content.retrieveStream).toBe('function'); + expect(runtime._assetStorage).toBe(services.content); + expect(runtime._checkpointStore).toBeInstanceOf(services.checkpoints.constructor); + expect(runtime._indexStore).toBeInstanceOf(services.indexes.constructor); }); - it('preserves explicitly provided blobStorage', async () => { - const customStorage = { - store: vi.fn(), - retrieve: vi.fn(), - storeStream: vi.fn(), - retrieveStream: vi.fn(), - }; - const persistence = makeMockPersistence(); - const graph = await openRuntimeHostProduct({ - persistence, - runtimeStorage: new MemoryRuntimeStorageAdapter({ history: persistence }), - graphName: 'test', - writerId: 'w1', - blobStorage: customStorage, + it('round-trips attached content through semantic asset storage', async () => { + const history = new InMemoryGraphAdapter(); + const runtime = await openMemoryRuntimeHostProduct({ + persistence: history, + graphName: 'attachments', + writerId: 'writer-1', }); - expect(graph._blobStorage).toBe(customStorage); - }); - - it('attachContent uses provider content storage instead of history blobs', async () => { - const persistence = makeMockPersistence(); - const graph = await openRuntimeHostProduct({ - persistence, - runtimeStorage: new MemoryRuntimeStorageAdapter({ history: persistence }), - graphName: 'test', - writerId: 'w1', + await runtime.patch(async (patch) => { + patch.addNode('doc:readme'); + await patch.attachContent('doc:readme', 'hello', { mime: 'text/plain' }); }); + await runtime.materialize(); - const patch = await graph.createPatch(); - patch.addNode('n1'); - - await patch.attachContent('n1', 'hello'); + const content = await runtime.getContent('doc:readme'); + expect(new TextDecoder().decode(content ?? new Uint8Array())).toBe('hello'); + expect(await runtime.getContentHandle('doc:readme')).toMatch(/^git-cas:/u); + }); - expect(graph._persistence.writeBlob).not.toHaveBeenCalled(); + it('rejects production runtime opens without an explicit storage provider', async () => { + await expect(openRuntimeHostProduct({ + persistence: new InMemoryGraphAdapter(), + graphName: 'missing-storage', + writerId: 'writer-1', + })).rejects.toMatchObject({ code: 'E_RUNTIME_STORAGE_REQUIRED' }); }); }); diff --git a/test/unit/domain/WarpCore.content.test.ts b/test/unit/domain/WarpCore.content.test.ts index 657cfc99..7db067a0 100644 --- a/test/unit/domain/WarpCore.content.test.ts +++ b/test/unit/domain/WarpCore.content.test.ts @@ -17,12 +17,12 @@ function createMockCoreSurfaceForAdopt() { return { getContent: vi.fn(async () => new Uint8Array([1, 2, 3])), getContentStream: vi.fn(async () => (async function* () { yield new Uint8Array([1]); })()), - getContentOid: vi.fn(async () => 'a'.repeat(40)), - getContentMeta: vi.fn(async () => ({ oid: 'a'.repeat(40), mime: 'text/plain', size: 42 })), + getContentHandle: vi.fn(async () => 'asset:node'), + getContentMeta: vi.fn(async () => ({ handle: 'asset:node', mime: 'text/plain', size: 42 })), getEdgeContent: vi.fn(async () => new Uint8Array([4, 5, 6])), getEdgeContentStream: vi.fn(async () => (async function* () { yield new Uint8Array([2]); })()), - getEdgeContentOid: vi.fn(async () => 'b'.repeat(40)), - getEdgeContentMeta: vi.fn(async () => ({ oid: 'b'.repeat(40), mime: null, size: 10 })), + getEdgeContentHandle: vi.fn(async () => 'asset:edge'), + getEdgeContentMeta: vi.fn(async () => ({ handle: 'asset:edge', mime: null, size: 10 })), get _effectPipeline() { return effectPipeline; }, @@ -115,18 +115,18 @@ describe('WarpCore', () => { expect(result).toBeDefined(); }); - it('getContentOid delegates to the adopted surface method', async () => { - const result = await core.getContentOid('node:1'); + it('getContentHandle delegates to the adopted surface method', async () => { + const result = await core.getContentHandle('node:1'); - expect(surface.getContentOid).toHaveBeenCalledWith('node:1'); - expect(result).toBe('a'.repeat(40)); + expect(surface.getContentHandle).toHaveBeenCalledWith('node:1'); + expect(result).toBe('asset:node'); }); it('getContentMeta delegates to the adopted surface method', async () => { const result = await core.getContentMeta('node:1'); expect(surface.getContentMeta).toHaveBeenCalledWith('node:1'); - expect(result).toEqual({ oid: 'a'.repeat(40), mime: 'text/plain', size: 42 }); + expect(result).toEqual({ handle: 'asset:node', mime: 'text/plain', size: 42 }); }); }); @@ -155,18 +155,18 @@ describe('WarpCore', () => { expect(result).toBeDefined(); }); - it('getEdgeContentOid delegates to the adopted surface method', async () => { - const result = await core.getEdgeContentOid('a', 'b', 'knows'); + it('getEdgeContentHandle delegates to the adopted surface method', async () => { + const result = await core.getEdgeContentHandle('a', 'b', 'knows'); - expect(surface.getEdgeContentOid).toHaveBeenCalledWith('a', 'b', 'knows'); - expect(result).toBe('b'.repeat(40)); + expect(surface.getEdgeContentHandle).toHaveBeenCalledWith('a', 'b', 'knows'); + expect(result).toBe('asset:edge'); }); it('getEdgeContentMeta delegates to the adopted surface method', async () => { const result = await core.getEdgeContentMeta('a', 'b', 'knows'); expect(surface.getEdgeContentMeta).toHaveBeenCalledWith('a', 'b', 'knows'); - expect(result).toEqual({ oid: 'b'.repeat(40), mime: null, size: 10 }); + expect(result).toEqual({ handle: 'asset:edge', mime: null, size: 10 }); }); }); diff --git a/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts b/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts index d235bf1b..2c1aa6f6 100644 --- a/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts +++ b/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import WarpCore from "../../../src/domain/WarpCore.ts"; import SchemaUnsupportedError from "../../../src/domain/errors/SchemaUnsupportedError.ts"; import { resolveRuntimeHostConstructionOptions } from "../../../src/domain/warp/RuntimeHostBoot.ts"; -import type { CorePersistence } from "../../../src/domain/types/WarpPersistence.ts"; import MemoryRuntimeStorageAdapter from "../../../test/helpers/MemoryRuntimeStorageAdapter.ts"; +import InMemoryGraphAdapter from "../../../test/helpers/InMemoryGraphAdapter.ts"; import type RuntimeStorageProviderPort from "../../../src/ports/RuntimeStorageProviderPort.ts"; import type { RuntimeStorageRequest } from "../../../src/ports/RuntimeStorageProviderPort.ts"; import WarpStateCachePort, { @@ -13,11 +13,6 @@ import WarpStateCachePort, { } from "../../../src/ports/WarpStateCachePort.ts"; import { InMemoryTrieStore } from "../../helpers/trieHelpers.ts"; -type MockPersistence = CorePersistence & { - configGet: ReturnType; - configSet: ReturnType; -}; - class TestStateCache extends WarpStateCachePort { getExact(_coordinate: WarpStateCoordinate): Promise { return Promise.resolve(null); @@ -50,38 +45,8 @@ class TestStateCache extends WarpStateCachePort { } } -function makeMockPersistence(): MockPersistence { - return { - commitNode: vi.fn(async () => "c".repeat(40)), - showNode: vi.fn(async () => ""), - readRef: vi.fn(async () => null), - listRefs: vi.fn(async () => []), - updateRef: vi.fn(async () => undefined), - deleteRef: vi.fn(async () => undefined), - compareAndSwapRef: vi.fn(async () => undefined), - logNodes: vi.fn(async () => ""), - logNodesStream: vi.fn(), - countNodes: vi.fn(async () => 0), - configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(undefined), - readBlob: vi.fn(async () => new Uint8Array()), - writeBlob: vi.fn(async () => "a".repeat(40)), - readTree: vi.fn(async () => ({})), - getNodeInfo: vi.fn(async () => ({ - message: "", - parents: [], - sha: "a".repeat(40), - author: "", - date: "", - })), - nodeExists: vi.fn(async () => true), - getCommitTree: vi.fn(async () => "b".repeat(40)), - readTreeOids: vi.fn(async () => ({})), - writeTree: vi.fn(async () => "a".repeat(40)), - commitNodeWithTree: vi.fn(async () => "d".repeat(40)), - ping: vi.fn(async () => ({ ok: true, latencyMs: 0 })), - emptyTree: "4b825dc642cb6eb9a060e54bf8d69288fbee4904", - }; +function makeMockPersistence(): InMemoryGraphAdapter { + return new InMemoryGraphAdapter(); } describe("WarpCore state-session auto-construction", () => { diff --git a/test/unit/domain/WarpGraph.audit.test.ts b/test/unit/domain/WarpGraph.audit.test.ts index 2dbc56bc..fe2b04d7 100644 --- a/test/unit/domain/WarpGraph.audit.test.ts +++ b/test/unit/domain/WarpGraph.audit.test.ts @@ -8,6 +8,9 @@ import { describe, it, expect, vi } from 'vitest'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../helpers/MemoryRuntimeStorageAdapter.ts'; +import defaultCodec, { decode } from '../../../src/infrastructure/codecs/CborCodec.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; describe('WarpCore — audit mode', () => { it('rejects audit: "yes" (non-boolean truthy)', async () => { @@ -155,8 +158,15 @@ describe('WarpCore — audit mode', () => { it('audit commit tree contains receipt.cbor with correct receipt data', async () => { const persistence = new InMemoryGraphAdapter(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ history: persistence }); + const storage = await runtimeStorage.createRuntimeStorageServices({ + timelineName: 'events', + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); const graph = await openRuntimeHostProduct({ persistence, + runtimeStorage, graphName: 'events', writerId: 'alice', audit: true, @@ -169,25 +179,22 @@ describe('WarpCore — audit mode', () => { patch.addNode('user:eve'); await patch.commit(); - const auditSha = await persistence.readRef('refs/warp/events/audit/alice'); + const auditSha = await storage.auditLog.readHead('events', 'alice'); if (!auditSha) { throw new Error('audit ref must exist after audited commit'); } - const commit = (persistence as any)._commits.get(auditSha); - expect(commit).toBeTruthy(); - const tree = await persistence.readTree((commit as any).treeOid); - expect(tree).toHaveProperty('receipt.cbor'); - - // Decode and verify - const { decode } = await import('../../../src/infrastructure/codecs/CborCodec.ts'); - const receiptBlob = tree['receipt.cbor']; - expect(receiptBlob).toBeDefined(); - const receipt = (decode((receiptBlob as Uint8Array)) as Record); - expect(receipt['version']).toBe(1); - expect(receipt['graphName']).toBe('events'); - expect(receipt['writerId']).toBe('alice'); - expect(typeof receipt['timestamp']).toBe('number'); + const entry = await storage.auditLog.readEntry(auditSha); + const receipt = decode(entry.receipt); + expect(receipt).toMatchObject({ + version: 1, + graphName: 'events', + writerId: 'alice', + timestamp: expect.any(Number), + }); + if (!isRecord(receipt) || typeof receipt['timestamp'] !== 'number') { + throw new Error('decoded audit receipt must contain a numeric timestamp'); + } expect(Number.isInteger(receipt['timestamp'])).toBe(true); }); @@ -213,3 +220,7 @@ describe('WarpCore — audit mode', () => { expect(hasAlice).toBe(true); }); }); + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/test/unit/domain/WarpGraph.checkpoint.test.ts b/test/unit/domain/WarpGraph.checkpoint.test.ts index 4fc5fdad..4280fb87 100644 --- a/test/unit/domain/WarpGraph.checkpoint.test.ts +++ b/test/unit/domain/WarpGraph.checkpoint.test.ts @@ -124,10 +124,11 @@ describe('WarpCore', () => { await graph.syncCoverage(); - // Verify updateRef was called with the correct coverage ref - expect(persistence.updateRef).toHaveBeenCalledWith( + // Verify the coverage head was published with compare-and-swap semantics. + expect(persistence.compareAndSwapRef).toHaveBeenCalledWith( 'refs/warp/events/coverage/head', - anchorSha + anchorSha, + writerSha, ); }); @@ -257,10 +258,11 @@ describe('WarpCore', () => { await graph.createCheckpoint(); - // Verify updateRef was called with the correct checkpoint ref - expect(persistence.updateRef).toHaveBeenCalledWith( + // Verify the checkpoint head was published with compare-and-swap semantics. + expect(persistence.compareAndSwapRef).toHaveBeenCalledWith( 'refs/warp/events/checkpoints/head', - checkpointSha + checkpointSha, + writerSha, ); }); diff --git a/test/unit/domain/WarpGraph.content.test.ts b/test/unit/domain/WarpGraph.content.test.ts index c83ebb23..750f2040 100644 --- a/test/unit/domain/WarpGraph.content.test.ts +++ b/test/unit/domain/WarpGraph.content.test.ts @@ -31,7 +31,21 @@ function attachmentEvent( return { lamport, writerId, patchSha, opIndex }; } -describe('WarpCore content attachment (query methods)', () => { +function assetStorageWith(...chunks: Uint8Array[]) { + return { + stage: vi.fn(), + open: vi.fn().mockImplementation(() => (async function* () { + yield* chunks; + })()), + }; +} + +function expectOpenedHandle(assetStorage: ReturnType, token: string) { + expect(assetStorage.open).toHaveBeenCalledOnce(); + expect(assetStorage.open.mock.calls[0]?.[0]?.toString()).toBe(token); +} + +describe('WarpGraph content attachment queries', () => { let mockPersistence; let graph; @@ -56,7 +70,7 @@ describe('WarpCore content attachment (query methods)', () => { }); }); - describe('getContentOid()', () => { + describe('getContentHandle()', () => { it('returns the _content property value for a node', async () => { setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); @@ -64,8 +78,8 @@ describe('WarpCore content attachment (query methods)', () => { state.prop.set(propKey, { eventId: null, value: 'abc123' }); }); - const oid = await graph.getContentOid('doc:1'); - expect(oid).toBe('abc123'); + const handle = await graph.getContentHandle('doc:1'); + expect(handle).toBe('abc123'); }); it('returns null when node has no _content property', async () => { @@ -73,15 +87,15 @@ describe('WarpCore content attachment (query methods)', () => { addNode(state, 'doc:1', 1); }); - const oid = await graph.getContentOid('doc:1'); - expect(oid).toBeNull(); + const handle = await graph.getContentHandle('doc:1'); + expect(handle).toBeNull(); }); it('returns null when node does not exist', async () => { setupGraphState(graph, () => {}); - const oid = await graph.getContentOid('nonexistent'); - expect(oid).toBeNull(); + const handle = await graph.getContentHandle('nonexistent'); + expect(handle).toBeNull(); }); it('returns null when _content is not a string', async () => { @@ -91,8 +105,8 @@ describe('WarpCore content attachment (query methods)', () => { state.prop.set(propKey, { eventId: null, value: 42 }); }); - const oid = await graph.getContentOid('doc:1'); - expect(oid).toBeNull(); + const handle = await graph.getContentHandle('doc:1'); + expect(handle).toBeNull(); }); }); @@ -108,7 +122,7 @@ describe('WarpCore content attachment (query methods)', () => { const meta = await graph.getContentMeta('doc:1'); expect(meta).toEqual({ - oid: 'abc123', + handle: 'abc123', mime: 'text/markdown', size: 42, }); @@ -134,13 +148,13 @@ describe('WarpCore content attachment (query methods)', () => { const meta = await graph.getContentMeta('doc:1'); expect(meta).toEqual({ - oid: 'new456', + handle: 'new456', mime: null, size: null, }); }); - it('returns null metadata fields when only the oid exists', async () => { + it('returns null metadata fields when only the handle exists', async () => { setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); state.prop.set(encodePropKey('doc:1', '_content'), { eventId: null, value: 'abc123' }); @@ -149,7 +163,7 @@ describe('WarpCore content attachment (query methods)', () => { const meta = await graph.getContentMeta('doc:1'); expect(meta).toEqual({ - oid: 'abc123', + handle: 'abc123', mime: null, size: null, }); @@ -165,15 +179,10 @@ describe('WarpCore content attachment (query methods)', () => { }); describe('getContent()', () => { - it('reads and returns the blob buffer', async () => { + it('collects and returns bytes from the configured asset store', async () => { const buf = new TextEncoder().encode('# ADR 001\n\nSome content'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockResolvedValue(buf), - storeStream: vi.fn(), - retrieveStream: vi.fn(), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(buf); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); @@ -183,7 +192,7 @@ describe('WarpCore content attachment (query methods)', () => { const content = await graph.getContent('doc:1'); expect(content).toEqual(buf); - expect(blobStorage.retrieve).toHaveBeenCalledWith('abc123'); + expectOpenedHandle(assetStorage, 'abc123'); }); it('returns null when no content attached', async () => { @@ -204,69 +213,61 @@ describe('WarpCore content attachment (query methods)', () => { }); }); - describe('getContent() with blobStorage', () => { - it('uses blobStorage.retrieve() when blobStorage is provided', async () => { + describe('getContent() asset storage failures', () => { + it('resolves opaque handles through the asset storage port', async () => { const casBuf = new TextEncoder().encode('cas-stored content'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockResolvedValue(casBuf), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(casBuf); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); const propKey = encodePropKey('doc:1', '_content'); - state.prop.set(propKey, { eventId: null, value: 'cas-tree-oid' }); + state.prop.set(propKey, { eventId: null, value: 'asset:document' }); }); const content = await graph.getContent('doc:1'); expect(content).toEqual(casBuf); - expect(blobStorage.retrieve).toHaveBeenCalledWith('cas-tree-oid'); + expectOpenedHandle(assetStorage, 'asset:document'); expect(mockPersistence.readBlob).not.toHaveBeenCalled(); }); - it('uses auto-constructed blobStorage when none explicitly provided', async () => { - // Runtime storage supplies the content port. Inject a mock here to - // verify that reads stay behind that port. + it('uses the runtime-provided asset store without reading Git directly', async () => { const rawBuf = new TextEncoder().encode('raw blob'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockResolvedValue(rawBuf), - storeStream: vi.fn(), - retrieveStream: vi.fn(), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(rawBuf); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); const propKey = encodePropKey('doc:1', '_content'); - state.prop.set(propKey, { eventId: null, value: 'raw-oid' }); + state.prop.set(propKey, { eventId: null, value: 'asset:runtime-provided' }); }); const content = await graph.getContent('doc:1'); expect(content).toEqual(rawBuf); - expect(blobStorage.retrieve).toHaveBeenCalledWith('raw-oid'); + expectOpenedHandle(assetStorage, 'asset:runtime-provided'); + expect(mockPersistence.readBlob).not.toHaveBeenCalled(); }); - it('preserves E_MISSING_OBJECT from blobStorage.retrieve()', async () => { - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockRejectedValue( + it('preserves storage errors from assetStorage.open()', async () => { + const assetStorage = { + stage: vi.fn(), + open: vi.fn().mockImplementation(() => { + throw ( new PersistenceError( - 'Missing Git object: cas-tree-oid', + 'Missing stored asset: asset:missing', PersistenceError.E_MISSING_OBJECT, - { context: { oid: 'cas-tree-oid' } }, - ), - ), + { context: { handle: 'asset:missing' } }, + )); + }), }; - (graph)._blobStorage = blobStorage; + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); const propKey = encodePropKey('doc:1', '_content'); - state.prop.set(propKey, { eventId: null, value: 'cas-tree-oid' }); + state.prop.set(propKey, { eventId: null, value: 'asset:missing' }); }); await expect(graph.getContent('doc:1')) @@ -274,42 +275,40 @@ describe('WarpCore content attachment (query methods)', () => { }); }); - describe('getEdgeContent() with blobStorage', () => { - it('uses blobStorage.retrieve() when blobStorage is provided', async () => { + describe('getEdgeContent() asset storage failures', () => { + it('resolves opaque handles through the asset storage port', async () => { const casBuf = new TextEncoder().encode('cas-edge content'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockResolvedValue(casBuf), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(casBuf); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 1); addNode(state, 'b', 2); addEdge(state, 'a', 'b', 'rel', 3); const propKey = encodeEdgePropKey('a', 'b', 'rel', '_content'); - state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value: 'cas-edge-oid' }); + state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value: 'asset:edge' }); }); const content = await graph.getEdgeContent('a', 'b', 'rel'); expect(content).toEqual(casBuf); - expect(blobStorage.retrieve).toHaveBeenCalledWith('cas-edge-oid'); + expectOpenedHandle(assetStorage, 'asset:edge'); expect(mockPersistence.readBlob).not.toHaveBeenCalled(); }); - it('preserves E_MISSING_OBJECT from blobStorage.retrieve()', async () => { - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockRejectedValue( + it('preserves storage errors from assetStorage.open()', async () => { + const assetStorage = { + stage: vi.fn(), + open: vi.fn().mockImplementation(() => { + throw ( new PersistenceError( - 'Missing Git object: cas-edge-oid', + 'Missing stored asset: asset:missing-edge', PersistenceError.E_MISSING_OBJECT, - { context: { oid: 'cas-edge-oid' } }, - ), - ), + { context: { handle: 'asset:missing-edge' } }, + )); + }), }; - (graph)._blobStorage = blobStorage; + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 1); @@ -318,7 +317,7 @@ describe('WarpCore content attachment (query methods)', () => { const propKey = encodeEdgePropKey('a', 'b', 'rel', '_content'); state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, - value: 'cas-edge-oid', + value: 'asset:missing-edge', }); }); @@ -327,7 +326,7 @@ describe('WarpCore content attachment (query methods)', () => { }); }); - describe('getEdgeContentOid()', () => { + describe('getEdgeContentHandle()', () => { it('returns the _content property value for an edge', async () => { setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 1); @@ -337,8 +336,8 @@ describe('WarpCore content attachment (query methods)', () => { state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value: 'def456' }); }); - const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); - expect(oid).toBe('def456'); + const handle = await graph.getEdgeContentHandle('a', 'b', 'rel'); + expect(handle).toBe('def456'); }); it('returns null when edge has no _content', async () => { @@ -348,15 +347,15 @@ describe('WarpCore content attachment (query methods)', () => { addEdge(state, 'a', 'b', 'rel', 3); }); - const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); - expect(oid).toBeNull(); + const handle = await graph.getEdgeContentHandle('a', 'b', 'rel'); + expect(handle).toBeNull(); }); it('returns null when edge does not exist', async () => { setupGraphState(graph, () => {}); - const oid = await graph.getEdgeContentOid('a', 'b', 'rel'); - expect(oid).toBeNull(); + const handle = await graph.getEdgeContentHandle('a', 'b', 'rel'); + expect(handle).toBeNull(); }); }); @@ -383,7 +382,7 @@ describe('WarpCore content attachment (query methods)', () => { const meta = await graph.getEdgeContentMeta('a', 'b', 'rel'); expect(meta).toEqual({ - oid: 'def456', + handle: 'def456', mime: 'application/octet-stream', size: 6, }); @@ -396,7 +395,7 @@ describe('WarpCore content attachment (query methods)', () => { addEdge(state, 'a', 'b', 'rel', 3); state.prop.set(encodeEdgePropKey('a', 'b', 'rel', '_content'), { eventId: attachmentEvent(0, 'feedbabe', 3), - value: 'new-edge-oid', + value: 'new-edge-handle', }); state.prop.set(encodeEdgePropKey('a', 'b', 'rel', '_content.mime'), { eventId: attachmentEvent(1), @@ -411,7 +410,7 @@ describe('WarpCore content attachment (query methods)', () => { const meta = await graph.getEdgeContentMeta('a', 'b', 'rel'); expect(meta).toEqual({ - oid: 'new-edge-oid', + handle: 'new-edge-handle', mime: null, size: null, }); @@ -429,15 +428,10 @@ describe('WarpCore content attachment (query methods)', () => { }); describe('getEdgeContent()', () => { - it('reads and returns the blob buffer', async () => { + it('collects and returns bytes from the configured asset store', async () => { const buf = new TextEncoder().encode('edge content'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn().mockResolvedValue(buf), - storeStream: vi.fn(), - retrieveStream: vi.fn(), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(buf); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 1); @@ -449,7 +443,7 @@ describe('WarpCore content attachment (query methods)', () => { const content = await graph.getEdgeContent('a', 'b', 'rel'); expect(content).toEqual(buf); - expect(blobStorage.retrieve).toHaveBeenCalledWith('def456'); + expectOpenedHandle(assetStorage, 'def456'); }); it('returns null when no content attached', async () => { @@ -468,21 +462,13 @@ describe('WarpCore content attachment (query methods)', () => { it('returns an async iterable of content chunks', async () => { const chunk1 = new TextEncoder().encode('hello '); const chunk2 = new TextEncoder().encode('world'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn(), - storeStream: vi.fn(), - retrieveStream: vi.fn().mockReturnValue((async function* () { - yield chunk1; - yield chunk2; - })()), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(chunk1, chunk2); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'doc:1', 1); const propKey = encodePropKey('doc:1', '_content'); - state.prop.set(propKey, { eventId: null, value: 'cas-tree-oid' }); + state.prop.set(propKey, { eventId: null, value: 'asset:streamed-document' }); }); const stream = await graph.getContentStream('doc:1'); @@ -496,7 +482,7 @@ describe('WarpCore content attachment (query methods)', () => { expect(chunks).toHaveLength(2); expect(chunks[0]).toBe(chunk1); expect(chunks[1]).toBe(chunk2); - expect(blobStorage.retrieveStream).toHaveBeenCalledWith('cas-tree-oid'); + expectOpenedHandle(assetStorage, 'asset:streamed-document'); }); it('returns null when no content is attached', async () => { @@ -519,15 +505,8 @@ describe('WarpCore content attachment (query methods)', () => { describe('getEdgeContentStream()', () => { it('returns an async iterable of edge content chunks', async () => { const chunk = new TextEncoder().encode('edge stream data'); - const blobStorage = { - store: vi.fn(), - retrieve: vi.fn(), - storeStream: vi.fn(), - retrieveStream: vi.fn().mockReturnValue((async function* () { - yield chunk; - })()), - }; - (graph)._blobStorage = blobStorage; + const assetStorage = assetStorageWith(chunk); + (graph)._assetStorage = assetStorage; setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 1); @@ -536,7 +515,7 @@ describe('WarpCore content attachment (query methods)', () => { const propKey = encodeEdgePropKey('a', 'b', 'rel', '_content'); state.prop.set(propKey, { eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, - value: 'cas-edge-tree-oid', + value: 'asset:streamed-edge', }); }); @@ -550,7 +529,7 @@ describe('WarpCore content attachment (query methods)', () => { expect(chunks).toHaveLength(1); expect(chunks[0]).toBe(chunk); - expect(blobStorage.retrieveStream).toHaveBeenCalledWith('cas-edge-tree-oid'); + expectOpenedHandle(assetStorage, 'asset:streamed-edge'); }); it('returns null when no edge content is attached', async () => { diff --git a/test/unit/domain/WarpGraph.coverageGaps.test.ts b/test/unit/domain/WarpGraph.coverageGaps.test.ts index 38091aed..3db1014f 100644 --- a/test/unit/domain/WarpGraph.coverageGaps.test.ts +++ b/test/unit/domain/WarpGraph.coverageGaps.test.ts @@ -825,7 +825,7 @@ describe('WarpCore coverage gaps', () => { writerId: 'writer-1', lamport: 2, patchOid, - ops: [{ type: 'NodeAdd', node: 'user:alice', dot: 'writer-1:2' }], + ops: [{ type: 'NodeAdd', node: 'user:alice', dot: Dot.create('writer-1', 2) }], parentSha: fromSha, }); diff --git a/test/unit/domain/WarpGraph.encryption.test.ts b/test/unit/domain/WarpGraph.encryption.test.ts index b92646e2..a02162a5 100644 --- a/test/unit/domain/WarpGraph.encryption.test.ts +++ b/test/unit/domain/WarpGraph.encryption.test.ts @@ -1,295 +1,178 @@ -/** - * Integration tests for graph encryption at rest (B164). - * - * Tests the patchBlobStorage flow end-to-end using a mock - * BlobStoragePort that simulates encrypted storage in memory. - */ -import { describe, it, expect, beforeEach } from 'vitest'; -import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; -import BlobStoragePort from '../../../src/ports/BlobStoragePort.ts'; +import { describe, expect, it } from 'vitest'; 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 { openMemoryRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; +import MemoryRuntimeStorageAdapter from '../../helpers/MemoryRuntimeStorageAdapter.ts'; import { createInMemoryRepo } from '../../helpers/warpGraphTestUtils.ts'; -// --------------------------------------------------------------------------- -// Mock BlobStoragePort — stores/retrieves from an in-memory Map -// --------------------------------------------------------------------------- - -class InMemoryBlobStorage extends BlobStoragePort { - _blobs: Map; - _counter: number; - - constructor() { - super(); - this._blobs = new Map(); - this._counter = 0; - } - - async store(content: string | Uint8Array) { - this._counter++; - // Generate a fake OID (40-char hex) - const oid = this._counter.toString(16).padStart(40, '0'); - const buf = typeof content === 'string' - ? new TextEncoder().encode(content) - : new Uint8Array(content); - this._blobs.set(oid, buf); - return oid; - } - - async retrieve(oid: string) { - const buf = this._blobs.get(oid); - if (!buf) { - throw new Error(`InMemoryBlobStorage: OID not found: ${oid}`); - } - return buf; - } - - async storeStream(_stream: any): Promise { - throw new Error('storeStream not implemented'); - } - - async *retrieveStream(_oid: string): AsyncGenerator { - yield* ([] as Uint8Array[]); - throw new Error('retrieveStream not implemented'); - } -} - -function createMixedStoragePatchJournal(repo, patchStorage) { - return new CborPatchJournalAdapter({ - codec: defaultCodec, - blobPort: repo.persistence, - commitPort: repo.persistence, - patchBlobStorage: patchStorage, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyPatchStorageReads: true, - }), - }); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('WarpCore encryption at rest (B164)', () => { - let repo; - let patchStorage; - - beforeEach(() => { - repo = createInMemoryRepo(); - patchStorage = new InMemoryBlobStorage(); - }); - - it('writes encrypted patches via patchBlobStorage and reads them back', async () => { - const graph = await openRuntimeHostProduct({ +describe('git-cas patch encryption composition', () => { + it('records encrypted git-cas storage metadata and round-trips the patch', async () => { + const repo = createInMemoryRepo(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encrypted: true, + }); + const runtime = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'enc-test', + runtimeStorage, + graphName: 'encrypted-events', writerId: 'writer-1', - patchBlobStorage: patchStorage, }); - // Write a patch - const sha = await graph.patch(p => { - p.addNode('user:alice'); - p.setProperty('user:alice', 'name', 'Alice'); + const sha = await runtime.patch((patch) => { + patch.addNode('user:alice'); + patch.setProperty('user:alice', 'role', 'admin'); }); + const message = runtime._commitMessageCodec.decodePatch( + await repo.persistence.showNode(sha), + ); - expect(sha).toBeTruthy(); - // Patch CBOR should be in our mock storage, not raw persistence - expect(patchStorage._blobs.size).toBe(1); - - // Materialize should work (reads back via patchBlobStorage) - const state = await graph.materialize(); - expect(state).toBeTruthy(); - - // Query to verify data integrity - expect(await graph.hasNode('user:alice')).toBe(true); - const props = (await graph.getNodeProps('user:alice') as any); - expect(props.name).toBe('Alice'); + expect(message.storage).toMatchObject({ + strategy: 'git-cas-asset', + encrypted: true, + }); + const plaintextPatch = runtime._codec.encode(await runtime.loadPatchBySha(sha)); + const storedPatch = await runtimeStorage.backing.retrieve(message.patchHandle.toString()); + expect(storedPatch).not.toEqual(plaintextPatch); + await runtime.materialize(); + await expect(runtime.hasNode('user:alice')).resolves.toBe(true); + await expect(runtime.getNodeProps('user:alice')).resolves.toMatchObject({ role: 'admin' }); }); - it('reads encrypted patches after re-opening with patchBlobStorage', async () => { - // Write with encryption - const graph1 = await openRuntimeHostProduct({ + it('rejects encrypted patch history opened with a different key', async () => { + const repo = createInMemoryRepo(); + const writerStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encryptionKey: new Uint8Array(32).fill(0x11), + }); + const writer = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'enc-test', + runtimeStorage: writerStorage, + graphName: 'encrypted-wrong-key', writerId: 'writer-1', - patchBlobStorage: patchStorage, }); - await graph1.patch(p => { - p.addNode('user:bob'); - p.setProperty('user:bob', 'role', 'admin'); + await writer.patch((patch) => { + patch.addNode('private:node'); }); - - // Re-open with same storage (simulating re-open with key) - const graph2 = await openRuntimeHostProduct({ - persistence: repo.persistence, - graphName: 'enc-test', - writerId: 'writer-2', - patchBlobStorage: patchStorage, + const readerStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encryptionKey: new Uint8Array(32).fill(0x22), + backing: writerStorage.backing, }); - await graph2.materialize(); - - expect(await graph2.hasNode('user:bob')).toBe(true); - const props = (await graph2.getNodeProps('user:bob') as any); - expect(props.role).toBe('admin'); - }); - - it('throws EncryptionError when reading encrypted patches without patchBlobStorage', async () => { - // Write with encryption - const graph1 = await openRuntimeHostProduct({ + const reader = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'enc-test', - writerId: 'writer-1', - patchBlobStorage: patchStorage, - }); - await graph1.patch(p => { - p.addNode('user:charlie'); + runtimeStorage: readerStorage, + graphName: 'encrypted-wrong-key', + writerId: 'reader', }); - // Re-open WITHOUT patchBlobStorage — should fail during open() or materialize() - // (the migration boundary check in open() reads the tip patch) - const openPromise = openRuntimeHostProduct({ - persistence: repo.persistence, - graphName: 'enc-test', - writerId: 'writer-2', + await expect(reader.materialize()).rejects.toMatchObject({ + name: 'EncryptionError', + code: 'E_CAS_CONTENT_DECRYPTION_FAILED', }); - - await expect(openPromise).rejects.toThrow(/encrypted patches/); }); - it('handles mixed encrypted and unencrypted patches', async () => { - // Write unencrypted patches first - const graph1 = await openRuntimeHostProduct({ - persistence: repo.persistence, - graphName: 'mixed-test', - writerId: 'writer-1', + it('rejects corrupted encrypted patch bytes', async () => { + const repo = createInMemoryRepo(); + const writerStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encrypted: true, }); - await graph1.patch(p => { - p.addNode('user:plain'); - p.setProperty('user:plain', 'mode', 'clear'); - }); - - // Then write encrypted patches with a different writer - const graph2 = await openRuntimeHostProduct({ + const writer = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'mixed-test', - writerId: 'writer-2', - patchBlobStorage: patchStorage, - patchJournal: createMixedStoragePatchJournal(repo, patchStorage), - }); - await graph2.patch(p => { - p.addNode('user:secret'); - p.setProperty('user:secret', 'mode', 'encrypted'); + runtimeStorage: writerStorage, + graphName: 'encrypted-corrupt', + writerId: 'writer-1', }); - - // Re-open with patchBlobStorage — should read both - const graph3 = await openRuntimeHostProduct({ + const sha = await writer.patch((patch) => { + patch.addNode('private:node'); + }); + const message = writer._commitMessageCodec.decodePatch( + await repo.persistence.showNode(sha), + ); + const stored = await writerStorage.backing.retrieve(message.patchHandle.toString()); + const corrupted = stored.slice(); + const lastIndex = corrupted.length - 1; + corrupted[lastIndex] = (corrupted[lastIndex] ?? 0) ^ 0xff; + writerStorage.backing.replace(message.patchHandle, corrupted); + const readerStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encrypted: true, + backing: writerStorage.backing, + }); + const reader = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'mixed-test', + runtimeStorage: readerStorage, + graphName: 'encrypted-corrupt', writerId: 'reader', - patchBlobStorage: patchStorage, - patchJournal: createMixedStoragePatchJournal(repo, patchStorage), }); - await graph3.materialize(); - expect(await graph3.hasNode('user:plain')).toBe(true); - expect(await graph3.hasNode('user:secret')).toBe(true); - const plainProps = (await graph3.getNodeProps('user:plain') as any); - expect(plainProps.mode).toBe('clear'); - const secretProps = (await graph3.getNodeProps('user:secret') as any); - expect(secretProps.mode).toBe('encrypted'); + await expect(reader.materialize()).rejects.toMatchObject({ + name: 'EncryptionError', + code: 'E_CAS_CONTENT_DECRYPTION_FAILED', + }); }); - it('no behavior change when patchBlobStorage is not provided', async () => { - const graph = await openRuntimeHostProduct({ + it('reopens encrypted patch history through the same repository storage provider', async () => { + const repo = createInMemoryRepo(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encrypted: true, + }); + const writer = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'plain-test', + runtimeStorage, + graphName: 'encrypted-reopen', writerId: 'writer-1', }); - - await graph.patch(p => { - p.addNode('user:normal'); - p.setProperty('user:normal', 'status', 'active'); + await writer.patch((patch) => { + patch.addNode('user:bob'); + patch.setProperty('user:bob', 'status', 'active'); }); - // patchBlobStorage should be empty — patches went to persistence directly - expect(patchStorage._blobs.size).toBe(0); - - const state = await graph.materialize(); - expect(state).toBeTruthy(); - - expect(await graph.hasNode('user:normal')).toBe(true); - const props = (await graph.getNodeProps('user:normal') as any); - expect(props.status).toBe('active'); - }); - - it('multiple encrypted patches accumulate correctly', async () => { - const graph = await openRuntimeHostProduct({ + const reader = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'multi-test', - writerId: 'writer-1', - patchBlobStorage: patchStorage, - }); - - await graph.patch(p => { - p.addNode('a'); - p.setProperty('a', 'v', 1); - }); - await graph.patch(p => { - p.addNode('b'); - p.addEdge('a', 'b', 'link'); - }); - await graph.patch(p => { - p.setProperty('a', 'v', 2); + runtimeStorage, + graphName: 'encrypted-reopen', + writerId: 'reader', }); + await reader.materialize(); - // 3 patches stored - expect(patchStorage._blobs.size).toBe(3); - - const state = await graph.materialize(); - expect(state).toBeTruthy(); - - const nodes = await graph.getNodes(); - expect(nodes.sort()).toEqual(['a', 'b']); - const aProps = (await graph.getNodeProps('a') as any); - expect(aProps.v).toBe(2); // LWW: latest wins + await expect(reader.hasNode('user:bob')).resolves.toBe(true); + await expect(reader.getNodeProps('user:bob')).resolves.toMatchObject({ status: 'active' }); }); - it('provenance methods work with encrypted patches', async () => { - const graph = await openRuntimeHostProduct({ + it('keeps provenance reads on the semantic patch journal', async () => { + const repo = createInMemoryRepo(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ + history: repo.persistence, + encrypted: true, + }); + const runtime = await openMemoryRuntimeHostProduct({ persistence: repo.persistence, - graphName: 'prov-test', + runtimeStorage, + graphName: 'encrypted-provenance', writerId: 'writer-1', - patchBlobStorage: patchStorage, }); - - await graph.patch(p => { - p.addNode('x'); - p.setProperty('x', 'k', 'v1'); + await runtime.patch((patch) => { + patch.addNode('doc:1'); }); - await graph.patch(p => { - p.setProperty('x', 'k', 'v2'); + await runtime.patch((patch) => { + patch.setProperty('doc:1', 'version', 2); }); + await runtime.materialize(); - await graph.materialize(); - - // patchesFor should work - const patches = await graph.patchesFor('x'); - expect(patches.length).toBeGreaterThanOrEqual(2); - - // loadPatchBySha should work - const firstPatch = patches[0] ?? ''; - const loaded = await graph.loadPatchBySha(firstPatch); - expect(loaded).toBeTruthy(); - expect(loaded.ops).toBeDefined(); + const shas = await runtime.patchesFor('doc:1'); + expect(shas).toHaveLength(2); + await expect(runtime.loadPatchBySha(shas[0] ?? '')).resolves.toMatchObject({ + writer: 'writer-1', + }); }); - it('EncryptionError has correct code', () => { - const err = new EncryptionError('test'); - expect(err.code).toBe('E_ENCRYPTED_PATCH'); - expect(err.name).toBe('EncryptionError'); - expect(err).toBeInstanceOf(Error); + it('retains the public encryption error contract', () => { + const error = new EncryptionError('asset decryption failed'); + expect(error).toMatchObject({ + name: 'EncryptionError', + code: 'E_ENCRYPTED_PATCH', + }); }); }); diff --git a/test/unit/domain/WarpGraph.strands.compare.test.ts b/test/unit/domain/WarpGraph.strands.compare.test.ts index 56d98bde..23320680 100644 --- a/test/unit/domain/WarpGraph.strands.compare.test.ts +++ b/test/unit/domain/WarpGraph.strands.compare.test.ts @@ -519,7 +519,7 @@ describe('WarpCore strand foundation', () => { const strandReader = createStateReader(strandState); const strandContentMeta = strandReader.getNodeContentMeta('doc:1'); expect(strandContentMeta).toEqual({ - oid: expect.any(String), + handle: expect.any(String), mime: 'text/plain', size: 14, }); @@ -560,13 +560,13 @@ describe('WarpCore strand foundation', () => { value: null, }); const attachOp = - /** @type {import('../../../src/domain/types/CoordinateComparison.ts').VisibleStateTransferOperation & { op: 'attach_node_content', nodeId: string, content: Uint8Array, contentOid: string, mime?: string|null, size?: number|null }} */ transferPlan.ops.find( + /** @type {import('../../../src/domain/types/CoordinateComparison.ts').VisibleStateTransferOperation & { op: 'attach_node_content', nodeId: string, content: Uint8Array, contentHandle: string, mime?: string|null, size?: number|null }} */ transferPlan.ops.find( (op) => op.op === 'attach_node_content' && op.nodeId === 'doc:1' ); expect(attachOp).toMatchObject({ op: 'attach_node_content', nodeId: 'doc:1', - contentOid: expect.any(String), + contentHandle: expect.any(String), mime: 'text/plain', size: 14, }); @@ -601,7 +601,7 @@ describe('WarpCore strand foundation', () => { { op: 'attach_node_content', nodeId: 'doc:1', - contentOid: attachOp.contentOid, + contentHandle: attachOp.contentHandle, mime: 'text/plain', size: 14, }, diff --git a/test/unit/domain/WarpGraph.strands.test.ts b/test/unit/domain/WarpGraph.strands.test.ts index 7dc03e3a..449e830b 100644 --- a/test/unit/domain/WarpGraph.strands.test.ts +++ b/test/unit/domain/WarpGraph.strands.test.ts @@ -61,6 +61,11 @@ function createMockPersistence() { deleteRef: vi.fn(async (ref) => { refs.delete(ref); }), + compareAndDeleteRef: vi.fn(async (ref, expectedOid) => { + if ((refs.get(ref) || null) !== expectedOid) return false; + refs.delete(ref); + return true; + }), configGet: vi.fn(async () => null), configSet: vi.fn(async () => {}), showNode: vi.fn(async (sha) => { @@ -778,14 +783,14 @@ describe('WarpCore strand foundation', () => { outgoing: [{ nodeId: 'n2', label: 'links', direction: 'outgoing' }], incoming: [], content: { - oid: expect.any(String), + handle: expect.any(String), mime: 'text/plain', size: 5, }, }); expect(reader.getEdgeProps('n1', 'n2', 'links')).toEqual({}); expect(reader.getNodeContentMeta('n1')).toEqual({ - oid: expect.any(String), + handle: expect.any(String), mime: 'text/plain', size: 5, }); diff --git a/test/unit/domain/WarpGraph.test.ts b/test/unit/domain/WarpGraph.test.ts index a6adfb7c..9775b119 100644 --- a/test/unit/domain/WarpGraph.test.ts +++ b/test/unit/domain/WarpGraph.test.ts @@ -581,7 +581,7 @@ describe('WarpCore', () => { }); persistence.listRefs.mockResolvedValue(['refs/warp/events/writers/writer-1']); - persistence.readRef.mockResolvedValue(commitSha); + persistence.readRef.mockImplementation((ref: string) => Promise.resolve(ref.includes('/checkpoints/') ? null : commitSha)); persistence.getNodeInfo.mockResolvedValue(mockPatch.nodeInfo); persistence.readBlob.mockResolvedValue(mockPatch.patchBuffer); @@ -688,7 +688,7 @@ describe('WarpCore', () => { }); persistence.listRefs.mockResolvedValue(['refs/warp/events/writers/writer-1']); - persistence.readRef.mockResolvedValue(commitSha2); // tip is the second commit + persistence.readRef.mockImplementation((ref: string) => Promise.resolve(ref.includes('/checkpoints/') ? null : commitSha2)); // getNodeInfo is called for each commit in the chain (newest first) persistence.getNodeInfo diff --git a/test/unit/domain/WarpGraph.versionVector.test.ts b/test/unit/domain/WarpGraph.versionVector.test.ts index 0cbe0d80..56e79a80 100644 --- a/test/unit/domain/WarpGraph.versionVector.test.ts +++ b/test/unit/domain/WarpGraph.versionVector.test.ts @@ -11,6 +11,7 @@ import { Dot } from '../../../src/domain/crdt/Dot.ts'; import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import WarpStream from '../../../src/domain/stream/WarpStream.ts'; import type { CommitLogChunk } from '../../../src/ports/CommitPort.ts'; +import InMemoryGraphAdapter from '../../helpers/InMemoryGraphAdapter.ts'; const crypto = new NodeCryptoAdapter(); @@ -310,10 +311,7 @@ describe('WarpCore', () => { }); it('first builder commits OK, second builder fails with race detection', async () => { - const persistence = createMockPersistence(); - - persistence.readRef.mockResolvedValueOnce(null); // During open() checkpoint check - persistence.listRefs.mockResolvedValue([]); + const persistence = new InMemoryGraphAdapter(); const graph = await openRuntimeHostProduct({ persistence, @@ -322,32 +320,18 @@ describe('WarpCore', () => { schema: 2, } as Parameters[0]); - // Both builders read ref at creation time (both see null) - persistence.readRef.mockResolvedValueOnce(null); // builder1 creation + // Both builders capture the same empty writer frontier. const builder1 = await graph.createPatch(); builder1.addNode('user:alice'); - persistence.readRef.mockResolvedValueOnce(null); // builder2 creation const builder2 = await graph.createPatch(); builder2.addNode('user:bob'); - // Setup mocks for builder1's commit - persistence.readRef - .mockResolvedValueOnce(null) // builder1 commit check - still null - .mockResolvedValueOnce(null); // builder1 final CAS compare - still null - persistence.writeBlob.mockResolvedValue('a'.repeat(40)); - persistence.writeTree.mockResolvedValue('b'.repeat(40)); - const commit1Sha = 'c'.repeat(40); - persistence.commitNodeWithTree.mockResolvedValue(commit1Sha); - persistence.updateRef.mockResolvedValue(undefined); - - // builder1 commits successfully + // The first publication advances the real in-memory ref atomically. const sha1 = await builder1.commit(); - expect(sha1).toBe(commit1Sha); - - // Now builder2 tries to commit, but ref has advanced - persistence.readRef.mockResolvedValueOnce(commit1Sha); // builder2 commit check - now points to commit1 + expect(sha1).toMatch(/^[0-9a-f]{40}$/u); + // The second builder still carries the old expected head and must fail. await expect(builder2.commit()).rejects.toThrow( /Commit failed: writer ref was updated by another process.*Re-materialize and retry/ ); diff --git a/test/unit/domain/WarpGraph.writerInvalidation.test.ts b/test/unit/domain/WarpGraph.writerInvalidation.test.ts index 3d309b09..75162e20 100644 --- a/test/unit/domain/WarpGraph.writerInvalidation.test.ts +++ b/test/unit/domain/WarpGraph.writerInvalidation.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import { encodePatchMessage } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { createMockPersistence } from '../../helpers/warpGraphTestUtils.ts'; +import { buildWriterRef } from '../../../src/domain/utils/RefLayout.ts'; +import InMemoryGraphAdapter from '../../helpers/InMemoryGraphAdapter.ts'; /** * AP/INVAL/3 — Writer.commitPatch() and PatchSession.commit() trigger @@ -13,62 +13,12 @@ import { createMockPersistence } from '../../helpers/warpGraphTestUtils.ts'; * reflect the new state immediately. */ -const FAKE_BLOB_OID = 'a'.repeat(40); -const FAKE_TREE_OID = 'b'.repeat(40); -const FAKE_COMMIT_SHA = 'c'.repeat(40); -const FAKE_COMMIT_SHA_2 = 'd'.repeat(40); - -/** - * Configure the mock persistence so that a Writer-based first commit succeeds. - * - * Writer flow hits readRef 3 times for a first commit: - * 1. Writer.beginPatch() reads ref to get expectedOldHead - * 2. PatchCommitter reads ref for its CAS check - * 3. PatchCommitter verifies the writer ref advanced after compare-and-swap - */ -function mockWriterFirstCommit(/** @type {any} */ persistence) { - persistence.readRef - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValue(FAKE_COMMIT_SHA); - persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); - persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); - persistence.commitNodeWithTree.mockResolvedValue(FAKE_COMMIT_SHA); -} - -/** - * Configure the mock persistence so that a Writer-based second commit succeeds. - * - * After the first commit, the writer ref points to FAKE_COMMIT_SHA. - * readRef returns FAKE_COMMIT_SHA until compare-and-swap succeeds, then - * FAKE_COMMIT_SHA_2 for the post-update visibility check. showNode returns a - * valid patch message so lamport can be extracted. - */ -function mockWriterSecondCommit(/** @type {any} */ persistence) { - const patchMessage = encodePatchMessage({ - graph: 'test', - writer: 'writer-1', - lamport: 1, - patchOid: FAKE_BLOB_OID, - schema: 2, - }); - - persistence.readRef - .mockResolvedValueOnce(FAKE_COMMIT_SHA) - .mockResolvedValueOnce(FAKE_COMMIT_SHA) - .mockResolvedValue(FAKE_COMMIT_SHA_2); - persistence.showNode.mockResolvedValue(patchMessage); - persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); - persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); - persistence.commitNodeWithTree.mockResolvedValue(FAKE_COMMIT_SHA_2); -} - describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { - let persistence; - let graph; + let persistence: InMemoryGraphAdapter; + let graph: Awaited>; beforeEach(async () => { - persistence = createMockPersistence(); + persistence = new InMemoryGraphAdapter(); graph = await openRuntimeHostProduct({ persistence, graphName: 'test', @@ -81,9 +31,10 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { it('writer.commitPatch() followed by hasNode() returns true without explicit re-materialize', async () => { await graph.materialize(); - mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); + await writer.commitPatch((patch) => { + patch.addNode('test:node'); + }); // Query reflects the commit immediately — no explicit materialize needed expect(await graph.hasNode('test:node')).toBe(true); @@ -94,9 +45,10 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { await graph.materialize(); expect((graph)._stateDirty).toBe(false); - mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); + await writer.commitPatch((patch) => { + patch.addNode('test:node'); + }); // Eager re-materialize applied the patch, so state is fresh expect((graph)._stateDirty).toBe(false); @@ -107,7 +59,6 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { it('beginPatch() + patch.commit() followed by hasNode() returns true', async () => { await graph.materialize(); - mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); const patch = await writer.beginPatch(); patch.addNode('test:node'); @@ -120,7 +71,6 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { it('beginPatch() + setProperty reflected in getNodeProps() after commit', async () => { await graph.materialize(); - mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); const patch = await writer.beginPatch(); patch.addNode('test:node'); @@ -129,7 +79,7 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { const props = await graph.getNodeProps('test:node'); expect(props).not.toBeNull(); - expect(props.name).toBe('Alice'); + expect(props?.['name']).toBe('Alice'); }); // ── Multiple sequential writer commits ─────────────────────────── @@ -137,15 +87,17 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { it('multiple sequential writer commits keep state fresh', async () => { await graph.materialize(); - mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:a')); + await writer.commitPatch((patch) => { + patch.addNode('test:a'); + }); expect((graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:a')).toBe(true); - mockWriterSecondCommit(persistence); const writer2 = await graph.writer('writer-1'); - await writer2.commitPatch((/** @type {any} */ p) => p.addNode('test:b')); + await writer2.commitPatch((patch) => { + patch.addNode('test:b'); + }); expect((graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:b')).toBe(true); @@ -157,9 +109,10 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { it('writer commit without prior materialize sets _stateDirty to true', async () => { // No materialize() call — _cachedState is null - mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); + await writer.commitPatch((patch) => { + patch.addNode('test:node'); + }); // No _cachedState, so can't eagerly apply — dirty expect((graph)._stateDirty).toBe(true); @@ -170,10 +123,11 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { it('writer(id) path also triggers eager invalidation', async () => { await graph.materialize(); - mockWriterFirstCommit(persistence); const writer = await graph.writer('fresh-writer'); - await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); + await writer.commitPatch((patch) => { + patch.addNode('test:node'); + }); expect(await graph.hasNode('test:node')).toBe(true); expect((graph)._stateDirty).toBe(false); @@ -185,11 +139,12 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { await graph.materialize(); const stateBeforeAttempt = (graph)._cachedState; - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob.mockRejectedValue(new Error('disk full')); + vi.spyOn(persistence, 'writeBlob').mockRejectedValueOnce(new Error('disk full')); const writer = await graph.writer('writer-1'); - await expect(writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node'))).rejects.toThrow('disk full'); + await expect(writer.commitPatch((patch) => { + patch.addNode('test:node'); + })).rejects.toThrow('disk full'); // State should be unchanged expect((graph)._stateDirty).toBe(false); @@ -200,14 +155,12 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { await graph.materialize(); const stateBeforeAttempt = (graph)._cachedState; - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); - persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); - persistence.commitNodeWithTree.mockResolvedValue(FAKE_COMMIT_SHA); - persistence.compareAndSwapRef.mockRejectedValue(new Error('ref lock failed')); + vi.spyOn(persistence, 'compareAndSwapRef').mockRejectedValueOnce(new Error('ref lock failed')); const writer = await graph.writer('writer-1'); - await expect(writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node'))).rejects.toThrow('ref lock failed'); + await expect(writer.commitPatch((patch) => { + patch.addNode('test:node'); + })).rejects.toThrow('ref lock failed'); expect((graph)._stateDirty).toBe(false); expect((graph)._cachedState).toBe(stateBeforeAttempt); @@ -217,13 +170,15 @@ describe('WarpCore Writer invalidation (AP/INVAL/3)', () => { await graph.materialize(); const stateBeforeAttempt = (graph)._cachedState; - // beginPatch() sees null, but by the time PatchSession.commit() checks, ref has advanced - persistence.readRef - .mockResolvedValueOnce(null) // Writer.beginPatch() — get expectedOldHead - .mockResolvedValueOnce(FAKE_COMMIT_SHA); // PatchSession.commit() — CAS pre-check - const writer = await graph.writer('writer-1'); - await expect(writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node'))).rejects.toThrow(); + const patch = await writer.beginPatch(); + patch.addNode('test:node'); + const concurrentSha = await persistence.commitNode({ + message: 'concurrent writer publication', + }); + await persistence.updateRef(buildWriterRef('test', 'writer-1'), concurrentSha); + + await expect(patch.commit()).rejects.toThrow(); expect((graph)._stateDirty).toBe(false); expect((graph)._cachedState).toBe(stateBeforeAttempt); diff --git a/test/unit/domain/WarpWorldline.capabilities.test.ts b/test/unit/domain/WarpWorldline.capabilities.test.ts index d3dc47e9..a649510c 100644 --- a/test/unit/domain/WarpWorldline.capabilities.test.ts +++ b/test/unit/domain/WarpWorldline.capabilities.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import WarpWorldline from '../../../src/domain/WarpWorldline.ts'; import MemoryCapabilityReport from '../../../src/domain/memory/MemoryCapabilityReport.ts'; import ProjectionHandle from '../../../src/domain/services/ProjectionHandle.ts'; +import { testRetentionWitness } from '../../helpers/storageRetention.ts'; function createHandle(): WarpWorldline { return new WarpWorldline({ @@ -16,7 +17,12 @@ function createHandle(): WarpWorldline { }, }, }), - admitIntent: async (descriptor) => ({ admitted: true, sha: 'blob:intent:123', intentId: descriptor.intentId }), + admitIntent: async (descriptor) => ({ + admitted: true, + sha: 'blob:intent:123', + intentId: descriptor.intentId, + retention: testRetentionWitness('intent-123'), + }), }); } diff --git a/test/unit/domain/WarpWorldline.test.ts b/test/unit/domain/WarpWorldline.test.ts index 2d1d3634..e3e3f1fa 100644 --- a/test/unit/domain/WarpWorldline.test.ts +++ b/test/unit/domain/WarpWorldline.test.ts @@ -5,6 +5,7 @@ import WarpWorldline, { type WarpWorldlinePatchBuild } from '../../../src/domain import { openMemoryWarpWorldline as openWarpWorldline } from '../../helpers/MemoryRuntimeHost.ts'; import ProjectionHandle from '../../../src/domain/services/ProjectionHandle.ts'; import Observer, { type ObserverBacking } from '../../../src/domain/services/query/Observer.ts'; +import { testRetentionWitness } from '../../helpers/storageRetention.ts'; import type { Aperture } from '../../../src/domain/types/Aperture.ts'; import type { WorldlineSource } from '../../../src/domain/capabilities/QueryCapability.ts'; @@ -101,6 +102,7 @@ function createHandle( admitted: true, sha: 'blob:intent:123', intentId: descriptor.intentId, + retention: testRetentionWitness('intent-123'), }), }); } diff --git a/test/unit/domain/graph/ContentAttachmentPayload.test.ts b/test/unit/domain/graph/ContentAttachmentPayload.test.ts index 71266845..8a82835d 100644 --- a/test/unit/domain/graph/ContentAttachmentPayload.test.ts +++ b/test/unit/domain/graph/ContentAttachmentPayload.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from 'vitest'; import ContentAttachmentMime from '../../../../src/domain/graph/ContentAttachmentMime.ts'; -import ContentAttachmentOid from '../../../../src/domain/graph/ContentAttachmentOid.ts'; +import ContentAttachmentHandle from '../../../../src/domain/graph/ContentAttachmentHandle.ts'; import ContentAttachmentPayload from '../../../../src/domain/graph/ContentAttachmentPayload.ts'; import ContentAttachmentSize from '../../../../src/domain/graph/ContentAttachmentSize.ts'; import WarpError from '../../../../src/domain/errors/WarpError.ts'; describe('ContentAttachmentPayload graph substrate nouns', () => { - it('validates content OIDs as runtime-backed attachment references', () => { - const oid = new ContentAttachmentOid('abc123'); + it('validates opaque handles as runtime-backed attachment references', () => { + const handle = new ContentAttachmentHandle('abc123'); - expect(oid.toString()).toBe('abc123'); - expect(oid.equals(new ContentAttachmentOid('abc123'))).toBe(true); - expect(oid.equals(new ContentAttachmentOid('def456'))).toBe(false); - expect(oid.equals(null)).toBe(false); - expect(Object.isFrozen(oid)).toBe(true); - expect(() => new ContentAttachmentOid('')).toThrow(WarpError); - expect(() => new ContentAttachmentOid('bad\0oid')).toThrow(WarpError); + expect(handle.toString()).toBe('abc123'); + expect(handle.equals(new ContentAttachmentHandle('abc123'))).toBe(true); + expect(handle.equals(new ContentAttachmentHandle('def456'))).toBe(false); + expect(handle.equals(null)).toBe(false); + expect(Object.isFrozen(handle)).toBe(true); + expect(() => new ContentAttachmentHandle('')).toThrow(WarpError); + expect(() => new ContentAttachmentHandle('bad\0handle')).toThrow(WarpError); }); it('validates optional MIME hints as runtime-backed metadata', () => { @@ -45,12 +45,12 @@ describe('ContentAttachmentPayload graph substrate nouns', () => { it('represents typed content attachment payload metadata', () => { const payload = new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('abc123'), + handle: new ContentAttachmentHandle('abc123'), mime: new ContentAttachmentMime('text/markdown'), size: new ContentAttachmentSize(42), }); - expect(payload.oid.toString()).toBe('abc123'); + expect(payload.handle.toString()).toBe('abc123'); expect(payload.mime?.toString()).toBe('text/markdown'); expect(payload.size?.toNumber()).toBe(42); expect(payload.hasMime()).toBe(true); @@ -58,14 +58,14 @@ describe('ContentAttachmentPayload graph substrate nouns', () => { expect(Object.isFrozen(payload)).toBe(true); }); - it('allows absent MIME and size metadata without losing the OID', () => { + it('allows absent MIME and size metadata without losing the handle', () => { const payload = new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('abc123'), + handle: new ContentAttachmentHandle('abc123'), mime: null, size: null, }); - expect(payload.oid.toString()).toBe('abc123'); + expect(payload.handle.toString()).toBe('abc123'); expect(payload.mime).toBeNull(); expect(payload.size).toBeNull(); expect(payload.hasMime()).toBe(false); @@ -80,14 +80,14 @@ describe('ContentAttachmentPayload graph substrate nouns', () => { expect(() => { new ContentAttachmentPayload({ // @ts-expect-error exercising runtime validation - oid: 'abc123', + handle: 'abc123', mime: null, size: null, }); }).toThrow(WarpError); expect(() => { new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('abc123'), + handle: new ContentAttachmentHandle('abc123'), // @ts-expect-error exercising runtime validation mime: 'text/plain', size: null, @@ -95,7 +95,7 @@ describe('ContentAttachmentPayload graph substrate nouns', () => { }).toThrow(WarpError); expect(() => { new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('abc123'), + handle: new ContentAttachmentHandle('abc123'), mime: null, // @ts-expect-error exercising runtime validation size: 42, diff --git a/test/unit/domain/graph/ContentAttachmentRecord.test.ts b/test/unit/domain/graph/ContentAttachmentRecord.test.ts index ebacf9ba..57d25c1d 100644 --- a/test/unit/domain/graph/ContentAttachmentRecord.test.ts +++ b/test/unit/domain/graph/ContentAttachmentRecord.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import ContentAttachmentOid from '../../../../src/domain/graph/ContentAttachmentOid.ts'; +import ContentAttachmentHandle from '../../../../src/domain/graph/ContentAttachmentHandle.ts'; import ContentAttachmentPayload from '../../../../src/domain/graph/ContentAttachmentPayload.ts'; import ContentAttachmentRecord from '../../../../src/domain/graph/ContentAttachmentRecord.ts'; import EdgeRecord from '../../../../src/domain/graph/EdgeRecord.ts'; @@ -12,7 +12,7 @@ describe('ContentAttachmentRecord graph substrate noun', () => { const nodeOwner = NodeRecord.fromLegacyNodeId('doc:1'); const edgeOwner = EdgeRecord.fromLegacyEdge({ from: 'doc:1', to: 'doc:2', label: 'links' }); const payload = new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('abc123'), + handle: new ContentAttachmentHandle('abc123'), mime: null, size: null, }); @@ -31,7 +31,7 @@ describe('ContentAttachmentRecord graph substrate noun', () => { it('rejects fake content attachment record envelopes', () => { const owner = NodeRecord.fromLegacyNodeId('doc:1'); const payload = new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('abc123'), + handle: new ContentAttachmentHandle('abc123'), mime: null, size: null, }); diff --git a/test/unit/domain/graph/ContentAttachmentWriteIntent.test.ts b/test/unit/domain/graph/ContentAttachmentWriteIntent.test.ts index 509609b7..7dfae66a 100644 --- a/test/unit/domain/graph/ContentAttachmentWriteIntent.test.ts +++ b/test/unit/domain/graph/ContentAttachmentWriteIntent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import ContentAttachmentMime from '../../../../src/domain/graph/ContentAttachmentMime.ts'; -import ContentAttachmentOid from '../../../../src/domain/graph/ContentAttachmentOid.ts'; +import ContentAttachmentHandle from '../../../../src/domain/graph/ContentAttachmentHandle.ts'; import ContentAttachmentPayload from '../../../../src/domain/graph/ContentAttachmentPayload.ts'; import ContentAttachmentSize from '../../../../src/domain/graph/ContentAttachmentSize.ts'; import ContentAttachmentWriteIntent from '../../../../src/domain/graph/ContentAttachmentWriteIntent.ts'; @@ -11,7 +11,7 @@ describe('ContentAttachmentWriteIntent graph substrate noun', () => { const intent = ContentAttachmentWriteIntent.forNode('doc:1', payload()); expect(intent.nodeId()).toBe('doc:1'); - expect(intent.oid()).toBe('content-oid'); + expect(intent.handle().toString()).toBe('content-handle'); expect(intent.mime()).toBe('text/plain'); expect(intent.size()).toBe(12); }); @@ -28,7 +28,7 @@ describe('ContentAttachmentWriteIntent graph substrate noun', () => { to: 'doc:2', label: 'links', }); - expect(intent.oid()).toBe('content-oid'); + expect(intent.handle().toString()).toBe('content-handle'); }); it('rejects reading a node target from an edge write intent', () => { @@ -44,7 +44,7 @@ describe('ContentAttachmentWriteIntent graph substrate noun', () => { function payload(): ContentAttachmentPayload { return new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('content-oid'), + handle: new ContentAttachmentHandle('content-handle'), mime: new ContentAttachmentMime('text/plain'), size: new ContentAttachmentSize(12), }); diff --git a/test/unit/domain/graph/GraphOpAlgebra.test.ts b/test/unit/domain/graph/GraphOpAlgebra.test.ts index 83080a2c..07bb5087 100644 --- a/test/unit/domain/graph/GraphOpAlgebra.test.ts +++ b/test/unit/domain/graph/GraphOpAlgebra.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import AttachmentKey from '../../../../src/domain/graph/AttachmentKey.ts'; import AttachmentRecord from '../../../../src/domain/graph/AttachmentRecord.ts'; import AttachmentSchemaVersion from '../../../../src/domain/graph/AttachmentSchemaVersion.ts'; -import ContentAttachmentOid from '../../../../src/domain/graph/ContentAttachmentOid.ts'; +import ContentAttachmentHandle from '../../../../src/domain/graph/ContentAttachmentHandle.ts'; import ContentAttachmentPayload from '../../../../src/domain/graph/ContentAttachmentPayload.ts'; import ContentAttachmentRecord from '../../../../src/domain/graph/ContentAttachmentRecord.ts'; import EdgeRecord from '../../../../src/domain/graph/EdgeRecord.ts'; @@ -34,7 +34,7 @@ describe('GraphOpAlgebra', () => { const contentRecord = new ContentAttachmentRecord({ owner: nodeRecord, payload: new ContentAttachmentPayload({ - oid: new ContentAttachmentOid('oid-a'), + handle: new ContentAttachmentHandle('asset-a'), mime: null, size: null, }), diff --git a/test/unit/domain/migrations/DryRunGraphModelMigrationPlanner.test.ts b/test/unit/domain/migrations/DryRunGraphModelMigrationPlanner.test.ts index abf1a20a..42879660 100644 --- a/test/unit/domain/migrations/DryRunGraphModelMigrationPlanner.test.ts +++ b/test/unit/domain/migrations/DryRunGraphModelMigrationPlanner.test.ts @@ -165,7 +165,7 @@ function sourceBasis(): GraphModelMigrationBasis { function contentSource( legacyContentKey: string, - contentOid: string, + contentHandle: string, ): GraphModelMigrationContentSource { - return new GraphModelMigrationContentSource({ legacyContentKey, contentOid }); + return new GraphModelMigrationContentSource({ legacyContentKey, contentHandle }); } diff --git a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts index e59b6088..ebf569c2 100644 --- a/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationConstructorGuards.test.ts @@ -162,8 +162,8 @@ describe('graph model migration constructor guards', () => { })).toThrow(/legacyContentKey/); expect(() => new GraphModelMigrationContentSource({ legacyContentKey: 'node:a\0_content', - contentOid: '', - })).toThrow(/contentOid/); + contentHandle: '', + })).toThrow(/contentHandle/); expect(() => new GraphModelMigrationStateSnapshotReference({ snapshotId: '', })).toThrow(/snapshotId/); diff --git a/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts b/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts index 18ec1d8a..5a5dc508 100644 --- a/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationOperationLowering.test.ts @@ -130,7 +130,7 @@ function sourceInventory(options: { contentSources: [ new GraphModelMigrationContentSource({ legacyContentKey: 'node:a\0_content', - contentOid: 'oid:a', + contentHandle: 'asset:a', }), ], warnings: [], diff --git a/test/unit/domain/migrations/GraphModelMigrationSourceInventory.test.ts b/test/unit/domain/migrations/GraphModelMigrationSourceInventory.test.ts index bdb114b0..b7cf3308 100644 --- a/test/unit/domain/migrations/GraphModelMigrationSourceInventory.test.ts +++ b/test/unit/domain/migrations/GraphModelMigrationSourceInventory.test.ts @@ -90,7 +90,7 @@ describe('GraphModelMigrationSourceInventory', () => { it('records state and content source facts immutably', () => { const contentSource = new GraphModelMigrationContentSource({ legacyContentKey: 'node:a\0_content', - contentOid: 'oid:content:a', + contentHandle: 'asset:content:a', }); const stateSnapshot = new GraphModelMigrationStateSnapshotReference({ snapshotId: 'snapshot:one', diff --git a/test/unit/domain/runtimeProductExecutableSurface.test.ts b/test/unit/domain/runtimeProductExecutableSurface.test.ts index 0118d54a..b8862fe7 100644 --- a/test/unit/domain/runtimeProductExecutableSurface.test.ts +++ b/test/unit/domain/runtimeProductExecutableSurface.test.ts @@ -144,11 +144,11 @@ describe('runtime product executable surface', () => { admitted: true, intentId: 'assign-alice', }); - await expect(runtime.queueIntent('draft:admin', descriptor)).resolves.toMatchObject({ + await expect(runtime.queueIntent('draft-admin', descriptor)).resolves.toMatchObject({ admitted: true, intentId: 'assign-alice', }); - await expect(runtime.getWriterIntents('draft:admin')).resolves.toEqual([descriptor]); + await expect(runtime.getWriterIntents('draft-admin')).resolves.toEqual([descriptor]); expect(runtime.buildPatchDivergence([], [], null)).toMatchObject({}); await expect(runtime.createSyncRequest()).resolves.toMatchObject({ type: 'sync-request' }); diff --git a/test/unit/domain/runtimeReadingBasisErrors.test.ts b/test/unit/domain/runtimeReadingBasisErrors.test.ts index c5bd3820..3713f794 100644 --- a/test/unit/domain/runtimeReadingBasisErrors.test.ts +++ b/test/unit/domain/runtimeReadingBasisErrors.test.ts @@ -4,7 +4,6 @@ import ProvenanceController from '../../../src/domain/services/controllers/Prove import type { ProvenanceReadHost } from '../../../src/domain/services/controllers/ReadGraphHost.ts'; import { createEmptyState } from '../../../src/domain/services/JoinReducer.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; import { createMockPersistence } from '../../helpers/warpGraphTestUtils.ts'; @@ -17,8 +16,9 @@ function createProvenanceReadHost(options: { provenanceDegraded: boolean }): Pro _autoMaterialize: false, _persistence: createMockPersistence(), _commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - _readPatchBlob: async () => new Uint8Array(), - _codec: defaultCodec, + _readPatch: async () => { + throw new Error('unused patch read'); + }, _provenanceDegraded: options.provenanceDegraded, _provenanceIndex: null, }; diff --git a/test/unit/domain/services/AuditReceiptService.coverage.test.ts b/test/unit/domain/services/AuditReceiptService.coverage.test.ts index 12fb18c5..e94749ed 100644 --- a/test/unit/domain/services/AuditReceiptService.coverage.test.ts +++ b/test/unit/domain/services/AuditReceiptService.coverage.test.ts @@ -1,118 +1,65 @@ -/** - * @fileoverview AuditReceiptService — coverage probe tests. - * - * Validates that getStats() accurately reflects committed, skipped, - * and failed counts under various scenarios. - */ - -import { describe, it, expect, vi } from 'vitest'; import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; import { AuditReceiptService } from '../../../../src/domain/services/audit/AuditReceiptService.ts'; -import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryAuditLogAdapter from '../../../helpers/InMemoryAuditLogAdapter.ts'; +import AuditPublicationConflictError from '../../../../src/domain/errors/AuditPublicationConflictError.ts'; -const testCrypto = { - /** @param {string} algorithm @param {string|Buffer|Uint8Array} data */ - async hash(algorithm, data) { +const crypto = { + async hash(algorithm: string, data: string | Uint8Array) { return createHash(algorithm).update(data).digest('hex'); }, - async hmac() { return Buffer.alloc(0); }, + async hmac() { return new Uint8Array(); }, timingSafeEqual() { return false; }, }; -/** @param {number} lamport @param {string} sha */ -function makeReceipt(lamport, sha) { +function tick(lamport: number) { return Object.freeze({ - patchSha: sha, + patchSha: lamport.toString(16).padStart(40, '0'), writer: 'alice', lamport, ops: Object.freeze([ - Object.freeze(({ op: 'NodeAdd', target: `n${lamport}`, result: 'applied' } as const)), + Object.freeze({ op: 'NodeAdd' as const, target: `n${lamport}`, result: 'applied' as const }), ]), }); } -describe('AuditReceiptService — Coverage Probe', () => { - it('happy path: stats.committed === N after N data commits', async () => { - const persistence = new InMemoryGraphAdapter(); - const service = new AuditReceiptService({ - persistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - }); - await service.init(); +function createService(auditLog: InMemoryAuditLogAdapter) { + return new AuditReceiptService({ + auditLog, + graphName: 'events', + writerId: 'alice', + codec: defaultCodec, + crypto, + }); +} - const n = 5; - for (let i = 1; i <= n; i++) { - const sha = (i.toString(16)).padStart(40, '0'); - await service.commit(makeReceipt(i, sha)); +describe('AuditReceiptService statistics', () => { + it('counts successful semantic publications', async () => { + const subject = createService(new InMemoryAuditLogAdapter()); + await subject.init(); + for (let lamport = 1; lamport <= 5; lamport += 1) { + await subject.commit(tick(lamport)); } - - const stats = service.getStats(); - expect(stats.committed).toBe(n); - expect(stats.skipped).toBe(0); - expect(stats.failed).toBe(0); - expect(stats.degraded).toBe(false); + expect(subject.getStats()).toEqual({ committed: 5, skipped: 0, failed: 0, degraded: false }); }); - it('persistence failure: stats.failed incremented', async () => { - const persistence = new InMemoryGraphAdapter(); - const failingPersistence = Object.create(persistence); - failingPersistence.writeBlob = async () => { - throw new Error('write failed'); - }; - failingPersistence.readRef = persistence.readRef.bind(persistence); - - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; - const service = new AuditReceiptService({ - persistence: failingPersistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - await service.commit(makeReceipt(1, 'a'.repeat(40))); - - const stats = service.getStats(); - expect(stats.failed).toBe(1); - expect(stats.committed).toBe(0); + it('counts a failed publication without claiming a commit', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + auditLog.failAppendsWith(new Error('write failed')); + const subject = createService(auditLog); + await subject.init(); + await subject.commit(tick(1)); + expect(subject.getStats()).toMatchObject({ committed: 0, failed: 1 }); }); - it('degraded mode: stats.skipped incremented on subsequent calls', async () => { - const persistence = new InMemoryGraphAdapter(); - const failingPersistence = Object.create(persistence); - failingPersistence.compareAndSwapRef = async () => { - throw new Error('CAS fail'); - }; - failingPersistence.writeBlob = persistence.writeBlob.bind(persistence); - failingPersistence.writeTree = persistence.writeTree.bind(persistence); - failingPersistence.commitNodeWithTree = persistence.commitNodeWithTree.bind(persistence); - failingPersistence.readRef = persistence.readRef.bind(persistence); - - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; - const service = new AuditReceiptService({ - persistence: failingPersistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - // First call → CAS fail → retry → CAS fail → degraded - await service.commit(makeReceipt(1, 'a'.repeat(40))); - - // Second call → skipped due to degraded - await service.commit(makeReceipt(2, 'b'.repeat(40))); - - const stats = service.getStats(); - expect(stats.degraded).toBe(true); - expect(stats.skipped).toBeGreaterThan(0); + it('counts work skipped after repeated publication conflicts degrade the service', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + auditLog.failAppendsWith(new AuditPublicationConflictError(null, 'f'.repeat(40))); + const subject = createService(auditLog); + await subject.init(); + await subject.commit(tick(1)); + await subject.commit(tick(2)); + expect(subject.getStats()).toMatchObject({ degraded: true, failed: 1, skipped: 1 }); }); }); diff --git a/test/unit/domain/services/AuditReceiptService.test.ts b/test/unit/domain/services/AuditReceiptService.test.ts index eba48658..66e1dfc5 100644 --- a/test/unit/domain/services/AuditReceiptService.test.ts +++ b/test/unit/domain/services/AuditReceiptService.test.ts @@ -1,675 +1,244 @@ -/** - * @fileoverview AuditReceiptService — unit tests. - * - * Tests canonicalization, receipt construction, golden vector conformance, - * adversarial chain invariants, CAS conflict handling, and error resilience. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createHash } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; import { - sortedReplacer, + AuditReceiptService, + OPS_DIGEST_PREFIX, + buildReceiptRecord, canonicalOpsJson, computeOpsDigest, - buildReceiptRecord, - OPS_DIGEST_PREFIX, - AuditReceiptService, + sortedReplacer, } from '../../../../src/domain/services/audit/AuditReceiptService.ts'; -import { - encode as cborEncode, -} from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryAuditLogAdapter from '../../../helpers/InMemoryAuditLogAdapter.ts'; +import AuditPublicationConflictError from '../../../../src/domain/errors/AuditPublicationConflictError.ts'; -// ── Test crypto adapter ────────────────────────────────────────────────── - -/** Sync-friendly crypto adapter matching CryptoPort */ const testCrypto = { - /** @param {string} algorithm @param {string|Buffer|Uint8Array} data */ - async hash(algorithm, data) { + async hash(algorithm: string, data: string | Uint8Array) { return createHash(algorithm).update(data).digest('hex'); }, - async hmac() { return Buffer.alloc(0); }, + async hmac() { return new Uint8Array(); }, timingSafeEqual() { return false; }, }; -// ============================================================================ -// Canonicalization -// ============================================================================ - -describe('AuditReceiptService — Canonicalization', () => { - it('sortedReplacer produces deterministic key order', () => { - const obj = { z: 1, a: 2, m: 3 }; - const json1 = JSON.stringify(obj, sortedReplacer); - const json2 = JSON.stringify({ a: 2, m: 3, z: 1 }, sortedReplacer); - expect(json1).toBe(json2); - expect(json1).toBe('{"a":2,"m":3,"z":1}'); - }); - - it('sortedReplacer handles nested objects', () => { - const obj = { b: { z: 1, a: 2 }, a: 3 }; - const json = JSON.stringify(obj, sortedReplacer); - expect(json).toBe('{"a":3,"b":{"a":2,"z":1}}'); - }); - - it('sortedReplacer passes arrays through unchanged', () => { - const arr = [{ b: 1, a: 2 }]; - const json = JSON.stringify(arr, sortedReplacer); - expect(json).toBe('[{"a":2,"b":1}]'); - }); - - it('OPS_DIGEST_PREFIX contains literal null byte at position 21', () => { - const bytes = new TextEncoder().encode(OPS_DIGEST_PREFIX); - expect(bytes[bytes.length - 1]).toBe(0x00); - expect(bytes.length).toBe(22); - }); - - it('canonicalOpsJson matches spec Section 5.2', () => { - const ops = (([ - { op: 'NodeAdd', target: 'user:alice', result: 'applied' }, - { op: 'PropSet', target: 'user:alice\0name', result: 'applied' }, - ]) as const); - const json = canonicalOpsJson(ops); - const hex = Buffer.from(json, 'utf8').toString('hex'); - expect(hex).toBe( - '5b7b226f70223a224e6f6465416464222c22726573756c74223a226170706c696564222c22746172676574223a22757365723a616c696365227d2c7b226f70223a2250726f70536574222c22726573756c74223a226170706c696564222c22746172676574223a22757365723a616c6963655c75303030306e616d65227d5d', +function receipt(lamport = 1, writer = 'alice') { + return Object.freeze({ + patchSha: lamport.toString(16).padStart(40, '0'), + writer, + lamport, + ops: Object.freeze([ + Object.freeze({ op: 'NodeAdd' as const, target: `node:${lamport}`, result: 'applied' as const }), + ]), + }); +} + +function service(auditLog: InMemoryAuditLogAdapter, logger?: ReturnType) { + return new AuditReceiptService({ + auditLog, + graphName: 'events', + writerId: 'alice', + codec: defaultCodec, + crypto: testCrypto, + ...(logger === undefined ? {} : { logger }), + }); +} + +function mockLogger() { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn(), + }; + logger.child.mockReturnValue(logger); + return logger; +} + +describe('AuditReceiptService canonical receipts', () => { + it('canonicalizes object keys and applies the audit domain separator', async () => { + expect(JSON.stringify({ z: 1, a: 2 }, sortedReplacer)).toBe('{"a":2,"z":1}'); + expect(new TextEncoder().encode(OPS_DIGEST_PREFIX)).toHaveLength(22); + const ops = [{ op: 'NodeAdd' as const, target: 'user:alice', result: 'applied' as const }]; + expect(await computeOpsDigest(ops, testCrypto)).not.toBe( + createHash('sha256').update(canonicalOpsJson(ops)).digest('hex'), ); }); -}); -// ============================================================================ -// Domain Separator -// ============================================================================ - -describe('AuditReceiptService — Domain Separator', () => { - it('with vs without prefix produces different hashes', async () => { - const ops = ([{ op: 'NodeAdd', target: 'test', result: 'applied' }] as const); - const withPrefix = await computeOpsDigest(ops, testCrypto); - const json = canonicalOpsJson(ops); - const without = createHash('sha256').update(json).digest('hex'); - expect(withPrefix).not.toBe(without); - }); - - it('prefix is exactly 22 bytes', () => { - const bytes = new TextEncoder().encode(OPS_DIGEST_PREFIX); - expect(bytes.length).toBe(22); - }); -}); - -// ============================================================================ -// Golden Vector Conformance -// ============================================================================ - -describe('AuditReceiptService — Golden Vectors', () => { - it('Vector 1: genesis receipt opsDigest', async () => { - const ops = (([ - { op: 'NodeAdd', target: 'user:alice', result: 'applied' }, - { op: 'PropSet', target: 'user:alice\0name', result: 'applied' }, - ]) as const); - const digest = await computeOpsDigest(ops, testCrypto); - expect(digest).toBe('63df7eaa05e5dc38b436ffd562dad96d2175c7fa089fec6df8bb78bdc389b8fe'); - }); - - it('Vector 2: continuation receipt opsDigest', async () => { - const ops = (([ - { op: 'EdgeAdd', target: 'user:alice\0user:bob\0follows', result: 'applied' }, - ]) as const); - const digest = await computeOpsDigest(ops, testCrypto); - expect(digest).toBe('2d060db4f93b99b55c5effdf7f28042e09c1e93f1e0369a7e561bfc639f4e3d3'); - }); - - it('Vector 3: mixed outcomes opsDigest', async () => { - const ops = (([ - { op: 'NodeAdd', target: 'user:charlie', result: 'applied' }, - { op: 'PropSet', target: 'user:alice\0name', result: 'superseded', reason: 'LWW: writer bob at lamport 5 wins' }, - { op: 'NodeAdd', target: 'user:alice', result: 'redundant' }, - ]) as const); - const digest = await computeOpsDigest(ops, testCrypto); - expect(digest).toBe('c8e06e3a8b8d920dd9b27ebb4d5944e91053314150cd3671d0557d3cff58d057'); - }); - - it('Vector 4: SHA-256 OIDs opsDigest', async () => { - const ops = (([ - { op: 'NodeAdd', target: 'server:prod-1', result: 'applied' }, - ]) as const); - const digest = await computeOpsDigest(ops, testCrypto); - expect(digest).toBe('03a8cb1f891ac5b92277271559bf4e2f235a4313a04ab947c1ec5a4f78185cb8'); + it('retains the published golden digest', async () => { + const ops = [ + { op: 'NodeAdd' as const, target: 'user:alice', result: 'applied' as const }, + { op: 'PropSet' as const, target: 'user:alice\0name', result: 'applied' as const }, + ]; + await expect(computeOpsDigest(ops, testCrypto)).resolves.toBe( + '63df7eaa05e5dc38b436ffd562dad96d2175c7fa089fec6df8bb78bdc389b8fe', + ); }); -}); -// ============================================================================ -// Receipt Construction -// ============================================================================ - -describe('AuditReceiptService — buildReceiptRecord', () => { - function validFields() { - return { + it('builds a frozen schema-1 receipt and enforces every causal field invariant', () => { + const valid = { version: 1, graphName: 'events', writerId: 'alice', dataCommit: 'a'.repeat(40), tickStart: 1, tickEnd: 1, - opsDigest: '0'.repeat(64), + opsDigest: 'b'.repeat(64), prevAuditCommit: '0'.repeat(40), - timestamp: 1768435200000, + timestamp: 1, }; - } - - it('creates frozen receipt with sorted keys', () => { - const receipt = buildReceiptRecord(validFields()); - expect(Object.isFrozen(receipt)).toBe(true); - const keys = Object.keys(receipt); - const sorted = [...keys].sort(); - expect(keys).toEqual(sorted); - }); - - it('CBOR encoding of receipt matches expected key order', () => { - const receipt = buildReceiptRecord(validFields()); - const encoded = cborEncode(receipt); - const decoded = (defaultCodec.decode(encoded) as Record); - const keys = Object.keys(decoded); - expect(keys).toEqual([ - 'dataCommit', 'graphName', 'opsDigest', 'prevAuditCommit', - 'tickEnd', 'tickStart', 'timestamp', 'version', 'writerId', - ]); - }); - - it('lowercase-normalizes OID fields', () => { - const f = validFields(); - f.dataCommit = 'A'.repeat(40); - const receipt = buildReceiptRecord(f); - expect(receipt['dataCommit']).toBe('a'.repeat(40)); - }); - - it('rejects version !== 1', () => { - const f = validFields(); - f.version = 2; - expect(() => buildReceiptRecord(f)).toThrow('Invalid version'); - }); - - it('rejects tickStart > tickEnd', () => { - const f = validFields(); - f.tickStart = 3; - f.tickEnd = 1; - expect(() => buildReceiptRecord(f)).toThrow('tickEnd'); - }); - - it('rejects tickStart !== tickEnd in v1', () => { - const f = validFields(); - f.tickStart = 1; - f.tickEnd = 3; - expect(() => buildReceiptRecord(f)).toThrow('v1 requires'); - }); - - it('rejects OID length mismatch', () => { - const f = validFields(); - f.dataCommit = 'f'.repeat(64); - f.prevAuditCommit = '0'.repeat(40); - expect(() => buildReceiptRecord(f)).toThrow('OID length mismatch'); - }); - - it('rejects non-genesis with zero-hash sentinel', () => { - const f = validFields(); - f.tickStart = 5; - f.tickEnd = 5; - f.prevAuditCommit = '0'.repeat(40); - expect(() => buildReceiptRecord(f)).toThrow('Non-genesis'); - }); - - it('rejects invalid OID hex', () => { - const f = validFields(); - f.dataCommit = 'z'.repeat(40); - expect(() => buildReceiptRecord(f)).toThrow('Invalid dataCommit OID'); - }); - - it('rejects negative timestamp', () => { - const f = validFields(); - f.timestamp = -1; - expect(() => buildReceiptRecord(f)).toThrow('Invalid timestamp'); - }); - - it('rejects non-integer timestamp', () => { - const f = validFields(); - f.timestamp = 1.5; - expect(() => buildReceiptRecord(f)).toThrow('Invalid timestamp'); - }); - - it('rejects tickStart < 1', () => { - const f = validFields(); - f.tickStart = 0; - f.tickEnd = 0; - expect(() => buildReceiptRecord(f)).toThrow('tickStart'); - }); - - it('rejects empty graphName', () => { - const f = validFields(); - f.graphName = ''; - expect(() => buildReceiptRecord(f)).toThrow('Invalid graphName'); - }); - - it('rejects empty writerId', () => { - const f = validFields(); - f.writerId = ''; - expect(() => buildReceiptRecord(f)).toThrow('Invalid writerId'); - }); - - it('rejects invalid opsDigest', () => { - const f = validFields(); - f.opsDigest = 'abc123'; - expect(() => buildReceiptRecord(f)).toThrow('Invalid opsDigest'); - }); - - it('rejects invalid prevAuditCommit OID', () => { - const f = validFields(); - f.prevAuditCommit = 'g'.repeat(40); - expect(() => buildReceiptRecord(f)).toThrow('Invalid prevAuditCommit OID'); - }); - - it('rejects timestamps above Number.MAX_SAFE_INTEGER', () => { - const f = validFields(); - f.timestamp = Number.MAX_SAFE_INTEGER + 1; - expect(() => buildReceiptRecord(f)).toThrow('exceeds Number.MAX_SAFE_INTEGER'); + expect(Object.isFrozen(buildReceiptRecord(valid))).toBe(true); + + const invalidCases: ReadonlyArray<{ + patch: Partial; + message: RegExp; + }> = [ + { patch: { version: 2 }, message: /version/ }, + { patch: { graphName: '' }, message: /graphName/ }, + { patch: { writerId: '' }, message: /writerId/ }, + { patch: { dataCommit: 'not-an-oid' }, message: /dataCommit/ }, + { patch: { opsDigest: 'not-a-digest' }, message: /opsDigest/ }, + { patch: { prevAuditCommit: 'not-an-oid' }, message: /prevAuditCommit/ }, + { patch: { prevAuditCommit: '0'.repeat(64) }, message: /OID length mismatch/ }, + { patch: { tickStart: 0 }, message: /tickStart/ }, + { patch: { tickStart: 2, tickEnd: 1 }, message: /tickEnd/ }, + { + patch: { tickEnd: 2, prevAuditCommit: 'c'.repeat(40) }, + message: /tickStart === tickEnd/, + }, + { patch: { tickStart: 2, tickEnd: 2 }, message: /Non-genesis/ }, + { patch: { timestamp: -1 }, message: /timestamp/ }, + { patch: { timestamp: Number.MAX_SAFE_INTEGER + 1 }, message: /MAX_SAFE_INTEGER/ }, + ]; + + for (const invalid of invalidCases) { + expect(() => buildReceiptRecord({ ...valid, ...invalid.patch })).toThrow(invalid.message); + } }); }); -// ============================================================================ -// Service Integration -// ============================================================================ - -describe('AuditReceiptService — commit flow', () => { - let persistence; - let service; - - beforeEach(async () => { - persistence = new InMemoryGraphAdapter(); - service = new AuditReceiptService({ - persistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - }); - await service.init(); - }); - - it('init adopts the existing audit ref tip when present', async () => { - const persistence = (({ - readRef: vi.fn(async () => 'a'.repeat(40)), - }) as any); - const service = new AuditReceiptService({ - persistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - }); - - await service.init(); +describe('AuditReceiptService semantic publication', () => { + it('publishes a receipt with anchored retention evidence', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const subject = service(auditLog); + await subject.init(); - expect((service as any)._prevAuditCommit).toBe('a'.repeat(40)); - expect((service as any)._expectedOldRef).toBe('a'.repeat(40)); - }); - - it('init logs and resets state when reading the audit ref fails', async () => { - const logger = { warn: vi.fn() }; - const persistence = (({ - readRef: vi.fn(async () => { - throw new Error('ref read failed'); - }), - }) as any); - const service = new AuditReceiptService({ - persistence, + const published = await subject.commit(receipt()); + if (published === null) { + throw new Error('expected audit publication'); + } + expect(published.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + root: { kind: 'publication', generation: published.sha }, + }); + expect(await auditLog.readHead('events', 'alice')).toBe(published.sha); + const stored = await auditLog.readEntry(published.sha); + expect(defaultCodec.decode>(stored.receipt)).toMatchObject({ graphName: 'events', writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger: (logger as any), + dataCommit: '1'.padStart(40, '0'), }); - (service as any)._prevAuditCommit = 'f'.repeat(40); - (service as any)._expectedOldRef = 'f'.repeat(40); - - await service.init(); - - expect(logger.warn).toHaveBeenCalledWith('[warp:audit]', expect.objectContaining({ - code: 'AUDIT_INIT_READ_FAILED', - writerId: 'alice', - })); - expect((service as any)._prevAuditCommit).toBeNull(); - expect((service as any)._expectedOldRef).toBeNull(); - }); - - function makeTickReceipt(lamport = 1, patchSha = 'a'.repeat(40)) { - return Object.freeze({ - patchSha, - writer: 'alice', - lamport, - ops: Object.freeze([ - Object.freeze(({ op: 'NodeAdd', target: 'user:alice', result: 'applied' } as const)), - ]), - }); - } - - it('creates audit commit on first call (genesis)', async () => { - const sha = await service.commit(makeTickReceipt()); - expect(sha).toBeTruthy(); - expect(typeof sha).toBe('string'); - - // Audit ref should be set - const ref = await persistence.readRef('refs/warp/events/audit/alice'); - expect(ref).toBe(sha); }); - it('chains audit commits (parent linking)', async () => { - const sha1 = await service.commit(makeTickReceipt(1, 'a'.repeat(40))); - const sha2 = await service.commit(makeTickReceipt(2, 'b'.repeat(40))); - - expect(sha1).toBeTruthy(); - expect(sha2).toBeTruthy(); - expect(sha2).not.toBe(sha1); - - // Second commit should have first as parent - const info = await persistence.getNodeInfo((sha2)); - expect(info.parents).toEqual([sha1]); - }); + it('chains publications through their semantic parent', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const subject = service(auditLog); + await subject.init(); + const first = await subject.commit(receipt(1)); + const second = await subject.commit(receipt(2)); + if (first === null || second === null) { + throw new Error('expected audit publications'); + } - it('stats track committed count', async () => { - await service.commit(makeTickReceipt(1, 'a'.repeat(40))); - await service.commit(makeTickReceipt(2, 'b'.repeat(40))); - const stats = service.getStats(); - expect(stats.committed).toBe(2); - expect(stats.failed).toBe(0); - expect(stats.skipped).toBe(0); - expect(stats.degraded).toBe(false); + expect((await auditLog.readEntry(second.sha)).parents).toEqual([first.sha]); + expect(subject.getStats()).toEqual({ committed: 2, skipped: 0, failed: 0, degraded: false }); }); - it('audit commit tree contains receipt.cbor blob', async () => { - const sha = await service.commit(makeTickReceipt()); - const commit = ((persistence))._commits.get((sha)); - expect(commit).toBeTruthy(); - const tree = await persistence.readTree(/** @type {{ treeOid: string }} */ (commit).treeOid); - expect(tree).toHaveProperty('receipt.cbor'); - expect(Buffer.isBuffer(tree['receipt.cbor'])).toBe(true); + it('adopts an existing semantic head during initialization', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + auditLog.forceHead('events', 'alice', 'a'.repeat(40)); + const subject = service(auditLog); + await subject.init(); - // Decode the CBOR and verify structure - const receiptBlob = tree['receipt.cbor']; - expect(receiptBlob).toBeDefined(); - const receipt = (defaultCodec.decode((receiptBlob)) as Record); - expect(receipt['version']).toBe(1); - expect(receipt['graphName']).toBe('events'); - expect(receipt['writerId']).toBe('alice'); - expect(receipt['dataCommit']).toBe('a'.repeat(40)); + const published = await subject.commit(receipt(2)); + expect(published).not.toBeNull(); + expect((await auditLog.readEntry(published!.sha)).parents).toEqual(['a'.repeat(40)]); }); -}); -// ============================================================================ -// CAS Conflict + Retry -// ============================================================================ + it('retries once after a publication conflict using the refreshed head', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const log = mockLogger(); + const subject = service(auditLog, log); + await subject.init(); + await subject.commit(receipt(1)); + auditLog.forceHead('events', 'alice', 'f'.repeat(40)); -describe('AuditReceiptService — CAS conflict handling', () => { - it('retries once on CAS mismatch with refreshed tip', async () => { - const persistence = new InMemoryGraphAdapter(); - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; - const service = new AuditReceiptService({ - persistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - // First commit succeeds normally - const receipt1 = Object.freeze({ - patchSha: 'a'.repeat(40), - writer: 'alice', - lamport: 1, - ops: Object.freeze([Object.freeze(({ op: 'NodeAdd', target: 'n1', result: 'applied' } as const))]), - }); - await service.commit(receipt1); - - // Now simulate CAS conflict: externally update the ref - const externalSha = await persistence.commitNode({ message: 'external' }); - await persistence.updateRef('refs/warp/events/audit/alice', externalSha); - - // Next commit should trigger CAS conflict and retry - const receipt2 = Object.freeze({ - patchSha: 'b'.repeat(40), - writer: 'alice', - lamport: 2, - ops: Object.freeze([Object.freeze(({ op: 'NodeAdd', target: 'n2', result: 'applied' } as const))]), - }); - const sha2 = await service.commit(receipt2); - - // Should have logged CAS conflict - const casLog = logger.warn.mock.calls.find( - (c) => c[1]?.code === 'AUDIT_REF_CAS_CONFLICT', + const published = await subject.commit(receipt(2)); + expect(published).not.toBeNull(); + expect((await auditLog.readEntry(published!.sha)).parents).toEqual(['f'.repeat(40)]); + expect(log.warn).toHaveBeenCalledWith( + '[warp:audit]', + expect.objectContaining({ code: 'AUDIT_REF_CAS_CONFLICT' }), ); - expect(casLog).toBeTruthy(); - - // Should still succeed after retry - expect(sha2).toBeTruthy(); - }); - - it('degrades after second CAS failure', async () => { - const persistence = new InMemoryGraphAdapter(); - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; - - // Create a persistence that always fails CAS - const failingPersistence = Object.create(persistence); - let casCallCount = 0; - failingPersistence.compareAndSwapRef = async () => { - casCallCount++; - throw new Error('CAS mismatch'); - }; - // Forward all other methods to the real adapter - failingPersistence.writeBlob = persistence.writeBlob.bind(persistence); - failingPersistence.writeTree = persistence.writeTree.bind(persistence); - failingPersistence.commitNodeWithTree = persistence.commitNodeWithTree.bind(persistence); - failingPersistence.readRef = persistence.readRef.bind(persistence); - - const service = new AuditReceiptService({ - persistence: failingPersistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - const receipt = Object.freeze({ - patchSha: 'a'.repeat(40), - writer: 'alice', - lamport: 1, - ops: Object.freeze([Object.freeze(({ op: 'NodeAdd', target: 'n1', result: 'applied' } as const))]), - }); - - // Should fail gracefully (commit() catches errors) - const result = await service.commit(receipt); - expect(result).toBeNull(); - - // Should be degraded now - const stats = service.getStats(); - expect(stats.degraded).toBe(true); - - // Subsequent calls should be skipped - const result2 = await service.commit(receipt); - expect(result2).toBeNull(); - expect(service.getStats().skipped).toBeGreaterThan(0); }); -}); - -// ============================================================================ -// Error Resilience -// ============================================================================ -describe('AuditReceiptService — Error resilience', () => { - it('writeBlob failure logs AUDIT_WRITE_BLOB_FAILED, does not throw', async () => { - const persistence = new InMemoryGraphAdapter(); - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; + it('degrades after the retry also conflicts and skips subsequent work', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const conflict = new AuditPublicationConflictError(null, 'f'.repeat(40)); + auditLog.failAppendsWith(conflict); + const subject = service(auditLog, mockLogger()); + await subject.init(); - const failingPersistence = Object.create(persistence); - failingPersistence.writeBlob = async () => { - throw new Error('disk full'); - }; - failingPersistence.readRef = persistence.readRef.bind(persistence); - - const service = new AuditReceiptService({ - persistence: failingPersistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - const receipt = Object.freeze({ - patchSha: 'a'.repeat(40), - writer: 'alice', - lamport: 1, - ops: Object.freeze([Object.freeze(({ op: 'NodeAdd', target: 'n1', result: 'applied' } as const))]), - }); - - const result = await service.commit(receipt); - expect(result).toBeNull(); - - const blobLog = logger.warn.mock.calls.find( - (c) => c[1]?.code === 'AUDIT_WRITE_BLOB_FAILED', - ); - expect(blobLog).toBeTruthy(); + await expect(subject.commit(receipt(1))).resolves.toBeNull(); + await expect(subject.commit(receipt(2))).resolves.toBeNull(); + expect(subject.getStats()).toMatchObject({ degraded: true, failed: 1, skipped: 1 }); }); - it('writeTree failure logs AUDIT_WRITE_TREE_FAILED, does not throw', async () => { - const persistence = new InMemoryGraphAdapter(); - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; + it('does not degrade when a transient failure follows the first publication conflict', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const realAppend = auditLog.append.bind(auditLog); + const append = vi.spyOn(auditLog, 'append'); + append + .mockRejectedValueOnce(new AuditPublicationConflictError(null, 'f'.repeat(40))) + .mockRejectedValueOnce(new Error('storage unavailable during retry')) + .mockImplementation(realAppend); + const subject = service(auditLog, mockLogger()); + await subject.init(); - const failingPersistence = Object.create(persistence); - failingPersistence.writeBlob = persistence.writeBlob.bind(persistence); - failingPersistence.writeTree = async () => { - throw new Error('tree error'); - }; - failingPersistence.readRef = persistence.readRef.bind(persistence); - - const service = new AuditReceiptService({ - persistence: failingPersistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - const receipt = Object.freeze({ - patchSha: 'a'.repeat(40), - writer: 'alice', - lamport: 1, - ops: Object.freeze([Object.freeze(({ op: 'NodeAdd', target: 'n1', result: 'applied' } as const))]), - }); - - const result = await service.commit(receipt); - expect(result).toBeNull(); - - const treeLog = logger.warn.mock.calls.find( - (c) => c[1]?.code === 'AUDIT_WRITE_TREE_FAILED', - ); - expect(treeLog).toBeTruthy(); + await expect(subject.commit(receipt(1))).resolves.toBeNull(); + await expect(subject.commit(receipt(1))).resolves.not.toBeNull(); + expect(subject.getStats()).toMatchObject({ committed: 1, failed: 1, degraded: false }); }); - it('structured error codes present in all log calls', async () => { - const persistence = new InMemoryGraphAdapter(); - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; + it('records ordinary storage failures without claiming durability', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + auditLog.failAppendsWith(new Error('storage unavailable')); + const subject = service(auditLog, mockLogger()); + await subject.init(); - const failingPersistence = Object.create(persistence); - failingPersistence.writeBlob = async () => { - throw new Error('fail'); - }; - failingPersistence.readRef = persistence.readRef.bind(persistence); - - const service = new AuditReceiptService({ - persistence: failingPersistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - const receipt = Object.freeze({ - patchSha: 'a'.repeat(40), - writer: 'alice', - lamport: 1, - ops: Object.freeze([Object.freeze(({ op: 'NodeAdd', target: 'n1', result: 'applied' } as const))]), - }); - - await service.commit(receipt); - - // Every warn call should have a code field - for (const call of logger.warn.mock.calls) { - expect(call[1]).toHaveProperty('code'); - expect(typeof call[1].code).toBe('string'); - } + await expect(subject.commit(receipt())).resolves.toBeNull(); + expect(subject.getStats()).toMatchObject({ committed: 0, failed: 1, degraded: false }); }); -}); -// ============================================================================ -// Integration with TickReceipt -// ============================================================================ + it('rejects cross-writer attribution before publishing', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const subject = service(auditLog, mockLogger()); + await subject.init(); -describe('AuditReceiptService — cross-writer guard', () => { - it('rejects tickReceipt with mismatched writer', async () => { - const persistence = new InMemoryGraphAdapter(); - const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(() => logger) }; - const service = new AuditReceiptService({ - persistence, - graphName: 'events', - writerId: 'alice', - codec: defaultCodec, - crypto: testCrypto, - logger, - }); - await service.init(); - - const receipt = Object.freeze({ - patchSha: 'a'.repeat(40), - writer: 'eve', // ← wrong writer - lamport: 1, - ops: Object.freeze([ - Object.freeze(({ op: 'NodeAdd', target: 'x', result: 'applied' } as const)), - ]), - }); - - // Should reject or log and skip — must not attribute eve's ops to alice's audit chain - const sha = await service.commit(receipt); - // commit() should return null (skipped) since the writer doesn't match - expect(sha).toBeNull(); - - // Audit ref should NOT have been set - const ref = await persistence.readRef('refs/warp/events/audit/alice'); - expect(ref).toBeNull(); + await expect(subject.commit(receipt(1, 'eve'))).resolves.toBeNull(); + await expect(auditLog.readHead('events', 'alice')).resolves.toBeNull(); }); -}); -describe('AuditReceiptService — TickReceipt integration', () => { - it('ops with reason field → correct canonical key order', () => { - const ops = (([ - { op: 'PropSet', target: 'a\0b', result: 'superseded', reason: 'LWW conflict' }, - ]) as const); - const json = canonicalOpsJson(ops); - // "reason" sorts before "result" - expect(json).toContain('"reason":"LWW conflict","result":"superseded"'); - }); + it('starts clean but emits evidence when head discovery fails', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const log = mockLogger(); + auditLog.failReadsWith(new Error('head unavailable')); + const subject = service(auditLog, log); - it('ops without reason → field absent, not null', () => { - const ops = (([ - { op: 'NodeAdd', target: 'x', result: 'applied' }, - ]) as const); - const json = canonicalOpsJson(ops); - expect(json).not.toContain('reason'); - expect(json).not.toContain('null'); + await subject.init(); + expect(log.warn).toHaveBeenCalledWith( + '[warp:audit]', + expect.objectContaining({ code: 'AUDIT_INIT_READ_FAILED' }), + ); }); }); diff --git a/test/unit/domain/services/AuditVerifierService.bench.ts b/test/unit/domain/services/AuditVerifierService.bench.ts index 555d168c..40073c75 100644 --- a/test/unit/domain/services/AuditVerifierService.bench.ts +++ b/test/unit/domain/services/AuditVerifierService.bench.ts @@ -6,7 +6,7 @@ */ import { createHash } from 'node:crypto'; -import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; +import InMemoryAuditLogAdapter from '../../../../test/helpers/InMemoryAuditLogAdapter.ts'; import { AuditReceiptService } from '../../../../src/domain/services/audit/AuditReceiptService.ts'; import AuditVerifierService from '../../../../src/domain/services/audit/AuditVerifierService.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; @@ -26,9 +26,9 @@ const CHAIN_LENGTH = 1000; console.log(`Building ${CHAIN_LENGTH}-receipt chain...`); const t0 = performance.now(); -const persistence = new InMemoryGraphAdapter(); +const auditLog = new InMemoryAuditLogAdapter(); const service = new AuditReceiptService({ - persistence, + auditLog, graphName: 'bench', writerId: 'alice', codec: defaultCodec, @@ -57,7 +57,7 @@ console.log(`Verifying ${CHAIN_LENGTH}-receipt chain...`); const t1 = performance.now(); const verifier = new AuditVerifierService({ - persistence, + auditLog, codec: defaultCodec, }); const result = await verifier.verifyChain('bench', 'alice'); diff --git a/test/unit/domain/services/AuditVerifierService.test.ts b/test/unit/domain/services/AuditVerifierService.test.ts index d7df7212..84766184 100644 --- a/test/unit/domain/services/AuditVerifierService.test.ts +++ b/test/unit/domain/services/AuditVerifierService.test.ts @@ -1,1260 +1,191 @@ -/** - * @fileoverview AuditVerifierService — unit tests. - * - * Builds audit chains programmatically using InMemoryGraphAdapter + AuditReceiptService, - * then verifies chain integrity, tamper detection, and edge cases. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; import { createHash } from 'node:crypto'; -import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; +import { describe, expect, it } from 'vitest'; import { AuditReceiptService } from '../../../../src/domain/services/audit/AuditReceiptService.ts'; import AuditVerifierService from '../../../../src/domain/services/audit/AuditVerifierService.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import defaultTrustCrypto from '../../../../src/infrastructure/adapters/TrustCryptoSingleton.ts'; -import { encodeAuditMessage } from '../../../../src/domain/services/codec/AuditMessageCodec.ts'; -import { - KEY_ADD_1, - KEY_ADD_2, - WRITER_BIND_ADD_ALICE, -} from '../trust/fixtures/goldenRecords.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import type { PublishedAuditRecord } from '../../../../src/ports/AuditLogPort.ts'; +import InMemoryAuditLogAdapter from '../../../helpers/InMemoryAuditLogAdapter.ts'; import { MockTrustChainPort } from '../../../helpers/MockTrustChainPort.ts'; -import { TrustRecord } from '../../../../src/domain/trust/TrustRecord.ts'; -// ── Test crypto adapter ────────────────────────────────────────────────── - -const testCrypto = { - /** @param {string} algorithm @param {string|Buffer|Uint8Array} data */ - async hash(algorithm, data) { +const crypto = { + async hash(algorithm: string, data: string | Uint8Array) { return createHash(algorithm).update(data).digest('hex'); }, - async hmac() { return Buffer.alloc(0); }, + async hmac() { return new Uint8Array(); }, timingSafeEqual() { return false; }, }; -// ── Helpers ────────────────────────────────────────────────────────────── - -/** - * Creates an AuditReceiptService bound to a persistence adapter. - * @param {InMemoryGraphAdapter} persistence - * @param {string} graphName - * @param {string} writerId - */ -async function createAuditService(persistence, graphName, writerId) { - const service = new AuditReceiptService({ - persistence, - graphName, +async function auditService(auditLog: InMemoryAuditLogAdapter, writerId = 'alice') { + const subject = new AuditReceiptService({ + auditLog, + graphName: 'events', writerId, codec: defaultCodec, - crypto: testCrypto, + crypto, }); - await service.init(); - return service; + await subject.init(); + return subject; } -/** - * Commits a tick receipt and returns the audit commit SHA. - * @param {AuditReceiptService} service - * @param {number} lamport - * @param {string} [patchSha] - * @param {string} [writer] - */ -async function commitReceipt(service: any, lamport: any, patchSha?: string, writer = 'alice') { - const sha = patchSha || `${lamport.toString(16).padStart(2, '0')}${'a'.repeat(38)}`; - return await service.commit(Object.freeze({ - patchSha: sha, +async function commit( + subject: AuditReceiptService, + lamport: number, + writer = 'alice', +): Promise { + const result = await subject.commit(Object.freeze({ + patchSha: lamport.toString(16).padStart(40, '0'), writer, lamport, ops: Object.freeze([ - Object.freeze({ op: 'NodeAdd', target: `node:${lamport}`, result: 'applied' }), + Object.freeze({ op: 'NodeAdd' as const, target: `node:${lamport}`, result: 'applied' as const }), ]), })); -} - -/** - * Creates a verifier for the given persistence. - * @param {InMemoryGraphAdapter} persistence - */ -function createVerifier(persistence) { - return new AuditVerifierService({ - persistence, - codec: defaultCodec, - }); -} - -/** - * Mutates the decoded receipt stored in an audit commit and rewrites the tree. - * @param {InMemoryGraphAdapter} persistence - * @param {string} commitSha - * @param {(receipt: Record) => void} mutate - * @returns {Promise>} - */ -async function mutateReceipt(persistence, commitSha, mutate) { - const commit = ((persistence)['_commits'].get(commitSha) as any); - if (!commit) { - throw new Error(`missing commit ${commitSha}`); + if (result === null) { + throw new Error('expected audit publication'); } - const tree = await persistence.readTree((commit.treeOid as string)); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - mutate(receipt); - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - commit['treeOid'] = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - return receipt; + return result; } -/** - * Rewrites the audit commit message. - * @param {InMemoryGraphAdapter} persistence - * @param {string} commitSha - * @param {(message: string) => string} mutate - */ -function mutateCommitMessage(persistence, commitSha, mutate) { - const commit = ((persistence)['_commits'].get(commitSha) as any); - if (!commit) { - throw new Error(`missing commit ${commitSha}`); - } - commit['message'] = mutate((commit['message'] as string)); -} - -/** - * Seeds a trust chain into the in-memory repo. - * @param {InMemoryGraphAdapter} persistence - * @param {string} graphName - * @param {Array>} records - */ -/** - * Creates a verifier with a mock trust chain port seeded with the given records. - * @param {InMemoryGraphAdapter} persistence - * @param {import('../../../../src/domain/trust/TrustRecord.ts').TrustRecord[]} records - */ -function createTrustVerifier(persistence, records) { - const trustChain = new MockTrustChainPort(); - trustChain.seed(records); - return new AuditVerifierService({ - persistence, - codec: defaultCodec, - trustChain, - trustCrypto: defaultTrustCrypto, - }); +function verifier(auditLog: InMemoryAuditLogAdapter): AuditVerifierService { + return new AuditVerifierService({ auditLog, codec: defaultCodec }); } -/** - * Creates a verifier with a failing trust chain port. - * @param {InMemoryGraphAdapter} persistence - * @param {Error} err - */ -function createFailingTrustVerifier(persistence, err) { - const trustChain = new MockTrustChainPort(); - trustChain.failWith(err); - return new AuditVerifierService({ - persistence, - codec: defaultCodec, - trustChain, - trustCrypto: defaultTrustCrypto, - }); -} - -// ============================================================================ -// Valid chains -// ============================================================================ - -describe('AuditVerifierService — valid chains', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('verifies a genesis-only chain', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('VALID'); - expect(result.receiptsVerified).toBe(1); - expect(result.errors).toEqual([]); - expect(result.genesisCommit).toBeTruthy(); - }); - - it('verifies a multi-receipt chain (3 receipts)', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - await commitReceipt(service, 2); - await commitReceipt(service, 3); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); +describe('AuditVerifierService semantic chains', () => { + it('verifies genesis and multi-receipt chains', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + await commit(writer, 1); + await commit(writer, 2); + await commit(writer, 3); + const result = await verifier(auditLog).verifyChain('events', 'alice'); expect(result.status).toBe('VALID'); expect(result.receiptsVerified).toBe(3); expect(result.errors).toEqual([]); + expect(result.genesisCommit).not.toBeNull(); }); - it('returns VALID with 0 chains when no audit refs exist', async () => { - const verifier = createVerifier(persistence); - const result = await verifier.verifyAll('events'); - - expect(result.summary.total).toBe(0); - expect(result.summary.valid).toBe(0); - expect(result.chains).toEqual([]); - }); - - it('returns empty result for non-existent writer', async () => { - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'nobody'); - - expect(result.status).toBe('VALID'); - expect(result.receiptsVerified).toBe(0); - expect(result.tipCommit).toBeNull(); + it('returns an empty valid result when no semantic head exists', async () => { + const result = await verifier(new InMemoryAuditLogAdapter()).verifyChain('events', 'alice'); + expect(result).toMatchObject({ status: 'VALID', receiptsVerified: 0, tipCommit: null }); }); - it('returns an empty result when the audit ref cannot be read', async () => { - persistence.readRef = async () => { - throw new Error('ref storage unavailable'); - }; + it('applies configured read failures to writer discovery and entry reads', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const failure = new Error('audit storage unavailable'); + auditLog.failReadsWith(failure); - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('VALID'); - expect(result.receiptsVerified).toBe(0); - expect(result.tipCommit).toBeNull(); + await expect(verifier(auditLog).verifyAll('events')).rejects.toBe(failure); + await expect(auditLog.readEntry('a'.repeat(40))).rejects.toBe(failure); }); -}); -// ============================================================================ -// PARTIAL (--since) -// ============================================================================ + it('supports bounded partial verification from a requested publication', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + await commit(writer, 1); + const second = await commit(writer, 2); + await commit(writer, 3); -describe('AuditVerifierService — --since', () => { - let persistence; - let auditShas; - - beforeEach(async () => { - persistence = new InMemoryGraphAdapter(); - const service = await createAuditService(persistence, 'events', 'alice'); - auditShas = []; - for (let i = 1; i <= 5; i++) { - const sha = await commitReceipt(service, i); - auditShas.push((sha)); - } + const result = await verifier(auditLog).verifyChain('events', 'alice', { since: second.sha }); + expect(result).toMatchObject({ status: 'PARTIAL', receiptsVerified: 2, stoppedAt: second.sha }); }); - it('returns PARTIAL when --since stops mid-chain', async () => { - const verifier = createVerifier(persistence); - const since = (auditShas[2] as string); // commit for tick 3 - const result = await verifier.verifyChain('events', 'alice', { since }); - - expect(result.status).toBe('PARTIAL'); - expect(result.receiptsVerified).toBe(3); // ticks 5, 4, 3 - expect(result.stoppedAt).toBe(since); - expect(result.errors).toEqual([]); - }); - - it('returns PARTIAL when --since is the tip', async () => { - const verifier = createVerifier(persistence); - const since = (auditShas[4] as string); // tip - const result = await verifier.verifyChain('events', 'alice', { since }); - - expect(result.status).toBe('PARTIAL'); - expect(result.receiptsVerified).toBe(1); - }); - - it('returns ERROR with SINCE_NOT_FOUND when commit not in chain', async () => { - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice', { since: 'f'.repeat(40) }); + it('fails when a requested lower bound is not in the chain', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + await commit(writer, 1); + const result = await verifier(auditLog).verifyChain('events', 'alice', { + since: 'f'.repeat(40), + }); expect(result.status).toBe('ERROR'); - expect(result.errors[0]?.code).toBe('SINCE_NOT_FOUND'); - }); -}); - -// ============================================================================ -// BROKEN_CHAIN — structural integrity -// ============================================================================ - -describe('AuditVerifierService — broken chain', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects chain link broken (Git parent mismatch)', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - const sha2 = await commitReceipt(service, 2); - - // Tamper: rewrite sha2's Git parent to a different commit - const commit = ((persistence) as any)['_commits'].get((sha2)); - if (commit) { - commit.parents = ['f'.repeat(40)]; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'GIT_PARENT_MISMATCH')).toBe(true); - }); - - it('detects genesis with parents', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - // Tamper: add a parent to the genesis commit - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - commit.parents = ['f'.repeat(40)]; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'GENESIS_HAS_PARENTS')).toBe(true); - }); - - it('detects continuation with no parent', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - const sha2 = await commitReceipt(service, 2); - - // Tamper: remove parents from continuation commit - const commit = ((persistence) as any)['_commits'].get((sha2)); - if (commit) { - commit.parents = []; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'CONTINUATION_NO_PARENT')).toBe(true); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'SINCE_NOT_FOUND' }), + ])); }); - it('detects tick monotonicity violation', async () => { - // Build chain manually to create non-monotonic ticks - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1tick = await commitReceipt(service, 1); - await commitReceipt(service, 2); - - // Tamper: change tick in sha1's receipt to be >= sha2's tick - const commit1 = ((persistence) as any)['_commits'].get((sha1tick)); - if (commit1) { - const tree = await persistence.readTree(commit1.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - receipt['tickStart'] = 5; - receipt['tickEnd'] = 5; - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTreeOid = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit1.treeOid = newTreeOid; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'TICK_MONOTONICITY')).toBe(true); - }); - - it('detects extra entries in tree (RECEIPT_TREE_INVALID)', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - // Tamper: add extra entry to the tree - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receiptBlob = await persistence.writeBlob((tree['receipt.cbor'] as Uint8Array)); - const extraBlob = await persistence.writeBlob(Buffer.from('extra')); - const newTreeOid = await persistence.writeTree([ - `100644 blob ${receiptBlob}\treceipt.cbor`, - `100644 blob ${extraBlob}\textra.txt`, - ]); - commit.treeOid = newTreeOid; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'RECEIPT_TREE_INVALID')).toBe(true); - }); - - it('detects missing receipt.cbor in tree', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - // Tamper: replace tree with one that has wrong filename - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receiptBlob = await persistence.writeBlob((tree['receipt.cbor'] as Uint8Array)); - const newTreeOid = await persistence.writeTree([ - `100644 blob ${receiptBlob}\twrong.cbor`, - ]); - commit.treeOid = newTreeOid; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'RECEIPT_TREE_INVALID')).toBe(true); - }); -}); - -// ============================================================================ -// DATA_MISMATCH — content integrity -// ============================================================================ - -describe('AuditVerifierService — data mismatch', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects trailer dataCommit mismatch with CBOR', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - // Tamper: rewrite commit message with different dataCommit - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - // Re-encode message with wrong dataCommit - commit.message = encodeAuditMessage({ - graph: 'events', - writer: 'alice', - dataCommit: 'b'.repeat(40), - opsDigest: receipt['opsDigest'], - }); - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('DATA_MISMATCH'); - expect(result.errors.some((e) => e.code === 'TRAILER_MISMATCH')).toBe(true); - }); - - it('detects trailer opsDigest mismatch with CBOR', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - commit.message = encodeAuditMessage({ - graph: 'events', - writer: 'alice', - dataCommit: receipt['dataCommit'], - opsDigest: 'f'.repeat(64), - }); - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('DATA_MISMATCH'); - expect(result.errors.some((e) => e.code === 'TRAILER_MISMATCH')).toBe(true); - }); - - it('detects trailer writer mismatch with CBOR', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - commit.message = encodeAuditMessage({ - graph: 'events', - writer: 'bob', - dataCommit: receipt['dataCommit'], - opsDigest: receipt['opsDigest'], - }); - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('DATA_MISMATCH'); - expect(result.errors.some((e) => e.code === 'TRAILER_MISMATCH')).toBe(true); - }); - - it('detects trailer graph mismatch with CBOR', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - commit.message = encodeAuditMessage({ - graph: 'other', - writer: 'alice', - dataCommit: receipt['dataCommit'], - opsDigest: receipt['opsDigest'], - }); - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('DATA_MISMATCH'); - expect(result.errors.some((e) => e.code === 'TRAILER_MISMATCH')).toBe(true); - }); - - it('detects corrupt CBOR', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - // Replace receipt.cbor with garbage - const garbageBlob = await persistence.writeBlob(Buffer.from('not valid cbor')); - const newTree = await persistence.writeTree([`100644 blob ${garbageBlob}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); + it('detects a missing semantic receipt entry', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + const published = await commit(writer, 1); + auditLog.removeEntry(published.sha); + const result = await verifier(auditLog).verifyChain('events', 'alice'); expect(result.status).toBe('ERROR'); - expect(result.errors.some((e) => e.code === 'CBOR_DECODE_FAILED')).toBe(true); - }); - - it('detects trailer decode failure', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - mutateCommitMessage(persistence, (sha1), () => 'not an audit receipt'); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('DATA_MISMATCH'); - expect(result.errors.some((e) => e.code === 'TRAILER_MISMATCH')).toBe(true); - }); - - it('detects trailer schema mismatch with receipt metadata', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - mutateCommitMessage( - persistence, - (sha1), - (message) => message.replace(/eg-schema:\s*1/, 'eg-schema: 2'), - ); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('DATA_MISMATCH'); - expect(result.errors.some((e) => e.code === 'TRAILER_MISMATCH')).toBe(true); - }); -}); - -// ============================================================================ -// OID format validation -// ============================================================================ - -describe('AuditVerifierService — OID format', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects uppercase hex in dataCommit', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - // Tamper: uppercase the dataCommit - receipt['dataCommit'] = 'A'.repeat(40); - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - // OID validation normalizes to lowercase then checks hex format - // 'A'.repeat(40).toLowerCase() = 'a'.repeat(40) which IS valid hex - // So this passes OID validation but will fail trailer consistency - expect(result.errors.length).toBeGreaterThan(0); - }); - - it('detects non-hex characters in dataCommit', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - receipt['dataCommit'] = 'g'.repeat(40); - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'OID_FORMAT_INVALID')).toBe(true); - }); - - it('detects wrong-length dataCommit', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - receipt['dataCommit'] = 'a'.repeat(32); - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'OID_FORMAT_INVALID')).toBe(true); + expect(result.errors[0]?.code).toBe('MISSING_RECEIPT_BLOB'); }); - it('detects invalid prevAuditCommit format', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - await mutateReceipt(persistence, (sha1), (receipt) => { - receipt['prevAuditCommit'] = 'g'.repeat(40); - }); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); + it('detects a broken causal parent link', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + await commit(writer, 1); + const second = await commit(writer, 2); + const entry = await auditLog.readEntry(second.sha); + auditLog.replaceEntry(second.sha, { ...entry, parents: [] }); + const result = await verifier(auditLog).verifyChain('events', 'alice'); expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'OID_FORMAT_INVALID')).toBe(true); - }); -}); - -// ============================================================================ -// Warnings -// ============================================================================ - -describe('AuditVerifierService — warnings', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('warns about tick gap', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - // Skip tick 2 — directly write tick 3 - await commitReceipt(service, 3); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('VALID'); - expect(result.warnings.some((w) => w.code === 'TICK_GAP')).toBe(true); - }); - - it('warns when tip moves during verification', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - - const verifier = createVerifier(persistence); - - // Monkey-patch readRef to simulate tip moving after first read - const originalReadRef = persistence.readRef.bind(persistence); - let callCount = 0; - persistence.readRef = async (ref) => { - callCount++; - if (callCount === 1) { - // First call: return current tip - const tip = await originalReadRef(ref); - // Now write another receipt to advance the tip - await commitReceipt(service, 2); - return tip; - } - return await originalReadRef(ref); - }; - - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.warnings.some((w) => w.code === 'TIP_MOVED_DURING_VERIFY')).toBe(true); - }); -}); - -// ============================================================================ -// verifyAll — multiple writers -// ============================================================================ - -describe('AuditVerifierService — verifyAll', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('aggregates results for multiple writers', async () => { - const alice = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(alice, 1, undefined, 'alice'); - await commitReceipt(alice, 2, undefined, 'alice'); - - const bob = await createAuditService(persistence, 'events', 'bob'); - await commitReceipt(bob, 1, 'b'.repeat(40), 'bob'); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyAll('events'); - - expect(result.summary.total).toBe(2); - expect(result.summary.valid).toBe(2); - expect(result.chains).toHaveLength(2); - expect(result.chains.map((c) => c.writerId).sort()).toEqual(['alice', 'bob']); - }); - - it('returns empty result when no audit refs exist', async () => { - const verifier = createVerifier(persistence); - const result = await verifier.verifyAll('events'); - - expect(result.summary.total).toBe(0); - expect(result.chains).toEqual([]); - }); - - it('trustWarning is null when no trust config is present', async () => { - const verifier = createVerifier(persistence); - const result = await verifier.verifyAll('events'); - - expect(result.trustWarning).toBeNull(); - }); -}); - -// ============================================================================ -// Writer consistency -// ============================================================================ - -describe('AuditVerifierService — writer/graph consistency', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects writer ID mismatch within chain', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1, undefined, 'alice'); - - // Tamper: change writerId in receipt of sha1 - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - receipt['writerId'] = 'mallory'; - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - expect(result.errors.length).toBeGreaterThan(0); }); - it('detects writer mismatch against the requested writer when trailers agree', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1, undefined, 'alice'); - - const receipt = await mutateReceipt(persistence, (sha1), (receipt) => { - receipt['writerId'] = 'mallory'; + it('detects receipt/trailer mismatch without inspecting Git trees', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + const published = await commit(writer, 1); + const entry = await auditLog.readEntry(published.sha); + const decoded = defaultCodec.decode>(entry.receipt); + auditLog.replaceEntry(published.sha, { + ...entry, + receipt: defaultCodec.encode({ ...decoded, dataCommit: 'e'.repeat(40) }), }); - mutateCommitMessage( - persistence, - (sha1), - () => encodeAuditMessage({ - graph: (receipt['graphName'] as string), - writer: (receipt['writerId'] as string), - dataCommit: (receipt['dataCommit'] as string), - opsDigest: (receipt['opsDigest'] as string), - }), - ); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'WRITER_CONSISTENCY')).toBe(true); - }); - - it('detects graph mismatch against the requested graph when trailers agree', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1, undefined, 'alice'); - - const receipt = await mutateReceipt(persistence, (sha1), (receipt) => { - receipt['graphName'] = 'other-events'; - }); - mutateCommitMessage( - persistence, - (sha1), - () => encodeAuditMessage({ - graph: (receipt['graphName'] as string), - writer: (receipt['writerId'] as string), - dataCommit: (receipt['dataCommit'] as string), - opsDigest: (receipt['opsDigest'] as string), - }), - ); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'WRITER_CONSISTENCY')).toBe(true); - }); - - it('detects writer changes between linked receipts', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1, undefined, 'alice'); - await commitReceipt(service, 2, undefined, 'alice'); - - const receipt = await mutateReceipt(persistence, (sha1), (receipt) => { - receipt['writerId'] = 'mallory'; - }); - mutateCommitMessage( - persistence, - (sha1), - () => encodeAuditMessage({ - graph: (receipt['graphName'] as string), - writer: (receipt['writerId'] as string), - dataCommit: (receipt['dataCommit'] as string), - opsDigest: (receipt['opsDigest'] as string), - }), - ); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'WRITER_CONSISTENCY')).toBe(true); - }); - - it('detects graph changes between linked receipts', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1, undefined, 'alice'); - await commitReceipt(service, 2, undefined, 'alice'); - - const receipt = await mutateReceipt(persistence, (sha1), (receipt) => { - receipt['graphName'] = 'other-events'; - }); - mutateCommitMessage( - persistence, - (sha1), - () => encodeAuditMessage({ - graph: (receipt['graphName'] as string), - writer: (receipt['writerId'] as string), - dataCommit: (receipt['dataCommit'] as string), - opsDigest: (receipt['opsDigest'] as string), - }), - ); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('BROKEN_CHAIN'); - expect(result.errors.some((e) => e.code === 'WRITER_CONSISTENCY')).toBe(true); - }); -}); - -// ============================================================================ -// Schema validation -// ============================================================================ - -describe('AuditVerifierService — schema validation', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects missing receipt fields', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - // Replace receipt with incomplete object - const incomplete = { version: 1, graphName: 'events' }; - const cborBytes = defaultCodec.encode(incomplete); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.errors.some((e) => e.code === 'RECEIPT_SCHEMA_INVALID')).toBe(true); - }); - - it('detects unsupported receipt version', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - receipt['version'] = 99; - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.errors.some((e) => e.code === 'RECEIPT_SCHEMA_INVALID')).toBe(true); - }); - - it('detects non-object receipts', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const blobOid = await persistence.writeBlob(Buffer.from(defaultCodec.encode('not-an-object'))); - commit.treeOid = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.errors.some((e) => e.code === 'RECEIPT_SCHEMA_INVALID')).toBe(true); + const result = await verifier(auditLog).verifyChain('events', 'alice'); + expect(result.status).toBe('DATA_MISMATCH'); + expect(result.errors[0]?.code).toBe('TRAILER_MISMATCH'); }); - for (const [name, mutate] of (([ - ['detects missing required fields with a full 9-field object', (receipt) => { delete receipt['writerId']; receipt['extra'] = 'filler'; }], - ['detects empty graphName', (receipt) => { receipt['graphName'] = ''; }], - ['detects empty writerId', (receipt) => { receipt['writerId'] = ''; }], - ['detects non-string dataCommit', (receipt) => { receipt['dataCommit'] = 42; }], - ['detects non-string opsDigest', (receipt) => { receipt['opsDigest'] = 42; }], - ['detects non-string prevAuditCommit', (receipt) => { receipt['prevAuditCommit'] = 42; }], - ['detects tickStart below 1', (receipt) => { receipt['tickStart'] = 0; }], - ['detects tickEnd below tickStart', (receipt) => { receipt['tickEnd'] = 0; }], - ['detects v1 receipts with tickStart != tickEnd', (receipt) => { receipt['tickEnd'] = 2; }], - ['detects negative timestamps', (receipt) => { receipt['timestamp'] = -1; }], - ]) as Array<[string, (receipt: Record) => void]>)) { - it(name, async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - await mutateReceipt(persistence, (sha1), (mutate)); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.errors.some((e) => e.code === 'RECEIPT_SCHEMA_INVALID')).toBe(true); + it('detects corrupt receipt bytes as a schema read failure', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + const writer = await auditService(auditLog); + const published = await commit(writer, 1); + const entry = await auditLog.readEntry(published.sha); + auditLog.replaceEntry(published.sha, { + ...entry, + receipt: new Uint8Array([0xff, 0xfe, 0xfd]), }); - } -}); - -// ============================================================================ -// OID length consistency -// ============================================================================ - -describe('AuditVerifierService — OID length mismatch', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects OID length change between receipts', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - const sha2 = await commitReceipt(service, 2); - - // Tamper: change sha2's receipt to use 64-char OIDs - const commit = ((persistence) as any)['_commits'].get((sha2)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - receipt['dataCommit'] = 'a'.repeat(64); - receipt['prevAuditCommit'] = receipt['prevAuditCommit'].padEnd(64, '0'); - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); + const result = await verifier(auditLog).verifyChain('events', 'alice'); + expect(result.status).toBe('ERROR'); expect(result.errors.length).toBeGreaterThan(0); }); - it('detects prevAuditCommit length != dataCommit length', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (commit) { - const tree = await persistence.readTree(commit.treeOid); - const receipt = (defaultCodec.decode((tree['receipt.cbor'] as Uint8Array)) as Record); - // dataCommit is 40 chars, make prevAuditCommit 64 chars - receipt['prevAuditCommit'] = '0'.repeat(64); - const cborBytes = defaultCodec.encode(receipt); - const blobOid = await persistence.writeBlob(Buffer.from(cborBytes)); - const newTree = await persistence.writeTree([`100644 blob ${blobOid}\treceipt.cbor`]); - commit.treeOid = newTree; - } - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.errors.some((e) => e.code === 'OID_LENGTH_MISMATCH')).toBe(true); - }); -}); - -// ============================================================================ -// trustWarning — CLI-injected trust detection -// ============================================================================ - -describe('AuditVerifierService — trustWarning', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('passes through CLI-injected trustWarning', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - - const warning = { - code: 'TRUST_CONFIG_PRESENT_UNENFORCED', - message: 'Deprecated WARP_TRUSTED_ROOT trust config detected; use signed trust records or --trust-pin', - sources: ['env'], - }; - - const verifier = createVerifier(persistence); - const result = await verifier.verifyAll('events', { trustWarning: warning }); - expect(result.trustWarning).toEqual(warning); - }); -}); - -describe('AuditVerifierService — evaluateTrust', () => { - it('returns error/fail when trust-chain read fails', async () => { - const persistence = new InMemoryGraphAdapter(); - const verifier = createFailingTrustVerifier(persistence, new Error('trust storage unavailable')); - const result = await verifier.evaluateTrust('events'); - - expect(result.trust.status).toBe('error'); - expect(result.trust.source).toBe('ref'); - expect(result.trustVerdict).toBe('fail'); - expect(result.trust.explanations).toHaveLength(1); - expect(result.trust.explanations[0]?.reasonCode).toBe('TRUST_RECORD_CHAIN_INVALID'); - expect(result.trust.explanations[0]?.reason).toContain('trust storage unavailable'); - }); - - it('returns not_configured when no trust records exist', async () => { - const persistence = new InMemoryGraphAdapter(); - const verifier = createTrustVerifier(persistence, []); - const result = await verifier.evaluateTrust('events'); + it('aggregates multiple writer chains and passes through trust warnings', async () => { + const auditLog = new InMemoryAuditLogAdapter(); + await commit(await auditService(auditLog, 'alice'), 1, 'alice'); + await commit(await auditService(auditLog, 'bob'), 1, 'bob'); + const warning = { code: 'TRUST_UNAVAILABLE', message: 'offline', sources: ['ref'] }; - expect(result.trustVerdict).toBe('not_configured'); - expect(result.trust.status).toBe('not_configured'); - expect(result.trust.explanations).toEqual([]); - }); - - it('verifies signed trust records end-to-end', async () => { - const persistence = new InMemoryGraphAdapter(); - const verifier = createTrustVerifier(persistence, [KEY_ADD_1, KEY_ADD_2, WRITER_BIND_ADD_ALICE]); - const result = await verifier.evaluateTrust('events', { - mode: 'enforce', - writerIds: ['alice'], + const result = await verifier(auditLog).verifyAll('events', { + trustWarning: warning, + verifiedAt: '2026-01-01T00:00:00.000Z', }); - - expect(result.trustVerdict).toBe('pass'); - expect(result.trust.explanations).toEqual([ - expect.objectContaining({ - writerId: 'alice', - trusted: true, - reasonCode: 'WRITER_BOUND_TO_ACTIVE_KEY', - }), - ]); + expect(result.summary).toEqual({ total: 2, valid: 2, partial: 0, invalid: 0 }); + expect(result.trustWarning).toEqual(warning); }); - it('fails closed when a trust record signature is tampered', async () => { - const persistence = new InMemoryGraphAdapter(); - // Tampered record: signaturePayload is from original, but sig bytes are zeroed - const tampered = TrustRecord.fromDecoded({ - schemaVersion: KEY_ADD_2.schemaVersion, - recordType: KEY_ADD_2.recordType, - recordId: KEY_ADD_2.recordId, - issuerKeyId: KEY_ADD_2.issuerKeyId, - issuedAt: KEY_ADD_2.issuedAt, - prev: KEY_ADD_2.prev, - subject: (KEY_ADD_2.subject as any), - meta: (KEY_ADD_2.meta as any), - signature: { alg: 'ed25519', sig: Buffer.alloc(64, 0).toString('base64') }, - signaturePayload: KEY_ADD_2.signaturePayload, - }); - - const verifier = createTrustVerifier(persistence, [KEY_ADD_1, tampered, WRITER_BIND_ADD_ALICE]); - const result = await verifier.evaluateTrust('events', { - mode: 'enforce', - writerIds: ['alice'], + it('fails trust evaluation closed when the trust record chain cannot be read', async () => { + const trustChain = new MockTrustChainPort(); + trustChain.failWith(new Error('trust storage unavailable')); + const subject = new AuditVerifierService({ + auditLog: new InMemoryAuditLogAdapter(), + codec: defaultCodec, + trustChain, + trustCrypto: defaultTrustCrypto, }); + const result = await subject.evaluateTrust('events'); expect(result.trustVerdict).toBe('fail'); expect(result.trust.status).toBe('error'); - expect(result.trust.explanations[0]?.reasonCode).toBe('TRUST_RECORD_CHAIN_INVALID'); - expect(result.trust.explanations[0]?.reason).toContain('Trust evidence invalid'); - }); - - it('defaults trust policy mode to warn when mode is omitted', async () => { - const persistence = new InMemoryGraphAdapter(); - const verifier = createTrustVerifier(persistence, [KEY_ADD_1, KEY_ADD_2, WRITER_BIND_ADD_ALICE]); - const result = await verifier.evaluateTrust('events', { - writerIds: ['alice'], - }); - - expect(result.mode).toBe('signed_evidence'); - expect(result.trust.status).toBe('configured'); - expect(result.trust.source).toBe('ref'); - expect(result.trustVerdict).toBe('pass'); - }); -}); - -describe('AuditVerifierService — storage failure paths', () => { - let persistence; - - beforeEach(() => { - persistence = new InMemoryGraphAdapter(); - }); - - it('detects unreadable commit metadata', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - persistence.getNodeInfo = async () => { - throw new Error('commit missing'); - }; - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('ERROR'); - expect(result.errors.some((e) => e.code === 'MISSING_RECEIPT_BLOB')).toBe(true); - }); - - it('detects unreadable commit tree', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - const originalGetCommitTree = persistence.getCommitTree.bind(persistence); - persistence.getCommitTree = async (commitSha) => { - if (commitSha === sha1) { - throw new Error('tree lookup failed'); - } - return originalGetCommitTree(commitSha); - }; - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('ERROR'); - expect(result.errors.some((e) => e.code === 'MISSING_RECEIPT_BLOB')).toBe(true); - }); - - it('detects unreadable tree entries', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - persistence.readTreeOids = async () => { - throw new Error('tree decode failed'); - }; - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('ERROR'); - expect(result.errors.some((e) => e.code === 'RECEIPT_TREE_INVALID')).toBe(true); - }); - - it('detects missing receipt blob entries even when the tree shape is otherwise correct', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - await commitReceipt(service, 1); - persistence.readTreeOids = (async () => ({ 'receipt.cbor': undefined }) as any); - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('ERROR'); - expect(result.errors.some((e) => e.code === 'MISSING_RECEIPT_BLOB')).toBe(true); - }); - - it('detects unreadable receipt blobs', async () => { - const service = await createAuditService(persistence, 'events', 'alice'); - const sha1 = await commitReceipt(service, 1); - const originalReadBlob = persistence.readBlob.bind(persistence); - const commit = ((persistence) as any)['_commits'].get((sha1)); - if (!commit) { - throw new Error('missing audit commit'); - } - const treeOids = await persistence.readTreeOids(commit.treeOid); - const receiptBlob = treeOids['receipt.cbor']; - persistence.readBlob = async (oid) => { - if (oid === receiptBlob) { - throw new Error('blob read failed'); - } - return originalReadBlob(oid); - }; - - const verifier = createVerifier(persistence); - const result = await verifier.verifyChain('events', 'alice'); - - expect(result.status).toBe('ERROR'); - expect(result.errors.some((e) => e.code === 'MISSING_RECEIPT_BLOB')).toBe(true); - }); -}); - -// ============================================================================ -// Domain purity — no process.env in src/domain/ -// ============================================================================ - -describe('Domain purity boundary', () => { - it('src/domain/ does not reference process.env', async () => { - const { execSync } = await import('node:child_process'); - const result = execSync( - 'grep -r "process\\.env" src/domain/ || true', - { encoding: 'utf8', cwd: new URL('../../../../', import.meta.url).pathname }, - ); - expect(result.trim()).toBe(''); }); }); diff --git a/test/unit/domain/services/BitmapIndexReader.chunked.test.ts b/test/unit/domain/services/BitmapIndexReader.chunked.test.ts index df481ae2..7e2773cc 100644 --- a/test/unit/domain/services/BitmapIndexReader.chunked.test.ts +++ b/test/unit/domain/services/BitmapIndexReader.chunked.test.ts @@ -24,7 +24,7 @@ describe('BitmapIndexReader chunked shard support', () => { const revChunk0 = await storage.writeBlob(defaultCodec.encode({ bb0001: encodeBitmap([0]) })); const revChunk1 = await storage.writeBlob(defaultCodec.encode({ bb0002: encodeBitmap([0]) })); - const reader = new BitmapIndexReader({ storage, codec: defaultCodec }); + const reader = new BitmapIndexReader({ indexStore: storage, codec: defaultCodec }); reader.setup({ 'meta_aa.chunk-000000.cbor': metaAaChunk0, 'meta_bb.chunk-000000.cbor': metaBbChunk0, diff --git a/test/unit/domain/services/BitmapIndexReader.test.ts b/test/unit/domain/services/BitmapIndexReader.test.ts index 41c4b9db..861f40e1 100644 --- a/test/unit/domain/services/BitmapIndexReader.test.ts +++ b/test/unit/domain/services/BitmapIndexReader.test.ts @@ -1,615 +1,127 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ShardCorruptionError, ShardLoadError } from '../../../../src/domain/errors/index.ts'; import BitmapIndexReader from '../../../../src/domain/services/index/BitmapIndexReader.ts'; -import BitmapIndexBuilder from '../../../../src/domain/services/index/BitmapIndexBuilder.ts'; -import { ShardLoadError, ShardCorruptionError } from '../../../../src/domain/errors/index.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; import { getRoaringBitmap32 } from '../../../../src/domain/utils/roaring.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import MockIndexStorage from '../../../helpers/MockIndexStorage.ts'; -/** - * Encodes an object as a CBOR Uint8Array using the domain codec. - * Mirrors the format written by BitmapIndexBuilder. - * @param {Record} data - * @returns {Uint8Array} - */ -const encodeShard = (data) => defaultCodec.encode(data); - -/** - * Encodes a bitmap shard where values are Uint8Array bitmap bytes. - * @param {Record} data - * @returns {Uint8Array} - */ -const encodeBitmapShard = (data) => defaultCodec.encode(data); - -/** - * Creates serialized bitmap bytes for a set of numeric IDs. - * @param {number[]} ids - * @returns {Uint8Array} - */ -const _makeBitmapBytes = (ids) => { +function bitmap(ids: number[]): Uint8Array { const RoaringBitmap32 = getRoaringBitmap32(); - const bm = new RoaringBitmap32(ids); - return new Uint8Array(bm.serialize(true)); -}; -void _makeBitmapBytes; + const value = new RoaringBitmap32(); + ids.forEach((id) => value.add(id)); + return new Uint8Array(value.serialize(true)); +} -function createMockLogger() { - const logger = { +function logger() { + const value = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: vi.fn(), }; - logger.child.mockReturnValue(logger); - return logger; + value.child.mockReturnValue(value); + return value; } -describe('BitmapIndexReader', () => { - let mockStorage; - let reader; +describe('BitmapIndexReader bounded shard reads', () => { + let storage: MockIndexStorage; + let reader: BitmapIndexReader; beforeEach(() => { - mockStorage = { - readBlob: vi.fn(), - }; - reader = new BitmapIndexReader(({ storage: mockStorage, codec: defaultCodec } as any)); - }); - - describe('constructor validation', () => { - it('throws when storage is not provided', () => { - expect(() => new BitmapIndexReader(({} as any))).toThrow('BitmapIndexReader requires a storage adapter'); - }); - - it('throws when called with no arguments', () => { - // @ts-expect-error — testing runtime guard for missing required options - expect(() => new BitmapIndexReader()).toThrow(); - }); - - it('uses default maxCachedShards of 100', () => { - const readerWithDefaults = new BitmapIndexReader(({ storage: mockStorage, codec: defaultCodec } as any)); - expect(readerWithDefaults.maxCachedShards).toBe(100); - }); - - it('accepts custom maxCachedShards', () => { - const readerWithCustom = new BitmapIndexReader(({ storage: mockStorage, maxCachedShards: 50, codec: defaultCodec } as any)); - expect(readerWithCustom.maxCachedShards).toBe(50); - }); + storage = new MockIndexStorage(); + reader = new BitmapIndexReader({ indexStore: storage, codec: defaultCodec }); }); - describe('OID validation in setup()', () => { - it('accepts valid hex OIDs', () => { - const validOids = { - 'meta_ab.cbor': 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', - 'shards_fwd_ab.cbor': 'f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5', - }; - reader.setup(validOids); - expect(reader.shardOids.size).toBe(2); - }); - - it('skips invalid OIDs in non-strict mode with warning', () => { - const warnSpy = vi.fn(); - const lenientReader = new BitmapIndexReader((({ - storage: mockStorage, - strict: false, - codec: defaultCodec, - logger: { warn: warnSpy, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, - }) as any)); - lenientReader.setup({ - 'meta_ab.cbor': 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', - 'meta_cd.cbor': 'not-a-valid-oid!!!', - }); - expect((lenientReader as any).shardOids.size).toBe(1); - expect((lenientReader as any).shardOids.has('meta_ab.cbor')).toBe(true); - expect((lenientReader as any).shardOids.has('meta_cd.cbor')).toBe(false); - expect(warnSpy).toHaveBeenCalledWith('Skipping shard with invalid OID', expect.objectContaining({ - shardPath: 'meta_cd.cbor', - reason: 'invalid_oid', - })); - }); - - it('throws ShardCorruptionError for invalid OIDs in strict mode', () => { - const strictReader = new BitmapIndexReader((({ - storage: mockStorage, - strict: true, - codec: defaultCodec, - }) as any)); - expect(() => strictReader.setup({ - 'meta_ab.cbor': 'not-valid-oid', - })).toThrow(ShardCorruptionError); - }); - - it('includes shard path and OID in strict mode error', () => { - const strictReader = new BitmapIndexReader((({ - storage: mockStorage, - strict: true, - codec: defaultCodec, - }) as any)); - try { - strictReader.setup({ 'meta_ab.cbor': 'bad!' }); - expect.fail('should have thrown'); - } catch (err) { - const e = (err); - expect(e).toBeInstanceOf(ShardCorruptionError); - expect((e as any).shardPath).toBe('meta_ab.cbor'); - expect((e as any).oid).toBe('bad!'); - expect((e as any).reason).toBe('invalid_oid'); - } - }); + it('requires a semantic index store and exposes bounded cache configuration', () => { + // @ts-expect-error Runtime guard for JavaScript callers. + expect(() => new BitmapIndexReader({ codec: defaultCodec })).toThrow(/storage adapter/); + expect(reader.maxCachedShards).toBe(100); + expect(new BitmapIndexReader({ + indexStore: storage, + codec: defaultCodec, + maxCachedShards: 4, + }).maxCachedShards).toBe(4); }); - describe('setup', () => { - it('stores shard OIDs for lazy loading', () => { - reader.setup({ 'meta_aa.cbor': 'a1b2c3d400000000000000000000000000000000', 'shards_fwd_aa.cbor': 'e5f6a7b800000000000000000000000000000000' }); - expect(reader.shardOids.size).toBe(2); - }); - - it('clears cache when called', () => { - ((reader)).loadedShards.set('test', {}); - reader.shardOids.set('test', 'aaaa'); - - reader.setup({}); - - expect(reader.shardOids.size).toBe(0); - expect(((reader)).loadedShards.size).toBe(0); - }); + it('resolves IDs and edges by opening only configured shard handles', async () => { + const metaAa = await storage.writeBlob(defaultCodec.encode({ aa0001: 0 })); + const metaBb = await storage.writeBlob(defaultCodec.encode({ bb0001: 1 })); + const forward = await storage.writeBlob(defaultCodec.encode({ aa0001: bitmap([1]) })); + const reverse = await storage.writeBlob(defaultCodec.encode({ bb0001: bitmap([0]) })); + reader.setup({ + 'meta_aa.cbor': metaAa, + 'meta_bb.cbor': metaBb, + 'shards_fwd_aa.cbor': forward, + 'shards_rev_bb.cbor': reverse, + }); + + await expect(reader.lookupId('aa0001')).resolves.toBe(0); + await expect(reader.getChildren('aa0001')).resolves.toEqual(['bb0001']); + await expect(reader.getParents('bb0001')).resolves.toEqual(['aa0001']); }); - describe('getParents / getChildren', () => { - it('returns empty array for unknown SHA', async () => { - reader.setup({}); - const parents = await reader.getParents('unknown'); - expect(parents).toEqual([]); - }); - - it('loads and decodes bitmap data', async () => { - // Build a real index - const builder = new BitmapIndexBuilder({ codec: defaultCodec }); - builder.addEdge('aabbccdd00000000000000000000000000000000', 'eeff00dd00000000000000000000000000000000'); - const tree = await builder.serialize(); + it('clears loaded state when configured with a new handle set', async () => { + const first = await storage.writeBlob(defaultCodec.encode({ aa0001: 0 })); + reader.setup({ 'meta_aa.cbor': first }); + await expect(reader.lookupId('aa0001')).resolves.toBe(0); - // Mock storage to return serialized data - mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { - if (oid === 'aaa1bbb200000000000000000000000000000000') return tree['meta_aa.cbor'] || tree['meta_ee.cbor']; - if (oid === 'bbb2ccc300000000000000000000000000000000') return tree['shards_rev_ee.cbor']; - return defaultCodec.encode({}); - }); - - reader.setup({ - 'meta_aa.cbor': 'aaa1bbb200000000000000000000000000000000', - 'meta_ee.cbor': 'aaa1bbb200000000000000000000000000000000', - 'shards_rev_ee.cbor': 'bbb2ccc300000000000000000000000000000000', - }); - - const parents = await reader.getParents('eeff00dd00000000000000000000000000000000'); - expect(parents).toContain('aabbccdd00000000000000000000000000000000'); - }); + const second = await storage.writeBlob(defaultCodec.encode({ aa0002: 2 })); + reader.setup({ 'meta_aa.cbor': second }); + await expect(reader.lookupId('aa0001')).resolves.toBeUndefined(); + await expect(reader.lookupId('aa0002')).resolves.toBe(2); }); - describe('lookupId', () => { - it('returns undefined for unknown SHA', async () => { - reader.setup({}); - const id = await reader.lookupId('unknown'); - expect(id).toBeUndefined(); - }); + it('rejects non-handle shard locators in strict mode', () => { + expect(() => reader.setup({ + // @ts-expect-error Runtime validation for untyped callers. + 'meta_aa.cbor': 'raw-object-id', + })).toThrow(ShardCorruptionError); }); - describe('corrupt shard recovery', () => { - it('throws ShardLoadError when shard OID points to non-existent blob', async () => { - mockStorage.readBlob.mockRejectedValue(new Error('object not found')); - - reader.setup({ - 'meta_ab.cbor': 'ccc3ddd400000000000000000000000000000000', - 'shards_rev_ab.cbor': 'ddd4eee500000000000000000000000000000000' - }); - - await expect(reader.getParents('abcd123400000000000000000000000000000000')).rejects.toThrow(ShardLoadError); - }); - - it('returns empty array when shard contains invalid CBOR (non-strict)', async () => { - const lenient = new BitmapIndexReader(({ storage: mockStorage, strict: false, codec: defaultCodec } as any)); - mockStorage.readBlob.mockResolvedValue(new Uint8Array([0xff, 0xfe, 0xfd])); // invalid CBOR bytes - - lenient.setup({ - 'meta_ab.cbor': 'eee5fff600000000000000000000000000000000', - 'shards_rev_ab.cbor': 'eee5fff600000000000000000000000000000000' - }); - - const parents = await lenient.getParents('abcd123400000000000000000000000000000000'); - expect(parents).toEqual([]); - }); - - it('returns empty array with warning when shard contains wrong data type (non-strict)', async () => { - const mockLogger = createMockLogger(); - const lenient = new BitmapIndexReader({ - storage: mockStorage, - strict: false, - codec: defaultCodec, - logger: mockLogger, - }); - // Valid CBOR but wrong structure (array instead of object) - mockStorage.readBlob.mockResolvedValue(defaultCodec.encode([1, 2, 3])); - - lenient.setup({ - 'shards_rev_ab.cbor': 'fff6aaa100000000000000000000000000000000' - }); - - const parents = await lenient.getParents('abcd123400000000000000000000000000000000'); - expect(parents).toEqual([]); - expect(mockLogger.warn).toHaveBeenCalledWith('Shard shape invalid', expect.objectContaining({ - operation: 'loadShard', - shardPath: 'shards_rev_ab.cbor', - reason: 'shard_not_object', - })); + it('skips non-handle locators with evidence in lenient mode', () => { + const log = logger(); + const lenient = new BitmapIndexReader({ + indexStore: storage, + codec: defaultCodec, + strict: false, + logger: log, }); - - it('rejects top-level byte-string shards before caching them (non-strict)', async () => { - const sha = 'abcd123400000000000000000000000000000000'; - const shardPath = 'shards_rev_ab.cbor'; - const mockLogger = createMockLogger(); - const lenient = new BitmapIndexReader({ - storage: mockStorage, - strict: false, - codec: defaultCodec, - logger: mockLogger, - }); - mockStorage.readBlob.mockResolvedValue(defaultCodec.encode(new Uint8Array([1, 2, 3]))); - - lenient.setup({ - [shardPath]: 'fff6aaa100000000000000000000000000000000', - }); - - await expect(lenient.getParents(sha)).resolves.toEqual([]); - await expect(lenient.getParents(sha)).resolves.toEqual([]); - expect(mockStorage.readBlob).toHaveBeenCalledTimes(2); - expect(mockLogger.warn).toHaveBeenCalledTimes(2); - expect(mockLogger.warn).toHaveBeenCalledWith('Shard shape invalid', expect.objectContaining({ - operation: 'loadShard', - shardPath, - reason: 'shard_not_object', - })); - }); - - it('throws ShardLoadError on storage failure but continues after', async () => { - // Build a real index for comparison - const builder = new BitmapIndexBuilder({ codec: defaultCodec }); - builder.addEdge('aabbccdd00000000000000000000000000000000', 'eeff00dd00000000000000000000000000000000'); - const tree = await builder.serialize(); - - let callCount = 0; - mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { - callCount++; - // First call fails, subsequent calls succeed - if (callCount === 1) { - throw new Error('transient failure'); - } - // Return real data for subsequent calls - if (oid === 'aaa1bbb200000000000000000000000000000000') return tree['meta_aa.cbor'] || tree['meta_ee.cbor']; - if (oid === 'bbb2ccc300000000000000000000000000000000') return tree['shards_rev_ee.cbor']; - return defaultCodec.encode({}); - }); - - reader.setup({ - 'meta_aa.cbor': 'aaa1bbb200000000000000000000000000000000', - 'meta_ee.cbor': 'aaa1bbb200000000000000000000000000000000', - 'shards_rev_ee.cbor': 'bbb2ccc300000000000000000000000000000000', - 'shards_rev_aa.cbor': 'eee5fff600000000000000000000000000000000' // This one fails - }); - - // First query hits storage error - should throw ShardLoadError - await expect(reader.getParents('aabbccdd00000000000000000000000000000000')).rejects.toThrow(ShardLoadError); - - // Reader should still be functional for other queries - // (the reader wasn't corrupted by the error) - expect(reader.shardOids.size).toBe(4); + lenient.setup({ + // @ts-expect-error Runtime validation for untyped callers. + 'meta_aa.cbor': 'raw-object-id', }); - it('in strict mode throws ShardCorruptionError on invalid CBOR', async () => { - const strictReader = new BitmapIndexReader(({ storage: mockStorage, strict: true, codec: defaultCodec } as any)); - mockStorage.readBlob.mockResolvedValue(new Uint8Array([0xff, 0xfe, 0xfd])); // invalid CBOR bytes - - strictReader.setup({ - 'shards_rev_ab.cbor': 'eee5fff600000000000000000000000000000000' - }); - - await expect(strictReader.getParents('abcd123400000000000000000000000000000000')).rejects.toThrow(ShardCorruptionError); - }); - - it('ShardLoadError contains cause and context', async () => { - const originalError = new Error('network timeout'); - mockStorage.readBlob.mockRejectedValue(originalError); - - reader.setup({ - 'meta_ef.cbor': 'ddeeff3300000000000000000000000000000000' - }); - - try { - await reader.lookupId('ef00567800000000000000000000000000000000'); - expect.fail('Should have thrown'); - } catch (/** @type {any} */ err) { - expect(err).toBeInstanceOf(ShardLoadError); - expect((err as any).code).toBe('SHARD_LOAD_ERROR'); - expect((err as any).shardPath).toBe('meta_ef.cbor'); - expect((err as any).oid).toBe('ddeeff3300000000000000000000000000000000'); - expect((err as any).cause).toBe(originalError); - } - }); - - it('non-strict mode returns empty but strict mode throws for same corruption', async () => { - // Valid CBOR encoding of a plain object — but reader treats it as a bitmap shard - // and requires values to be Uint8Array bitmap bytes. - const sha = 'abcd123400000000000000000000000000000000'; - const corruptBitmapData = defaultCodec.encode({ [sha]: 'not-a-bitmap' }); - const mockLogger = createMockLogger(); - - // Non-strict reader - const nonStrictReader = new BitmapIndexReader({ - storage: mockStorage, - strict: false, - codec: defaultCodec, - logger: mockLogger, - }); - mockStorage.readBlob.mockResolvedValue(corruptBitmapData); - nonStrictReader.setup({ 'shards_rev_ab.cbor': 'eee5fff600000000000000000000000000000000' }); - - const nonStrictResult = await nonStrictReader.getParents(sha); - expect(nonStrictResult).toEqual([]); // Graceful degradation - - expect(mockLogger.warn).toHaveBeenCalledWith('Bitmap value invalid', expect.objectContaining({ - operation: 'deserializeBitmap', - shardPath: 'shards_rev_ab.cbor', - reason: 'bitmap_value_not_bytes', - sha, - })); - - // Strict reader gets same data and rejects the non-byte bitmap value. - const strictReader = new BitmapIndexReader(({ storage: mockStorage, strict: true, codec: defaultCodec } as any)); - strictReader.setup({ 'shards_rev_ab.cbor': 'eee5fff600000000000000000000000000000000' }); - - await expect(strictReader.getParents(sha)).rejects.toThrow(ShardCorruptionError); - }); - - it('logs a warning on each CBOR decode error (no caching on failure)', async () => { - const mockLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const nonStrictReader = new BitmapIndexReader((({ - storage: mockStorage, - strict: false, - codec: defaultCodec, - logger: mockLogger, - }) as any)); - - // Return invalid CBOR bytes (decode failure) - mockStorage.readBlob.mockResolvedValue(new Uint8Array([0xff, 0xfe, 0xfd])); - - nonStrictReader.setup({ 'shards_rev_ab.cbor': 'aab1ccdd00000000000000000000000000000000' }); - - // First access - should log warning and return empty - const result1 = await nonStrictReader.getParents('abcd123400000000000000000000000000000000'); - expect(result1).toEqual([]); - expect(mockLogger.warn).toHaveBeenCalledTimes(1); - - // Second access to same shard - decode fails again (not cached), logs again - const result2 = await nonStrictReader.getParents('abcd123400000000000000000000000000000000'); - expect(result2).toEqual([]); - expect(mockLogger.warn).toHaveBeenCalledTimes(2); - expect(mockStorage.readBlob).toHaveBeenCalledTimes(2); - }); - - it('returns empty array when bitmap deserialization fails in non-strict mode', async () => { - const mockLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const lenientReader = new BitmapIndexReader((({ - storage: mockStorage, - strict: false, - codec: defaultCodec, - logger: mockLogger, - }) as any)); - const sha = 'abcd123400000000000000000000000000000000'; - // Meta shard: sha → id 1 - const metaShard = encodeShard({ [sha]: 1 }); - // Edge shard: sha → garbage bytes that fail roaring deserialization - const edgeShard = encodeBitmapShard({ [sha]: new Uint8Array(Buffer.from('definitely-not-a-roaring-bitmap')) }); - - mockStorage.readBlob.mockImplementation(async (/** @type {string} */ oid) => { - if (oid === '1111222200000000000000000000000000000000') { - return metaShard; - } - if (oid === '2222333300000000000000000000000000000000') { - return edgeShard; - } - throw new Error(`Unexpected oid: ${oid}`); - }); - - lenientReader.setup({ - 'meta_ab.cbor': '1111222200000000000000000000000000000000', - 'shards_fwd_ab.cbor': '2222333300000000000000000000000000000000', - }); - - const children = await lenientReader.getChildren(sha); - expect(children).toEqual([]); - expect(mockLogger.warn).toHaveBeenCalledWith('Bitmap deserialization failed', expect.objectContaining({ - shardPath: 'shards_fwd_ab.cbor', - })); - }); + expect(log.warn).toHaveBeenCalledWith( + 'Skipping shard with invalid handle', + expect.objectContaining({ shardPath: 'meta_aa.cbor', reason: 'invalid_handle' }), + ); }); - describe('LRU cache eviction', () => { - it('evicts least recently used shards when exceeding maxCachedShards', async () => { - // Create reader with small cache size - const smallCacheReader = new BitmapIndexReader((({ - storage: mockStorage, - maxCachedShards: 2, - codec: defaultCodec, - }) as any)); - - // Create valid CBOR shard data - const createValidShard = (/** @type {any} */ id) => defaultCodec.encode({ id }); - - mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { - return createValidShard(oid); - }); - - smallCacheReader.setup({ - 'meta_aa.cbor': 'aa00112200000000000000000000000000000000', - 'meta_bb.cbor': 'bb33445500000000000000000000000000000000', - 'meta_cc.cbor': 'cc66778800000000000000000000000000000000', - }); - - // Load first shard - await smallCacheReader.lookupId('aabbccdd00000000000000000000000000000000'); - expect((smallCacheReader as any).loadedShards.size).toBe(1); - expect(mockStorage.readBlob).toHaveBeenCalledTimes(1); - - // Load second shard - await smallCacheReader.lookupId('bbccddee00000000000000000000000000000000'); - expect((smallCacheReader as any).loadedShards.size).toBe(2); - expect(mockStorage.readBlob).toHaveBeenCalledTimes(2); - - // Load third shard - should evict first - await smallCacheReader.lookupId('ccddeeff00000000000000000000000000000000'); - expect((smallCacheReader as any).loadedShards.size).toBe(2); // Still 2 due to LRU eviction - - // First shard should be evicted, accessing it again should reload - await smallCacheReader.lookupId('aabbccdd00000000000000000000000000000000'); - expect(mockStorage.readBlob).toHaveBeenCalledTimes(4); // 3 + 1 reload - }); + it('reports missing assets as shard load failures', async () => { + reader.setup({ 'meta_aa.cbor': new AssetHandle('missing') }); - it('marks accessed shards as recently used', async () => { - const smallCacheReader = new BitmapIndexReader((({ - storage: mockStorage, - maxCachedShards: 2, - codec: defaultCodec, - }) as any)); - - const createValidShard = (/** @type {any} */ id) => defaultCodec.encode({ id }); - - mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { - return createValidShard(oid); - }); - - smallCacheReader.setup({ - 'meta_aa.cbor': 'aa00112200000000000000000000000000000000', - 'meta_bb.cbor': 'bb33445500000000000000000000000000000000', - 'meta_cc.cbor': 'cc66778800000000000000000000000000000000', - }); - - // Load first two shards - await smallCacheReader.lookupId('aabbccdd00000000000000000000000000000000'); // Load aa - await smallCacheReader.lookupId('bbccddee00000000000000000000000000000000'); // Load bb - - // Access 'aa' again to make it recently used - await smallCacheReader.lookupId('aabbccdd00000000000000000000000000000000'); - - // Load third shard - should evict 'bb' (now oldest) - await smallCacheReader.lookupId('ccddeeff00000000000000000000000000000000'); // Load cc - - // 'aa' should still be in cache (was recently used) - expect((smallCacheReader as any).loadedShards.has('meta_aa.cbor')).toBe(true); - // 'bb' should have been evicted - expect((smallCacheReader as any).loadedShards.has('meta_bb.cbor')).toBe(false); - // 'cc' should be in cache - expect((smallCacheReader as any).loadedShards.has('meta_cc.cbor')).toBe(true); - }); + await expect(reader.lookupId('aa0001')).rejects.toBeInstanceOf(ShardLoadError); }); - describe('internal edge cases', () => { - it('returns cached id-to-sha mapping without repopulating', async () => { - (reader)._idToShaCache = ['sha0']; - const result = await (reader)._buildIdToShaMapping(); - expect(result).toBe((reader)._idToShaCache); - }); - - it('warns when id-to-sha cache grows beyond the warning threshold', () => { - const warn = vi.fn(); - const noisyReader = new BitmapIndexReader((({ - storage: mockStorage, - codec: defaultCodec, - logger: { warn, info: vi.fn(), error: vi.fn(), debug: vi.fn() }, - }) as any)); - (noisyReader as any)._warnLargeIdCache(1_000_001); - expect(warn).toHaveBeenCalledWith('ID-to-SHA cache has high memory usage', expect.objectContaining({ - operation: '_buildIdToShaMapping', - entryCount: 1_000_001, - })); - }); - - it('wraps codec decode errors into ShardCorruptionError in strict mode', async () => { - const strictReader = new BitmapIndexReader(({ storage: mockStorage, strict: true, codec: defaultCodec } as any)); - const anyReader = (strictReader); - anyReader.setup({ 'meta_ab.cbor': '3333444400000000000000000000000000000000' }); - // Inject a codec that throws on decode - const fakeCodec = { - decode: vi.fn(() => { throw new RangeError('unexpected parse failure'); }), - }; - (anyReader as any)._codec = fakeCodec; - mockStorage.readBlob.mockResolvedValue(defaultCodec.encode({})); - - await expect((anyReader as any)._getOrLoadShard('meta_ab.cbor')).rejects.toThrow(ShardCorruptionError); - }); - }); - - describe('round-trip with BitmapIndexBuilder', () => { - it('correctly resolves parent/child edges from a builder-generated tree', async () => { - const builder = new BitmapIndexBuilder({ codec: defaultCodec }); - const parentSha = 'aaaa000000000000000000000000000000000000'; - const childSha = 'bbbb000000000000000000000000000000000000'; - builder.addEdge(parentSha, childSha); - const tree = await builder.serialize(); - - // Assign fake OIDs to tree entries - const oidMap = ({} as Record); - const blobMap = ({} as Record); - let counter = 0; - for (const [path, data] of Object.entries(tree)) { - const oid = String(counter).padStart(40, '0'); - counter++; - oidMap[path] = oid; - blobMap[oid] = data; - } - - mockStorage.readBlob.mockImplementation(async (/** @type {string} */ oid) => { - if (blobMap[oid]) { return blobMap[oid]; } - throw new Error(`Unknown OID: ${oid}`); - }); - - reader.setup(oidMap); - - const parents = await reader.getParents(childSha); - expect(parents).toContain(parentSha); - - const children = await reader.getChildren(parentSha); - expect(children).toContain(childSha); - }); - - it('uses custom codec when provided', async () => { - const decodeCalls = ([] as Uint8Array[]); - const spyCodec = { - encode: defaultCodec.encode.bind(defaultCodec), - decode: (/** @type {Uint8Array} */ buf) => { - decodeCalls.push(buf); - return defaultCodec.decode(buf); - }, - }; - - const customReader = new BitmapIndexReader((({ - storage: mockStorage, - codec: spyCodec, - }) as any)); - - mockStorage.readBlob.mockResolvedValue(defaultCodec.encode({ 'abcd123400000000000000000000000000000000': 7 })); - customReader.setup({ 'meta_ab.cbor': 'aabb112200000000000000000000000000000000' }); - - const id = await customReader.lookupId('abcd123400000000000000000000000000000000'); - expect(id).toBe(7); - expect(decodeCalls.length).toBe(1); - }); + it('fails closed on corrupt shard bytes and can degrade explicitly', async () => { + const corrupt = await storage.writeBlob(new Uint8Array([0xff, 0xfe, 0xfd])); + reader.setup({ 'meta_aa.cbor': corrupt }); + await expect(reader.lookupId('aa0001')).rejects.toBeInstanceOf(ShardCorruptionError); + + const log = logger(); + const lenient = new BitmapIndexReader({ + indexStore: storage, + codec: defaultCodec, + strict: false, + logger: log, + }); + lenient.setup({ 'meta_aa.cbor': corrupt }); + await expect(lenient.lookupId('aa0001')).resolves.toBeUndefined(); + expect(log.warn).toHaveBeenCalledWith( + 'Shard decode failed', + expect.objectContaining({ shardPath: 'meta_aa.cbor' }), + ); }); }); diff --git a/test/unit/domain/services/CheckpointService.anchors.test.ts b/test/unit/domain/services/CheckpointService.anchors.test.ts deleted file mode 100644 index 50d0f407..00000000 --- a/test/unit/domain/services/CheckpointService.anchors.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { createCheckpointEnvelope, type CheckpointPersistence } from '../../../../src/domain/services/state/checkpointCreate.ts'; -import { createFrontier, updateFrontier } from '../../../../src/domain/services/Frontier.ts'; -import { createEmptyState, encodePropKey as encodePropKeyV5 } from '../../../../src/domain/services/JoinReducer.ts'; -import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import { CONTENT_PROPERTY_KEY } from '../../../../src/domain/services/KeyCodec.ts'; -import type { ContentAnchorObjectType } from '../../../../src/domain/services/state/checkpointHelpers.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import type WarpStream from '../../../../src/domain/stream/WarpStream.ts'; -import { createFakeCodecPort, createMockCrypto } from '../../../helpers/mockPorts.ts'; -import type { - CommitLogChunk, - CommitNodeOptions, - CommitNodeWithTreeOptions, - LogNodesOptions, - NodeInfo, - PingResult, -} from '../../../../src/ports/CommitPort.ts'; - -const codec = createFakeCodecPort(); -const crypto = createMockCrypto(); - -function makeOid(prefix: string): string { - const base = prefix.replace(/[^0-9a-f]/gi, '0').toLowerCase(); - return (base + '0'.repeat(40)).slice(0, 40); -} - -function makeSequentialOid(index: number): string { - return index.toString(16).padStart(40, '0'); -} - -class AnchorCheckpointPersistenceError extends Error { - constructor(message: string) { - super(message); - this.name = 'AnchorCheckpointPersistenceError'; - } -} - -class AnchorCheckpointPersistence implements CheckpointPersistence { - readonly writtenTrees: string[][] = []; - private readonly _objectTypes = new Map(); - private _blobIndex = 0; - - get emptyTree(): string { - return makeOid('empty'); - } - - setObjectType(oid: string, objectType: ContentAnchorObjectType): void { - this._objectTypes.set(oid, objectType); - } - - envelopeTreeEntries(): readonly string[] { - const entries = this.writtenTrees[1]; - if (entries === undefined) { - throw new AnchorCheckpointPersistenceError('Missing checkpoint envelope tree write'); - } - return entries; - } - - async writeBlob(_content: Uint8Array | string): Promise { - this._blobIndex += 1; - return makeOid(`blob${this._blobIndex}`); - } - - async readBlob(_oid: string): Promise { - throw new AnchorCheckpointPersistenceError('readBlob is not used by anchor creation tests'); - } - - async writeTree(entries: string[]): Promise { - this.writtenTrees.push([...entries]); - return makeOid(`tree${this.writtenTrees.length}`); - } - - async readTree(_treeOid: string): Promise> { - throw new AnchorCheckpointPersistenceError('readTree is not used by anchor creation tests'); - } - - async readTreeOids(_treeOid: string): Promise> { - throw new AnchorCheckpointPersistenceError('readTreeOids is not used by anchor creation tests'); - } - - async commitNode(_options: CommitNodeOptions): Promise { - throw new AnchorCheckpointPersistenceError('commitNode is not used by anchor creation tests'); - } - - async commitNodeWithTree(_options: CommitNodeWithTreeOptions): Promise { - return makeOid('checkpoint'); - } - - async showNode(_sha: string): Promise { - throw new AnchorCheckpointPersistenceError('showNode is not used by anchor creation tests'); - } - - async getNodeInfo(_sha: string): Promise { - throw new AnchorCheckpointPersistenceError('getNodeInfo is not used by anchor creation tests'); - } - - async logNodes(_options: LogNodesOptions): Promise { - throw new AnchorCheckpointPersistenceError('logNodes is not used by anchor creation tests'); - } - - async logNodesStream(_options: LogNodesOptions): Promise> { - throw new AnchorCheckpointPersistenceError('logNodesStream is not used by anchor creation tests'); - } - - async countNodes(_ref: string): Promise { - throw new AnchorCheckpointPersistenceError('countNodes is not used by anchor creation tests'); - } - - async nodeExists(_sha: string): Promise { - throw new AnchorCheckpointPersistenceError('nodeExists is not used by anchor creation tests'); - } - - async getCommitTree(_sha: string): Promise { - throw new AnchorCheckpointPersistenceError('getCommitTree is not used by anchor creation tests'); - } - - async ping(): Promise { - throw new AnchorCheckpointPersistenceError('ping is not used by anchor creation tests'); - } - - async readObjectType(oid: string): Promise { - return this._objectTypes.get(oid) ?? 'tree'; - } -} - -describe('CheckpointService content anchors', () => { - it('preserves legacy raw blob content anchors when creating a checkpoint', async () => { - const persistence = new AnchorCheckpointPersistence(); - const state = createEmptyState(); - state.nodeAlive.add('legacy', Dot.create('alice', 1)); - state.nodeAlive.add('cas', Dot.create('alice', 2)); - - const legacyBlobOid = makeSequentialOid(1); - const casTreeOid = makeSequentialOid(2); - persistence.setObjectType(legacyBlobOid, 'blob'); - persistence.setObjectType(casTreeOid, 'tree'); - - state.mutatePropRegisterLWW(encodePropKeyV5('legacy', CONTENT_PROPERTY_KEY), { - eventId: { lamport: 1, writerId: 'alice', patchSha: makeOid('patch1'), opIndex: 0 }, - value: legacyBlobOid, - }); - state.mutatePropRegisterLWW(encodePropKeyV5('cas', CONTENT_PROPERTY_KEY), { - eventId: { lamport: 2, writerId: 'alice', patchSha: makeOid('patch2'), opIndex: 0 }, - value: casTreeOid, - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - await createCheckpointEnvelope({ - persistence, - graphName: 'test', - state, - frontier, - crypto, - codec, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - expect(persistence.envelopeTreeEntries()).toEqual([ - `100644 blob ${legacyBlobOid}\t_content_${legacyBlobOid}`, - `040000 tree ${casTreeOid}\t_content_${casTreeOid}`, - expect.stringContaining('\tappliedVV.cbor'), - expect.stringContaining('\tfrontier.cbor'), - expect.stringContaining('\tstate'), - ]); - }); -}); diff --git a/test/unit/domain/services/CheckpointService.edgeCases.test.ts b/test/unit/domain/services/CheckpointService.edgeCases.test.ts index dffbc6af..98fa9dfa 100644 --- a/test/unit/domain/services/CheckpointService.edgeCases.test.ts +++ b/test/unit/domain/services/CheckpointService.edgeCases.test.ts @@ -1,919 +1,101 @@ -/** - * Edge-case tests for CheckpointService. - * - * Covers validation boundaries, schema mismatches, empty states, - * and unusual but valid inputs that the main test file does not exercise. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - create as createCheckpoint, - type CreateCheckpointOptions, -} from '../../../../src/domain/services/state/checkpointCreate.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import { createEmptyState } from '../../../../src/domain/services/JoinReducer.ts'; +import StateHashService from '../../../../src/domain/services/state/StateHashService.ts'; +import { createCheckpointEnvelope } from '../../../../src/domain/services/state/checkpointCreate.ts'; import { - loadCheckpoint as loadCheckpointWithCodec, - materializeIncremental as materializeIncrementalWithCodec, + loadCheckpoint, + materializeIncremental, reconstructStateFromCheckpoint, - type LoadCheckpointOptions, - type LoadPersistence, - type MaterializeIncrementalOptions, } from '../../../../src/domain/services/state/checkpointLoad.ts'; -import { CURRENT_CHECKPOINT_SCHEMA } from '../../../../src/domain/services/state/checkpointHelpers.ts'; -import { - createFrontier, - updateFrontier, - serializeFrontier, -} from '../../../../src/domain/services/Frontier.ts'; -import { - deserializeFullState, - serializeCheckpointStateEnvelope, - deserializeCheckpointStateEnvelope, - computeAppliedVV, - serializeAppliedVV, -} from '../../../../src/domain/services/state/CheckpointSerializer.ts'; -import { computeStateHash } from '../../../../src/domain/services/state/StateSerializer.ts'; -import { - createEmptyState, - encodeEdgeKey as encodeEdgeKeyV5, - encodePropKey as encodePropKeyV5, -} from '../../../../src/domain/services/JoinReducer.ts'; -import { - DEFAULT_COMMIT_MESSAGE_CODEC, - encodeCheckpointMessage, - decodeCheckpointMessage, -} from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { Dot, encodeDot } from '../../../../src/domain/crdt/Dot.ts'; -import { ProvenanceIndex } from '../../../../src/domain/services/provenance/ProvenanceIndex.ts'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryCheckpointStore from '../../../helpers/InMemoryCheckpointStore.ts'; -const crypto = new NodeCryptoAdapter(); - -type CreateCheckpointTestOptions = - Omit & - Partial>; - -type LoadCheckpointTestOptions = - Omit & - Partial>; - -type MaterializeIncrementalTestOptions = - Omit & - Partial>; - -async function create(options: CreateCheckpointTestOptions): ReturnType { - return await createCheckpoint({ - ...options, - codec: options.codec ?? defaultCodec, - commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, - }); -} - -async function loadCheckpoint( - persistence: LoadPersistence, - checkpointSha: string, - options: LoadCheckpointTestOptions = {}, -): ReturnType { - return await loadCheckpointWithCodec(persistence, checkpointSha, { - ...options, - codec: options.codec ?? defaultCodec, - commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, - }); -} - -async function materializeIncremental( - options: MaterializeIncrementalTestOptions, -): ReturnType { - return await materializeIncrementalWithCodec({ - ...options, - codec: options.codec ?? defaultCodec, - commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, - }); -} - -/** Creates a valid 40-char hex OID for testing. */ -const makeOid = (prefix) => { - const base = prefix.replace(/[^0-9a-f]/gi, '0').toLowerCase(); - return (base + '0'.repeat(40)).slice(0, 40); -}; - -function installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - appliedVV = computeAppliedVV(state), - provenanceIndex = undefined, - includeAppliedVV = true, - indexShardOids = {}, -}: { - mockPersistence: any; - state: ReturnType; - frontier: Map; - stateHash: string; - appliedVV?: ReturnType; - provenanceIndex?: ProvenanceIndex; - includeAppliedVV?: boolean; - indexShardOids?: Record; -}) { - const frontierOid = makeOid('frontier'); - const appliedVVOid = makeOid('appliedvv'); - const stateEnvelope = serializeCheckpointStateEnvelope(state, { codec: defaultCodec }); - const blobMap = new Map([ - [makeOid('nodealive'), stateEnvelope.nodeAlive], - [makeOid('edgealive'), stateEnvelope.edgeAlive], - [makeOid('prop'), stateEnvelope.prop], - [makeOid('observed'), stateEnvelope.observedFrontier], - [makeOid('edgebirth'), stateEnvelope.edgeBirthEvent], - [frontierOid, serializeFrontier(frontier, { codec: defaultCodec })], - ]); - if (includeAppliedVV) { - blobMap.set(appliedVVOid, serializeAppliedVV(appliedVV, { codec: defaultCodec })); - } - if (provenanceIndex !== undefined) { - blobMap.set(makeOid('provenance'), provenanceIndex.serialize({ codec: defaultCodec })); - } - - mockPersistence.showNode.mockResolvedValue(encodeCheckpointMessage({ - graph: 'test', - stateHash, - frontierOid, - indexOid: makeOid('envelope'), - schema: 5, - })); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state': makeOid('state-tree'), - 'state/nodeAlive': makeOid('nodealive'), - 'state/edgeAlive': makeOid('edgealive'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - 'frontier.cbor': frontierOid, - ...(includeAppliedVV ? { 'appliedVV.cbor': appliedVVOid } : {}), - ...(provenanceIndex !== undefined ? { 'provenanceIndex.cbor': makeOid('provenance') } : {}), - ...indexShardOids, - }); - mockPersistence.readBlob.mockImplementation((oid) => { - const blob = blobMap.get(oid); - if (blob !== undefined) { - return Promise.resolve(blob); - } - throw new Error(`Unknown oid: ${oid}`); - }); -} - -function envelopeFromCreateBlobs(blobs: Uint8Array[]) { - return deserializeCheckpointStateEnvelope({ - nodeAlive: requireBlobAt(blobs, 0), - edgeAlive: requireBlobAt(blobs, 1), - prop: requireBlobAt(blobs, 2), - observedFrontier: requireBlobAt(blobs, 3), - edgeBirthEvent: requireBlobAt(blobs, 4), - }, { codec: defaultCodec }); -} - -function requireBlobAt(blobs: Uint8Array[], index: number): Uint8Array { - const blob = blobs[index]; - if (blob === undefined) { - throw new Error(`Missing checkpoint envelope blob at index ${index}`); - } - return blob; -} - -function splitTreeEntry(entry: string): { oid: string; path: string } { - const [left, path] = entry.split('\t'); - if (left === undefined || path === undefined) { - throw new Error(`Invalid tree entry: ${entry}`); - } - const oid = left.split(' ')[2]; - if (oid === undefined) { - throw new Error(`Invalid tree oid entry: ${entry}`); - } - return { oid, path }; -} - -describe('CheckpointService edge cases', () => { - let mockPersistence: any; - - beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn(), - writeTree: vi.fn(), - readBlob: vi.fn(), - readTreeOids: vi.fn(), - commitNodeWithTree: vi.fn(), - showNode: vi.fn(), - getNodeInfo: vi.fn(), - }; - }); - - // -------------------------------------------------------------------------- - // Schema version validation - // -------------------------------------------------------------------------- - - describe('unsupported schema versions', () => { - it('rejects schema:2 with migration error in shipped runtime', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 2, - }); - - mockPersistence.showNode.mockResolvedValue(message); - - await expect( - loadCheckpoint(mockPersistence, makeOid('legacy2')) - ).rejects.toThrow(/schema:2/i); - expect(mockPersistence.readTreeOids).not.toHaveBeenCalled(); - }); - - it('rejects schema:3 with migration error in shipped runtime', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 3, - }); - - mockPersistence.showNode.mockResolvedValue(message); - - await expect( - loadCheckpoint(mockPersistence, makeOid('legacy3')) - ).rejects.toThrow(/schema:3/i); - expect(mockPersistence.readTreeOids).not.toHaveBeenCalled(); - }); - - it('rejects schema:4 with migration error in shipped runtime', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 4, - }); - - mockPersistence.showNode.mockResolvedValue(message); - - await expect( - loadCheckpoint(mockPersistence, makeOid('legacy4')) - ).rejects.toThrow(/schema:4/i); - expect(mockPersistence.readTreeOids).not.toHaveBeenCalled(); - }); - - it('loads schema:5 checkpoint envelopes', async () => { - const state = createEmptyState(); - state.nodeAlive.add('current', Dot.create('w1', 1)); - const frontier = createFrontier(); - updateFrontier(frontier, 'w1', makeOid('sha1')); - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - }); - - const result = await loadCheckpoint(mockPersistence, makeOid('current')); - expect(result.schema).toBe(5); - expect(result.state.nodeAlive.contains('current')).toBe(true); - }); - - it('rejects schema:99 with migration error', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 99, - }); - - mockPersistence.showNode.mockResolvedValue(message); - - await expect( - loadCheckpoint(mockPersistence, makeOid('futureschema')) - ).rejects.toThrow(/schema:99/); - }); - - it('rejects schema:3 before reading legacy state blobs', async () => { - const state = createEmptyState(); - const dot = Dot.create('w1', 1); - state.nodeAlive.add('x', dot); - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash, - frontierOid: makeOid('frontier'), - indexOid: makeOid('tree'), - schema: 3, - }); - - mockPersistence.showNode.mockResolvedValue(message); - - await expect(loadCheckpoint( - mockPersistence, - makeOid('checkpoint') - )).rejects.toThrow(/schema:3/i); - expect(mockPersistence.readTreeOids).not.toHaveBeenCalled(); - }); - }); - - // -------------------------------------------------------------------------- - // Empty state checkpoint - // -------------------------------------------------------------------------- - - describe('empty state checkpoint', () => { - it('roundtrips an empty state through create and load', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - - const writtenBlobs = new Map(); - let writtenMessage: any; - let stateTreeEntries: string[] = []; - let envelopeTreeEntries: string[] = []; - let blobIndex = 0; - - mockPersistence.writeBlob.mockImplementation( - (buffer) => { - const oid = makeOid(`blob${blobIndex++}`); - writtenBlobs.set(oid, buffer); - return Promise.resolve(oid); - } - ); - mockPersistence.writeTree - .mockImplementationOnce((entries) => { - stateTreeEntries = entries; - return Promise.resolve(makeOid('state-tree')); - }) - .mockImplementationOnce((entries) => { - envelopeTreeEntries = entries; - return Promise.resolve(makeOid('envelope')); - }); - mockPersistence.commitNodeWithTree.mockImplementation( - (/** @type {any} */ { message }) => { - writtenMessage = message; - return Promise.resolve(makeOid('checkpoint')); - } - ); - - await create({ - persistence: mockPersistence, - graphName: 'empty-graph', - state, - frontier, - crypto, - }); - - // Setup load mocks - mockPersistence.showNode.mockResolvedValue(writtenMessage); - const treeOids: Record = {}; - for (const entry of stateTreeEntries) { - const { oid, path } = splitTreeEntry(entry); - treeOids[`state/${path}`] = oid; - } - for (const entry of envelopeTreeEntries) { - const { oid, path } = splitTreeEntry(entry); - if (path !== 'state') { - treeOids[path] = oid; - } - } - mockPersistence.readTreeOids.mockResolvedValue(treeOids); - mockPersistence.readBlob.mockImplementation((oid) => { - const blob = writtenBlobs.get(oid); - if (blob !== undefined) { - return Promise.resolve(blob); - } - throw new Error(`Unknown oid: ${oid}`); - }); - - const loaded = await loadCheckpoint( - mockPersistence, - makeOid('checkpoint') - ); - - expect(loaded.schema).toBe(CURRENT_CHECKPOINT_SCHEMA); - expect(loaded.state.nodeAlive.elements()).toHaveLength(0); - expect(loaded.state.edgeAlive.elements()).toHaveLength(0); - expect(loaded.state.propSize()).toBe(0); - expect(loaded.frontier.size).toBe(0); - }); - }); - - // -------------------------------------------------------------------------- - // deserializeFullState edge cases - // -------------------------------------------------------------------------- - - describe('deserializeFullState edge cases', () => { - it('throws for null buffer', () => { - expect(() => deserializeFullState(null as never)) - .toThrow('Checkpoint state buffer is missing'); - }); - - it('throws for undefined buffer', () => { - expect(() => deserializeFullState(undefined as never)) - .toThrow('Checkpoint state buffer is missing'); - }); - - it('throws for wrong version string', () => { - // Encode a CBOR object with an unexpected version - const defaultCodecImport = import( - '../../../../src/infrastructure/codecs/CborCodec.ts' - ); - return defaultCodecImport.then(({ default: codec }) => { - const buf = codec.encode({ version: 'full-v99', nodeAlive: {} }); - expect(() => deserializeFullState(buf, { codec: defaultCodec })).toThrow( - /Unsupported full state version.*full-v99/ - ); - }); - }); - }); - - // -------------------------------------------------------------------------- - // Missing appliedVV.cbor (backward compatibility) - // -------------------------------------------------------------------------- - - describe('missing appliedVV.cbor', () => { - it('returns null appliedVV when blob is absent', async () => { - const state = createEmptyState(); - state.nodeAlive.add('a', Dot.create('w1', 1)); - - const frontier = createFrontier(); - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - includeAppliedVV: false, - }); - - const result = await loadCheckpoint( - mockPersistence, - makeOid('checkpoint') - ); - expect(result.appliedVV).toBeNull(); - expect(result.state.nodeAlive.entries.has('a')).toBe(true); - }); - }); - - // -------------------------------------------------------------------------- - // materializeIncremental edge cases - // -------------------------------------------------------------------------- - - describe('materializeIncremental edge cases', () => { - it('returns checkpoint state when target frontier matches checkpoint', async () => { - const state = createEmptyState(); - const dot = Dot.create('w1', 1); - state.nodeAlive.add('x', dot); - - const frontier = createFrontier(); - updateFrontier(frontier, 'w1', makeOid('sha1')); - - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - }); - - // patchLoader returns empty — no new patches since checkpoint - const patchLoader = vi.fn().mockResolvedValue([]); - - const result = await materializeIncremental({ - persistence: mockPersistence, - graphName: 'test', - checkpointSha: makeOid('checkpoint'), - targetFrontier: frontier, - patchLoader, - }); - - // Should return the checkpoint state unchanged - expect(result.nodeAlive.contains('x')).toBe(true); - expect(patchLoader).toHaveBeenCalledTimes(1); - }); - - it('returns checkpoint state when target frontier is empty', async () => { - const state = createEmptyState(); - state.nodeAlive.add('y', Dot.create('w1', 1)); - - const frontier = createFrontier(); - updateFrontier(frontier, 'w1', makeOid('sha1')); - - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - }); - - // Empty target frontier — no writers to catch up on - const emptyTargetFrontier = createFrontier(); - - const result = await materializeIncremental({ - persistence: mockPersistence, - graphName: 'test', - checkpointSha: makeOid('checkpoint'), - targetFrontier: emptyTargetFrontier, - patchLoader: vi.fn(), - }); - - // No patches loaded, returns checkpoint state as-is - expect(result.nodeAlive.contains('y')).toBe(true); - }); - - it('applies newly loaded patches on top of checkpoint state', async () => { - const state = createEmptyState(); - state.nodeAlive.add('base', Dot.create('w1', 1)); - - const checkpointFrontier = createFrontier(); - updateFrontier(checkpointFrontier, 'w1', makeOid('sha1')); - - const targetFrontier = createFrontier(); - updateFrontier(targetFrontier, 'w1', makeOid('sha1')); - updateFrontier(targetFrontier, 'w2', makeOid('sha2')); - - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier: checkpointFrontier, - stateHash, - }); - - const patchLoader = vi.fn(async () => [ - { - sha: makeOid('patch'), - patch: { - writer: 'w2', - lamport: 1, - ops: [ - { - type: 'NodeAdd', - node: 'new-node', - dot: Dot.create('w2', 1), - }, - ], - }, - }, - ]); - - const result = await materializeIncremental({ - persistence: mockPersistence, - graphName: 'test', - checkpointSha: makeOid('checkpoint'), - targetFrontier, - patchLoader: (patchLoader as any), - }); - - expect(result.nodeAlive.contains('base')).toBe(true); - expect(result.nodeAlive.contains('new-node')).toBe(true); - expect(patchLoader).toHaveBeenCalledWith('w1', makeOid('sha1'), makeOid('sha1')); - expect(patchLoader).toHaveBeenCalledWith('w2', null, makeOid('sha2')); - }); - }); - - // -------------------------------------------------------------------------- - // reconstructStateFromCheckpoint edge cases - // -------------------------------------------------------------------------- - - describe('reconstructStateFromCheckpoint edge cases', () => { - it('handles nodes with no edges', () => { - const state = reconstructStateFromCheckpoint({ - nodes: ['isolated1', 'isolated2'], - edges: [], - props: [], - }); - - expect(state.nodeAlive.contains('isolated1')).toBe(true); - expect(state.nodeAlive.contains('isolated2')).toBe(true); - expect(state.edgeAlive.elements()).toHaveLength(0); - }); - - it('handles nodes with properties but no edges', () => { - const state = reconstructStateFromCheckpoint({ - nodes: ['solo'], - edges: [], - props: [{ node: 'solo', key: 'name', value: 'alone' }], - }); - - expect(state.nodeAlive.contains('solo')).toBe(true); - const propKey = encodePropKeyV5('solo', 'name'); - expect(state.hasProp(propKey)).toBe(true); - expect((state.getEncodedProp(propKey as any))!.value).toBe('alone'); - }); - - it('uses synthetic dot for all elements (shared identity)', () => { - const state = reconstructStateFromCheckpoint({ - nodes: ['a', 'b'], - edges: [{ from: 'a', to: 'b', label: 'link' }], - props: [], - }); - - // Both nodes should share the same synthetic dot (__checkpoint__:1) - const dotsA = state.nodeAlive.entries.get('a'); - const dotsB = state.nodeAlive.entries.get('b'); - expect(dotsA).toBeDefined(); - expect(dotsB).toBeDefined(); - expect([...(dotsA as Set)]).toEqual([ - '__checkpoint__:1', - ]); - expect([...(dotsB as Set)]).toEqual([ - '__checkpoint__:1', - ]); - }); - - it('initializes edgeBirthEvent for reconstructed edges', () => { - const state = reconstructStateFromCheckpoint({ - nodes: ['x', 'y'], - edges: [{ from: 'x', to: 'y', label: 'rel' }], - props: [], - }); - - const edgeKey = encodeEdgeKeyV5('x', 'y', 'rel'); - expect(state.edgeBirthEvent.has(edgeKey)).toBe(true); - const birthEvent = state.edgeBirthEvent.get(edgeKey); - expect(birthEvent).toBeDefined(); - expect(birthEvent?.lamport).toBe(0); - }); - }); - - // -------------------------------------------------------------------------- - // Checkpoint schema constants - // -------------------------------------------------------------------------- - - describe('schema constants', () => { - it('CURRENT_CHECKPOINT_SCHEMA is the current schema', () => { - expect(CURRENT_CHECKPOINT_SCHEMA).toBe(5); - }); - }); - - // -------------------------------------------------------------------------- - // Compaction with empty state - // -------------------------------------------------------------------------- - - describe('compaction with empty state', () => { - it('compaction on empty state is a no-op', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - - const stateEnvelopeBlobs: Uint8Array[] = []; - mockPersistence.writeBlob.mockImplementation( - (buffer) => { - if (stateEnvelopeBlobs.length < 5) { - stateEnvelopeBlobs.push(buffer); - } - return Promise.resolve(makeOid('blob')); - } - ); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue( - makeOid('checkpoint') - ); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - compact: true, - crypto, - }); - - const restored = envelopeFromCreateBlobs(stateEnvelopeBlobs); - expect(restored.nodeAlive.elements()).toHaveLength(0); - expect(restored.edgeAlive.elements()).toHaveLength(0); - expect(restored.nodeAlive.tombstones.size).toBe(0); - expect(restored.edgeAlive.tombstones.size).toBe(0); - }); - }); - - // -------------------------------------------------------------------------- - // Checkpoint with tombstoned-only state (all nodes removed) - // -------------------------------------------------------------------------- - - describe('checkpoint with all-tombstoned state', () => { - it('compacts a fully-tombstoned state to empty', async () => { - const state = createEmptyState(); - const dot1 = Dot.create('w1', 1); - const dot2 = Dot.create('w1', 2); - state.nodeAlive.add('gone1', dot1); - state.nodeAlive.add('gone2', dot2); - state.nodeAlive.remove(new Set([encodeDot(dot1)])); - state.nodeAlive.remove(new Set([encodeDot(dot2)])); - - const frontier = createFrontier(); - updateFrontier(frontier, 'w1', makeOid('sha1')); - - const stateEnvelopeBlobs: Uint8Array[] = []; - mockPersistence.writeBlob.mockImplementation( - (buffer) => { - if (stateEnvelopeBlobs.length < 5) { - stateEnvelopeBlobs.push(buffer); - } - return Promise.resolve(makeOid('blob')); - } - ); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue( - makeOid('checkpoint') - ); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - compact: true, - crypto, - }); - - const restored = envelopeFromCreateBlobs(stateEnvelopeBlobs); - // After compaction, all tombstoned entries should be removed - expect(restored.nodeAlive.elements()).toHaveLength(0); - expect(restored.nodeAlive.entries.size).toBe(0); - expect(restored.nodeAlive.tombstones.size).toBe(0); - }); - }); - - // -------------------------------------------------------------------------- - // Checkpoint message decoding edge cases - // -------------------------------------------------------------------------- - - describe('checkpoint message encoding/decoding', () => { - it('roundtrips schema:5 message correctly', () => { - const message = encodeCheckpointMessage({ - graph: 'my-graph', - stateHash: 'f'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 5, - }); - - const decoded = decodeCheckpointMessage(message); - expect(decoded.kind).toBe('checkpoint'); - expect(decoded.graph).toBe('my-graph'); - expect(decoded.schema).toBe(CURRENT_CHECKPOINT_SCHEMA); - expect(decoded.checkpointVersion).toBe('v5'); - }); - - it('preserves stateHash through encode/decode', () => { - const hash = 'abcdef0123456789'.repeat(4); - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: hash, - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 5, - }); - - const decoded = decodeCheckpointMessage(message); - expect(decoded.stateHash).toBe(hash); - }); - }); - - // -------------------------------------------------------------------------- - // Provenenance index loading (absent) - // -------------------------------------------------------------------------- - - describe('provenanceIndex absent', () => { - it('returns undefined provenanceIndex when blob is absent', async () => { - const state = createEmptyState(); - state.nodeAlive.add('a', Dot.create('w1', 1)); - - const frontier = createFrontier(); - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - }); - - const result = await loadCheckpoint( - mockPersistence, - makeOid('checkpoint') - ); - expect(result.provenanceIndex).toBeUndefined(); - }); - }); - - describe('provenanceIndex present', () => { - it('loads provenanceIndex from checkpoint tree when blob is present', async () => { - const state = createEmptyState(); - state.nodeAlive.add('a', Dot.create('w1', 1)); - - const provenanceIndex = new ProvenanceIndex(); - provenanceIndex.addPatch(makeOid('patch1'), ['a'], ['a']); - - const frontier = createFrontier(); - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - provenanceIndex, - }); - - const result = await loadCheckpoint( - mockPersistence, - makeOid('checkpoint') - ); - - expect(result.provenanceIndex).toBeDefined(); - expect(result.provenanceIndex?.patchesFor('a')).toEqual([makeOid('patch1')]); - }); - }); - - describe('createCheckpointEnvelope with checkpointStore and provenance index', () => { - it('uses schema:5 envelope writes when checkpointStore is provided', async () => { - const state = createEmptyState(); - state.nodeAlive.add('n', Dot.create('w1', 1)); - const frontier = createFrontier(); - const checkpointStore = { - writeCheckpoint: vi.fn(async () => ({ - nodeAliveBlobOid: makeOid('nodeAlive'), - edgeAliveBlobOid: makeOid('edgeAlive'), - propBlobOid: makeOid('prop'), - observedFrontierBlobOid: makeOid('observedFrontier'), - edgeBirthEventBlobOid: makeOid('edgeBirthEvent'), - frontierBlobOid: makeOid('frontier'), - appliedVVBlobOid: makeOid('appliedvv'), - provenanceIndexBlobOid: null, - })), - }; - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeOid(`blob${blobIndex++}`))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - checkpointStore: (checkpointStore as any), - crypto, - }); - - expect(checkpointStore.writeCheckpoint).toHaveBeenCalledOnce(); - expect(mockPersistence.writeBlob).not.toHaveBeenCalled(); - const message = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; - expect(decodeCheckpointMessage(message).schema).toBe(5); - }); - - it('writes provenanceIndex blob in the schema:5 envelope path', async () => { - const state = createEmptyState(); - state.nodeAlive.add('n', Dot.create('w1', 1)); - const frontier = createFrontier(); - const provenanceIndex = new ProvenanceIndex(); - provenanceIndex.addPatch(makeOid('patch1'), ['n'], ['n']); - - let blobIndex = 0; - const blobOids = [ - makeOid('nodealive'), - makeOid('edgealive'), - makeOid('prop'), - makeOid('observed'), - makeOid('edgebirth'), - makeOid('frontier'), - makeOid('appliedvv'), - makeOid('prov'), - ]; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(blobOids[blobIndex++])); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - provenanceIndex, - crypto, - }); +const stateHashService = new StateHashService({ + codec: defaultCodec, + crypto: new NodeCryptoAdapter(), +}); - expect(mockPersistence.writeBlob).toHaveBeenCalledTimes(8); - const treeEntries = mockPersistence.writeTree.mock.calls[1][0]; - expect(treeEntries).toContain(`100644 blob ${makeOid('prov')}\tprovenanceIndex.cbor`); - }); +describe('checkpoint domain edge cases', () => { + it('rejects an unknown semantic checkpoint handle', async () => { + await expect(loadCheckpoint(new InMemoryCheckpointStore(), 'f'.repeat(40))) + .rejects.toThrow(/Checkpoint not found/); + }); + + it('returns the checkpoint state without invoking loaders when no suffix exists', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const state = createEmptyState(); + state.nodeAlive.add('node:a', Dot.create('alice', 1)); + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state, + frontier: new Map([['alice', 'a'.repeat(40)]]), + compact: false, + stateHashService, + }); + const patchLoader = vi.fn(async () => []); + + const result = await materializeIncremental({ + checkpointStore, + graphName: 'events', + checkpointSha: sha, + targetFrontier: new Map(), + patchLoader, + }); + expect(result).toBe(state); + expect(patchLoader).not.toHaveBeenCalled(); + }); + + it('rejects a checkpoint from a different graph before replaying patches', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'other-events', + state: createEmptyState(), + frontier: new Map(), + compact: false, + stateHashService, + }); + const patchLoader = vi.fn(async () => []); + + await expect(materializeIncremental({ + checkpointStore, + graphName: 'events', + checkpointSha: sha, + targetFrontier: new Map(), + patchLoader, + })).rejects.toThrow(/belongs to graph other-events, not events/); + expect(patchLoader).not.toHaveBeenCalled(); + }); + + it('loads a writer absent from the checkpoint from causal genesis', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state: createEmptyState(), + frontier: new Map(), + compact: false, + stateHashService, + }); + const patchLoader = vi.fn(async () => []); + const target = 'b'.repeat(40); + + await materializeIncremental({ + checkpointStore, + graphName: 'events', + checkpointSha: sha, + targetFrontier: new Map([['bob', target]]), + patchLoader, + }); + expect(patchLoader).toHaveBeenCalledWith('bob', null, target); + }); + + it('reconstructs an empty visible projection as an empty state', () => { + const state = reconstructStateFromCheckpoint({ nodes: [], edges: [], props: [] }); + expect(state.nodeAlive.entries.size).toBe(0); + expect(state.edgeAlive.entries.size).toBe(0); }); }); diff --git a/test/unit/domain/services/CheckpointService.test.ts b/test/unit/domain/services/CheckpointService.test.ts index a4fd5b61..b0d9e4b5 100644 --- a/test/unit/domain/services/CheckpointService.test.ts +++ b/test/unit/domain/services/CheckpointService.test.ts @@ -1,1567 +1,183 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { Dot, encodeDot } from '../../../../src/domain/crdt/Dot.ts'; +import { createEmptyState } from '../../../../src/domain/services/JoinReducer.ts'; +import StateHashService from '../../../../src/domain/services/state/StateHashService.ts'; import { - create as createCheckpoint, - createCheckpointEnvelope as createCheckpointEnvelopeWithCodec, - type CreateCheckpointOptions, + createCheckpointEnvelope, } from '../../../../src/domain/services/state/checkpointCreate.ts'; import { - loadCheckpoint as loadCheckpointWithCodec, + loadCheckpoint, + materializeIncremental, reconstructStateFromCheckpoint, - type LoadCheckpointOptions, - type LoadPersistence, } from '../../../../src/domain/services/state/checkpointLoad.ts'; -import { createFrontier, updateFrontier, serializeFrontier } from '../../../../src/domain/services/Frontier.ts'; -import { computeStateHash } from '../../../../src/domain/services/state/StateSerializer.ts'; -import { - serializeCheckpointStateEnvelope, - deserializeCheckpointStateEnvelope, - computeAppliedVV, - serializeAppliedVV, - deserializeAppliedVV, -} from '../../../../src/domain/services/state/CheckpointSerializer.ts'; -import { createEmptyState, encodeEdgeKey as encodeEdgeKeyV5, encodePropKey as encodePropKeyV5 } from '../../../../src/domain/services/JoinReducer.ts'; -import { - DEFAULT_COMMIT_MESSAGE_CODEC, - encodeCheckpointMessage, - decodeCheckpointMessage, -} from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { Dot, encodeDot } from '../../../../src/domain/crdt/Dot.ts'; -import { CONTENT_PROPERTY_KEY, encodeEdgePropKey } from '../../../../src/domain/services/KeyCodec.ts'; -import { ProvenanceIndex } from '../../../../src/domain/services/provenance/ProvenanceIndex.ts'; +import Patch from '../../../../src/domain/types/Patch.ts'; +import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryCheckpointStore from '../../../helpers/InMemoryCheckpointStore.ts'; -const crypto = new NodeCryptoAdapter(); - -type CreateCheckpointTestOptions = - Omit & - Partial>; - -type LoadCheckpointTestOptions = - Omit & - Partial>; - -async function create(options: CreateCheckpointTestOptions): ReturnType { - return await createCheckpoint({ - ...options, - codec: options.codec ?? defaultCodec, - commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, - }); -} +const stateHashService = new StateHashService({ + codec: defaultCodec, + crypto: new NodeCryptoAdapter(), +}); -async function createCheckpointEnvelope( - options: CreateCheckpointTestOptions, -): ReturnType { - return await createCheckpointEnvelopeWithCodec({ - ...options, - codec: options.codec ?? defaultCodec, - commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, - }); +function stateWithNode(nodeId = 'node:a') { + const state = createEmptyState(); + state.nodeAlive.add(nodeId, Dot.create('alice', 1)); + return state; } -async function loadCheckpoint( - persistence: LoadPersistence, - checkpointSha: string, - options: LoadCheckpointTestOptions = {}, -): ReturnType { - return await loadCheckpointWithCodec(persistence, checkpointSha, { - ...options, - codec: options.codec ?? defaultCodec, - commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, +describe('checkpoint domain lifecycle', () => { + it('hashes state and delegates one semantic publication record', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const state = stateWithNode(); + const frontier = new Map([['alice', 'a'.repeat(40)]]); + + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state, + frontier, + parents: ['b'.repeat(40)], + compact: false, + stateHashService, + }); + + expect(sha).toHaveLength(40); + expect(checkpointStore.lastPublished).toMatchObject({ + graphName: 'events', + parents: ['b'.repeat(40)], + state, + frontier, + }); + expect(checkpointStore.lastPublished?.stateHash).toMatch(/^[0-9a-f]{64}$/); }); -} -// Helper to create valid 40-char hex OIDs for testing -const makeOid = (prefix) => { - const base = prefix.replace(/[^0-9a-f]/gi, '0').toLowerCase(); - return (base + '0'.repeat(40)).slice(0, 40); -}; - -const makeSequentialOid = (index) => index.toString(16).padStart(40, '0'); - -function installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - appliedVV = computeAppliedVV(state), - provenanceIndex = undefined, - includeAppliedVV = true, - indexShardOids = {}, -}: { - mockPersistence: any; - state: ReturnType; - frontier: Map; - stateHash: string; - appliedVV?: ReturnType; - provenanceIndex?: ProvenanceIndex; - includeAppliedVV?: boolean; - indexShardOids?: Record; -}) { - const frontierOid = makeOid('frontier'); - const appliedVVOid = makeOid('appliedvv'); - const stateEnvelope = serializeCheckpointStateEnvelope(state, { codec: defaultCodec }); - const blobMap = new Map([ - [makeOid('nodealive'), stateEnvelope.nodeAlive], - [makeOid('edgealive'), stateEnvelope.edgeAlive], - [makeOid('prop'), stateEnvelope.prop], - [makeOid('observed'), stateEnvelope.observedFrontier], - [makeOid('edgebirth'), stateEnvelope.edgeBirthEvent], - [frontierOid, serializeFrontier(frontier, { codec: defaultCodec })], - ]); - if (includeAppliedVV) { - blobMap.set(appliedVVOid, serializeAppliedVV(appliedVV, { codec: defaultCodec })); - } - if (provenanceIndex !== undefined) { - blobMap.set(makeOid('provenance'), provenanceIndex.serialize({ codec: defaultCodec })); - } - - mockPersistence.showNode.mockResolvedValue(encodeCheckpointMessage({ - graph: 'test', - stateHash, - frontierOid, - indexOid: makeOid('envelope'), - schema: 5, - })); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state': makeOid('state-tree'), - 'state/nodeAlive': makeOid('nodealive'), - 'state/edgeAlive': makeOid('edgealive'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - 'frontier.cbor': frontierOid, - ...(includeAppliedVV ? { 'appliedVV.cbor': appliedVVOid } : {}), - ...(provenanceIndex !== undefined ? { 'provenanceIndex.cbor': makeOid('provenance') } : {}), - ...indexShardOids, - }); - mockPersistence.readBlob.mockImplementation((oid) => { - const blob = blobMap.get(oid); - if (blob !== undefined) { - return Promise.resolve(blob); - } - throw new Error(`Unknown oid: ${oid}`); + it('compacts a cloned checkpoint state without mutating the live state', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const state = createEmptyState(); + const dot = Dot.create('alice', 1); + state.nodeAlive.add('deleted', dot); + state.nodeAlive.remove(new Set([encodeDot(dot)])); + + await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state, + frontier: new Map(), + compact: true, + stateHashService, + }); + + expect(state.nodeAlive.entries.has('deleted')).toBe(true); + expect(checkpointStore.lastPublished?.state.nodeAlive.entries.has('deleted')).toBe(false); + expect(checkpointStore.lastPublished?.state.nodeAlive.tombstones.size).toBe(0); }); -} - -function deserializeEnvelopeFromBlobOrder(blobs: Uint8Array[]) { - return deserializeCheckpointStateEnvelope({ - nodeAlive: requireBlobAt(blobs, 0), - edgeAlive: requireBlobAt(blobs, 1), - prop: requireBlobAt(blobs, 2), - observedFrontier: requireBlobAt(blobs, 3), - edgeBirthEvent: requireBlobAt(blobs, 4), - }, { codec: defaultCodec }); -} - -function requireBlobAt(blobs: Uint8Array[], index: number): Uint8Array { - const blob = blobs[index]; - if (blob === undefined) { - throw new Error(`Missing checkpoint envelope blob at index ${index}`); - } - return blob; -} - -function splitTreeEntry(entry: string): { oid: string; path: string } { - const [left, path] = entry.split('\t'); - if (left === undefined || path === undefined) { - throw new Error(`Invalid tree entry: ${entry}`); - } - const oid = left.split(' ')[2]; - if (oid === undefined) { - throw new Error(`Invalid tree oid entry: ${entry}`); - } - return { oid, path }; -} - -describe('CheckpointService', () => { - let mockPersistence: any; - - beforeEach(() => { - // Create mock persistence adapter - mockPersistence = { - writeBlob: vi.fn(), - writeTree: vi.fn(), - readBlob: vi.fn(), - readTreeOids: vi.fn(), - commitNodeWithTree: vi.fn(), - showNode: vi.fn(), - getNodeInfo: vi.fn(), - }; - }); - - describe('create', () => { - it('creates schema:5 checkpoint commit with state envelope and frontier blobs', async () => { - // Setup test data - V5 state - const state = createEmptyState(); - const dot = Dot.create('writer1', 1); - state.nodeAlive.add('x', dot); - - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('sha123')); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeSequentialOid(++blobIndex))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - // Execute - const checkpointSha = await create({ - persistence: mockPersistence, - graphName: 'test-graph', - state, - frontier, - crypto, - }); - - // Verify - expect(checkpointSha).toBe(makeOid('checkpoint')); - expect(mockPersistence.writeBlob).toHaveBeenCalledTimes(7); - expect(mockPersistence.writeTree).toHaveBeenCalledTimes(2); - expect(mockPersistence.commitNodeWithTree).toHaveBeenCalledTimes(1); - }); - - it('creates tree entries in sorted order', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeSequentialOid(++blobIndex))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('sha')); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - const stateEntries = mockPersistence.writeTree.mock.calls[0][0]; - expect(stateEntries.map((entry) => entry.slice(entry.indexOf('\t') + 1))).toEqual([ - 'edgeAlive', - 'edgeBirthEvent.cbor', - 'nodeAlive', - 'observedFrontier.cbor', - 'prop.cbor', - ]); - - const envelopeEntries = mockPersistence.writeTree.mock.calls[1][0]; - expect(envelopeEntries.map((entry) => entry.slice(entry.indexOf('\t') + 1))).toEqual([ - 'appliedVV.cbor', - 'frontier.cbor', - 'state', - ]); - }); - - it('includes parents in commit', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - mockPersistence.writeBlob.mockResolvedValue(makeOid('blob')); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('sha')); + it('preserves tombstones when compaction is disabled', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const state = createEmptyState(); + const dot = Dot.create('alice', 1); + state.nodeAlive.add('deleted', dot); + state.nodeAlive.remove(new Set([encodeDot(dot)])); - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - parents: [makeOid('parent1'), makeOid('parent2')], - crypto, - }); - - expect(mockPersistence.commitNodeWithTree).toHaveBeenCalledWith( - expect.objectContaining({ - parents: [makeOid('parent1'), makeOid('parent2')], - }) - ); - }); - - it('encodes checkpoint message with correct trailers', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeSequentialOid(++blobIndex))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('sha')); - - await create({ - persistence: mockPersistence, - graphName: 'my-graph', - state, - frontier, - crypto, - }); - - const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; - const decoded = decodeCheckpointMessage(messageArg); - - expect(decoded.kind).toBe('checkpoint'); - expect(decoded.graph).toBe('my-graph'); - expect(decoded.schema).toBe(5); + await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state, + frontier: new Map(), + compact: false, + stateHashService, }); - it('creates schema:5 envelope tree with state subtree instead of state.cbor', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeSequentialOid(++blobIndex))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope-tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - expect(mockPersistence.writeTree).toHaveBeenCalledTimes(2); - - const stateEntries = mockPersistence.writeTree.mock.calls[0][0]; - expect(stateEntries.some((entry) => entry.includes('\tnodeAlive'))).toBe(true); - expect(stateEntries.some((entry) => entry.includes('\tedgeAlive'))).toBe(true); - expect(stateEntries.some((entry) => entry.includes('\tprop.cbor'))).toBe(true); - expect(stateEntries.some((entry) => entry.includes('\tobservedFrontier.cbor'))).toBe(true); - expect(stateEntries.some((entry) => entry.includes('\tedgeBirthEvent.cbor'))).toBe(true); - - const envelopeEntries = mockPersistence.writeTree.mock.calls[1][0]; - expect(envelopeEntries.some((entry) => entry.includes('\tstate'))).toBe(true); - expect(envelopeEntries.some((entry) => entry.includes('\tstate.cbor'))).toBe(false); - - const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; - const decoded = decodeCheckpointMessage(messageArg); - expect(decoded.schema).toBe(5); - }); - - it('creates schema:5 envelope tree with provenanceIndex and index subtree intact', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - const provenanceIndex = new ProvenanceIndex(); - provenanceIndex.addPatch(makeOid('patch'), ['node:a'], ['node:a']); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeSequentialOid(++blobIndex))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('index-tree')) - .mockResolvedValueOnce(makeOid('envelope-tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - provenanceIndex, - indexTree: { - 'bitmap/part-000.cbor': new Uint8Array([9, 9, 9]), - }, - crypto, - }); - - expect(mockPersistence.writeTree).toHaveBeenCalledTimes(3); - - const envelopeEntries = mockPersistence.writeTree.mock.calls[2]?.[0] ?? []; - expect(envelopeEntries.some((entry) => entry.includes('\tstate'))).toBe(true); - expect(envelopeEntries.some((entry) => entry.includes('\tprovenanceIndex.cbor'))).toBe(true); - expect(envelopeEntries.some((entry) => entry.includes('\tindex'))).toBe(true); - expect(envelopeEntries.some((entry) => entry.includes('\tstate.cbor'))).toBe(false); - - const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; - const decoded = decodeCheckpointMessage(messageArg); - expect(decoded.schema).toBe(5); - }); + expect(checkpointStore.lastPublished?.state.nodeAlive.entries.has('deleted')).toBe(true); + expect(checkpointStore.lastPublished?.state.nodeAlive.tombstones.has('alice:1')).toBe(true); }); - describe('loadCheckpoint', () => { - it('loads checkpoint state and frontier from commit', async () => { - // Create V5 state (ORSet-based) - const v5State = createEmptyState(); - const dot = Dot.create('writer1', 1); - v5State.nodeAlive.add('node1', dot); - v5State.nodeAlive.add('node2', dot); - v5State.edgeAlive.add(encodeEdgeKeyV5('node1', 'node2', 'link'), dot); - v5State.mutatePropRegisterLWW(encodePropKeyV5('node1', 'name'), { - eventId: { lamport: 1, writerId: 'w', patchSha: makeOid('abc'), opIndex: 0 }, - value: { type: 'inline', value: 'test' }, - }); - - const originalFrontier = createFrontier(); - updateFrontier(originalFrontier, 'writer1', makeOid('sha111')); - - const stateHash = await computeStateHash(v5State, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state: v5State, - frontier: originalFrontier, - stateHash, - }); - - // Execute - const result = await loadCheckpoint(mockPersistence, makeOid('checkpointSha')); - - // Verify - expect(result.stateHash).toBe(stateHash); - expect(result.schema).toBe(5); - expect(result.frontier.get('writer1')).toBe(makeOid('sha111')); - // V5 returns full ORSet state - expect(result.state.nodeAlive.entries.has('node1')).toBe(true); - expect(result.state.nodeAlive.entries.has('node2')).toBe(true); - }); - - it('throws if frontier.cbor is missing', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 5, - }); - - mockPersistence.showNode.mockResolvedValue(message); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: 'sha' }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state/nodeAlive': makeOid('nodealive'), - 'state/edgeAlive': makeOid('edgealive'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - // Missing frontier.cbor - }); - - await expect(loadCheckpoint(mockPersistence, 'sha')) - .rejects.toThrow('missing frontier.cbor'); - }); - - it('throws if state/nodeAlive is missing', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 5, - }); - - mockPersistence.showNode.mockResolvedValue(message); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: 'sha' }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'frontier.cbor': 'frontier-oid', - 'state/edgeAlive': makeOid('edgealive'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - // Missing state/nodeAlive - }); - mockPersistence.readBlob.mockResolvedValue(serializeFrontier(createFrontier(), { codec: defaultCodec })); - - await expect(loadCheckpoint(mockPersistence, 'sha')) - .rejects.toThrow('missing state/nodeAlive'); - }); - - it('throws for retired checkpoint schemas with upgrade guidance', async () => { - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: 'a'.repeat(40), - indexOid: 'b'.repeat(40), - schema: 1, - }); - - mockPersistence.showNode.mockResolvedValue(message); - - await expect(loadCheckpoint(mockPersistence, makeOid('retiredcheckpoint'))) - .rejects.toThrow(/schema:1.*upgrade/i); - }); - - it('loads schema:5 checkpoint envelope without requiring state.cbor', async () => { - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('sha111')); - - const emptyState = createEmptyState(); - const stateHash = await computeStateHash(emptyState, { crypto, codec: defaultCodec }); - const frontierBuffer = serializeFrontier(frontier, { codec: defaultCodec }); - const appliedVVBuffer = serializeAppliedVV(computeAppliedVV(emptyState), { codec: defaultCodec }); - - const frontierBlobOid = makeOid('frontier'); - const appliedVVBlobOid = makeOid('appliedvv'); - - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash, - frontierOid: frontierBlobOid, - indexOid: makeOid('envelope'), - schema: 5, - }); - - mockPersistence.showNode.mockResolvedValue(message); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state': makeOid('state-tree'), - 'state/nodeAlive': makeOid('node-root'), - 'state/edgeAlive': makeOid('edge-root'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - 'frontier.cbor': frontierBlobOid, - 'appliedVV.cbor': appliedVVBlobOid, - }); - mockPersistence.readBlob.mockImplementation((oid) => { - if (oid === frontierBlobOid) { - return Promise.resolve(frontierBuffer); - } - if (oid === appliedVVBlobOid) { - return Promise.resolve(appliedVVBuffer); - } - return Promise.resolve(new Uint8Array()); - }); - - const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(result.schema).toBe(5); - expect(result.frontier.get('writer1')).toBe(makeOid('sha111')); - }); - - it('fails closed when schema:5 envelope is missing state/nodeAlive', async () => { - const frontier = createFrontier(); - const frontierBlobOid = makeOid('frontier'); - const appliedVVBlobOid = makeOid('appliedvv'); - - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: frontierBlobOid, - indexOid: makeOid('envelope'), - schema: 5, - }); - - mockPersistence.showNode.mockResolvedValue(message); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state': makeOid('state-tree'), - 'state/edgeAlive': makeOid('edge-root'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - 'frontier.cbor': frontierBlobOid, - 'appliedVV.cbor': appliedVVBlobOid, - }); - mockPersistence.readBlob.mockImplementation((oid) => { - if (oid === frontierBlobOid) { - return Promise.resolve(serializeFrontier(frontier, { codec: defaultCodec })); - } - if (oid === appliedVVBlobOid) { - return Promise.resolve(serializeAppliedVV(computeAppliedVV(createEmptyState()), { codec: defaultCodec })); - } - return Promise.resolve(new Uint8Array()); - }); - - await expect(loadCheckpoint(mockPersistence, makeOid('checkpoint'))) - .rejects.toThrow(/state\/nodeAlive/i); - }); - - it('fails closed when schema:5 envelope is missing state/edgeAlive', async () => { - const frontier = createFrontier(); - const frontierBlobOid = makeOid('frontier'); - const appliedVVBlobOid = makeOid('appliedvv'); - - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: frontierBlobOid, - indexOid: makeOid('envelope'), - schema: 5, - }); - - mockPersistence.showNode.mockResolvedValue(message); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state': makeOid('state-tree'), - 'state/nodeAlive': makeOid('node-root'), - 'state/prop.cbor': makeOid('prop'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - 'frontier.cbor': frontierBlobOid, - 'appliedVV.cbor': appliedVVBlobOid, - }); - mockPersistence.readBlob.mockImplementation((oid) => { - if (oid === frontierBlobOid) { - return Promise.resolve(serializeFrontier(frontier, { codec: defaultCodec })); - } - if (oid === appliedVVBlobOid) { - return Promise.resolve(serializeAppliedVV(computeAppliedVV(createEmptyState()), { codec: defaultCodec })); - } - return Promise.resolve(new Uint8Array()); - }); - - await expect(loadCheckpoint(mockPersistence, makeOid('checkpoint'))) - .rejects.toThrow(/state\/edgeAlive/i); - }); - - it('fails closed when schema:5 envelope is missing state/prop.cbor', async () => { - const frontier = createFrontier(); - const frontierBlobOid = makeOid('frontier'); - const appliedVVBlobOid = makeOid('appliedvv'); - - const message = encodeCheckpointMessage({ - graph: 'test', - stateHash: 'a'.repeat(64), - frontierOid: frontierBlobOid, - indexOid: makeOid('envelope'), - schema: 5, - }); - - mockPersistence.showNode.mockResolvedValue(message); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - mockPersistence.readTreeOids.mockResolvedValue({ - 'state': makeOid('state-tree'), - 'state/nodeAlive': makeOid('node-root'), - 'state/edgeAlive': makeOid('edge-root'), - 'state/observedFrontier.cbor': makeOid('observed'), - 'state/edgeBirthEvent.cbor': makeOid('edgebirth'), - 'frontier.cbor': frontierBlobOid, - 'appliedVV.cbor': appliedVVBlobOid, - }); - mockPersistence.readBlob.mockImplementation((oid) => { - if (oid === frontierBlobOid) { - return Promise.resolve(serializeFrontier(frontier, { codec: defaultCodec })); - } - if (oid === appliedVVBlobOid) { - return Promise.resolve(serializeAppliedVV(computeAppliedVV(createEmptyState()), { codec: defaultCodec })); - } - return Promise.resolve(new Uint8Array()); - }); - - await expect(loadCheckpoint(mockPersistence, makeOid('checkpoint'))) - .rejects.toThrow(/state\/prop\.cbor/i); - }); + it('round-trips state and frontier through CheckpointStorePort', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const state = stateWithNode(); + const frontier = new Map([['alice', 'a'.repeat(40)]]); + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state, + frontier, + compact: false, + stateHashService, + }); + + const loaded = await loadCheckpoint(checkpointStore, sha); + expect(loaded.state.nodeAlive.contains('node:a')).toBe(true); + expect(loaded.frontier).toEqual(frontier); + expect(loaded.schema).toBe(5); + expect(loaded.appliedVV?.get('alice')).toBe(1); }); - // Note: materializeIncremental tests removed - they relied on schema:1 checkpoints - // which are no longer supported as a runtime option. - - // Note: roundtrip test using createPatch (schema:1) removed - tests now focus on schema:5 - - describe('schema:5 serialization', () => { - describe('create', () => { - it('creates checkpoint using v5 full state serializer', async () => { - // Create v5 state (ORSet-based) - const state = createEmptyState(); - const dot = Dot.create('writer1', 1); - state.nodeAlive.add('node1', dot); - state.nodeAlive.add('node2', dot); - state.edgeAlive.add(encodeEdgeKeyV5('node1', 'node2', 'link'), dot); - state.mutatePropRegisterLWW(encodePropKeyV5('node1', 'name'), { - eventId: { lamport: 1, writerId: 'w', patchSha: makeOid('abc'), opIndex: 0 }, - value: { type: 'inline', value: 'Test' }, - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('sha1')); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeSequentialOid(++blobIndex))); - mockPersistence.writeTree - .mockResolvedValueOnce(makeOid('state-tree')) - .mockResolvedValueOnce(makeOid('envelope')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('sha')); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; - const decoded = decodeCheckpointMessage(messageArg); - expect(decoded.schema).toBe(5); - - expect(mockPersistence.writeBlob).toHaveBeenCalledTimes(7); - - const stateEntries = mockPersistence.writeTree.mock.calls[0][0]; - expect(stateEntries.some((entry) => entry.includes('\tnodeAlive'))).toBe(true); - expect(stateEntries.some((entry) => entry.includes('\tedgeAlive'))).toBe(true); - }); - }); - - describe('loadCheckpoint', () => { - it('loads checkpoint with v5 full state deserializer', async () => { - // Create v5 state and serialize it using FULL STATE serializer - const v5State = createEmptyState(); - const dot = Dot.create('writer1', 1); - v5State.nodeAlive.add('x', dot); - v5State.nodeAlive.add('y', dot); - v5State.edgeAlive.add(encodeEdgeKeyV5('x', 'y', 'conn'), dot); - v5State.mutatePropRegisterLWW(encodePropKeyV5('x', 'val'), { - eventId: { lamport: 1, writerId: 'w', patchSha: makeOid('p'), opIndex: 0 }, - value: { type: 'inline', value: 'hello' }, - }); - - const frontier = createFrontier(); - const stateHash = await computeStateHash(v5State, { crypto, codec: defaultCodec }); - const appliedVV = computeAppliedVV(v5State); - - installSchema5CheckpointRead({ - mockPersistence, - state: v5State, - frontier, - stateHash, - appliedVV, - }); - - const result: any = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(result.schema).toBe(5); - // V5 returns full ORSet state, not visible projection - expect(result.state.nodeAlive.entries.has('x')).toBe(true); - expect(result.state.nodeAlive.entries.has('y')).toBe(true); - expect(result.state.nodeAlive.entries.get('x').has('writer1:1')).toBe(true); - - // Verify appliedVV was loaded - expect(result.appliedVV).toBeDefined(); - expect(result.appliedVV.get('writer1')).toBe(1); - }); - }); - - describe('roundtrip', () => { - it('roundtrip preserves full ORSet data', async () => { - const state = createEmptyState(); - const dot = Dot.create('writer1', 1); - state.nodeAlive.add('a', dot); - state.nodeAlive.add('b', dot); - state.edgeAlive.add(encodeEdgeKeyV5('a', 'b', 'rel'), dot); - state.mutatePropRegisterLWW(encodePropKeyV5('a', 'color'), { - eventId: { lamport: 1, writerId: 'w', patchSha: makeOid('p'), opIndex: 0 }, - value: { type: 'inline', value: 'red' }, - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('p')); - - let writtenMessage: any; - let capturedStateTree: string[] = []; - let capturedEnvelopeTree: string[] = []; - const writtenBlobs = new Map(); - let blobIndex = 0; - - mockPersistence.writeBlob.mockImplementation((buffer) => { - const oid = makeSequentialOid(++blobIndex); - writtenBlobs.set(oid, buffer); - return Promise.resolve(oid); - }); - mockPersistence.writeTree - .mockImplementationOnce((entries) => { - capturedStateTree = entries; - return Promise.resolve(makeOid('state-tree')); - }) - .mockImplementationOnce((entries) => { - capturedEnvelopeTree = entries; - return Promise.resolve(makeOid('envelope')); - }); - mockPersistence.commitNodeWithTree.mockImplementation((/** @type {any} */ { message }) => { - writtenMessage = message; - return Promise.resolve(makeOid('checkpoint')); - }); - - await create({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - // Setup for loading - mockPersistence.showNode.mockResolvedValue(writtenMessage); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpoint') }); - const treeOids: Record = {}; - for (const entry of capturedStateTree) { - const { oid, path } = splitTreeEntry(entry); - treeOids[`state/${path}`] = oid; - } - for (const entry of capturedEnvelopeTree) { - const { oid, path } = splitTreeEntry(entry); - if (path !== 'state') { - treeOids[path] = oid; - } - } - mockPersistence.readTreeOids.mockResolvedValue(treeOids); - mockPersistence.readBlob.mockImplementation((oid) => { - const blob = writtenBlobs.get(oid); - if (blob !== undefined) { - return Promise.resolve(blob); - } - throw new Error(`Unknown oid: ${oid}`); - }); - - const loaded: any = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(loaded.schema).toBe(5); - // V5 returns full ORSet state with dots preserved - expect(loaded.state.nodeAlive.entries.has('a')).toBe(true); - expect(loaded.state.nodeAlive.entries.has('b')).toBe(true); - expect(loaded.state.nodeAlive.entries.get('a').has('writer1:1')).toBe(true); - - // Verify edges - const edgeKey = encodeEdgeKeyV5('a', 'b', 'rel'); - expect(loaded.state.edgeAlive.entries.has(edgeKey)).toBe(true); - - // Verify props - const propKey = encodePropKeyV5('a', 'color'); - expect(loaded.state.hasProp(propKey)).toBe(true); - expect(loaded.state.getEncodedProp(propKey).value).toEqual({ type: 'inline', value: 'red' }); - - // Verify appliedVV - expect(loaded.appliedVV.get('writer1')).toBe(1); - }); - }); - - describe('reconstructStateFromCheckpoint', () => { - it('creates ORSet-based state from visible projection', () => { - const visibleProjection = { - nodes: ['n1', 'n2', 'n3'], - edges: [ - { from: 'n1', to: 'n2', label: 'a' }, - { from: 'n2', to: 'n3', label: 'b' }, - ], - props: [ - { node: 'n1', key: 'x', value: { type: 'inline', value: 1 } }, - { node: 'n2', key: 'y', value: { type: 'inline', value: 2 } }, - ], - }; - - const state = reconstructStateFromCheckpoint(visibleProjection); - - // Verify nodes are in ORSet - expect(state.nodeAlive.contains('n1')).toBe(true); - expect(state.nodeAlive.contains('n2')).toBe(true); - expect(state.nodeAlive.contains('n3')).toBe(true); - expect(state.nodeAlive.elements().sort()).toEqual(['n1', 'n2', 'n3']); - - // Verify edges are in ORSet - const edge1Key = encodeEdgeKeyV5('n1', 'n2', 'a'); - const edge2Key = encodeEdgeKeyV5('n2', 'n3', 'b'); - expect(state.edgeAlive.contains(edge1Key)).toBe(true); - expect(state.edgeAlive.contains(edge2Key)).toBe(true); - - // Verify props are in LWW map - const prop1Key = encodePropKeyV5('n1', 'x'); - const prop2Key = encodePropKeyV5('n2', 'y'); - expect(state.hasProp(prop1Key)).toBe(true); - expect(state.hasProp(prop2Key)).toBe(true); - expect((state.getEncodedProp(prop1Key as any))!.value).toEqual({ type: 'inline', value: 1 }); - expect((state.getEncodedProp(prop2Key as any))!.value).toEqual({ type: 'inline', value: 2 }); - - // Verify observedFrontier exists - expect(state.observedFrontier).toBeDefined(); - }); - - it('handles empty projection', () => { - const visibleProjection = { - nodes: [], - edges: [], - props: [], - }; - - const state = reconstructStateFromCheckpoint(visibleProjection); - - expect(state.nodeAlive.elements()).toHaveLength(0); - expect(state.edgeAlive.elements()).toHaveLength(0); - expect(state.propSize()).toBe(0); - }); - }); + it('propagates materialized index shards as opaque checkpoint handles', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state: stateWithNode(), + frontier: new Map(), + compact: false, + indexTree: { 'meta_aa.cbor': defaultCodec.encode({ node: 1 }) }, + stateHashService, + }); + + const loaded = await loadCheckpoint(checkpointStore, sha); + expect(loaded.indexShardHandles?.['meta_aa.cbor']?.toString()) + .toContain('checkpoint-shard:'); }); - describe('V5 checkpoint with full ORSet state', () => { - let mockPersistence: any; - - beforeEach(() => { - mockPersistence = { - writeBlob: vi.fn(), - writeTree: vi.fn(), - readBlob: vi.fn(), - readTreeOids: vi.fn(), - commitNodeWithTree: vi.fn(), - showNode: vi.fn(), - getNodeInfo: vi.fn(), - }; - }); - - describe('createCheckpointEnvelope', () => { - it('creates V5 checkpoint with full state', async () => { - // Create V5 state with nodes, edges, props - const state = createEmptyState(); - const dot1 = Dot.create('alice', 1); - const dot2 = Dot.create('alice', 2); - state.nodeAlive.add('n1', dot1); - state.nodeAlive.add('n2', dot2); - state.edgeAlive.add(encodeEdgeKeyV5('n1', 'n2', 'link'), Dot.create('alice', 3)); - state.mutatePropRegisterLWW(encodePropKeyV5('n1', 'name'), { - eventId: { lamport: 1, writerId: 'alice', patchSha: makeOid('p1'), opIndex: 0 }, - value: { type: 'inline', value: 'Node1' }, - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - // Track written blobs - const writtenBlobs: any[] = []; - mockPersistence.writeBlob.mockImplementation((buffer) => { - writtenBlobs.push(buffer); - return Promise.resolve(makeOid(`blob${writtenBlobs.length}`)); - }); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - // Verify schema:5 state envelope + frontier + appliedVV blobs. - expect(mockPersistence.writeBlob).toHaveBeenCalledTimes(7); - - const stateEntries = mockPersistence.writeTree.mock.calls[0][0]; - expect(stateEntries.some((e) => e.includes('\tnodeAlive'))).toBe(true); - expect(stateEntries.some((e) => e.includes('\tedgeAlive'))).toBe(true); - expect(stateEntries.some((e) => e.includes('\tprop.cbor'))).toBe(true); - - const envelopeEntries = mockPersistence.writeTree.mock.calls[1][0]; - expect(envelopeEntries.some((e) => e.includes('\tstate'))).toBe(true); - expect(envelopeEntries.some((e) => e.includes('\tfrontier.cbor'))).toBe(true); - expect(envelopeEntries.some((e) => e.includes('\tappliedVV.cbor'))).toBe(true); - expect(envelopeEntries.some((e) => e.includes('\tstate.cbor'))).toBe(false); - - // Verify current schema in message. - const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; - const decoded = decodeCheckpointMessage(messageArg); - expect(decoded.schema).toBe(5); - }); - - it('compacts tombstoned dots when compact=true', async () => { - const state = createEmptyState(); - const dot = Dot.create('alice', 1); - state.nodeAlive.add('deleted', dot); - state.nodeAlive.remove(new Set([encodeDot(dot)])); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - const stateEnvelopeBlobs: Uint8Array[] = []; - mockPersistence.writeBlob.mockImplementation((buffer) => { - if (stateEnvelopeBlobs.length < 5) { - stateEnvelopeBlobs.push(buffer); - } - return Promise.resolve(makeOid('blob')); - }); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - compact: true, - crypto, - }); - - // Verify the state blob was compacted (tombstoned entry removed) - const restoredState = deserializeEnvelopeFromBlobOrder(stateEnvelopeBlobs); - // After compaction, the tombstoned entry should be removed - expect(restoredState.nodeAlive.entries.has('deleted')).toBe(false); - expect(restoredState.nodeAlive.tombstones.size).toBe(0); - }); - - it('preserves tombstoned dots when compact=false', async () => { - const state = createEmptyState(); - const dot = Dot.create('alice', 1); - state.nodeAlive.add('deleted', dot); - state.nodeAlive.remove(new Set([encodeDot(dot)])); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - const stateEnvelopeBlobs: Uint8Array[] = []; - mockPersistence.writeBlob.mockImplementation((buffer) => { - if (stateEnvelopeBlobs.length < 5) { - stateEnvelopeBlobs.push(buffer); - } - return Promise.resolve(makeOid('blob')); - }); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - compact: false, - crypto, - }); - - // Verify the state blob preserves tombstoned entry - const restoredState = deserializeEnvelopeFromBlobOrder(stateEnvelopeBlobs); - expect(restoredState.nodeAlive.entries.has('deleted')).toBe(true); - expect(restoredState.nodeAlive.tombstones.has('alice:1')).toBe(true); - }); - - it('anchors unique content storage trees in sorted tree order for node and edge content', async () => { - const state = createEmptyState(); - state.nodeAlive.add('n1', Dot.create('alice', 1)); - state.nodeAlive.add('n2', Dot.create('alice', 2)); - state.edgeAlive.add(encodeEdgeKeyV5('n1', 'n2', 'link'), Dot.create('alice', 3)); - - const sharedOid = makeOid('contenta'); - const edgeOid = makeOid('contentb'); - - state.mutatePropRegisterLWW(encodePropKeyV5('n1', CONTENT_PROPERTY_KEY), { - eventId: { lamport: 1, writerId: 'alice', patchSha: makeOid('patch1'), opIndex: 0 }, - value: sharedOid, - }); - state.mutatePropRegisterLWW(encodePropKeyV5('n2', CONTENT_PROPERTY_KEY), { - eventId: { lamport: 2, writerId: 'alice', patchSha: makeOid('patch2'), opIndex: 0 }, - value: sharedOid, - }); - state.mutatePropRegisterLWW(encodeEdgePropKey('n1', 'n2', 'link', CONTENT_PROPERTY_KEY), { - eventId: { lamport: 3, writerId: 'alice', patchSha: makeOid('patch3'), opIndex: 0 }, - value: edgeOid, - }); - state.mutatePropRegisterLWW(encodePropKeyV5('n1', 'label'), { - eventId: { lamport: 4, writerId: 'alice', patchSha: makeOid('patch4'), opIndex: 0 }, - value: 'ignore-me', - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation(() => Promise.resolve(makeOid(`blob${blobIndex++}`))); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - const treeEntries = mockPersistence.writeTree.mock.calls[1][0]; - expect(treeEntries).toEqual([ - `040000 tree ${sharedOid}\t_content_${sharedOid}`, - `040000 tree ${edgeOid}\t_content_${edgeOid}`, - expect.stringContaining('\tappliedVV.cbor'), - expect.stringContaining('\tfrontier.cbor'), - expect.stringContaining('\tstate'), - ]); - }); - - it('anchors large content sets without duplicate entries when batch flushes occur', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - for (let i = 0; i < 300; i++) { - const nodeId = `n${i}`; - state.nodeAlive.add(nodeId, Dot.create('alice', i + 1)); - const contentOid = makeSequentialOid(i); - state.mutatePropRegisterLWW(encodePropKeyV5(nodeId, CONTENT_PROPERTY_KEY), { - eventId: { - lamport: i + 1, - writerId: 'alice', - patchSha: makeOid(`patch${String(i).padStart(3, '0')}`), - opIndex: 0, - }, - value: contentOid, - }); - } - - state.mutatePropRegisterLWW(encodePropKeyV5('n0', 'name'), { - eventId: { lamport: 301, writerId: 'alice', patchSha: makeOid('patchname'), opIndex: 0 }, - value: 'not-content', - }); - state.edgeAlive.add(encodeEdgeKeyV5('n0', 'n1', 'dup'), Dot.create('alice', 301)); - state.mutatePropRegisterLWW(encodeEdgePropKey('n0', 'n1', 'dup', CONTENT_PROPERTY_KEY), { - eventId: { lamport: 302, writerId: 'alice', patchSha: makeOid('patchdup'), opIndex: 0 }, - value: makeSequentialOid(0), - }); - - mockPersistence.writeBlob.mockResolvedValue(makeOid('blob')); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - const treeEntries = mockPersistence.writeTree.mock.calls[1][0]; - const contentEntries = treeEntries.filter((entry) => entry.includes('\t_content_')); - expect(contentEntries).toHaveLength(300); - expect(contentEntries[0]).toBe(`040000 tree ${makeSequentialOid(0)}\t_content_${makeSequentialOid(0)}`); - expect(contentEntries[299]).toBe(`040000 tree ${makeSequentialOid(299)}\t_content_${makeSequentialOid(299)}`); - }); - - it('merges reversed content-anchor batches into sorted unique output', async () => { - const state = createEmptyState(); - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - for (let i = 0; i < 256; i++) { - const nodeId = `high-${i}`; - state.nodeAlive.add(nodeId, Dot.create('alice', i + 1)); - const contentOid = makeSequentialOid(300 + i); - state.mutatePropRegisterLWW(encodePropKeyV5(nodeId, CONTENT_PROPERTY_KEY), { - eventId: { - lamport: i + 1, - writerId: 'alice', - patchSha: makeOid(`high${String(i).padStart(3, '0')}`), - opIndex: 0, - }, - value: contentOid, - }); - } - - for (let i = 0; i < 10; i++) { - const nodeId = `low-${i}`; - state.nodeAlive.add(nodeId, Dot.create('alice', 400 + i)); - const contentOid = makeSequentialOid(i); - state.mutatePropRegisterLWW(encodePropKeyV5(nodeId, CONTENT_PROPERTY_KEY), { - eventId: { - lamport: 400 + i, - writerId: 'alice', - patchSha: makeOid(`low${String(i).padStart(3, '0')}`), - opIndex: 0, - }, - value: contentOid, - }); - } - - mockPersistence.writeBlob.mockResolvedValue(makeOid('blob')); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - }); - - const treeEntries = mockPersistence.writeTree.mock.calls[1][0]; - const contentEntries = treeEntries.filter((entry) => entry.includes('\t_content_')); - expect(contentEntries[0]).toBe(`040000 tree ${makeSequentialOid(0)}\t_content_${makeSequentialOid(0)}`); - expect(contentEntries[9]).toBe(`040000 tree ${makeSequentialOid(9)}\t_content_${makeSequentialOid(9)}`); - expect(contentEntries[10]).toBe(`040000 tree ${makeSequentialOid(300)}\t_content_${makeSequentialOid(300)}`); - }); - }); - - describe('loadCheckpoint for V5', () => { - it('loads V5 checkpoint with full ORSet state', async () => { - // Create and serialize V5 state - const originalState = createEmptyState(); - const dot1 = Dot.create('alice', 1); - const dot2 = Dot.create('bob', 2); - originalState.nodeAlive.add('x', dot1); - originalState.nodeAlive.add('y', dot2); - originalState.edgeAlive.add(encodeEdgeKeyV5('x', 'y', 'conn'), Dot.create('alice', 3)); - originalState.mutatePropRegisterLWW(encodePropKeyV5('x', 'val'), { - eventId: { lamport: 5, writerId: 'alice', patchSha: makeOid('p'), opIndex: 0 }, - value: { type: 'inline', value: 42 }, - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - const appliedVV = computeAppliedVV(originalState); - const stateHash = await computeStateHash(originalState, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state: originalState, - frontier, - stateHash, - appliedVV, - }); - - const result: any = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - // Verify schema - expect(result.schema).toBe(5); - - // Verify full state was loaded (not visible projection) - expect(result.state.nodeAlive).toBeDefined(); - expect(result.state.nodeAlive.entries).toBeDefined(); - expect(result.state.nodeAlive.entries.has('x')).toBe(true); - expect(result.state.nodeAlive.entries.has('y')).toBe(true); - - // Verify dots are preserved - expect(result.state.nodeAlive.entries.get('x').has('alice:1')).toBe(true); - expect(result.state.nodeAlive.entries.get('y').has('bob:2')).toBe(true); - - // Verify appliedVV was loaded - expect(result.appliedVV).toBeDefined(); - expect(result.appliedVV.get('alice')).toBe(3); - expect(result.appliedVV.get('bob')).toBe(2); - }); - - it('ignores _content_ anchor entries when loading a checkpoint tree', async () => { - const originalState = createEmptyState(); - originalState.nodeAlive.add('x', Dot.create('alice', 1)); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - - const stateHash = await computeStateHash(originalState, { crypto, codec: defaultCodec }); - - const contentAnchorOid = makeOid('content'); - - installSchema5CheckpointRead({ - mockPersistence, - state: originalState, - frontier, - stateHash, - indexShardOids: { - [`_content_${contentAnchorOid}`]: contentAnchorOid, - }, - }); - - const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(result.state.nodeAlive.entries.has('x')).toBe(true); - expect(mockPersistence.readBlob).not.toHaveBeenCalledWith(contentAnchorOid); - }); - - it('loads V5 checkpoint without appliedVV for backward compatibility', async () => { - // Create V5 state - const originalState = createEmptyState(); - originalState.nodeAlive.add('a', Dot.create('w1', 1)); - - const frontier = createFrontier(); - updateFrontier(frontier, 'w1', makeOid('sha1')); - - const stateHash = await computeStateHash(originalState, { crypto, codec: defaultCodec }); - - installSchema5CheckpointRead({ - mockPersistence, - state: originalState, - frontier, - stateHash, - includeAppliedVV: false, - }); - - const result: any = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(result.schema).toBe(5); - expect(result.state.nodeAlive.entries.has('a')).toBe(true); - expect(result.appliedVV).toBeNull(); - }); - }); - - describe('V5 checkpoint roundtrip', () => { - it('create -> loadCheckpoint preserves full state', async () => { - // Build V5 state with various elements - const state = createEmptyState(); - const aliceDot1 = Dot.create('alice', 1); - const aliceDot2 = Dot.create('alice', 2); - const bobDot1 = Dot.create('bob', 1); - - state.nodeAlive.add('n1', aliceDot1); - state.nodeAlive.add('n2', aliceDot2); - state.nodeAlive.add('n3', bobDot1); - state.edgeAlive.add(encodeEdgeKeyV5('n1', 'n2', 'follows'), Dot.create('alice', 3)); - state.edgeAlive.add(encodeEdgeKeyV5('n2', 'n3', 'knows'), Dot.create('bob', 2)); - - state.mutatePropRegisterLWW(encodePropKeyV5('n1', 'name'), { - eventId: { lamport: 10, writerId: 'alice', patchSha: makeOid('p1'), opIndex: 0 }, - value: { type: 'inline', value: 'Alice' }, - }); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - updateFrontier(frontier, 'bob', makeOid('sha2')); - - // Capture written data during create. - const writtenBlobs = new Map(); - let writtenMessage: any; - let capturedStateTree: string[] = []; - let capturedEnvelopeTree: string[] = []; - let blobIndex = 0; - - mockPersistence.writeBlob.mockImplementation((buffer) => { - const oid = makeSequentialOid(++blobIndex); - writtenBlobs.set(oid, buffer); - return Promise.resolve(oid); - }); - mockPersistence.writeTree - .mockImplementationOnce((entries) => { - capturedStateTree = entries; - return Promise.resolve(makeOid('state-tree')); - }) - .mockImplementationOnce((entries) => { - capturedEnvelopeTree = entries; - return Promise.resolve(makeOid('envelope')); - }); - mockPersistence.commitNodeWithTree.mockImplementation((/** @type {any} */ { message }) => { - writtenMessage = message; - return Promise.resolve(makeOid('checkpointSha')); - }); - - // Create checkpoint - await create(({ - persistence: mockPersistence, - graphName: 'roundtrip-v5', - state, - frontier, - compact: false, // Don't compact to preserve all state - crypto, - } as any)); - - // Setup mocks for loading - mockPersistence.showNode.mockResolvedValue(writtenMessage); - mockPersistence.getNodeInfo.mockResolvedValue({ sha: makeOid('checkpointSha') }); - const treeOids: Record = {}; - for (const entry of capturedStateTree) { - const { oid, path } = splitTreeEntry(entry); - treeOids[`state/${path}`] = oid; - } - for (const entry of capturedEnvelopeTree) { - const { oid, path } = splitTreeEntry(entry); - if (path !== 'state') { - treeOids[path] = oid; - } - } - mockPersistence.readTreeOids.mockResolvedValue(treeOids); - mockPersistence.readBlob.mockImplementation((oid) => { - const blob = writtenBlobs.get(oid); - if (blob !== undefined) { - return Promise.resolve(blob); - } - throw new Error(`Unknown oid: ${oid}`); - }); - - // Load checkpoint - const loaded: any = await loadCheckpoint(mockPersistence, makeOid('checkpointSha')); - - // Verify schema - expect(loaded.schema).toBe(5); - - // Verify nodes with dots preserved - expect(loaded.state.nodeAlive.entries.has('n1')).toBe(true); - expect(loaded.state.nodeAlive.entries.has('n2')).toBe(true); - expect(loaded.state.nodeAlive.entries.has('n3')).toBe(true); - expect(loaded.state.nodeAlive.entries.get('n1').has('alice:1')).toBe(true); - expect(loaded.state.nodeAlive.entries.get('n2').has('alice:2')).toBe(true); - expect(loaded.state.nodeAlive.entries.get('n3').has('bob:1')).toBe(true); - - // Verify edges with dots preserved - const edge1Key = encodeEdgeKeyV5('n1', 'n2', 'follows'); - const edge2Key = encodeEdgeKeyV5('n2', 'n3', 'knows'); - expect(loaded.state.edgeAlive.entries.has(edge1Key)).toBe(true); - expect(loaded.state.edgeAlive.entries.has(edge2Key)).toBe(true); - - // Verify props - const propKey = encodePropKeyV5('n1', 'name'); - expect(loaded.state.hasProp(propKey)).toBe(true); - expect(loaded.state.getEncodedProp(propKey).value).toEqual({ type: 'inline', value: 'Alice' }); - - // Verify frontier - expect(loaded.frontier.get('alice')).toBe(makeOid('sha1')); - expect(loaded.frontier.get('bob')).toBe(makeOid('sha2')); - - // Verify appliedVV - expect(loaded.appliedVV.get('alice')).toBe(3); - expect(loaded.appliedVV.get('bob')).toBe(2); - }); - - }); - - describe('appliedVV correctness', () => { - it('appliedVV is computed and saved correctly', async () => { - const state = createEmptyState(); - // Add various dots - state.nodeAlive.add('a', Dot.create('alice', 5)); - state.nodeAlive.add('b', Dot.create('alice', 3)); - state.nodeAlive.add('c', Dot.create('bob', 7)); - state.edgeAlive.add(encodeEdgeKeyV5('a', 'b', 'x'), Dot.create('alice', 10)); - - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid('sha1')); - updateFrontier(frontier, 'bob', makeOid('sha2')); - - let capturedAppliedVVBlob: any; - let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation((buffer) => { - if (blobIndex === 6) { - capturedAppliedVVBlob = buffer; - } - blobIndex++; - return Promise.resolve(makeOid(`blob${blobIndex}`)); - }); - mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - compact: false, - crypto, - }); - - // Deserialize and verify appliedVV - const appliedVV = deserializeAppliedVV(capturedAppliedVVBlob, { codec: defaultCodec }); - expect(appliedVV.get('alice')).toBe(10); // max counter for alice - expect(appliedVV.get('bob')).toBe(7); // max counter for bob - }); - }); + it('replays only causal suffix patches after the checkpoint frontier', async () => { + const checkpointStore = new InMemoryCheckpointStore(); + const checkpointTip = 'a'.repeat(40); + const targetTip = 'b'.repeat(40); + const sha = await createCheckpointEnvelope({ + checkpointStore, + graphName: 'events', + state: stateWithNode('node:a'), + frontier: new Map([['alice', checkpointTip]]), + compact: false, + stateHashService, + }); + const patchLoader = vi.fn(async () => [{ + sha: targetTip, + patch: new Patch({ + schema: 3, + writer: 'alice', + lamport: 2, + context: { alice: 1 }, + ops: [new NodeAdd('node:b', Dot.create('alice', 2))], + reads: [], + writes: ['node:b'], + }), + }]); + + const materialized = await materializeIncremental({ + checkpointStore, + graphName: 'events', + checkpointSha: sha, + targetFrontier: new Map([['alice', targetTip]]), + patchLoader, + }); + + expect(patchLoader).toHaveBeenCalledWith('alice', checkpointTip, targetTip); + expect(materialized.nodeAlive.contains('node:a')).toBe(true); + expect(materialized.nodeAlive.contains('node:b')).toBe(true); }); - describe('schema:5 index subtree', () => { - it('creates schema:5 checkpoint with index subtree when indexTree is provided', async () => { - const state = createEmptyState(); - const dot = Dot.create('writer1', 1); - state.nodeAlive.add('x', dot); - - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('aaa')); - - const blobStore = new Map(); - let blobCounter = 0; - mockPersistence.writeBlob.mockImplementation((buf) => { - const oid = makeOid(`b${String(blobCounter++).padStart(3, '0')}`); - blobStore.set(oid, buf); - return Promise.resolve(oid); - }); - - const treeOids = new Map(); - let treeCounter = 0; - mockPersistence.writeTree.mockImplementation((entries) => { - const oid = makeOid(`t${String(treeCounter++).padStart(3, '0')}`); - treeOids.set(oid, entries); - return Promise.resolve(oid); - }); - - mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('ccc')); - - // Simulate an index tree with a few shards - const indexTree = { - 'meta_ab.cbor': Buffer.from('meta-data'), - 'fwd_cd.cbor': Buffer.from('fwd-data'), - }; - - await createCheckpointEnvelope({ - persistence: mockPersistence, - graphName: 'test', - state, - frontier, - crypto, - indexTree, - }); - - expect(mockPersistence.writeTree).toHaveBeenCalledTimes(3); - - const stateEntries = mockPersistence.writeTree.mock.calls[0][0]; - expect(stateEntries.some((entry) => entry.includes('\tnodeAlive'))).toBe(true); - - const subtreeEntries = mockPersistence.writeTree.mock.calls[1][0]; - expect(subtreeEntries.length).toBe(2); - for (const entry of subtreeEntries) { - expect(entry).toMatch(/^100644 blob/); - } - - const mainEntries = mockPersistence.writeTree.mock.calls[2][0]; - const indexEntry = mainEntries.find((e) => e.includes('\tindex')); - expect(indexEntry).toBeDefined(); - expect(indexEntry).toMatch(/^040000 tree/); - - const stateEntry = mainEntries.find((e) => e.includes('\tstate')); - expect(stateEntry).toBeDefined(); - - // Commit message has current schema. - const commitArgs = mockPersistence.commitNodeWithTree.mock.calls[0][0]; - expect(commitArgs.message).toContain('eg-schema: 5'); + it('reconstructs nodes, edges, and properties from a visible projection', () => { + const state = reconstructStateFromCheckpoint({ + nodes: ['node:a', 'node:b'], + edges: [{ from: 'node:a', to: 'node:b', label: 'knows' }], + props: [{ node: 'node:a', key: 'name', value: 'Alice' }], }); - it('loads schema:5 checkpoint with indexShardOids', async () => { - const state = createEmptyState(); - const dot = Dot.create('writer1', 1); - state.nodeAlive.add('x', dot); - - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('aaa')); - - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - indexShardOids: { - 'index/meta_ab.cbor': makeOid('idxmeta'), - 'index/fwd_cd.cbor': makeOid('idxfwd'), - }, - }); - - const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(result.schema).toBe(5); - expect(result.indexShardOids).not.toBeNull(); - if (!result.indexShardOids) { throw new Error('expected indexShardOids'); } - expect(result.indexShardOids['meta_ab.cbor']).toBe(makeOid('idxmeta')); - expect(result.indexShardOids['fwd_cd.cbor']).toBe(makeOid('idxfwd')); - }); - - it('returns null indexShardOids for schema:5 checkpoints without index subtree', async () => { - const state = createEmptyState(); - const dot = Dot.create('writer1', 1); - state.nodeAlive.add('x', dot); - - const frontier = createFrontier(); - updateFrontier(frontier, 'writer1', makeOid('aaa')); - - const stateHash = await computeStateHash(state, { crypto, codec: defaultCodec }); - installSchema5CheckpointRead({ - mockPersistence, - state, - frontier, - stateHash, - }); - - const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); - - expect(result.schema).toBe(5); - expect(result.indexShardOids).toBeNull(); - }); + expect(state.nodeAlive.contains('node:a')).toBe(true); + expect(state.edgeAlive.contains('node:a\0node:b\0knows')).toBe(true); + expect(state.getNodeProp('node:a', 'name')?.value).toBe('Alice'); }); }); diff --git a/test/unit/domain/services/ContentAttachmentProjection.test.ts b/test/unit/domain/services/ContentAttachmentProjection.test.ts index a9355c61..f7650f33 100644 --- a/test/unit/domain/services/ContentAttachmentProjection.test.ts +++ b/test/unit/domain/services/ContentAttachmentProjection.test.ts @@ -115,10 +115,10 @@ function describeContent(record: ContentAttachmentRecord | null): string { const mime = record.payload.mime?.toString() ?? 'null'; const size = record.payload.size?.toNumber() ?? 'null'; if (record.owner instanceof NodeRecord) { - return `node:${record.owner.id.toString()}:${record.payload.oid.toString()}:${mime}:${size}`; + return `node:${record.owner.id.toString()}:${record.payload.handle.toString()}:${mime}:${size}`; } if (record.owner instanceof EdgeRecord) { - return `edge:${record.owner.id.toString()}:${record.payload.oid.toString()}:${mime}:${size}`; + return `edge:${record.owner.id.toString()}:${record.payload.handle.toString()}:${mime}:${size}`; } return 'unknown'; } diff --git a/test/unit/domain/services/LogicalIndexReader.test.ts b/test/unit/domain/services/LogicalIndexReader.test.ts index d5817c34..efd3530e 100644 --- a/test/unit/domain/services/LogicalIndexReader.test.ts +++ b/test/unit/domain/services/LogicalIndexReader.test.ts @@ -12,6 +12,9 @@ import { createEmptyState, applyPatchOp } from '../../../../src/domain/services/ import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import { EventId } from '../../../../src/domain/utils/EventId.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import MockIndexStorage from '../../../helpers/MockIndexStorage.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -137,30 +140,36 @@ describe('LogicalIndexReader', () => { expect(idx.isAlive('X')).toBe(true); expect(idx.getEdges('X', 'out')).toEqual([{ neighborId: 'Y', label: 'owns' }]); }); + + it('preserves the loaded index when a replacement tree cannot be decoded', () => { + const state = fixtureToState(F7_MULTILABEL_SAME_NEIGHBOR); + const service = new MaterializedViewService({ codec: defaultCodec }); + const reader = new LogicalIndexReader({ codec: defaultCodec }); + reader.loadFromTree(service.build(state).tree); + + expect(() => reader.loadFromTree({ + 'meta_ff.cbor': new Uint8Array([0xff]), + })).toThrow(); + + expect(reader.toLogicalIndex().isAlive('A')).toBe(true); + expect(reader.toLogicalIndex().getEdges('A', 'out')).toHaveLength(2); + }); }); - describe('loadFromOids', () => { - it('loads shards lazily via mock storage', async () => { + describe('loadFromHandles', () => { + it('loads shards through the semantic index store', async () => { const state = fixtureToState(F7_MULTILABEL_SAME_NEIGHBOR); const service = new MaterializedViewService({ codec: defaultCodec }); const { tree } = service.build(state); - // Simulate OID→buffer mapping - /** @type {Record} */ const shardOids = {}; - const blobStore = new Map(); - let oidCounter = 0; + const storage = new MockIndexStorage(); + const shardHandles = {} as Record>>; for (const [path, buf] of Object.entries(tree)) { - const oid = `oid_${String(oidCounter++).padStart(4, '0')}`; - shardOids[path] = oid; - blobStore.set(oid, buf); + shardHandles[path] = await storage.writeBlob(buf); } - const mockStorage = { - readBlob: vi.fn((oid) => Promise.resolve(blobStore.get(oid))), - }; - - const reader = new LogicalIndexReader({ codec: defaultCodec }); - await reader.loadFromOids(shardOids, mockStorage); + const reader = new LogicalIndexReader({ codec: defaultCodec, indexStore: storage }); + await reader.loadFromHandles(shardHandles); const idx = reader.toLogicalIndex(); expect(idx.isAlive('A')).toBe(true); @@ -169,8 +178,23 @@ describe('LogicalIndexReader', () => { const outEdges = idx.getEdges('A', 'out'); expect(outEdges.length).toBe(2); - // Verify storage was called - expect(mockStorage.readBlob).toHaveBeenCalled(); + expect(storage.writeBlob).toHaveBeenCalled(); + }); + + it('preserves the loaded index when a replacement asset cannot be decoded', async () => { + const state = fixtureToState(F7_MULTILABEL_SAME_NEIGHBOR); + const service = new MaterializedViewService({ codec: defaultCodec }); + const { tree } = service.build(state); + const storage = new MockIndexStorage(); + const reader = new LogicalIndexReader({ codec: defaultCodec, indexStore: storage }); + reader.loadFromTree(tree); + + await expect(reader.loadFromHandles({ + 'meta_ff.cbor': new AssetHandle('missing-replacement-shard'), + })).rejects.toMatchObject({ code: 'E_INDEX_SHARD_MISSING' }); + + expect(reader.toLogicalIndex().isAlive('A')).toBe(true); + expect(reader.toLogicalIndex().getEdges('A', 'out')).toHaveLength(2); }); }); @@ -300,38 +324,39 @@ describe('LogicalIndexReader', () => { }); }); - describe('loadFromOids with indexStore (decodeShard path)', () => { - it('uses indexStore.decodeShard instead of storage.readBlob + codec', async () => { + describe('loadFromHandles with indexStore', () => { + it('uses indexStore.decodeShard for opaque shard handles', async () => { const state = fixtureToState(F7_MULTILABEL_SAME_NEIGHBOR); const service = new MaterializedViewService({ codec: defaultCodec }); const { tree } = service.build(state); - // Build shardOids and a decoded-data map (simulating what decodeShard returns) - /** @type {Record} */ const shardOids = {}; - /** @type {Map} */ const decodedByOid = new Map(); + const shardHandles: Record = {}; + const decodedByHandle = new Map(); let oidCounter = 0; for (const [path, buf] of Object.entries(tree)) { - const oid = `oid_${String(oidCounter++).padStart(4, '0')}`; - shardOids[path] = oid; - decodedByOid.set(oid, defaultCodec.decode(buf)); + const handle = new AssetHandle(`test-shard:${String(oidCounter++).padStart(4, '0')}`); + shardHandles[path] = handle; + decodedByHandle.set(handle.toString(), defaultCodec.decode(buf)); } + let activeDecodes = 0; + let maximumConcurrentDecodes = 0; const mockIndexStore = ((({ - decodeShard: vi.fn((oid) => Promise.resolve(decodedByOid.get(oid))), + decodeShard: vi.fn(async (handle: AssetHandle) => { + activeDecodes += 1; + maximumConcurrentDecodes = Math.max(maximumConcurrentDecodes, activeDecodes); + await Promise.resolve(); + activeDecodes -= 1; + return decodedByHandle.get(handle.toString()); + }), })) as any); - const mockStorage = { - readBlob: vi.fn(), - }; - const reader = new LogicalIndexReader({ indexStore: mockIndexStore }); - await reader.loadFromOids(shardOids, mockStorage); + await reader.loadFromHandles(shardHandles); const idx = reader.toLogicalIndex(); - // indexStore.decodeShard was used expect(mockIndexStore.decodeShard).toHaveBeenCalled(); - // storage.readBlob was NOT used - expect(mockStorage.readBlob).not.toHaveBeenCalled(); + expect(maximumConcurrentDecodes).toBe(1); // Results are correct expect(idx.isAlive('A')).toBe(true); @@ -346,13 +371,16 @@ describe('LogicalIndexReader', () => { const state = fixtureToState(F7_MULTILABEL_SAME_NEIGHBOR); const buildService = new LogicalIndexBuildService(); const { shards } = buildService.buildShards(state); + const shardStream = WarpStream.from(shards); + const collect = vi.spyOn(shardStream, 'collect'); const mockIndexStore = ((({ - scanShards: vi.fn(() => WarpStream.from(shards)), + scanShards: vi.fn(() => shardStream), })) as any); const reader = new LogicalIndexReader({ indexStore: mockIndexStore }); - await reader.loadFromStore('fake-tree-oid'); + const indexHandle = new BundleHandle('test-index'); + await reader.loadFromStore(indexHandle); const idx = reader.toLogicalIndex(); expect(idx.isAlive('A')).toBe(true); @@ -364,12 +392,14 @@ describe('LogicalIndexReader', () => { const labels = outEdges.map((e) => e.label).sort(); expect(labels).toEqual(['manages', 'owns']); - expect(mockIndexStore.scanShards).toHaveBeenCalledWith('fake-tree-oid'); + expect(mockIndexStore.scanShards).toHaveBeenCalledWith(indexHandle); + expect(collect).not.toHaveBeenCalled(); }); it('throws when no indexStore is configured', async () => { const reader = new LogicalIndexReader({ codec: defaultCodec }); - await expect(reader.loadFromStore('any-oid')).rejects.toThrow(/indexStore/i); + await expect(reader.loadFromStore(new BundleHandle('any-index'))) + .rejects.toThrow(/indexStore/i); }); }); diff --git a/test/unit/domain/services/MaterializedViewService.test.ts b/test/unit/domain/services/MaterializedViewService.test.ts index 35d68e31..46aca8aa 100644 --- a/test/unit/domain/services/MaterializedViewService.test.ts +++ b/test/unit/domain/services/MaterializedViewService.test.ts @@ -1,229 +1,84 @@ -import { describe, it, expect, vi } from 'vitest'; -import MaterializedViewService from '../../../../src/domain/services/MaterializedViewService.ts'; -import { createEmptyState, applyPatchOp } from '../../../../src/domain/services/JoinReducer.ts'; +import { describe, expect, it } from 'vitest'; + import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import { applyPatchOp, createEmptyState } from '../../../../src/domain/services/JoinReducer.ts'; +import MaterializedViewService from '../../../../src/domain/services/MaterializedViewService.ts'; import { EventId } from '../../../../src/domain/utils/EventId.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; -// ── Helpers ────────────────────────────────────────────────────────────────── - function buildTestState() { const state = createEmptyState(); const writer = 'w1'; const sha = 'a'.repeat(40); - let opIdx = 0; + let opIndex = 0; let lamport = 1; - // Add nodes for (const nodeId of ['A', 'B', 'C']) { - const dot = Dot.create(writer, lamport); - const eventId = new EventId(lamport, writer, sha, opIdx++); - applyPatchOp(state, { type: 'NodeAdd', node: nodeId, dot }, eventId); - lamport++; + applyPatchOp( + state, + { type: 'NodeAdd', node: nodeId, dot: Dot.create(writer, lamport) }, + new EventId(lamport, writer, sha, opIndex++), + ); + lamport += 1; } - - // Add edges - for (const { from, to, label } of [ + for (const edge of [ { from: 'A', to: 'B', label: 'manages' }, { from: 'A', to: 'C', label: 'owns' }, ]) { - const dot = Dot.create(writer, lamport); - const eventId = new EventId(lamport, writer, sha, opIdx++); - applyPatchOp(state, { type: 'EdgeAdd', from, to, label, dot }, eventId); - lamport++; + applyPatchOp( + state, + { type: 'EdgeAdd', ...edge, dot: Dot.create(writer, lamport) }, + new EventId(lamport, writer, sha, opIndex++), + ); + lamport += 1; } - - // Add properties - for (const { nodeId, key, value } of [ - { nodeId: 'A', key: 'name', value: 'Alice' }, - { nodeId: 'B', key: 'role', value: 'admin' }, + for (const property of [ + { node: 'A', key: 'name', value: 'Alice' }, + { node: 'B', key: 'role', value: 'admin' }, ]) { - const eventId = new EventId(lamport, writer, sha, opIdx++); - applyPatchOp(state, { type: 'PropSet', node: nodeId, key, value }, eventId); - lamport++; + applyPatchOp( + state, + { type: 'PropSet', ...property }, + new EventId(lamport, writer, sha, opIndex++), + ); + lamport += 1; } - return state; } -// ── Tests ──────────────────────────────────────────────────────────────────── - describe('MaterializedViewService', () => { - describe('build', () => { - it('builds logicalIndex and receipt from state', () => { - const service = new MaterializedViewService({ codec: defaultCodec }); - const state = buildTestState(); - const { tree, logicalIndex, receipt } = service.build(state); - - // tree is populated - expect(Object.keys(tree).length).toBeGreaterThan(0); - - // logicalIndex works - expect(logicalIndex.isAlive('A')).toBe(true); - expect(logicalIndex.isAlive('B')).toBe(true); - expect(logicalIndex.isAlive('Z')).toBe(false); - - // receipt has nodeCount - expect(/** @type {{ nodeCount: number }} */ (receipt)['nodeCount']).toBe(3); - }); - - it('builds a working propertyReader from state', async () => { - const service = new MaterializedViewService({ codec: defaultCodec }); - const state = buildTestState(); - const { propertyReader } = service.build(state); - - expect(propertyReader).not.toBeNull(); - - // Verify property reader returns correct node properties - const propsA = await propertyReader.getNodeProps('A'); - expect(propsA).toEqual({ name: 'Alice' }); - - const propsB = await propertyReader.getNodeProps('B'); - expect(propsB).toEqual({ role: 'admin' }); - - // Node with no properties returns null - const propsC = await propertyReader.getNodeProps('C'); - expect(propsC).toBeNull(); - - // Non-existent node returns null - const propsZ = await propertyReader.getNodeProps('Z'); - expect(propsZ).toBeNull(); - }); - - it('logicalIndex getEdges returns correct edges', () => { - const service = new MaterializedViewService({ codec: defaultCodec }); - const state = buildTestState(); - const { logicalIndex } = service.build(state); - - const edges = logicalIndex.getEdges('A', 'out'); - expect(edges.length).toBe(2); - const labels = edges.map((e) => e.label).sort(); - expect(labels).toEqual(['manages', 'owns']); - }); + it('builds a logical index and receipt from state', () => { + const result = new MaterializedViewService({ codec: defaultCodec }).build(buildTestState()); + + expect(Object.keys(result.tree).length).toBeGreaterThan(0); + expect(result.logicalIndex.isAlive('A')).toBe(true); + expect(result.logicalIndex.isAlive('B')).toBe(true); + expect(result.logicalIndex.isAlive('Z')).toBe(false); + expect(result.receipt['nodeCount']).toBe(3); }); - describe('persistIndexTree', () => { - it('writes blobs and creates tree, returns OID', async () => { - const service = new MaterializedViewService({ codec: defaultCodec }); - const state = buildTestState(); - const { tree } = service.build(state); - - const blobOids = new Map(); - let blobCounter = 0; - const mockPersistence = { - writeBlob: vi.fn((buf) => { - const oid = `blob_${String(blobCounter++).padStart(4, '0')}${'0'.repeat(35)}`; - blobOids.set(oid, buf); - return Promise.resolve(oid); - }), - writeTree: vi.fn(() => Promise.resolve('tree_oid_' + '0'.repeat(31))), - }; - - const treeOid = await service.persistIndexTree(tree, mockPersistence); + it('builds a lazy property reader for the materialized view', async () => { + const { propertyReader } = new MaterializedViewService({ codec: defaultCodec }) + .build(buildTestState()); - // writeBlob called for each shard - expect(mockPersistence.writeBlob).toHaveBeenCalledTimes(Object.keys(tree).length); - // writeTree called once - expect(mockPersistence.writeTree).toHaveBeenCalledTimes(1); - // Returns the tree OID - expect(treeOid).toBe('tree_oid_' + '0'.repeat(31)); - - // Tree entries are sorted and have correct format - const firstCall = (mockPersistence.writeTree.mock.calls as string[][])[0]; - if (!firstCall) { throw new Error('expected calls'); } - const treeEntries = firstCall[0]; - if (!treeEntries) { throw new Error('expected treeEntries'); } - for (const entry of treeEntries) { - expect(entry).toMatch(/^100644 blob [^\s]+\t/); - } - }); + await expect(propertyReader.getNodeProps('A')).resolves.toEqual({ name: 'Alice' }); + await expect(propertyReader.getNodeProps('B')).resolves.toEqual({ role: 'admin' }); + await expect(propertyReader.getNodeProps('C')).resolves.toBeNull(); + await expect(propertyReader.getNodeProps('Z')).resolves.toBeNull(); }); - describe('loadFromOids', () => { - it('loads logicalIndex from shard OIDs via storage', async () => { - const service = new MaterializedViewService({ codec: defaultCodec }); - const state = buildTestState(); - const { tree } = service.build(state); - - // Simulate OID→buffer mapping (only index shards, not props) - /** @type {Record} */ const shardOids = {}; - const blobStore = new Map(); - let oidCounter = 0; - for (const [path, buf] of Object.entries(tree)) { - const oid = `oid_${String(oidCounter++).padStart(4, '0')}${'0'.repeat(35)}`; - shardOids[path] = oid; - blobStore.set(oid, buf); - } - - const mockStorage = { - readBlob: vi.fn((oid) => Promise.resolve(blobStore.get(oid))), - }; - - const result = await service.loadFromOids(shardOids, mockStorage); + it('builds deterministic neighborhood queries', () => { + const { logicalIndex } = new MaterializedViewService({ codec: defaultCodec }) + .build(buildTestState()); - expect(result.logicalIndex).toBeDefined(); - expect(result.logicalIndex.isAlive('A')).toBe(true); - expect(result.logicalIndex.isAlive('Z')).toBe(false); - - expect(result.propertyReader).toBeDefined(); - }); + expect(logicalIndex.getEdges('A', 'out').map((edge) => edge.label).sort()) + .toEqual(['manages', 'owns']); }); - describe('roundtrip: build → persist → load', () => { - it('produces identical query results after roundtrip', async () => { - const service = new MaterializedViewService({ codec: defaultCodec }); - const state = buildTestState(); - const { tree, logicalIndex: origIndex } = service.build(state); - - // Persist - const blobStore = new Map(); - let blobCounter = 0; - const mockPersistence = { - writeBlob: vi.fn((buf) => { - const oid = `b${String(blobCounter++).padStart(3, '0')}${'0'.repeat(36)}`; - blobStore.set(oid, buf); - return Promise.resolve(oid); - }), - writeTree: vi.fn((_entries) => { - // Extract OIDs from entries to build shardOids - return Promise.resolve('tree_' + '0'.repeat(35)); - }), - }; - - await service.persistIndexTree(tree, mockPersistence); - - // Build shardOids from writeBlob calls - /** @type {Record} */ const shardOids = {}; - const firstCall = (mockPersistence.writeTree.mock.calls as string[][])[0]; - if (!firstCall) { throw new Error('expected calls'); } - const treeEntries = firstCall[0]; - if (!treeEntries) { throw new Error('expected treeEntries'); } - for (const entry of treeEntries) { - const match = entry.match(/^100644 blob ([^\s]+)\t(.+)$/); - if (match && match[2] !== undefined && match[1] !== undefined) { - shardOids[match[2]] = match[1]; - } - } - - const mockStorage = { - readBlob: vi.fn((oid) => Promise.resolve(blobStore.get(oid))), - }; - - // Load - const { logicalIndex: loadedIndex } = await service.loadFromOids(shardOids, mockStorage); - - // Compare results - expect(loadedIndex.isAlive('A')).toBe(origIndex.isAlive('A')); - expect(loadedIndex.isAlive('B')).toBe(origIndex.isAlive('B')); - expect(loadedIndex.isAlive('Z')).toBe(origIndex.isAlive('Z')); - - const origEdges = origIndex.getEdges('A', 'out'); - const loadedEdges = loadedIndex.getEdges('A', 'out'); - expect(loadedEdges.length).toBe(origEdges.length); + it('does not expose physical persistence methods', () => { + const service = new MaterializedViewService({ codec: defaultCodec }); - const origLabels = origEdges.map((e) => e.label).sort(); - const loadedLabels = loadedEdges.map((e) => e.label).sort(); - expect(loadedLabels).toEqual(origLabels); - }); + expect('persistIndexTree' in service).toBe(false); + expect('loadFromOids' in service).toBe(false); }); }); diff --git a/test/unit/domain/services/MessageCodecModules.test.ts b/test/unit/domain/services/MessageCodecModules.test.ts index 58762649..7a3336ca 100644 --- a/test/unit/domain/services/MessageCodecModules.test.ts +++ b/test/unit/domain/services/MessageCodecModules.test.ts @@ -32,6 +32,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 AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; import PropSet from '../../../../src/domain/types/ops/PropSet.ts'; const OID = 'a'.repeat(40); @@ -73,20 +74,21 @@ describe('message codec modules', () => { }); const anchorMessage = encodeAnchorMessage({ graph: 'events', schema: 3 }); - expect(decodePatchMessage(patchMessage)).toMatchObject({ + const decodedPatch = decodePatchMessage(patchMessage); + expect(decodedPatch).toMatchObject({ kind: 'patch', graph: 'events', writer: 'writer-1', lamport: 7, - patchOid: OID, schema: 3, }); + expect(decodedPatch.patchHandle).toBeInstanceOf(AssetHandle); + expect(decodedPatch.patchHandle.toString()).toBe(OID); + expect('patchOid' in decodedPatch).toBe(false); expect(decodeCheckpointMessage(checkpointMessage)).toMatchObject({ kind: 'checkpoint', graph: 'events', stateHash: STATE_HASH, - frontierOid: OID, - indexOid: OID, schema: 3, }); expect(decodeAnchorMessage(anchorMessage)).toMatchObject({ diff --git a/test/unit/domain/services/PatchBuilder.cas.test.ts b/test/unit/domain/services/PatchBuilder.cas.test.ts index 8a82b7c6..5daf68c7 100644 --- a/test/unit/domain/services/PatchBuilder.cas.test.ts +++ b/test/unit/domain/services/PatchBuilder.cas.test.ts @@ -1,285 +1,131 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; -import { WriterError } from '../../../../src/domain/warp/Writer.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; - -/** - * Creates a mock persistence adapter for CAS testing. - * - * @param {Object} [overrides] - Method overrides - * @returns {Object} Mock persistence adapter - */ -function createMockPersistence(overrides = {}): any { - const persistence = { - readRef: vi.fn().mockResolvedValue(null), - showNode: vi.fn(), - writeBlob: vi.fn().mockResolvedValue('a'.repeat(40)), - writeTree: vi.fn().mockResolvedValue('b'.repeat(40)), - commitNodeWithTree: vi.fn().mockResolvedValue('c'.repeat(40)), - updateRef: vi.fn().mockResolvedValue(undefined), - compareAndSwapRef: vi.fn(), - ...overrides, - }; - persistence.compareAndSwapRef.mockImplementation(async (ref, newOid, expectedOid) => { - const actualOid = await persistence.readRef(ref); - if (actualOid !== expectedOid) { - throw new Error(`CAS mismatch for ${ref}`); - } - persistence.readRef.mockResolvedValue(newOid); - }); - return persistence; -} - -/** - * Creates a CborPatchJournalAdapter wired to the given persistence's blob ops. - * @param {ReturnType} persistence - * @returns {CborPatchJournalAdapter} - */ -function createPatchJournal(persistence) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - }); -} - -describe('PatchBuilder CAS conflict detection', () => { - it('test fixture compareAndSwapRef rejects expected-head mismatches', async () => { - const currentSha = 'a'.repeat(40); - const nextSha = 'b'.repeat(40); - const persistence = createMockPersistence({ - readRef: vi.fn().mockResolvedValue(currentSha), +import { + createPatchBuilderMockPersistence as createMockPersistence, + createPatchJournal, +} from './PatchBuilderTestHarness.ts'; + +describe('PatchBuilder causal publication conflicts', () => { + it('rejects a stale expected head before calling the journal', async () => { + const expected = 'a'.repeat(40); + const actual = 'f'.repeat(40); + const persistence = createMockPersistence(); + persistence.readRef.mockResolvedValue(actual); + const patchJournal = createPatchJournal(persistence); + const builder = createBuilder({ persistence, patchJournal, expectedParentSha: expected }); + builder.addNode('node:a'); + + await expect(builder.commit()).rejects.toMatchObject({ + code: 'WRITER_CAS_CONFLICT', + expectedSha: expected, + actualSha: actual, }); - - await expect( - persistence.compareAndSwapRef('refs/warp/test-graph/writers/writer1', nextSha, null) - ).rejects.toThrow('CAS mismatch'); + expect(patchJournal.requests).toEqual([]); }); - // --------------------------------------------------------------- - // CAS conflict: ref advanced between createPatch and commit - // --------------------------------------------------------------- - describe('when writer ref advances between createPatch and commit', () => { - it('throws WriterError with code WRITER_CAS_CONFLICT', async () => { - const expectedParent = 'a'.repeat(40); - const advancedSha = 'f'.repeat(40); - - const persistence = createMockPersistence({ - // Simulate ref having advanced to a different SHA - readRef: vi.fn().mockResolvedValue(advancedSha), - }); - - const builder = new PatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: expectedParent, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - builder.addNode('x'); - - await expect(builder.commit()).rejects.toThrow(WriterError); - await expect( - // Re-create builder since the first commit consumed the rejection - new PatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: expectedParent, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }) - .addNode('x') - .commit() - ).rejects.toMatchObject({ code: 'WRITER_CAS_CONFLICT' }); + it('reports deleted and unexpectedly-created refs symmetrically', async () => { + const expected = 'a'.repeat(40); + const deletedPersistence = createMockPersistence(); + deletedPersistence.readRef.mockResolvedValue(null); + const deleted = createBuilder({ + persistence: deletedPersistence, + patchJournal: createPatchJournal(deletedPersistence), + expectedParentSha: expected, }); - - it('includes expectedSha and actualSha properties on the error', async () => { - const expectedParent = 'a'.repeat(40); - const advancedSha = 'f'.repeat(40); - - const persistence = createMockPersistence({ - readRef: vi.fn().mockResolvedValue(advancedSha), - }); - - const builder = new PatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: expectedParent, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - builder.addNode('x'); - - try { - await builder.commit(); - expect.unreachable('commit() should have thrown'); - } catch (/** @type {any} */ err) { - expect(err).toBeInstanceOf(WriterError); - expect((err as any).expectedSha).toBe(expectedParent); - expect((err as any).actualSha).toBe(advancedSha); - } + deleted.addNode('node:a'); + + const createdPersistence = createMockPersistence(); + createdPersistence.readRef.mockResolvedValue('b'.repeat(40)); + const created = createBuilder({ + persistence: createdPersistence, + patchJournal: createPatchJournal(createdPersistence), + expectedParentSha: null, }); + created.addNode('node:a'); - it('error message contains recovery hint', async () => { - const expectedParent = 'a'.repeat(40); - const advancedSha = 'f'.repeat(40); - - const persistence = createMockPersistence({ - readRef: vi.fn().mockResolvedValue(advancedSha), - }); - - const builder = new PatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: expectedParent, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - builder.addNode('x'); - - await expect(builder.commit()).rejects.toThrow( - 'Commit failed: writer ref was updated by another process. Re-materialize and retry.' - ); + await expect(deleted.commit()).rejects.toMatchObject({ + expectedSha: expected, + actualSha: null, }); - - it('handles null expectedParentSha vs non-null actual ref', async () => { - const advancedSha = 'f'.repeat(40); - - const persistence = createMockPersistence({ - readRef: vi.fn().mockResolvedValue(advancedSha), - }); - - const builder = new PatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, // Writer expected no prior commits - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - builder.addNode('x'); - - try { - await builder.commit(); - expect.unreachable('commit() should have thrown'); - } catch (/** @type {any} */ err) { - expect(err).toBeInstanceOf(WriterError); - expect((err as any).code).toBe('WRITER_CAS_CONFLICT'); - expect((err as any).expectedSha).toBeNull(); - expect((err as any).actualSha).toBe(advancedSha); - } + await expect(created.commit()).rejects.toMatchObject({ + expectedSha: null, + actualSha: 'b'.repeat(40), }); + }); - it('handles non-null expectedParentSha vs null actual ref', async () => { - const expectedParent = 'a'.repeat(40); - - const persistence = createMockPersistence({ - // Ref was deleted / does not exist - readRef: vi.fn().mockResolvedValue(null), - }); - - const builder = new PatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: expectedParent, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - builder.addNode('x'); + it('maps a publication conflict to WriterError with the observed head', async () => { + const actual = 'f'.repeat(40); + const persistence = createMockPersistence(); + persistence.readRef.mockResolvedValueOnce(null).mockResolvedValue(actual); + const patchJournal = createPatchJournal(persistence); + patchJournal.failure = Object.assign(new Error('publication conflict'), { + code: 'PUBLICATION_CONFLICT', + }); + const builder = createBuilder({ persistence, patchJournal, expectedParentSha: null }); + builder.addNode('node:a'); - try { - await builder.commit(); - expect.unreachable('commit() should have thrown'); - } catch (/** @type {any} */ err) { - expect(err).toBeInstanceOf(WriterError); - expect((err as any).code).toBe('WRITER_CAS_CONFLICT'); - expect((err as any).expectedSha).toBe(expectedParent); - expect((err as any).actualSha).toBeNull(); - } + await expect(builder.commit()).rejects.toMatchObject({ + code: 'WRITER_CAS_CONFLICT', + expectedSha: null, + actualSha: actual, }); }); - // --------------------------------------------------------------- - // No CAS conflict: normal commits still succeed - // --------------------------------------------------------------- - describe('when no CAS conflict occurs', () => { - it('succeeds when expectedParentSha matches current ref (both null)', async () => { - const persistence = createMockPersistence({ - readRef: vi.fn().mockResolvedValue(null), - }); - - const builder = new PatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); + it('preserves a non-conflict storage failure when the head is unchanged', async () => { + const persistence = createMockPersistence(); + persistence.readRef.mockResolvedValue(null); + const patchJournal = createPatchJournal(persistence); + const failure = new Error('storage offline'); + patchJournal.failure = failure; + const builder = createBuilder({ persistence, patchJournal, expectedParentSha: null }); + builder.addNode('node:a'); - builder.addNode('x'); - const sha = await builder.commit(); + await expect(builder.commit()).rejects.toBe(failure); + }); - expect(sha).toBe('c'.repeat(40)); - expect(persistence.commitNodeWithTree).toHaveBeenCalledOnce(); - expect(persistence.compareAndSwapRef).toHaveBeenCalledOnce(); + it('forwards a matching head and custom target ref to storage', async () => { + const parent = 'a'.repeat(40); + const persistence = createMockPersistence(); + persistence.readRef.mockResolvedValue(parent); + persistence.showNode.mockResolvedValue('not-a-patch-message'); + const patchJournal = createPatchJournal(persistence); + const builder = createBuilder({ + persistence, + patchJournal, + expectedParentSha: parent, + targetRefPath: 'refs/warp/events/strands/review', }); + builder.addNode('node:a'); - it('succeeds when expectedParentSha matches current ref (both same SHA)', async () => { - const parentSha = 'd'.repeat(40); - const patchOid = 'e'.repeat(40); - - const persistence = createMockPersistence({ - readRef: vi.fn().mockResolvedValue(parentSha), - showNode: vi.fn().mockResolvedValue( - `warp:patch\n\neg-kind: patch\neg-graph: test-graph\neg-writer: writer1\neg-lamport: 3\neg-patch-oid: ${patchOid}\neg-schema: 2` - ), - }); - - const builder = new PatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: parentSha, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); + await builder.commit(); - builder.addNode('x'); - const sha = await builder.commit(); - - expect(sha).toBe('c'.repeat(40)); - expect(persistence.commitNodeWithTree).toHaveBeenCalledOnce(); + expect(persistence.readRef).toHaveBeenCalledWith('refs/warp/events/strands/review'); + expect(patchJournal.requests[0]).toMatchObject({ + targetRef: 'refs/warp/events/strands/review', + expectedHead: parent, + parent, }); }); }); + +function createBuilder(options: { + persistence: ReturnType; + patchJournal: ReturnType; + expectedParentSha: string | null; + targetRefPath?: string; +}): PatchBuilder { + return new PatchBuilder({ + persistence: options.persistence, + graphName: 'events', + writerId: 'writer-1', + lamport: 1, + versionVector: VersionVector.empty(), + getCurrentState: () => null, + expectedParentSha: options.expectedParentSha, + ...(options.targetRefPath === undefined ? {} : { targetRefPath: options.targetRefPath }), + patchJournal: options.patchJournal, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); +} diff --git a/test/unit/domain/services/PatchBuilder.commit.test.ts b/test/unit/domain/services/PatchBuilder.commit.test.ts index 26175e4b..d0aa3a01 100644 --- a/test/unit/domain/services/PatchBuilder.commit.test.ts +++ b/test/unit/domain/services/PatchBuilder.commit.test.ts @@ -1,324 +1,180 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import { encodeEdgeKey } from '../../../../src/domain/services/JoinReducer.ts'; -import { decodePatchMessage } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { requirePatchOp } from '../PatchOperationAssertions.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import { createGitCasPatchStorage } from '../../../../src/ports/CommitMessageCodecPort.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import { createPatchBuilder, createPatchBuilderMockPersistence as createMockPersistence, createPatchJournal, - decodeWrittenPatch, } from './PatchBuilderTestHarness.ts'; -describe('PatchBuilder commit', () => { - describe('commit()', () => { - it('commits a patch and returns the commit SHA', async () => { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', +describe('PatchBuilder semantic commit', () => { + it('publishes one causal patch request and returns its commit identity', async () => { + const persistence = createMockPersistence(); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ + persistence, + patchJournal, + graphName: 'events', + writerId: 'writer-1', + lamport: 1, + }); + builder.addNode('node:a').setProperty('node:a', 'status', 'open'); + + await expect(builder.commit()).resolves.toBe('c'.repeat(40)); + + expect(patchJournal.requests).toHaveLength(1); + expect(patchJournal.requests[0]).toMatchObject({ + graph: 'events', + writer: 'writer-1', + targetRef: 'refs/warp/events/writers/writer-1', + expectedHead: null, + parent: null, + attachments: [], + patch: { + schema: 2, + writer: 'writer-1', lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - - builder.addNode('x'); - const sha = await builder.commit(); - - expect(sha).toBe('c'.repeat(40)); - expect(persistence.writeBlob).toHaveBeenCalledOnce(); - expect(persistence.writeTree).toHaveBeenCalledOnce(); - expect(persistence.commitNodeWithTree).toHaveBeenCalledOnce(); - expect(persistence.compareAndSwapRef).toHaveBeenCalledWith( - 'refs/warp/test-graph/writers/writer1', - 'c'.repeat(40), - null, - ); - }); - - it('throws error for empty patch', async () => { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - - await expect(builder.commit()).rejects.toThrow('Cannot commit empty patch'); - }); - - it('creates commit with schema:2 in trailers', async () => { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - - builder.addNode('x'); - await builder.commit(); - - // Check the commit message passed to commitNodeWithTree - const commitCall = persistence.commitNodeWithTree.mock.calls[0]![0]; - const decoded = decodePatchMessage(commitCall.message); - - expect(decoded.schema).toBe(2); - expect(decoded.writer).toBe('writer1'); - expect(decoded.graph).toBe('test-graph'); - expect(decoded.lamport).toBe(1); - }); - - it('increments lamport when continuing from existing ref', async () => { - const persistence = createMockPersistence(); - const existingSha = 'd'.repeat(40); - const existingPatchOid = 'e'.repeat(40); - // Simulate existing ref with lamport 5 - persistence.readRef.mockResolvedValue(existingSha); - persistence.showNode.mockResolvedValue( - `warp:patch\n\neg-kind: patch\neg-graph: test-graph\neg-writer: writer1\neg-lamport: 5\neg-patch-oid: ${existingPatchOid}\neg-schema: 2` - ); - - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, // Constructor lamport is 1, but commit should use 6 - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: existingSha, // Race detection: expected parent matches current ref - }); - - builder.addNode('x'); - await builder.commit(); - - // Check the commit has lamport 6 (5 + 1) - const commitCall = persistence.commitNodeWithTree.mock.calls[0]![0]; - const decoded = decodePatchMessage(commitCall.message); - expect(decoded.lamport).toBe(6); - - // Parent should be the existing commit - expect(commitCall.parents).toEqual([existingSha]); - }); - - it('creates tree with patch.cbor blob', async () => { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - - builder.addNode('x'); - await builder.commit(); - - // Check writeTree was called with correct format - const treeCall = persistence.writeTree.mock.calls[0]![0]; - expect(treeCall).toHaveLength(1); - expect(treeCall[0]).toMatch(/^100644 blob [a-f0-9]+\tpatch\.cbor$/); - }); - - it('writes patch blob with CBOR encoding', async () => { - const persistence = createMockPersistence(); - const patchJournal = createPatchJournal(persistence); - const vv = VersionVector.empty(); - vv.set('otherWriter', 3); - - const builder = createPatchBuilder({ - persistence, - patchJournal, - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: vv, - getCurrentState: () => null, - }); - - builder.addNode('x').setProperty('x', 'name', 'X'); - await builder.commit(); - - // Decode the blob that was written - const patch = decodeWrittenPatch(persistence); - - expect(patch.schema).toBe(2); - expect(patch.writer).toBe('writer1'); - expect(patch.lamport).toBe(1); - expect(patch.ops).toHaveLength(2); - expect(requirePatchOp(patch, 0)).toMatchObject({ type: 'NodeAdd' }); - expect(requirePatchOp(patch, 1)).toMatchObject({ type: 'PropSet' }); - // Context should be serialized version vector - expect(patch.context).toBeDefined(); - }); - - it('first commit has no parents', async () => { - const persistence = createMockPersistence(); - // No existing ref - persistence.readRef.mockResolvedValue(null); - - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - - builder.addNode('x'); - await builder.commit(); - - const commitCall = persistence.commitNodeWithTree.mock.calls[0]![0]; - expect(commitCall.parents).toEqual([]); + writes: ['node:a'], + }, }); }); - describe('use-after-commit guard', () => { - async function createCommittedBuilder() { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - builder.addNode('x'); - await builder.commit(); - return { builder, persistence }; - } + it('returns publication retention evidence from commitWithEvidence()', async () => { + const persistence = createMockPersistence(); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ persistence, patchJournal }); + builder.addNode('node:a'); - it('throws after commit when calling addNode', async () => { - const { builder } = await createCommittedBuilder(); - expect(() => builder.addNode('y')).toThrow('PatchBuilder already committed — create a new builder'); - }); + const result = await builder.commitWithEvidence(); - it('throws after commit when calling removeNode', async () => { - const { builder } = await createCommittedBuilder(); - expect(() => builder.removeNode('x')).toThrow('PatchBuilder already committed — create a new builder'); - }); - - it('throws after commit when calling addEdge', async () => { - const { builder } = await createCommittedBuilder(); - expect(() => builder.addEdge('a', 'b', 'rel')).toThrow('PatchBuilder already committed — create a new builder'); - }); - - it('throws after commit when calling removeEdge', async () => { - const { builder } = await createCommittedBuilder(); - expect(() => builder.removeEdge('a', 'b', 'rel')).toThrow('PatchBuilder already committed — create a new builder'); - }); - - it('throws after commit when calling setProperty', async () => { - const { builder } = await createCommittedBuilder(); - expect(() => builder.setProperty('x', 'name', 'Alice')).toThrow('PatchBuilder already committed — create a new builder'); + expect(result.sha).toBe('c'.repeat(40)); + expect(result.bundleHandle.toString()).toBe('bundle:test-patch'); + expect(result.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + root: { kind: 'publication' }, }); + expect(result.patch.ops).toHaveLength(1); + }); - it('throws after commit when calling setEdgeProperty', async () => { - const { builder } = await createCommittedBuilder(); - expect(() => builder.setEdgeProperty('a', 'b', 'rel', 'since', '2026-01-01')).toThrow('PatchBuilder already committed — create a new builder'); - }); + it('rejects empty patches before calling storage', async () => { + const persistence = createMockPersistence(); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ persistence, patchJournal }); - it('throws after commit when calling attachContent', async () => { - const { builder, persistence } = await createCommittedBuilder(); - const initialWriteBlobCalls = persistence.writeBlob.mock.calls.length; - await expect(builder.attachContent('x', 'payload')).rejects.toThrow('PatchBuilder already committed — create a new builder'); - expect(persistence.writeBlob).toHaveBeenCalledTimes(initialWriteBlobCalls); - }); + await expect(builder.commit()).rejects.toMatchObject({ code: 'E_PATCH_EMPTY' }); + expect(patchJournal.requests).toEqual([]); + }); - it('throws after commit when calling attachEdgeContent', async () => { - const { builder, persistence } = await createCommittedBuilder(); - const initialWriteBlobCalls = persistence.writeBlob.mock.calls.length; - await expect(builder.attachEdgeContent('a', 'b', 'rel', 'payload')).rejects.toThrow('PatchBuilder already committed — create a new builder'); - expect(persistence.writeBlob).toHaveBeenCalledTimes(initialWriteBlobCalls); + it('advances lamport time and forwards the existing causal parent', async () => { + const parent = 'd'.repeat(40); + const persistence = createMockPersistence({ + readRef: createMockPersistence().readRef.mockResolvedValue(parent), + showNode: createMockPersistence().showNode.mockResolvedValue( + DEFAULT_COMMIT_MESSAGE_CODEC.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'writer-1', + lamport: 5, + schema: 2, + patchHandle: new AssetHandle('asset:parent'), + storage: createGitCasPatchStorage({ encrypted: false }), + }), + ), + }); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ + persistence, + patchJournal, + graphName: 'events', + writerId: 'writer-1', + lamport: 1, + expectedParentSha: parent, + }); + builder.addNode('node:a'); + + await builder.commit(); + + expect(patchJournal.requests[0]).toMatchObject({ + expectedHead: parent, + parent, + patch: { lamport: 6 }, }); + }); - it('throws after commit when calling commit again', async () => { - const { builder } = await createCommittedBuilder(); - await expect(builder.commit()).rejects.toThrow('PatchBuilder already committed — create a new builder'); - }); + it('preserves attachment handles in the publication request', async () => { + const persistence = createMockPersistence(); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ persistence, patchJournal }); + builder.addNode('node:a'); + (builder as unknown as { _contentAssets: AssetHandle[] })._contentAssets.push( + new AssetHandle('asset:attachment'), + ); - it('throws during an in-flight commit when mutating or committing again', async () => { - let releaseReadRef: (value: string | null) => void = () => {}; - const readRefPromise: Promise = new Promise((resolve) => { - releaseReadRef = resolve; - }); - const persistence = createMockPersistence(); - persistence.readRef.mockImplementation(() => readRefPromise); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); + await builder.commit(); - builder.addNode('x'); - const pendingCommit = builder.commit(); + expect(patchJournal.requests[0]?.attachments.map(String)).toEqual(['asset:attachment']); + }); - expect(() => builder.addNode('y')).toThrow('PatchBuilder already committed — create a new builder'); - await expect(builder.commit()).rejects.toThrow('PatchBuilder already committed — create a new builder'); + it('blocks mutation and repeat publication after a successful commit', async () => { + const persistence = createMockPersistence(); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ persistence, patchJournal }); + builder.addNode('node:a'); + await builder.commit(); + + expect(() => builder.addNode('node:b')).toThrow(/already committed/u); + await expect(builder.attachContent('node:a', 'payload')).rejects.toThrow(/already committed/u); + await expect(builder.commit()).rejects.toThrow(/already committed/u); + expect(patchJournal.requests).toHaveLength(1); + }); - releaseReadRef(null); - await expect(pendingCommit).resolves.toBe('c'.repeat(40)); - }); + it('blocks mutation while publication is in flight', async () => { + let releaseRead: (head: string | null) => void = () => {}; + const read = new Promise((resolve) => { + releaseRead = resolve; + }); + const persistence = createMockPersistence(); + persistence.readRef.mockImplementation(async () => await read); + const patchJournal = createPatchJournal(persistence); + const builder = createPatchBuilder({ persistence, patchJournal }); + builder.addNode('node:a'); + + const pending = builder.commit(); + expect(() => builder.addNode('node:b')).toThrow(/already committed/u); + await expect(builder.commit()).rejects.toThrow(/already committed/u); + releaseRead(null); + await expect(pending).resolves.toBe('c'.repeat(40)); + }); - it('allows reading ops/reads/writes/versionVector after commit', async () => { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); + it('allows retry after a storage failure', async () => { + const persistence = createMockPersistence(); + const patchJournal = createPatchJournal(persistence); + patchJournal.failure = new Error('publication unavailable'); + const builder = createPatchBuilder({ persistence, patchJournal }); + builder.addNode('node:a'); - builder.addEdge('user:alice', 'user:bob', 'follows'); - await builder.commit(); + await expect(builder.commit()).rejects.toThrow('publication unavailable'); + patchJournal.failure = null; + await expect(builder.commit()).resolves.toBe('c'.repeat(40)); + }); - const edgeKey = encodeEdgeKey('user:alice', 'user:bob', 'follows'); - expect(builder.ops).toHaveLength(1); - expect(builder.reads.has('user:alice')).toBe(true); - expect(builder.reads.has('user:bob')).toBe(true); - expect(builder.writes.has(edgeKey)).toBe(true); - expect(builder.versionVector.get('writer1')).toBe(1); + it('keeps read and write sets observable after publication', async () => { + const persistence = createMockPersistence(); + const builder = createPatchBuilder({ + persistence, + patchJournal: createPatchJournal(persistence), + versionVector: VersionVector.empty(), }); + builder.addEdge('user:alice', 'user:bob', 'follows'); + await builder.commit(); - it('does NOT set _committed on failed commit (CAS ref advance throws)', async () => { - const persistence = createMockPersistence(); - persistence.compareAndSwapRef.mockRejectedValueOnce(new Error('simulated compareAndSwapRef failure')); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'test-graph', - writerId: 'writer1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - }); - - builder.addNode('x'); - await expect(builder.commit()).rejects.toThrow('simulated compareAndSwapRef failure'); - await expect(builder.commit()).resolves.toBe('c'.repeat(40)); - }); + expect(builder.reads).toEqual(new Set(['user:alice', 'user:bob'])); + expect(builder.writes).toEqual(new Set([ + encodeEdgeKey('user:alice', 'user:bob', 'follows'), + ])); }); - }); diff --git a/test/unit/domain/services/PatchBuilder.content.test.ts b/test/unit/domain/services/PatchBuilder.content.test.ts index 3096fb3f..ff33475c 100644 --- a/test/unit/domain/services/PatchBuilder.content.test.ts +++ b/test/unit/domain/services/PatchBuilder.content.test.ts @@ -1,603 +1,199 @@ -import { describe, it, expect, vi } from 'vitest'; -import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; -import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import ORSet from '../../../../src/domain/crdt/ORSet.ts'; +import { describe, expect, it } from 'vitest'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import { encodeEdgeKey } from '../../../../src/domain/services/KeyCodec.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; - -/** - * Creates a mock blob storage with configurable OID return. - * @param {{ storeOid?: string }} [opts] - * @returns {any} - */ -function createMockBlobStorage(opts: { storeOid?: string } = {}) { - const oid = opts.storeOid || 'd'.repeat(40); - return { - store: vi.fn().mockResolvedValue(oid), - retrieve: vi.fn(), - storeStream: vi.fn().mockResolvedValue(oid), - retrieveStream: vi.fn(), - }; -} - -/** - * Creates a mock persistence adapter for testing. - * @param {Object} [overrides] - * @returns {any} - */ -function createMockPersistence(overrides = {}) { - const persistence = { - readRef: vi.fn().mockResolvedValue(null), - showNode: vi.fn(), - writeBlob: vi.fn().mockResolvedValue('d'.repeat(40)), - writeTree: vi.fn().mockResolvedValue('e'.repeat(40)), - commitNodeWithTree: vi.fn().mockResolvedValue('f'.repeat(40)), - updateRef: vi.fn().mockResolvedValue(undefined), - compareAndSwapRef: vi.fn(), - ...overrides, - }; - persistence.compareAndSwapRef.mockImplementation(async (ref, newOid, expectedOid) => { - const actualOid = await persistence.readRef(ref); - if (actualOid !== expectedOid) { - throw new Error(`CAS mismatch for ${ref}`); - } - persistence.readRef.mockResolvedValue(newOid); - }); - return persistence; -} - -/** - * Creates a mock V5 state for testing. - * @returns {any} - */ -function createMockState() { - return { - nodeAlive: ORSet.empty(), - edgeAlive: ORSet.empty(), - prop: new Map(), - observedFrontier: VersionVector.empty(), - }; -} - -/** - * Creates a CborPatchJournalAdapter wired to the given persistence's blob ops. - * @param {ReturnType} persistence - * @returns {CborPatchJournalAdapter} - */ -function createPatchJournal(persistence) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - }); -} - -describe('PatchBuilder content attachment', () => { - it('test fixture compareAndSwapRef rejects expected-head mismatches', async () => { - const persistence = createMockPersistence(); - const currentSha = 'a'.repeat(40); - const nextSha = 'b'.repeat(40); - persistence.readRef.mockResolvedValue(currentSha); - - await expect( - persistence.compareAndSwapRef('refs/warp/events/writers/writer-1', nextSha, null) - ).rejects.toThrow('CAS mismatch'); - }); - - describe('attachContent()', () => { - it('writes blob and sets content reference metadata properties', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage({ storeOid: 'abc123' }); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await builder.attachContent('node:1', 'hello world'); - - expect(blobStorage.store).toHaveBeenCalledWith('hello world', { slug: 'g/node:1', mime: null, size: 11 }); - const patch = builder.build(); - expect(patch.ops).toHaveLength(3); - expect(patch.ops).toContainEqual(expect.objectContaining({ +import { + encodeEdgeKey, + encodeLegacyEdgePropNode, +} from '../../../../src/domain/services/KeyCodec.ts'; +import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import { + createPatchBuilder, + createPatchBuilderMockPersistence, + createPatchJournal, + RecordingAssetStorage, +} from './PatchBuilderTestHarness.ts'; + +describe('PatchBuilder content attachments', () => { + it('stages node content and lowers its opaque handle and metadata', async () => { + const state = stateWithNode('doc:1'); + const assets = new RecordingAssetStorage(['asset:document']); + const builder = createPatchBuilder({ + graphName: 'events', + getCurrentState: () => state, + assetStorage: assets, + }); + + await builder.attachContent('doc:1', 'hello', { mime: 'text/plain' }); + + expect(assets.calls).toHaveLength(1); + expect(new TextDecoder().decode(assets.calls[0]?.bytes)).toBe('hello'); + expect(assets.calls[0]?.options).toEqual({ + slug: 'events/doc:1', + filename: 'content', + expectedSize: 5, + }); + expect(builder.build().ops).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'PropSet', - node: 'node:1', + node: 'doc:1', key: '_content', - value: 'abc123', - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ + value: 'asset:document', + }), + expect.objectContaining({ type: 'PropSet', - node: 'node:1', + node: 'doc:1', key: '_content.size', - value: 11, - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ + value: 5, + }), + expect.objectContaining({ type: 'PropSet', - node: 'node:1', - key: '_content.mime', - value: null, - })); - }); - - it('accepts optional content metadata and persists it alongside the blob oid', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage({ storeOid: 'abc123' }); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await builder.attachContent('node:1', 'hello world', { - mime: 'text/plain', - size: 11, - }); - - const patch = builder.build(); - expect(patch.ops).toContainEqual(expect.objectContaining({ + node: 'doc:1', key: '_content.mime', value: 'text/plain', - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - key: '_content.size', - value: 11, - })); - }); - - it('tracks blob OID in _contentBlobs', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage({ storeOid: 'abc123' }); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await builder.attachContent('node:1', 'content'); - - expect((builder as any)._contentBlobs).toEqual(['abc123']); - }); - - it('returns the builder for chaining', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage(); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - const result = await builder.attachContent('node:1', 'content'); - expect(result).toBe(builder); - }); - - it('propagates blob storage errors', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage(); - blobStorage.store = vi.fn().mockRejectedValue(new Error('disk full')); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await expect(builder.attachContent('node:1', 'x')).rejects.toThrow('disk full'); - }); - - it('does not write blobs for unknown nodes', async () => { - const persistence = createMockPersistence(); - const builder = new PatchBuilder((({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => createMockState(), - }) as any)); + }), + ])); + }); - await expect(builder.attachContent('missing:node', 'content')) - .rejects.toThrow("Cannot attach content to unknown node 'missing:node': add the node first"); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); + it('streams content without buffering it at the public call boundary', async () => { + const state = stateWithNode('doc:1'); + const assets = new RecordingAssetStorage(['asset:stream']); + const builder = createPatchBuilder({ + getCurrentState: () => state, + assetStorage: assets, }); - it('rejects mismatched size metadata before writing the blob', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage(); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); + await builder.attachContent('doc:1', chunks('hello', ' world'), { size: 11 }); - await expect(builder.attachContent('node:1', 'hello', { size: 9 })) - .rejects.toThrow('content metadata size 9 does not match actual byte size 5'); - expect(blobStorage.store).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); - }); + expect(new TextDecoder().decode(assets.calls[0]?.bytes)).toBe('hello world'); + expect(assets.calls[0]?.options.expectedSize).toBe(11); + expect(builder.build().ops).toContainEqual(expect.objectContaining({ + key: '_content.size', + value: 11, + })); }); - describe('clearContent()', () => { - it('sets reserved content registers to null without writing a blob', () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const builder = new PatchBuilder((({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - }) as any)); - - const result = builder.clearContent('node:1'); - - expect(result).toBe(builder); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); - const patch = builder.build(); - expect(patch.ops).toHaveLength(3); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - node: 'node:1', - key: '_content', - value: null, - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - node: 'node:1', - key: '_content.size', - value: null, - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - node: 'node:1', - key: '_content.mime', - value: null, - })); - }); + it('stages edge content and lowers edge properties', async () => { + const state = WarpState.empty(); + state.edgeAlive.add(encodeEdgeKey('doc:1', 'doc:2', 'links'), Dot.create('writer-a', 1)); + const assets = new RecordingAssetStorage(['asset:edge']); + const builder = createPatchBuilder({ + graphName: 'events', + getCurrentState: () => state, + assetStorage: assets, + }); + + await builder.attachEdgeContent('doc:1', 'doc:2', 'links', 'edge-data'); + + expect(assets.calls[0]?.options.slug).toBe('events/doc:1/doc:2/links'); + expect(builder.build()).toMatchObject({ schema: 3 }); + expect(builder.build().ops).toContainEqual(expect.objectContaining({ + type: 'PropSet', + node: encodeLegacyEdgePropNode('doc:1', 'doc:2', 'links'), + scope: 2, + key: '_content', + value: 'asset:edge', + })); + }); - it('rejects unknown nodes without writing a blob', () => { - const persistence = createMockPersistence(); - const builder = new PatchBuilder((({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => createMockState(), - }) as any)); + it('rejects missing asset storage before lowering attachment properties', async () => { + const builder = createPatchBuilder({ getCurrentState: () => stateWithNode('doc:1') }); - expect(() => builder.clearContent('missing:node')) - .toThrow("Cannot attach content to unknown node 'missing:node': add the node first"); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); + await expect(builder.attachContent('doc:1', 'hello')).rejects.toMatchObject({ + code: 'NO_ASSET_STORAGE', }); + expect(builder.build().ops).toEqual([]); }); - describe('attachEdgeContent()', () => { - it('writes blob and sets content reference metadata on the edge', async () => { - const state = createMockState(); - const edgeKey = encodeEdgeKey('a', 'b', 'rel'); - state.edgeAlive.add(edgeKey, Dot.create('w1', 1)); - - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage({ storeOid: 'def456' }); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await builder.attachEdgeContent('a', 'b', 'rel', Buffer.from('binary')); - - expect(blobStorage.store).toHaveBeenCalledWith(Buffer.from('binary'), { slug: 'g/a/b/rel', mime: null, size: 6 }); - const patch = builder.build(); - expect(patch.ops).toHaveLength(3); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - key: '_content', - value: 'def456', - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - key: '_content.size', - value: 6, - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - key: '_content.mime', - value: null, - })); - // Schema should be 3 (edge properties present) - expect(patch.schema).toBe(3); + it('rejects unknown graph targets without staging content', async () => { + const assets = new RecordingAssetStorage(); + const builder = createPatchBuilder({ + getCurrentState: () => WarpState.empty(), + assetStorage: assets, }); - it('tracks blob OID in _contentBlobs', async () => { - const state = createMockState(); - state.edgeAlive.add(encodeEdgeKey('a', 'b', 'rel'), Dot.create('w1', 1)); - - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage({ storeOid: 'def456' }); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await builder.attachEdgeContent('a', 'b', 'rel', 'content'); - - expect((builder as any)._contentBlobs).toEqual(['def456']); + await expect(builder.attachContent('missing', 'hello')).rejects.toMatchObject({ + code: 'E_PATCH_CONTENT_UNKNOWN_NODE', }); + await expect( + builder.attachEdgeContent('a', 'b', 'links', 'hello'), + ).rejects.toMatchObject({ code: 'E_PATCH_EDGE_PROP_UNKNOWN_EDGE' }); + expect(assets.calls).toEqual([]); + }); - it('returns the builder for chaining', async () => { - const state = createMockState(); - state.edgeAlive.add(encodeEdgeKey('a', 'b', 'rel'), Dot.create('w1', 1)); - - const persistence = createMockPersistence(); - const blobStorage = createMockBlobStorage(); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - const result = await builder.attachEdgeContent('a', 'b', 'rel', 'x'); - expect(result).toBe(builder); + it('validates MIME and expected size before asking storage to stage bytes', async () => { + const state = stateWithNode('doc:1'); + const assets = new RecordingAssetStorage(); + const builder = createPatchBuilder({ + getCurrentState: () => state, + assetStorage: assets, }); - it('does not pollute _contentBlobs when edge does not exist', async () => { - const persistence = createMockPersistence({ - writeBlob: vi.fn().mockResolvedValue('def456'), - }); - const builder = new PatchBuilder((({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => createMockState(), - }) as any)); - - await expect( - builder.attachEdgeContent('no', 'such', 'edge', 'content') - ).rejects.toThrow(); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); - }); + await expect(builder.attachContent('doc:1', 'hello', { mime: '' })) + .rejects.toThrow(/mime must be a non-empty string/u); + await expect(builder.attachContent('doc:1', 'hello', { size: 4 })) + .rejects.toThrow(/does not match actual byte size/u); + expect(assets.calls).toEqual([]); + expect(builder.build().ops).toEqual([]); }); - describe('clearEdgeContent()', () => { - it('sets reserved edge content registers to null without writing a blob', () => { - const state = createMockState(); - const edgeKey = encodeEdgeKey('a', 'b', 'rel'); - state.edgeAlive.add(edgeKey, Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const builder = new PatchBuilder((({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - }) as any)); - - const result = builder.clearEdgeContent('a', 'b', 'rel'); - - expect(result).toBe(builder); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); - const patch = builder.build(); - expect(patch.ops).toHaveLength(3); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - key: '_content', - value: null, - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - key: '_content.size', - value: null, - })); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - key: '_content.mime', - value: null, - })); - expect(patch.schema).toBe(3); + it('does not lower properties when storage fails', async () => { + const assets = new RecordingAssetStorage(); + const failure = new Error('asset storage unavailable'); + assets.failure = failure; + const builder = createPatchBuilder({ + getCurrentState: () => stateWithNode('doc:1'), + assetStorage: assets, }); - it('rejects unknown edges without writing a blob', () => { - const persistence = createMockPersistence(); - const builder = new PatchBuilder((({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => createMockState(), - }) as any)); - - expect(() => builder.clearEdgeContent('no', 'such', 'edge')).toThrow(); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - expect((builder as any)._contentBlobs).toEqual([]); - }); + await expect(builder.attachContent('doc:1', 'hello')).rejects.toBe(failure); + expect(builder.build().ops).toEqual([]); }); - describe('multiple attachments in one patch', () => { - it('tracks multiple blob OIDs', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - state.nodeAlive.add('node:2', Dot.create('w1', 2)); - let callCount = 0; - const blobStorage = createMockBlobStorage(); - blobStorage.store = vi.fn().mockImplementation(() => { - callCount++; - return Promise.resolve(`blob${callCount}`); - }); - const persistence = createMockPersistence(); - const builder = new PatchBuilder((({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - await builder.attachContent('node:1', 'first'); - await builder.attachContent('node:2', 'second'); - - expect((builder as any)._contentBlobs).toEqual(['blob1', 'blob2']); - expect(blobStorage.store).toHaveBeenCalledTimes(2); - }); + it('forwards staged attachment handles to the patch publication bundle', async () => { + const persistence = createPatchBuilderMockPersistence(); + const journal = createPatchJournal(persistence); + const assets = new RecordingAssetStorage(['asset:first', 'asset:second']); + const builder = createPatchBuilder({ + persistence, + patchJournal: journal, + assetStorage: assets, + }); + builder.addNode('doc:1').addNode('doc:2'); + await builder.attachContent('doc:1', 'first'); + await builder.attachContent('doc:2', 'second'); + + await builder.commit(); + + expect(journal.requests[0]?.attachments.map(String)).toEqual([ + 'asset:first', + 'asset:second', + ]); }); - describe('attachContent() with streaming input', () => { - it('accepts an AsyncIterable as content', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const blobStorage = { - store: vi.fn().mockResolvedValue('cas-oid-1'), - retrieve: vi.fn(), - storeStream: vi.fn().mockResolvedValue('cas-stream-oid'), - retrieveStream: vi.fn(), - }; - const builder = new PatchBuilder((({ - persistence: createMockPersistence(), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - async function* source() { - yield new TextEncoder().encode('hello '); - yield new TextEncoder().encode('world'); - } - - await builder.attachContent('node:1', source(), { size: 11 }); - - // Should call storeStream, not store - expect(blobStorage.storeStream).toHaveBeenCalledOnce(); - expect(blobStorage.store).not.toHaveBeenCalled(); - const patch = builder.build(); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - node: 'node:1', - key: '_content', - value: 'cas-stream-oid', - })); + it('clears node and edge content with null property intents and no storage write', () => { + const state = stateWithNode('doc:1'); + state.edgeAlive.add(encodeEdgeKey('doc:1', 'doc:2', 'links'), Dot.create('writer-a', 2)); + const assets = new RecordingAssetStorage(); + const builder = createPatchBuilder({ + getCurrentState: () => state, + assetStorage: assets, }); - it('accepts a ReadableStream as content', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const blobStorage = { - store: vi.fn().mockResolvedValue('cas-oid-1'), - retrieve: vi.fn(), - storeStream: vi.fn().mockResolvedValue('cas-rs-oid'), - retrieveStream: vi.fn(), - }; - const builder = new PatchBuilder((({ - persistence: createMockPersistence(), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); + builder.clearContent('doc:1'); + builder.clearEdgeContent('doc:1', 'doc:2', 'links'); - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('rs content')); - controller.close(); - }, - }); - - await builder.attachContent('node:1', stream, { size: 10 }); - - expect(blobStorage.storeStream).toHaveBeenCalledOnce(); - }); + expect(assets.calls).toEqual([]); + expect(builder.build().ops.filter((op) => 'value' in op && op.value === null)).toHaveLength(6); }); +}); - describe('attachEdgeContent() with streaming input', () => { - it('accepts an AsyncIterable as content', async () => { - const state = createMockState(); - state.edgeAlive.add(encodeEdgeKey('a', 'b', 'rel'), Dot.create('w1', 1)); - const blobStorage = { - store: vi.fn().mockResolvedValue('cas-oid-1'), - retrieve: vi.fn(), - storeStream: vi.fn().mockResolvedValue('cas-edge-stream-oid'), - retrieveStream: vi.fn(), - }; - const builder = new PatchBuilder((({ - persistence: createMockPersistence(), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }) as any)); - - async function* source() { - yield new TextEncoder().encode('edge '); - yield new TextEncoder().encode('data'); - } - - await builder.attachEdgeContent('a', 'b', 'rel', source(), { size: 9 }); +function stateWithNode(nodeId: string): WarpState { + const state = WarpState.empty(); + state.nodeAlive.add(nodeId, Dot.create('writer-a', 1)); + return state; +} - expect(blobStorage.storeStream).toHaveBeenCalledOnce(); - expect(blobStorage.store).not.toHaveBeenCalled(); - }); - }); -}); +async function* chunks(...values: string[]): AsyncIterable { + for (const value of values) { + yield new TextEncoder().encode(value); + } +} diff --git a/test/unit/domain/services/PatchBuilder.contentPersistence.test.ts b/test/unit/domain/services/PatchBuilder.contentPersistence.test.ts deleted file mode 100644 index 1d019c1a..00000000 --- a/test/unit/domain/services/PatchBuilder.contentPersistence.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import { encodeEdgeKey } from '../../../../src/domain/services/KeyCodec.ts'; -import { - createPatchBuilder, - createPatchBuilderMockBlobStorage as createMockBlobStorage, - createPatchBuilderMockPersistence as createMockPersistence, - createPatchBuilderMockState as createMockState, - createPatchJournal, -} from './PatchBuilderTestHarness.ts'; - -describe('PatchBuilder content persistence', () => { - describe('attachContent() with blobStorage', () => { - it('uses blobStorage.store() when blobStorage is provided', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const blobStorage = createMockBlobStorage({ storeOid: 'cas-tree-oid' }); - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }); - - await builder.attachContent('node:1', 'hello world'); - - expect(blobStorage.store).toHaveBeenCalledWith('hello world', { - slug: 'g/node:1', - mime: null, - size: 11, - }); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - const patch = builder.build(); - expect(patch.ops).toContainEqual(expect.objectContaining({ - type: 'PropSet', - node: 'node:1', - key: '_content', - value: 'cas-tree-oid', - })); - }); - - it('throws NO_BLOB_STORAGE when blobStorage is not provided', async () => { - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - }); - - await expect(builder.attachContent('node:1', 'hello')) - .rejects.toThrow('Cannot attach content without blob storage'); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - }); - }); - - describe('no raw writeBlob fallback (OG-014)', () => { - it('throws when blobStorage is absent and content is attached', async () => { - // OG-014 mandates that CAS is mandatory. Once implemented, - // attachContent without blobStorage should throw, not silently - // fall back to persistence.writeBlob(). - const state = createMockState(); - state.nodeAlive.add('node:1', Dot.create('w1', 1)); - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - // blobStorage intentionally omitted - }); - - // Should throw because there is no blob storage to handle content - await expect(builder.attachContent('node:1', 'hello')) - .rejects.toThrow(); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - }); - }); - - describe('attachEdgeContent() with blobStorage', () => { - it('uses blobStorage.store() when blobStorage is provided', async () => { - const state = createMockState(); - state.edgeAlive.add(encodeEdgeKey('a', 'b', 'rel'), Dot.create('w1', 1)); - - const blobStorage = createMockBlobStorage({ storeOid: 'cas-edge-tree-oid' }); - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }); - - await builder.attachEdgeContent('a', 'b', 'rel', 'edge-data'); - - expect(blobStorage.store).toHaveBeenCalledWith('edge-data', { - slug: 'g/a/b/rel', - mime: null, - size: 9, - }); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - }); - }); - - describe('commit() with content blobs', () => { - it('includes _content_ entries in tree when content blobs exist', async () => { - const contentOid = 'a'.repeat(40); - const patchBlobOid = 'b'.repeat(40); - const blobStorage = createMockBlobStorage({ storeOid: contentOid }); - const persistence = createMockPersistence({ - writeBlob: vi.fn(async (_content: Uint8Array | string): Promise => patchBlobOid), // commit() CBOR blob only - writeTree: vi.fn(async (_entries: string[]): Promise => 'c'.repeat(40)), - }); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, - blobStorage, - }); - - builder.addNode('n1'); - await builder.attachContent('n1', 'hello'); - await builder.commit(); - - // writeTree should be called with both patch.cbor and _content_ - const treeEntries = persistence.writeTree.mock.calls[0]![0]; - expect(treeEntries).toHaveLength(2); - expect(treeEntries[0]).toBe(`100644 blob ${patchBlobOid}\tpatch.cbor`); - expect(treeEntries[1]).toBe(`040000 tree ${contentOid}\t_content_${contentOid}`); - }); - - it('creates single-entry tree when no content blobs', async () => { - const persistence = createMockPersistence(); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, - }); - - builder.addNode('n1'); - await builder.commit(); - - const treeEntries = persistence.writeTree.mock.calls[0]![0]; - expect(treeEntries).toHaveLength(1); - expect(treeEntries[0]).toContain('patch.cbor'); - }); - - it('includes multiple _content_ entries for multiple attachments', async () => { - const contentA = '1'.repeat(40); - const contentB = '2'.repeat(40); - const patchBlob = '3'.repeat(40); - let storeIdx = 0; - const storeOids = [contentA, contentB]; - const blobStorage = createMockBlobStorage(); - blobStorage.store = vi.fn().mockImplementation(() => - Promise.resolve(storeOids[storeIdx++])); - const persistence = createMockPersistence({ - writeBlob: vi.fn(async (_content: Uint8Array | string): Promise => patchBlob), // CBOR blob only - writeTree: vi.fn(async (_entries: string[]): Promise => '4'.repeat(40)), - }); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, - blobStorage, - }); - - builder.addNode('n1').addNode('n2'); - await builder.attachContent('n1', 'first'); - await builder.attachContent('n2', 'second'); - await builder.commit(); - - const treeEntries = persistence.writeTree.mock.calls[0]![0]; - expect(treeEntries).toHaveLength(3); - expect(treeEntries[0]).toContain('patch.cbor'); - expect(treeEntries[1]).toBe(`040000 tree ${contentA}\t_content_${contentA}`); - expect(treeEntries[2]).toBe(`040000 tree ${contentB}\t_content_${contentB}`); - }); - - it('deduplicates tree entries when same content is attached to multiple nodes', async () => { - const sharedOid = 'a'.repeat(40); - const patchBlob = 'b'.repeat(40); - const blobStorage = createMockBlobStorage({ storeOid: sharedOid }); - const persistence = createMockPersistence({ - writeBlob: vi.fn(async (_content: Uint8Array | string): Promise => patchBlob), // commit() CBOR blob only - writeTree: vi.fn(async (_entries: string[]): Promise => 'c'.repeat(40)), - }); - const builder = createPatchBuilder({ - persistence, - patchJournal: createPatchJournal(persistence), - graphName: 'g', - writerId: 'w1', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, - blobStorage, - }); - - builder.addNode('n1').addNode('n2'); - await builder.attachContent('n1', 'same-data'); - await builder.attachContent('n2', 'same-data'); - await builder.commit(); - - const treeEntries = persistence.writeTree.mock.calls[0]![0]; - expect(treeEntries).toHaveLength(2); - expect(treeEntries[0]).toContain('patch.cbor'); - expect(treeEntries[1]).toBe(`040000 tree ${sharedOid}\t_content_${sharedOid}`); - }); - }); -}); diff --git a/test/unit/domain/services/PatchBuilder.test.ts b/test/unit/domain/services/PatchBuilder.test.ts index 645c247a..a4b7b69c 100644 --- a/test/unit/domain/services/PatchBuilder.test.ts +++ b/test/unit/domain/services/PatchBuilder.test.ts @@ -5,10 +5,6 @@ import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import ORSet from '../../../../src/domain/crdt/ORSet.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import { encodeEdgeKey } from '../../../../src/domain/services/JoinReducer.ts'; -import { decodePatchMessage } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { decode } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; /** * Creates a mock V5 state for testing. @@ -47,18 +43,6 @@ function createMockPersistence() { return persistence; } -/** - * Creates a CborPatchJournalAdapter wired to the given mock persistence's blob ops. - * @param {ReturnType} persistence - * @returns {CborPatchJournalAdapter} - */ -function createPatchJournal(persistence) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - }); -} - describe('PatchBuilder', () => { it('test fixture compareAndSwapRef rejects expected-head mismatches', async () => { const persistence = createMockPersistence(); diff --git a/test/unit/domain/services/PatchBuilderContentWriteIntent.test.ts b/test/unit/domain/services/PatchBuilderContentWriteIntent.test.ts index a7ff9e2e..4dcbc18a 100644 --- a/test/unit/domain/services/PatchBuilderContentWriteIntent.test.ts +++ b/test/unit/domain/services/PatchBuilderContentWriteIntent.test.ts @@ -1,188 +1,49 @@ import { describe, expect, it } from 'vitest'; - -import BlobPort from '../../../../src/ports/BlobPort.ts'; -import BlobStoragePort, { type BlobStorageOptions } from '../../../../src/ports/BlobStoragePort.ts'; -import CommitPort, { - type CommitLogChunk, - type CommitNodeOptions, - type CommitNodeWithTreeOptions, - type LogNodesOptions, - type NodeInfo, - type PingResult, -} from '../../../../src/ports/CommitPort.ts'; -import RefPort, { type ListRefsOptions } from '../../../../src/ports/RefPort.ts'; -import TreePort from '../../../../src/ports/TreePort.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import WarpStream from '../../../../src/domain/stream/WarpStream.ts'; -import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; import { encodeEdgeKey } from '../../../../src/domain/services/KeyCodec.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import { + createPatchBuilder, + RecordingAssetStorage, +} from './PatchBuilderTestHarness.ts'; -describe('PatchBuilder content write intent lowering', () => { - it('rejects malformed stored node content OIDs before lowering legacy properties', async () => { +describe('PatchBuilder content intent lowering', () => { + it('stores a graph-neutral asset handle in node property atoms', async () => { const state = WarpState.empty(); state.nodeAlive.add('doc:1', Dot.create('writer-a', 1)); - const builder = contentBuilder(state, new IntentBlobStorage('')); - - await expect(builder.attachContent('doc:1', 'hello')).rejects.toThrow(/ContentAttachmentOid/); - expect(builder.build().ops).toEqual([]); + const storage = new RecordingAssetStorage(['git-cas:asset:document']); + const builder = createPatchBuilder({ + getCurrentState: () => state, + assetStorage: storage, + }); + + await builder.attachContent('doc:1', 'hello'); + + expect(builder.build().ops).toContainEqual(expect.objectContaining({ + type: 'PropSet', + node: 'doc:1', + key: '_content', + value: 'git-cas:asset:document', + })); }); - it('rejects malformed streamed edge MIME hints before lowering legacy properties', async () => { + it('validates streamed edge metadata before staging the asset', async () => { const state = WarpState.empty(); state.edgeAlive.add(encodeEdgeKey('doc:1', 'doc:2', 'links'), Dot.create('writer-a', 1)); - const blobStorage = new IntentBlobStorage('edge-oid'); - const builder = contentBuilder(state, blobStorage); + const storage = new RecordingAssetStorage(); + const builder = createPatchBuilder({ + getCurrentState: () => state, + assetStorage: storage, + }); await expect( builder.attachEdgeContent('doc:1', 'doc:2', 'links', chunks(), { mime: '' }), - ).rejects.toThrow(/ContentAttachmentMime/); - expect(blobStorage.storeStreamCount()).toBe(0); + ).rejects.toThrow(/ContentAttachmentMime/u); + expect(storage.calls).toEqual([]); expect(builder.build().ops).toEqual([]); }); }); -function contentBuilder(state: WarpState, blobStorage: BlobStoragePort): PatchBuilder { - return new PatchBuilder({ - persistence: new IntentPersistence(), - graphName: 'g', - writerId: 'writer-a', - lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => state, - blobStorage, - }); -} - async function* chunks(): AsyncIterable { yield new TextEncoder().encode('edge'); } - -class IntentBlobStorage extends BlobStoragePort { - private readonly oid: string; - private storedStreamCount = 0; - - constructor(oid: string) { - super(); - this.oid = oid; - } - - async store( - _content: Uint8Array | string, - _options?: BlobStorageOptions, - ): Promise { - return this.oid; - } - - async retrieve(_oid: string): Promise { - return new Uint8Array(); - } - - async storeStream( - _source: AsyncIterable, - _options?: BlobStorageOptions, - ): Promise { - this.storedStreamCount += 1; - return this.oid; - } - - retrieveStream(_oid: string): AsyncIterable { - return chunks(); - } - - storeStreamCount(): number { - return this.storedStreamCount; - } -} - -class IntentPersistence extends CommitPort implements BlobPort, TreePort, RefPort { - readonly emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - - async commitNode(_options: CommitNodeOptions): Promise { - return 'c'.repeat(40); - } - - async showNode(_sha: string): Promise { - return ''; - } - - async getNodeInfo(_sha: string): Promise { - return { - sha: 'c'.repeat(40), - message: '', - author: '', - date: '', - parents: [], - }; - } - - async logNodes(_options: LogNodesOptions): Promise { - return ''; - } - - async logNodesStream(_options: LogNodesOptions): Promise> { - return WarpStream.from([]); - } - - async countNodes(_ref: string): Promise { - return 0; - } - - async commitNodeWithTree(_options: CommitNodeWithTreeOptions): Promise { - return 'c'.repeat(40); - } - - async nodeExists(_sha: string): Promise { - return false; - } - - async getCommitTree(_sha: string): Promise { - return this.emptyTree; - } - - async ping(): Promise { - return { ok: true, latencyMs: 0 }; - } - - async writeBlob(_content: Uint8Array | string): Promise { - return 'b'.repeat(40); - } - - async readBlob(_oid: string): Promise { - return new Uint8Array(); - } - - async writeTree(_entries: string[]): Promise { - return 't'.repeat(40); - } - - async readTree(_treeOid: string): Promise> { - return {}; - } - - async readTreeOids(_treeOid: string): Promise> { - return {}; - } - - async updateRef(_ref: string, _oid: string): Promise { - } - - async readRef(_ref: string): Promise { - return null; - } - - async deleteRef(_ref: string): Promise { - } - - async listRefs(_prefix: string, _options?: ListRefsOptions): Promise { - return []; - } - - async compareAndSwapRef( - _ref: string, - _newOid: string, - _expectedOid: string | null, - ): Promise { - } -} diff --git a/test/unit/domain/services/PatchBuilderTestHarness.ts b/test/unit/domain/services/PatchBuilderTestHarness.ts index 6ed6fba1..76cefaf7 100644 --- a/test/unit/domain/services/PatchBuilderTestHarness.ts +++ b/test/unit/domain/services/PatchBuilderTestHarness.ts @@ -1,69 +1,58 @@ import { vi, type Mock } from 'vitest'; -import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; +import PatchEntry from '../../../../src/domain/artifacts/PatchEntry.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; +import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; import WarpStream from '../../../../src/domain/stream/WarpStream.ts'; -import Patch from '../../../../src/domain/types/Patch.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec, decode } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { hydrateDecodedPatch } from '../../../../src/domain/services/PatchHydrator.ts'; +import type Patch from '../../../../src/domain/types/Patch.ts'; +import AssetStoragePort, { + type AssetWriteOptions, + type StagedAsset, +} from '../../../../src/ports/AssetStoragePort.ts'; import type { CommitLogChunk } from '../../../../src/ports/CommitPort.ts'; -import type BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; -import type { BlobStorageOptions } from '../../../../src/ports/BlobStoragePort.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import PatchJournalPort, { + type AppendPatchRequest, + type PublishedPatch, +} from '../../../../src/ports/PatchJournalPort.ts'; +import type { PatchCommitMessage } from '../../../../src/ports/CommitMessageCodecPort.ts'; +import { collectAsyncIterable } from '../../../../src/domain/utils/streamUtils.ts'; +import { testRetentionWitness } from '../../../helpers/storageRetention.ts'; type PatchBuilderOptions = ConstructorParameters[0]; type PatchBuilderPersistence = PatchBuilderOptions['persistence']; -const DEFAULT_PATCH_BLOB_OID = 'a'.repeat(40); -const DEFAULT_TREE_OID = 'b'.repeat(40); const DEFAULT_COMMIT_OID = 'c'.repeat(40); -const DEFAULT_EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; +const WRITTEN_PATCHES = new WeakMap(); export type PatchBuilderMockPersistence = PatchBuilderPersistence & { readRef: Mock<(ref: string) => Promise>; showNode: Mock<(sha: string) => Promise>; - writeBlob: Mock<(content: Uint8Array | string) => Promise>; - writeTree: Mock<(entries: string[]) => Promise>; - commitNodeWithTree: Mock<(options: { treeOid: string; parents?: string[]; message: string; sign?: boolean }) => Promise>; updateRef: Mock<(ref: string, oid: string) => Promise>; compareAndSwapRef: Mock<(ref: string, newOid: string, expectedOid: string | null) => Promise>; - readBlob: Mock<(oid: string) => Promise>; - readTree: Mock<(treeOid: string) => Promise>>; - readTreeOids: Mock<(treeOid: string) => Promise>>; deleteRef: Mock<(ref: string) => Promise>; listRefs: Mock<(prefix: string, options?: { limit?: number }) => Promise>; commitNode: Mock<(options: { message: string; parents?: string[]; sign?: boolean }) => Promise>; - getNodeInfo: Mock<(sha: string) => Promise<{ sha: string; message: string; author: string; date: string; parents: string[] }>>; - getCommitTree: Mock<(sha: string) => Promise>; + getNodeInfo: Mock<(sha: string) => Promise<{ + sha: string; + message: string; + author: string; + date: string; + parents: string[]; + }>>; logNodes: Mock<(options: { ref: string; limit?: number; format?: string }) => Promise>; - logNodesStream: Mock<(options: { ref: string; limit?: number; format?: string }) => Promise>>; + logNodesStream: Mock<( + options: { ref: string; limit?: number; format?: string }, + ) => Promise>>; countNodes: Mock<(ref: string) => Promise>; nodeExists: Mock<(sha: string) => Promise>; ping: Mock<() => Promise<{ ok: boolean; latencyMs: number }>>; }; -export type PatchBuilderMockBlobStorage = BlobStoragePort & { - store: Mock<(content: Uint8Array | string, options?: BlobStorageOptions) => Promise>; - retrieve: Mock<(oid: string) => Promise>; - storeStream: Mock<(source: AsyncIterable, options?: BlobStorageOptions) => Promise>; - retrieveStream: Mock<(oid: string) => AsyncIterable>; -}; - function emptyCommitLogStream(): WarpStream { - return WarpStream.from({ - [Symbol.asyncIterator]: async function* () { - // Empty by design. - }, - }); -} - -function emptyByteStream(): AsyncIterable { - return { - [Symbol.asyncIterator]: async function* () { - // Empty by design. - }, - }; + return WarpStream.from([]); } export function createPatchBuilderMockPersistence( @@ -72,41 +61,44 @@ export function createPatchBuilderMockPersistence( const persistence = { readRef: vi.fn(async (_ref: string): Promise => null), showNode: vi.fn(async (_sha: string): Promise => ''), - writeBlob: vi.fn(async (_content: Uint8Array | string): Promise => DEFAULT_PATCH_BLOB_OID), - writeTree: vi.fn(async (_entries: string[]): Promise => DEFAULT_TREE_OID), - commitNodeWithTree: vi.fn(async (_options: { - treeOid: string; - parents?: string[]; - message: string; - sign?: boolean; - }): Promise => DEFAULT_COMMIT_OID), updateRef: vi.fn(async (_ref: string, _oid: string): Promise => {}), - compareAndSwapRef: vi.fn(), - readBlob: vi.fn(async (_oid: string): Promise => new Uint8Array()), - readTree: vi.fn(async (_treeOid: string): Promise> => ({})), - readTreeOids: vi.fn(async (_treeOid: string): Promise> => ({})), + compareAndSwapRef: vi.fn(async (_ref: string, _newOid: string, _expectedOid: string | null): Promise => {}), deleteRef: vi.fn(async (_ref: string): Promise => {}), listRefs: vi.fn(async (_prefix: string, _options?: { limit?: number }): Promise => []), - commitNode: vi.fn(async (_options: { message: string; parents?: string[]; sign?: boolean }): Promise => DEFAULT_COMMIT_OID), - getNodeInfo: vi.fn(async (sha: string): Promise<{ sha: string; message: string; author: string; date: string; parents: string[] }> => ({ + commitNode: vi.fn(async (_options: { + message: string; + parents?: string[]; + sign?: boolean; + }): Promise => DEFAULT_COMMIT_OID), + getNodeInfo: vi.fn(async (sha: string) => ({ sha, message: '', author: '', date: '', parents: [], })), - getCommitTree: vi.fn(async (_sha: string): Promise => DEFAULT_EMPTY_TREE_OID), - logNodes: vi.fn(async (_options: { ref: string; limit?: number; format?: string }): Promise => ''), - logNodesStream: vi.fn(async (_options: { ref: string; limit?: number; format?: string }): Promise> => emptyCommitLogStream()), + logNodes: vi.fn(async (_options: { + ref: string; + limit?: number; + format?: string; + }): Promise => ''), + logNodesStream: vi.fn(async (_options: { + ref: string; + limit?: number; + format?: string; + }): Promise> => emptyCommitLogStream()), countNodes: vi.fn(async (_ref: string): Promise => 0), nodeExists: vi.fn(async (_sha: string): Promise => true), - ping: vi.fn(async (): Promise<{ ok: boolean; latencyMs: number }> => ({ ok: true, latencyMs: 0 })), - emptyTree: DEFAULT_EMPTY_TREE_OID, + ping: vi.fn(async () => ({ ok: true, latencyMs: 0 })), ...overrides, } satisfies PatchBuilderMockPersistence; if (overrides.compareAndSwapRef === undefined) { - persistence.compareAndSwapRef.mockImplementation(async (ref: string, newOid: string, expectedOid: string | null): Promise => { + persistence.compareAndSwapRef.mockImplementation(async ( + ref: string, + newOid: string, + expectedOid: string | null, + ): Promise => { const actualOid = await persistence.readRef(ref); if (actualOid !== expectedOid) { throw new Error(`CAS mismatch for ${ref}`); @@ -114,32 +106,91 @@ export function createPatchBuilderMockPersistence( persistence.readRef.mockResolvedValue(newOid); }); } - return persistence; } -export function createPatchBuilderMockState(): WarpState { - return WarpState.empty(); +export class RecordingPatchJournal extends PatchJournalPort { + readonly requests: AppendPatchRequest[] = []; + failure: unknown = null; + sha = DEFAULT_COMMIT_OID; + readonly #persistence: object; + + constructor(persistence: object) { + super(); + this.#persistence = persistence; + } + + override async appendPatch(request: AppendPatchRequest): Promise { + this.requests.push(request); + if (this.failure !== null) { + throw this.failure; + } + WRITTEN_PATCHES.set(this.#persistence, request.patch); + const stagedPatch = stagedAsset(new AssetHandle('asset:test-patch'), 1); + return Object.freeze({ + sha: this.sha, + bundleHandle: new BundleHandle('bundle:test-patch'), + stagedPatch, + retention: testRetentionWitness(this.sha), + }); + } + + override readPatch(_message: PatchCommitMessage): Promise { + throw new PatchBuilderFixtureError('readPatch is outside this fixture'); + } + + override scanPatchRange( + _writerId: string, + _fromSha: string | null, + _toSha: string, + ): WarpStream { + return WarpStream.from([]); + } } -export function createPatchBuilderMockBlobStorage( - opts: { storeOid?: string } = {}, -): PatchBuilderMockBlobStorage { - const oid = opts.storeOid ?? 'd'.repeat(40); - return { - store: vi.fn(async (_content: Uint8Array | string, _options?: BlobStorageOptions): Promise => oid), - retrieve: vi.fn(async (_oid: string): Promise => new Uint8Array()), - storeStream: vi.fn(async (_source: AsyncIterable, _options?: BlobStorageOptions): Promise => oid), - retrieveStream: vi.fn((_oid: string): AsyncIterable => emptyByteStream()), - } satisfies PatchBuilderMockBlobStorage; +export class RecordingAssetStorage extends AssetStoragePort { + readonly calls: Array<{ bytes: Uint8Array; options: AssetWriteOptions }> = []; + readonly #handles: string[]; + readonly #assets = new Map(); + failure: unknown = null; + + constructor(handles: readonly string[] = ['asset:test-content']) { + super(); + this.#handles = [...handles]; + } + + override async stage( + source: AsyncIterable, + options: AssetWriteOptions, + ): Promise { + if (this.failure !== null) { + throw this.failure; + } + const bytes = await collectAsyncIterable(source); + const token = this.#handles[this.calls.length] ?? `asset:test-content-${this.calls.length}`; + const handle = new AssetHandle(token); + this.calls.push({ bytes, options }); + this.#assets.set(token, bytes.slice()); + return stagedAsset(handle, bytes.byteLength); + } + + override async *open(handle: AssetHandle): AsyncIterable { + const bytes = this.#assets.get(handle.toString()); + if (bytes === undefined) { + throw new PatchBuilderFixtureError(`unknown test asset: ${handle.toString()}`); + } + yield bytes.slice(); + } } -export function createPatchJournal(persistence: PatchBuilderMockPersistence): CborPatchJournalAdapter { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); +export function createPatchBuilderMockState(): WarpState { + return WarpState.empty(); +} + +export function createPatchJournal( + persistence: PatchBuilderMockPersistence, +): RecordingPatchJournal { + return new RecordingPatchJournal(persistence); } export function createPatchBuilderOptions( @@ -158,17 +209,32 @@ export function createPatchBuilderOptions( }; } -export function createPatchBuilder(overrides: Partial = {}): PatchBuilder { +export function createPatchBuilder( + overrides: Partial = {}, +): PatchBuilder { return new PatchBuilder(createPatchBuilderOptions(overrides)); } -export function decodeWrittenPatch(persistence: PatchBuilderMockPersistence, callIndex = 0): Patch { - const blobData = persistence.writeBlob.mock.calls[callIndex]?.[0]; - if (blobData === undefined) { - throw new Error(`Expected writeBlob call ${callIndex}`); - } - if (!(blobData instanceof Uint8Array)) { - throw new Error(`Expected writeBlob call ${callIndex} to contain bytes`); +export function decodeWrittenPatch( + persistence: PatchBuilderMockPersistence, +): Patch { + const patch = WRITTEN_PATCHES.get(persistence); + if (patch === undefined) { + throw new PatchBuilderFixtureError('expected the semantic journal to receive a patch'); } - return hydrateDecodedPatch(decode(blobData)); + return patch; +} + +function stagedAsset(handle: AssetHandle, size: number): StagedAsset { + return Object.freeze({ + handle, + size, + observedAt: '1970-01-01T00:00:00.000Z', + retention: Object.freeze({ + reachability: 'unanchored', + protection: 'not-established', + }), + }); } + +class PatchBuilderFixtureError extends Error {} diff --git a/test/unit/domain/services/PatchCommitter.visibility.test.ts b/test/unit/domain/services/PatchCommitter.visibility.test.ts index 7285525c..ea2ad0a1 100644 --- a/test/unit/domain/services/PatchCommitter.visibility.test.ts +++ b/test/unit/domain/services/PatchCommitter.visibility.test.ts @@ -1,165 +1,105 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; -import Patch from '../../../../src/domain/types/Patch.ts'; -import PersistenceError from '../../../../src/domain/errors/PersistenceError.ts'; +import PatchPublicationConflictError from '../../../../src/domain/errors/PatchPublicationConflictError.ts'; import nullLogger from '../../../../src/domain/utils/nullLogger.ts'; -import WarpStream from '../../../../src/domain/stream/WarpStream.ts'; import { commitPatch, type CommitState } from '../../../../src/domain/services/PatchCommitter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import CommitPort, { - type CommitLogChunk, - type CommitNodeOptions, - type CommitNodeWithTreeOptions, - type LogNodesOptions, - type NodeInfo, - type PingResult, -} from '../../../../src/ports/CommitPort.ts'; -import type BlobPort from '../../../../src/ports/BlobPort.ts'; -import type TreePort from '../../../../src/ports/TreePort.ts'; -import type RefPort from '../../../../src/ports/RefPort.ts'; -import type { ListRefsOptions } from '../../../../src/ports/RefPort.ts'; -import PatchJournalPort, { type ReadPatchOptions } from '../../../../src/ports/PatchJournalPort.ts'; -import type PatchEntry from '../../../../src/domain/artifacts/PatchEntry.ts'; - -const PATCH_OID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const TREE_OID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -const COMMIT_OID = 'cccccccccccccccccccccccccccccccccccccccc'; -const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - -class InvisibleWriterRefPersistence extends CommitPort implements BlobPort, TreePort, RefPort { - readonly compareAndSwapRefs: string[] = []; - readonly committedTrees: CommitNodeWithTreeOptions[] = []; - - async commitNode(_options: CommitNodeOptions): Promise { - throw unusedPortMethod('commitNode'); - } - - async showNode(_sha: string): Promise { - throw unusedPortMethod('showNode'); - } - - async getNodeInfo(_sha: string): Promise { - throw unusedPortMethod('getNodeInfo'); - } - - async logNodes(_options: LogNodesOptions): Promise { - throw unusedPortMethod('logNodes'); - } - - async logNodesStream(_options: LogNodesOptions): Promise> { - throw unusedPortMethod('logNodesStream'); - } - - async countNodes(_ref: string): Promise { - throw unusedPortMethod('countNodes'); - } - - async commitNodeWithTree(options: CommitNodeWithTreeOptions): Promise { - this.committedTrees.push(options); - return COMMIT_OID; - } - - async nodeExists(_sha: string): Promise { - return true; - } - - async getCommitTree(_sha: string): Promise { - return TREE_OID; - } - - async ping(): Promise { - return { ok: true, latencyMs: 0 }; - } - - async writeBlob(_content: Uint8Array | string): Promise { - return PATCH_OID; - } - - async readBlob(_oid: string): Promise { - return new Uint8Array(); - } - - async writeTree(_entries: string[]): Promise { - return TREE_OID; - } - - async readTree(_treeOid: string): Promise> { - return {}; - } - - async readTreeOids(_treeOid: string): Promise> { - return {}; - } - - get emptyTree(): string { - return EMPTY_TREE; - } - - async updateRef(_ref: string, _oid: string): Promise { - throw unusedPortMethod('updateRef'); - } - - async readRef(_ref: string): Promise { - return null; - } - - async deleteRef(_ref: string): Promise { - throw unusedPortMethod('deleteRef'); - } - - async listRefs(_prefix: string, _options?: ListRefsOptions): Promise { - return []; - } - - async compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise { - this.compareAndSwapRefs.push(`${ref}:${newOid}:${expectedOid ?? '(missing)'}`); - } -} +import { + createPatchBuilderMockPersistence, + createPatchJournal, +} from './PatchBuilderTestHarness.ts'; + +describe('commitPatch semantic publication contract', () => { + it('forwards causal identity and invokes success only after publication', async () => { + const persistence = createPatchBuilderMockPersistence(); + const journal = createPatchJournal(persistence); + const onCommitSuccess = vi.fn(); + + const result = await commitPatch(makeState({ + persistence, + journal, + onCommitSuccess, + })); -class WrittenThenThrowingCasPersistence extends InvisibleWriterRefPersistence { - #refHead: string | null = null; + expect(result).toMatchObject({ + sha: 'c'.repeat(40), + retention: { reachability: 'anchored' }, + }); + expect(result.bundleHandle.toString()).toBe('bundle:test-patch'); + expect(journal.requests[0]).toMatchObject({ + graph: 'visibility-graph', + writer: 'writer-a', + targetRef: 'refs/warp/visibility-graph/writers/writer-a', + expectedHead: null, + parent: null, + }); + expect(onCommitSuccess).toHaveBeenCalledWith(result); + }); - override async readRef(_ref: string): Promise { - return this.#refHead; - } + it('does not invoke success when semantic publication fails', async () => { + const persistence = createPatchBuilderMockPersistence(); + const journal = createPatchJournal(persistence); + const failure = new Error('publication failed'); + journal.failure = failure; + const onCommitSuccess = vi.fn(); - override async compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise { - this.compareAndSwapRefs.push(`${ref}:${newOid}:${expectedOid ?? '(missing)'}`); - this.#refHead = newOid; - throw new PersistenceError('CAS transport threw after writing the ref', PersistenceError.E_REF_IO); - } -} + await expect(commitPatch(makeState({ + persistence, + journal, + onCommitSuccess, + }))).rejects.toBe(failure); -class CapturingPatchJournal extends PatchJournalPort { - readonly patches: Patch[] = []; + expect(onCommitSuccess).not.toHaveBeenCalled(); + expect(journal.requests).toHaveLength(1); + }); - async writePatch(patch: Patch): Promise { - this.patches.push(patch); - return PATCH_OID; - } + it('translates a storage conflict after observing the advanced writer head', async () => { + const persistence = createPatchBuilderMockPersistence(); + persistence.readRef + .mockResolvedValueOnce(null) + .mockResolvedValue('f'.repeat(40)); + const journal = createPatchJournal(persistence); + journal.failure = Object.assign(new Error('publication conflict'), { + code: 'PUBLICATION_CONFLICT', + }); - async readPatch(_patchOid: string, _options?: ReadPatchOptions): Promise { - throw unusedPortMethod('readPatch'); - } + await expect(commitPatch(makeState({ + persistence, + journal, + onCommitSuccess: null, + }))).rejects.toMatchObject({ + code: 'WRITER_CAS_CONFLICT', + expectedSha: null, + actualSha: 'f'.repeat(40), + }); + }); - scanPatchRange(_writerId: string, _fromSha: string | null, _toSha: string): WarpStream { - throw unusedPortMethod('scanPatchRange'); - } -} + it('translates a typed publication conflict when the observed head is unchanged', async () => { + const persistence = createPatchBuilderMockPersistence(); + const journal = createPatchJournal(persistence); + journal.failure = new PatchPublicationConflictError(); -function unusedPortMethod(methodName: string): PersistenceError { - return new PersistenceError(`Unexpected ${methodName} call`, PersistenceError.E_REF_IO); -} + await expect(commitPatch(makeState({ + persistence, + journal, + onCommitSuccess: null, + }))).rejects.toMatchObject({ + code: 'WRITER_CAS_CONFLICT', + expectedSha: null, + actualSha: null, + }); + }); +}); -function makeCommitState( - persistence: InvisibleWriterRefPersistence, - patchJournal: CapturingPatchJournal, - onCommitSuccess: CommitState['onCommitSuccess'], -): CommitState { +function makeState(options: { + persistence: ReturnType; + journal: ReturnType; + onCommitSuccess: CommitState['onCommitSuccess']; +}): CommitState { return { - persistence, + persistence: options.persistence, graphName: 'visibility-graph', writerId: 'writer-a', lamport: 1, @@ -170,50 +110,10 @@ function makeCommitState( hasEdgeProps: false, expectedParentSha: null, targetRefPath: null, - contentBlobs: [], - patchJournal, + contentAssets: [], + patchJournal: options.journal, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, logger: nullLogger, - onCommitSuccess, + onCommitSuccess: options.onCommitSuccess, }; } - -describe('commitPatch writer-ref visibility contract', () => { - it('rejects when compareAndSwapRef resolves but the new commit is not visible at the writer ref', async () => { - const persistence = new InvisibleWriterRefPersistence(); - const patchJournal = new CapturingPatchJournal(); - let successCallbackCount = 0; - - await expect(commitPatch(makeCommitState( - persistence, - patchJournal, - () => { successCallbackCount += 1; }, - ))).rejects.toMatchObject({ - code: PersistenceError.E_REF_IO, - }); - - expect(patchJournal.patches).toHaveLength(1); - expect(persistence.committedTrees).toHaveLength(1); - expect(persistence.compareAndSwapRefs).toEqual([ - `refs/warp/visibility-graph/writers/writer-a:${COMMIT_OID}:(missing)`, - ]); - expect(successCallbackCount).toBe(0); - }); - - it('accepts a CAS transport error when the new commit is already visible', async () => { - const persistence = new WrittenThenThrowingCasPersistence(); - const patchJournal = new CapturingPatchJournal(); - let successCallbackCount = 0; - - await expect(commitPatch(makeCommitState( - persistence, - patchJournal, - () => { successCallbackCount += 1; }, - ))).resolves.toBe(COMMIT_OID); - - expect(persistence.compareAndSwapRefs).toEqual([ - `refs/warp/visibility-graph/writers/writer-a:${COMMIT_OID}:(missing)`, - ]); - expect(successCallbackCount).toBe(1); - }); -}); diff --git a/test/unit/domain/services/PropertyIndex.test.ts b/test/unit/domain/services/PropertyIndex.test.ts index 9b07ed33..7a13e61a 100644 --- a/test/unit/domain/services/PropertyIndex.test.ts +++ b/test/unit/domain/services/PropertyIndex.test.ts @@ -1,220 +1,113 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { PropertyShard } from '../../../../src/domain/artifacts/PropertyShard.ts'; import PropertyIndexBuilder from '../../../../src/domain/services/index/PropertyIndexBuilder.ts'; import PropertyIndexReader from '../../../../src/domain/services/index/PropertyIndexReader.ts'; -import { PropertyShard } from '../../../../src/domain/artifacts/PropertyShard.ts'; import computeShardKey from '../../../../src/domain/utils/shardKey.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import { F10_PROTO_POLLUTION } from '../../../helpers/fixtureDsl.ts'; -import { createFakeCodecPort } from '../../../helpers/mockPorts.ts'; - -const codec = createFakeCodecPort(); - -/** - * Creates an in-memory mock storage from PropertyShard instances. - * Encodes each shard's entries so PropertyIndexReader can decode them. - */ -/** @param {Array} shards */ -function mockStorageFromShards(shards) { - const blobs = new Map(); - const oids = ({}) as Record; - let oidCounter = 0; - - for (const shard of shards) { - const path = `props_${shard.shardKey}.cbor`; - const oid = `oid_${oidCounter++}`; - blobs.set(oid, codec.encode(shard.entries)); - oids[path] = oid; +import MockIndexStorage from '../../../helpers/MockIndexStorage.ts'; + +async function storedReader(builder: PropertyIndexBuilder): Promise { + const storage = new MockIndexStorage(); + const handles = {} as Record>>; + for (const shard of builder.yieldShards()) { + handles[`props_${shard.shardKey}.cbor`] = await storage.writeBlob( + defaultCodec.encode(shard.entries), + ); } - - return { - storage: ({ readBlob: async (/** @type {string} */ oid) => blobs.get(oid) } as any), - oids, - }; + const reader = new PropertyIndexReader({ indexStore: storage }); + reader.setupHandles(handles); + return reader; } -describe('PropertyIndex', () => { - it('build → serialize → load → query matches', async () => { +describe('PropertyIndex handle-backed reads', () => { + it('builds, stores, and queries property shards through opaque handles', async () => { const builder = new PropertyIndexBuilder(); builder.addProperty('user:alice', 'name', 'Alice'); builder.addProperty('user:alice', 'age', 30); builder.addProperty('user:bob', 'name', 'Bob'); + const reader = await storedReader(builder); - const shards = ([...builder.yieldShards()] as Array); - const { storage, oids } = mockStorageFromShards(shards); - - const reader = new PropertyIndexReader({ storage, codec }); - reader.setup(oids); - - const aliceProps = await reader.getNodeProps('user:alice'); - expect(aliceProps).toEqual({ name: 'Alice', age: 30 }); - - const bobName = await reader.getProperty('user:bob', 'name'); - expect(bobName).toBe('Bob'); + await expect(reader.getNodeProps('user:alice')).resolves.toEqual({ name: 'Alice', age: 30 }); + await expect(reader.getProperty('user:bob', 'name')).resolves.toBe('Bob'); + await expect(reader.getNodeProps('missing')).resolves.toBeNull(); }); - it('missing node returns null', async () => { + it('reads freshly materialized in-memory shards without a storage adapter', async () => { const builder = new PropertyIndexBuilder(); - builder.addProperty('user:alice', 'name', 'Alice'); - - const shards = ([...builder.yieldShards()] as Array); - const { storage, oids } = mockStorageFromShards(shards); - - const reader = new PropertyIndexReader({ storage, codec }); - reader.setup(oids); + builder.addProperty('node:1', 'color', 'red'); + const tree: Record = {}; + for (const shard of builder.yieldShards()) { + tree[`props_${shard.shardKey}.cbor`] = defaultCodec.encode(shard.entries); + } + const reader = new PropertyIndexReader({ codec: defaultCodec }); + reader.setupTree(tree); - expect(await reader.getNodeProps('nonexistent')).toBeNull(); - expect(await reader.getProperty('nonexistent', 'name')).toBeUndefined(); + await expect(reader.getProperty('node:1', 'color')).resolves.toBe('red'); }); - it('multiple nodes in same shard are correctly isolated', async () => { - const builder = new PropertyIndexBuilder(); + it('keeps nodes isolated when they share one shard', async () => { const first = 'a'; const shardKey = computeShardKey(first); - let second: string | null = null; - for (let i = 0; i < 10000; i++) { - const candidate = `node:${i}`; - if (candidate !== first && computeShardKey(candidate) === shardKey) { - second = candidate; - break; - } - } - if (!second) { - throw new Error('failed to find a same-shard node for test'); + const second = Array.from({ length: 10_000 }, (_, index) => `node:${index}`) + .find((candidate) => candidate !== first && computeShardKey(candidate) === shardKey); + if (second === undefined) { + throw new Error('failed to find a same-shard node'); } + const builder = new PropertyIndexBuilder(); builder.addProperty(first, 'x', 1); builder.addProperty(second, 'y', 2); + const reader = await storedReader(builder); - const shards = ([...builder.yieldShards()] as Array); - const { storage, oids } = mockStorageFromShards(shards); - - const reader = new PropertyIndexReader({ storage, codec }); - reader.setup(oids); - - expect(await reader.getNodeProps(first)).toEqual({ x: 1 }); - expect(await reader.getNodeProps(second)).toEqual({ y: 2 }); + await expect(reader.getNodeProps(first)).resolves.toEqual({ x: 1 }); + await expect(reader.getNodeProps(second)).resolves.toEqual({ y: 2 }); }); - it('round-trip: build → serialize → reader → values match', async () => { - const builder = new PropertyIndexBuilder(); - builder.addProperty('node:1', 'color', 'red'); - builder.addProperty('node:1', 'weight', 42); - builder.addProperty('node:2', 'color', 'blue'); - - const shards = ([...builder.yieldShards()] as Array); - - // Verify PropertyShard entries are well-formed objects - for (const shard of shards) { - expect(shard).toBeInstanceOf(PropertyShard); - expect(Array.isArray(shard.entries)).toBe(true); - } - - const { storage, oids } = mockStorageFromShards(shards); - const reader = new PropertyIndexReader({ storage, codec }); - reader.setup(oids); + it('fails when handle-backed reads have no index store', async () => { + const storage = new MockIndexStorage(); + const handle = await storage.writeBlob(defaultCodec.encode([])); + const reader = new PropertyIndexReader(); + reader.setupHandles({ [`props_${computeShardKey('node:1')}.cbor`]: handle }); - expect(await reader.getProperty('node:1', 'color')).toBe('red'); - expect(await reader.getProperty('node:1', 'weight')).toBe(42); - expect(await reader.getProperty('node:2', 'color')).toBe('blue'); + await expect(reader.getNodeProps('node:1')).rejects.toMatchObject({ code: 'E_INDEX_NO_STORE' }); }); - it('proto pollution safety (F10): __proto__ node props do not leak', async () => { - const builder = new PropertyIndexBuilder(); - for (const { nodeId, key, value } of (F10_PROTO_POLLUTION.props as any)) { - builder.addProperty(nodeId, key, value); - } - - const shards = ([...builder.yieldShards()] as Array); - const { storage, oids } = mockStorageFromShards(shards); - - const reader = new PropertyIndexReader({ storage, codec }); - reader.setup(oids); + it('rejects malformed decoded shard payloads', async () => { + const storage = new MockIndexStorage(); + const handle = await storage.writeBlob(defaultCodec.encode({ invalid: true })); + const reader = new PropertyIndexReader({ indexStore: storage }); + reader.setupHandles({ [`props_${computeShardKey('node:1')}.cbor`]: handle }); - const props = await reader.getNodeProps('__proto__'); - expect(props).toEqual({ polluted: true }); - expect((({} as Record))['polluted']).toBeUndefined(); - }); - - it('throws a descriptive error when a shard OID is missing', async () => { - const reader = new PropertyIndexReader({ - storage: ({ readBlob: async () => undefined } as any), - codec, + await expect(reader.getNodeProps('node:1')).rejects.toMatchObject({ + code: 'E_INDEX_SHARD_MALFORMED', }); - reader.setup({ 'props_ab.cbor': 'oid_missing' }); - const abNodeId = `ab${'0'.repeat(38)}`; - - await expect(reader.getNodeProps(abNodeId)).rejects.toThrow(/missing blob.*oid_missing/i); }); - it('throws when decoded shard payload is not an array', async () => { - const abNodeId = `ab${'0'.repeat(38)}`; - const shardPath = `props_${computeShardKey(abNodeId)}.cbor`; - const reader = new PropertyIndexReader({ - storage: ({ readBlob: async () => codec.encode({ [abNodeId]: { name: 'Alice' } }) } as any), - codec, - }); - reader.setup({ [shardPath]: 'oid_bad_format' }); - - await expect(reader.getNodeProps(abNodeId)).rejects.toThrow(/invalid shard format.*expected array.*object/i); - }); - - it('loads via indexStore.decodeShard — codec-free', async () => { + it('does not permit __proto__ property data to mutate Object.prototype', async () => { const builder = new PropertyIndexBuilder(); - builder.addProperty('user:alice', 'name', 'Alice'); - builder.addProperty('user:alice', 'age', 30); - builder.addProperty('user:bob', 'name', 'Bob'); - - const shards = ([...builder.yieldShards()] as Array); - - // Build a mock indexStore that returns decoded shard entries directly - const decodedByOid = new Map(); - const oids = ({}) as Record; - let oidCounter = 0; - for (const shard of shards) { - const path = `props_${shard.shardKey}.cbor`; - const oid = `oid_${oidCounter++}`; - decodedByOid.set(oid, shard.entries); - oids[path] = oid; + for (const { nodeId, key, value } of F10_PROTO_POLLUTION.props) { + builder.addProperty(nodeId, key, value); } + const reader = await storedReader(builder); - const mockIndexStore = (({ - decodeShard: async ( oid) => decodedByOid.get(oid), - }) as any); - - const reader = new PropertyIndexReader({ indexStore: mockIndexStore }); - reader.setup(oids); - - const aliceProps = await reader.getNodeProps('user:alice'); - expect(aliceProps).toEqual({ name: 'Alice', age: 30 }); - - const bobName = await reader.getProperty('user:bob', 'name'); - expect(bobName).toBe('Bob'); + await expect(reader.getNodeProps('__proto__')).resolves.toEqual({ polluted: true }); + expect(({} as Record)['polluted']).toBeUndefined(); }); - it('serializes deterministically for equivalent property sets across op orders', () => { - const order1 = new PropertyIndexBuilder(); - order1.addProperty('node:alpha', 'name', 'Alice'); - order1.addProperty('node:beta', 'name', 'Bob'); - order1.addProperty('node:alpha', 'role', 'admin'); - order1.addProperty('node:beta', 'active', true); - - const order2 = new PropertyIndexBuilder(); - order2.addProperty('node:beta', 'active', true); - order2.addProperty('node:alpha', 'role', 'admin'); - order2.addProperty('node:beta', 'name', 'Bob'); - order2.addProperty('node:alpha', 'name', 'Alice'); - - const shards1 = ([...order1.yieldShards()] as Array); - const shards2 = ([...order2.yieldShards()] as Array); - - // Same number of shards with same shard keys - const keys1 = shards1.map((s) => s.shardKey).sort(); - const keys2 = shards2.map((s) => s.shardKey).sort(); - expect(keys1).toEqual(keys2); - - // Same entries per shard key - for (const shard1 of shards1) { - const shard2 = shards2.find((s) => s.shardKey === shard1.shardKey); - expect(shard2).toBeDefined(); - expect(shard1.entries).toEqual((shard2 as any).entries); - } + it('serializes equivalent property sets deterministically across operation order', () => { + const first = new PropertyIndexBuilder(); + first.addProperty('node:alpha', 'name', 'Alice'); + first.addProperty('node:beta', 'name', 'Bob'); + first.addProperty('node:alpha', 'role', 'admin'); + const second = new PropertyIndexBuilder(); + second.addProperty('node:alpha', 'role', 'admin'); + second.addProperty('node:beta', 'name', 'Bob'); + second.addProperty('node:alpha', 'name', 'Alice'); + + const firstShards = [...first.yieldShards()] as PropertyShard[]; + const secondShards = [...second.yieldShards()] as PropertyShard[]; + expect(firstShards).toEqual(secondShards); + expect(firstShards.map((shard) => defaultCodec.encode(shard.entries))) + .toEqual(secondShards.map((shard) => defaultCodec.encode(shard.entries))); }); }); diff --git a/test/unit/domain/services/StateReaderPropertyProjection.test.ts b/test/unit/domain/services/StateReaderPropertyProjection.test.ts index 832f4f9a..a337af00 100644 --- a/test/unit/domain/services/StateReaderPropertyProjection.test.ts +++ b/test/unit/domain/services/StateReaderPropertyProjection.test.ts @@ -75,12 +75,12 @@ describe('StateReader property projection routing', () => { const reader = createStateReader(state); expect(reader.getNodeContentMeta('node:1')).toEqual({ - oid: 'node-oid', + handle: 'node-oid', mime: null, size: 512, }); expect(reader.getEdgeContentMeta('node:1', 'node:2', 'rel')).toEqual({ - oid: 'edge-oid', + handle: 'edge-oid', mime: 'text/plain', size: null, }); diff --git a/test/unit/domain/services/StateSerializer.test.ts b/test/unit/domain/services/StateSerializer.test.ts index 619f316c..ff53cea3 100644 --- a/test/unit/domain/services/StateSerializer.test.ts +++ b/test/unit/domain/services/StateSerializer.test.ts @@ -457,12 +457,12 @@ describe('StateSerializer', () => { { nodeId: 'd', label: 'back', direction: 'incoming' }, ]); expect(reader.getNodeContentMeta('a')).toEqual({ - oid: 'oid:node', + handle: 'oid:node', mime: 'text/plain', size: 12, }); expect(reader.getEdgeContentMeta('a', 'c', 'rel')).toEqual({ - oid: 'oid:edge', + handle: 'oid:edge', mime: 'application/json', size: 7, }); @@ -477,7 +477,7 @@ describe('StateSerializer', () => { outgoing: [{ nodeId: 'c', label: 'rel', direction: 'outgoing' }], incoming: [{ nodeId: 'd', label: 'back', direction: 'incoming' }], content: { - oid: 'oid:node', + handle: 'oid:node', mime: 'text/plain', size: 12, }, diff --git a/test/unit/domain/services/SyncProtocol.divergence.test.ts b/test/unit/domain/services/SyncProtocol.divergence.test.ts index 6225d1a3..ee70f9b7 100644 --- a/test/unit/domain/services/SyncProtocol.divergence.test.ts +++ b/test/unit/domain/services/SyncProtocol.divergence.test.ts @@ -8,10 +8,8 @@ import { describe, it, expect, vi } from 'vitest'; import { processSyncRequest } from '../../../../src/domain/services/sync/SyncProtocol.ts'; import { encodePatchMessage } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { encode } from '../../../../src/infrastructure/codecs/CborCodec.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import FixturePatchJournal from '../../../helpers/FixturePatchJournal.ts'; const SHA_A = 'a'.repeat(40); const SHA_B = 'b'.repeat(40); @@ -32,11 +30,13 @@ function setupCommit(commits: Record, blobs: Record, s schema: 2, }); commits[sha] = { message, parents }; - blobs[patchOid] = encode(patch); + blobs[patchOid] = patch; } function createMockPersistence(/** @type {Record} */ commits = {}, /** @type {Record} */ blobs = {}) { return { + fixtureCommits: commits, + fixturePatches: blobs, showNode: vi.fn(async (/** @type {any} */ sha) => { if (commits[sha]?.message) { return commits[sha].message; } throw new Error(`Commit not found: ${sha}`); @@ -47,18 +47,13 @@ function createMockPersistence(/** @type {Record} */ commits = {}, } throw new Error(`Commit not found: ${sha}`); }), - readBlob: vi.fn(async (/** @type {any} */ oid) => { - if (blobs[oid]) { return blobs[oid]; } - throw new Error(`Blob not found: ${oid}`); - }), }; } function createPatchJournal(/** @type {any} */ persistence) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - commitPort: persistence, + return new FixturePatchJournal({ + commits: persistence.fixtureCommits, + patches: persistence.fixturePatches, }); } diff --git a/test/unit/domain/services/SyncProtocol.stateCoherence.test.ts b/test/unit/domain/services/SyncProtocol.stateCoherence.test.ts index c445bf24..a4352ee8 100644 --- a/test/unit/domain/services/SyncProtocol.stateCoherence.test.ts +++ b/test/unit/domain/services/SyncProtocol.stateCoherence.test.ts @@ -17,10 +17,8 @@ import { createFrontier, updateFrontier } from '../../../../src/domain/services/ // createDot reserved for future test expansion // import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import { encodePatchMessage } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { encode } from '../../../../src/infrastructure/codecs/CborCodec.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import FixturePatchJournal from '../../../helpers/FixturePatchJournal.ts'; // --------------------------------------------------------------------------- // Helpers @@ -70,10 +68,9 @@ function stateSignature(/** @type {any} */ state) { } function createPatchJournal(/** @type {any} */ persistence) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - commitPort: persistence, + return new FixturePatchJournal({ + commits: persistence.fixtureCommits, + patches: persistence.fixturePatches, }); } @@ -261,12 +258,14 @@ describe('SyncProtocol — state coherence (Phase 4, Invariant 5)', () => { const msgB = encodePatchMessage({ graph: 'events', writer: 'w1', lamport: 2, patchOid: OID_B, schema: 2 }); commits[SHA_A] = { message: msgA, parents: [] }; - blobs[OID_A] = encode(patchA); + blobs[OID_A] = patchA; commits[SHA_B] = { message: msgB, parents: [] }; // No parent — diverged from SHA_A - blobs[OID_B] = encode(patchB); + blobs[OID_B] = patchB; const persistence = { + fixtureCommits: commits, + fixturePatches: blobs, showNode: vi.fn(async (/** @type {any} */ sha) => { if (commits[sha]?.message) { return commits[sha].message; } throw new Error(`Commit not found: ${sha}`); @@ -283,10 +282,6 @@ describe('SyncProtocol — state coherence (Phase 4, Invariant 5)', () => { } throw new Error(`Commit not found: ${sha}`); }), - readBlob: vi.fn(async (/** @type {any} */ oid) => { - if (blobs[oid]) { return blobs[oid]; } - throw new Error(`Blob not found: ${oid}`); - }), }; const logger = createMockLogger(); diff --git a/test/unit/domain/services/SyncProtocol.test.ts b/test/unit/domain/services/SyncProtocol.test.ts index 7fe10371..109ce4ce 100644 --- a/test/unit/domain/services/SyncProtocol.test.ts +++ b/test/unit/domain/services/SyncProtocol.test.ts @@ -19,9 +19,7 @@ import { DEFAULT_COMMIT_MESSAGE_CODEC, encodePatchMessage, } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { encode } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import FixturePatchJournal from '../../../helpers/FixturePatchJournal.ts'; // ----------------------------------------------------------------------------- // Test Fixtures and Helpers @@ -65,6 +63,8 @@ function createNodeAddOp(node: any, dot: any) { /** @returns {any} */ function createMockPersistence(commits: any = {}, blobs: any = {}): any { return { + fixtureCommits: commits, + fixturePatches: blobs, showNode: vi.fn(async sha => { if (commits[sha]?.message) { return commits[sha].message; @@ -85,26 +85,13 @@ function createMockPersistence(commits: any = {}, blobs: any = {}): any { throw new Error(`Commit not found: ${sha}`); }), - readBlob: vi.fn(async oid => { - if (blobs[oid]) { - return blobs[oid]; - } - throw new Error(`Blob not found: ${oid}`); - }), }; } -/** - * Creates a CborPatchJournalAdapter wired to the given mock persistence's blob ops. - * @param {ReturnType} persistence - * @returns {CborPatchJournalAdapter} - */ function createPatchJournal(persistence: any) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - commitPort: persistence, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + return new FixturePatchJournal({ + commits: persistence.fixtureCommits, + patches: persistence.fixturePatches, }); } @@ -139,7 +126,7 @@ function setupCommit(commits: any, blobs: any, sha: any, patch: any, patchOid: a }); commits[sha] = { message, parents }; - blobs[patchOid] = encode(patch); + blobs[patchOid] = patch; } // ----------------------------------------------------------------------------- diff --git a/test/unit/domain/services/TreeConstruction.determinism.test.ts b/test/unit/domain/services/TreeConstruction.determinism.test.ts index 00baf539..060aa706 100644 --- a/test/unit/domain/services/TreeConstruction.determinism.test.ts +++ b/test/unit/domain/services/TreeConstruction.determinism.test.ts @@ -1,199 +1,100 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import fc from 'fast-check'; import { createRng } from '../../../helpers/seededRng.ts'; -import { PatchBuilder } from '../../../../src/domain/services/PatchBuilder.ts'; -import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import { createFrontier, updateFrontier } from '../../../../src/domain/services/Frontier.ts'; -import { createCheckpointEnvelope } from '../../../../src/domain/services/state/checkpointCreate.ts'; -import { createEmptyState, encodeEdgeKey as encodeEdgeKeyV5, encodePropKey as encodePropKeyV5 } from '../../../../src/domain/services/JoinReducer.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import { CONTENT_PROPERTY_KEY, encodeEdgePropKey } from '../../../../src/domain/services/KeyCodec.ts'; -import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; -import InMemoryBlobStorageAdapter from '../../../../test/helpers/InMemoryBlobStorageAdapter.ts'; -import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; +import { createEmptyState, encodePropKey } from '../../../../src/domain/services/JoinReducer.ts'; +import StateHashService from '../../../../src/domain/services/state/StateHashService.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import Patch from '../../../../src/domain/types/Patch.ts'; +import NodeAdd from '../../../../src/domain/types/ops/NodeAdd.ts'; import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; const PROPERTY_TEST_SEED = 4242; -const FIXED_CLOCK = { now: () => 42 }; -const FIXED_AUTHOR = 'Determinism '; -const crypto = new NodeCryptoAdapter(); - const contentIdsArb = fc.uniqueArray(fc.integer({ min: 1, max: 0xffff }), { minLength: 1, - maxLength: 5, + maxLength: 8, +}); +const stateHashService = new StateHashService({ + codec: defaultCodec, + crypto: new NodeCryptoAdapter(), }); -/** - * @param {number} value - * @returns {string} - */ -function makeOid(value) { - return value.toString(16).padStart(40, '0'); -} +describe('semantic storage determinism', () => { + it('produces the same patch bundle handle for attachment permutations', async () => { + await fc.assert( + fc.asyncProperty(contentIdsArb, fc.integer(), async (contentIds, seed) => { + const handles = contentIds.map((id) => new AssetHandle(`asset:${id}`)); + const baseline = await publish(handles); + const shuffled = await publish(createRng(seed).shuffle(handles)); + expect(shuffled).toBe(baseline); + }), + { seed: PROPERTY_TEST_SEED, numRuns: 40 }, + ); + }); -/** - * @param {number[]} contentIds - * @param {number|null} shuffleSeed - * @returns {Promise} - */ -async function createPatchTreeOid(contentIds, shuffleSeed) { - const persistence = new InMemoryGraphAdapter({ - author: FIXED_AUTHOR, - clock: FIXED_CLOCK, + it('produces the same checkpoint state hash for insertion permutations', async () => { + await fc.assert( + fc.asyncProperty(contentIdsArb, fc.integer(), async (contentIds, seed) => { + const baseline = await stateHash(contentIds); + const shuffled = await stateHash(createRng(seed).shuffle(contentIds)); + expect(shuffled).toBe(baseline); + }), + { seed: PROPERTY_TEST_SEED, numRuns: 40 }, + ); }); +}); - const builder = new PatchBuilder((({ - persistence, - patchJournal: new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - }), - graphName: 'g', - writerId: 'alice', +async function publish(attachments: readonly AssetHandle[]): Promise { + const history = new InMemoryGraphAdapter(); + const assets = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: assets }); + const journal = new CborPatchJournalAdapter({ + assetStorage: assets, + cas, + codec: defaultCodec, + commitReader: history, + }); + const patch = new Patch({ + schema: 2, + writer: 'alice', lamport: 1, - versionVector: VersionVector.empty(), - getCurrentState: () => null, - expectedParentSha: null, - blobStorage: new InMemoryBlobStorageAdapter(), - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }) as any)); - - for (let i = 0; i < contentIds.length; i++) { - builder.addNode(`n${i}`); - } - builder.addNode('dup'); - - for (let i = 0; i < contentIds.length; i++) { - await builder.attachContent(`n${i}`, `payload:${contentIds[i]}`); - } - await builder.attachContent('dup', `payload:${contentIds[0]}`); - - if (shuffleSeed !== null) { - (builder as any)._contentBlobs = createRng(shuffleSeed).shuffle((builder as any)._contentBlobs); - } - - const commitSha = await builder.commit(); - return await persistence.getCommitTree(commitSha); + context: {}, + ops: [new NodeAdd('node:a', Dot.create('alice', 1))], + reads: [], + writes: ['node:a'], + }); + const published = await journal.appendPatch({ + patch, + graph: 'events', + writer: 'alice', + targetRef: 'refs/warp/events/writers/alice', + expectedHead: null, + parent: null, + attachments, + }); + return published.bundleHandle.toString(); } -/** - * @param {number[]} contentIds - * @param {number|null} shuffleSeed - * @returns {Promise} - */ -async function createCheckpointTreeOid(contentIds, shuffleSeed) { - const persistence = new InMemoryGraphAdapter({ - author: FIXED_AUTHOR, - clock: FIXED_CLOCK, - }); +async function stateHash(contentIds: readonly number[]): Promise { const state = createEmptyState(); - const frontier = createFrontier(); - updateFrontier(frontier, 'alice', makeOid(0xabc)); - - const propItems: Array<{ key: string; value: unknown; eventId: { lamport: number; writerId: string; patchSha: string; opIndex: number } }> = []; - - for (let i = 0; i < contentIds.length; i++) { - const nodeId = `n${i}`; - state.nodeAlive.add(nodeId, Dot.create('alice', i + 1)); - propItems.push({ - key: encodePropKeyV5(nodeId, CONTENT_PROPERTY_KEY), - value: makeOid((contentIds[i] as number)), - eventId: { - lamport: i + 1, + for (const contentId of contentIds) { + const nodeId = `node:${contentId}`; + state.nodeAlive.add(nodeId, Dot.create('alice', contentId)); + state.mutatePropLWW( + encodePropKey(nodeId, 'content'), + { + lamport: contentId, writerId: 'alice', - patchSha: makeOid(0x1000 + i), + patchSha: contentId.toString(16).padStart(40, '0'), opIndex: 0, }, - }); - } - - state.nodeAlive.add('dup', Dot.create('alice', contentIds.length + 1)); - propItems.push({ - key: encodePropKeyV5('dup', CONTENT_PROPERTY_KEY), - value: makeOid((contentIds[0] as number)), - eventId: { - lamport: contentIds.length + 1, - writerId: 'alice', - patchSha: makeOid(0x2000), - opIndex: 0, - }, - }); - - if (contentIds.length > 1) { - const edgeFrom = 'n0'; - const edgeTo = `n${contentIds.length - 1}`; - const edgeLabel = 'rel'; - state.edgeAlive.add( - encodeEdgeKeyV5(edgeFrom, edgeTo, edgeLabel), - Dot.create('alice', contentIds.length + 2), + `asset:${contentId}`, ); - propItems.push({ - key: encodeEdgePropKey(edgeFrom, edgeTo, edgeLabel, CONTENT_PROPERTY_KEY), - value: makeOid((contentIds[0] as number)), - eventId: { - lamport: contentIds.length + 2, - writerId: 'alice', - patchSha: makeOid(0x3000), - opIndex: 0, - }, - }); } - - propItems.push({ - key: encodePropKeyV5('n0', 'name'), - value: 'ignore-me', - eventId: { - lamport: contentIds.length + 3, - writerId: 'alice', - patchSha: makeOid(0x4000), - opIndex: 0, - }, - }); - - const orderedItems = shuffleSeed === null - ? propItems - : createRng(shuffleSeed).shuffle(propItems); - - for (const item of orderedItems) { - state.mutatePropLWW((item as any).key, (item as any).eventId, (item as any).value); - } - - const checkpointSha = await createCheckpointEnvelope({ - persistence, - graphName: 'g', - state, - frontier, - crypto, - codec: defaultCodec, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - }); - - return await persistence.getCommitTree(checkpointSha); + return await stateHashService.compute(state); } - -describe('tree construction determinism (B99)', () => { - it('PatchBuilder tree OID is stable across content-blob order permutations', async () => { - await fc.assert( - fc.asyncProperty(contentIdsArb, fc.integer(), async (contentIds, shuffleSeed) => { - const baselineTreeOid = await createPatchTreeOid(contentIds, null); - const shuffledTreeOid = await createPatchTreeOid(contentIds, shuffleSeed); - expect(shuffledTreeOid).toBe(baselineTreeOid); - }), - { seed: PROPERTY_TEST_SEED, numRuns: 40 }, - ); - }); - - it('CheckpointService tree OID is stable across content-property insertion orders', async () => { - await fc.assert( - fc.asyncProperty(contentIdsArb, fc.integer(), async (contentIds, shuffleSeed) => { - const baselineTreeOid = await createCheckpointTreeOid(contentIds, null); - const shuffledTreeOid = await createCheckpointTreeOid(contentIds, shuffleSeed); - expect(shuffledTreeOid).toBe(baselineTreeOid); - }), - { seed: PROPERTY_TEST_SEED, numRuns: 40 }, - ); - }); -}); diff --git a/test/unit/domain/services/VisibleStateTransferPlanner.test.ts b/test/unit/domain/services/VisibleStateTransferPlanner.test.ts index 8f48fc21..efa2a18e 100644 --- a/test/unit/domain/services/VisibleStateTransferPlanner.test.ts +++ b/test/unit/domain/services/VisibleStateTransferPlanner.test.ts @@ -82,10 +82,10 @@ describe('VisibleStateTransferPlanner', () => { }, }, nodeContentMeta: { - beta: { oid: 'node-beta', mime: 'text/plain', size: 4 }, + beta: { handle: 'node-beta', mime: 'text/plain', size: 4 }, }, edgeContentMeta: { - [newEdgeKey]: { oid: 'edge-new', mime: 'application/octet-stream', size: 3 }, + [newEdgeKey]: { handle: 'edge-new', mime: 'application/octet-stream', size: 3 }, }, }); @@ -116,10 +116,10 @@ describe('VisibleStateTransferPlanner', () => { }, }, nodeContentMeta: { - alpha: { oid: 'node-alpha-old', mime: 'text/plain', size: 8 }, + alpha: { handle: 'node-alpha-old', mime: 'text/plain', size: 8 }, }, edgeContentMeta: { - [sharedEdgeKey]: { oid: 'edge-shared-old', mime: 'application/octet-stream', size: 5 }, + [sharedEdgeKey]: { handle: 'edge-shared-old', mime: 'application/octet-stream', size: 5 }, }, }); @@ -150,7 +150,7 @@ describe('VisibleStateTransferPlanner', () => { op: 'attach_node_content', nodeId: 'beta', content: new TextEncoder().encode('node:beta'), - contentOid: 'node-beta', + contentHandle: 'node-beta', mime: 'text/plain', size: 4, }, @@ -164,7 +164,7 @@ describe('VisibleStateTransferPlanner', () => { to: 'beta', label: 'fresh', content: new TextEncoder().encode('edge:alpha->beta:fresh'), - contentOid: 'edge-new', + contentHandle: 'edge-new', mime: 'application/octet-stream', size: 3, }, @@ -195,11 +195,11 @@ describe('VisibleStateTransferPlanner', () => { }); expect(loadNodeContent).toHaveBeenCalledTimes(1); - expect(loadNodeContent).toHaveBeenCalledWith('beta', { oid: 'node-beta', mime: 'text/plain', size: 4 }); + expect(loadNodeContent).toHaveBeenCalledWith('beta', { handle: 'node-beta', mime: 'text/plain', size: 4 }); expect(loadEdgeContent).toHaveBeenCalledTimes(1); expect(loadEdgeContent).toHaveBeenCalledWith( { from: 'alpha', to: 'beta', label: 'fresh' }, - { oid: 'edge-new', mime: 'application/octet-stream', size: 3 }, + { handle: 'edge-new', mime: 'application/octet-stream', size: 3 }, ); }); @@ -209,8 +209,8 @@ describe('VisibleStateTransferPlanner', () => { edges: [{ from: 'alpha', to: 'alpha', label: 'self' }], nodeProps: { alpha: { status: 'ready' } }, edgeProps: { [makeEdgeKey('alpha', 'alpha', 'self')]: { weight: 1 } }, - nodeContentMeta: { alpha: { oid: 'same-node', mime: 'text/plain', size: 4 } }, - edgeContentMeta: { [makeEdgeKey('alpha', 'alpha', 'self')]: { oid: 'same-edge', mime: 'text/plain', size: 4 } }, + nodeContentMeta: { alpha: { handle: 'same-node', mime: 'text/plain', size: 4 } }, + edgeContentMeta: { [makeEdgeKey('alpha', 'alpha', 'self')]: { handle: 'same-edge', mime: 'text/plain', size: 4 } }, }); const loadNodeContent = vi.fn(); diff --git a/test/unit/domain/services/WarpMessageCodec.test.ts b/test/unit/domain/services/WarpMessageCodec.test.ts index 09496647..9b96ad0c 100644 --- a/test/unit/domain/services/WarpMessageCodec.test.ts +++ b/test/unit/domain/services/WarpMessageCodec.test.ts @@ -1,932 +1,222 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; import { - encodePatchMessage, - encodeCheckpointMessage, - encodeAnchorMessage, + CHECKPOINT_STORAGE_FORMAT, + createGitCasPatchStorage, +} from '../../../../src/ports/CommitMessageCodecPort.ts'; +import { + TrailerCommitMessageCodecAdapter, decodePatchMessage, - decodeCheckpointMessage, - decodeAnchorMessage, detectMessageKind, + encodePatchMessage, } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { createGitCasPatchStorage } from '../../../../src/ports/CommitMessageCodecPort.ts'; - -// Test fixtures -const VALID_OID_SHA1 = 'a'.repeat(40); -const VALID_OID_SHA256 = 'b'.repeat(64); -const VALID_STATE_HASH = 'c'.repeat(64); - -describe('WarpMessageCodec', () => { - describe('encodePatchMessage', () => { - it('encodes a valid patch message with all required fields', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 42, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - - expect(message).toContain('warp:patch'); - expect(message).toContain('eg-kind: patch'); - expect(message).toContain('eg-graph: events'); - expect(message).toContain('eg-writer: node-1'); - expect(message).toContain('eg-lamport: 42'); - expect(message).toContain(`eg-patch-oid: ${VALID_OID_SHA1}`); - expect(message).toContain('eg-schema: 2'); - }); - - it('accepts custom schema version', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - schema: 3, - }); - - expect(message).toContain('eg-schema: 3'); - }); - - it('accepts SHA-256 OIDs', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA256, - }); - - expect(message).toContain(`eg-patch-oid: ${VALID_OID_SHA256}`); - }); - - it('rejects invalid graph name', () => { - expect(() => - encodePatchMessage({ - graph: '../etc', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }) - ).toThrow('path traversal'); - }); - - it('rejects empty graph name', () => { - expect(() => - encodePatchMessage({ - graph: '', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }) - ).toThrow('cannot be empty'); - }); - - it('rejects invalid writer ID', () => { - expect(() => - encodePatchMessage({ - graph: 'events', - writer: 'node/1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }) - ).toThrow('forward slash'); - }); - - it('rejects zero lamport', () => { - expect(() => - encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 0, - patchOid: VALID_OID_SHA1, - }) - ).toThrow('positive integer'); - }); - - it('rejects negative lamport', () => { - expect(() => - encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: -1, - patchOid: VALID_OID_SHA1, - }) - ).toThrow('positive integer'); - }); - - it('rejects non-integer lamport', () => { - expect(() => - encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1.5, - patchOid: VALID_OID_SHA1, - }) - ).toThrow('positive integer'); - }); - - it('rejects invalid OID format', () => { - expect(() => - encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: 'not-a-valid-oid', - }) - ).toThrow('40 or 64 character hex string'); - }); - - it('rejects OID with uppercase characters', () => { - expect(() => - encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: 'A'.repeat(40), - }) - ).toThrow('40 or 64 character hex string'); - }); - - it('includes eg-encrypted trailer when encrypted=true', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - encrypted: true, - }); - - expect(message).toContain('eg-encrypted: true'); - }); - - it('omits eg-encrypted trailer when encrypted=false', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - encrypted: false, - }); - - expect(message).not.toContain('eg-encrypted'); - }); - it('omits eg-encrypted trailer by default', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }); - - expect(message).not.toContain('eg-encrypted'); - }); +const LEGACY_OID = 'a'.repeat(40); +const STATE_HASH = 'b'.repeat(64); + +describe('TrailerCommitMessageCodecAdapter', () => { + const codec = new TrailerCommitMessageCodecAdapter(); + + it('round-trips current git-cas patch asset locators', () => { + const encoded = codec.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'writer-1', + lamport: 42, + schema: 3, + patchHandle: new AssetHandle('git-cas:asset:patch-42'), + storage: createGitCasPatchStorage({ encrypted: false }), + }); + const decoded = codec.decodePatch(encoded); + + expect(encoded).toContain('eg-patch-handle: git-cas:asset:patch-42'); + expect(encoded).toContain('eg-storage-version: v19'); + expect(encoded).toContain('eg-storage-schema: git-cas-asset-patch-v1'); + expect(decoded).toMatchObject({ + kind: 'patch', + graph: 'events', + writer: 'writer-1', + lamport: 42, + schema: 3, + storage: { strategy: 'git-cas-asset', encrypted: false }, + }); + expect(decoded.patchHandle.toString()).toBe('git-cas:asset:patch-42'); }); - describe('encodeCheckpointMessage', () => { - it('encodes a valid checkpoint message with all required fields', () => { - const message = encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - schema: 2, - }); - - expect(message).toContain('warp:checkpoint'); - expect(message).toContain('eg-kind: checkpoint'); - expect(message).toContain('eg-graph: events'); - expect(message).toContain(`eg-state-hash: ${VALID_STATE_HASH}`); - expect(message).toContain(`eg-frontier-oid: ${VALID_OID_SHA1}`); - expect(message).toContain(`eg-index-oid: ${VALID_OID_SHA1}`); - expect(message).toContain('eg-schema: 2'); - }); - - it('accepts custom schema version', () => { - const message = encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - schema: 3, - }); - - expect(message).toContain('eg-schema: 3'); - }); - - it('rejects invalid graph name', () => { - expect(() => - encodeCheckpointMessage({ - graph: 'my graph', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - }) - ).toThrow('contains space'); - }); - - it('rejects invalid stateHash (not 64 chars)', () => { - expect(() => - encodeCheckpointMessage({ - graph: 'events', - stateHash: 'a'.repeat(40), - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - }) - ).toThrow('64 character hex string'); + it('records current encrypted asset routes without exposing keys', () => { + const encoded = codec.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'writer-1', + lamport: 1, + schema: 2, + patchHandle: new AssetHandle('git-cas:asset:encrypted'), + storage: createGitCasPatchStorage({ encrypted: true }), }); - it('rejects invalid frontierOid', () => { - expect(() => - encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: 'invalid', - indexOid: VALID_OID_SHA1, - }) - ).toThrow('40 or 64 character hex string'); - }); - - it('rejects invalid indexOid', () => { - expect(() => - encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: 'invalid', - }) - ).toThrow('40 or 64 character hex string'); + expect(encoded).toContain('eg-encrypted: true'); + expect(codec.decodePatch(encoded).storage).toMatchObject({ + strategy: 'git-cas-asset', + encrypted: true, }); + expect(encoded).not.toMatch(/key|passphrase/iu); }); - describe('encodeAnchorMessage', () => { - it('encodes a valid anchor message with required fields', () => { - const message = encodeAnchorMessage({ graph: 'events', schema: 2 }); - - expect(message).toContain('warp:anchor'); - expect(message).toContain('eg-kind: anchor'); - expect(message).toContain('eg-graph: events'); - expect(message).toContain('eg-schema: 2'); - }); - - it('accepts custom schema version', () => { - const message = encodeAnchorMessage({ graph: 'events', schema: 5 }); - - expect(message).toContain('eg-schema: 5'); - }); - - it('rejects invalid graph name', () => { - expect(() => encodeAnchorMessage({ graph: '' })).toThrow('cannot be empty'); + it('continues to decode supported legacy patch OID messages', () => { + const encoded = encodePatchMessage({ + graph: 'legacy-events', + writer: 'writer-1', + lamport: 1, + patchOid: LEGACY_OID, + schema: 2, }); + const decoded = decodePatchMessage(encoded); - it('rejects zero schema version', () => { - expect(() => encodeAnchorMessage({ graph: 'events', schema: 0 })).toThrow('positive integer'); - }); + expect(decoded.patchHandle.toString()).toBe(LEGACY_OID); + expect(decoded.storage.strategy).toBe('legacy-git-blob'); + expect(decoded.encrypted).toBe(false); }); - describe('decodePatchMessage', () => { - it('decodes a valid patch message', () => { - const encoded = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 42, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - - const decoded = decodePatchMessage(encoded); + it('classifies encrypted legacy locators as external storage', () => { + const decoded = decodePatchMessage(encodePatchMessage({ + graph: 'legacy-events', + writer: 'writer-1', + lamport: 1, + patchOid: LEGACY_OID, + encrypted: true, + })); - expect(decoded.kind).toBe('patch'); - expect(decoded.graph).toBe('events'); - expect(decoded.writer).toBe('node-1'); - expect(decoded.lamport).toBe(42); - expect(decoded.patchOid).toBe(VALID_OID_SHA1); - expect(decoded.schema).toBe(2); - }); - - it('throws when eg-kind is not patch', () => { - const anchorMessage = encodeAnchorMessage({ graph: 'events' }); - - expect(() => decodePatchMessage(anchorMessage)).toThrow("eg-kind must be 'patch'"); - }); - - it('throws when eg-graph is missing', () => { - // Manually construct a malformed message - const message = `warp:patch - -eg-kind: patch -eg-writer: node-1 -eg-lamport: 1 -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('missing required trailer eg-graph'); - }); - - it('throws when eg-writer is missing', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-lamport: 1 -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('missing required trailer eg-writer'); - }); - - it('throws when eg-lamport is missing', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: node-1 -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('missing required trailer eg-lamport'); - }); - - it('throws when eg-lamport is not a positive integer', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: node-1 -eg-lamport: zero -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('eg-lamport must be a positive integer'); - }); - - it('throws when eg-lamport is zero', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: node-1 -eg-lamport: 0 -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('eg-lamport must be a positive integer'); - }); - - it('throws when eg-patch-oid is missing', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: node-1 -eg-lamport: 1 -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('missing required trailer eg-patch-oid'); - }); - - it('throws when eg-schema is missing', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: node-1 -eg-lamport: 1 -eg-patch-oid: ${VALID_OID_SHA1}`; - - expect(() => decodePatchMessage(message)).toThrow('missing required trailer eg-schema'); - }); - - it('throws when eg-graph contains invalid characters', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: inv alid/name -eg-writer: node-1 -eg-lamport: 1 -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('Invalid graph name'); - }); - - it('throws when eg-writer contains invalid characters', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: bad writer! -eg-lamport: 1 -eg-patch-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('Invalid writer ID'); - }); - - it('throws when eg-patch-oid is not a valid hex OID', () => { - const message = `warp:patch - -eg-kind: patch -eg-graph: events -eg-writer: node-1 -eg-lamport: 1 -eg-patch-oid: not-a-valid-oid -eg-schema: 1`; - - expect(() => decodePatchMessage(message)).toThrow('Invalid patchOid'); - }); - - it('decodes encrypted=true from eg-encrypted trailer', () => { - const encoded = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - encrypted: true, - }); - const decoded = decodePatchMessage(encoded); - expect(decoded.encrypted).toBe(true); - }); - - it('decodes encrypted=false when eg-encrypted trailer is absent', () => { - const encoded = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }); - const decoded = decodePatchMessage(encoded); - expect(decoded.encrypted).toBe(false); - }); - - it('roundtrips encrypted flag correctly', () => { - for (const encrypted of [true, false]) { - const encoded = encodePatchMessage({ - graph: 'events', - writer: 'w1', - lamport: 5, - patchOid: VALID_OID_SHA1, - encrypted, - }); - const decoded = decodePatchMessage(encoded); - expect(decoded.encrypted).toBe(encrypted); - } + expect(decoded.storage).toMatchObject({ + strategy: 'legacy-external-storage', + encrypted: true, }); }); - describe('decodeCheckpointMessage', () => { - it('decodes a valid checkpoint message', () => { - const encoded = encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - schema: 2, - }); - - const decoded = decodeCheckpointMessage(encoded); - - expect(decoded.kind).toBe('checkpoint'); - expect(decoded.graph).toBe('events'); - expect(decoded.stateHash).toBe(VALID_STATE_HASH); - expect(decoded.frontierOid).toBe(VALID_OID_SHA1); - expect(decoded.indexOid).toBe(VALID_OID_SHA1); - expect(decoded.schema).toBe(2); - }); - - it('throws when eg-kind is not checkpoint', () => { - const patchMessage = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }); - - expect(() => decodeCheckpointMessage(patchMessage)).toThrow( - "eg-kind must be 'checkpoint'" - ); - }); - - it('throws when eg-graph is missing', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-state-hash: ${VALID_STATE_HASH} -eg-frontier-oid: ${VALID_OID_SHA1} -eg-index-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow('missing required trailer eg-graph'); - }); - - it('throws when eg-state-hash is missing', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-frontier-oid: ${VALID_OID_SHA1} -eg-index-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow( - 'missing required trailer eg-state-hash' - ); - }); - - it('throws when eg-frontier-oid is missing', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-state-hash: ${VALID_STATE_HASH} -eg-index-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow( - 'missing required trailer eg-frontier-oid' - ); - }); - - it('throws when eg-index-oid is missing', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-state-hash: ${VALID_STATE_HASH} -eg-frontier-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow( - 'missing required trailer eg-index-oid' - ); - }); - - it('throws when eg-schema is missing', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-state-hash: ${VALID_STATE_HASH} -eg-frontier-oid: ${VALID_OID_SHA1} -eg-index-oid: ${VALID_OID_SHA1}`; - - expect(() => decodeCheckpointMessage(message)).toThrow( - 'missing required trailer eg-schema' - ); - }); - - it('throws when eg-graph contains invalid characters', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: inv alid/name -eg-state-hash: ${VALID_STATE_HASH} -eg-frontier-oid: ${VALID_OID_SHA1} -eg-index-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow('Invalid graph name'); - }); - - it('throws when eg-state-hash is not a valid SHA-256', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-state-hash: not-a-sha256 -eg-frontier-oid: ${VALID_OID_SHA1} -eg-index-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow('Invalid stateHash'); - }); - - it('throws when eg-frontier-oid is not a valid hex OID', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-state-hash: ${VALID_STATE_HASH} -eg-frontier-oid: not-a-valid-oid -eg-index-oid: ${VALID_OID_SHA1} -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow('Invalid frontierOid'); - }); - - it('throws when eg-index-oid is not a valid hex OID', () => { - const message = `warp:checkpoint - -eg-kind: checkpoint -eg-graph: events -eg-state-hash: ${VALID_STATE_HASH} -eg-frontier-oid: ${VALID_OID_SHA1} -eg-index-oid: not-a-valid-oid -eg-schema: 1`; - - expect(() => decodeCheckpointMessage(message)).toThrow('Invalid indexOid'); + it('round-trips storage-neutral checkpoint publication metadata', () => { + const encoded = codec.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'events', + stateHash: STATE_HASH, + schema: 5, + checkpointVersion: CHECKPOINT_STORAGE_FORMAT, + bundleHandle: new BundleHandle('bundle:checkpoint'), + }); + const decoded = codec.decodeCheckpoint(encoded); + + expect(encoded).toContain('eg-checkpoint: v19'); + expect(encoded).toContain('eg-checkpoint-handle: bundle:checkpoint'); + expect(encoded).not.toContain('frontier-oid'); + expect(encoded).not.toContain('index-oid'); + expect(decoded).toEqual({ + kind: 'checkpoint', + graph: 'events', + stateHash: STATE_HASH, + schema: 5, + checkpointVersion: 'v19', + bundleHandle: new BundleHandle('bundle:checkpoint'), }); }); - describe('decodeAnchorMessage', () => { - it('decodes a valid anchor message', () => { - const encoded = encodeAnchorMessage({ graph: 'events', schema: 2 }); - - const decoded = decodeAnchorMessage(encoded); - - expect(decoded.kind).toBe('anchor'); - expect(decoded.graph).toBe('events'); - expect(decoded.schema).toBe(2); - }); - - it('throws when eg-kind is not anchor', () => { - const patchMessage = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - }); - - expect(() => decodeAnchorMessage(patchMessage)).toThrow("eg-kind must be 'anchor'"); - }); - - it('throws when eg-graph is missing', () => { - const message = `warp:anchor - -eg-kind: anchor -eg-schema: 1`; - - expect(() => decodeAnchorMessage(message)).toThrow('missing required trailer eg-graph'); - }); - - it('throws when eg-schema is missing', () => { - const message = `warp:anchor - -eg-kind: anchor -eg-graph: events`; - - expect(() => decodeAnchorMessage(message)).toThrow('missing required trailer eg-schema'); - }); - - it('throws when eg-schema is invalid', () => { - const message = `warp:anchor - -eg-kind: anchor -eg-graph: events -eg-schema: invalid`; - - expect(() => decodeAnchorMessage(message)).toThrow('eg-schema must be a positive integer'); - }); + it('rejects contradictory checkpoint storage metadata on encode', () => { + expect(() => codec.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'events', + stateHash: STATE_HASH, + schema: 5, + checkpointVersion: CHECKPOINT_STORAGE_FORMAT, + bundleHandle: null, + })).toThrow(/requires a bundle handle/u); + + expect(() => codec.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'events', + stateHash: STATE_HASH, + schema: 5, + checkpointVersion: 'v5', + bundleHandle: new BundleHandle('bundle:checkpoint'), + })).toThrow(/current storage version/u); }); - describe('detectMessageKind', () => { - it('detects patch messages', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - - expect(detectMessageKind(message)).toBe('patch'); - }); - - it('detects checkpoint messages', () => { - const message = encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - schema: 2, - }); - - expect(detectMessageKind(message)).toBe('checkpoint'); - }); - - it('detects anchor messages', () => { - const message = encodeAnchorMessage({ graph: 'events', schema: 2 }); - - expect(detectMessageKind(message)).toBe('anchor'); - }); - - it('returns null for non-WARP messages', () => { - const message = 'Just a regular commit message'; - - expect(detectMessageKind(message)).toBeNull(); - }); - - it('returns null for messages with unknown eg-kind', () => { - const message = `warp:unknown - -eg-kind: unknown -eg-graph: events -eg-schema: 1`; - - expect(detectMessageKind(message)).toBeNull(); - }); - - it('returns null for non-string input', () => { - expect(detectMessageKind(null as any)).toBeNull(); - expect(detectMessageKind(undefined as any)).toBeNull(); - expect(detectMessageKind(123 as any)).toBeNull(); - expect(detectMessageKind({} as any)).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(detectMessageKind('')).toBeNull(); - }); - - it('returns null for malformed messages', () => { - expect(detectMessageKind('just\nsome\ntext')).toBeNull(); + it('round-trips anchors', () => { + const encoded = codec.encodeAnchor({ kind: 'anchor', graph: 'events', schema: 5 }); + expect(codec.decodeAnchor(encoded)).toEqual({ + kind: 'anchor', + graph: 'events', + schema: 5, }); }); - describe('round-trip encoding/decoding', () => { - it('patch message round-trips correctly', () => { - const original = { - graph: 'my-events', - writer: 'producer-1', - lamport: 12345, - patchOid: VALID_OID_SHA1, - schema: 2, - }; - - const encoded = encodePatchMessage(original); - const decoded = decodePatchMessage(encoded); - - expect(decoded.kind).toBe('patch'); - expect(decoded.graph).toBe(original.graph); - expect(decoded.writer).toBe(original.writer); - expect(decoded.lamport).toBe(original.lamport); - expect(decoded.patchOid).toBe(original.patchOid); - expect(decoded.schema).toBe(original.schema); - }); - - it('checkpoint message round-trips correctly', () => { - const original = { - graph: 'team/events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: 'd'.repeat(40), - schema: 2, - }; - - const encoded = encodeCheckpointMessage(original); - const decoded = decodeCheckpointMessage(encoded); - - expect(decoded.kind).toBe('checkpoint'); - expect(decoded.graph).toBe(original.graph); - expect(decoded.stateHash).toBe(original.stateHash); - expect(decoded.frontierOid).toBe(original.frontierOid); - expect(decoded.indexOid).toBe(original.indexOid); - expect(decoded.schema).toBe(original.schema); - }); - - it('anchor message round-trips correctly', () => { - const original = { - graph: 'production', - schema: 3, - }; - - const encoded = encodeAnchorMessage(original); - const decoded = decodeAnchorMessage(encoded); - - expect(decoded.kind).toBe('anchor'); - expect(decoded.graph).toBe(original.graph); - expect(decoded.schema).toBe(original.schema); - }); - - it('handles edge case graph names', () => { - const edgeCases = ['a', 'Graph123', 'my-graph_v2', 'team/shared/events']; - - for (const graph of edgeCases) { - const encoded = encodeAnchorMessage({ graph, schema: 2 }); - const decoded = decodeAnchorMessage(encoded); - expect(decoded.graph).toBe(graph); - } - }); - - it('handles edge case writer IDs', () => { - const edgeCases = ['a', 'Writer_01', 'node-123', 'writer.v2']; - - for (const writer of edgeCases) { - const encoded = encodePatchMessage({ - graph: 'events', - writer, - lamport: 1, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - const decoded = decodePatchMessage(encoded); - expect(decoded.writer).toBe(writer); - } - }); - - it('handles large lamport values', () => { - const largeLamport = Number.MAX_SAFE_INTEGER; - - const encoded = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: largeLamport, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - const decoded = decodePatchMessage(encoded); - - expect(decoded.lamport).toBe(largeLamport); - }); + it.each([ + ['patch', codec.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'writer-1', + lamport: 1, + schema: 2, + patchHandle: new AssetHandle('asset:patch'), + storage: createGitCasPatchStorage({ encrypted: false }), + })], + ['checkpoint', codec.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'events', + stateHash: STATE_HASH, + schema: 5, + checkpointVersion: 'v5', + bundleHandle: null, + })], + ['anchor', codec.encodeAnchor({ kind: 'anchor', graph: 'events', schema: 5 })], + ] as const)('detects %s messages', (kind, message) => { + expect(codec.detectKind(message)).toBe(kind); + expect(detectMessageKind(message)).toBe(kind); }); - describe('message format verification', () => { - it('patch message has correct title', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - - const lines = message.split('\n'); - expect(lines[0]).toBe('warp:patch'); - }); - - it('preserves legacy patch trailer line order', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - schema: 2, - }); - - expect(message.split('\n')).toEqual([ - 'warp:patch', - '', - 'eg-kind: patch', - 'eg-graph: events', - 'eg-writer: node-1', - 'eg-lamport: 1', - `eg-patch-oid: ${VALID_OID_SHA1}`, - 'eg-schema: 2', - '', - ]); - }); - - it('preserves conditional patch trailer line order', () => { - const message = encodePatchMessage({ - graph: 'events', - writer: 'node-1', - lamport: 1, - patchOid: VALID_OID_SHA1, - schema: 3, - storage: createGitCasPatchStorage({ encrypted: true }), - }); - - expect(message.split('\n')).toEqual([ - 'warp:patch', - '', - 'eg-kind: patch', - 'eg-graph: events', - 'eg-writer: node-1', - 'eg-lamport: 1', - `eg-patch-oid: ${VALID_OID_SHA1}`, - 'eg-schema: 3', - 'eg-storage-version: v17', - 'eg-storage-schema: git-cas-cbor-patch-v1', - 'eg-encrypted: true', - '', - ]); - }); - - it('checkpoint message has correct title', () => { - const message = encodeCheckpointMessage({ - graph: 'events', - stateHash: VALID_STATE_HASH, - frontierOid: VALID_OID_SHA1, - indexOid: VALID_OID_SHA1, - schema: 2, - }); - - const lines = message.split('\n'); - expect(lines[0]).toBe('warp:checkpoint'); - }); - - it('anchor message has correct title', () => { - const message = encodeAnchorMessage({ graph: 'events', schema: 2 }); - - const lines = message.split('\n'); - expect(lines[0]).toBe('warp:anchor'); - }); + it('returns null for non-WARP messages', () => { + expect(codec.detectKind('ordinary commit')).toBeNull(); + }); - it('trailers are separated by blank line from title', () => { - const message = encodeAnchorMessage({ graph: 'events', schema: 2 }); + it('rejects malformed graph, writer, lamport, hash, and handles', () => { + expect(() => codec.encodePatch({ + kind: 'patch', + graph: '../events', + writer: 'writer-1', + lamport: 1, + schema: 2, + patchHandle: new AssetHandle('asset:patch'), + storage: createGitCasPatchStorage({ encrypted: false }), + })).toThrow(/path traversal/u); + expect(() => codec.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'writer/1', + lamport: 1, + schema: 2, + patchHandle: new AssetHandle('asset:patch'), + storage: createGitCasPatchStorage({ encrypted: false }), + })).toThrow(/forward slash/u); + expect(() => encodePatchMessage({ + graph: 'events', + writer: 'writer-1', + lamport: 0, + patchOid: LEGACY_OID, + })).toThrow(/positive integer/u); + expect(() => codec.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'events', + stateHash: 'short', + schema: 5, + checkpointVersion: 'v5', + bundleHandle: null, + })).toThrow(/64 character hex string/u); + }); - const lines = message.split('\n'); - expect(lines[0]).toBe('warp:anchor'); - expect(lines[1]).toBe(''); - expect(lines[2]).toMatch(/^eg-/); - }); + it('rejects locator/type mismatches and missing required trailers', () => { + expect(() => codec.decodePatch('warp:patch\n\neg-kind: patch\n')) + .toThrow(/missing required trailer/u); + expect(() => codec.decodeCheckpoint(codec.encodeAnchor({ + kind: 'anchor', + graph: 'events', + schema: 5, + }))).toThrow(/must be 'checkpoint'/u); }); }); diff --git a/test/unit/domain/services/WarpMessageCodec.v3.test.ts b/test/unit/domain/services/WarpMessageCodec.v3.test.ts index 40a84753..0daa87d7 100644 --- a/test/unit/domain/services/WarpMessageCodec.v3.test.ts +++ b/test/unit/domain/services/WarpMessageCodec.v3.test.ts @@ -150,7 +150,7 @@ describe('WarpMessageCodec schema v3', () => { expect(decoded.graph).toBe('events'); expect(decoded.writer).toBe('node-1'); expect(decoded.lamport).toBe(42); - expect(decoded.patchOid).toBe(VALID_OID_SHA1); + expect(decoded.patchHandle.toString()).toBe(VALID_OID_SHA1); expect(decoded.schema).toBe(3); }); @@ -187,7 +187,7 @@ describe('WarpMessageCodec schema v3', () => { expect(decoded.graph).toBe(original.graph); expect(decoded.writer).toBe(original.writer); expect(decoded.lamport).toBe(original.lamport); - expect(decoded.patchOid).toBe(original.patchOid); + expect(decoded.patchHandle.toString()).toBe(original.patchOid); expect(decoded.schema).toBe(3); }); @@ -207,7 +207,7 @@ describe('WarpMessageCodec schema v3', () => { expect(decoded.graph).toBe(original.graph); expect(decoded.writer).toBe(original.writer); expect(decoded.lamport).toBe(original.lamport); - expect(decoded.patchOid).toBe(original.patchOid); + expect(decoded.patchHandle.toString()).toBe(original.patchOid); expect(decoded.schema).toBe(2); }); }); diff --git a/test/unit/domain/services/WormholeService.test.ts b/test/unit/domain/services/WormholeService.test.ts index 8408eb3f..0f844878 100644 --- a/test/unit/domain/services/WormholeService.test.ts +++ b/test/unit/domain/services/WormholeService.test.ts @@ -8,9 +8,11 @@ import { } from '../../../../src/domain/services/WormholeService.ts'; import ProvenancePayload from '../../../../src/domain/services/provenance/ProvenancePayload.ts'; import WormholeError from '../../../../src/domain/errors/WormholeError.ts'; -import EncryptionError from '../../../../src/domain/errors/EncryptionError.ts'; -import PersistenceError from '../../../../src/domain/errors/PersistenceError.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import WarpStream from '../../../../src/domain/stream/WarpStream.ts'; +import type PatchEntry from '../../../../src/domain/artifacts/PatchEntry.ts'; +import type Patch from '../../../../src/domain/types/Patch.ts'; +import type { PatchCommitMessage } from '../../../../src/ports/CommitMessageCodecPort.ts'; +import type PatchJournalPort from '../../../../src/ports/PatchJournalPort.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC, encodePatchMessage, @@ -35,19 +37,44 @@ import { type CreateWormholeOptions = Parameters[0]; type CreateWormholeTestOptions = - Omit & - Partial>; + Omit & + Partial>; async function createWormhole( options: CreateWormholeTestOptions, ): ReturnType { return await createWormholeWithCodec({ ...options, - codec: options.codec ?? defaultCodec, commitMessageCodec: options.commitMessageCodec ?? DEFAULT_COMMIT_MESSAGE_CODEC, + patchJournal: options.patchJournal ?? fixturePatchJournal(options.persistence), }); } +function fixturePatchJournal(persistence: CreateWormholeOptions['persistence']): PatchJournalPort { + const fixture = persistence as CreateWormholeOptions['persistence'] & { + readonly patchJournal?: PatchJournalPort; + }; + return fixture.patchJournal ?? patchJournalThat(async () => { + throw new WormholeTestError('patch journal should not be read by this test'); + }); +} + +function patchJournalThat( + readPatch: (message: PatchCommitMessage) => Promise, +): PatchJournalPort { + return { + async appendPatch() { + throw new WormholeTestError('appendPatch is outside this fixture'); + }, + readPatch, + scanPatchRange(): WarpStream { + return WarpStream.from([]); + }, + }; +} + +class WormholeTestError extends Error {} + describe('WormholeService', () => { describe('createWormhole', () => { it('creates a wormhole from a single patch', async () => { @@ -277,11 +304,6 @@ describe('WormholeService', () => { it('throws E_WORMHOLE_INVALID_RANGE when a patch belongs to another graph', async () => { const sha = generateOid(5000); const patchOid = generateOid(5001); - const patch = createPatch({ - writer: 'alice', - lamport: 1, - ops: [createNodeAddV2('node-a', Dot.create('alice', 1))], - }); const persistence = { nodeExists: vi.fn(async (candidate) => candidate === sha), getNodeInfo: vi.fn(async () => ({ @@ -293,7 +315,6 @@ describe('WormholeService', () => { }), parents: [], })), - readBlob: vi.fn(async () => defaultCodec.encode(patch)), }; await expect(createWormhole({ @@ -307,49 +328,14 @@ describe('WormholeService', () => { }); }); - it('throws EncryptionError for encrypted patches without patchBlobStorage', async () => { + it('loads patch payloads through the semantic patch journal', async () => { const sha = generateOid(6000); const patchOid = generateOid(6001); - const readBlob = vi.fn(); - const persistence = { - nodeExists: vi.fn(async (candidate) => candidate === sha), - getNodeInfo: vi.fn(async () => ({ - message: encodePatchMessage({ - graph: 'test-graph', - writer: 'alice', - lamport: 1, - patchOid, - encrypted: true, - }), - parents: [], - })), - readBlob, - }; - - await expect(createWormhole({ - persistence: (persistence as any), - graphName: 'test-graph', - fromSha: sha, - toSha: sha, - })).rejects.toBeInstanceOf(EncryptionError); - expect(readBlob).not.toHaveBeenCalled(); - }); - - it('loads encrypted patches from patchBlobStorage when provided', async () => { - const sha = generateOid(7000); - const patchOid = generateOid(7001); const patch = createPatch({ writer: 'alice', lamport: 1, ops: [createNodeAddV2('node-a', Dot.create('alice', 1))], }); - const patchBlobStorage = { - retrieve: vi.fn(async (oid) => { - expect(oid).toBe(patchOid); - return defaultCodec.encode(patch); - }), - }; - const readBlob = vi.fn(); const persistence = { nodeExists: vi.fn(async (candidate) => candidate === sha), getNodeInfo: vi.fn(async () => ({ @@ -358,29 +344,30 @@ describe('WormholeService', () => { writer: 'alice', lamport: 1, patchOid, - encrypted: true, }), parents: [], })), - readBlob, }; + const readPatch = vi.fn(async (message: PatchCommitMessage) => { + expect(message.patchHandle.toString()).toBe(patchOid); + return patch; + }); const wormhole = await createWormhole({ persistence: (persistence as any), graphName: 'test-graph', fromSha: sha, toSha: sha, - patchBlobStorage: (patchBlobStorage as any), + patchJournal: patchJournalThat(readPatch), }); expect(wormhole.patchCount).toBe(1); - expect(patchBlobStorage.retrieve).toHaveBeenCalledTimes(1); - expect(readBlob).not.toHaveBeenCalled(); + expect(readPatch).toHaveBeenCalledTimes(1); }); - it('throws PersistenceError when the patch blob is missing', async () => { - const sha = generateOid(8000); - const patchOid = generateOid(8001); + it('propagates semantic patch journal read failures', async () => { + const sha = generateOid(7000); + const patchOid = generateOid(7001); const persistence = { nodeExists: vi.fn(async (candidate) => candidate === sha), getNodeInfo: vi.fn(async () => ({ @@ -392,15 +379,16 @@ describe('WormholeService', () => { }), parents: [], })), - readBlob: vi.fn(async () => null), }; + const failure = new WormholeTestError('asset unavailable'); await expect(createWormhole({ persistence: (persistence as any), graphName: 'test-graph', fromSha: sha, toSha: sha, - })).rejects.toBeInstanceOf(PersistenceError); + patchJournal: patchJournalThat(async () => await Promise.reject(failure)), + })).rejects.toBe(failure); }); }); diff --git a/test/unit/domain/services/audit/AuditChainVerifier.test.ts b/test/unit/domain/services/audit/AuditChainVerifier.test.ts new file mode 100644 index 00000000..de25483f --- /dev/null +++ b/test/unit/domain/services/audit/AuditChainVerifier.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from 'vitest'; + +import AuditChainVerifier from '../../../../../src/domain/services/audit/AuditChainVerifier.ts'; +import { encodeAuditMessage } from '../../../../../src/domain/services/codec/AuditMessageCodec.ts'; +import { CborCodec } from '../../../../../src/infrastructure/codecs/CborCodec.ts'; +import AuditLogPort, { + type AppendAuditRecordRequest, + type AuditLogEntry, + type PublishedAuditRecord, +} from '../../../../../src/ports/AuditLogPort.ts'; +import CodecPort from '../../../../../src/ports/CodecPort.ts'; + +const GRAPH = 'events'; +const WRITER = 'alice'; +const GENESIS = '1'.repeat(40); +const TIP = '2'.repeat(40); +const DATA = 'a'.repeat(40); +const DIGEST = 'b'.repeat(64); +const ZERO = '0'.repeat(40); +const codec = new CborCodec(); + +type Receipt = { + version: number; + graphName: string; + writerId: string; + dataCommit: string; + tickStart: number; + tickEnd: number; + opsDigest: string; + prevAuditCommit: string; + timestamp: number; +}; + +class FixtureAuditLog extends AuditLogPort { + readonly entries = new Map(); + head: string | null = null; + movedHead: string | null = null; + headFailure: Error | string | null = null; + readonly #headReads = new Map(); + + override async readHead(_graphName: string, _writerId: string): Promise { + if (this.headFailure !== null) { + throw this.headFailure; + } + const key = `${_graphName}:${_writerId}`; + const reads = this.#headReads.get(key) ?? 0; + this.#headReads.set(key, reads + 1); + return reads > 0 && this.movedHead !== null ? this.movedHead : this.head; + } + + override async listWriterIds(): Promise { + return [WRITER]; + } + + override async append(_request: AppendAuditRecordRequest): Promise { + throw new Error('FixtureAuditLog does not publish'); + } + + override async readEntry(sha: string): Promise { + const entry = this.entries.get(sha); + if (entry === undefined) { + throw new Error(`missing ${sha}`); + } + return entry; + } +} + +class ThrowingCodec extends CodecPort { + override encode(data: TEncoded): Uint8Array { + return codec.encode(data); + } + + override decode(_bytes: Uint8Array): TDecoded { + throw new Error('corrupt receipt bytes'); + } +} + +function receipt(overrides: Partial = {}): Receipt { + return { + version: 1, + graphName: GRAPH, + writerId: WRITER, + dataCommit: DATA, + tickStart: 1, + tickEnd: 1, + opsDigest: DIGEST, + prevAuditCommit: ZERO, + timestamp: 1, + ...overrides, + }; +} + +function auditMessage(value: Receipt): string { + return encodeAuditMessage({ + graph: value.graphName, + writer: value.writerId, + dataCommit: value.dataCommit, + opsDigest: value.opsDigest, + }); +} + +function addEntry( + log: FixtureAuditLog, + sha: string, + value: unknown, + options: { parents?: string[]; message?: string; messageReceipt?: Receipt } = {}, +): void { + const messageReceipt = options.messageReceipt ?? receipt(); + log.entries.set(sha, Object.freeze({ + sha, + parents: Object.freeze([...(options.parents ?? [])]), + message: options.message ?? auditMessage(messageReceipt), + receipt: codec.encode(value), + })); +} + +function validChain(): FixtureAuditLog { + const log = new FixtureAuditLog(); + const first = receipt(); + const second = receipt({ + tickStart: 2, + tickEnd: 2, + dataCommit: 'c'.repeat(40), + opsDigest: 'd'.repeat(64), + prevAuditCommit: GENESIS, + timestamp: 2, + }); + addEntry(log, GENESIS, first, { messageReceipt: first }); + addEntry(log, TIP, second, { parents: [GENESIS], messageReceipt: second }); + log.head = TIP; + return log; +} + +function receiptMissingTimestamp(): Readonly> { + const value: Record = { ...receipt(), extra: true }; + delete value['timestamp']; + return value; +} + +describe('AuditChainVerifier semantic audit-log boundary', () => { + it('verifies a complete two-receipt chain and a bounded partial chain', async () => { + const complete = await new AuditChainVerifier(validChain(), codec) + .verifyChain(GRAPH, WRITER); + const partial = await new AuditChainVerifier(validChain(), codec) + .verifyChain(GRAPH, WRITER, { since: TIP }); + + expect(complete).toMatchObject({ + status: 'VALID', + receiptsVerified: 2, + receiptsScanned: 2, + tipCommit: TIP, + genesisCommit: GENESIS, + errors: [], + }); + expect(partial).toMatchObject({ + status: 'PARTIAL', + receiptsVerified: 1, + stoppedAt: TIP, + since: TIP, + }); + }); + + it('treats an absent head as an empty valid chain', async () => { + const missing = new FixtureAuditLog(); + + await expect(new AuditChainVerifier(missing, codec).verifyChain(GRAPH, WRITER)) + .resolves.toMatchObject({ status: 'VALID', receiptsScanned: 0 }); + }); + + it.each([ + [new Error('head unavailable'), 'head unavailable'], + ['head unavailable without an Error', 'head unavailable without an Error'], + ])('reports an unreadable head as an audit verification error', async (failure, message) => { + const unavailable = new FixtureAuditLog(); + unavailable.headFailure = failure; + + await expect(new AuditChainVerifier(unavailable, codec).verifyChain(GRAPH, WRITER)) + .resolves.toMatchObject({ + status: 'ERROR', + receiptsScanned: 0, + errors: [{ code: 'AUDIT_HEAD_UNAVAILABLE', message: `Cannot read audit head: ${message}` }], + }); + }); + + it.each([ + ['not-object', null], + ['field-count', {}], + ['missing-field', receiptMissingTimestamp()], + ['version', receipt({ version: 2 })], + ['graph', receipt({ graphName: '' })], + ['writer', receipt({ writerId: '' })], + ['data-commit-type', { ...receipt(), dataCommit: 1 }], + ['tick-start', receipt({ tickStart: 0 })], + ['tick-end', receipt({ tickEnd: 0 })], + ['tick-width', receipt({ tickEnd: 2 })], + ['timestamp', receipt({ timestamp: -1 })], + ])('rejects malformed receipt schema: %s', async (_label, value) => { + const log = new FixtureAuditLog(); + addEntry(log, TIP, value); + log.head = TIP; + + const result = await new AuditChainVerifier(log, codec).verifyChain(GRAPH, WRITER); + + expect(result.errors[0]).toMatchObject({ code: 'RECEIPT_SCHEMA_INVALID' }); + }); + + it.each([ + ['data-format', receipt({ dataCommit: 'not-hex' })], + ['data-length', receipt({ dataCommit: 'a'.repeat(39) })], + ['previous-format', receipt({ prevAuditCommit: 'not-hex' })], + ['length', receipt({ dataCommit: 'a'.repeat(64) })], + ])('rejects invalid or inconsistent OIDs: %s', async (_label, value) => { + const log = new FixtureAuditLog(); + const messageReceipt = receipt({ + dataCommit: value.dataCommit.length === 64 ? 'a'.repeat(64) : DATA, + }); + addEntry(log, TIP, value, { messageReceipt }); + log.head = TIP; + + const result = await new AuditChainVerifier(log, codec).verifyChain(GRAPH, WRITER); + + expect(result.errors[0]?.code).toMatch(/OID_(?:FORMAT_INVALID|LENGTH_MISMATCH)/u); + }); + + it.each([ + ['graph', receipt(), receipt({ graphName: 'other' })], + ['writer', receipt(), receipt({ writerId: 'bob' })], + ['data', receipt(), receipt({ dataCommit: 'c'.repeat(40) })], + ['digest', receipt(), receipt({ opsDigest: 'd'.repeat(64) })], + ])('detects receipt/trailer substitution: %s', async (_label, value, messageReceipt) => { + const log = new FixtureAuditLog(); + addEntry(log, TIP, value, { messageReceipt }); + log.head = TIP; + + const result = await new AuditChainVerifier(log, codec).verifyChain(GRAPH, WRITER); + + expect(result).toMatchObject({ + status: 'DATA_MISMATCH', + errors: [expect.objectContaining({ code: 'TRAILER_MISMATCH' })], + }); + }); + + it('reports missing entries, undecodable receipts, and invalid trailers', async () => { + const missing = new FixtureAuditLog(); + missing.head = TIP; + + const decodeFailure = validChain(); + + const trailerFailure = validChain(); + const trailerTip = trailerFailure.entries.get(TIP); + if (trailerTip === undefined) { + throw new Error('valid fixture is missing its trailer tip'); + } + trailerFailure.entries.set(TIP, { ...trailerTip, message: 'not an audit message' }); + + const missingResult = await new AuditChainVerifier(missing, codec).verifyChain(GRAPH, WRITER); + const decodeResult = await new AuditChainVerifier(decodeFailure, new ThrowingCodec()) + .verifyChain(GRAPH, WRITER); + const trailerResult = await new AuditChainVerifier(trailerFailure, codec) + .verifyChain(GRAPH, WRITER); + + expect(missingResult.errors[0]?.code).toBe('MISSING_RECEIPT_BLOB'); + expect(decodeResult.errors[0]?.code).toBe('CBOR_DECODE_FAILED'); + expect(trailerResult).toMatchObject({ status: 'DATA_MISMATCH' }); + }); + + it('detects broken genesis and continuation topology', async () => { + const genesisParent = new FixtureAuditLog(); + const first = receipt(); + addEntry(genesisParent, GENESIS, first, { + parents: [TIP], + messageReceipt: first, + }); + genesisParent.head = GENESIS; + + const noParent = new FixtureAuditLog(); + const continuation = receipt({ prevAuditCommit: GENESIS }); + addEntry(noParent, TIP, continuation, { messageReceipt: continuation }); + noParent.head = TIP; + + const wrongParent = new FixtureAuditLog(); + addEntry(wrongParent, TIP, continuation, { + parents: ['f'.repeat(40)], + messageReceipt: continuation, + }); + wrongParent.head = TIP; + + const results = await Promise.all([ + new AuditChainVerifier(genesisParent, codec).verifyChain(GRAPH, WRITER), + new AuditChainVerifier(noParent, codec).verifyChain(GRAPH, WRITER), + new AuditChainVerifier(wrongParent, codec).verifyChain(GRAPH, WRITER), + ]); + + expect(results.map((result) => result.errors[0]?.code)).toEqual([ + 'GENESIS_HAS_PARENTS', + 'CONTINUATION_NO_PARENT', + 'GIT_PARENT_MISMATCH', + ]); + }); + + it('detects expected identity mismatches after trailer validation', async () => { + const wrongWriter = new FixtureAuditLog(); + const bob = receipt({ writerId: 'bob' }); + addEntry(wrongWriter, TIP, bob, { messageReceipt: bob }); + wrongWriter.head = TIP; + + const wrongGraph = new FixtureAuditLog(); + const other = receipt({ graphName: 'other' }); + addEntry(wrongGraph, TIP, other, { messageReceipt: other }); + wrongGraph.head = TIP; + + const writerResult = await new AuditChainVerifier(wrongWriter, codec) + .verifyChain(GRAPH, WRITER); + const graphResult = await new AuditChainVerifier(wrongGraph, codec) + .verifyChain(GRAPH, WRITER); + + expect(writerResult.errors[0]?.code).toBe('WRITER_CONSISTENCY'); + expect(graphResult.errors[0]?.code).toBe('WRITER_CONSISTENCY'); + }); + + it('detects OID width drift inside an otherwise connected chain', async () => { + const changedDataWidth = validChain(); + const wideGenesis = receipt({ + dataCommit: 'a'.repeat(64), + opsDigest: DIGEST, + prevAuditCommit: '0'.repeat(64), + }); + addEntry(changedDataWidth, GENESIS, wideGenesis, { messageReceipt: wideGenesis }); + + const changedPreviousWidth = validChain(); + const widePrevious = receipt({ prevAuditCommit: '0'.repeat(64) }); + addEntry(changedPreviousWidth, GENESIS, widePrevious, { messageReceipt: widePrevious }); + + const dataResult = await new AuditChainVerifier(changedDataWidth, codec) + .verifyChain(GRAPH, WRITER); + const previousResult = await new AuditChainVerifier(changedPreviousWidth, codec) + .verifyChain(GRAPH, WRITER); + + expect(dataResult.errors[0]?.code).toBe('OID_LENGTH_MISMATCH'); + expect(previousResult.errors[0]?.code).toBe('OID_LENGTH_MISMATCH'); + }); + + it('distinguishes tick regression, tick gaps, and graph drift across links', async () => { + const regression = validChain(); + const regressedGenesis = receipt({ tickStart: 2, tickEnd: 2 }); + addEntry(regression, GENESIS, regressedGenesis, { messageReceipt: regressedGenesis }); + + const gap = validChain(); + const gapTip = receipt({ + tickStart: 4, + tickEnd: 4, + dataCommit: 'c'.repeat(40), + opsDigest: 'd'.repeat(64), + prevAuditCommit: GENESIS, + timestamp: 4, + }); + addEntry(gap, TIP, gapTip, { parents: [GENESIS], messageReceipt: gapTip }); + + const graphDrift = validChain(); + const other = receipt({ graphName: 'other' }); + addEntry(graphDrift, GENESIS, other, { messageReceipt: other }); + + const regressionResult = await new AuditChainVerifier(regression, codec) + .verifyChain(GRAPH, WRITER); + const gapResult = await new AuditChainVerifier(gap, codec).verifyChain(GRAPH, WRITER); + const graphResult = await new AuditChainVerifier(graphDrift, codec) + .verifyChain(GRAPH, WRITER); + + expect(regressionResult.errors[0]?.code).toBe('TICK_MONOTONICITY'); + expect(gapResult.warnings).toContainEqual(expect.objectContaining({ code: 'TICK_GAP' })); + expect(graphResult.errors[0]?.code).toBe('WRITER_CONSISTENCY'); + }); + + it('reports missing since coordinates, tick defects, identity drift, and tip movement', async () => { + const since = await new AuditChainVerifier(validChain(), codec) + .verifyChain(GRAPH, WRITER, { since: 'f'.repeat(40) }); + + const gapLog = validChain(); + const genesisEntry = gapLog.entries.get(GENESIS); + const genesisReceipt = receipt({ writerId: 'bob' }); + if (genesisEntry === undefined) { + throw new Error('valid fixture is missing genesis'); + } + gapLog.entries.set(GENESIS, { + ...genesisEntry, + message: auditMessage(genesisReceipt), + receipt: codec.encode(genesisReceipt), + }); + + const moved = validChain(); + moved.movedHead = 'e'.repeat(40); + + const identity = await new AuditChainVerifier(gapLog, codec).verifyChain(GRAPH, WRITER); + const movedResult = await new AuditChainVerifier(moved, codec).verifyChain(GRAPH, WRITER); + + expect(since.errors[0]?.code).toBe('SINCE_NOT_FOUND'); + expect(identity.errors[0]?.code).toBe('WRITER_CONSISTENCY'); + expect(movedResult.warnings).toContainEqual(expect.objectContaining({ + code: 'TIP_MOVED_DURING_VERIFY', + })); + }); +}); diff --git a/test/unit/domain/services/controllers/CheckpointController.snapshotCache.test.ts b/test/unit/domain/services/controllers/CheckpointController.snapshotCache.test.ts index 1948dcea..6852ab10 100644 --- a/test/unit/domain/services/controllers/CheckpointController.snapshotCache.test.ts +++ b/test/unit/domain/services/controllers/CheckpointController.snapshotCache.test.ts @@ -5,6 +5,8 @@ import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts' import defaultCrypto from '../../../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import GCPolicy from '../../../../../src/domain/services/GCPolicy.ts'; +import type Patch from '../../../../../src/domain/types/Patch.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; const { createCheckpointCommitMock } = vi.hoisted(() => ({ createCheckpointCommitMock: vi.fn(), @@ -61,7 +63,7 @@ type HostFixture = { _crypto: typeof defaultCrypto; _codec: typeof defaultCodec; _commitMessageCodec: typeof DEFAULT_COMMIT_MESSAGE_CODEC; - _checkpointStore: null; + _checkpointStore: InMemoryCheckpointStore; _stateHashService: null; _logger: { warn: (_message: string, _context?: object) => void; @@ -77,7 +79,7 @@ type HostFixture = { _lastFrontier: null; _cachedViewHash: null; _stateCache: SnapshotCacheFixture; - _readPatchBlob: (_patchMeta: ReturnType) => Promise; + _readPatch: (_patchMeta: ReturnType) => Promise; discoverWriters: () => Promise; }; @@ -124,7 +126,7 @@ function createHost(snapshotCache: SnapshotCacheFixture): HostFixture { _crypto: defaultCrypto, _codec: defaultCodec, _commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - _checkpointStore: null, + _checkpointStore: new InMemoryCheckpointStore(), _stateHashService: null, _logger: logger, _gcPolicy: new GCPolicy({ ...GCPolicy.DEFAULT }), @@ -134,7 +136,9 @@ function createHost(snapshotCache: SnapshotCacheFixture): HostFixture { _lastFrontier: null, _cachedViewHash: null, _stateCache: snapshotCache, - _readPatchBlob: vi.fn().mockResolvedValue(new Uint8Array()), + _readPatch: vi.fn(async () => { + throw new Error('unused patch read'); + }), discoverWriters: vi.fn().mockResolvedValue(['alice']), }; } diff --git a/test/unit/domain/services/controllers/CheckpointController.test.ts b/test/unit/domain/services/controllers/CheckpointController.test.ts index d7ad1815..f30d5798 100644 --- a/test/unit/domain/services/controllers/CheckpointController.test.ts +++ b/test/unit/domain/services/controllers/CheckpointController.test.ts @@ -43,7 +43,6 @@ const { isCurrentCheckpointSchemaMock, decodePatchMessageMock, detectMessageKindMock, - encodeAnchorMessageMock, executeGCMock, collectGCMetricsMock, computeAppliedVVMock, @@ -57,7 +56,6 @@ const { isCurrentCheckpointSchemaMock: vi.fn(), decodePatchMessageMock: vi.fn(), detectMessageKindMock: vi.fn(), - encodeAnchorMessageMock: vi.fn(), executeGCMock: vi.fn(), collectGCMetricsMock: vi.fn(), computeAppliedVVMock: vi.fn(), @@ -76,13 +74,13 @@ vi.mock('../../../../../src/domain/services/state/checkpointCreate.ts', () => ({ })); vi.mock('../../../../../src/domain/services/state/checkpointHelpers.ts', () => ({ + CURRENT_CHECKPOINT_SCHEMA: 5, isCurrentCheckpointSchema: isCurrentCheckpointSchemaMock, })); vi.mock('../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts', () => ({ decodePatchMessage: decodePatchMessageMock, detectMessageKind: detectMessageKindMock, - encodeAnchorMessage: encodeAnchorMessageMock, })); vi.mock('../../../../../src/domain/services/executeGC.ts', () => ({ @@ -132,19 +130,23 @@ function createMockHost(overrides = {}) { _graphName: 'test-graph', _persistence: { readRef: vi.fn().mockResolvedValue(null), - updateRef: vi.fn().mockResolvedValue(undefined), - commitNode: vi.fn().mockResolvedValue('anchor-sha'), getNodeInfo: vi.fn().mockResolvedValue({ message: 'msg', parents: [] }), - showNode: vi.fn().mockResolvedValue('msg'), }, _cachedState: null, _stateDirty: false, _checkpointing: false, _viewService: null, - _checkpointStore: null, + _checkpointStore: { + publishCheckpoint: vi.fn(), + resolveHead: vi.fn().mockResolvedValue(null), + loadCheckpoint: vi.fn(), + readMetadata: vi.fn(), + loadBasis: vi.fn(), + publishCoverage: vi.fn().mockResolvedValue('coverage-sha'), + }, _stateHashService: null, _provenanceIndex: null, - _codec: { decode: vi.fn() }, + _codec: {}, _commitMessageCodec: { detectKind: detectMessageKindMock, decodeCheckpoint: vi.fn(), @@ -162,7 +164,7 @@ function createMockHost(overrides = {}) { ), }; }), - encodeAnchor: encodeAnchorMessageMock, + encodeAnchor: vi.fn(), }, _crypto: {}, _logger: null, @@ -180,6 +182,7 @@ function createMockHost(overrides = {}) { materialize: vi.fn().mockResolvedValue(stubState()), _loadWriterPatches: vi.fn().mockResolvedValue([]), _validatePatchAgainstCheckpoint: vi.fn().mockResolvedValue(undefined), + _readPatch: vi.fn(), _autoMaterialize: false, ...overrides, }; @@ -230,13 +233,14 @@ describe('CheckpointController', () => { const result = await ctrl.createCheckpoint(); expect(result).toBe('cp-sha'); - expect((host['_persistence'] as any).updateRef).toHaveBeenCalledWith( - expect.stringContaining('checkpoints'), - 'cp-sha', - ); expect(updateFrontierMock).toHaveBeenCalledTimes(2); expect(createCheckpointCommitMock).toHaveBeenCalledWith( - expect.objectContaining({ state }), + expect.objectContaining({ + checkpointStore: host['_checkpointStore'], + graphName: 'test-graph', + parents: ['sha-alice', 'sha-bob'], + state, + }), ); }); @@ -309,17 +313,13 @@ describe('CheckpointController', () => { it('creates an octopus anchor from writer tips', async () => { host['discoverWriters'] = vi.fn().mockResolvedValue(['alice']); ((host['_persistence'] as any).readRef as any).mockResolvedValue('sha-alice'); - encodeAnchorMessageMock.mockReturnValue('anchor-msg'); await ctrl.syncCoverage(); - expect((host['_persistence'] as any).commitNode).toHaveBeenCalledWith( - expect.objectContaining({ message: 'anchor-msg', parents: ['sha-alice'] }), - ); - expect((host['_persistence'] as any).updateRef).toHaveBeenCalledWith( - expect.stringContaining('coverage'), - 'anchor-sha', - ); + expect((host['_checkpointStore'] as any).publishCoverage).toHaveBeenCalledWith({ + graphName: 'test-graph', + parents: ['sha-alice'], + }); }); it('returns early when no writers exist', async () => { @@ -327,7 +327,7 @@ describe('CheckpointController', () => { await ctrl.syncCoverage(); - expect((host['_persistence'] as any).commitNode).not.toHaveBeenCalled(); + expect((host['_checkpointStore'] as any).publishCoverage).not.toHaveBeenCalled(); }); it('returns early when no writer SHAs are found', async () => { @@ -336,7 +336,7 @@ describe('CheckpointController', () => { await ctrl.syncCoverage(); - expect((host['_persistence'] as any).commitNode).not.toHaveBeenCalled(); + expect((host['_checkpointStore'] as any).publishCoverage).not.toHaveBeenCalled(); }); }); @@ -346,17 +346,22 @@ describe('CheckpointController', () => { describe('_loadLatestCheckpoint', () => { it('returns checkpoint when ref exists', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue('cp-sha'); - const cpData = { state: stubState(), frontier: new Map(), stateHash: 'abc', schema: 5, appliedVV: null, indexShardOids: null }; + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue('cp-sha'); + const cpData = { state: stubState(), frontier: new Map(), stateHash: 'abc', schema: 5, appliedVV: null, indexShardHandles: null }; loadCheckpointMock.mockResolvedValue(cpData); const result = await ctrl._loadLatestCheckpoint(); expect(result).toBe(cpData); + expect(loadCheckpointMock).toHaveBeenCalledWith( + host['_checkpointStore'], + 'cp-sha', + host['_graphName'], + ); }); it('returns null when ref is empty', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue(''); + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue(''); const result = await ctrl._loadLatestCheckpoint(); @@ -364,25 +369,24 @@ describe('CheckpointController', () => { }); it('returns null when ref is null', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue(null); + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue(null); const result = await ctrl._loadLatestCheckpoint(); expect(result).toBeNull(); }); - it('returns null for known load errors (missing, not found, ENOENT)', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue('cp-sha'); + it('propagates every load failure after resolving a checkpoint head', async () => { + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue('cp-sha'); for (const msg of ['object missing', 'ref not found', 'ENOENT: no such file', 'non-empty string']) { loadCheckpointMock.mockRejectedValueOnce(new Error(msg)); - const result = await ctrl._loadLatestCheckpoint(); - expect(result).toBeNull(); + await expect(ctrl._loadLatestCheckpoint()).rejects.toThrow(msg); } }); it('rethrows unknown errors', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue('cp-sha'); + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue('cp-sha'); loadCheckpointMock.mockRejectedValue(new Error('disk on fire')); await expect(ctrl._loadLatestCheckpoint()).rejects.toThrow('disk on fire'); @@ -402,7 +406,7 @@ describe('CheckpointController', () => { .mockResolvedValueOnce([patchA]) .mockResolvedValueOnce([patchB]); - const checkpoint = ({ state: stubState(), frontier: new Map([['alice', 'old-sha']]), stateHash: 'h', schema: 5, appliedVV: null, indexShardOids: null } as any); + const checkpoint = ({ state: stubState(), frontier: new Map([['alice', 'old-sha']]), stateHash: 'h', schema: 5, appliedVV: null, indexShardHandles: null } as any); const result = await ctrl._loadPatchesSince(checkpoint); expect(result).toEqual([patchA, patchB]); @@ -415,7 +419,7 @@ describe('CheckpointController', () => { const patch = { patch: { ops: [] }, sha: 'tip-sha' }; (host['_loadWriterPatches'] as any).mockResolvedValue([patch]); - const checkpoint = ({ state: stubState(), frontier: new Map(), stateHash: 'h', schema: 5, appliedVV: null, indexShardOids: null } as any); + const checkpoint = ({ state: stubState(), frontier: new Map(), stateHash: 'h', schema: 5, appliedVV: null, indexShardHandles: null } as any); await ctrl._loadPatchesSince(checkpoint); expect(host['_validatePatchAgainstCheckpoint']).toHaveBeenCalledWith('alice', 'tip-sha', checkpoint); @@ -425,7 +429,7 @@ describe('CheckpointController', () => { host['discoverWriters'] = vi.fn().mockResolvedValue(['alice']); (host['_loadWriterPatches'] as any).mockResolvedValue([]); - const checkpoint = ({ state: stubState(), frontier: new Map(), stateHash: 'h', schema: 5, appliedVV: null, indexShardOids: null } as any); + const checkpoint = ({ state: stubState(), frontier: new Map(), stateHash: 'h', schema: 5, appliedVV: null, indexShardHandles: null } as any); await ctrl._loadPatchesSince(checkpoint); expect(host['_validatePatchAgainstCheckpoint']).not.toHaveBeenCalled(); @@ -438,66 +442,56 @@ describe('CheckpointController', () => { describe('_validateMigrationBoundary', () => { it('passes when checkpoint has v5 schema', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue('cp-sha'); - detectMessageKindMock.mockReturnValue('checkpoint'); - ((host['_commitMessageCodec'] as any).decodeCheckpoint as any).mockReturnValue({ schema: 5 }); + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue('cp-sha'); + ((host['_checkpointStore'] as any).readMetadata as any).mockResolvedValue({ + checkpointSha: 'cp-sha', + stateHash: 'state-hash', + schema: 5, + }); isCurrentCheckpointSchemaMock.mockReturnValue(true); await expect(ctrl._validateMigrationBoundary()).resolves.toBeUndefined(); + expect(host['_checkpointStore'].readMetadata).toHaveBeenCalledWith( + 'cp-sha', + host['_graphName'], + ); }); it('throws SchemaUnsupportedError when schema:1 patches exist', async () => { - ((host['_persistence'] as any).readRef as any) - .mockResolvedValueOnce('') // checkpoint ref empty - .mockResolvedValueOnce('tip-sha'); // writer ref + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue(null); + ((host['_persistence'] as any).readRef as any).mockResolvedValue('tip-sha'); isCurrentCheckpointSchemaMock.mockReturnValue(false); host['discoverWriters'] = vi.fn().mockResolvedValue(['alice']); ((host['_persistence'] as any).getNodeInfo as any).mockResolvedValue({ message: 'patch-msg', parents: [] }); detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ blobSha: 'blob-sha' }); - - // Wire _readPatchBlob and _codec.decode on the host for _hasSchema1Patches - host['_readPatchBlob'] = vi.fn().mockResolvedValue(new Uint8Array(0)); - ((host['_codec'] as any).decode as any).mockReturnValue({ schema: 1 }); + decodePatchMessageMock.mockReturnValue({ schema: 1 }); await expect(ctrl._validateMigrationBoundary()).rejects.toThrow(SchemaUnsupportedError); + expect(host['_readPatch']).not.toHaveBeenCalled(); }); it('passes when no checkpoint and no schema:1 patches', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue(''); + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue(null); isCurrentCheckpointSchemaMock.mockReturnValue(false); host['discoverWriters'] = vi.fn().mockResolvedValue([]); await expect(ctrl._validateMigrationBoundary()).resolves.toBeUndefined(); }); - it('rejects a checkpoint ref whose commit message is empty', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue('cp-sha'); - ((host['_persistence'] as any).showNode as any).mockResolvedValue(''); - const validation = ctrl._validateMigrationBoundary(); - - await expect(validation).rejects.toMatchObject({ - code: 'E_CHECKPOINT_REF_INVALID', - context: { - checkpointSha: 'cp-sha', - reason: 'empty-checkpoint-message', - }, + it('rejects checkpoint metadata from a retired schema', async () => { + ((host['_checkpointStore'] as any).resolveHead as any).mockResolvedValue('cp-sha'); + ((host['_checkpointStore'] as any).readMetadata as any).mockResolvedValue({ + checkpointSha: 'cp-sha', + stateHash: 'state-hash', + schema: 4, }); - await expect(validation).rejects.toBeInstanceOf(PersistenceError); - }); - - it('rejects a checkpoint ref whose commit is not a checkpoint', async () => { - ((host['_persistence'] as any).readRef as any).mockResolvedValue('cp-sha'); - ((host['_persistence'] as any).showNode as any).mockResolvedValue('patch-message'); - detectMessageKindMock.mockReturnValue('patch'); + isCurrentCheckpointSchemaMock.mockReturnValue(false); await expect(ctrl._validateMigrationBoundary()).rejects.toMatchObject({ - code: 'E_CHECKPOINT_REF_INVALID', - context: { - checkpointSha: 'cp-sha', - reason: 'non-checkpoint-message', - }, + code: 'E_CHECKPOINT_UNSUPPORTED_SCHEMA', + context: { checkpointSha: 'cp-sha', schema: 4 }, }); + await expect(ctrl._validateMigrationBoundary()).rejects.toBeInstanceOf(PersistenceError); }); }); diff --git a/test/unit/domain/services/controllers/ComparisonController.test.ts b/test/unit/domain/services/controllers/ComparisonController.test.ts index f1a998e7..f3672065 100644 --- a/test/unit/domain/services/controllers/ComparisonController.test.ts +++ b/test/unit/domain/services/controllers/ComparisonController.test.ts @@ -26,6 +26,8 @@ import { encodeEdgeKey, encodePropKey } from '../../../../../src/domain/services import { LWWRegister } from '../../../../../src/domain/crdt/LWW.ts'; import { EventId } from '../../../../../src/domain/utils/EventId.ts'; import Patch from '../../../../../src/domain/types/Patch.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; +import WarpStream from '../../../../../src/domain/stream/WarpStream.ts'; // ── Hoisted mocks ────────────────────────────────────────────────────────── @@ -205,9 +207,8 @@ function createMockHost(overrides = {}) { _crypto: { hash: vi.fn(async () => 'mock-hash') }, _codec: {}, _stateHashService: null, - _blobStorage: null, - _persistence: { - readBlob: vi.fn(async () => new Uint8Array([1, 2, 3])), + _assetStorage: { + open: vi.fn(() => WarpStream.from([new Uint8Array([1, 2, 3])])), }, getFrontier: vi.fn(async () => new Map([['alice', 'sha-alice-1']])), materializeCoordinate: vi.fn(async (_request?: { frontier: Map; ceiling?: number }) => emptyState), @@ -766,14 +767,20 @@ describe('ComparisonController', () => { expect(result['scope']).toEqual(scope); }); - it('loads content blobs via blobStorage when available', async () => { - const blobStorageRetrieve = vi.fn(async () => new Uint8Array([10, 20])); - host['_blobStorage'] = { retrieve: blobStorageRetrieve }; + it('loads content through the semantic asset storage port', async () => { + const open = vi.fn(() => WarpStream.from([ + new Uint8Array([10]), + new Uint8Array([20]), + ])); + host['_assetStorage'] = { open }; planVisibleStateTransferMock.mockImplementationOnce(((async (_src, _tgt, loaders) => { - // Simulate the planner calling loadNodeContent if (loaders.loadNodeContent) { - await loaders.loadNodeContent('n1', { oid: 'blob-oid' }); + await loaders.loadNodeContent('n1', { + handle: 'asset-handle', + mime: null, + size: 2, + }); } return { summary: { opCount: 0 }, ops: [] }; }) as any)); @@ -783,25 +790,26 @@ describe('ComparisonController', () => { target: { kind: 'live' }, }); - expect(blobStorageRetrieve).toHaveBeenCalledWith('blob-oid'); + expect(open).toHaveBeenCalledWith(new AssetHandle('asset-handle')); }); - it('falls back to persistence.readBlob when blobStorage is null', async () => { - host['_blobStorage'] = null; + it('fails closed when semantic asset storage is unavailable', async () => { + host['_assetStorage'] = null; planVisibleStateTransferMock.mockImplementationOnce(((async (_src, _tgt, loaders) => { if (loaders.loadEdgeContent) { - await loaders.loadEdgeContent('e1', { oid: 'blob-oid-2' }); + await loaders.loadEdgeContent( + { from: 'a', to: 'b', label: 'rel' }, + { handle: 'asset-handle-2', mime: null, size: 1 }, + ); } return { summary: { opCount: 0 }, ops: [] }; }) as any)); - await controller.planCoordinateTransfer({ + await expect(controller.planCoordinateTransfer({ source: { kind: 'live' }, target: { kind: 'live' }, - }); - - expect((((host['_persistence']).readBlob) as ReturnType)).toHaveBeenCalledWith('blob-oid-2'); + })).rejects.toMatchObject({ code: 'invalid_coordinate' }); }); }); diff --git a/test/unit/domain/services/controllers/ForkController.test.ts b/test/unit/domain/services/controllers/ForkController.test.ts index d2693374..3d809f01 100644 --- a/test/unit/domain/services/controllers/ForkController.test.ts +++ b/test/unit/domain/services/controllers/ForkController.test.ts @@ -53,6 +53,18 @@ function createMockHost(overrides = {}) { _logger: null, _crypto: null, _codec: null, + _runtimeStorage: { createRuntimeStorageServices: vi.fn() }, + _assetStorage: null, + _patchJournal: { + appendPatch: vi.fn(), + readPatch: vi.fn(), + scanPatchRange: vi.fn(), + }, + _commitMessageCodec: { + detectKind: vi.fn(), + decodePatch: vi.fn(), + encodePatch: vi.fn(), + }, _checkpointPolicy: null, ...overrides, }; @@ -449,7 +461,8 @@ describe('ForkController', () => { graphName: 'test-graph', fromSha: 'sha-a', toSha: 'sha-b', - codec: host._codec, + commitMessageCodec: host._commitMessageCodec, + patchJournal: host._patchJournal, }); expect(result).toEqual(wormholeResult); expect(result).toBeDefined(); diff --git a/test/unit/domain/services/controllers/IntentController.test.ts b/test/unit/domain/services/controllers/IntentController.test.ts index 24f63e2a..a525f0fc 100644 --- a/test/unit/domain/services/controllers/IntentController.test.ts +++ b/test/unit/domain/services/controllers/IntentController.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it, vi } from 'vitest'; import IntentController, { type IntentHost, } from '../../../../../src/domain/services/controllers/IntentController.ts'; +import WarpStream from '../../../../../src/domain/stream/WarpStream.ts'; import type { WarpIntentDescriptor } from '../../../../../src/domain/types/WarpIntentDescriptor.ts'; +import type IntentStorePort from '../../../../../src/ports/IntentStorePort.ts'; +import { testRetentionWitness } from '../../../../helpers/storageRetention.ts'; const descriptor: WarpIntentDescriptor = { intentId: 'assign-alice', @@ -29,30 +32,72 @@ function withGuards( function createController( getNodeProps: ReturnType, ): IntentController { + const intentStore = createIntentStore(); return new IntentController({ _graphName: 'events', _writerId: 'agent-1', + _intentStore: intentStore, worldline: () => ({ getNodeProps }), } as unknown as IntentHost); } +function createIntentStore(): IntentStorePort & { + publish: ReturnType; +} { + const queued = new Map(); + let admissionSequence = 0; + const publish = vi.fn(async (request: { + channel: 'admitted' | 'queued'; + ownerId: string; + descriptor: WarpIntentDescriptor; + }) => { + if (request.channel === 'queued') { + const descriptors = queued.get(request.ownerId) ?? []; + descriptors.push(request.descriptor); + queued.set(request.ownerId, descriptors); + } else { + admissionSequence += 1; + } + const sha = request.channel === 'queued' + ? `queued:${request.ownerId}:${request.descriptor.intentId}` + : `intent:${request.descriptor.intentId}:${request.ownerId}:${admissionSequence}`; + return { + sha, + retention: testRetentionWitness(sha), + }; + }); + return { + publish, + scan: vi.fn((_graphName: string, channel: 'admitted' | 'queued', ownerId: string) => ( + WarpStream.from(channel === 'queued' ? queued.get(ownerId) ?? [] : []) + )), + } as unknown as IntentStorePort & { publish: ReturnType }; +} + describe('IntentController', () => { - it('admits a descriptor without writing an unattached persistence blob', async () => { - const writeBlob = vi.fn(async () => 'a'.repeat(40)); + it('publishes an admitted descriptor through the semantic intent store', async () => { + const intentStore = createIntentStore(); const host = { _graphName: 'events', _writerId: 'agent-1', - _persistence: { writeBlob }, + _intentStore: intentStore, worldline: () => ({ getNodeProps: vi.fn() }), } as unknown as IntentHost; const controller = new IntentController(host); + const sha = 'intent:assign-alice:agent-1:1'; await expect(controller.admitIntent(descriptor)).resolves.toEqual({ admitted: true, intentId: 'assign-alice', - sha: 'intent:assign-alice:agent-1:1', + sha, + retention: testRetentionWitness(sha), + }); + expect(intentStore.publish).toHaveBeenCalledWith({ + graphName: 'events', + channel: 'admitted', + ownerId: 'agent-1', + descriptor, }); - expect(writeBlob).not.toHaveBeenCalled(); }); it('enforces node status guards without persisting the descriptor', async () => { @@ -82,6 +127,7 @@ describe('IntentController', () => { admitted: true, intentId: 'assign-alice', sha: 'intent:assign-alice:agent-1:1', + retention: testRetentionWitness('intent:assign-alice:agent-1:1'), }); await expect(controller.admitIntent(guarded)).resolves.toMatchObject({ admitted: false, @@ -110,6 +156,7 @@ describe('IntentController', () => { admitted: true, intentId: 'assign-alice', sha: `intent:assign-alice:agent-1:${counter}`, + retention: testRetentionWitness(`intent:assign-alice:agent-1:${counter}`), }); } await expect(controller.admitIntent(guarded)).resolves.toEqual({ @@ -125,13 +172,16 @@ describe('IntentController', () => { it('queues descriptors by strand without writing storage', async () => { const controller = createController(vi.fn()); + const strandId = 'draft-admin'; + const sha = `queued:${strandId}:assign-alice`; - await expect(controller.queueIntent('draft:admin', descriptor)).resolves.toEqual({ + await expect(controller.queueIntent(strandId, descriptor)).resolves.toEqual({ admitted: true, intentId: 'assign-alice', - sha: 'queued:draft:admin:assign-alice', + sha, + retention: testRetentionWitness(sha), }); - await expect(controller.getWriterIntents('draft:admin')).resolves.toEqual([descriptor]); + await expect(controller.getWriterIntents(strandId)).resolves.toEqual([descriptor]); await expect(controller.getWriterIntents('missing')).resolves.toEqual([]); }); }); diff --git a/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts b/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts index c9406db2..1e91c836 100644 --- a/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts +++ b/test/unit/domain/services/controllers/MaterializeController.snapshotCache.test.ts @@ -4,6 +4,7 @@ import { createEmptyState } from '../../../../../src/domain/services/JoinReducer import Patch from '../../../../../src/domain/types/Patch.ts'; import type { CheckpointData, PatchWithSha } from '../../../../../src/domain/capabilities/PatchCollector.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; type Coordinate = { frontier: Map; @@ -120,6 +121,7 @@ function createControllerFixtures() { showNode: vi.fn().mockResolvedValue(''), readBlob: vi.fn().mockResolvedValue(new Uint8Array([1])), }, + checkpointStore: new InMemoryCheckpointStore(), commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, getStateCache: () => stateCache, patches, diff --git a/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts b/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts index ba7fe2aa..9e380519 100644 --- a/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts +++ b/test/unit/domain/services/controllers/MaterializeController.stateSession.test.ts @@ -14,6 +14,7 @@ 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"; +import InMemoryCheckpointStore from "../../../../helpers/InMemoryCheckpointStore.ts"; const GEOMETRY = TrieGeometry.default16way(); @@ -162,6 +163,7 @@ function createControllerFixtures() { showNode: vi.fn().mockResolvedValue(""), readBlob: vi.fn().mockResolvedValue(new Uint8Array([1])), }, + checkpointStore: new InMemoryCheckpointStore(), commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, getStateCache: () => stateCache, patches, diff --git a/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts b/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts index ea72f061..73bf398f 100644 --- a/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts +++ b/test/unit/domain/services/controllers/MaterializePatchStreamReducer.test.ts @@ -18,7 +18,7 @@ import MaterializePatchStreamReducer from '../../../../../src/domain/services/co 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'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; describe('MaterializePatchStreamReducer', () => { it('reduces each patch before requesting the next stream item', async () => { @@ -160,7 +160,7 @@ function materializeDeps(patches: PatchCollector): MaterializeDeps { codec: new TestCodec(), crypto: new TestCrypto(), persistence: new TestPersistence(), - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + checkpointStore: new InMemoryCheckpointStore(), patches, graphCloner: new UnusedDetachedGraphFactory(), graphName: 'stream-memory-witness', diff --git a/test/unit/domain/services/controllers/PatchController.test.ts b/test/unit/domain/services/controllers/PatchController.test.ts index b24688de..c9380fac 100644 --- a/test/unit/domain/services/controllers/PatchController.test.ts +++ b/test/unit/domain/services/controllers/PatchController.test.ts @@ -12,8 +12,8 @@ import VersionVector from '../../../../../src/domain/crdt/VersionVector.ts'; import WarpState from '../../../../../src/domain/services/state/WarpState.ts'; import { Dot } from '../../../../../src/domain/crdt/Dot.ts'; import QueryError from '../../../../../src/domain/errors/QueryError.ts'; -import EncryptionError from '../../../../../src/domain/errors/EncryptionError.ts'; -import PersistenceError from '../../../../../src/domain/errors/PersistenceError.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; +import { createGitCasPatchStorage } from '../../../../../src/ports/CommitMessageCodecPort.ts'; // ── Mocks ─────────────────────────────────────────────────────────────────── @@ -84,7 +84,7 @@ function createMockHost(overrides = {}) { _clock: { now: vi.fn(() => 1000) }, _maxObservedLamport: 0, _versionVector: VersionVector.empty(), - _blobStorage: null, + _assetStorage: null, _effectSink: null, _logger: null, _commitMessageCodec: { @@ -112,7 +112,6 @@ function createMockHost(overrides = {}) { _patchesSinceCheckpoint: 0, _onDeleteWithData: 'reject', _patchJournal: null, - _patchBlobStorage: null, _patchInProgress: false, _provenanceIndex: null, _lastFrontier: null, @@ -141,10 +140,6 @@ function createMockPersistence() { updateRef: vi.fn(), showNode: vi.fn(), getNodeInfo: vi.fn(), - writeBlob: vi.fn(), - writeTree: vi.fn(), - commitNodeWithTree: vi.fn(), - readBlob: vi.fn(), listRefs: vi.fn().mockResolvedValue([]), configGet: vi.fn(), configSet: vi.fn(), @@ -203,7 +198,7 @@ describe('PatchController', () => { persistence.showNode.mockResolvedValue('patch-message-data'); detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ lamport: 5, patchOid: 'oid1' }); + decodePatchMessageMock.mockReturnValue({ lamport: 5, patchHandle: new AssetHandle('asset:1') }); patchBuilderMock.mockImplementation(function () { return {}; @@ -224,7 +219,7 @@ describe('PatchController', () => { persistence.showNode.mockResolvedValue('msg'); detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ lamport: 3, patchOid: 'oid1' }); + decodePatchMessageMock.mockReturnValue({ lamport: 3, patchHandle: new AssetHandle('asset:1') }); patchBuilderMock.mockImplementation(function () { return {}; @@ -250,7 +245,7 @@ describe('PatchController', () => { persistence.showNode.mockResolvedValue('msg'); detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ lamport: 1, patchOid: 'oid1' }); + decodePatchMessageMock.mockReturnValue({ lamport: 1, patchHandle: new AssetHandle('asset:1') }); patchBuilderMock.mockImplementation(function () { return {}; @@ -272,7 +267,7 @@ describe('PatchController', () => { persistence.showNode.mockResolvedValue('msg'); detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ lamport: 1, patchOid: 'oid1' }); + decodePatchMessageMock.mockReturnValue({ lamport: 1, patchHandle: new AssetHandle('asset:1') }); patchBuilderMock.mockImplementation(function () { return {}; @@ -310,7 +305,7 @@ describe('PatchController', () => { persistence.showNode.mockResolvedValue('msg'); detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ lamport: 1, patchOid: 'oid1' }); + decodePatchMessageMock.mockReturnValue({ lamport: 1, patchHandle: new AssetHandle('asset:1') }); patchBuilderMock.mockImplementation(function () { return {}; @@ -338,10 +333,10 @@ describe('PatchController', () => { it('passes optional deps to PatchBuilder when available', async () => { const journal = { readPatch: vi.fn(), writePatch: vi.fn() }; const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - const blobStorage = { store: vi.fn(), retrieve: vi.fn() }; + const assetStorage = { stage: vi.fn(), open: vi.fn() }; host['_patchJournal'] = journal; host['_logger'] = logger; - host['_blobStorage'] = blobStorage; + host['_assetStorage'] = assetStorage; const persistence = (host['_persistence'] as ReturnType); persistence.readRef.mockResolvedValue(null); @@ -355,13 +350,13 @@ describe('PatchController', () => { const args = patchBuilderMock.mock.calls[0]?.[0]; expect(args.patchJournal).toBe(journal); expect(args.logger).toBe(logger); - expect(args.blobStorage).toBe(blobStorage); + expect(args.assetStorage).toBe(assetStorage); }); it('omits optional deps from PatchBuilder when null', async () => { host['_patchJournal'] = null; host['_logger'] = null; - host['_blobStorage'] = null; + host['_assetStorage'] = null; const persistence = (host['_persistence'] as ReturnType); persistence.readRef.mockResolvedValue(null); @@ -375,7 +370,7 @@ describe('PatchController', () => { const args = patchBuilderMock.mock.calls[0]?.[0]; expect(args).not.toHaveProperty('patchJournal'); expect(args).not.toHaveProperty('logger'); - expect(args).not.toHaveProperty('blobStorage'); + expect(args).not.toHaveProperty('assetStorage'); }); }); @@ -388,9 +383,9 @@ describe('PatchController', () => { const persistence = (host['_persistence'] as ReturnType); persistence.readRef.mockResolvedValue(null); - const commitMock = vi.fn().mockResolvedValue('sha-abc'); + const commitMock = vi.fn().mockResolvedValue({ sha: 'sha-abc' }); patchBuilderMock.mockImplementation(function () { - return { commit: commitMock }; + return { commitWithEvidence: commitMock }; }); const buildFn = vi.fn(); @@ -412,7 +407,7 @@ describe('PatchController', () => { persistence.readRef.mockResolvedValue(null); patchBuilderMock.mockImplementation(function () { - return { commit: vi.fn() }; + return { commitWithEvidence: vi.fn() }; }); await expect(ctrl.patch(() => { @@ -427,7 +422,7 @@ describe('PatchController', () => { persistence.readRef.mockResolvedValue(null); patchBuilderMock.mockImplementation(function () { - return { commit: vi.fn().mockRejectedValue(new Error('commit failed')) }; + return { commitWithEvidence: vi.fn().mockRejectedValue(new Error('commit failed')) }; }); await expect(ctrl.patch(() => {})).rejects.toThrow('commit failed'); @@ -452,7 +447,7 @@ describe('PatchController', () => { let callCount = 0; patchBuilderMock.mockImplementation(function () { - return { commit: vi.fn().mockResolvedValue(`sha-${++callCount}`) }; + return { commitWithEvidence: vi.fn().mockResolvedValue({ sha: `sha-${++callCount}` }) }; }); const shas = await ctrl.patchMany( @@ -713,8 +708,8 @@ describe('PatchController', () => { detectMessageKindMock.mockReturnValue('patch'); decodePatchMessageMock - .mockReturnValueOnce({ lamport: 2, patchOid: 'oid2', encrypted: false }) - .mockReturnValueOnce({ lamport: 1, patchOid: 'oid1', encrypted: false }); + .mockReturnValueOnce({ lamport: 2, patchHandle: new AssetHandle('asset:2') }) + .mockReturnValueOnce({ lamport: 1, patchHandle: new AssetHandle('asset:1') }); const journal = { readPatch: vi.fn() }; journal.readPatch @@ -740,7 +735,7 @@ describe('PatchController', () => { detectMessageKindMock.mockReturnValue('patch'); decodePatchMessageMock - .mockReturnValueOnce({ lamport: 3, patchOid: 'oid3', encrypted: false }); + .mockReturnValueOnce({ lamport: 3, patchHandle: new AssetHandle('asset:3') }); const journal = { readPatch: vi.fn().mockResolvedValue({ ops: ['op3'] }) }; host['_patchJournal'] = journal; @@ -764,7 +759,7 @@ describe('PatchController', () => { .mockReturnValueOnce('checkpoint'); decodePatchMessageMock - .mockReturnValueOnce({ lamport: 2, patchOid: 'oid2', encrypted: false }); + .mockReturnValueOnce({ lamport: 2, patchHandle: new AssetHandle('asset:2') }); const journal = { readPatch: vi.fn().mockResolvedValue({ ops: ['op2'] }) }; host['_patchJournal'] = journal; @@ -775,43 +770,23 @@ describe('PatchController', () => { expect(result[0]?.sha).toBe('sha-2'); }); - it('falls back to codec decode when no patchJournal is set', async () => { + it('fails closed when no patch journal is configured', async () => { host['_patchJournal'] = null; const persistence = (host['_persistence'] as ReturnType); persistence.readRef.mockResolvedValue('sha-1'); - persistence.getNodeInfo.mockResolvedValue({ message: 'msg1', parents: [] }); - detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockReturnValue({ lamport: 1, patchOid: 'blob-oid' }); - - const rawBytes = new Uint8Array([1, 2, 3]); - persistence.readBlob.mockResolvedValue(rawBytes); - - const decodedPatch = { - writer: 'alice', + decodePatchMessageMock.mockReturnValue({ lamport: 1, - context: { alice: 0 }, - ops: [{ type: 'NodeAdd', id: 'n1', dot: ['alice', 1] }], - }; - const codec = /** @type {{ decode: import('vitest').Mock }} */ (host['_codec']); - codec.decode.mockReturnValue(decodedPatch); - - const result = await ctrl._loadWriterPatches('alice'); + patchHandle: new AssetHandle('asset:1'), + }); - expect(result).toHaveLength(1); - expect(result[0]?.patch).toMatchObject({ writer: 'alice', lamport: 1 }); - expect(result[0]?.patch.ops[0]).toMatchObject({ - type: 'NodeAdd', - node: 'n1', - dot: Dot.create('alice', 1), + await expect(ctrl._loadWriterPatches('alice')).rejects.toMatchObject({ + code: 'E_MISSING_JOURNAL', }); - expect(persistence.readBlob).toHaveBeenCalledWith('blob-oid'); - expect(codec.decode).toHaveBeenCalledWith(rawBytes); }); - it('continues legacy fallback decoding across parent commits', async () => { - host['_patchJournal'] = null; + it('reads every parent patch through the semantic journal', async () => { const persistence = (host['_persistence'] as ReturnType); persistence.readRef.mockResolvedValue('sha-2'); persistence.getNodeInfo @@ -819,37 +794,30 @@ describe('PatchController', () => { .mockResolvedValueOnce({ message: 'msg1', parents: [] }); detectMessageKindMock.mockReturnValue('patch'); + const newestHandle = new AssetHandle('asset:2'); + const oldestHandle = new AssetHandle('asset:1'); decodePatchMessageMock - .mockReturnValueOnce({ lamport: 2, patchOid: 'blob-oid-2' }) - .mockReturnValueOnce({ lamport: 1, patchOid: 'blob-oid-1' }); - - const blob2 = new Uint8Array([2]); - const blob1 = new Uint8Array([1]); - persistence.readBlob - .mockResolvedValueOnce(blob2) - .mockResolvedValueOnce(blob1); - - const codec = /** @type {{ decode: import('vitest').Mock }} */ (host['_codec']); - codec.decode - .mockReturnValueOnce({ - writer: 'alice', - lamport: 2, - context: { alice: 1 }, - ops: [{ type: 'NodeAdd', id: 'n2', dot: ['alice', 2] }], - }) - .mockReturnValueOnce({ - writer: 'alice', - lamport: 1, - context: { alice: 0 }, - ops: [{ type: 'NodeAdd', id: 'n1', dot: ['alice', 1] }], - }); + .mockReturnValueOnce({ lamport: 2, patchHandle: newestHandle }) + .mockReturnValueOnce({ lamport: 1, patchHandle: oldestHandle }); + const journal = { + readPatch: vi.fn() + .mockResolvedValueOnce({ writer: 'alice', lamport: 2, ops: [] }) + .mockResolvedValueOnce({ writer: 'alice', lamport: 1, ops: [] }), + }; + host['_patchJournal'] = journal; const result = await ctrl._loadWriterPatches('alice'); expect(result).toHaveLength(2); expect(result.map((entry) => entry.sha)).toEqual(['sha-1', 'sha-2']); - expect(persistence.readBlob).toHaveBeenNthCalledWith(1, 'blob-oid-2'); - expect(persistence.readBlob).toHaveBeenNthCalledWith(2, 'blob-oid-1'); + expect(journal.readPatch).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ patchHandle: newestHandle }), + ); + expect(journal.readPatch).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ patchHandle: oldestHandle }), + ); }); }); @@ -914,45 +882,35 @@ describe('PatchController', () => { }); // ──────────────────────────────────────────────────────────────────────── - // _readPatchBlob + // _readPatch // ──────────────────────────────────────────────────────────────────────── - describe('_readPatchBlob()', () => { - it('reads unencrypted blob from persistence', async () => { - const blob = new Uint8Array([10, 20, 30]); - const persistence = (host['_persistence'] as ReturnType); - persistence.readBlob.mockResolvedValue(blob); - - const result = await ctrl._readPatchBlob({ patchOid: 'oid1', encrypted: false }); - - expect(result).toBe(blob); - expect(persistence.readBlob).toHaveBeenCalledWith('oid1'); - }); - - it('throws PersistenceError when unencrypted blob is missing', async () => { - const persistence = (host['_persistence'] as ReturnType); - persistence.readBlob.mockResolvedValue(null); - - await expect(ctrl._readPatchBlob({ patchOid: 'oid1', encrypted: false })) - .rejects.toThrow(PersistenceError); - }); - - it('reads encrypted blob from patchBlobStorage', async () => { - const blob = new Uint8Array([40, 50, 60]); - const patchBlobStorage = { retrieve: vi.fn().mockResolvedValue(blob), store: vi.fn() }; - host['_patchBlobStorage'] = patchBlobStorage; - - const result = await ctrl._readPatchBlob({ patchOid: 'oid1', encrypted: true }); + describe('_readPatch()', () => { + const patchMeta = { + kind: 'patch' as const, + graph: 'test-graph', + writer: 'alice', + lamport: 1, + schema: 3, + patchHandle: new AssetHandle('asset:patch'), + storage: createGitCasPatchStorage({ encrypted: false }), + }; + + it('delegates decoded patch locators to the semantic journal', async () => { + const patch = { writer: 'alice', lamport: 1, ops: [] }; + const journal = { readPatch: vi.fn().mockResolvedValue(patch) }; + host['_patchJournal'] = journal; - expect(result).toBe(blob); - expect(patchBlobStorage.retrieve).toHaveBeenCalledWith('oid1'); + await expect(ctrl._readPatch(patchMeta)).resolves.toBe(patch); + expect(journal.readPatch).toHaveBeenCalledWith(patchMeta); }); - it('throws EncryptionError when encrypted but no patchBlobStorage', async () => { - host['_patchBlobStorage'] = null; + it('fails closed when no semantic journal is configured', async () => { + host['_patchJournal'] = null; - await expect(ctrl._readPatchBlob({ patchOid: 'oid1', encrypted: true })) - .rejects.toThrow(EncryptionError); + await expect(ctrl._readPatch(patchMeta)).rejects.toMatchObject({ + code: 'E_MISSING_JOURNAL', + }); }); }); @@ -1006,9 +964,9 @@ describe('PatchController', () => { .mockResolvedValueOnce({ message: 'msg-b1', parents: [] }); // bob sha-b1 detectMessageKindMock.mockReturnValue('patch'); - decodePatchMessageMock.mockImplementation((message: string) => ( - message === 'msg-a1' ? { lamport: 2 } : { lamport: 1 } - )); + decodePatchMessageMock + .mockReturnValueOnce({ lamport: 2, patchHandle: new AssetHandle('asset:a1') }) + .mockReturnValueOnce({ lamport: 1, patchHandle: new AssetHandle('asset:b1') }); const result = await ctrl.discoverTicks(); diff --git a/test/unit/domain/services/controllers/ProvenanceController.test.ts b/test/unit/domain/services/controllers/ProvenanceController.test.ts index 6c99f1b7..e9c6af06 100644 --- a/test/unit/domain/services/controllers/ProvenanceController.test.ts +++ b/test/unit/domain/services/controllers/ProvenanceController.test.ts @@ -11,6 +11,7 @@ import QueryError from '../../../../../src/domain/errors/QueryError.ts'; import Patch from '../../../../../src/domain/types/Patch.ts'; import NodeAdd from '../../../../../src/domain/types/ops/NodeAdd.ts'; import { Dot } from '../../../../../src/domain/crdt/Dot.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; // ── Mock WarpMessageCodec ─────────────────────────────────────────────── @@ -97,8 +98,7 @@ function createHost(overrides = {}) { }; }), }, - _readPatchBlob: vi.fn(async () => new Uint8Array([1, 2, 3])), - _codec: { decode: vi.fn(() => ({ ops: [], writer: 'w1', lamport: 1 })) }, + _readPatch: vi.fn(async () => makePatch()), ...overrides, }; } @@ -178,14 +178,14 @@ describe('ProvenanceController — materializeSlice', () => { host = createHost(); ctrl = new ProvenanceController(host); - // Default: detectMessageKind returns 'patch', decodePatchMessage returns metadata + // Default: the commit codec identifies a semantic patch locator. (mockDetectMessageKind as any).mockReturnValue('patch'); (mockDecodePatchMessage as any).mockReturnValue({ kind: 'patch', graph: 'g', writer: 'w1', lamport: 1, - patchOid: 'abc', + patchHandle: new AssetHandle('asset:abc'), schema: 2, encrypted: false, }); @@ -203,7 +203,7 @@ describe('ProvenanceController — materializeSlice', () => { it('replays patches via ProvenancePayload by default', async () => { const patch = makePatch({ writer: 'w1', lamport: 1 }); host._provenanceIndex.patchesFor.mockReturnValue(['sha1']); - host._codec.decode.mockReturnValue(patch); + host._readPatch.mockResolvedValue(patch); const fakeState = { nodeAlive: new Map([['n1', true]]) }; mockReplay.mockReturnValue(fakeState); @@ -226,7 +226,7 @@ describe('ProvenanceController — materializeSlice', () => { it('uses reducePatches with receipts when options.receipts is true', async () => { const patch = makePatch({ writer: 'w1', lamport: 1 }); host._provenanceIndex.patchesFor.mockReturnValue(['sha1']); - host._codec.decode.mockReturnValue(patch); + host._readPatch.mockResolvedValue(patch); const fakeState = { nodeAlive: new Map() }; const fakeReceipts = [{ type: 'tick' }]; @@ -288,7 +288,7 @@ describe('ProvenanceController — _computeBackwardCone', () => { graph: 'g', writer: 'w1', lamport: 1, - patchOid: 'abc', + patchHandle: new AssetHandle('asset:abc'), schema: 2, encrypted: false, }); @@ -305,7 +305,7 @@ describe('ProvenanceController — _computeBackwardCone', () => { it('collects patches for a single entity without reads', async () => { const patch = makePatch({ writer: 'w1', lamport: 1 }); host._provenanceIndex.patchesFor.mockReturnValue(['sha1']); - host._codec.decode.mockReturnValue(patch); + host._readPatch.mockResolvedValue(patch); const cone = await ctrl._computeBackwardCone('node:x'); @@ -321,9 +321,9 @@ describe('ProvenanceController — _computeBackwardCone', () => { .mockReturnValueOnce(['sha-a']) // node:x .mockReturnValueOnce(['sha-b']); // node:b - host._codec.decode - .mockReturnValueOnce(patchA) - .mockReturnValueOnce(patchB); + host._readPatch + .mockResolvedValueOnce(patchA) + .mockResolvedValueOnce(patchB); const cone = await ctrl._computeBackwardCone('node:x'); @@ -341,9 +341,9 @@ describe('ProvenanceController — _computeBackwardCone', () => { .mockReturnValueOnce(['sha-x']) // node:x .mockReturnValueOnce(['sha-y']); // node:y - host._codec.decode - .mockReturnValueOnce(patchX) - .mockReturnValueOnce(patchY); + host._readPatch + .mockResolvedValueOnce(patchX) + .mockResolvedValueOnce(patchY); const cone = await ctrl._computeBackwardCone('node:x'); @@ -360,9 +360,9 @@ describe('ProvenanceController — _computeBackwardCone', () => { .mockReturnValueOnce(['sha-x']) .mockReturnValueOnce(['sha-y']); - host._codec.decode - .mockReturnValueOnce(patchX) - .mockReturnValueOnce(patchY); + host._readPatch + .mockResolvedValueOnce(patchX) + .mockResolvedValueOnce(patchY); const cone = await ctrl._computeBackwardCone('node:x'); @@ -378,7 +378,7 @@ describe('ProvenanceController — _computeBackwardCone', () => { .mockReturnValueOnce(['shared-sha']) // node:x .mockReturnValueOnce(['shared-sha']); // node:y - host._codec.decode.mockReturnValue(sharedPatch); + host._readPatch.mockResolvedValue(sharedPatch); const cone = await ctrl._computeBackwardCone('node:x'); @@ -412,12 +412,12 @@ describe('ProvenanceController — loadPatchBySha', () => { }); it('loads and decodes a patch commit', async () => { - const patch = { + const patch = new Patch({ writer: 'w1', lamport: 5, context: { w1: 0 }, - ops: [{ type: 'NodeAdd', id: 'n1', dot: ['w1', 1] }], - }; + ops: [new NodeAdd('n1', Dot.create('w1', 1))], + }); (mockDetectMessageKind as any).mockReturnValue('patch'); (mockDecodePatchMessage as any).mockReturnValue({ @@ -425,11 +425,11 @@ describe('ProvenanceController — loadPatchBySha', () => { graph: 'g', writer: 'w1', lamport: 5, - patchOid: 'blob-oid', + patchHandle: new AssetHandle('asset:patch'), schema: 2, encrypted: false, }); - host._codec.decode.mockReturnValue(patch); + host._readPatch.mockResolvedValue(patch); const result = await ctrl.loadPatchBySha('abc123'); @@ -441,10 +441,9 @@ describe('ProvenanceController — loadPatchBySha', () => { expect(host._persistence.getNodeInfo).toHaveBeenCalledWith('abc123'); expect(mockDetectMessageKind).toHaveBeenCalledWith('patch-message'); expect(mockDecodePatchMessage).toHaveBeenCalledWith('patch-message'); - expect(host._readPatchBlob).toHaveBeenCalledWith( - expect.objectContaining({ patchOid: 'blob-oid' }), + expect(host._readPatch).toHaveBeenCalledWith( + expect.objectContaining({ patchHandle: new AssetHandle('asset:patch') }), ); - expect(host._codec.decode).toHaveBeenCalledWith(new Uint8Array([1, 2, 3])); }); it('throws when commit is not a patch', async () => { diff --git a/test/unit/domain/services/controllers/QueryContentProjectionReads.test.ts b/test/unit/domain/services/controllers/QueryContentProjectionReads.test.ts index b372ba9a..e589d6fc 100644 --- a/test/unit/domain/services/controllers/QueryContentProjectionReads.test.ts +++ b/test/unit/domain/services/controllers/QueryContentProjectionReads.test.ts @@ -7,9 +7,9 @@ import type { LWWRegister } from '../../../../../src/domain/crdt/LWW.ts'; import type { QueryContentHost } from '../../../../../src/domain/services/controllers/ReadGraphHost.ts'; import { getContentMetaImpl, - getContentOidImpl, + getContentHandleImpl, getEdgeContentMetaImpl, - getEdgeContentOidImpl, + getEdgeContentHandleImpl, } from '../../../../../src/domain/services/controllers/QueryContent.ts'; import { CONTENT_MIME_PROPERTY_KEY, @@ -51,7 +51,7 @@ type StateSpec = { }; describe('QueryContent projection-backed reads', () => { - it('returns null node content OID and metadata for malformed storage references', async () => { + it('returns null node content handle and metadata for malformed storage references', async () => { const eid = event(5); const host = hostWithState(buildState({ nodes: ['alice'], @@ -61,11 +61,11 @@ describe('QueryContent projection-backed reads', () => { ], })); - await expect(getContentOidImpl(host, 'alice')).resolves.toBeNull(); + await expect(getContentHandleImpl(host, 'alice')).resolves.toBeNull(); await expect(getContentMetaImpl(host, 'alice')).resolves.toBeNull(); }); - it('returns null edge content OID and metadata for malformed storage references', async () => { + it('returns null edge content handle and metadata for malformed storage references', async () => { const eid = event(5); const edge = { from: 'alice', to: 'bob', label: 'knows' }; const host = hostWithState(buildState({ @@ -77,7 +77,7 @@ describe('QueryContent projection-backed reads', () => { ], })); - await expect(getEdgeContentOidImpl(host, edge)).resolves.toBeNull(); + await expect(getEdgeContentHandleImpl(host, edge)).resolves.toBeNull(); await expect(getEdgeContentMetaImpl(host, edge)).resolves.toBeNull(); }); @@ -92,7 +92,7 @@ describe('QueryContent projection-backed reads', () => { })); await expect(getContentMetaImpl(host, 'alice')).resolves.toEqual({ - oid: 'deadbeef', + handle: 'deadbeef', mime: null, size: null, }); @@ -111,7 +111,7 @@ describe('QueryContent projection-backed reads', () => { })); await expect(getEdgeContentMetaImpl(host, edge)).resolves.toEqual({ - oid: 'cafebabe', + handle: 'cafebabe', mime: null, size: null, }); @@ -127,10 +127,7 @@ function hostWithState(state: WarpState): QueryContentHost { _autoMaterialize: true, _cachedState: state, _ensureFreshState: vi.fn(async () => undefined), - _blobStorage: null, - _persistence: { - readBlob: vi.fn(async () => new Uint8Array([1, 2, 3])), - }, + _assetStorage: null, }; } diff --git a/test/unit/domain/services/controllers/QueryController.test.ts b/test/unit/domain/services/controllers/QueryController.test.ts index 5fdc9308..3b1f38ce 100644 --- a/test/unit/domain/services/controllers/QueryController.test.ts +++ b/test/unit/domain/services/controllers/QueryController.test.ts @@ -5,6 +5,7 @@ import SnapshotWarpState from '../../../../../src/domain/services/snapshot/Snaps import ORSet from '../../../../../src/domain/crdt/ORSet.ts'; import VersionVector from '../../../../../src/domain/crdt/VersionVector.ts'; import { Dot } from '../../../../../src/domain/crdt/Dot.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; import { encodePropKey, encodeEdgeKey, @@ -53,6 +54,12 @@ function lww(value, eid = null) { return { value, eventId: eid }; } +function chunks(...values: Uint8Array[]): AsyncIterable { + return (async function* (): AsyncGenerator { + yield* values; + })(); +} + /** * Creates a WarpState with nodes, edges, and properties. * @@ -111,10 +118,9 @@ function createHost(state, overrides = {}) { _stateDirty: false, getFrontier: vi.fn().mockResolvedValue(new Map(frontier)), _ensureFreshState: vi.fn().mockResolvedValue(undefined), - _persistence: { - readBlob: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + _assetStorage: { + open: vi.fn(() => chunks(new Uint8Array([1, 2, 3]))), }, - _blobStorage: null, _propertyReader: null, _logicalIndex: null, _materializedGraph: null, @@ -592,18 +598,18 @@ describe('QueryController', () => { // ── Content attachment (node) ──────────────────────────────────────────── - describe('getContentOid()', () => { + describe('getContentHandle()', () => { it('returns null when node has no content', async () => { - const oid = await ctrl.getContentOid('alice'); - expect(oid).toBeNull(); + const handle = await ctrl.getContentHandle('alice'); + expect(handle).toBeNull(); }); it('returns null when node does not exist', async () => { - const oid = await ctrl.getContentOid('nobody'); - expect(oid).toBeNull(); + const handle = await ctrl.getContentHandle('nobody'); + expect(handle).toBeNull(); }); - it('returns the OID when content is attached', async () => { + it('returns the opaque handle when content is attached', async () => { const s = buildState({ nodes: ['alice'], props: [ @@ -611,8 +617,8 @@ describe('QueryController', () => { ], }); host._cachedState = s; - const oid = await ctrl.getContentOid('alice'); - expect(oid).toBe('deadbeef'); + const handle = await ctrl.getContentHandle('alice'); + expect(handle).toBe('deadbeef'); }); }); @@ -622,7 +628,7 @@ describe('QueryController', () => { expect(meta).toBeNull(); }); - it('returns oid with null mime/size when siblings are absent', async () => { + it('returns a handle with null mime/size when siblings are absent', async () => { const s = buildState({ nodes: ['alice'], props: [ @@ -631,7 +637,7 @@ describe('QueryController', () => { }); host._cachedState = s; const meta = await ctrl.getContentMeta('alice'); - expect(meta).toEqual({ oid: 'deadbeef', mime: null, size: null }); + expect(meta).toEqual({ handle: 'deadbeef', mime: null, size: null }); }); it('includes mime and size when from same lineage', async () => { @@ -646,7 +652,7 @@ describe('QueryController', () => { }); host._cachedState = s; const meta = await ctrl.getContentMeta('alice'); - expect(meta).toEqual({ oid: 'deadbeef', mime: 'text/plain', size: 42 }); + expect(meta).toEqual({ handle: 'deadbeef', mime: 'text/plain', size: 42 }); }); it('returns null mime/size when from different lineage', async () => { @@ -662,7 +668,7 @@ describe('QueryController', () => { }); host._cachedState = s; const meta = await ctrl.getContentMeta('alice'); - expect(meta).toEqual({ oid: 'deadbeef', mime: null, size: null }); + expect(meta).toEqual({ handle: 'deadbeef', mime: null, size: null }); }); it('returns null size when value is not a non-negative integer', async () => { @@ -676,7 +682,7 @@ describe('QueryController', () => { }); host._cachedState = s; const meta = await ctrl.getContentMeta('alice'); - expect(meta).toEqual({ oid: 'deadbeef', mime: null, size: null }); + expect(meta).toEqual({ handle: 'deadbeef', mime: null, size: null }); }); }); @@ -686,7 +692,7 @@ describe('QueryController', () => { expect(buf).toBeNull(); }); - it('reads blob from persistence when no blobStorage', async () => { + it('streams the asset through the semantic storage port', async () => { const s = buildState({ nodes: ['alice'], props: [ @@ -696,14 +702,14 @@ describe('QueryController', () => { host._cachedState = s; const buf = await ctrl.getContent('alice'); expect(buf).toEqual(new Uint8Array([1, 2, 3])); - expect(host._persistence.readBlob).toHaveBeenCalledWith('deadbeef'); + expect(host._assetStorage.open).toHaveBeenCalledWith(new AssetHandle('deadbeef')); }); - it('reads blob from blobStorage when available', async () => { - const mockBlobStorage = { - retrieve: vi.fn().mockResolvedValue(new Uint8Array([4, 5, 6])), + it('collects multiple asset chunks without a raw Git fallback', async () => { + const assetStorage = { + open: vi.fn(() => chunks(new Uint8Array([4]), new Uint8Array([5, 6]))), }; - host._blobStorage = mockBlobStorage; + host._assetStorage = assetStorage; const s = buildState({ nodes: ['alice'], props: [ @@ -713,24 +719,34 @@ describe('QueryController', () => { host._cachedState = s; const buf = await ctrl.getContent('alice'); expect(buf).toEqual(new Uint8Array([4, 5, 6])); - expect(mockBlobStorage.retrieve).toHaveBeenCalledWith('deadbeef'); + expect(assetStorage.open).toHaveBeenCalledWith(new AssetHandle('deadbeef')); + }); + + it('fails closed when semantic asset storage is unavailable', async () => { + host._assetStorage = null; + host._cachedState = buildState({ + nodes: ['alice'], + props: [{ nodeId: 'alice', key: CONTENT_PROPERTY_KEY, value: 'deadbeef' }], + }); + + await expect(ctrl.getContent('alice')).rejects.toMatchObject({ code: 'E_CONTENT_STORAGE' }); }); }); // ── Content attachment (edge) ──────────────────────────────────────────── - describe('getEdgeContentOid()', () => { + describe('getEdgeContentHandle()', () => { it('returns null when edge has no content', async () => { - const oid = await ctrl.getEdgeContentOid('alice', 'bob', 'knows'); - expect(oid).toBeNull(); + const handle = await ctrl.getEdgeContentHandle('alice', 'bob', 'knows'); + expect(handle).toBeNull(); }); it('returns null when edge does not exist', async () => { - const oid = await ctrl.getEdgeContentOid('alice', 'carol', 'knows'); - expect(oid).toBeNull(); + const handle = await ctrl.getEdgeContentHandle('alice', 'carol', 'knows'); + expect(handle).toBeNull(); }); - it('returns the OID when content is attached to an edge', async () => { + it('returns the opaque handle when content is attached to an edge', async () => { const s = buildState({ nodes: ['alice', 'bob'], edges: [{ from: 'alice', to: 'bob', label: 'knows' }], @@ -739,8 +755,8 @@ describe('QueryController', () => { ], }); host._cachedState = s; - const oid = await ctrl.getEdgeContentOid('alice', 'bob', 'knows'); - expect(oid).toBe('cafebabe'); + const handle = await ctrl.getEdgeContentHandle('alice', 'bob', 'knows'); + expect(handle).toBe('cafebabe'); }); it('returns null when endpoint node is dead', async () => { @@ -752,8 +768,8 @@ describe('QueryController', () => { ], }); host._cachedState = s; - const oid = await ctrl.getEdgeContentOid('alice', 'bob', 'knows'); - expect(oid).toBeNull(); + const handle = await ctrl.getEdgeContentHandle('alice', 'bob', 'knows'); + expect(handle).toBeNull(); }); }); @@ -763,7 +779,7 @@ describe('QueryController', () => { expect(meta).toBeNull(); }); - it('returns oid with mime and size from same lineage', async () => { + it('returns a handle with mime and size from the same lineage', async () => { const eid = eventId(5, 'w1', 'sha1'); const s = buildState({ nodes: ['alice', 'bob'], @@ -776,7 +792,7 @@ describe('QueryController', () => { }); host._cachedState = s; const meta = await ctrl.getEdgeContentMeta('alice', 'bob', 'knows'); - expect(meta).toEqual({ oid: 'cafebabe', mime: 'image/png', size: 1024 }); + expect(meta).toEqual({ handle: 'cafebabe', mime: 'image/png', size: 1024 }); }); it('filters edge content behind birth event', async () => { @@ -804,7 +820,7 @@ describe('QueryController', () => { expect(buf).toBeNull(); }); - it('reads blob from persistence', async () => { + it('streams the edge asset through the semantic storage port', async () => { const s = buildState({ nodes: ['alice', 'bob'], edges: [{ from: 'alice', to: 'bob', label: 'knows' }], @@ -815,14 +831,14 @@ describe('QueryController', () => { host._cachedState = s; const buf = await ctrl.getEdgeContent('alice', 'bob', 'knows'); expect(buf).toEqual(new Uint8Array([1, 2, 3])); - expect(host._persistence.readBlob).toHaveBeenCalledWith('cafebabe'); + expect(host._assetStorage.open).toHaveBeenCalledWith(new AssetHandle('cafebabe')); }); - it('reads blob from blobStorage when available', async () => { - const mockBlobStorage = { - retrieve: vi.fn().mockResolvedValue(new Uint8Array([7, 8, 9])), + it('collects multiple edge asset chunks', async () => { + const assetStorage = { + open: vi.fn(() => chunks(new Uint8Array([7, 8]), new Uint8Array([9]))), }; - host._blobStorage = mockBlobStorage; + host._assetStorage = assetStorage; const s = buildState({ nodes: ['alice', 'bob'], edges: [{ from: 'alice', to: 'bob', label: 'knows' }], @@ -833,7 +849,7 @@ describe('QueryController', () => { host._cachedState = s; const buf = await ctrl.getEdgeContent('alice', 'bob', 'knows'); expect(buf).toEqual(new Uint8Array([7, 8, 9])); - expect(mockBlobStorage.retrieve).toHaveBeenCalledWith('cafebabe'); + expect(assetStorage.open).toHaveBeenCalledWith(new AssetHandle('cafebabe')); }); }); @@ -845,7 +861,7 @@ describe('QueryController', () => { expect(stream).toBeNull(); }); - it('returns async iterable wrapping buffered read', async () => { + it('returns the asset stream', async () => { const s = buildState({ nodes: ['alice'], props: [ @@ -863,16 +879,11 @@ describe('QueryController', () => { expect(chunks).toEqual([new Uint8Array([1, 2, 3])]); }); - it('uses blobStorage.retrieveStream when available', async () => { - const mockStream = (async function* () { - yield new Uint8Array([10]); - yield new Uint8Array([20]); - })(); - const mockBlobStorage = { - retrieve: vi.fn(), - retrieveStream: vi.fn().mockReturnValue(mockStream), + it('preserves multiple chunks from the asset storage stream', async () => { + const assetStorage = { + open: vi.fn(() => chunks(new Uint8Array([10]), new Uint8Array([20]))), }; - host._blobStorage = mockBlobStorage; + host._assetStorage = assetStorage; const s = buildState({ nodes: ['alice'], props: [ @@ -882,7 +893,12 @@ describe('QueryController', () => { host._cachedState = s; const stream = await ctrl.getContentStream('alice'); expect(stream).not.toBeNull(); - expect(mockBlobStorage.retrieveStream).toHaveBeenCalledWith('deadbeef'); + const observed: Uint8Array[] = []; + for await (const chunk of stream as AsyncIterable) { + observed.push(chunk); + } + expect(observed).toEqual([new Uint8Array([10]), new Uint8Array([20])]); + expect(assetStorage.open).toHaveBeenCalledWith(new AssetHandle('deadbeef')); }); }); @@ -892,7 +908,7 @@ describe('QueryController', () => { expect(stream).toBeNull(); }); - it('returns async iterable wrapping buffered read', async () => { + it('returns the edge asset stream', async () => { const s = buildState({ nodes: ['alice', 'bob'], edges: [{ from: 'alice', to: 'bob', label: 'knows' }], diff --git a/test/unit/domain/services/controllers/StrandController.host-interface.test.ts b/test/unit/domain/services/controllers/StrandController.host-interface.test.ts index a2487c7b..06e28448 100644 --- a/test/unit/domain/services/controllers/StrandController.host-interface.test.ts +++ b/test/unit/domain/services/controllers/StrandController.host-interface.test.ts @@ -44,8 +44,16 @@ function createStrandHost(): StrandHost { _cachedViewHash: null, _cachedState: null, _patchJournal: null, - _patchBlobStorage: null, - _blobStorage: null, + _strandStore: { + readDescriptor: async () => null, + publishDescriptor: async () => { + throw new Error('unused descriptor publication'); + }, + listStrandIds: async () => [], + hasDescriptor: async () => false, + deleteDescriptor: async () => false, + }, + _assetStorage: null, _commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, _logger: null, _codec: { diff --git a/test/unit/domain/services/optic/CheckpointFactResolver.test.ts b/test/unit/domain/services/optic/CheckpointFactResolver.test.ts index ba546a0c..a0ce96b2 100644 --- a/test/unit/domain/services/optic/CheckpointFactResolver.test.ts +++ b/test/unit/domain/services/optic/CheckpointFactResolver.test.ts @@ -4,6 +4,7 @@ import MemoryBudgetError from '../../../../../src/domain/errors/MemoryBudgetErro import MemoryBudget from '../../../../../src/domain/memory/MemoryBudget.ts'; import WarpMemoryPool from '../../../../../src/domain/memory/WarpMemoryPool.ts'; import CheckpointFactResolver from '../../../../../src/domain/services/optic/CheckpointFactResolver.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; import { CheckpointContentAnchorFact, CheckpointEdgeFact, @@ -62,21 +63,28 @@ describe('CheckpointFactResolver', () => { expect(pool.snapshot()).toMatchObject({ leased: 0, peak: 1, rejected: 0 }); }); - it('resolves current content OID from streamed content anchors', async () => { + it('resolves the current content handle from streamed content anchors', async () => { const { resolver, pool } = resolverFixture(); - await expect(resolver.resolveContentOid(facts([ + const handle = await resolver.resolveContentHandle(facts([ contentAnchor('task:1', 'oid-old', 1), contentAnchor('task:1', 'oid-current', 5), contentAnchor('task:2', 'oid-other', 6), - ]), 'task:1')).resolves.toBe('oid-current'); - await expect(resolver.resolveContentOid(facts([]), 'task:missing')).resolves.toBeNull(); + ]), 'task:1'); + expect(handle?.toString()).toBe('oid-current'); + await expect(resolver.resolveContentHandle(facts([]), 'task:missing')).resolves.toBeNull(); expect(pool.snapshot()).toMatchObject({ leased: 0, peak: 1, rejected: 0 }); }); it('rejects malformed constructor fields before field access', () => { // @ts-expect-error deliberate malformed constructor fixture expect(() => new CheckpointFactResolver(null)).toThrow(MemoryBudgetError); + expect(() => new CheckpointContentAnchorFact({ + owner: 'task:1', + // @ts-expect-error deliberate raw-token substitution + contentHandle: 'raw-token', + eventId: event(1), + })).toThrowError(expect.objectContaining({ code: 'E_CHECKPOINT_CONTENT_HANDLE' })); }); }); @@ -109,8 +117,12 @@ function nodeProperty(nodeId: string, key: string, value: string | null, lamport return new CheckpointNodePropertyFact({ nodeId, key, value, eventId: event(lamport) }); } -function contentAnchor(owner: string, contentOid: string, lamport: number): CheckpointContentAnchorFact { - return new CheckpointContentAnchorFact({ owner, contentOid, eventId: event(lamport) }); +function contentAnchor(owner: string, contentHandle: string, lamport: number): CheckpointContentAnchorFact { + return new CheckpointContentAnchorFact({ + owner, + contentHandle: new AssetHandle(contentHandle), + eventId: event(lamport), + }); } function event(lamport: number): EventId { diff --git a/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts b/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts index 4e52edff..b1aca5cc 100644 --- a/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts +++ b/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts @@ -16,7 +16,6 @@ import CheckpointTailOpticSource, { type CheckpointTailCheckpointFrontier, type CheckpointTailPatchEntry, } from '../../../../../src/domain/services/optic/CheckpointTailOpticSource.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; import Patch from '../../../../../src/domain/types/Patch.ts'; import NodeAdd from '../../../../../src/domain/types/ops/NodeAdd.ts'; @@ -24,11 +23,9 @@ import NodePropSet from '../../../../../src/domain/types/ops/NodePropSet.ts'; import Op from '../../../../../src/domain/types/ops/Op.ts'; import { OP_SCOPE_BOTH } from '../../../../../src/domain/types/ops/OpScope.ts'; import OpApplied from '../../../../../src/domain/types/ops/OpApplied.ts'; -import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; -import type BlobStoragePort from '../../../../../src/ports/BlobStoragePort.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; +import MockIndexStorage from '../../../../helpers/MockIndexStorage.ts'; import type CodecPort from '../../../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../../../src/ports/CommitMessageCodecPort.ts'; -import type { CorePersistence } from '../../../../../src/domain/types/WarpPersistence.ts'; const REPO_ROOT = fileURLToPath(new URL('../../../../../', import.meta.url)); const STREAM_SOURCE = 'src/domain/services/optic/CheckpointPatchFactStream.ts'; @@ -310,10 +307,9 @@ describe('CheckpointPatchFactStream', () => { class TestPatchFactStreamSource extends CheckpointTailOpticSource { readonly graphName = 'patch-fact-stream-test'; - readonly _persistence: CorePersistence = new InMemoryGraphAdapter(); readonly _codec: CodecPort = defaultCodec; - readonly _blobStorage: BlobStoragePort | null = null; - readonly _commitMessageCodec: CommitMessageCodecPort = DEFAULT_COMMIT_MESSAGE_CODEC; + readonly _checkpointStore = new InMemoryCheckpointStore(); + readonly _indexStore = new MockIndexStorage(); readonly loadCalls: Array<{ readonly tipSha: string; readonly stopAtSha: string | null }> = []; readonly validationCalls: Array<{ readonly writerId: string; readonly incomingSha: string }> = []; private readonly _chains: Map = new Map(); diff --git a/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts b/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts index 8e1571ed..7faea08c 100644 --- a/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts +++ b/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from 'vitest'; + +import { EdgeShard } from '../../../../../src/domain/artifacts/EdgeShard.ts'; +import { MetaShard } from '../../../../../src/domain/artifacts/MetaShard.ts'; +import { shardToEntry } from '../../../../../src/domain/services/MaterializedViewHelpers.ts'; +import LogicalBitmapIndexBuilder from '../../../../../src/domain/services/index/LogicalBitmapIndexBuilder.ts'; import CheckpointBasisManifest, { CheckpointBasisChunking, CheckpointBasisCompleteness, @@ -7,61 +12,66 @@ import CheckpointBasisManifest, { CheckpointBasisSupportPosture, } from '../../../../../src/domain/services/optic/CheckpointBasisManifest.ts'; import CheckpointShardFactReader from '../../../../../src/domain/services/optic/CheckpointShardFactReader.ts'; +import type { CheckpointTailIndexBasis } from '../../../../../src/domain/services/optic/CheckpointTailBasisLoader.ts'; import CheckpointTailOpticSource, { type CheckpointTailCheckpointFrontier, type CheckpointTailPatchEntry, } from '../../../../../src/domain/services/optic/CheckpointTailOpticSource.ts'; -import type { CheckpointTailIndexBasis } from '../../../../../src/domain/services/optic/CheckpointTailBasisLoader.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { shardToEntry } from '../../../../../src/domain/services/MaterializedViewHelpers.ts'; -import LogicalBitmapIndexBuilder from '../../../../../src/domain/services/index/LogicalBitmapIndexBuilder.ts'; import { CURRENT_CHECKPOINT_SCHEMA } from '../../../../../src/domain/services/state/checkpointHelpers.ts'; -import { EdgeShard } from '../../../../../src/domain/artifacts/EdgeShard.ts'; -import { MetaShard } from '../../../../../src/domain/artifacts/MetaShard.ts'; -import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; +import type CodecValue from '../../../../../src/domain/types/codec/CodecValue.ts'; import computeShardKey from '../../../../../src/domain/utils/shardKey.ts'; -import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; -import type BlobStoragePort from '../../../../../src/ports/BlobStoragePort.ts'; +import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; import type CodecPort from '../../../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../../../src/ports/CommitMessageCodecPort.ts'; -import type { CorePersistence } from '../../../../../src/domain/types/WarpPersistence.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; +import MockIndexStorage from '../../../../helpers/MockIndexStorage.ts'; const NODE_ID = 'node:manifest-backed'; const PROPERTY_KEY = 'title'; const PROPERTY_VALUE = 'manifest-backed value'; -const CHECKPOINT_SHA = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const CHECKPOINT_SHA = 'a'.repeat(40); const NEIGHBOR_ID = 'node:manifest-neighbor'; const EDGE_LABEL = 'owns'; describe('CheckpointShardFactReader manifest-backed routing', () => { - it('reads node liveness and properties from manifest roots instead of loose legacy maps', async () => { - const persistence = new InMemoryGraphAdapter(); + it('reads liveness and properties through their manifest-selected asset handles', async () => { + const indexStore = new MockIndexStorage(); const metaPath = `meta_${computeShardKey(NODE_ID)}.cbor`; const propPath = `props_${computeShardKey(NODE_ID)}.cbor`; - const metaOid = await persistence.writeBlob(metaShardBytes(NODE_ID)); - const propOid = await persistence.writeBlob(defaultCodec.encode([ + const metaHandle = await indexStore.writeBlob(metaShardBytes(NODE_ID)); + const propHandle = await indexStore.writeBlob(defaultCodec.encode([ [NODE_ID, { [PROPERTY_KEY]: PROPERTY_VALUE }], ])); - const source = new ManifestShardSource(persistence); + const source = new ManifestShardSource(indexStore); const basis = manifestBasis({ - livenessRoots: new Map([[metaPath, metaOid]]), - propertyRoots: new Map([[propPath, propOid]]), + livenessRoots: new Map([[metaPath, metaHandle]]), + propertyRoots: new Map([[propPath, propHandle]]), }); const reader = new CheckpointShardFactReader({ source }); await expect(reader.readNodeAlive(basis, NODE_ID)).resolves.toBe(true); await expect(reader.readProperty(basis, NODE_ID, PROPERTY_KEY)).resolves.toBe(PROPERTY_VALUE); - expect(reader.nodeLivenessShardIdentities(basis, NODE_ID)).toEqual([{ path: metaPath, oid: metaOid }]); - expect(reader.propertyShardIdentities(basis, NODE_ID)).toEqual([{ path: propPath, oid: propOid }]); + expect(reader.nodeLivenessShardIdentities(basis, NODE_ID)).toEqual([ + { path: metaPath, oid: metaHandle.toString() }, + ]); + expect(reader.propertyShardIdentities(basis, NODE_ID)).toEqual([ + { path: propPath, oid: propHandle.toString() }, + ]); + expect(indexStore.decodedShardHandles).toEqual([ + metaHandle.toString(), + propHandle.toString(), + ]); + expect(indexStore.openedShardHandles).toEqual([]); }); - it('fails closed when a manifest-backed property shard is missing', async () => { - const persistence = new InMemoryGraphAdapter(); + it('fails closed when a manifest-backed property asset is missing', async () => { + const indexStore = new MockIndexStorage(); const propPath = `props_${computeShardKey(NODE_ID)}.cbor`; - const source = new ManifestShardSource(persistence); + const missingHandle = new AssetHandle('test-index-shard:missing'); + const source = new ManifestShardSource(indexStore); const basis = manifestBasis({ - livenessRoots: new Map(), - propertyRoots: new Map([[propPath, 'deadbeef']]), + livenessRoots: new Map(), + propertyRoots: new Map([[propPath, missingHandle]]), }); const reader = new CheckpointShardFactReader({ source }); @@ -70,32 +80,144 @@ describe('CheckpointShardFactReader manifest-backed routing', () => { context: { reason: 'checkpoint-shard-unavailable', path: propPath, - oid: 'deadbeef', + oid: missingHandle.toString(), + }, + }); + }); + + it('fails closed when a manifest-backed liveness asset is missing', async () => { + const indexStore = new MockIndexStorage(); + const metaPath = `meta_${computeShardKey(NODE_ID)}.cbor`; + const missingHandle = new AssetHandle('test-index-shard:missing-liveness'); + const source = new ManifestShardSource(indexStore); + const basis = manifestBasis({ + livenessRoots: new Map([[metaPath, missingHandle]]), + propertyRoots: new Map(), + }); + const reader = new CheckpointShardFactReader({ source }); + + await expect(reader.readNodeAlive(basis, NODE_ID)).rejects.toMatchObject({ + code: 'E_OPTIC_NO_BOUNDED_BASIS', + context: { + reason: 'checkpoint-shard-unavailable', + path: metaPath, + oid: missingHandle.toString(), + }, + }); + }); + + it('preserves non-Error shard decode failures without misclassifying them', async () => { + const indexStore = new NonErrorDecodeIndexStorage(); + const metaPath = `meta_${computeShardKey(NODE_ID)}.cbor`; + const propPath = `props_${computeShardKey(NODE_ID)}.cbor`; + const metaHandle = await indexStore.writeBlob(metaShardBytes(NODE_ID)); + const propHandle = await indexStore.writeBlob(defaultCodec.encode([])); + const source = new ManifestShardSource(indexStore); + const basis = manifestBasis({ + livenessRoots: new Map([[metaPath, metaHandle]]), + propertyRoots: new Map([[propPath, propHandle]]), + }); + const reader = new CheckpointShardFactReader({ source }); + + await expect(reader.readNodeAlive(basis, NODE_ID)).rejects.toBe('decode-failure'); + await expect(reader.readProperty(basis, NODE_ID, PROPERTY_KEY)).rejects.toBe('decode-failure'); + }); + + it('preserves non-Error neighborhood stream failures without misclassifying them', async () => { + const indexStore = new NonErrorOpenIndexStorage(); + const metaPath = `meta_${computeShardKey(NODE_ID)}.cbor`; + const metaHandle = await indexStore.writeBlob(metaShardBytes(NODE_ID)); + const source = new ManifestShardSource(indexStore); + const basis = manifestBasis({ + livenessRoots: new Map([[metaPath, metaHandle]]), + propertyRoots: new Map(), + }); + const reader = new CheckpointShardFactReader({ source }); + + await expect(reader.readNeighborhood(basis, { + nodeId: NODE_ID, + direction: 'out', + labels: [], + })).rejects.toBe('open-failure'); + }); + + it('fails closed before opening a shard whose manifest and basis handles differ', async () => { + const indexStore = new MockIndexStorage(); + const path = `meta_${computeShardKey(NODE_ID)}.cbor`; + const manifestHandle = await indexStore.writeBlob(metaShardBytes(NODE_ID)); + const differentHandle = await indexStore.writeBlob(metaShardBytes('node:different')); + const source = new ManifestShardSource(indexStore); + const basis = manifestBasis({ + livenessRoots: new Map([[path, manifestHandle]]), + propertyRoots: new Map(), + }); + const mismatchedBasis: CheckpointTailIndexBasis = { + ...basis, + indexHandles: Object.freeze({ ...basis.indexHandles, [path]: differentHandle }), + }; + const reader = new CheckpointShardFactReader({ source }); + + await expect(reader.readNodeAlive(mismatchedBasis, NODE_ID)).rejects.toMatchObject({ + code: 'E_OPTIC_NO_BOUNDED_BASIS', + context: { + reason: 'checkpoint-shard-invalid', + path, + manifestHandle: manifestHandle.toString(), + basisHandle: differentHandle.toString(), + }, + }); + expect(indexStore.openedShardHandles).toEqual([]); + expect(indexStore.decodedShardHandles).toEqual([]); + }); + + it('fails closed when a manifest root has no corresponding basis handle', async () => { + const indexStore = new MockIndexStorage(); + const path = `meta_${computeShardKey(NODE_ID)}.cbor`; + const manifestHandle = await indexStore.writeBlob(metaShardBytes(NODE_ID)); + const source = new ManifestShardSource(indexStore); + const basis = manifestBasis({ + livenessRoots: new Map([[path, manifestHandle]]), + propertyRoots: new Map(), + }); + const missingBasisHandle: CheckpointTailIndexBasis = { + ...basis, + indexHandles: Object.freeze({}), + }; + const reader = new CheckpointShardFactReader({ source }); + + await expect(reader.readNodeAlive(missingBasisHandle, NODE_ID)).rejects.toMatchObject({ + code: 'E_OPTIC_NO_BOUNDED_BASIS', + context: { + reason: 'checkpoint-shard-invalid', + path, + manifestHandle: manifestHandle.toString(), + basisHandle: null, }, }); }); - it('does not read unrelated liveness shards for a local neighborhood', async () => { - const persistence = new InMemoryGraphAdapter(); + it('streams only the causal support shards needed for a local neighborhood', async () => { + const indexStore = new MockIndexStorage(); const sourceMetaPath = `meta_${computeShardKey(NODE_ID)}.cbor`; const neighborMetaPath = `meta_${computeShardKey(NEIGHBOR_ID)}.cbor`; const unrelatedMetaPath = 'meta_unrelated.cbor'; const outgoingPath = `fwd_${computeShardKey(NODE_ID)}.cbor`; const labelsPath = 'labels.cbor'; - const sourceMetaOid = await persistence.writeBlob(metaShardBytes(NODE_ID)); - const neighborMetaOid = await persistence.writeBlob(metaShardBytes(NEIGHBOR_ID)); - const outgoingOid = await persistence.writeBlob(neighborhoodShardBytes()); - const labelsOid = await persistence.writeBlob(defaultCodec.encode([[EDGE_LABEL, 0]])); - const source = new ManifestShardSource(persistence); + const sourceMetaHandle = await indexStore.writeBlob(metaShardBytes(NODE_ID)); + const neighborMetaHandle = await indexStore.writeBlob(metaShardBytes(NEIGHBOR_ID)); + const unrelatedMetaHandle = await indexStore.writeBlob(metaShardBytes('node:unrelated')); + const outgoingHandle = await indexStore.writeBlob(neighborhoodShardBytes()); + const labelsHandle = await indexStore.writeBlob(defaultCodec.encode([[EDGE_LABEL, 0]])); + const source = new ManifestShardSource(indexStore); const basis = manifestBasis({ livenessRoots: new Map([ - [sourceMetaPath, sourceMetaOid], - [neighborMetaPath, neighborMetaOid], - [unrelatedMetaPath, 'deadbeef'], + [sourceMetaPath, sourceMetaHandle], + [neighborMetaPath, neighborMetaHandle], + [unrelatedMetaPath, unrelatedMetaHandle], ]), - propertyRoots: new Map(), - outgoingAdjacencyRoots: new Map([[outgoingPath, outgoingOid]]), - edgeFactRoots: new Map([[labelsPath, labelsOid]]), + propertyRoots: new Map(), + outgoingAdjacencyRoots: new Map([[outgoingPath, outgoingHandle]]), + edgeFactRoots: new Map([[labelsPath, labelsHandle]]), }); const reader = new CheckpointShardFactReader({ source }); @@ -114,25 +236,32 @@ describe('CheckpointShardFactReader manifest-backed routing', () => { }], resumeCursors: [null], checkpointIndexShards: [ - { path: sourceMetaPath, oid: sourceMetaOid }, - { path: neighborMetaPath, oid: neighborMetaOid }, - { path: outgoingPath, oid: outgoingOid }, - { path: labelsPath, oid: labelsOid }, + { path: sourceMetaPath, oid: sourceMetaHandle.toString() }, + { path: neighborMetaPath, oid: neighborMetaHandle.toString() }, + { path: outgoingPath, oid: outgoingHandle.toString() }, + { path: labelsPath, oid: labelsHandle.toString() }, ].sort((left, right) => left.path.localeCompare(right.path)), }); + expect([...new Set(indexStore.openedShardHandles)].sort()).toEqual([ + sourceMetaHandle.toString(), + neighborMetaHandle.toString(), + outgoingHandle.toString(), + labelsHandle.toString(), + ].sort()); + expect(indexStore.openedShardHandles).not.toContain(unrelatedMetaHandle.toString()); + expect(indexStore.decodedShardHandles).toEqual([]); }); }); class ManifestShardSource extends CheckpointTailOpticSource { readonly graphName = 'manifest-backed-shard-reader-test'; - readonly _persistence: CorePersistence; readonly _codec: CodecPort = defaultCodec; - readonly _blobStorage: BlobStoragePort | null = null; - readonly _commitMessageCodec: CommitMessageCodecPort = DEFAULT_COMMIT_MESSAGE_CODEC; + readonly _checkpointStore = new InMemoryCheckpointStore(); + readonly _indexStore: MockIndexStorage; - constructor(persistence: CorePersistence) { + constructor(indexStore: MockIndexStorage) { super(); - this._persistence = persistence; + this._indexStore = indexStore; } discoverWriters(): Promise { @@ -160,17 +289,31 @@ class ManifestShardSource extends CheckpointTailOpticSource { } } +class NonErrorDecodeIndexStorage extends MockIndexStorage { + override decodeShard(): Promise { + return Promise.reject('decode-failure'); + } +} + +class NonErrorOpenIndexStorage extends MockIndexStorage { + override async *openShard(): AsyncIterable { + yield await Promise.reject('open-failure'); + } +} + +type HandleMap = Map; + function manifestBasis(options: { - readonly livenessRoots: Map; - readonly propertyRoots: Map; - readonly outgoingAdjacencyRoots?: Map; - readonly incomingAdjacencyRoots?: Map; - readonly edgeFactRoots?: Map; + readonly livenessRoots: HandleMap; + readonly propertyRoots: HandleMap; + readonly outgoingAdjacencyRoots?: HandleMap; + readonly incomingAdjacencyRoots?: HandleMap; + readonly edgeFactRoots?: HandleMap; }): CheckpointTailIndexBasis { - const frontier = new Map([['writer-a', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']]); - const outgoingAdjacencyRoots = options.outgoingAdjacencyRoots ?? new Map(); - const incomingAdjacencyRoots = options.incomingAdjacencyRoots ?? new Map(); - const edgeFactRoots = options.edgeFactRoots ?? new Map(); + const frontier = new Map([['writer-a', 'b'.repeat(40)]]); + const outgoingAdjacencyRoots = options.outgoingAdjacencyRoots ?? new Map(); + const incomingAdjacencyRoots = options.incomingAdjacencyRoots ?? new Map(); + const edgeFactRoots = options.edgeFactRoots ?? new Map(); const shardCount = Math.max( 1, options.livenessRoots.size @@ -191,26 +334,11 @@ function manifestBasis(options: { appliedVersionVector: new Map([['writer-a', 1]]), basisIdentity: 'basis:manifest-backed-shard-reader-test', semanticReadingIdentity: 'reading:manifest-backed-shard-reader-test:node-property', - livenessRoots: new CheckpointBasisShardRootMap({ - family: 'node-liveness', - roots: options.livenessRoots, - }), - propertyRoots: new CheckpointBasisShardRootMap({ - family: 'node-property', - roots: options.propertyRoots, - }), - outgoingAdjacencyRoots: new CheckpointBasisShardRootMap({ - family: 'outgoing-adjacency', - roots: outgoingAdjacencyRoots, - }), - incomingAdjacencyRoots: new CheckpointBasisShardRootMap({ - family: 'incoming-adjacency', - roots: incomingAdjacencyRoots, - }), - edgeFactRoots: new CheckpointBasisShardRootMap({ - family: 'edge-fact', - roots: edgeFactRoots, - }), + livenessRoots: rootMap('node-liveness', options.livenessRoots), + propertyRoots: rootMap('node-property', options.propertyRoots), + outgoingAdjacencyRoots: rootMap('outgoing-adjacency', outgoingAdjacencyRoots), + incomingAdjacencyRoots: rootMap('incoming-adjacency', incomingAdjacencyRoots), + edgeFactRoots: rootMap('edge-fact', edgeFactRoots), provenancePosture: CheckpointBasisSupportPosture.unavailable('not-indexed'), contentAnchorPosture: CheckpointBasisSupportPosture.unavailable('not-indexed'), shardGeometry: new CheckpointBasisShardGeometry({ @@ -222,11 +350,30 @@ function manifestBasis(options: { chunking: new CheckpointBasisChunking({ maxFactsPerShard: shardCount, chunkCount: 1 }), completeness: CheckpointBasisCompleteness.complete(), }), - indexOids: {}, - propOids: {}, + indexHandles: handleRecord( + options.livenessRoots, + outgoingAdjacencyRoots, + incomingAdjacencyRoots, + edgeFactRoots, + ), + propHandles: handleRecord(options.propertyRoots), }; } +function rootMap( + family: 'node-liveness' | 'node-property' | 'outgoing-adjacency' | 'incoming-adjacency' | 'edge-fact', + handles: HandleMap, +): CheckpointBasisShardRootMap { + return new CheckpointBasisShardRootMap({ + family, + roots: new Map([...handles].map(([path, handle]) => [path, handle.toString()])), + }); +} + +function handleRecord(...maps: readonly HandleMap[]): Readonly> { + return Object.freeze(Object.fromEntries(maps.flatMap((handles) => [...handles]))); +} + function metaShardBytes(nodeId: string): Uint8Array { const builder = new LogicalBitmapIndexBuilder(); builder.registerNode(nodeId); diff --git a/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts b/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts index cba41795..f7e47ce3 100644 --- a/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts +++ b/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts @@ -1,120 +1,62 @@ import { describe, expect, it } from 'vitest'; -import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; import CheckpointTailBasisVerifier from '../../../../../src/domain/services/optic/CheckpointTailBasisVerifier.ts'; import CheckpointTailOpticSource, { type CheckpointTailCheckpointFrontier, type CheckpointTailPatchEntry, } from '../../../../../src/domain/services/optic/CheckpointTailOpticSource.ts'; -import type { CorePersistence } from '../../../../../src/domain/types/WarpPersistence.ts'; -import type TreeEntryLimit from '../../../../../src/domain/tree/TreeEntryLimit.ts'; -import TreeEntryMissing from '../../../../../src/domain/tree/TreeEntryMissing.ts'; -import type TreeEntryPath from '../../../../../src/domain/tree/TreeEntryPath.ts'; -import type TreeEntryPrefixBatch from '../../../../../src/domain/tree/TreeEntryPrefixBatch.ts'; -import GitTimelineHistoryAdapter from '../../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; -import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; -import { - DEFAULT_COMMIT_MESSAGE_CODEC, - encodeCheckpointMessage, -} from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import { CURRENT_CHECKPOINT_SCHEMA } from '../../../../../src/domain/services/state/checkpointHelpers.ts'; -import type BlobStoragePort from '../../../../../src/ports/BlobStoragePort.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; +import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; +import type { + CheckpointBasis, + CheckpointData, +} from '../../../../../src/ports/CheckpointStorePort.ts'; import type CodecPort from '../../../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../../../src/ports/CommitMessageCodecPort.ts'; -import type { TreeEntryProbeResult } from '../../../../../src/ports/TreeEntryProbePort.ts'; -import { createGitRepo } from '../../../../helpers/warpGraphTestUtils.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; +import MockIndexStorage from '../../../../helpers/MockIndexStorage.ts'; const GRAPH_NAME = 'checkpoint-tail-basis-verifier'; -const FRONTIER_OID = '1'.repeat(40); -const INDEX_TREE_OID = '2'.repeat(40); -const INDEX_SHARD_OID = '3'.repeat(40); -const STATE_HASH = '4'.repeat(64); -const CHECKPOINT_FRONTIER_OID = '5'.repeat(40); -const LARGE_TREE_UNRELATED_ENTRY_COUNT = 4096; - -class ForbiddenTreeMapReadError extends Error { - constructor(treeOid: string) { - super(`CheckpointTailBasisVerifier attempted readTreeOids(${treeOid})`); - } -} - -class ProbeFixtureAdapter extends InMemoryGraphAdapter { - readonly readTreeOidsCalls: string[] = []; - readonly exactProbePaths: string[] = []; - readonly prefixProbePaths: string[] = []; - - override async readTreeOids(treeOid: string): Promise> { - this.readTreeOidsCalls.push(treeOid); - throw new ForbiddenTreeMapReadError(treeOid); - } - - override async readTreeEntryOid( - treeOid: string, - path: TreeEntryPath, - ): Promise { - this.exactProbePaths.push(path.value); - return await super.readTreeEntryOid(treeOid, path); - } +const CHECKPOINT_SHA = '1'.repeat(40); - override async readTreeEntryPrefix( - treeOid: string, - prefix: TreeEntryPath, - limit: TreeEntryLimit, - ): Promise { - this.prefixProbePaths.push(prefix.value); - return await super.readTreeEntryPrefix(treeOid, prefix, limit); - } -} - -class GitPrefixFallbackProbeAdapter extends GitTimelineHistoryAdapter { - readonly readTreeOidsCalls: string[] = []; - readonly exactProbePaths: string[] = []; - readonly prefixProbePaths: string[] = []; - readonly prefixProbeEntryPaths: string[] = []; +class BasisCheckpointStore extends InMemoryCheckpointStore { + readonly loadBasisCalls: string[] = []; + readonly loadCheckpointCalls: string[] = []; + private readonly _result: CheckpointBasis | Error; - override async readTreeOids(treeOid: string): Promise> { - this.readTreeOidsCalls.push(treeOid); - throw new ForbiddenTreeMapReadError(treeOid); + constructor(result: CheckpointBasis | Error) { + super(); + this._result = result; } - override async readTreeEntryOid( - treeOid: string, - path: TreeEntryPath, - ): Promise { - this.exactProbePaths.push(path.value); - if (path.value === 'index') { - return new TreeEntryMissing(path); + override async loadBasis(checkpointSha: string): Promise { + this.loadBasisCalls.push(checkpointSha); + if (this._result instanceof Error) { + throw this._result; } - return await super.readTreeEntryOid(treeOid, path); + return this._result; } - override async readTreeEntryPrefix( - treeOid: string, - prefix: TreeEntryPath, - limit: TreeEntryLimit, - ): Promise { - this.prefixProbePaths.push(prefix.value); - const batch = await super.readTreeEntryPrefix(treeOid, prefix, limit); - this.prefixProbeEntryPaths.push(...batch.entries.map((entry) => entry.path.value)); - return batch; + override async loadCheckpoint(checkpointSha: string): Promise { + this.loadCheckpointCalls.push(checkpointSha); + throw new Error('basis verification must not load checkpoint state'); } } class TestCheckpointTailOpticSource extends CheckpointTailOpticSource { readonly graphName = GRAPH_NAME; - readonly _persistence: CorePersistence; readonly _codec: CodecPort = defaultCodec; - readonly _blobStorage: BlobStoragePort | null = null; - readonly _commitMessageCodec: CommitMessageCodecPort = DEFAULT_COMMIT_MESSAGE_CODEC; + readonly _indexStore = new MockIndexStorage(); + readonly _checkpointStore: BasisCheckpointStore; private readonly _checkpointSha: string | null; constructor(options: { - readonly persistence: CorePersistence; - readonly checkpointSha: string | null; + readonly checkpointSha?: string | null; + readonly result: CheckpointBasis | Error; }) { super(); - this._persistence = options.persistence; - this._checkpointSha = options.checkpointSha; + this._checkpointSha = options.checkpointSha === undefined ? CHECKPOINT_SHA : options.checkpointSha; + this._checkpointStore = new BasisCheckpointStore(options.result); } discoverWriters(): Promise { @@ -143,176 +85,75 @@ class TestCheckpointTailOpticSource extends CheckpointTailOpticSource { } describe('CheckpointTailBasisVerifier', () => { - it('verifies basis evidence through tree-entry probes when readTreeOids is forbidden', async () => { - const fixture = await createFixture([ - `100644 blob ${FRONTIER_OID}\tfrontier.cbor`, - `040000 tree ${INDEX_TREE_OID}\tindex`, - ]); + it('verifies bounded support through loadBasis without loading state or shards', async () => { + const source = new TestCheckpointTailOpticSource({ result: validBasis() }); - const verification = await new CheckpointTailBasisVerifier({ - source: fixture.source, - }).verify(); + await expect(new CheckpointTailBasisVerifier({ source }).verify()).resolves.toEqual({ + checkpointSha: CHECKPOINT_SHA, + }); - expect(verification.checkpointSha).toBe(fixture.checkpointSha); - expect(fixture.persistence.readTreeOidsCalls).toEqual([]); - expect(fixture.persistence.exactProbePaths).toEqual(['frontier.cbor', 'index']); - expect(fixture.persistence.prefixProbePaths).toEqual([]); + expect(source._checkpointStore.loadBasisCalls).toEqual([CHECKPOINT_SHA]); + expect(source._checkpointStore.loadCheckpointCalls).toEqual([]); + expect(source._indexStore.openedShardHandles).toEqual([]); + expect(source._indexStore.decodedShardHandles).toEqual([]); }); - it('fails closed for missing frontier evidence through the probe path', async () => { - const fixture = await createFixture([ - `040000 tree ${INDEX_TREE_OID}\tindex`, - ]); + it('fails closed when no checkpoint is published', async () => { + const source = new TestCheckpointTailOpticSource({ + checkpointSha: null, + result: validBasis(), + }); - await expect(new CheckpointTailBasisVerifier({ - source: fixture.source, - }).verify()).rejects.toMatchObject({ + await expect(new CheckpointTailBasisVerifier({ source }).verify()).rejects.toMatchObject({ code: 'E_OPTIC_NO_BOUNDED_BASIS', - context: { - graphName: GRAPH_NAME, - reason: 'checkpoint-missing-frontier', - }, + context: { graphName: GRAPH_NAME, reason: 'missing-checkpoint' }, }); - expect(fixture.persistence.readTreeOidsCalls).toEqual([]); - expect(fixture.persistence.exactProbePaths).toEqual(['frontier.cbor']); + expect(source._checkpointStore.loadBasisCalls).toEqual([]); }); - it('fails closed for missing index evidence through the probe path', async () => { - const fixture = await createFixture([ - `100644 blob ${FRONTIER_OID}\tfrontier.cbor`, - ]); + it('fails closed for a checkpoint from an obsolete schema', async () => { + const source = new TestCheckpointTailOpticSource({ + result: validBasis({ schema: CURRENT_CHECKPOINT_SCHEMA - 1 }), + }); - await expect(new CheckpointTailBasisVerifier({ - source: fixture.source, - }).verify()).rejects.toMatchObject({ + await expect(new CheckpointTailBasisVerifier({ source }).verify()).rejects.toMatchObject({ code: 'E_OPTIC_NO_BOUNDED_BASIS', - context: { - graphName: GRAPH_NAME, - reason: 'checkpoint-missing-index-shards', - }, + context: { graphName: GRAPH_NAME, reason: 'checkpoint-without-index-tree' }, }); - expect(fixture.persistence.readTreeOidsCalls).toEqual([]); - expect(fixture.persistence.exactProbePaths).toEqual(['frontier.cbor', 'index']); - expect(fixture.persistence.prefixProbePaths).toEqual(['index/']); }); - it('accepts bounded prefix evidence when the index subtree entry is absent', async () => { - const fixture = await createFixture([ - `100644 blob ${FRONTIER_OID}\tfrontier.cbor`, - `100644 blob ${INDEX_SHARD_OID}\tindex/shard-000.cbor`, - ]); - - const verification = await new CheckpointTailBasisVerifier({ - source: fixture.source, - }).verify(); - - expect(verification.checkpointSha).toBe(fixture.checkpointSha); - expect(fixture.persistence.readTreeOidsCalls).toEqual([]); - expect(fixture.persistence.exactProbePaths).toEqual(['frontier.cbor', 'index']); - expect(fixture.persistence.prefixProbePaths).toEqual(['index/']); - }); - - it('accepts bounded prefix evidence through Git-backed tree-entry probes', async () => { - const repo = await createGitRepo('checkpoint-tail-basis-prefix-fallback'); - try { - const persistence = new GitPrefixFallbackProbeAdapter({ plumbing: repo.plumbing }); - const frontierOid = await persistence.writeBlob('frontier'); - const shardOid = await persistence.writeBlob('index-shard'); - const indexTreeOid = await persistence.writeTree([ - `100644 blob ${shardOid}\tshard-000.cbor`, - ]); - const checkpointIndexOid = await persistence.writeTree([ - `100644 blob ${frontierOid}\tfrontier.cbor`, - `040000 tree ${indexTreeOid}\tindex`, - ]); - const checkpointSha = await persistence.commitNodeWithTree({ - treeOid: persistence.emptyTree, - parents: [], - message: encodeCheckpointMessage({ - graph: GRAPH_NAME, - stateHash: STATE_HASH, - frontierOid: CHECKPOINT_FRONTIER_OID, - indexOid: checkpointIndexOid, - schema: CURRENT_CHECKPOINT_SCHEMA, - }), - }); - const source = new TestCheckpointTailOpticSource({ - persistence, - checkpointSha, - }); - - const verification = await new CheckpointTailBasisVerifier({ - source, - }).verify(); + it('fails closed when the checkpoint basis has no index shards', async () => { + const source = new TestCheckpointTailOpticSource({ + result: validBasis({ indexShardHandles: Object.freeze({}) }), + }); - expect(verification.checkpointSha).toBe(checkpointSha); - expect(persistence.readTreeOidsCalls).toEqual([]); - expect(persistence.exactProbePaths).toEqual(['frontier.cbor', 'index']); - expect(persistence.prefixProbePaths).toEqual(['index/']); - expect(persistence.prefixProbeEntryPaths).toEqual(['index/shard-000.cbor']); - } finally { - await repo.cleanup(); - } + await expect(new CheckpointTailBasisVerifier({ source }).verify()).rejects.toMatchObject({ + code: 'E_OPTIC_NO_BOUNDED_BASIS', + context: { graphName: GRAPH_NAME, reason: 'checkpoint-missing-index-shards' }, + }); }); - it('proves a large checkpoint tree through only requested basis evidence probes', async () => { - const unrelatedEntries = largeUnrelatedTreeEntries(); - const fixture = await createFixture([ - `100644 blob ${FRONTIER_OID}\tfrontier.cbor`, - `040000 tree ${INDEX_TREE_OID}\tindex`, - ...unrelatedEntries, - ]); - - const verification = await new CheckpointTailBasisVerifier({ - source: fixture.source, - }).verify(); + it('maps checkpoint-store failures to unavailable bounded support', async () => { + const source = new TestCheckpointTailOpticSource({ + result: new Error('checkpoint publication is unavailable'), + }); - expect(verification.checkpointSha).toBe(fixture.checkpointSha); - expect(unrelatedEntries).toHaveLength(LARGE_TREE_UNRELATED_ENTRY_COUNT); - expect(fixture.persistence.readTreeOidsCalls).toEqual([]); - expect(fixture.persistence.exactProbePaths).toEqual(['frontier.cbor', 'index']); - expect(fixture.persistence.prefixProbePaths).toEqual([]); - expect(fixture.persistence.exactProbePaths).not.toContain('state/shard-0000.cbor'); + await expect(new CheckpointTailBasisVerifier({ source }).verify()).rejects.toMatchObject({ + code: 'E_OPTIC_NO_BOUNDED_BASIS', + context: { graphName: GRAPH_NAME, reason: 'checkpoint-basis-unavailable' }, + }); }); }); -async function createFixture(indexTreeEntries: readonly string[]): Promise<{ - readonly persistence: ProbeFixtureAdapter; - readonly source: TestCheckpointTailOpticSource; - readonly checkpointSha: string; -}> { - const persistence = new ProbeFixtureAdapter(); - const indexOid = await persistence.writeTree([...indexTreeEntries]); - const checkpointSha = await persistence.commitNodeWithTree({ - treeOid: persistence.emptyTree, - parents: [], - message: encodeCheckpointMessage({ - graph: GRAPH_NAME, - stateHash: STATE_HASH, - frontierOid: CHECKPOINT_FRONTIER_OID, - indexOid, - schema: CURRENT_CHECKPOINT_SCHEMA, +function validBasis(overrides: Partial = {}): CheckpointBasis { + return { + checkpointSha: CHECKPOINT_SHA, + stateHash: '2'.repeat(64), + schema: CURRENT_CHECKPOINT_SCHEMA, + frontier: new Map([['writer-a', '3'.repeat(40)]]), + indexShardHandles: Object.freeze({ + 'meta_00.cbor': new AssetHandle('checkpoint-index:meta-00'), }), - }); - const source = new TestCheckpointTailOpticSource({ - persistence, - checkpointSha, - }); - return Object.freeze({ persistence, source, checkpointSha }); -} - -function largeUnrelatedTreeEntries(): string[] { - const entries: string[] = []; - for (let index = 0; index < LARGE_TREE_UNRELATED_ENTRY_COUNT; index += 1) { - entries.push(`100644 blob ${fixtureOid(index)}\tstate/shard-${fixtureShardIndex(index)}.cbor`); - } - return entries; -} - -function fixtureOid(index: number): string { - return (index + 6).toString(16).padStart(40, '0'); -} - -function fixtureShardIndex(index: number): string { - return String(index).padStart(4, '0'); + ...overrides, + }; } diff --git a/test/unit/domain/services/optic/CheckpointTailReadIdentityBuilder.test.ts b/test/unit/domain/services/optic/CheckpointTailReadIdentityBuilder.test.ts index 70f0a544..15089de2 100644 --- a/test/unit/domain/services/optic/CheckpointTailReadIdentityBuilder.test.ts +++ b/test/unit/domain/services/optic/CheckpointTailReadIdentityBuilder.test.ts @@ -62,8 +62,8 @@ function indexBasis(): CheckpointTailIndexBasis { schema: 5, frontier: new Map([['writer-a', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']]), manifest: manifest(), - indexOids: {}, - propOids: {}, + indexHandles: {}, + propHandles: {}, }; } diff --git a/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts b/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts index 0210bb4b..f9c018e0 100644 --- a/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts +++ b/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts @@ -7,82 +7,105 @@ import CheckpointTailOpticSource, { type CheckpointTailPatchEntry, } from '../../../../../src/domain/services/optic/CheckpointTailOpticSource.ts'; import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; -import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; -import type BlobStoragePort from '../../../../../src/ports/BlobStoragePort.ts'; import type CodecPort from '../../../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../../../src/ports/CommitMessageCodecPort.ts'; -import type { CorePersistence } from '../../../../../src/domain/types/WarpPersistence.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; +import MockIndexStorage from '../../../../helpers/MockIndexStorage.ts'; class TestCheckpointTailOpticSource extends CheckpointTailOpticSource { readonly graphName = 'events'; - readonly _persistence: CorePersistence = new InMemoryGraphAdapter(); readonly _codec: CodecPort = defaultCodec; - readonly _blobStorage: BlobStoragePort | null = null; - readonly _commitMessageCodec: CommitMessageCodecPort = DEFAULT_COMMIT_MESSAGE_CODEC; + readonly _checkpointStore = new InMemoryCheckpointStore(); + readonly _indexStore = new MockIndexStorage(); + readonly chainCalls: Array<{ readonly tipSha: string; readonly stopAtSha: string | null }> = []; + readonly validationCalls: Array<{ readonly writerId: string; readonly incomingSha: string }> = []; discoverWriters(): Promise { - return Promise.resolve([]); + return Promise.resolve(['live-writer']); } _readCheckpointSha(): Promise { - return Promise.resolve('checkpoint-sha'); + return Promise.resolve('live-checkpoint-sha'); } - _loadPatchChainFromSha(): Promise { + _loadPatchChainFromSha( + tipSha: string, + stopAtSha: string | null = null, + ): Promise { + this.chainCalls.push({ tipSha, stopAtSha }); return Promise.resolve([]); } _loadWriterPatches(): Promise { - return Promise.resolve([]); + throw new Error('coordinate reads must use the captured frontier'); } _validatePatchAgainstCheckpoint( - _writerId: string, - _incomingSha: string, - _checkpoint: CheckpointTailCheckpointFrontier | null | undefined + writerId: string, + incomingSha: string, + _checkpoint: CheckpointTailCheckpointFrontier | null | undefined, ): Promise { + this.validationCalls.push({ writerId, incomingSha }); return Promise.resolve(); } } -class MalformedPersistenceSource extends TestCheckpointTailOpticSource { - // @ts-expect-error exercising runtime source-port validation for JavaScript callers - override readonly _persistence: CorePersistence = { - showNode: () => Promise.resolve('checkpoint'), - }; -} - -class MalformedBlobStorageSource extends TestCheckpointTailOpticSource { - // @ts-expect-error exercising runtime source-port validation for JavaScript callers - override readonly _blobStorage: BlobStoragePort | null = { - store: () => Promise.resolve('storage-oid'), - }; -} +describe('CoordinateCheckpointTailOpticSource', () => { + it('captures a sorted frontier and reuses the source semantic ports', async () => { + const source = new TestCheckpointTailOpticSource(); + const frontier = new Map([ + ['writer-b', 'patch-b'], + ['writer-a', 'patch-a'], + ]); + const coordinate = new CoordinateCheckpointTailOpticSource({ + source, + checkpointSha: 'coordinate-checkpoint-sha', + frontier, + }); + + frontier.set('writer-c', 'patch-c'); + + await expect(coordinate.discoverWriters()).resolves.toEqual(['writer-a', 'writer-b']); + await expect(coordinate._readCheckpointSha()).resolves.toBe('coordinate-checkpoint-sha'); + expect(coordinate.graphName).toBe(source.graphName); + expect(coordinate._codec).toBe(source._codec); + expect(coordinate._checkpointStore).toBe(source._checkpointStore); + expect(coordinate._indexStore).toBe(source._indexStore); + }); -class MalformedCodecSource extends TestCheckpointTailOpticSource { - // @ts-expect-error exercising runtime source-port validation for JavaScript callers - override readonly _codec: CodecPort = { - encode: () => new Uint8Array(), - }; -} + it('loads writer patches from the captured coordinate tip', async () => { + const source = new TestCheckpointTailOpticSource(); + const coordinate = new CoordinateCheckpointTailOpticSource({ + source, + checkpointSha: 'coordinate-checkpoint-sha', + frontier: new Map([['writer-a', 'patch-a']]), + }); + + await coordinate._loadWriterPatches('writer-a', 'checkpoint-parent'); + await expect(coordinate._loadWriterPatches('missing-writer')).resolves.toEqual([]); + await expect(coordinate._loadWriterPatches('writer-a', 'patch-a')).resolves.toEqual([]); + + expect(source.chainCalls).toEqual([ + { tipSha: 'patch-a', stopAtSha: 'checkpoint-parent' }, + ]); + }); -class MalformedCommitMessageCodecSource extends TestCheckpointTailOpticSource { - // @ts-expect-error exercising runtime source-port validation for JavaScript callers - override readonly _commitMessageCodec: CommitMessageCodecPort = { - decodeCheckpoint: () => ({ - kind: 'checkpoint', - graph: 'events', - stateHash: 'state', - frontierOid: 'frontier', - indexOid: 'index', - schema: 5, - checkpointVersion: null, - }), - }; -} + it('delegates explicit chain loads and checkpoint validation', async () => { + const source = new TestCheckpointTailOpticSource(); + const coordinate = new CoordinateCheckpointTailOpticSource({ + source, + checkpointSha: 'coordinate-checkpoint-sha', + frontier: new Map(), + }); + + await coordinate._loadPatchChainFromSha('tip-sha', 'stop-sha'); + await coordinate._validatePatchAgainstCheckpoint('writer-a', 'incoming-sha', null); + + expect(source.chainCalls).toEqual([{ tipSha: 'tip-sha', stopAtSha: 'stop-sha' }]); + expect(source.validationCalls).toEqual([ + { writerId: 'writer-a', incomingSha: 'incoming-sha' }, + ]); + }); -describe('CoordinateCheckpointTailOpticSource', () => { it('rejects malformed constructor frontier before copying entries', () => { expect( () => @@ -91,17 +114,7 @@ describe('CoordinateCheckpointTailOpticSource', () => { checkpointSha: 'checkpoint-sha', // @ts-expect-error exercising runtime validation for JavaScript callers frontier: 'not-a-frontier', - }) - ).toThrow(WarpError); - - expect( - () => - new CoordinateCheckpointTailOpticSource({ - source: new TestCheckpointTailOpticSource(), - checkpointSha: 'checkpoint-sha', - // @ts-expect-error exercising runtime validation for JavaScript callers - frontier: 'not-a-frontier', - }) + }), ).toThrow('Coordinate checkpoint-tail optic source requires a frontier Map'); }); @@ -112,7 +125,7 @@ describe('CoordinateCheckpointTailOpticSource', () => { source: new TestCheckpointTailOpticSource(), checkpointSha: ' ', frontier: new Map([['writer-1', 'patch-sha']]), - }) + }), ).toThrow('Coordinate checkpoint-tail optic source requires non-empty identity fields'); expect( @@ -121,27 +134,37 @@ describe('CoordinateCheckpointTailOpticSource', () => { source: new TestCheckpointTailOpticSource(), checkpointSha: 'checkpoint-sha', frontier: new Map([['writer-1', ' ']]), - }) + }), ).toThrow('Coordinate checkpoint-tail optic source requires non-empty identity fields'); }); - it('rejects malformed source ports at the constructor boundary', () => { - const sources = [ - new MalformedPersistenceSource(), - new MalformedBlobStorageSource(), - new MalformedCodecSource(), - new MalformedCommitMessageCodecSource(), - ] as const; - - for (const source of sources) { + it('rejects malformed semantic source ports at the constructor boundary', () => { + const malformedCheckpointStore = new TestCheckpointTailOpticSource(); + Object.defineProperty(malformedCheckpointStore, '_checkpointStore', { + value: { resolveHead: () => Promise.resolve(null) }, + }); + const malformedIndexStore = new TestCheckpointTailOpticSource(); + Object.defineProperty(malformedIndexStore, '_indexStore', { + value: { openShard: () => emptyBytes() }, + }); + const malformedCodec = new TestCheckpointTailOpticSource(); + Object.defineProperty(malformedCodec, '_codec', { + value: { encode: () => new Uint8Array() }, + }); + + for (const source of [malformedCheckpointStore, malformedIndexStore, malformedCodec]) { expect( () => new CoordinateCheckpointTailOpticSource({ source, checkpointSha: 'checkpoint-sha', frontier: new Map([['writer-1', 'patch-sha']]), - }) - ).toThrow('Coordinate checkpoint-tail optic source requires a checkpoint-tail source'); + }), + ).toThrow(WarpError); } }); }); + +async function* emptyBytes(): AsyncIterable { + yield new Uint8Array(); +} diff --git a/test/unit/domain/services/optic/TraversalOptic.test.ts b/test/unit/domain/services/optic/TraversalOptic.test.ts index e82827cf..f0407659 100644 --- a/test/unit/domain/services/optic/TraversalOptic.test.ts +++ b/test/unit/domain/services/optic/TraversalOptic.test.ts @@ -11,15 +11,10 @@ import Optic from '../../../../../src/domain/services/optic/Optic.ts'; import OpticAperturePosture from '../../../../../src/domain/services/optic/OpticAperturePosture.ts'; import OpticBasisPosture from '../../../../../src/domain/services/optic/OpticBasisPosture.ts'; import OpticCoordinatePosture from '../../../../../src/domain/services/optic/OpticCoordinatePosture.ts'; -import { - DEFAULT_COMMIT_MESSAGE_CODEC, -} from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; -import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; -import type { CorePersistence } from '../../../../../src/domain/types/WarpPersistence.ts'; -import type BlobStoragePort from '../../../../../src/ports/BlobStoragePort.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; +import MockIndexStorage from '../../../../helpers/MockIndexStorage.ts'; import type CodecPort from '../../../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../../../src/ports/CommitMessageCodecPort.ts'; describe('TraversalOptic', () => { it('rejects an empty start node id at construction', () => { @@ -73,10 +68,9 @@ function traversalOptic(supportRule: 'global-discovery-refused' | 'traversal-win class TestCheckpointTailOpticSource extends CheckpointTailOpticSource { readonly graphName = 'traversal-optic-support-rule'; - readonly _persistence: CorePersistence = new InMemoryGraphAdapter(); readonly _codec: CodecPort = defaultCodec; - readonly _blobStorage: BlobStoragePort | null = null; - readonly _commitMessageCodec: CommitMessageCodecPort = DEFAULT_COMMIT_MESSAGE_CODEC; + readonly _checkpointStore = new InMemoryCheckpointStore(); + readonly _indexStore = new MockIndexStorage(); discoverWriters(): Promise { return Promise.resolve([]); diff --git a/test/unit/domain/services/optic/WorldlineOptic.test.ts b/test/unit/domain/services/optic/WorldlineOptic.test.ts index 88ef8267..5597220e 100644 --- a/test/unit/domain/services/optic/WorldlineOptic.test.ts +++ b/test/unit/domain/services/optic/WorldlineOptic.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest'; import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; -import type { CorePersistence } from '../../../../../src/domain/types/WarpPersistence.ts'; -import { - DEFAULT_COMMIT_MESSAGE_CODEC, -} from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import QueryError from '../../../../../src/domain/errors/QueryError.ts'; import CheckpointTailWitnessLocator from '../../../../../src/domain/services/optic/CheckpointTailWitnessLocator.ts'; import CheckpointTailOpticSource, { @@ -16,17 +12,15 @@ import NodePropertyOptic from '../../../../../src/domain/services/optic/NodeProp import Optic from '../../../../../src/domain/services/optic/Optic.ts'; import OpticCoordinatePosture from '../../../../../src/domain/services/optic/OpticCoordinatePosture.ts'; import WorldlineOptic from '../../../../../src/domain/services/optic/WorldlineOptic.ts'; -import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; -import type BlobStoragePort from '../../../../../src/ports/BlobStoragePort.ts'; +import InMemoryCheckpointStore from '../../../../helpers/InMemoryCheckpointStore.ts'; +import MockIndexStorage from '../../../../helpers/MockIndexStorage.ts'; import type CodecPort from '../../../../../src/ports/CodecPort.ts'; -import type CommitMessageCodecPort from '../../../../../src/ports/CommitMessageCodecPort.ts'; class TestCheckpointTailOpticSource extends CheckpointTailOpticSource { readonly graphName = 'worldline-optic-reification'; - readonly _persistence: CorePersistence = new InMemoryGraphAdapter(); readonly _codec: CodecPort = defaultCodec; - readonly _blobStorage: BlobStoragePort | null = null; - readonly _commitMessageCodec: CommitMessageCodecPort = DEFAULT_COMMIT_MESSAGE_CODEC; + readonly _checkpointStore = new InMemoryCheckpointStore(); + readonly _indexStore = new MockIndexStorage(); discoverWriters(): Promise { return Promise.resolve([]); diff --git a/test/unit/domain/services/pathKeyedTreeMaps.test.ts b/test/unit/domain/services/pathKeyedTreeMaps.test.ts index 738dbf4d..2f6deed6 100644 --- a/test/unit/domain/services/pathKeyedTreeMaps.test.ts +++ b/test/unit/domain/services/pathKeyedTreeMaps.test.ts @@ -1,39 +1,30 @@ import { describe, expect, it } from 'vitest'; -import { partitionShardOids } from '../../../../src/domain/services/MaterializedViewHelpers.ts'; -import { partitionTreeOids } from '../../../../src/domain/services/state/checkpointHelpers.ts'; -describe('path-keyed tree map helpers', () => { - it('partitions checkpoint tree paths without trusting object-member names', () => { - const result = partitionTreeOids(Object.fromEntries([ - ['__proto__', 'oid-root'], - ['constructor', 'oid-constructor'], - ['index/__proto__', 'oid-index-root'], - ['index/constructor', 'oid-index-constructor'], - ])); +import { partitionShardHandles } from '../../../../src/domain/services/MaterializedViewHelpers.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; - expect(Object.hasOwn(result.treeOids, '__proto__')).toBe(true); - expect(Object.hasOwn(result.treeOids, 'constructor')).toBe(true); - expect(Object.hasOwn(result.indexShardOids, '__proto__')).toBe(true); - expect(Object.hasOwn(result.indexShardOids, 'constructor')).toBe(true); - expect(result.treeOids['__proto__']).toBe('oid-root'); - expect(result.indexShardOids['__proto__']).toBe('oid-index-root'); - expect(Object.prototype).not.toHaveProperty('oid-root'); - }); +describe('path-keyed shard handle maps', () => { + it('partitions semantic shard handles without prototype side effects', () => { + const indexRoot = new AssetHandle('index:root'); + const indexConstructor = new AssetHandle('index:constructor'); + const propRoot = new AssetHandle('property:root'); + const propConstructor = new AssetHandle('property:constructor'); - it('partitions index shard paths without prototype side effects', () => { - const result = partitionShardOids(Object.fromEntries([ - ['__proto__', 'oid-index-root'], - ['constructor', 'oid-index-constructor'], - ['props___proto__', 'oid-prop-root'], - ['props_constructor', 'oid-prop-constructor'], + const result = partitionShardHandles(Object.fromEntries([ + ['__proto__', indexRoot], + ['constructor', indexConstructor], + ['props___proto__', propRoot], + ['props_constructor', propConstructor], ])); - expect(Object.hasOwn(result.indexOids, '__proto__')).toBe(true); - expect(Object.hasOwn(result.indexOids, 'constructor')).toBe(true); - expect(Object.hasOwn(result.propOids, 'props___proto__')).toBe(true); - expect(Object.hasOwn(result.propOids, 'props_constructor')).toBe(true); - expect(result.indexOids['__proto__']).toBe('oid-index-root'); - expect(result.propOids['props___proto__']).toBe('oid-prop-root'); - expect(Object.prototype).not.toHaveProperty('oid-index-root'); + expect(Object.hasOwn(result.indexHandles, '__proto__')).toBe(true); + expect(Object.hasOwn(result.indexHandles, 'constructor')).toBe(true); + expect(Object.hasOwn(result.propHandles, 'props___proto__')).toBe(true); + expect(Object.hasOwn(result.propHandles, 'props_constructor')).toBe(true); + expect(result.indexHandles['__proto__']).toBe(indexRoot); + expect(result.indexHandles.constructor).toBe(indexConstructor); + expect(result.propHandles['props___proto__']).toBe(propRoot); + expect(Object.getPrototypeOf(result.indexHandles)).toBe(Object.prototype); + expect(Object.getPrototypeOf(result.propHandles)).toBe(Object.prototype); }); }); diff --git a/test/unit/domain/services/strand/StrandService.test.ts b/test/unit/domain/services/strand/StrandService.test.ts index 086323e5..e6b4e0d1 100644 --- a/test/unit/domain/services/strand/StrandService.test.ts +++ b/test/unit/domain/services/strand/StrandService.test.ts @@ -11,10 +11,15 @@ import { STRAND_COUNTERFACTUAL_REASON, } from '../../../../../src/domain/services/strand/strandShared.ts'; import StrandError from '../../../../../src/domain/errors/StrandError.ts'; +import PatchPublicationConflictError from '../../../../../src/domain/errors/PatchPublicationConflictError.ts'; import { textEncode, textDecode } from '../../../../../src/domain/utils/bytes.ts'; import { createEmptyState } from '../../../../../src/domain/services/JoinReducer.ts'; +import AssetHandle from '../../../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../../../src/domain/storage/BundleHandle.ts'; import type PatchType from '../../../../../src/domain/types/Patch.ts'; import type { StrandDescriptor as StrandDescriptorType } from '../../../../../src/domain/services/strand/strandTypes.ts'; +import InMemoryBlobStorageAdapter from '../../../../helpers/InMemoryBlobStorageAdapter.ts'; +import { testRetentionWitness } from '../../../../helpers/storageRetention.ts'; /** @typedef {import('../../../../../src/domain/utils/parseStrandBlob.ts').StrandDescriptor} ParsedStrandBlob */ /** @typedef {import('../../../../../src/domain/services/strand/strandTypes.ts').StrandQueuedIntent} StrandQueuedIntent */ @@ -68,7 +73,7 @@ const OVERLAY_KIND = (STRAND_OVERLAY_KIND); * _freezeQueuedIntent( * descriptor: StrandDescriptor, * intentQueue: StrandDescriptor['intentQueue'], - * builder: { build(): Patch, contentBlobs: unknown[] } + * builder: { build(): Patch, contentAssets: AssetHandle[] } * ): StrandQueuedIntent * }} PatchServicePrivate */ @@ -97,9 +102,9 @@ const OVERLAY_KIND = (STRAND_OVERLAY_KIND); * overlayId: string, * parentSha: string|null, * patch: Patch, - * contentBlobOids: string[], + * contentAssetHandles: string[], * lamport: number - * }): Promise<{ sha: string, patch: Patch }> + * }): Promise * }} StrandServicePrivate */ @@ -216,7 +221,7 @@ function makeQueuedIntent(overrides = {}) { patch: makePatch(), reads: [], writes: [], - contentBlobOids: [], + contentAssetHandles: [], ...overrides, }; } @@ -280,11 +285,7 @@ function storeDescriptor(descriptor) { * readRef: ReturnType, * updateRef: ReturnType, * deleteRef: ReturnType, - * writeBlob: ReturnType, - * readBlob: ReturnType, * listRefs: ReturnType, - * writeTree: ReturnType, - * commitNodeWithTree: ReturnType, * }, * _crypto: { hash: ReturnType }, * _maxObservedLamport: number, @@ -297,10 +298,10 @@ function storeDescriptor(descriptor) { * _cachedFrontier: Map|null, * _provenanceIndex: unknown, * _provenanceDegraded: boolean, - * _patchJournal: { writePatch(patch: Patch): Promise }|null, + * _strandStore: object, + * _patchJournal: object|null, * _logger: { info: ReturnType, warn: ReturnType, error: ReturnType }|null, - * _blobStorage: unknown, - * _patchBlobStorage: { store(data: Uint8Array, options: { slug: string }): Promise }|null, + * _assetStorage: InMemoryBlobStorageAdapter, * _codec: { encode: ReturnType }, * _onDeleteWithData: unknown, * _lastFrontier: Map, @@ -318,23 +319,95 @@ function createMockGraph() { oidCounter = 0; clockCounter = 0; + const strandStore = { + readDescriptor: vi.fn((graphName, strandId) => { + const ref = `refs/warp/${graphName}/strands/${strandId}`; + const revision = refs.get(ref); + if (revision === undefined) { + return Promise.resolve(null); + } + const descriptor = blobs.get(revision); + if (descriptor === undefined) { + return Promise.reject(new Error(`descriptor asset missing: ${revision}`)); + } + return Promise.resolve(descriptor.slice()); + }), + publishDescriptor: vi.fn((request) => { + const revision = nextOid(); + blobs.set(revision, request.descriptor.slice()); + refs.set(`refs/warp/${request.graphName}/strands/${request.strandId}`, revision); + const descriptorHandle = new AssetHandle(`asset:${revision}`); + return Promise.resolve({ + revision, + descriptorAsset: Object.freeze({ + handle: descriptorHandle, + size: request.descriptor.byteLength, + observedAt: '1970-01-01T00:00:00.000Z', + retention: Object.freeze({ + reachability: 'unanchored' as const, + protection: 'not-established' as const, + }), + }), + bundleHandle: new BundleHandle(`bundle:${revision}`), + retention: testRetentionWitness(revision), + }); + }), + listStrandIds: vi.fn((graphName) => { + const prefix = `refs/warp/${graphName}/strands/`; + return Promise.resolve([...refs.keys()] + .filter((ref) => ref.startsWith(prefix)) + .map((ref) => ref.slice(prefix.length)) + .filter((strandId) => !strandId.includes('/')) + .sort()); + }), + hasDescriptor: vi.fn((graphName, strandId) => Promise.resolve( + refs.has(`refs/warp/${graphName}/strands/${strandId}`), + )), + deleteDescriptor: vi.fn((graphName, strandId) => Promise.resolve( + refs.delete(`refs/warp/${graphName}/strands/${strandId}`), + )), + }; + + const patchJournal = { + appendPatch: vi.fn((request) => { + const currentHead = refs.get(request.targetRef) ?? null; + if (currentHead !== request.expectedHead) { + return Promise.reject(new PatchPublicationConflictError()); + } + const sha = nextOid(); + const parentEntries = request.parent === null + ? [] + : patchChains.get(request.parent) ?? []; + patchChains.set(sha, [...parentEntries, { patch: request.patch, sha }]); + refs.set(request.targetRef, sha); + return Promise.resolve({ + sha, + bundleHandle: new BundleHandle(`bundle:${sha}`), + stagedPatch: Object.freeze({ + handle: new AssetHandle(`asset:${sha}`), + size: 0, + observedAt: '1970-01-01T00:00:00.000Z', + retention: Object.freeze({ + reachability: 'unanchored' as const, + protection: 'not-established' as const, + }), + }), + retention: testRetentionWitness(sha), + }); + }), + readPatch: vi.fn(), + scanPatchRange: vi.fn(), + }; + return { _graphName: 'test-graph', _persistence: { readRef: vi.fn(async (ref) => refs.get(ref) ?? null), updateRef: vi.fn(async (ref, oid) => { refs.set(ref, oid); }), deleteRef: vi.fn(async (ref) => { refs.delete(ref); }), - writeBlob: vi.fn(async (/** @type {Uint8Array} */ data) => { - const oid = nextOid(); - blobs.set(oid, data); - return oid; - }), - readBlob: vi.fn(async (oid) => blobs.get(oid) ?? null), listRefs: vi.fn(async (prefix) => { return [...refs.keys()].filter((ref) => ref.startsWith(prefix)); }), - writeTree: vi.fn(async () => nextOid()), - commitNodeWithTree: vi.fn(async () => nextOid()), }, _crypto: { hash: vi.fn(async (_algo, data) => `sha256:${typeof data === 'string' ? data.slice(0, 16) : 'bytes'}`), @@ -348,15 +421,15 @@ function createMockGraph() { _cachedFrontier: (null), _provenanceIndex: null, _provenanceDegraded: true, - _patchJournal: /** @type {{ writePatch(patch: Patch): Promise }|null} */ (null), + _strandStore: strandStore, + _patchJournal: patchJournal, _logger: /** @type {{ info: ReturnType, warn: ReturnType, error: ReturnType }|null} */ (null), - _blobStorage: null, - _patchBlobStorage: /** @type {{ store(data: Uint8Array, options: { slug: string }): Promise }|null} */ (null), + _assetStorage: new InMemoryBlobStorageAdapter(), _commitMessageCodec: { - encodePatch: vi.fn(({ writer, lamport, patchOid }) => `patch:${writer}:${lamport}:${patchOid}`), + encodePatch: vi.fn(({ writer, lamport, patchHandle }) => `patch:${writer}:${lamport}:${String(patchHandle)}`), }, _codec: { encode: vi.fn((patch) => textEncode(JSON.stringify(patch))) }, - _onDeleteWithData: undefined, + _onDeleteWithData: 'reject', _lastFrontier: new Map(), _writerId: 'writer1', getFrontier: vi.fn(async () => new Map([['writer1', 'tip-sha-1']])), @@ -542,13 +615,13 @@ describe('StrandService', () => { reads: [undefined, 'node:b', 'node:a'], writes: [null, 'node:c', 'node:a'], })) as PatchType), - contentBlobs: [undefined, 'blob:b', 'blob:a'], + contentAssets: [new AssetHandle('blob:b'), new AssetHandle('blob:a')], }, ); expect(intent.reads).toEqual(['node:a', 'node:b']); expect(intent.writes).toEqual(['node:a', 'node:c']); - expect(intent.contentBlobOids).toEqual(['blob:a', 'blob:b']); + expect(intent.contentAssetHandles).toEqual(['blob:a', 'blob:b']); }); it('rejects non-string queued intent footprint entries at the patch service boundary', () => { @@ -566,7 +639,7 @@ describe('StrandService', () => { reads: [42], writes: [], })) as unknown as PatchType), - contentBlobs: [], + contentAssets: [], }, )).toThrow(StrandError); }); @@ -584,7 +657,7 @@ describe('StrandService', () => { reads: [' '], writes: [], }))) as PatchType), - contentBlobs: [], + contentAssets: [], }, )).toThrow(StrandError); }); @@ -622,13 +695,17 @@ describe('StrandService', () => { expect(descriptor.materialization.cacheAuthority).toBe('derived'); }); - it('persists the descriptor as a blob and updates the ref', async () => { + it('publishes the descriptor through the semantic strand store', async () => { await service.create({ strandId: 'alpha' }); - expect(graph._persistence.writeBlob).toHaveBeenCalledTimes(1); - expect(graph._persistence.updateRef).toHaveBeenCalledTimes(1); - const refPath = requirePresent(graph._persistence.updateRef.mock.calls[0])[0]; - expect(refPath).toContain('strands/alpha'); + expect(graph._strandStore.publishDescriptor).toHaveBeenCalledTimes(1); + expect(graph._strandStore.publishDescriptor).toHaveBeenCalledWith( + expect.objectContaining({ + graphName: 'test-graph', + strandId: 'alpha', + attachments: [], + }), + ); }); it('generates a strandId when none is provided', async () => { @@ -1035,9 +1112,8 @@ describe('StrandService', () => { beforeEach(() => { // Create a mock class constructor with static open() detachedGraph = createMockGraph(); - // Copy refs/blobs from main graph to detached + // Share the same semantic in-memory stores with the detached graph. detachedGraph._persistence.readRef = graph._persistence.readRef; - detachedGraph._persistence.readBlob = graph._persistence.readBlob; detachedGraph._persistence.listRefs = graph._persistence.listRefs; detachedGraph._loadPatchChainFromSha = graph._loadPatchChainFromSha; @@ -1301,7 +1377,7 @@ describe('StrandService', () => { expect(intent.patch).toBeDefined(); expect(Array.isArray(intent.reads)).toBe(true); expect(Array.isArray(intent.writes)).toBe(true); - expect(Array.isArray(intent.contentBlobOids)).toBe(true); + expect(Array.isArray(intent.contentAssetHandles)).toBe(true); expect(Object.isFrozen(intent)).toBe(true); }); @@ -1377,7 +1453,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n1', 'w1', 1)] }), reads: ['n1'], writes: ['n1'], - contentBlobOids: [], + contentAssetHandles: [], }], }, }); @@ -1439,7 +1515,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n1', 'w1', 1)] }), reads: ['n1'], writes: ['n1'], - contentBlobOids: [], + contentAssetHandles: [], }, { intentId: 'alpha.intent.0002', @@ -1447,7 +1523,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n1', 'w1', 2)] }), reads: ['n1'], writes: ['n1'], - contentBlobOids: [], + contentAssetHandles: [], }, { intentId: 'alpha.intent.0003', @@ -1455,7 +1531,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n2', 'w1', 3)] }), reads: ['n2'], writes: ['n2'], - contentBlobOids: [], + contentAssetHandles: [], }, ], }, @@ -1485,7 +1561,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n1', 'w1', 1)] }), reads: ['n1'], writes: ['n1'], - contentBlobOids: [], + contentAssetHandles: [], }], }, }); @@ -1906,18 +1982,18 @@ describe('StrandService', () => { // ── descriptor store seam ───────────────────────────────────────────────── describe('descriptor store seam', () => { - it('parses valid descriptor blob', async () => { + it('parses a valid descriptor from the semantic store', async () => { const desc = buildValidDescriptor({ strandId: 'alpha' }); - const oid = nextOid(); - blobs.set(oid, textEncode(JSON.stringify(desc))); + storeDescriptor(desc); - const result = await service._descriptorStore.readDescriptorByOid(oid, 'alpha'); - expect(result.strandId).toBe('alpha'); + const result = await service._descriptorStore.readDescriptor('alpha'); + expect(requirePresent(result).strandId).toBe('alpha'); }); - it('throws E_STRAND_MISSING_OBJECT for missing blob', async () => { + it('throws E_STRAND_MISSING_OBJECT when descriptor storage cannot open a publication', async () => { + refs.set('refs/warp/test-graph/strands/ghost', 'nonexistent'); try { - await service._descriptorStore.readDescriptorByOid('nonexistent', 'ghost'); + await service._descriptorStore.readDescriptor('ghost'); expect.unreachable('should have thrown'); } catch (err) { expect(requireStrandError(err).code).toBe('E_STRAND_MISSING_OBJECT'); @@ -1927,9 +2003,10 @@ describe('StrandService', () => { it('throws E_STRAND_CORRUPT for invalid JSON', async () => { const oid = nextOid(); blobs.set(oid, textEncode('not json')); + refs.set('refs/warp/test-graph/strands/broken', oid); try { - await service._descriptorStore.readDescriptorByOid(oid, 'broken'); + await service._descriptorStore.readDescriptor('broken'); expect.unreachable('should have thrown'); } catch (err) { expect(requireStrandError(err).code).toBe('E_STRAND_CORRUPT'); @@ -1940,9 +2017,10 @@ describe('StrandService', () => { const desc = buildValidDescriptor({ strandId: 'alpha', graphName: 'other-graph' }); const oid = nextOid(); blobs.set(oid, textEncode(JSON.stringify(desc))); + refs.set('refs/warp/test-graph/strands/alpha', oid); try { - await service._descriptorStore.readDescriptorByOid(oid, 'alpha'); + await service._descriptorStore.readDescriptor('alpha'); expect.unreachable('should have thrown'); } catch (err) { // Wraps the graph mismatch as corrupt since the inner error is re-thrown @@ -1954,16 +2032,19 @@ describe('StrandService', () => { // ── _writeDescriptor ────────────────────────────────────────────────────── describe('_writeDescriptor', () => { - it('serializes descriptor as JSON blob and updates ref', async () => { + it('serializes and publishes a descriptor through StrandStorePort', async () => { const desc = buildValidDescriptor({ strandId: 'alpha' }); await strandServicePrivate(service)._writeDescriptor(desc); - expect(graph._persistence.writeBlob).toHaveBeenCalledTimes(1); - expect(graph._persistence.updateRef).toHaveBeenCalledTimes(1); - - // Verify the written blob is valid JSON - const writtenData = requirePresent(graph._persistence.writeBlob.mock.calls[0])[0]; + expect(graph._strandStore.publishDescriptor).toHaveBeenCalledTimes(1); + const request = requirePresent(graph._strandStore.publishDescriptor.mock.calls[0])[0]; + expect(request).toMatchObject({ + graphName: 'test-graph', + strandId: 'alpha', + attachments: [], + }); + const writtenData = request.descriptor; const parsed = JSON.parse(textDecode(writtenData)); expect(parsed.strandId).toBe('alpha'); }); @@ -2509,8 +2590,13 @@ describe('StrandService', () => { sha: 'new-head-sha', }); - expect(graph._persistence.writeBlob).toHaveBeenCalled(); - expect(graph._persistence.updateRef).toHaveBeenCalled(); + expect(graph._strandStore.publishDescriptor).toHaveBeenCalledTimes(1); + const request = requirePresent(graph._strandStore.publishDescriptor.mock.calls[0])[0]; + const published = JSON.parse(textDecode(request.descriptor)); + expect(published.overlay).toMatchObject({ + headPatchSha: 'new-head-sha', + patchCount: 1, + }); }); it('updates maxObservedLamport when patch lamport exceeds current', async () => { @@ -2560,144 +2646,131 @@ describe('StrandService', () => { describe('_commitQueuedPatch', () => { it('commits a patch via patch journal when available', async () => { - const mockJournal = { - writePatch: vi.fn(async () => 'a'.repeat(40)), - }; - graph._patchJournal = mockJournal; + const appendPatch = graph._patchJournal.appendPatch; const result = await strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: null, patch: makePatch({ ops: [makeNodeAddOp('n1', 'w1', 1)] }), - contentBlobOids: [], + contentAssetHandles: [], lamport: 5, }); - expect(mockJournal.writePatch).toHaveBeenCalledWith( - expect.objectContaining({ writer: 'alpha', lamport: 5 }), + expect(appendPatch).toHaveBeenCalledWith( + expect.objectContaining({ + graph: 'test-graph', + writer: 'alpha', + targetRef: 'refs/warp/test-graph/strand-overlays/alpha', + expectedHead: null, + parent: null, + attachments: [], + patch: expect.objectContaining({ writer: 'alpha', lamport: 5 }), + }), ); - expect(result.sha).toBeTruthy(); expect(result.patch.writer).toBe('alpha'); expect(result.patch.lamport).toBe(5); + expect(result.retention.handle.toString()).toBe(`test-asset:${result.sha}`); + expect(result.bundleHandle.toString()).toBe(`bundle:${result.sha}`); + expect(result.stagedPatch.handle.toString()).toBe(`asset:${result.sha}`); }); - it('falls back to codec + writeBlob when no journal', async () => { + it('fails closed when no semantic patch journal is configured', async () => { graph._patchJournal = null; - const result = await strandServicePrivate(service)._commitQueuedPatch({ + await expect(strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: null, patch: makePatch(), - contentBlobOids: [], + contentAssetHandles: [], lamport: 3, - }); - - expect(graph._codec.encode).toHaveBeenCalled(); - expect(graph._persistence.writeBlob).toHaveBeenCalled(); - expect(result.sha).toBeTruthy(); - }); - - it('uses patchBlobStorage when available', async () => { - graph._patchJournal = null; - graph._patchBlobStorage = { - store: vi.fn(async () => 'b'.repeat(40)), - }; - - await strandServicePrivate(service)._commitQueuedPatch({ - strandId: 'alpha', - overlayId: 'alpha', - parentSha: null, - patch: makePatch(), - contentBlobOids: [], - lamport: 1, - }); - - expect(graph._patchBlobStorage.store).toHaveBeenCalled(); + })).rejects.toMatchObject({ code: 'E_MISSING_JOURNAL' }); }); - it('creates tree with content blob entries', async () => { - graph._patchJournal = null; + it('forwards opaque content handles as journal attachments', async () => { + const appendPatch = graph._patchJournal.appendPatch; await strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: null, patch: makePatch(), - contentBlobOids: ['blob-1', 'blob-2'], + contentAssetHandles: ['asset:1', 'asset:2'], lamport: 1, }); - const treeEntries = (requirePresent(graph._persistence.writeTree.mock.calls[0])[0] as string[]); - expect(treeEntries).toHaveLength(3); // patch.cbor + 2 content blobs - expect(treeEntries.some((entry) => entry.includes('_content_blob-1'))).toBe(true); - expect(treeEntries.some((entry) => entry.includes('_content_blob-2'))).toBe(true); + expect(appendPatch).toHaveBeenCalledWith(expect.objectContaining({ + attachments: [new AssetHandle('asset:1'), new AssetHandle('asset:2')], + })); }); - it('deduplicates content blob OIDs', async () => { - graph._patchJournal = null; + it('preserves duplicate attachment handles for journal policy', async () => { + const appendPatch = graph._patchJournal.appendPatch; await strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: null, patch: makePatch(), - contentBlobOids: ['blob-1', 'blob-1', 'blob-1'], + contentAssetHandles: ['asset:1', 'asset:1'], lamport: 1, }); - const treeEntries = requirePresent(graph._persistence.writeTree.mock.calls[0])[0]; - expect(treeEntries).toHaveLength(2); // patch.cbor + 1 unique content blob + expect(appendPatch).toHaveBeenCalledWith(expect.objectContaining({ + attachments: [new AssetHandle('asset:1'), new AssetHandle('asset:1')], + })); }); - it('sets parent commit when parentSha is provided', async () => { - graph._patchJournal = null; + it('passes the expected head and parent to the journal', async () => { + const appendPatch = graph._patchJournal.appendPatch; + refs.set('refs/warp/test-graph/strand-overlays/alpha', 'parent-sha-abc'); await strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: 'parent-sha-abc', patch: makePatch(), - contentBlobOids: [], + contentAssetHandles: [], lamport: 1, }); - const commitArgs = requirePresent(graph._persistence.commitNodeWithTree.mock.calls[0])[0]; - expect(commitArgs.parents).toEqual(['parent-sha-abc']); + expect(appendPatch).toHaveBeenCalledWith(expect.objectContaining({ + expectedHead: 'parent-sha-abc', + parent: 'parent-sha-abc', + })); }); - it('uses empty parents when parentSha is null', async () => { - graph._patchJournal = null; + it('passes a null expected head for the first overlay patch', async () => { + const appendPatch = graph._patchJournal.appendPatch; await strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: null, patch: makePatch(), - contentBlobOids: [], + contentAssetHandles: [], lamport: 1, }); - const commitArgs = requirePresent(graph._persistence.commitNodeWithTree.mock.calls[0])[0]; - expect(commitArgs.parents).toEqual([]); + expect(appendPatch).toHaveBeenCalledWith(expect.objectContaining({ + expectedHead: null, + parent: null, + })); }); - it('updates overlay ref after commit', async () => { - graph._patchJournal = null; + it('rejects a stale expected overlay head', async () => { + refs.set('refs/warp/test-graph/strand-overlays/alpha', 'advanced-head'); - await strandServicePrivate(service)._commitQueuedPatch({ + await expect(strandServicePrivate(service)._commitQueuedPatch({ strandId: 'alpha', overlayId: 'alpha', parentSha: null, patch: makePatch(), - contentBlobOids: [], + contentAssetHandles: [], lamport: 1, - }); - - expect(graph._persistence.updateRef).toHaveBeenCalled(); - const refCall = requirePresent(graph._persistence.updateRef.mock.calls[0]); - expect(refCall[0]).toContain('overlay'); + })).rejects.toBeInstanceOf(PatchPublicationConflictError); + expect(refs.get('refs/warp/test-graph/strand-overlays/alpha')).toBe('advanced-head'); }); }); @@ -2765,8 +2838,6 @@ describe('StrandService', () => { }); it('commits multiple intents sequentially with incrementing lamport', async () => { - graph._patchJournal = null; - const desc = buildValidDescriptor({ strandId: 'alpha', overlay: { @@ -2785,7 +2856,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n1', 'w1', 1)] }), reads: ['n1'], writes: ['n1'], - contentBlobOids: [], + contentAssetHandles: [], footprint: new Set(['n1']), }, { @@ -2794,7 +2865,7 @@ describe('StrandService', () => { patch: makePatch({ ops: [makeNodeAddOp('n2', 'w1', 2)] }), reads: ['n2'], writes: ['n2'], - contentBlobOids: [], + contentAssetHandles: [], footprint: new Set(['n2']), }, ]; diff --git a/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts b/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts index 6d86b410..515e3adb 100644 --- a/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts +++ b/test/unit/domain/services/sync/SyncResponsePagingMetrics.test.ts @@ -13,11 +13,9 @@ 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, @@ -25,7 +23,11 @@ import type { } 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'; +import type { + AppendPatchRequest, + PublishedPatch, +} from '../../../../../src/ports/PatchJournalPort.ts'; +import type { PatchCommitMessage } from '../../../../../src/ports/CommitMessageCodecPort.ts'; const SHA_1 = '1'.repeat(40); const SHA_2 = '2'.repeat(40); @@ -180,11 +182,11 @@ class StreamingPatchJournal extends PatchJournalPort { this._entries = Object.freeze([...entries]); } - async writePatch(_patch: Patch): Promise { - throw unusedMethod('writePatch'); + async appendPatch(_request: AppendPatchRequest): Promise { + throw unusedMethod('appendPatch'); } - async readPatch(_patchOid: string, _options?: ReadPatchOptions): Promise { + async readPatch(_message: PatchCommitMessage): Promise { throw unusedMethod('readPatch'); } @@ -193,7 +195,7 @@ class StreamingPatchJournal extends PatchJournalPort { } } -class UnusedPersistence extends CommitPort implements BlobPort { +class UnusedPersistence extends CommitPort { async commitNode(_options: CommitNodeOptions): Promise { throw unusedMethod('commitNode'); } @@ -218,29 +220,14 @@ class UnusedPersistence extends CommitPort implements BlobPort { 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 { diff --git a/test/unit/domain/storage/StoragePrimitives.test.ts b/test/unit/domain/storage/StoragePrimitives.test.ts new file mode 100644 index 00000000..3ed948b1 --- /dev/null +++ b/test/unit/domain/storage/StoragePrimitives.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; +import StorageHandle from '../../../../src/domain/storage/StorageHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../../../src/domain/storage/StorageRetentionWitness.ts'; + +type WitnessOptions = ConstructorParameters[0]; + +describe('storage handles', () => { + it('preserves opaque identity across storage handle specializations', () => { + const generic = new StorageHandle('git-cas:1:asset:test'); + const asset = new AssetHandle('git-cas:1:asset:test'); + const bundle = new BundleHandle('git-cas:1:bundle:test'); + + expect(generic.toString()).toBe('git-cas:1:asset:test'); + expect(generic.equals(asset)).toBe(true); + expect(generic.equals(bundle)).toBe(false); + expect(generic.equals(null)).toBe(false); + expect(generic.equals(undefined)).toBe(false); + expect(Object.isFrozen(generic)).toBe(true); + }); + + it.each([ + '', + 'line\nbreak', + 'carriage\rreturn', + 'nul\0byte', + 'x'.repeat(4097), + ])('rejects malformed handle token %j', (token) => { + expect(() => new StorageHandle(token)).toThrowError(/StorageHandle/u); + }); + + it('rejects non-string handle tokens at runtime', () => { + expect(() => construct(StorageHandle, 42)).toThrowError(/StorageHandle/u); + }); +}); + +describe('storage retention evidence', () => { + it('retains validated runtime-backed handle and root identities', () => { + const root = validRoot(); + const witness = new StorageRetentionWitness(validWitnessOptions(root)); + + expect(witness.handle.toString()).toBe('git-cas:1:bundle:test'); + expect(witness.policy).toBe('pinned'); + expect(witness.reachability).toBe('anchored'); + expect(witness.root).toBe(root); + expect(witness.observedAt).toBe('1970-01-01T00:00:00.000Z'); + expect(Object.isFrozen(root)).toBe(true); + expect(Object.isFrozen(witness)).toBe(true); + }); + + it.each([ + ['root.kind', { ...validRootOptions(), kind: 'forever' }], + ['root.namespace', { ...validRootOptions(), namespace: '' }], + ['root.locator', { ...validRootOptions(), locator: '' }], + ['root.generation', { ...validRootOptions(), generation: '' }], + ['root.path', { ...validRootOptions(), path: '' }], + ])('rejects invalid %s', (_field, options) => { + expect(() => construct(StorageRetentionRoot, options)).toThrowError( + /Storage retention witness/u, + ); + }); + + it.each([ + ['handle', { ...validWitnessOptions(), handle: 'raw-string' }], + ['policy', { ...validWitnessOptions(), policy: 'forever' }], + ['reachability', { ...validWitnessOptions(), reachability: 'unknown' }], + ['root', { ...validWitnessOptions(), root: validRootOptions() }], + ['observedAt', { ...validWitnessOptions(), observedAt: 'yesterday' }], + ['observedAt range', { ...validWitnessOptions(), observedAt: '1970-19-41T28:70:70.000Z' }], + ])('rejects invalid witness %s', (_field, options) => { + expect(() => construct(StorageRetentionWitness, options)).toThrowError( + /Storage retention witness/u, + ); + }); + + it('rejects missing constructor option objects', () => { + expect(() => construct(StorageRetentionRoot, null)).toThrowError(/options/u); + expect(() => construct(StorageRetentionWitness, null)).toThrowError(/options/u); + }); +}); + +function validRootOptions(): ConstructorParameters[0] { + return { + kind: 'publication', + namespace: 'test', + locator: 'refs/warp/test/publications', + generation: 'generation-1', + path: '/', + }; +} + +function validRoot(): StorageRetentionRoot { + return new StorageRetentionRoot(validRootOptions()); +} + +function validWitnessOptions(root = validRoot()): WitnessOptions { + return { + handle: new BundleHandle('git-cas:1:bundle:test'), + policy: 'pinned', + reachability: 'anchored', + root, + observedAt: '1970-01-01T00:00:00.000Z', + }; +} + +function construct(target: Function, value: object | string | number | null): void { + Reflect.construct(target, [value]); +} diff --git a/test/unit/domain/strandAndRuntimeSeams.test.ts b/test/unit/domain/strandAndRuntimeSeams.test.ts index 43398469..54bff200 100644 --- a/test/unit/domain/strandAndRuntimeSeams.test.ts +++ b/test/unit/domain/strandAndRuntimeSeams.test.ts @@ -10,13 +10,9 @@ import RuntimeDetachedFactory from '../../../src/domain/warp/RuntimeDetachedFact import RuntimePatchCollector from '../../../src/domain/warp/RuntimePatchCollector.ts'; import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import MemoryRuntimeStorageAdapter from '../../../test/helpers/MemoryRuntimeStorageAdapter.ts'; -import PatchJournalPort from '../../../src/ports/PatchJournalPort.ts'; -import CheckpointStorePort from '../../../src/ports/CheckpointStorePort.ts'; -import IndexStorePort from '../../../src/ports/IndexStorePort.ts'; import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; import GCPolicy from '../../../src/domain/services/GCPolicy.ts'; -import WarpStream from '../../../src/domain/stream/WarpStream.ts'; import type { DetachedGraphOpen, @@ -24,15 +20,6 @@ import type { DetachedOpenOptions, } from '../../../src/domain/services/controllers/detachedOpen.ts'; import type { DetachedGraphInternalReadSurface } from '../../../src/domain/capabilities/DetachedGraphFactory.ts'; -import type Patch from '../../../src/domain/types/Patch.ts'; -import type PatchEntry from '../../../src/domain/artifacts/PatchEntry.ts'; -import type { - CheckpointData, - CheckpointRecord, - CheckpointWriteResult, -} from '../../../src/ports/CheckpointStorePort.ts'; -import type { IndexShard } from '../../../src/domain/artifacts/IndexShard.ts'; -import type CodecValue from '../../../src/domain/types/codec/CodecValue.ts'; describe('strand and runtime host seams', () => { it('uses StrandError as the public speculative-lane error noun', () => { @@ -127,6 +114,7 @@ describe('strand and runtime host seams', () => { expect(detached).toBe(readSurface); expect(options).toMatchObject({ persistence: host._persistence, + runtimeStorage: host._runtimeStorage, graphName: 'detached-runtime', writerId: 'agent-1', gcPolicy: GCPolicy.DEFAULT, @@ -136,12 +124,7 @@ describe('strand and runtime host seams', () => { codec: defaultCodec, audit: false, }); - expect(options.blobStorage).toBeUndefined(); - expect(options.patchBlobStorage).toBeUndefined(); expect(options.trust).toEqual({ mode: 'off', pin: null }); - expect(options.patchJournal).toBe(host._patchJournal); - expect(options.checkpointStore).toBe(host._checkpointStore); - expect(options.indexStore).toBe(host._indexStore); }); it('delegates patch collection through a strict runtime host wrapper', async () => { @@ -176,12 +159,7 @@ function createDetachedHost(): DetachedOpenHost { _gcPolicy: GCPolicy.DEFAULT, _checkpointPolicy: null, _logger: null, - _blobStorage: null, - _patchBlobStorage: null, _trustConfig: { mode: 'off', pin: null }, - _patchJournal: new RecordingPatchJournalPort(), - _checkpointStore: new RecordingCheckpointStorePort(), - _indexStore: new RecordingIndexStorePort(), _onDeleteWithData: 'reject', _crypto: defaultCrypto, _codec: defaultCodec, @@ -222,61 +200,6 @@ function requireDetachedOpenOptions( return call[0]; } -class RecordingPatchJournalPort extends PatchJournalPort { - async writePatch(_patch: Patch): Promise { - return 'patch-oid'; - } - - async readPatch(_patchOid: string): Promise { - throw new RuntimeSeamTestError('readPatch should not be called'); - } - - scanPatchRange( - _writerId: string, - _fromSha: string | null, - _toSha: string - ): WarpStream { - return WarpStream.from([]); - } -} - -class RecordingCheckpointStorePort extends CheckpointStorePort { - async writeCheckpoint(_record: CheckpointRecord): Promise { - return { - nodeAliveBlobOid: 'node-alive-oid', - edgeAliveBlobOid: 'edge-alive-oid', - propBlobOid: 'prop-oid', - observedFrontierBlobOid: 'observed-frontier-oid', - edgeBirthEventBlobOid: 'edge-birth-event-oid', - frontierBlobOid: 'frontier-oid', - appliedVVBlobOid: 'applied-vv-oid', - provenanceIndexBlobOid: null, - }; - } - - async readCheckpoint(_treeOids: Record): Promise { - throw new RuntimeSeamTestError('readCheckpoint should not be called'); - } -} - -class RecordingIndexStorePort extends IndexStorePort { - async writeShards(_shardStream: WarpStream): Promise { - return 'index-tree-oid'; - } - - scanShards(_treeOid: string): WarpStream { - return WarpStream.from([]); - } - - async readShardOids(_treeOid: string): Promise> { - return {}; - } - - async decodeShard(_blobOid: string): Promise { - throw new RuntimeSeamTestError('decodeShard should not be called'); - } -} - class RuntimeSeamTestError extends Error {} function requireSinglePatchSha(patches: ReadonlyArray<{ readonly sha: string }>): string { diff --git a/test/unit/domain/trust/TrustRecordService.test.ts b/test/unit/domain/trust/TrustRecordService.test.ts new file mode 100644 index 00000000..e2e1d794 --- /dev/null +++ b/test/unit/domain/trust/TrustRecordService.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest'; + +import TrustError from '../../../../src/domain/errors/TrustError.ts'; +import { TrustRecord } from '../../../../src/domain/trust/TrustRecord.ts'; +import { TrustRecordService } from '../../../../src/domain/trust/TrustRecordService.ts'; +import { MockTrustChainPort } from '../../../helpers/MockTrustChainPort.ts'; + +function record(recordId: string, prev: string | null = null): TrustRecord { + return TrustRecord.fromDecoded({ + schemaVersion: 1, + recordType: 'KEY_ADD', + recordId, + issuerKeyId: 'issuer', + issuedAt: '2026-07-15T00:00:00.000Z', + prev, + subject: { keyId: `key-${recordId}`, publicKey: `public-${recordId}` }, + meta: {}, + signature: { alg: 'ed25519', sig: `signature-${recordId}` }, + signaturePayload: new Uint8Array([1, 2, 3]), + }); +} + +function counterfeitSignature( + source: TrustRecord, + signature: { readonly alg: string; readonly sig: string }, +): TrustRecord { + return Object.freeze({ + ...source, + signature: Object.freeze(signature), + }) as unknown as TrustRecord; +} + +describe('TrustRecordService', () => { + it('validates the previous link and delegates a retained publication', async () => { + const chain = new MockTrustChainPort(); + const service = new TrustRecordService(chain); + const first = record('record-1'); + const second = record('record-2', first.recordId); + + const firstPublication = await service.appendRecord('events', first); + const secondPublication = await service.appendRecord('events', second); + + expect(firstPublication.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + }); + expect(secondPublication.commitSha).toContain('record-2'); + await expect(service.readTip('events')).resolves.toMatchObject({ + recordId: 'record-2', + }); + const records: TrustRecord[] = []; + for await (const stored of service.readRecords('events')) { + records.push(stored); + } + expect(records).toEqual([first, second]); + }); + + it('rejects previous-link mismatches before persistence', async () => { + const chain = new MockTrustChainPort(); + const persist = vi.spyOn(chain, 'persistRecord'); + const service = new TrustRecordService(chain); + + await expect(service.appendRecord('events', record('record-2', 'wrong-tip'))) + .rejects.toMatchObject({ code: 'E_TRUST_PREV_MISMATCH' }); + expect(persist).not.toHaveBeenCalled(); + }); + + it.each([ + [{ alg: 'rsa', sig: 'signature' }, 'Unsupported signature algorithm'], + [{ alg: 'ed25519', sig: '' }, 'empty signature'], + ])('rejects malformed signature envelopes from JavaScript callers', async (signature, message) => { + const chain = new MockTrustChainPort(); + const service = new TrustRecordService(chain); + const invalid = counterfeitSignature(record('record-1'), signature); + + await expect(service.appendRecord('events', invalid)).rejects.toThrow(message); + await expect(service.appendRecord('events', invalid, { skipSignatureVerify: true })) + .resolves.toMatchObject({ commitSha: expect.any(String) }); + }); + + it('retries CAS conflicts and invokes the caller-owned re-signing boundary', async () => { + const chain = new MockTrustChainPort(); + const persist = vi.spyOn(chain, 'persistRecord'); + persist.mockRejectedValueOnce(new TrustError('conflict', { code: 'E_TRUST_CAS_CONFLICT' })); + const resign = vi.fn(async (current: TrustRecord) => current); + const service = new TrustRecordService(chain); + + await expect(service.appendRecordWithRetry('events', record('record-1'), { resign })) + .resolves.toMatchObject({ attempts: 2 }); + expect(resign).toHaveBeenCalledOnce(); + }); + + it('re-signs against the fresh tip after a genuinely advancing conflict', async () => { + const chain = new MockTrustChainPort(); + const base = record('record-base'); + chain.seed([base]); + const originalPersist = chain.persistRecord.bind(chain); + const concurrent = record('record-concurrent', base.recordId); + vi.spyOn(chain, 'persistRecord').mockImplementationOnce(async () => { + await originalPersist('events', concurrent, 'mock-sha-record-b'); + throw new TrustError('conflict', { code: 'E_TRUST_CAS_CONFLICT' }); + }); + const resign = vi.fn(async (_current: TrustRecord, tip: { recordId: string | null }) => + record('record-rebased', tip.recordId)); + const service = new TrustRecordService(chain); + + await expect(service.appendRecordWithRetry( + 'events', + record('record-proposed', base.recordId), + { resign }, + )).resolves.toMatchObject({ attempts: 2 }); + expect(resign).toHaveBeenCalledWith( + expect.objectContaining({ recordId: 'record-proposed' }), + expect.objectContaining({ recordId: concurrent.recordId }), + ); + await expect(service.readTip('events')).resolves.toMatchObject({ + recordId: 'record-rebased', + }); + }); + + it('rejects a re-signed record that does not bind the fresh tip', async () => { + const chain = new MockTrustChainPort(); + vi.spyOn(chain, 'persistRecord') + .mockRejectedValueOnce(new TrustError('conflict', { code: 'E_TRUST_CAS_CONFLICT' })); + const service = new TrustRecordService(chain); + + await expect(service.appendRecordWithRetry('events', record('record-1'), { + resign: async () => record('record-2', 'stale-tip'), + })).rejects.toMatchObject({ code: 'E_TRUST_PREV_MISMATCH' }); + }); + + it('fails honestly when CAS retries are exhausted or the error is unrelated', async () => { + const exhaustedChain = new MockTrustChainPort(); + vi.spyOn(exhaustedChain, 'persistRecord') + .mockRejectedValue(new TrustError('conflict', { code: 'E_TRUST_CAS_CONFLICT' })); + const exhausted = new TrustRecordService(exhaustedChain); + + await expect(exhausted.appendRecordWithRetry( + 'events', + record('record-1'), + { maxRetries: 1 }, + )).rejects.toMatchObject({ code: 'E_TRUST_CAS_EXHAUSTED' }); + + const unrelatedChain = new MockTrustChainPort(); + const unrelated = new Error('storage offline'); + vi.spyOn(unrelatedChain, 'persistRecord').mockRejectedValueOnce(unrelated); + await expect(new TrustRecordService(unrelatedChain).appendRecordWithRetry( + 'events', + record('record-1'), + )).rejects.toBe(unrelated); + }); +}); diff --git a/test/unit/domain/utils/streamUtils.test.ts b/test/unit/domain/utils/streamUtils.test.ts index 7a8d14cc..abc3c48a 100644 --- a/test/unit/domain/utils/streamUtils.test.ts +++ b/test/unit/domain/utils/streamUtils.test.ts @@ -32,6 +32,10 @@ describe('streamUtils', () => { expect(isStreamingInput('hello')).toBe(false); }); + it('rejects objects with a non-callable async iterator member', () => { + expect(isStreamingInput({ [Symbol.asyncIterator]: 1 })).toBe(false); + }); + it('returns false for readable streams when the global constructor is unavailable', () => { globalThis.ReadableStream = (undefined as any); diff --git a/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts b/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts index b729d084..69c07676 100644 --- a/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts +++ b/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts @@ -11,6 +11,7 @@ import TrustCryptoPort, { type TrustSignatureVerification } from '../../../../sr import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import MemoryRuntimeStorageAdapter from '../../../../test/helpers/MemoryRuntimeStorageAdapter.ts'; import { createFakeCodecPort, createMockCrypto } from '../../../helpers/mockPorts.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; import type { NormalizedTrustConfig } from '../../../../src/domain/runtimeHelpers.ts'; @@ -28,7 +29,7 @@ class TestCommitMessageCodec extends CommitMessageCodecPort { graph: 'graph', writer: 'writer', lamport: 1, - patchOid: 'a'.repeat(40), + patchHandle: new AssetHandle('a'.repeat(40)), schema: 1, storage: LEGACY_GIT_BLOB_PATCH_STORAGE, }; @@ -43,10 +44,9 @@ class TestCommitMessageCodec extends CommitMessageCodecPort { kind: 'checkpoint', graph: 'graph', stateHash: 'b'.repeat(64), - frontierOid: 'c'.repeat(40), - indexOid: 'd'.repeat(40), schema: 1, checkpointVersion: null, + bundleHandle: null, }; } diff --git a/test/unit/domain/warp/Writer.test.ts b/test/unit/domain/warp/Writer.test.ts index e6fe283a..ff5b4b32 100644 --- a/test/unit/domain/warp/Writer.test.ts +++ b/test/unit/domain/warp/Writer.test.ts @@ -17,8 +17,7 @@ import { DEFAULT_COMMIT_MESSAGE_CODEC, encodePatchMessage, } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import { RecordingPatchJournal } from '../services/PatchBuilderTestHarness.ts'; /** * Creates a minimal mock persistence adapter. @@ -46,15 +45,32 @@ function createMockPersistence() { } /** - * Creates a CborPatchJournalAdapter wired to the given persistence's blob ops. + * Creates a semantic journal that records published patches. * @param {ReturnType} persistence - * @returns {CborPatchJournalAdapter} + * @returns {RecordingPatchJournal} */ function createPatchJournal(persistence) { - return new CborPatchJournalAdapter({ - codec: new CborCodec(), - blobPort: persistence, - }); + return new WriterFixtureJournal(persistence); +} + +class WriterFixtureJournal extends RecordingPatchJournal { + readonly _persistence; + + constructor(persistence) { + super(persistence); + this._persistence = persistence; + this.sha = 'b'.repeat(40); + } + + override async appendPatch(request) { + const published = await super.appendPatch(request); + await this._persistence.compareAndSwapRef( + request.targetRef, + published.sha, + request.expectedHead, + ); + return published; + } } /** @@ -303,7 +319,7 @@ describe('Writer (WARP schema:2)', () => { await expect(patch.commit()).rejects.toMatchObject({ code: 'EMPTY_PATCH' }); }); - it('creates commit with parent = previous writer head', async () => { + it('publishes with parent = previous writer head', async () => { const oldHead = 'a'.repeat(40); const newSha = 'b'.repeat(40); @@ -314,9 +330,10 @@ describe('Writer (WARP schema:2)', () => { persistence.commitNodeWithTree.mockResolvedValue(newSha); persistence.updateRef.mockResolvedValue(undefined); + const patchJournal = createPatchJournal(persistence); const writer = new Writer({ persistence, - patchJournal: createPatchJournal(persistence), + patchJournal, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, graphName: 'events', writerId: 'alice', @@ -330,15 +347,17 @@ describe('Writer (WARP schema:2)', () => { expect(sha).toBe(newSha); - // Verify commit was called with parent - expect(persistence.commitNodeWithTree).toHaveBeenCalledWith( - expect.objectContaining({ - parents: [oldHead], - }) - ); + expect(patchJournal.requests).toHaveLength(1); + expect(patchJournal.requests[0]).toMatchObject({ + graph: 'events', + writer: 'alice', + targetRef: 'refs/warp/events/writers/alice', + expectedHead: oldHead, + parent: oldHead, + }); }); - it('first commit (no existing head) uses no parents', async () => { + it('publishes a first patch with no parent', async () => { const newSha = 'b'.repeat(40); persistence.readRef.mockResolvedValue(null); @@ -347,9 +366,10 @@ describe('Writer (WARP schema:2)', () => { persistence.commitNodeWithTree.mockResolvedValue(newSha); persistence.updateRef.mockResolvedValue(undefined); + const patchJournal = createPatchJournal(persistence); const writer = new Writer({ persistence, - patchJournal: createPatchJournal(persistence), + patchJournal, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, graphName: 'events', writerId: 'alice', @@ -361,23 +381,23 @@ describe('Writer (WARP schema:2)', () => { patch.addNode('x'); await patch.commit(); - expect(persistence.commitNodeWithTree).toHaveBeenCalledWith( - expect.objectContaining({ - parents: [], - }) - ); + expect(patchJournal.requests[0]).toMatchObject({ + expectedHead: null, + parent: null, + }); }); - it('atomically advances writer ref after commit', async () => { + it('delegates atomic publication coordinates to the patch journal', async () => { const newSha = 'b'.repeat(40); persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockResolvedValue('c'.repeat(40)); persistence.writeTree.mockResolvedValue('d'.repeat(40)); persistence.commitNodeWithTree.mockResolvedValue(newSha); + const patchJournal = createPatchJournal(persistence); const writer = new Writer({ persistence, - patchJournal: createPatchJournal(persistence), + patchJournal, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, graphName: 'events', writerId: 'alice', @@ -389,11 +409,11 @@ describe('Writer (WARP schema:2)', () => { patch.addNode('x'); await patch.commit(); - expect(persistence.compareAndSwapRef).toHaveBeenCalledWith( - 'refs/warp/events/writers/alice', - newSha, - null - ); + expect(patchJournal.requests[0]).toMatchObject({ + targetRef: 'refs/warp/events/writers/alice', + expectedHead: null, + parent: null, + }); }); it('prevents double commit', async () => { diff --git a/test/unit/domain/warp/readPatchBlob.test.ts b/test/unit/domain/warp/readPatchBlob.test.ts index afed3f38..2a0b17cb 100644 --- a/test/unit/domain/warp/readPatchBlob.test.ts +++ b/test/unit/domain/warp/readPatchBlob.test.ts @@ -1,78 +1,55 @@ -/** - * Tests for _readPatchBlob null-guard on readBlob return value. - * - * @see src/domain/services/controllers/PatchController.js - */ - -import { describe, it, expect, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import PatchController from '../../../../src/domain/services/controllers/PatchController.ts'; -import PersistenceError from '../../../../src/domain/errors/PersistenceError.ts'; - -/** - * Builds a PatchController with a minimal mock host. - * - * @param {{ readBlob: import('vitest').Mock }} persistence - * @param {{ retrieve: import('vitest').Mock }|null} [patchBlobStorage] - * @returns {PatchController} - */ -function createController(persistence, patchBlobStorage = null) { - const host = ({ _persistence: persistence, _patchBlobStorage: patchBlobStorage } as any); - return new PatchController(host); -} +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import Patch from '../../../../src/domain/types/Patch.ts'; +import { createGitCasPatchStorage } from '../../../../src/ports/CommitMessageCodecPort.ts'; + +const locator = Object.freeze({ + kind: 'patch' as const, + graph: 'events', + writer: 'writer-1', + lamport: 1, + patchHandle: new AssetHandle('asset:patch'), + schema: 2, + storage: createGitCasPatchStorage({ encrypted: false }), +}); -describe('_readPatchBlob', () => { - it('returns blob when readBlob succeeds', async () => { - const expected = new Uint8Array([1, 2, 3]); - const ctrl = createController({ readBlob: vi.fn().mockResolvedValue(expected) }); - const result = await ctrl._readPatchBlob({ - patchOid: 'a'.repeat(40), - encrypted: false, +describe('PatchController semantic patch reads', () => { + it('delegates the decoded commit locator to PatchJournalPort', async () => { + const patch = new Patch({ + schema: 2, + writer: 'writer-1', + lamport: 1, + context: {}, + ops: [], + reads: [], + writes: [], }); - expect(result).toBe(expected); + const readPatch = vi.fn(async () => patch); + const controller = createController({ readPatch }); + + await expect(controller._readPatch(locator)).resolves.toBe(patch); + expect(readPatch).toHaveBeenCalledWith(locator); }); - it('throws PersistenceError with E_MISSING_OBJECT when readBlob returns null', async () => { - const ctrl = createController({ readBlob: vi.fn().mockResolvedValue(null) }); - await expect(ctrl._readPatchBlob({ - patchOid: 'b'.repeat(40), - encrypted: false, - })).rejects.toThrow(PersistenceError); + it('propagates storage-owned read failures unchanged', async () => { + const failure = new Error('asset unavailable'); + const controller = createController({ + readPatch: vi.fn(async () => await Promise.reject(failure)), + }); - try { - await ctrl._readPatchBlob({ patchOid: 'b'.repeat(40), encrypted: false }); - } catch (err) { - expect((err as any).code).toBe(PersistenceError.E_MISSING_OBJECT); - } + await expect(controller._readPatch(locator)).rejects.toBe(failure); }); - it('throws PersistenceError with E_MISSING_OBJECT when readBlob returns undefined', async () => { - const ctrl = createController({ readBlob: vi.fn().mockResolvedValue(undefined) }); - await expect(ctrl._readPatchBlob({ - patchOid: 'c'.repeat(40), - encrypted: false, - })).rejects.toThrow(PersistenceError); - }); + it('fails explicitly when no semantic journal is configured', async () => { + const controller = new PatchController({ _patchJournal: null } as never); - it('delegates to patchBlobStorage.retrieve when encrypted', async () => { - const expected = new Uint8Array([4, 5, 6]); - const patchBlobStorage = { retrieve: vi.fn().mockResolvedValue(expected) }; - const ctrl = createController( - { readBlob: vi.fn().mockResolvedValue(null) }, - patchBlobStorage as any, - ); - const result = await ctrl._readPatchBlob({ - patchOid: 'd'.repeat(40), - encrypted: true, + await expect(controller._readPatch(locator)).rejects.toMatchObject({ + code: 'E_MISSING_JOURNAL', }); - expect(result).toBe(expected); - expect(patchBlobStorage.retrieve).toHaveBeenCalledWith('d'.repeat(40)); - }); - - it('throws EncryptionError when encrypted but no patchBlobStorage', async () => { - const ctrl = createController({ readBlob: vi.fn() }, null); - await expect(ctrl._readPatchBlob({ - patchOid: 'e'.repeat(40), - encrypted: true, - })).rejects.toThrow(/encrypted.*patchBlobStorage/i); }); }); + +function createController(journal: { readPatch: (message: typeof locator) => Promise }): PatchController { + return new PatchController({ _patchJournal: journal } as never); +} diff --git a/test/unit/infrastructure/CborIndexStoreAdapter.test.ts b/test/unit/infrastructure/CborIndexStoreAdapter.test.ts index b202fc64..d27eb99e 100644 --- a/test/unit/infrastructure/CborIndexStoreAdapter.test.ts +++ b/test/unit/infrastructure/CborIndexStoreAdapter.test.ts @@ -1,385 +1,205 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { CborIndexStoreAdapter } from '../../../src/infrastructure/adapters/CborIndexStoreAdapter.ts'; -import { decodeCasPayloadPointer } from '../../../src/infrastructure/adapters/CasPayloadPointer.ts'; -import IndexStorePort from '../../../src/ports/IndexStorePort.ts'; -import BlobStoragePort from '../../../src/ports/BlobStoragePort.ts'; -import MockBlobPort from '../../helpers/MockBlobPort.ts'; -import MockTreePort from '../../helpers/MockTreePort.ts'; -import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; -import WarpStream from '../../../src/domain/stream/WarpStream.ts'; -import { MetaShard } from '../../../src/domain/artifacts/MetaShard.ts'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BundleHandle as GitCasBundleHandle } from '@git-stunts/git-cas'; import { EdgeShard } from '../../../src/domain/artifacts/EdgeShard.ts'; import { LabelShard } from '../../../src/domain/artifacts/LabelShard.ts'; +import { MetaShard } from '../../../src/domain/artifacts/MetaShard.ts'; import { PropertyShard } from '../../../src/domain/artifacts/PropertyShard.ts'; import { ReceiptShard } from '../../../src/domain/artifacts/ReceiptShard.ts'; - -class MemoryBlobStorage extends BlobStoragePort { - private readonly _store: Map; - private _counter: number; - - constructor() { - super(); - this._store = new Map(); - this._counter = 0; - } - - override async store(content: Uint8Array | string): Promise { - const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; - const oid = `storage_${String(this._counter++).padStart(4, '0')}`; - this._store.set(oid, bytes); - return oid; - } - - override async retrieve(oid: string): Promise { - const bytes = this._store.get(oid); - if (bytes === undefined) { - throw new Error(`Storage OID not found: ${oid}`); - } - return bytes; - } - - override async storeStream(source: AsyncIterable): Promise { - const chunks: Uint8Array[] = []; - for await (const chunk of source) { - chunks.push(chunk); - } - const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const merged = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; - } - return await this.store(merged); - } - - override retrieveStream(oid: string): AsyncIterable { - const self = this; - return { - async *[Symbol.asyncIterator]() { - yield await self.retrieve(oid); - }, - }; - } +import AssetHandle from '../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../src/domain/storage/BundleHandle.ts'; +import WarpStream from '../../../src/domain/stream/WarpStream.ts'; +import { collectAsyncIterable } from '../../../src/domain/utils/streamUtils.ts'; +import { + CborIndexStoreAdapter, + type GitCasIndexFacade, +} from '../../../src/infrastructure/adapters/CborIndexStoreAdapter.ts'; +import GitCasAssetStorageAdapter from '../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; +import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; +import IndexStorePort from '../../../src/ports/IndexStorePort.ts'; +import InMemoryGraphAdapter from '../../helpers/InMemoryGraphAdapter.ts'; +import InMemoryBlobStorageAdapter from '../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../helpers/InMemoryGitCasFacade.ts'; + +function shards() { + return [ + new MetaShard({ + shardKey: 'a0', + nodeToGlobal: [['node:1', 0], ['node:2', 1]], + nextLocalId: 2, + alive: new Uint8Array([0xff]), + }), + new EdgeShard({ + shardKey: 'a0', + direction: 'fwd', + buckets: { all: { '0': new Uint8Array([0x01]) } }, + }), + new EdgeShard({ + shardKey: 'a0', + direction: 'rev', + buckets: { all: { '1': new Uint8Array([0x02]) } }, + }), + new LabelShard({ labels: [['manages', 0], ['owns', 1]] }), + new PropertyShard({ shardKey: 'a0', entries: [['node:1', { name: 'Alice' }]] }), + new ReceiptShard({ version: 1, nodeCount: 2, labelCount: 2, shardCount: 5 }), + ]; } -describe('CborIndexStoreAdapter', () => { - let blobPort; - let treePort; - let adapter; +describe('CborIndexStoreAdapter opaque shard boundary', () => { + let history: InMemoryGraphAdapter; + let backing: InMemoryBlobStorageAdapter; + let cas: InMemoryGitCasFacade; + let assets: GitCasAssetStorageAdapter; + let indexes: CborIndexStoreAdapter; beforeEach(() => { - blobPort = new MockBlobPort(); - treePort = new MockTreePort(); - adapter = new CborIndexStoreAdapter({ + history = new InMemoryGraphAdapter(); + backing = new InMemoryBlobStorageAdapter(); + cas = new InMemoryGitCasFacade({ history, storage: backing }); + assets = new GitCasAssetStorageAdapter({ cas, legacyReader: history }); + indexes = new CborIndexStoreAdapter({ codec: defaultCodec, - blobPort, - treePort, + assetStorage: assets, + cas, }); }); - // ── Construction ──────────────────────────────────────────────── - - describe('constructor', () => { - it('extends IndexStorePort', () => { - expect(adapter).toBeInstanceOf(IndexStorePort); - }); - - it('rejects null codec', () => { - expect(() => new CborIndexStoreAdapter({ - codec: (null as any), - blobPort, - treePort, - })).toThrow('requires a codec'); - }); - - it('rejects null blobPort', () => { - expect(() => new CborIndexStoreAdapter({ - codec: defaultCodec, - blobPort: (null as any), - treePort, - })).toThrow('requires a blobPort'); - }); - - it('rejects null treePort', () => { - expect(() => new CborIndexStoreAdapter({ - codec: defaultCodec, - blobPort, - treePort: (null as any), - })).toThrow('requires a treePort'); - }); + it('is an IndexStorePort and validates infrastructure dependencies', () => { + expect(indexes).toBeInstanceOf(IndexStorePort); + expect(() => new CborIndexStoreAdapter({ + // @ts-expect-error Runtime dependency guard for JavaScript callers. + codec: null, + assetStorage: assets, + cas, + })).toThrow(/codec/); + expect(() => new CborIndexStoreAdapter({ + codec: defaultCodec, + // @ts-expect-error Runtime dependency guard for JavaScript callers. + assetStorage: null, + cas, + })).toThrow(/assetStorage/); + expect(() => new CborIndexStoreAdapter({ + codec: defaultCodec, + assetStorage: assets, + // @ts-expect-error Runtime dependency guard for JavaScript callers. + cas: null, + })).toThrow(/cas/); }); - // ── Shard Fixtures ────────────────────────────────────────────── - - /** - * Creates a representative set of IndexShard instances for testing. - * @returns {import('../../../src/domain/artifacts/IndexShard.ts').IndexShard[]} - */ - function createTestShards() { - return [ - new MetaShard({ - shardKey: 'a0', - nodeToGlobal: [['node:1', 0], ['node:2', 1]], - nextLocalId: 2, - alive: new Uint8Array([0xff]), - }), - new EdgeShard({ - shardKey: 'a0', - direction: 'fwd', - buckets: { all: { '0': new Uint8Array([0x01]) } }, - }), - new EdgeShard({ - shardKey: 'a0', - direction: 'rev', - buckets: { all: { '1': new Uint8Array([0x02]) } }, - }), - new LabelShard({ - labels: [['manages', 0], ['owns', 1]], - }), - new PropertyShard({ - shardKey: 'a0', - entries: [['node:1', { name: 'Alice' }]], - }), - new ReceiptShard({ - version: 1, - nodeCount: 2, - labelCount: 2, - shardCount: 5, - }), - ]; - } - - // ── writeShards ───────────────────────────────────────────────── - - describe('writeShards', () => { - it('persists shards and returns a tree OID', async () => { - const shards = createTestShards(); - const stream = WarpStream.from(shards); - const treeOid = await adapter.writeShards(stream); - - expect(typeof treeOid).toBe('string'); - expect(treeOid).toMatch(/^tree_/); - - // Verify blobs were written (one per shard) - expect(blobPort.writeBlob).toHaveBeenCalledTimes(shards.length); - - // Verify tree was written - expect(treePort.writeTree).toHaveBeenCalledTimes(1); - }); - - it('creates tree entries with correct paths', async () => { - const shards = createTestShards(); - await adapter.writeShards(WarpStream.from(shards)); - - const mock = (treePort.writeTree as any); - const firstCall = (mock.mock.calls[0] as unknown[]); - const treeEntries = (firstCall[0] as string[]); - const paths = treeEntries.map((e) => e.split('\t')[1]).sort(); - - expect(paths).toEqual([ - 'fwd_a0.cbor', - 'labels.cbor', - 'meta_a0.cbor', - 'props_a0.cbor', - 'receipt.cbor', - 'rev_a0.cbor', - ]); - }); - - it('stores shard payloads behind CAS pointer blobs when blobStorage is configured', async () => { - const blobStorage = new MemoryBlobStorage(); - const casBackedAdapter = new CborIndexStoreAdapter({ - codec: defaultCodec, - blobPort, - treePort, - blobStorage, - }); - - const treeOid = await casBackedAdapter.writeShards(WarpStream.from(createTestShards())); - const oidMap = await treePort.readTreeOids(treeOid); - - expect(Object.keys(oidMap)).toHaveLength(6); - - for (const oid of Object.values(oidMap)) { - const pointerBytes = await blobPort.readBlob(oid); - expect(decodeCasPayloadPointer(pointerBytes)).not.toBeNull(); - } - }); + it('writes and scans every supported shard class through one index handle', async () => { + const indexHandle = await indexes.writeShards(WarpStream.from(shards())); + const recovered = await indexes.scanShards(indexHandle).collect(); + + expect(indexHandle).toBeInstanceOf(BundleHandle); + expect(recovered).toHaveLength(6); + expect(recovered.some((shard) => shard instanceof MetaShard)).toBe(true); + expect(recovered.some((shard) => shard instanceof EdgeShard && shard.direction === 'fwd')).toBe(true); + expect(recovered.some((shard) => shard instanceof EdgeShard && shard.direction === 'rev')).toBe(true); + expect(recovered.some((shard) => shard instanceof LabelShard)).toBe(true); + expect(recovered.some((shard) => shard instanceof PropertyShard)).toBe(true); + expect(recovered.some((shard) => shard instanceof ReceiptShard)).toBe(true); }); - // ── writeShards → scanShards round-trip ───────────────────────── - - describe('writeShards → scanShards round-trip', () => { - it('reconstructs IndexShard instances from persisted tree', async () => { - const original = createTestShards(); - const treeOid = await adapter.writeShards(WarpStream.from(original)); - - const recovered = await adapter.scanShards(treeOid).collect(); - - expect(recovered).toHaveLength(original.length); - - // Check each shard subclass was correctly classified - const meta = recovered.find((s) => s instanceof MetaShard); - const fwd = recovered.find((s) => s instanceof EdgeShard && s.direction === 'fwd'); - const rev = recovered.find((s) => s instanceof EdgeShard && s.direction === 'rev'); - const labels = recovered.find((s) => s instanceof LabelShard); - const props = recovered.find((s) => s instanceof PropertyShard); - const receipt = recovered.find((s) => s instanceof ReceiptShard); - - expect(meta).toBeDefined(); - expect(fwd).toBeDefined(); - expect(rev).toBeDefined(); - expect(labels).toBeDefined(); - expect(props).toBeDefined(); - expect(receipt).toBeDefined(); - }); - - it('preserves MetaShard payload', async () => { - const original = new MetaShard({ - shardKey: 'b3', - nodeToGlobal: [['x', 10], ['y', 20]], - nextLocalId: 21, - alive: new Uint8Array([0xab, 0xcd]), - }); - const treeOid = await adapter.writeShards(WarpStream.from([original])); - const [recovered] = await adapter.scanShards(treeOid).collect(); - - expect(recovered).toBeInstanceOf(MetaShard); - const meta = (recovered); - expect(meta.shardKey).toBe('b3'); - expect(meta.nodeToGlobal).toEqual([['x', 10], ['y', 20]]); - expect(meta.nextLocalId).toBe(21); - expect(meta.alive).toEqual(new Uint8Array([0xab, 0xcd])); - }); - - it('preserves EdgeShard payload', async () => { - const original = new EdgeShard({ - shardKey: 'ff', - direction: 'fwd', - buckets: { all: { '5': new Uint8Array([0x01, 0x02]) } }, - }); - const treeOid = await adapter.writeShards(WarpStream.from([original])); - const [recovered] = await adapter.scanShards(treeOid).collect(); - - expect(recovered).toBeInstanceOf(EdgeShard); - const edge = (recovered); - expect(edge.shardKey).toBe('ff'); - expect(edge.direction).toBe('fwd'); - expect(edge.buckets).toEqual({ all: { '5': new Uint8Array([0x01, 0x02]) } }); - }); - - it('preserves LabelShard payload', async () => { - const original = new LabelShard({ - labels: [['edge_type_a', 0], ['edge_type_b', 1]], - }); - const treeOid = await adapter.writeShards(WarpStream.from([original])); - const [recovered] = await adapter.scanShards(treeOid).collect(); - - expect(recovered).toBeInstanceOf(LabelShard); - const label = (recovered); - expect(label.labels).toEqual([['edge_type_a', 0], ['edge_type_b', 1]]); - }); + it('lists shard handles without opening shard payloads', async () => { + const indexHandle = await indexes.writeShards(WarpStream.from(shards())); + const open = vi.spyOn(assets, 'open'); + const handles = await indexes.readShardHandles(indexHandle); + + expect(Object.keys(handles).sort()).toEqual([ + 'fwd_a0.cbor', + 'labels.cbor', + 'meta_a0.cbor', + 'props_a0.cbor', + 'receipt.cbor', + 'rev_a0.cbor', + ]); + expect(Object.values(handles).every((handle) => handle instanceof AssetHandle)).toBe(true); + expect(open).not.toHaveBeenCalled(); + }); - it('preserves PropertyShard payload', async () => { - const original = new PropertyShard({ - shardKey: 'c2', - entries: [['node:x', { k: 'v', n: 42 }]], - }); - const treeOid = await adapter.writeShards(WarpStream.from([original])); - const [recovered] = await adapter.scanShards(treeOid).collect(); + it('opens and decodes exactly one selected shard handle', async () => { + const indexHandle = await indexes.writeShards(WarpStream.from(shards())); + const handles = await indexes.readShardHandles(indexHandle); + const receiptHandle = handles['receipt.cbor']; + if (receiptHandle === undefined) { + throw new Error('expected receipt shard handle'); + } - expect(recovered).toBeInstanceOf(PropertyShard); - const prop = (recovered); - expect(prop.shardKey).toBe('c2'); - expect(prop.entries).toEqual([['node:x', { k: 'v', n: 42 }]]); + const bytes = await collectAsyncIterable(indexes.openShard(receiptHandle)); + expect(defaultCodec.decode(bytes)).toEqual({ + version: 1, + nodeCount: 2, + labelCount: 2, + shardCount: 5, }); + await expect(indexes.decodeShard(receiptHandle)).resolves.toEqual(defaultCodec.decode(bytes)); + }); - it('preserves ReceiptShard payload', async () => { - const original = new ReceiptShard({ - version: 2, - nodeCount: 1000, - labelCount: 50, - shardCount: 256, - }); - const treeOid = await adapter.writeShards(WarpStream.from([original])); - const [recovered] = await adapter.scanShards(treeOid).collect(); - - expect(recovered).toBeInstanceOf(ReceiptShard); - const receipt = (recovered); - expect(receipt.version).toBe(2); - expect(receipt.nodeCount).toBe(1000); - expect(receipt.labelCount).toBe(50); - expect(receipt.shardCount).toBe(256); + it('ignores unknown physical paths while scanning compatibility indexes', async () => { + const staged = await assets.stage(WarpStream.from([defaultCodec.encode({ ignored: true })]), { + slug: 'unknown-index-member', + filename: 'unknown.cbor', }); - - it('round-trips CAS-backed pointer blobs through blobStorage', async () => { - const blobStorage = new MemoryBlobStorage(); - const casBackedAdapter = new CborIndexStoreAdapter({ - codec: defaultCodec, - blobPort, - treePort, - blobStorage, - }); - - const original = createTestShards(); - const treeOid = await casBackedAdapter.writeShards(WarpStream.from(original)); - const recovered = await casBackedAdapter.scanShards(treeOid).collect(); - - expect(recovered).toHaveLength(original.length); - expect(recovered.find((shard) => shard instanceof MetaShard)).toBeDefined(); - expect(recovered.find((shard) => shard instanceof ReceiptShard)).toBeDefined(); + const bundle = await cas.bundles.putOrdered({ + members: [['unknown.cbor', staged.handle.toString()]], }); - }); - - // ── readShardOids ─────────────────────────────────────────────── - - describe('readShardOids', () => { - it('returns path→OID map without reading blob contents', async () => { - const treeOid = await adapter.writeShards(WarpStream.from(createTestShards())); - const oids = await adapter.readShardOids(treeOid); - expect(Object.keys(oids).sort()).toEqual([ - 'fwd_a0.cbor', - 'labels.cbor', - 'meta_a0.cbor', - 'props_a0.cbor', - 'receipt.cbor', - 'rev_a0.cbor', - ]); - - // Every value is a blob OID - for (const oid of Object.values(oids)) { - expect(typeof oid).toBe('string'); - expect(oid).toMatch(/^blob_/); - } - }); + await expect(indexes.scanShards(new BundleHandle(bundle.handle.toString())).collect()) + .resolves.toEqual([]); }); - // ── decodeShard ───────────────────────────────────────────────── - - describe('decodeShard', () => { - it('reads and decodes a blob by OID', async () => { - const data = { version: 1, nodeCount: 5, labelCount: 2, shardCount: 10 }; - const bytes = defaultCodec.encode(data); - const oid = await blobPort.writeBlob(bytes); + it('rejects duplicate member paths while listing or scanning an index bundle', async () => { + const indexHandle = await indexes.writeShards(WarpStream.from(shards())); + const duplicateCas: GitCasIndexFacade = { + bundles: { + putOrdered: cas.bundles.putOrdered, + iterateMembers: async function* (request) { + let duplicated = false; + for await (const member of cas.bundles.iterateMembers(request)) { + yield member; + if (!duplicated) { + yield member; + duplicated = true; + } + } + }, + }, + }; + const duplicateIndexes = indexAdapter(assets, duplicateCas); - const decoded = await adapter.decodeShard(oid); - expect(decoded).toEqual(data); - }); + await expect(duplicateIndexes.readShardHandles(indexHandle)) + .rejects.toMatchObject({ code: 'E_INDEX_DUPLICATE_BUNDLE_MEMBER' }); + await expect(duplicateIndexes.scanShards(indexHandle).collect()) + .rejects.toMatchObject({ code: 'E_INDEX_DUPLICATE_BUNDLE_MEMBER' }); }); - // ── scanShards classification ─────────────────────────────────── - - describe('scanShards classification', () => { - it('skips unknown path patterns without throwing', async () => { - // Manually insert a tree with an unrecognized path (e.g., frontier.cbor) - const bytes = defaultCodec.encode({ foo: 'bar' }); - const blobOid = await blobPort.writeBlob(bytes); - treePort.store.set('tree_unknown', { 'garbage_file.cbor': blobOid }); + it('rejects non-asset members while listing or scanning an index bundle', async () => { + const indexHandle = await indexes.writeShards(WarpStream.from(shards())); + const nonAssetCas: GitCasIndexFacade = { + bundles: { + putOrdered: cas.bundles.putOrdered, + iterateMembers: async function* (request) { + for await (const member of cas.bundles.iterateMembers(request)) { + yield Object.freeze({ + ...member, + path: 'unknown.cbor', + handle: GitCasBundleHandle.parse(indexHandle.toString()), + }); + } + }, + }, + }; + const nonAssetIndexes = indexAdapter(assets, nonAssetCas); - const shards = await adapter.scanShards('tree_unknown').collect(); - expect(shards).toEqual([]); - }); + await expect(nonAssetIndexes.readShardHandles(indexHandle)) + .rejects.toMatchObject({ code: 'E_INDEX_INVALID_BUNDLE_MEMBER' }); + await expect(nonAssetIndexes.scanShards(indexHandle).collect()) + .rejects.toMatchObject({ code: 'E_INDEX_INVALID_BUNDLE_MEMBER' }); }); }); + +function indexAdapter( + assetStorage: GitCasAssetStorageAdapter, + cas: GitCasIndexFacade, +): CborIndexStoreAdapter { + return new CborIndexStoreAdapter({ codec: defaultCodec, assetStorage, cas }); +} diff --git a/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts b/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts deleted file mode 100644 index 03d52a7f..00000000 --- a/test/unit/infrastructure/adapters/CasBlobAdapter.test.ts +++ /dev/null @@ -1,748 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const mockReadManifest = vi.fn(); -const mockRestore = vi.fn(); -const mockRestoreStream = vi.fn(); -const mockStore = vi.fn(); -const mockCreateTree = vi.fn(); - -class MockContentAddressableStore { - readManifest: typeof mockReadManifest; - restore: typeof mockRestore; - restoreStream: typeof mockRestoreStream; - store: typeof mockStore; - createTree: typeof mockCreateTree; - constructor() { - this.readManifest = mockReadManifest; - this.restore = mockRestore; - this.restoreStream = mockRestoreStream; - this.store = mockStore; - this.createTree = mockCreateTree; - } -} - -const { default: CasBlobAdapter } = await import( - '../../../../src/infrastructure/adapters/CasBlobAdapter.ts' -); -const { default: BlobStoragePort } = await import( - '../../../../src/ports/BlobStoragePort.ts' -); -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 -// --------------------------------------------------------------------------- - -function makePersistence() { - return { - readBlob: vi.fn().mockResolvedValue(new TextEncoder().encode('raw-blob-data')), - writeBlob: vi.fn().mockResolvedValue('blob-oid-1'), - }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('CasBlobAdapter', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('extends BlobStoragePort', () => { - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - expect(adapter).toBeInstanceOf(BlobStoragePort); - }); - - describe('store()', () => { - it('stores string content via CAS and returns tree OID', async () => { - const manifest = { chunks: ['chunk1'] }; - mockStore.mockResolvedValue(manifest); - mockCreateTree.mockResolvedValue('tree-oid-abc'); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - const oid = await adapter.store('hello world', { slug: 'test/node1' }); - - expect(oid).toBe('tree-oid-abc'); - expect(mockStore).toHaveBeenCalledOnce(); - expect(mockCreateTree).toHaveBeenCalledWith({ manifest }); - }); - - it('stores Uint8Array content via CAS', async () => { - const manifest = { chunks: ['chunk1'] }; - mockStore.mockResolvedValue(manifest); - mockCreateTree.mockResolvedValue('tree-oid-123'); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - const buf = new Uint8Array([1, 2, 3]); - const oid = await adapter.store(buf); - - expect(oid).toBe('tree-oid-123'); - expect(mockStore).toHaveBeenCalledOnce(); - }); - - it('generates a default slug when none provided', async () => { - mockStore.mockResolvedValue({}); - mockCreateTree.mockResolvedValue('tree-oid'); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - await adapter.store('data'); - - const storeCall = (mockStore.mock.calls[0] as any)[0]; - expect(storeCall.slug).toMatch(/^blob-/); - }); - - it('passes encryptionKey to CAS store when configured', async () => { - mockStore.mockResolvedValue({}); - mockCreateTree.mockResolvedValue('tree-oid'); - - const encKey = new Uint8Array(32); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - encryptionKey: encKey, - }); - - await adapter.store('secret data'); - - const storeCall = (mockStore.mock.calls[0] as any)[0]; - 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({ - cas: new MockContentAddressableStore(), - 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 () => { - mockStore.mockResolvedValue({}); - mockCreateTree.mockResolvedValue('tree-oid'); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - await adapter.store('plain data'); - - const storeCall = (mockStore.mock.calls[0] as any)[0]; - expect(storeCall.encryptionKey).toBeUndefined(); - }); - }); - - describe('has()', () => { - it('checks the injected CAS manifest store', async () => { - mockReadManifest.mockResolvedValue({ chunks: [] }); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - await expect(adapter.has('tree-oid')).resolves.toBe(true); - expect(mockReadManifest).toHaveBeenCalledWith({ treeOid: 'tree-oid' }); - }); - - it.each(['MANIFEST_NOT_FOUND', 'GIT_OBJECT_NOT_FOUND'])( - 'returns false for explicit CAS not-found code %s', - async (code) => { - mockReadManifest.mockRejectedValue(Object.assign(new Error('missing manifest'), { code })); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - await expect(adapter.has('missing-oid')).resolves.toBe(false); - }, - ); - - it('propagates non-not-found CAS failures', async () => { - const failure = Object.assign(new Error('backend unavailable'), { code: 'GIT_ERROR' }); - mockReadManifest.mockRejectedValue(failure); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - await expect(adapter.has('tree-oid')).rejects.toBe(failure); - }); - }); - - describe('retrieve()', () => { - it('retrieves content via CAS when manifest exists', async () => { - const manifest = { chunks: ['chunk1'] }; - const contentBuf = new TextEncoder().encode('restored content'); - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockResolvedValue({ buffer: contentBuf }); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - const result = await adapter.retrieve('tree-oid-abc'); - - expect(result).toBe(contentBuf); - expect(mockReadManifest).toHaveBeenCalledWith({ treeOid: 'tree-oid-abc' }); - expect(mockRestore).toHaveBeenCalledWith({ manifest }); - }); - - it('normalizes Buffer subclasses returned by git-cas', async () => { - mockReadManifest.mockResolvedValue({ chunks: [] }); - mockRestore.mockResolvedValue({ buffer: Buffer.from('restored content') }); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - const result = await adapter.retrieve('tree-oid'); - - expect(result.constructor).toBe(Uint8Array); - expect(new TextDecoder().decode(result)).toBe('restored content'); - }); - - it('passes encryptionKey to CAS restore when configured', async () => { - const manifest = { chunks: ['chunk1'] }; - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockResolvedValue({ buffer: new TextEncoder().encode('decrypted') }); - - const encKey = new Uint8Array(32); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - encryptionKey: encKey, - }); - - await adapter.retrieve('tree-oid'); - - expect(mockRestore).toHaveBeenCalledWith({ manifest, encryptionKey: encKey }); - }); - - 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({ - cas: new MockContentAddressableStore(), - 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({ - cas: new MockContentAddressableStore(), - 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); - const casErr = Object.assign(new Error('No manifest entry'), { code: 'MANIFEST_NOT_FOUND' }); - mockReadManifest.mockRejectedValue(casErr); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const result = await adapter.retrieve('raw-blob-oid'); - - expect(result).toBe(rawBuf); - expect(persistence.readBlob).toHaveBeenCalledWith('raw-blob-oid'); - }); - - 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); - const casErr = Object.assign(new Error('Failed to read tree'), { code: 'GIT_ERROR' }); - mockReadManifest.mockRejectedValue(casErr); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const result = await adapter.retrieve('raw-blob-oid'); - - expect(result).toBe(rawBuf); - expect(persistence.readBlob).toHaveBeenCalledWith('raw-blob-oid'); - }); - - 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); - mockReadManifest.mockResolvedValue({ chunks: [] }); - mockRestore.mockRejectedValue(new Error('not a tree object')); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const result = await adapter.retrieve('bad-tree-oid'); - - expect(result).toBe(rawBuf); - expect(persistence.readBlob).toHaveBeenCalledWith('bad-tree-oid'); - }); - - 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); - mockReadManifest.mockRejectedValue(new Error('bad object abc123')); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const result = await adapter.retrieve('bad-obj-oid'); - - expect(result).toBe(rawBuf); - expect(persistence.readBlob).toHaveBeenCalledWith('bad-obj-oid'); - }); - - 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); - mockReadManifest.mockRejectedValue(new Error('path does not exist')); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const result = await adapter.retrieve('missing-oid'); - - expect(result).toBe(rawBuf); - expect(persistence.readBlob).toHaveBeenCalledWith('missing-oid'); - }); - - it('throws E_MISSING_OBJECT when legacy fallback readBlob returns null', async () => { - const persistence = makePersistence(); - persistence.readBlob.mockResolvedValue(null); - const casErr = Object.assign(new Error('No manifest entry'), { code: 'MANIFEST_NOT_FOUND' }); - mockReadManifest.mockRejectedValue(casErr); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - 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('rethrows non-legacy CAS errors', async () => { - const persistence = makePersistence(); - const casErr = Object.assign(new Error('decryption failed'), { code: 'INTEGRITY_ERROR' }); - mockReadManifest.mockRejectedValue(casErr); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - }); - - 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({ - cas: new MockContentAddressableStore(), - 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({ - cas: new MockContentAddressableStore(), - persistence, - }); - - await expect(adapter.retrieve('enc-oid')).rejects.toMatchObject({ - code: 'E_CAS_VAULT_PASSPHRASE_FAILED', - }); - expect(persistence.readBlob).not.toHaveBeenCalled(); - }); - }); - - describe('storeStream()', () => { - it('stores content from an async iterable via CAS and returns tree OID', async () => { - const manifest = { chunks: ['chunk1', 'chunk2'] }; - mockStore.mockResolvedValue(manifest); - mockCreateTree.mockResolvedValue('tree-oid-stream'); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - async function* source() { - yield new TextEncoder().encode('hello '); - yield new TextEncoder().encode('world'); - } - - const oid = await adapter.storeStream(source(), { slug: 'test/streamed' }); - - expect(oid).toBe('tree-oid-stream'); - expect(mockStore).toHaveBeenCalledOnce(); - expect(mockCreateTree).toHaveBeenCalledWith({ manifest }); - // The source passed to CAS store should be the async iterable (or wrapped) - const storeCall = (mockStore.mock.calls[0] as any)[0]; - expect(storeCall.slug).toBe('test/streamed'); - }); - - it('passes encryptionKey to CAS store when configured', async () => { - mockStore.mockResolvedValue({}); - mockCreateTree.mockResolvedValue('tree-oid'); - - const encKey = new Uint8Array(32); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - encryptionKey: encKey, - }); - - async function* source() { - yield new Uint8Array([1]); - } - - await adapter.storeStream(source()); - - const storeCall = (mockStore.mock.calls[0] as any)[0]; - expect(storeCall.encryptionKey).toEqual(encKey); - expect(storeCall.encryptionKey).not.toBe(encKey); - }); - }); - - describe('retrieveStream()', () => { - it('retrieves content as an async iterable via CAS restoreStream', async () => { - const manifest = { chunks: ['chunk1'] }; - const chunk1 = new TextEncoder().encode('hello '); - const chunk2 = new TextEncoder().encode('world'); - - mockReadManifest.mockResolvedValue(manifest); - mockRestoreStream.mockReturnValue((async function* () { - yield chunk1; - yield chunk2; - })()); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - - const stream = adapter.retrieveStream('tree-oid-abc'); - const chunks: any[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - - expect(chunks).toHaveLength(2); - const result = new Uint8Array(chunks.reduce((n, c) => n + (c as any).byteLength, 0)); - let offset = 0; - for (const c of chunks) { - result.set(c, offset); - offset += (c as any).byteLength; - } - expect(new TextDecoder().decode(result)).toBe('hello world'); - }); - - it('can be cancelled before the CAS stream is opened', async () => { - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - const iterator = adapter.retrieveStream('tree-oid')[Symbol.asyncIterator](); - - await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); - expect(mockReadManifest).not.toHaveBeenCalled(); - }); - - it('delegates cancellation to an opened CAS stream', async () => { - let cancelled = false; - mockReadManifest.mockResolvedValue({ chunks: [] }); - mockRestoreStream.mockReturnValue((async function* () { - try { - yield new Uint8Array([1]); - } finally { - cancelled = true; - } - })()); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - }); - const iterator = adapter.retrieveStream('tree-oid')[Symbol.asyncIterator](); - - await expect(iterator.next()).resolves.toMatchObject({ done: false }); - await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); - expect(cancelled).toBe(true); - }); - - it('maps git-cas encryption failures without probing legacy blobs', async () => { - const persistence = makePersistence(); - mockReadManifest.mockRejectedValue(Object.assign( - new Error('Legacy encryption scheme is unsupported'), - { code: 'LEGACY_SCHEME' }, - )); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - }); - - const iterator = adapter.retrieveStream('encrypted-oid')[Symbol.asyncIterator](); - - await expect(iterator.next()).rejects.toMatchObject({ - code: 'E_CAS_LEGACY_ENCRYPTION_SCHEME', - }); - expect(persistence.readBlob).not.toHaveBeenCalled(); - }); - - it('rethrows non-legacy CAS stream failures', async () => { - const persistence = makePersistence(); - const failure = new Error('CAS unavailable'); - mockReadManifest.mockRejectedValue(failure); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - }); - - const iterator = adapter.retrieveStream('tree-oid')[Symbol.asyncIterator](); - - await expect(iterator.next()).rejects.toBe(failure); - expect(persistence.readBlob).not.toHaveBeenCalled(); - }); - - it('maps a legacy probe missing-object failure to E_MISSING_OBJECT', async () => { - const persistence = makePersistence(); - persistence.readBlob.mockRejectedValue(new PersistenceError( - 'missing', - PersistenceError.E_MISSING_OBJECT, - )); - mockReadManifest.mockRejectedValue(Object.assign( - new Error('No manifest entry'), - { code: 'MANIFEST_NOT_FOUND' }, - )); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - }); - - const iterator = adapter.retrieveStream('missing-oid')[Symbol.asyncIterator](); - - await expect(iterator.next()).rejects.toMatchObject({ - code: PersistenceError.E_MISSING_OBJECT, - }); - }); - - it('preserves unexpected legacy probe failures', async () => { - const persistence = makePersistence(); - const failure = new Error('Git transport failed'); - persistence.readBlob.mockRejectedValue(failure); - mockReadManifest.mockRejectedValue(Object.assign( - new Error('No manifest entry'), - { code: 'MANIFEST_NOT_FOUND' }, - )); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - }); - - const iterator = adapter.retrieveStream('legacy-oid')[Symbol.asyncIterator](); - - await expect(iterator.next()).rejects.toBe(failure); - }); - - 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); - const casErr = Object.assign(new Error('No manifest entry'), { code: 'MANIFEST_NOT_FOUND' }); - mockReadManifest.mockRejectedValue(casErr); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const stream = adapter.retrieveStream('raw-blob-oid'); - const chunks: any[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - - expect(chunks).toHaveLength(1); - expect(chunks[0]).toBe(rawBuf); - expect(persistence.readBlob).toHaveBeenCalledWith('raw-blob-oid'); - }); - - it('closes a legacy single-chunk stream deterministically', async () => { - const persistence = makePersistence(); - mockReadManifest.mockRejectedValue(Object.assign( - new Error('No manifest entry'), - { code: 'MANIFEST_NOT_FOUND' }, - )); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - const iterator = adapter.retrieveStream('raw-blob-oid')[Symbol.asyncIterator](); - - await expect(iterator.next()).resolves.toMatchObject({ done: false }); - await expect(iterator.return?.()).resolves.toMatchObject({ done: true }); - await expect(iterator.next()).resolves.toMatchObject({ done: true }); - }); - - it('passes encryptionKey to CAS restoreStream when configured', async () => { - const manifest = { chunks: ['chunk1'] }; - mockReadManifest.mockResolvedValue(manifest); - mockRestoreStream.mockReturnValue((async function* () { - yield new Uint8Array([1]); - })()); - - const encKey = new Uint8Array(32); - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence: makePersistence(), - encryptionKey: encKey, - }); - - const stream = adapter.retrieveStream('tree-oid'); - for await (const _ of stream) { /* drain */ } - - expect(mockRestoreStream).toHaveBeenCalledWith( - expect.objectContaining({ manifest, encryptionKey: encKey }), - ); - }); - - it('throws E_MISSING_OBJECT when legacy fallback readBlob returns null', async () => { - const persistence = makePersistence(); - persistence.readBlob.mockResolvedValue(null); - const casErr = Object.assign(new Error('No manifest entry'), { code: 'MANIFEST_NOT_FOUND' }); - mockReadManifest.mockRejectedValue(casErr); - - const adapter = new CasBlobAdapter({ - cas: new MockContentAddressableStore(), - persistence, - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - const stream = adapter.retrieveStream('ghost-oid'); - await expect(async () => { - for await (const _ of stream) { /* drain */ } - }).rejects.toMatchObject({ - code: PersistenceError.E_MISSING_OBJECT, - }); - }); - }); - -}); diff --git a/test/unit/infrastructure/adapters/CasPayloadPointer.test.ts b/test/unit/infrastructure/adapters/CasPayloadPointer.test.ts deleted file mode 100644 index 40f868d3..00000000 --- a/test/unit/infrastructure/adapters/CasPayloadPointer.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/CborCheckpointStoreAdapter.test.ts b/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts index 32264648..325103d0 100644 --- a/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts +++ b/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts @@ -1,439 +1,433 @@ -import { describe, it, expect } from 'vitest'; -import { CborCheckpointStoreAdapter } from '../../../../src/infrastructure/adapters/CborCheckpointStoreAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import CheckpointStorePort, { type CheckpointWriteResult } from '../../../../src/ports/CheckpointStorePort.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { + BundleHandle as GitCasBundleHandle, + RetentionWitness, +} from '@git-stunts/git-cas'; +import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import type { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; import ORSet from '../../../../src/domain/crdt/ORSet.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; -import { Dot } from '../../../../src/domain/crdt/Dot.ts'; -import { EventId } from '../../../../src/domain/utils/EventId.ts'; +import { ProvenanceIndex } from '../../../../src/domain/services/provenance/ProvenanceIndex.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; -import type { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; import type { PropValue } from '../../../../src/domain/types/PropValue.ts'; -import MockBlobPort from '../../../helpers/MockBlobPort.ts'; - -type EncodedRegister = { - eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; - value: PropValue; -}; - -type EncodedEdgeBirth = { - lamport: number; - writerId: string; - patchSha: string; - opIndex: number; -}; - -/** - * Builds a small but representative checkpoint state. - * @returns {WarpState} - */ -function createGoldenState() { +import { EventId } from '../../../../src/domain/utils/EventId.ts'; +import { collectAsyncIterable } from '../../../../src/domain/utils/streamUtils.ts'; +import { + CborCheckpointStoreAdapter, + type GitCasCheckpointFacade, +} from '../../../../src/infrastructure/adapters/CborCheckpointStoreAdapter.ts'; +import { CborIndexStoreAdapter } from '../../../../src/infrastructure/adapters/CborIndexStoreAdapter.ts'; +import GitCasAssetStorageAdapter from '../../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; +import { + DEFAULT_COMMIT_MESSAGE_CODEC, +} from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import CheckpointStorePort from '../../../../src/ports/CheckpointStorePort.ts'; +import { + CHECKPOINT_STORAGE_FORMAT, + LEGACY_CHECKPOINT_STORAGE_FORMAT, +} from '../../../../src/ports/CommitMessageCodecPort.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; + +function createState(): WarpState { const nodeAlive = ORSet.empty(); nodeAlive.add('user:alice', Dot.create('w1', 1)); nodeAlive.add('user:bob', Dot.create('w1', 2)); - const edgeAlive = ORSet.empty(); - edgeAlive.add('user:alice\x00user:bob\x00knows', Dot.create('w1', 3)); - + edgeAlive.add('user:alice\0user:bob\0knows', Dot.create('w1', 3)); const prop = new Map>(); - prop.set('user:alice\x00name', { + prop.set('user:alice\0name', { eventId: new EventId(1, 'w1', 'a'.repeat(40), 0), value: 'Alice', }); - const observedFrontier = VersionVector.empty(); observedFrontier.set('w1', 3); - return new WarpState({ nodeAlive, edgeAlive, prop, observedFrontier }); } -/** - * Creates an in-memory BlobPort backed by MockBlobPort. - * @returns {MockBlobPort} - */ -function createMemoryBlobPort() { - return new MockBlobPort(); +function createFixture() { + const codec = new CborCodec(); + const history = new InMemoryGraphAdapter(); + const backing = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: backing }); + const assets = new GitCasAssetStorageAdapter({ cas, legacyReader: history }); + const checkpoints = new CborCheckpointStoreAdapter({ + codec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + history, + assetStorage: assets, + cas, + }); + return { codec, history, backing, cas, assets, checkpoints }; } -function checkpointTreeOids(result: CheckpointWriteResult): Record { +function record(options: { + index?: boolean; + parents?: string[]; + provenance?: boolean; +} = {}) { + const appliedVV = VersionVector.empty(); + appliedVV.set('w1', 3); return { - 'state/nodeAlive': result.nodeAliveBlobOid, - 'state/edgeAlive': result.edgeAliveBlobOid, - 'state/prop.cbor': result.propBlobOid, - 'state/observedFrontier.cbor': result.observedFrontierBlobOid, - 'state/edgeBirthEvent.cbor': result.edgeBirthEventBlobOid, - 'frontier.cbor': result.frontierBlobOid, - 'appliedVV.cbor': result.appliedVVBlobOid, + graphName: 'test', + state: createState(), + frontier: new Map([['w1', 'a'.repeat(40)]]), + appliedVV, + stateHash: 'd'.repeat(64), + parents: options.parents ?? [], + ...(options.provenance === true ? { provenanceIndex: ProvenanceIndex.empty() } : {}), + ...(options.index === true + ? { indexShards: { 'meta_aa.cbor': new CborCodec().encode({ node: 1 }) } } + : {}), }; } -describe('CborCheckpointStoreAdapter (collapsed)', () => { - it('extends CheckpointStorePort', () => { - const adapter = new CborCheckpointStoreAdapter({ - codec: new CborCodec(), blobPort: createMemoryBlobPort(), - }); - expect(adapter).toBeInstanceOf(CheckpointStorePort); +describe('CborCheckpointStoreAdapter semantic lifecycle', () => { + it('is a CheckpointStorePort and requires every semantic dependency', () => { + const { codec, history, assets, cas, checkpoints } = createFixture(); + expect(checkpoints).toBeInstanceOf(CheckpointStorePort); + + // @ts-expect-error Runtime dependency guard for JavaScript callers. + expect(() => new CborCheckpointStoreAdapter({ commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, history, assetStorage: assets, cas })) + .toThrow(/codec/); + // @ts-expect-error Runtime dependency guard for JavaScript callers. + expect(() => new CborCheckpointStoreAdapter({ codec, history, assetStorage: assets, cas })) + .toThrow(/commitMessageCodec/); + // @ts-expect-error Runtime dependency guard for JavaScript callers. + expect(() => new CborCheckpointStoreAdapter({ codec, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, assetStorage: assets, cas })) + .toThrow(/history/); + expect(() => new CborCheckpointStoreAdapter({ + codec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + history, + assetStorage: assets, + // @ts-expect-error Runtime dependency guard for JavaScript callers. + cas: null, + })).toThrow(/cas/); }); - it('requires codec and blobPort dependencies', () => { - expect(() => - new CborCheckpointStoreAdapter({ - // @ts-expect-error Runtime dependency validation rejects null. - codec: null, - blobPort: createMemoryBlobPort(), - }) - ).toThrow('requires a codec'); - - expect(() => - new CborCheckpointStoreAdapter({ - codec: new CborCodec(), - // @ts-expect-error Runtime dependency validation rejects null. - blobPort: null, - }) - ).toThrow('requires a blobPort'); + it('publishes, resolves, and round-trips checkpoint state', async () => { + const { checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record()); + const loaded = await checkpoints.loadCheckpoint(published.checkpointSha); + + expect(await checkpoints.resolveHead('test')).toBe(published.checkpointSha); + expect(loaded.stateHash).toBe('d'.repeat(64)); + expect(loaded.schema).toBe(5); + expect(loaded.state.nodeAlive.contains('user:alice')).toBe(true); + expect(loaded.state.edgeAlive.contains('user:alice\0user:bob\0knows')).toBe(true); + expect(loaded.state.getNodeProp('user:alice', 'name')?.value).toBe('Alice'); + expect(loaded.frontier).toEqual(new Map([['w1', 'a'.repeat(40)]])); + expect(loaded.appliedVV?.get('w1')).toBe(3); + expect(loaded.indexShardHandles).toBeNull(); + expect(published.retention.reachability).toBe('anchored'); + expect(published.retention.handle.equals(published.bundleHandle)).toBe(true); }); - describe('writeCheckpoint', () => { - it('returns OIDs for state envelope, frontier, appliedVV', async () => { - const blobPort = createMemoryBlobPort(); - const adapter = new CborCheckpointStoreAdapter({ - codec: new CborCodec(), blobPort, + it('rejects a checkpoint bound to a different graph before opening assets', async () => { + const { assets, checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record()); + const open = vi.spyOn(assets, 'open'); + + await expect(checkpoints.loadCheckpoint(published.checkpointSha, 'other')) + .rejects.toMatchObject({ + code: 'E_CHECKPOINT_GRAPH_MISMATCH', + context: { + actualGraphName: 'test', + expectedGraphName: 'other', + }, }); + await expect(checkpoints.readMetadata(published.checkpointSha, 'other')) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_GRAPH_MISMATCH' }); + await expect(checkpoints.loadBasis(published.checkpointSha, 'other')) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_GRAPH_MISMATCH' }); + expect(open).not.toHaveBeenCalled(); + }); - const vv = VersionVector.empty(); - vv.set('w1', 3); - - const result = await adapter.writeCheckpoint({ - state: createGoldenState(), - frontier: new Map([['w1', 'abc123']]), - appliedVV: vv, - stateHash: 'deadbeef', - }); + it('reads metadata without opening checkpoint payloads', async () => { + const { assets, checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record()); + const open = vi.spyOn(assets, 'open'); - expect(typeof result.nodeAliveBlobOid).toBe('string'); - expect(typeof result.edgeAliveBlobOid).toBe('string'); - expect(typeof result.propBlobOid).toBe('string'); - expect(typeof result.observedFrontierBlobOid).toBe('string'); - expect(typeof result.edgeBirthEventBlobOid).toBe('string'); - expect(typeof result.frontierBlobOid).toBe('string'); - expect(typeof result.appliedVVBlobOid).toBe('string'); - expect(result.provenanceIndexBlobOid).toBeNull(); - expect(blobPort.writeBlob).toHaveBeenCalledTimes(7); + await expect(checkpoints.readMetadata(published.checkpointSha)).resolves.toEqual({ + checkpointSha: published.checkpointSha, + stateHash: 'd'.repeat(64), + schema: 5, }); + expect(open).not.toHaveBeenCalled(); + }); - it('writes 8 blobs when provenanceIndex is provided', async () => { - const blobPort = createMemoryBlobPort(); - const adapter = new CborCheckpointStoreAdapter({ - codec: new CborCodec(), blobPort, - }); - - const { ProvenanceIndex } = await import('../../../../src/domain/services/provenance/ProvenanceIndex.ts'); - const provIndex = new ProvenanceIndex(); + it('loads a bounded basis and opens one shard through an opaque handle', async () => { + const { codec, assets, checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record({ index: true })); + const basis = await checkpoints.loadBasis(published.checkpointSha); + const shardHandle = basis.indexShardHandles['meta_aa.cbor']; + if (shardHandle === undefined) { + throw new Error('expected checkpoint index shard handle'); + } + const bytes = await collectAsyncIterable(assets.open(shardHandle)); + + expect(codec.decode(bytes)).toEqual({ node: 1 }); + expect(basis.frontier).toEqual(new Map([['w1', 'a'.repeat(40)]])); + expect(Object.isFrozen(basis.indexShardHandles)).toBe(true); + }); - const vv = VersionVector.empty(); + it('round-trips an optional provenance index through the checkpoint bundle', async () => { + const { checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record({ provenance: true })); + const loaded = await checkpoints.loadCheckpoint(published.checkpointSha); - const result = await adapter.writeCheckpoint({ - state: createGoldenState(), - frontier: new Map(), - appliedVV: vv, - stateHash: 'deadbeef', - provenanceIndex: provIndex, - }); + expect(loaded.provenanceIndex?.toJSON()).toEqual(ProvenanceIndex.empty().toJSON()); + }); - expect(result.provenanceIndexBlobOid).not.toBeNull(); - expect(blobPort.writeBlob).toHaveBeenCalledTimes(8); + it('rejects an index member whose bytes are missing at publication time', async () => { + const { checkpoints } = createFixture(); + const indexShards: Record = {}; + Object.defineProperty(indexShards, 'missing.cbor', { + enumerable: true, + value: undefined, }); - it('writes checkpoint tree blobs directly', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ - codec, - blobPort, - }); - - const vv = VersionVector.empty(); - vv.set('w1', 3); - - const result = await adapter.writeCheckpoint({ - state: createGoldenState(), - frontier: new Map([['w1', 'abc123']]), - appliedVV: vv, - stateHash: 'deadbeef', - }); + await expect(checkpoints.publishCheckpoint({ ...record(), indexShards })) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_MISSING_INDEX_SHARD' }); + }); - const nodeAliveBytes = await blobPort.readBlob(result.nodeAliveBlobOid); - const frontierBytes = await blobPort.readBlob(result.frontierBlobOid); - const appliedVVBytes = await blobPort.readBlob(result.appliedVVBlobOid); + it('fails closed when a checkpoint has no bounded index basis', async () => { + const { checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record()); - expect(nodeAliveBytes.byteLength).toBeGreaterThan(0); - expect(codec.decode(frontierBytes)).toEqual({ w1: 'abc123' }); - expect(codec.decode(appliedVVBytes)).toEqual({ w1: 3 }); - }); + await expect(checkpoints.loadBasis(published.checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_MISSING_INDEX' }); }); - describe('readCheckpoint', () => { - it('round-trips state, frontier, appliedVV', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); + it('rejects a publication result that names a different bundle', async () => { + const fixture = createFixture(); + const otherBundle = await fixture.cas.bundles.putOrdered({ members: [] }); + const cas: GitCasCheckpointFacade = { + bundles: fixture.cas.bundles, + publications: { + commit: async (request) => { + const publication = await fixture.cas.publications.commit(request); + return Object.freeze({ ...publication, root: otherBundle.handle }); + }, + }, + }; + + await expect(checkpointAdapter(fixture, cas).publishCheckpoint(record())) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_PUBLICATION_MISMATCH' }); + }); - const vv = VersionVector.empty(); - vv.set('w1', 3); + it('rejects retention evidence that names a different bundle', async () => { + const fixture = createFixture(); + const otherBundle = await fixture.cas.bundles.putOrdered({ members: [] }); + const cas: GitCasCheckpointFacade = { + bundles: fixture.cas.bundles, + publications: { + commit: async (request) => { + const publication = await fixture.cas.publications.commit(request); + const witness = new RetentionWitness({ + handle: otherBundle.handle, + policy: 'pinned', + reachability: 'anchored', + root: { + kind: 'publication', + namespace: publication.ref, + ref: publication.ref, + generation: publication.commitId, + path: '/', + }, + observedAt: new Date(0).toISOString(), + }); + return Object.freeze({ ...publication, witness }); + }, + }, + }; + + await expect(checkpointAdapter(fixture, cas).publishCheckpoint(record())) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_RETENTION_MISMATCH' }); + }); - const writeResult = await adapter.writeCheckpoint({ - state: createGoldenState(), - frontier: new Map([['w1', 'abc123']]), - appliedVV: vv, - stateHash: 'deadbeef', - }); + it('rejects non-asset checkpoint bundle members', async () => { + const fixture = createFixture(); + const published = await fixture.checkpoints.publishCheckpoint(record()); + const cas: GitCasCheckpointFacade = { + bundles: { + putOrdered: fixture.cas.bundles.putOrdered, + iterateMembers: async function* (request) { + for await (const member of fixture.cas.bundles.iterateMembers(request)) { + yield Object.freeze({ + ...member, + handle: GitCasBundleHandle.parse(published.bundleHandle.toString()), + }); + } + }, + }, + publications: fixture.cas.publications, + }; + + await expect(checkpointAdapter(fixture, cas).loadCheckpoint(published.checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_INVALID_BUNDLE_MEMBER' }); + }); - const data = await adapter.readCheckpoint(checkpointTreeOids(writeResult)); + it('rejects duplicate checkpoint bundle member paths', async () => { + const fixture = createFixture(); + const published = await fixture.checkpoints.publishCheckpoint(record()); + const cas: GitCasCheckpointFacade = { + bundles: { + putOrdered: fixture.cas.bundles.putOrdered, + iterateMembers: async function* (request) { + let duplicated = false; + for await (const member of fixture.cas.bundles.iterateMembers(request)) { + yield member; + if (!duplicated) { + yield member; + duplicated = true; + } + } + }, + }, + publications: fixture.cas.publications, + }; + + await expect(checkpointAdapter(fixture, cas).loadCheckpoint(published.checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_DUPLICATE_BUNDLE_MEMBER' }); + }); - expect(data.state).toBeDefined(); - expect(data.state.nodeAlive).toBeDefined(); - expect(data.frontier.get('w1')).toBe('abc123'); - expect(data.appliedVV).not.toBeNull(); - expect(data.appliedVV?.get('w1')).toBe(3); - expect(data).not.toHaveProperty('stateHash'); - expect(data).not.toHaveProperty('schema'); - }); + it('rejects an empty index member path in a checkpoint bundle', async () => { + const fixture = createFixture(); + const published = await fixture.checkpoints.publishCheckpoint(record()); + const cas: GitCasCheckpointFacade = { + bundles: { + putOrdered: fixture.cas.bundles.putOrdered, + iterateMembers: async function* (request) { + let replaced = false; + for await (const member of fixture.cas.bundles.iterateMembers(request)) { + yield replaced ? member : Object.freeze({ ...member, path: 'index/' }); + replaced = true; + } + }, + }, + publications: fixture.cas.publications, + }; + + await expect(checkpointAdapter(fixture, cas).loadCheckpoint(published.checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_INVALID_BUNDLE_MEMBER' }); + }); - it('throws on missing schema:5 state envelope artifacts', async () => { - const adapter = new CborCheckpointStoreAdapter({ - codec: new CborCodec(), blobPort: createMemoryBlobPort(), - }); - await expect(adapter.readCheckpoint({ 'frontier.cbor': 'frontier' })) - .rejects.toThrow('missing state/nodeAlive'); + it('rejects current checkpoint metadata without a bundle handle', async () => { + const { history, checkpoints } = createFixture(); + const checkpointSha = await history.commitNode({ + message: malformedCheckpointMessage(CHECKPOINT_STORAGE_FORMAT), + parents: [], }); - it('throws on missing frontier.cbor', async () => { - const blobPort = createMemoryBlobPort(); - const adapter = new CborCheckpointStoreAdapter({ - codec: new CborCodec(), blobPort, - }); - - const nodeAliveOid = await blobPort.writeBlob(new Uint8Array([1, 2, 3])); + await expect(checkpoints.loadCheckpoint(checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_MISSING_BUNDLE_HANDLE' }); + await expect(checkpoints.readMetadata(checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_MISSING_BUNDLE_HANDLE' }); + }); - await expect(adapter.readCheckpoint({ 'state/nodeAlive': nodeAliveOid })) - .rejects.toThrow('missing frontier.cbor'); + it('rejects bundle handles attached to legacy checkpoint storage metadata', async () => { + const { history, checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record()); + const currentMessage = DEFAULT_COMMIT_MESSAGE_CODEC.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'test', + stateHash: 'd'.repeat(64), + schema: 5, + checkpointVersion: CHECKPOINT_STORAGE_FORMAT, + bundleHandle: published.bundleHandle, }); - - it('returns stripped index shard oids when index artifacts are present', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - - const vv = VersionVector.empty(); - const writeResult = await adapter.writeCheckpoint({ - state: createGoldenState(), - frontier: new Map(), - appliedVV: vv, - stateHash: 'deadbeef', - }); - - const data = await adapter.readCheckpoint({ - ...checkpointTreeOids(writeResult), - 'index/meta_aa.cbor': 'oid-meta', - 'index/props_aa.cbor': 'oid-props', - }); - - expect(data.indexShardOids).toEqual({ - 'meta_aa.cbor': 'oid-meta', - 'props_aa.cbor': 'oid-props', - }); + const checkpointSha = await history.commitNode({ + message: currentMessage.replace( + `eg-checkpoint: ${CHECKPOINT_STORAGE_FORMAT}`, + `eg-checkpoint: ${LEGACY_CHECKPOINT_STORAGE_FORMAT}`, + ), + parents: [], }); - it('round-trips direct checkpoint blobs', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - - const vv = VersionVector.empty(); - vv.set('w1', 3); - - const writeResult = await adapter.writeCheckpoint({ - state: createGoldenState(), - frontier: new Map([['w1', 'abc123']]), - appliedVV: vv, - stateHash: 'deadbeef', - }); - - const data = await adapter.readCheckpoint(checkpointTreeOids(writeResult)); - - expect(data.frontier.get('w1')).toBe('abc123'); - expect(data.appliedVV?.get('w1')).toBe(3); - }); + await expect(checkpoints.readMetadata(checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_UNSUPPORTED_STORAGE' }); }); - describe('state envelope helpers', () => { - it('sorts props and edge birth events and round-trips birth metadata', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const adapter = new CborCheckpointStoreAdapter({ - codec, - blobPort, - }); + it('rejects unknown checkpoint storage versions', async () => { + const { history, checkpoints } = createFixture(); + const checkpointSha = await history.commitNode({ + message: malformedCheckpointMessage('v999'), + parents: [], + }); - const prop = new Map>([ - ['user:z\x00name', { - eventId: new EventId(3, 'w3', 'c'.repeat(40), 2), - value: 'Zed', - }], - ['user:a\x00name', { - eventId: new EventId(1, 'w1', 'a'.repeat(40), 0), - value: 'Ada', - }], - ]); - - const edgeBirthEvent = new Map([ - ['user:z\x00user:y\x00likes', new EventId(9, 'w9', 'f'.repeat(40), 2)], - ['user:a\x00user:b\x00knows', new EventId(1, 'w1', 'e'.repeat(40), 0)], - ]); - - const state = new WarpState({ - nodeAlive: ORSet.empty(), - edgeAlive: ORSet.empty(), - prop, - observedFrontier: VersionVector.empty(), - edgeBirthEvent, - }); + await expect(checkpoints.readMetadata(checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_UNSUPPORTED_STORAGE' }); + }); - const writeResult = await adapter.writeCheckpoint({ - state, - frontier: new Map(), - appliedVV: VersionVector.empty(), - stateHash: 'deadbeef', - }); - const rawProp = codec.decode>( - await blobPort.readBlob(writeResult.propBlobOid), - ); - const rawEdgeBirthEvent = codec.decode>( - await blobPort.readBlob(writeResult.edgeBirthEventBlobOid), - ); - - expect(rawProp.map(([key]) => key)).toEqual([ - 'user:a\x00name', - 'user:z\x00name', - ]); - expect(rawEdgeBirthEvent.map(([key]) => key)).toEqual([ - 'user:a\x00user:b\x00knows', - 'user:z\x00user:y\x00likes', - ]); - - const decoded = (await adapter.readCheckpoint(checkpointTreeOids(writeResult))).state; - expect(decoded.getEncodedProp('user:a\x00name')?.value).toBe('Ada'); - const decodedBirthEvent = decoded.edgeBirthEvent.get('user:a\x00user:b\x00knows'); - expect(decodedBirthEvent).toBeInstanceOf(EventId); - expect(decodedBirthEvent).toEqual(new EventId(1, 'w1', 'e'.repeat(40), 0)); - }); + it.each([ + null, + [], + { w1: 1 }, + { '': 'a'.repeat(40) }, + { w1: '' }, + ])('rejects malformed decoded frontier entries: %j', async (frontier) => { + const fixture = createFixture(); + const checkpointSha = await checkpointWithFrontier(fixture, frontier); + + await expect(fixture.checkpoints.loadCheckpoint(checkpointSha)) + .rejects.toMatchObject({ code: 'E_CHECKPOINT_INVALID_FRONTIER' }); + }); - it('rejects malformed edge birth payloads', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - const treeOids = { - 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), - 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), - 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ - ['user:a\x00user:b\x00knows', { - lamport: 0, - writerId: '', - patchSha: '0000', - opIndex: 0, - }], - ])), - 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), - }; - - await expect(adapter.readCheckpoint(treeOids)) - .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); + it('publishes coverage as a causal anchor of checkpoint parents', async () => { + const { history, checkpoints } = createFixture(); + const published = await checkpoints.publishCheckpoint(record()); + const coverageSha = await checkpoints.publishCoverage({ + graphName: 'test', + parents: [published.checkpointSha], }); - it('rejects non-array edge birth payloads', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - const treeOids = { - 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), - 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), - 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode({ not: 'an-array' })), - 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), - }; - - await expect(adapter.readCheckpoint(treeOids)) - .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); - }); + expect((await history.getNodeInfo(coverageSha)).parents).toEqual([published.checkpointSha]); + expect(DEFAULT_COMMIT_MESSAGE_CODEC.detectKind(await history.showNode(coverageSha))).toBe('anchor'); + }); +}); - it('rejects malformed edge birth tuples', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - const treeOids = { - 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), - 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), - 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ - ['user:a\x00user:b\x00knows'], - ])), - 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), - }; - - await expect(adapter.readCheckpoint(treeOids)) - .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); - }); +async function checkpointWithFrontier( + fixture: ReturnType, + frontier: unknown, +): Promise { + const published = await fixture.checkpoints.publishCheckpoint(record()); + const frontierMember = fixture.cas.readBundleMembers(published.bundleHandle.toString()) + .find(([path]) => path === 'frontier.cbor'); + if (frontierMember === undefined) { + throw new Error('expected frontier bundle member'); + } + fixture.backing.replace(frontierMember[1], fixture.codec.encode(frontier)); + return published.checkpointSha; +} - it('rejects non-string edge birth keys', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - const treeOids = { - 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), - 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), - 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ - [42, { - lamport: 1, - writerId: 'writer', - patchSha: 'e'.repeat(40), - opIndex: 0, - }], - ])), - 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), - }; - - await expect(adapter.readCheckpoint(treeOids)) - .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); - }); +function checkpointAdapter( + fixture: ReturnType, + cas: GitCasCheckpointFacade, +): CborCheckpointStoreAdapter { + return new CborCheckpointStoreAdapter({ + codec: fixture.codec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + history: fixture.history, + assetStorage: fixture.assets, + cas, + }); +} - it('identifies the string key for a malformed edge birth value', async () => { - const blobPort = createMemoryBlobPort(); - const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); - const treeOids = { - 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), - 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), - 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), - 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ - ['user:a\x00user:b\x00knows', { - lamport: 'not-a-number', - writerId: 'writer', - patchSha: 'e'.repeat(40), - opIndex: 0, - }], - ])), - 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), - }; - - await expect(adapter.readCheckpoint(treeOids)) - .rejects.toThrow('invalid for user:a'); - }); +function malformedCheckpointMessage(storageVersion: string): string { + const legacy = DEFAULT_COMMIT_MESSAGE_CODEC.encodeCheckpoint({ + kind: 'checkpoint', + graph: 'test', + stateHash: 'd'.repeat(64), + schema: 5, + checkpointVersion: LEGACY_CHECKPOINT_STORAGE_FORMAT, + bundleHandle: null, }); -}); + return legacy.replace( + `eg-checkpoint: ${LEGACY_CHECKPOINT_STORAGE_FORMAT}`, + `eg-checkpoint: ${storageVersion}`, + ); +} diff --git a/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts b/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts index 36303de5..6036da9b 100644 --- a/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts +++ b/test/unit/infrastructure/adapters/CborPatchJournalAdapter.test.ts @@ -1,424 +1,210 @@ -import { describe, it, expect, vi } from 'vitest'; -import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; -import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import { describe, expect, it } from 'vitest'; +import { AssetHandle as GitCasAssetHandle } from '@git-stunts/git-cas'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; +import PatchPublicationConflictError from '../../../../src/domain/errors/PatchPublicationConflictError.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 AssetHandle from '../../../../src/domain/storage/AssetHandle.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/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; +import { + DEFAULT_COMMIT_MESSAGE_CODEC, +} from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; import { - LEGACY_EXTERNAL_PATCH_STORAGE, LEGACY_GIT_BLOB_PATCH_STORAGE, - createGitCasPatchStorage, } from '../../../../src/ports/CommitMessageCodecPort.ts'; +import PatchJournalPort from '../../../../src/ports/PatchJournalPort.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. - * If this test breaks, the wire format changed — investigate before fixing. - * - * Note: ops use tuple form `['alice', 1]` for dot — this is the wire format - * that CBOR (de)serializes. The domain typedef uses Dot class, but the codec - * boundary handles the tuple ↔ Dot mapping. - */ -const GOLDEN_PATCH = hydrateDecodedPatch({ - schema: 2, - writer: 'alice', - lamport: 1, - context: { alice: 0 }, - ops: [ - { type: 'NodeAdd', id: 'user:alice', dot: ['alice', 1] }, - { type: 'PropSet', node: 'user:alice', key: 'name', value: 'Alice' }, - ], - reads: [], - writes: ['user:alice'], -}); - -const GOLDEN_HEX = - 'b9000767636f6e74657874b9000165616c69636500676c616d706f727401636f707382b9000563646f74b9000267636f756e7465720168777269746572496465616c696365646e6f64656a757365723a616c6963656b726563656970744e616d65674e6f64654164646573636f7065036474797065674e6f6465416464b90006636b6579646e616d65646e6f64656a757365723a616c6963656b726563656970744e616d656750726f705365746573636f70650264747970656750726f705365746576616c756565416c696365657265616473f766736368656d61026677726974657265616c69636566777269746573816a757365723a616c696365'; - -/** - * Creates an in-memory BlobPort backed by MockBlobPort. - * @returns {MockBlobPort} - */ -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; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; + +const TARGET_REF = 'refs/warp/test/writers/alice'; + +function createFixture(options: { compatibility?: boolean } = {}) { + const history = new InMemoryGraphAdapter(); + const assets = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: assets }); + const journal = new CborPatchJournalAdapter({ + assetStorage: assets, + cas, + codec: new CborCodec(), + commitReader: history, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + ...(options.compatibility === true + ? { compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY } + : {}), + }); + return { history, assets, cas, journal }; } -function createRuntimeClassPatch(): Patch { +function createPatch(lamport: number, nodeId: string): 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'], + lamport, + context: { alice: lamport - 1 }, + ops: [new NodeAdd(nodeId, new Dot('alice', lamport))], + reads: [], + writes: [nodeId], }); } -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(); - const blobPort = createMemoryBlobPort(); - const adapter = new CborPatchJournalAdapter({ codec, blobPort }); - expect(adapter).toBeInstanceOf(PatchJournalPort); - }); - - it('rejects missing required dependencies', () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - - // @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 () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const adapter = new CborPatchJournalAdapter({ codec, blobPort }); - - const oid = await adapter.writePatch(GOLDEN_PATCH); - expect(typeof oid).toBe('string'); - expect(oid.length).toBeGreaterThan(0); - }); - - it('readPatch returns the same Patch object', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const adapter = new CborPatchJournalAdapter({ codec, blobPort }); - - const oid = await adapter.writePatch(GOLDEN_PATCH); - const result = await adapter.readPatch(oid); - const [firstOp, secondOp] = result.ops; - - expect(result.schema).toBe(2); - expect(result.writer).toBe('alice'); - expect(result.lamport).toBe(1); - expect(result.ops).toHaveLength(2); - expect(firstOp).toBeInstanceOf(NodeAdd); - if (!(firstOp instanceof NodeAdd)) { - throw new Error('expected NodeAdd'); - } - expect(firstOp.type).toBe('NodeAdd'); - expect(firstOp.dot).toBeInstanceOf(Dot); - expect(firstOp.node).toBe('user:alice'); - expect(secondOp?.type).toBe('PropSet'); - expect(result.writes).toEqual(['user:alice']); +describe('CborPatchJournalAdapter semantic publication', () => { + it('is a PatchJournalPort and rejects missing semantic dependencies', () => { + const { journal, assets, cas, history } = createFixture(); + expect(journal).toBeInstanceOf(PatchJournalPort); + + // @ts-expect-error Runtime guard for JavaScript callers. + expect(() => new CborPatchJournalAdapter({ cas, codec: new CborCodec(), commitReader: history })) + .toThrow(/assetStorage/); + // @ts-expect-error Runtime guard for JavaScript callers. + expect(() => new CborPatchJournalAdapter({ assetStorage: assets, codec: new CborCodec(), commitReader: history })) + .toThrow(/cas/); + // @ts-expect-error Runtime guard for JavaScript callers. + expect(() => new CborPatchJournalAdapter({ assetStorage: assets, cas, commitReader: history })) + .toThrow(/codec/); }); - describe('golden fixture (wire format stability)', () => { - it('produces byte-identical output to the known golden hex', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const adapter = new CborPatchJournalAdapter({ codec, blobPort }); - - await adapter.writePatch(GOLDEN_PATCH); - const storedBytes = storedBlobBytes(blobPort); - const storedHex = Array.from(storedBytes).map( - (/** @type {number} */ b) => b.toString(16).padStart(2, '0'), - ).join(''); - - expect(storedHex).toBe(GOLDEN_HEX); + it('streams a patch, bundles every attachment, and publishes retention evidence', async () => { + const { history, assets, cas, journal } = createFixture(); + const attachment = await assets.store('attachment'); + const patch = createPatch(1, 'node:a'); + + const published = await journal.appendPatch({ + patch, + graph: 'test', + writer: 'alice', + targetRef: TARGET_REF, + expectedHead: null, + parent: null, + attachments: [attachment, attachment], }); - it('round-trips the golden bytes back to the same domain object', async () => { - const codec = new CborCodec(); - const goldenBytes = hexToBytes(GOLDEN_HEX); - const blobPort = createMemoryBlobPort(); - blobPort.store.set('golden', goldenBytes); - const adapter = new CborPatchJournalAdapter({ codec, blobPort }); - - const result = await adapter.readPatch('golden'); - const [firstOp] = result.ops; - expect(result.schema).toBe(2); - expect(result.writer).toBe('alice'); - expect(result.ops).toHaveLength(2); - expect(firstOp).toBeInstanceOf(NodeAdd); - if (!(firstOp instanceof NodeAdd)) { - throw new Error('expected NodeAdd'); - } - 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)); + expect(await history.readRef(TARGET_REF)).toBe(published.sha); + expect(published.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + root: { kind: 'publication', locator: TARGET_REF, generation: published.sha }, }); + expect(cas.readPublicationRoot(published.sha)).toBe(published.bundleHandle.toString()); + expect(cas.readBundleMembers(published.bundleHandle.toString())).toEqual([ + ['attachments/00000000', attachment.toString()], + ['patch', published.stagedPatch.handle.toString()], + ]); }); - describe('encrypted patch support', () => { - function createMockBlobStorage(opts: MockBlobStorageOptions = {}): MockPatchBlobStorage { - return new MockPatchBlobStorage(opts); - } - - it('uses patchBlobStorage when provided for writePatch', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const patchBlobStorage = createMockBlobStorage({ storeResult: 'encrypted_oid' }); - const adapter = new CborPatchJournalAdapter({ codec, blobPort, patchBlobStorage }); - - const oid = await adapter.writePatch(GOLDEN_PATCH); - expect(oid).toBe('encrypted_oid'); - expect(patchBlobStorage.store).toHaveBeenCalledOnce(); - expect(blobPort.writeBlob).not.toHaveBeenCalled(); + it('round-trips runtime patch classes through the published commit locator', async () => { + const { history, journal } = createFixture(); + const original = createPatch(1, 'node:a'); + const published = await journal.appendPatch({ + patch: original, + graph: 'test', + writer: 'alice', + targetRef: TARGET_REF, + expectedHead: null, + parent: null, + attachments: [], }); + const commit = await history.getNodeInfo(published.sha); + const loaded = await journal.readPatch(DEFAULT_COMMIT_MESSAGE_CODEC.decodePatch(commit.message)); - it('uses patchBlobStorage for readPatch when encrypted flag is set', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const goldenBytes = codec.encode(GOLDEN_PATCH); - const patchBlobStorage = createMockBlobStorage({ retrieveResult: goldenBytes }); - const adapter = new CborPatchJournalAdapter({ codec, blobPort, patchBlobStorage }); + expect(loaded.writer).toBe('alice'); + expect(loaded.lamport).toBe(1); + expect(loaded.ops[0]).toBeInstanceOf(NodeAdd); + expect((loaded.ops[0] as NodeAdd).node).toBe('node:a'); + }); - const result = await adapter.readPatch('some_oid', { encrypted: true }); - expect(result.writer).toBe('alice'); - expect(patchBlobStorage.retrieve).toHaveBeenCalledWith('some_oid'); - expect(blobPort.readBlob).not.toHaveBeenCalled(); + it('maps provider publication conflicts to a typed domain error', async () => { + const { history, assets, cas } = createFixture(); + const providerFailure = Object.assign(new Error('conflict'), { + code: 'PUBLICATION_CONFLICT', }); - - it('reports whether external storage is configured', () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const plainAdapter = new CborPatchJournalAdapter({ codec, blobPort }); - const encryptedAdapter = new CborPatchJournalAdapter({ - codec, - blobPort, - patchBlobStorage: createMockBlobStorage(), - }); - - expect(plainAdapter.usesExternalStorage).toBe(false); - expect(encryptedAdapter.usesExternalStorage).toBe(true); + const journal = new CborPatchJournalAdapter({ + assetStorage: assets, + cas: { + bundles: cas.bundles, + publications: { commit: () => Promise.reject(providerFailure) }, + }, + codec: new CborCodec(), + commitReader: history, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); - 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.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + await expect(journal.appendPatch({ + patch: createPatch(1, 'node:a'), + graph: 'test', + writer: 'alice', + targetRef: TARGET_REF, + expectedHead: null, + parent: null, + attachments: [], + })).rejects.toMatchObject({ + code: PatchPublicationConflictError.CODE, + cause: providerFailure, }); + }); - 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({ encrypted: 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('scans a causal patch range in chronological order and detects divergence', async () => { + const { history, journal } = createFixture(); + const first = await journal.appendPatch({ + patch: createPatch(1, 'node:a'), + graph: 'test', + writer: 'alice', + targetRef: TARGET_REF, + expectedHead: null, + parent: null, + attachments: [], }); - - 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({ encrypted: false }), - compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, - }); - - await expect(adapter.readPatch(patchOid, { storage: LEGACY_GIT_BLOB_PATCH_STORAGE })) - .resolves.toMatchObject({ writer: 'alice' }); + const second = await journal.appendPatch({ + patch: createPatch(2, 'node:b'), + graph: 'test', + writer: 'alice', + targetRef: TARGET_REF, + expectedHead: first.sha, + parent: first.sha, + attachments: [], }); - }); - describe('scanPatchRange', () => { - it('requires a commitPort', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const adapter = new CborPatchJournalAdapter({ codec, blobPort }); + const entries = await journal.scanPatchRange('alice', null, second.sha).collect(); + expect(entries.map((entry) => entry.sha)).toEqual([first.sha, second.sha]); + expect(entries.map((entry) => entry.patch.lamport)).toEqual([1, 2]); + await expect(journal.scanPatchRange('alice', 'f'.repeat(40), second.sha).collect()) + .rejects.toBeInstanceOf(SyncError); - await expect(adapter.scanPatchRange('alice', null, 'sha-1').collect()).rejects.toBeInstanceOf(SyncError); + const nonPatch = await history.commitNode({ + message: 'not a patch publication', + parents: [first.sha], }); + await expect(journal.scanPatchRange('alice', first.sha, nonPatch).collect()) + .rejects.toBeInstanceOf(SyncError); + }); - it('yields hydrated patches in chronological order', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const patch1 = createPatch({ - schema: 2, + it('fails closed for legacy patch storage unless compatibility is explicit', async () => { + const current = createFixture(); + const compatible = createFixture({ compatibility: true }); + const patch = createPatch(1, 'node:a'); + const stagedHandle = await compatible.assets.store(new CborCodec().encode(patch)); + const handle = new AssetHandle(GitCasAssetHandle.parse(stagedHandle.toString()).oid); + const message = DEFAULT_COMMIT_MESSAGE_CODEC.decodePatch( + DEFAULT_COMMIT_MESSAGE_CODEC.encodePatch({ + kind: 'patch', + graph: 'test', writer: 'alice', lamport: 1, - context: { alice: 0 }, - ops: [new NodeAdd('n1', new Dot('alice', 1))], - }); - const patch2 = createPatch({ - schema: 2, - writer: 'alice', - lamport: 2, - context: { alice: 1 }, - ops: [new NodeAdd('n2', new Dot('alice', 2))], - }); - const patchOid1 = 'a'.repeat(40); - const patchOid2 = 'b'.repeat(40); - blobPort.store.set(patchOid1, codec.encode(patch1)); - blobPort.store.set(patchOid2, codec.encode(patch2)); - const commitPort = { - getNodeInfo: vi.fn() - .mockResolvedValueOnce({ - message: encodePatchMessage({ - graph: 'test', - writer: 'alice', - lamport: 2, - patchOid: patchOid2, - }), - parents: ['sha-1'], - }) - .mockResolvedValueOnce({ - message: encodePatchMessage({ - graph: 'test', - writer: 'alice', - lamport: 1, - patchOid: patchOid1, - }), - parents: [], - }), - }; - const adapter = new CborPatchJournalAdapter({ codec, blobPort, commitPort }); - - const entries = await adapter.scanPatchRange('alice', null, 'sha-2').collect(); - - expect(entries).toHaveLength(2); - expect(entries.map((entry) => entry.sha)).toEqual(['sha-1', 'sha-2']); - expect(entries[0]?.patch.ops[0]).toBeInstanceOf(NodeAdd); - expect(entries[1]?.patch.ops[0]).toBeInstanceOf(NodeAdd); - }); - - it('detects divergence when the expected ancestor is not reached', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const patchOid = 'c'.repeat(40); - blobPort.store.set(patchOid, codec.encode(GOLDEN_PATCH)); - const commitPort = { - getNodeInfo: vi.fn().mockResolvedValue({ - message: encodePatchMessage({ - graph: 'test', - writer: 'alice', - lamport: 1, - patchOid, - }), - parents: [], - }), - }; - const adapter = new CborPatchJournalAdapter({ codec, blobPort, commitPort }); - - await expect(adapter.scanPatchRange('alice', 'sha-root', 'sha-1').collect()).rejects.toBeInstanceOf(SyncError); - }); - - it('stops scanning when a non-patch commit is encountered', async () => { - const codec = new CborCodec(); - const blobPort = createMemoryBlobPort(); - const commitPort = { - getNodeInfo: vi.fn().mockResolvedValue({ - message: 'checkpoint message', - parents: ['sha-parent'], - }), - }; - const adapter = new CborPatchJournalAdapter({ codec, blobPort, commitPort }); - - const entries = await adapter.scanPatchRange('alice', null, 'sha-stop').collect(); + patchHandle: handle, + schema: 3, + storage: LEGACY_GIT_BLOB_PATCH_STORAGE, + }), + ); - expect(entries).toEqual([]); + await expect(current.journal.readPatch(message)).rejects.toThrow(/Legacy patch storage reads/); + await expect(compatible.journal.readPatch(message)).resolves.toMatchObject({ + writer: 'alice', + lamport: 1, }); }); }); diff --git a/test/unit/infrastructure/adapters/GitCasAssetStorageAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasAssetStorageAdapter.test.ts new file mode 100644 index 00000000..42c830ca --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasAssetStorageAdapter.test.ts @@ -0,0 +1,203 @@ +import { + AssetHandle as GitCasAssetHandle, + type AssetCapability, +} from '@git-stunts/git-cas'; +import { describe, expect, it, vi } from 'vitest'; + +import PersistenceError from '../../../../src/domain/errors/PersistenceError.ts'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import GitCasAssetStorageAdapter from '../../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; +import { + V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, +} from '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; + +const LEGACY_OID = 'a'.repeat(40); + +function createFixture() { + const history = new InMemoryGraphAdapter(); + const backing = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: backing }); + const put = vi.fn(cas.assets.put); + const facade = { + assets: { + put, + adopt: cas.assets.adopt, + open: cas.assets.open, + }, + }; + const legacyReader = { readBlob: vi.fn(async () => null) }; + const adapter = new GitCasAssetStorageAdapter({ cas: facade, legacyReader }); + return { adapter, backing, cas, history, legacyReader, put }; +} + +function validHandle(oid = 'b'.repeat(64)): AssetHandle { + return new AssetHandle(new GitCasAssetHandle({ + codec: 'raw', + hashAlgorithm: 'sha256', + oid, + }).toString()); +} + +async function collect(source: AsyncIterable): Promise { + const chunks: Uint8Array[] = []; + for await (const chunk of source) { + chunks.push(chunk); + } + return Uint8Array.from(chunks.flatMap((chunk) => [...chunk])); +} + +async function* chunks(): AsyncGenerator { + yield new Uint8Array([1, 2]); + yield new Uint8Array([3, 4]); +} + +describe('GitCasAssetStorageAdapter', () => { + it('hands the original stream to git-cas and round-trips through an opaque handle', async () => { + const { adapter, put } = createFixture(); + const source = chunks(); + + const staged = await adapter.stage(source, { slug: 'streamed' }); + + expect(put).toHaveBeenCalledWith(expect.objectContaining({ + source, + slug: 'streamed', + filename: 'content', + })); + expect(staged.retention).toEqual({ + reachability: 'unanchored', + protection: 'not-established', + }); + await expect(collect(adapter.open(staged.handle))) + .resolves.toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it('rejects a staged asset whose byte count differs from the declared size', async () => { + const { adapter } = createFixture(); + + await expect(adapter.stage(chunks(), { + slug: 'mismatched', + expectedSize: 3, + })).rejects.toMatchObject({ + code: 'E_ASSET_SIZE_MISMATCH', + expectedSize: 3, + actualSize: 4, + }); + }); + + it('adopts legacy asset-tree OIDs before opening them', async () => { + const { backing, cas, history } = createFixture(); + const stored = await backing.store('legacy tree'); + const oid = GitCasAssetHandle.parse(stored.toString()).oid; + vi.spyOn(history, 'readObjectType').mockResolvedValue('tree'); + const adopt = vi.fn(cas.assets.adopt); + const adapter = new GitCasAssetStorageAdapter({ + cas: { assets: { ...cas.assets, adopt } }, + legacyReader: { readBlob: vi.fn(async () => null) }, + }); + + await expect(collect(adapter.open(new AssetHandle(oid)))) + .resolves.toEqual(new TextEncoder().encode('legacy tree')); + expect(adopt).toHaveBeenCalledWith({ treeOid: oid }); + }); + + it('requires explicit compatibility before returning a legacy raw blob', async () => { + const bytes = new Uint8Array([7, 8, 9]); + const assets = legacyFallbackAssets(); + const legacyReader = { readBlob: vi.fn(async () => bytes) }; + const current = new GitCasAssetStorageAdapter({ + cas: { assets }, + legacyReader, + }); + const compatible = new GitCasAssetStorageAdapter({ + cas: { assets }, + legacyReader, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + + await expect(collect(current.open(new AssetHandle(LEGACY_OID)))) + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + expect(legacyReader.readBlob).not.toHaveBeenCalled(); + await expect(collect(compatible.open(new AssetHandle(LEGACY_OID)))) + .resolves.toEqual(bytes); + }); + + it('preserves current asset adoption failures without probing legacy blobs', async () => { + const corruption = Object.assign(new Error('manifest integrity failure'), { + code: 'MANIFEST_INTEGRITY_ERROR', + }); + const legacyReader = { readBlob: vi.fn(async () => new Uint8Array([1])) }; + const assets = { + ...legacyFallbackAssets(), + adopt: vi.fn(async () => Promise.reject(corruption)), + }; + const adapter = new GitCasAssetStorageAdapter({ + cas: { assets }, + legacyReader, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + + await expect(collect(adapter.open(new AssetHandle(LEGACY_OID)))) + .rejects.toBe(corruption); + expect(legacyReader.readBlob).not.toHaveBeenCalled(); + }); + + it('preserves unexpected legacy read errors and reports missing objects with cause', async () => { + const assets = legacyFallbackAssets(); + const unexpected = new Error('disk unavailable'); + const failed = new GitCasAssetStorageAdapter({ + cas: { assets }, + legacyReader: { readBlob: vi.fn(async () => Promise.reject(unexpected)) }, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + const missingCause = new PersistenceError( + 'missing legacy blob', + PersistenceError.E_MISSING_OBJECT, + ); + const missing = new GitCasAssetStorageAdapter({ + cas: { assets }, + legacyReader: { readBlob: vi.fn(async () => Promise.reject(missingCause)) }, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + + await expect(collect(failed.open(new AssetHandle(LEGACY_OID)))).rejects.toBe(unexpected); + await expect(collect(missing.open(new AssetHandle(LEGACY_OID)))) + .rejects.toMatchObject({ + code: PersistenceError.E_MISSING_OBJECT, + cause: expect.any(Error), + }); + }); + + it('maps git-cas encryption failures at the streaming boundary', async () => { + const upstream = Object.assign(new Error('legacy encryption scheme'), { + code: 'LEGACY_SCHEME', + }); + const assets = { + ...legacyFallbackAssets(), + open: vi.fn((): AsyncIterable => { + throw upstream; + }), + }; + const adapter = new GitCasAssetStorageAdapter({ + cas: { assets }, + legacyReader: { readBlob: vi.fn(async () => null) }, + }); + + await expect(collect(adapter.open(validHandle()))) + .rejects.toMatchObject({ + code: 'E_CAS_LEGACY_ENCRYPTION_SCHEME', + }); + }); +}); + +function legacyFallbackAssets(): Pick { + return { + put: vi.fn(), + adopt: vi.fn(async () => Promise.reject( + Object.assign(new Error('not an asset tree'), { code: 'GIT_ERROR' }), + )), + open: vi.fn(() => chunks()), + }; +} diff --git a/test/unit/infrastructure/adapters/GitCasAuditLogAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasAuditLogAdapter.test.ts new file mode 100644 index 00000000..043ff51d --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasAuditLogAdapter.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from 'vitest'; + +import GitCasAssetStorageAdapter from '../../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; +import GitCasAuditLogAdapter from '../../../../src/infrastructure/adapters/GitCasAuditLogAdapter.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; +import { + V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, +} from '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts'; + +const encoder = new TextEncoder(); + +function createFixture(options: { readonly compatibility?: boolean } = {}) { + const history = new InMemoryGraphAdapter(); + const backing = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: backing }); + const assets = new GitCasAssetStorageAdapter({ cas, legacyReader: history }); + const auditCas = { + assets: { + put: vi.fn(cas.assets.put), + adopt: vi.fn(cas.assets.adopt), + open: vi.fn(cas.assets.open), + }, + publications: cas.publications, + }; + const log = new GitCasAuditLogAdapter({ + history, + cas: auditCas, + assets, + ...(options.compatibility === true + ? { compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY } + : {}), + }); + return { assets, auditCas, backing, cas, history, log }; +} + +describe('GitCasAuditLogAdapter', () => { + it('publishes, lists, and reads causally retained audit receipts', async () => { + const { auditCas, log } = createFixture(); + const alice = await log.append({ + graphName: 'events', + writerId: 'alice', + expectedHead: null, + parent: null, + message: 'audit alice', + receipt: encoder.encode('alice receipt'), + }); + await log.append({ + graphName: 'events', + writerId: 'bob', + expectedHead: null, + parent: null, + message: 'audit bob', + receipt: encoder.encode('bob receipt'), + }); + + expect(await log.readHead('events', 'alice')).toBe(alice.sha); + expect(await log.listWriterIds('events')).toEqual(['alice', 'bob']); + expect(alice.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + root: { + kind: 'publication', + generation: alice.sha, + }, + }); + await expect(log.readEntry(alice.sha)).resolves.toMatchObject({ + sha: alice.sha, + message: 'audit alice', + parents: [], + receipt: encoder.encode('alice receipt'), + }); + expect(auditCas.assets.open).not.toHaveBeenCalled(); + }); + + it('reads the legacy single-blob receipt tree through the compatibility path', async () => { + const { assets, auditCas, history, log } = createFixture(); + auditCas.assets.adopt.mockRejectedValueOnce(legacyTreeError()); + const receiptOid = await history.writeBlob(encoder.encode('legacy receipt')); + const treeOid = await history.writeTree([ + `100644 blob ${receiptOid}\treceipt.cbor`, + ]); + const sha = await history.commitNodeWithTree({ + treeOid, + parents: [], + message: 'legacy audit', + }); + + const readTreeOids = vi.spyOn(history, 'readTreeOids'); + await expect(log.readEntry(sha)).rejects.toMatchObject({ + code: 'E_LEGACY_SUBSTRATE_DISABLED', + }); + expect(readTreeOids).not.toHaveBeenCalled(); + + auditCas.assets.adopt.mockRejectedValueOnce(legacyTreeError()); + const compatible = new GitCasAuditLogAdapter({ + history, + cas: auditCas, + assets, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + await expect(compatible.readEntry(sha)).resolves.toMatchObject({ + sha, + message: 'legacy audit', + receipt: encoder.encode('legacy receipt'), + }); + }); + + it('rejects malformed legacy receipt trees without guessing', async () => { + const { auditCas, history, log } = createFixture({ compatibility: true }); + auditCas.assets.adopt.mockRejectedValueOnce(legacyTreeError()); + const first = await history.writeBlob(encoder.encode('first')); + const second = await history.writeBlob(encoder.encode('second')); + const treeOid = await history.writeTree([ + `100644 blob ${first}\treceipt.cbor`, + `100644 blob ${second}\textra.cbor`, + ]); + const sha = await history.commitNodeWithTree({ + treeOid, + parents: [], + message: 'malformed audit', + }); + + await expect(log.readEntry(sha)).rejects.toMatchObject({ + code: 'E_AUDIT_RECEIPT_TREE', + }); + }); + + it('preserves current receipt failures without probing legacy trees', async () => { + const { auditCas, history, log } = createFixture(); + const corruption = Object.assign(new Error('manifest integrity failure'), { + code: 'MANIFEST_INTEGRITY_ERROR', + }); + auditCas.assets.adopt.mockRejectedValueOnce(corruption); + const readTreeOids = vi.spyOn(history, 'readTreeOids'); + const sha = await history.commitNodeWithTree({ + treeOid: 'a'.repeat(40), + parents: [], + message: 'corrupt current audit', + }); + + await expect(log.readEntry(sha)).rejects.toBe(corruption); + expect(readTreeOids).not.toHaveBeenCalled(); + }); + + it('maps provider publication conflicts to the audit boundary error', async () => { + const { assets, auditCas, history } = createFixture(); + const log = new GitCasAuditLogAdapter({ + history, + assets, + cas: { + assets: auditCas.assets, + publications: { + commit: vi.fn().mockRejectedValue(Object.assign( + new Error('publication conflict'), + { + code: 'PUBLICATION_CONFLICT', + meta: { observed: 'f'.repeat(40) }, + }, + )), + }, + }, + }); + + await expect(log.append({ + graphName: 'events', + writerId: 'alice', + expectedHead: 'a'.repeat(40), + parent: 'a'.repeat(40), + message: 'audit alice', + receipt: encoder.encode('alice receipt'), + })).rejects.toMatchObject({ + code: 'E_AUDIT_PUBLICATION_CONFLICT', + expectedHead: 'a'.repeat(40), + observedHead: 'f'.repeat(40), + }); + }); + + it.each([ + Object.assign(new Error('missing metadata'), { code: 'PUBLICATION_CONFLICT' }), + Object.assign(new Error('null metadata'), { code: 'PUBLICATION_CONFLICT', meta: null }), + Object.assign(new Error('missing observation'), { code: 'PUBLICATION_CONFLICT', meta: {} }), + Object.assign(new Error('invalid observation'), { code: 'PUBLICATION_CONFLICT', meta: { observed: 42 } }), + ])('maps a provider conflict without a usable observed head', async (conflict) => { + const { assets, auditCas, history } = createFixture(); + const log = new GitCasAuditLogAdapter({ + history, + assets, + cas: { + assets: auditCas.assets, + publications: { commit: vi.fn().mockRejectedValue(conflict) }, + }, + }); + + await expect(log.append({ + graphName: 'events', + writerId: 'alice', + expectedHead: null, + parent: null, + message: 'audit alice', + receipt: encoder.encode('alice receipt'), + })).rejects.toMatchObject({ + code: 'E_AUDIT_PUBLICATION_CONFLICT', + expectedHead: null, + observedHead: null, + }); + }); + + it('preserves non-conflict publication failures', async () => { + const { assets, auditCas, history } = createFixture(); + const failure = new Error('publication storage unavailable'); + const log = new GitCasAuditLogAdapter({ + history, + assets, + cas: { + assets: auditCas.assets, + publications: { commit: vi.fn().mockRejectedValue(failure) }, + }, + }); + + await expect(log.append({ + graphName: 'events', + writerId: 'alice', + expectedHead: null, + parent: null, + message: 'audit alice', + receipt: encoder.encode('alice receipt'), + })).rejects.toBe(failure); + }); +}); + +function legacyTreeError(): Error & { readonly code: string } { + return Object.assign(new Error('asset manifest not found'), { + code: 'MANIFEST_NOT_FOUND', + }); +} diff --git a/test/unit/infrastructure/adapters/GitCasIntentStoreAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasIntentStoreAdapter.test.ts new file mode 100644 index 00000000..108525ff --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasIntentStoreAdapter.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; + +import type { WarpIntentDescriptor } from '../../../../src/domain/types/WarpIntentDescriptor.ts'; +import GitCasAssetStorageAdapter from '../../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; +import GitCasIntentStoreAdapter from '../../../../src/infrastructure/adapters/GitCasIntentStoreAdapter.ts'; +import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; + +const SHA = 'a'.repeat(40); + +function createFixture() { + const history = new InMemoryGraphAdapter(); + const backing = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: backing }); + const assets = new GitCasAssetStorageAdapter({ cas, legacyReader: history }); + const codec = new CborCodec(); + const intents = new GitCasIntentStoreAdapter({ history, cas, assets, codec }); + return { assets, backing, cas, codec, history, intents }; +} + +function descriptor(intentId: string): WarpIntentDescriptor { + return { + intentId, + nutritionLabel: { + bundleHash: `bundle-${intentId}`, + coreHash: `core-${intentId}`, + profile: 'default', + budget: 'bounded', + }, + precommitGuards: [ + { + op: 'nodeStatus', + nodeId: 'node:a', + expected: 'ready', + failureTag: 'not-ready', + }, + { + op: 'nodeUnassignedOrSelf', + nodeId: 'node:b', + agentId: 'alice', + failureTag: 'assigned', + }, + { + op: 'edgeExists', + nodeId: 'node:c', + failureTag: 'edge-missing', + }, + ], + suffixTransform: { + op: 'append', + payload: { intentId, route: ['queued', { intentId }] }, + }, + }; +} + +describe('GitCasIntentStoreAdapter', () => { + it('publishes retained descriptors and scans them in causal order', async () => { + const { intents } = createFixture(); + const first = await intents.publish({ + graphName: 'events', + channel: 'queued', + ownerId: 'alice', + descriptor: descriptor('intent-1'), + }); + const second = await intents.publish({ + graphName: 'events', + channel: 'queued', + ownerId: 'alice', + descriptor: descriptor('intent-2'), + }); + + expect(first.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + root: { kind: 'publication', generation: first.sha }, + }); + expect(second.sha).not.toBe(first.sha); + await expect(intents.scan('events', 'queued', 'alice').collect()) + .resolves.toEqual([descriptor('intent-1'), descriptor('intent-2')]); + }); + + it('rejects parent cycles, nonlinear history, and identity substitution', async () => { + const fixture = createFixture(); + const published = await fixture.intents.publish({ + graphName: 'events', + channel: 'queued', + ownerId: 'alice', + descriptor: descriptor('intent-1'), + }); + const node = await fixture.history.getNodeInfo(published.sha); + const cycle = new GitCasIntentStoreAdapter({ + history: fixedHistory(node.message, [SHA]), + cas: fixture.cas, + assets: fixture.assets, + codec: fixture.codec, + }); + const substituted = new GitCasIntentStoreAdapter({ + history: fixedHistory(node.message), + cas: fixture.cas, + assets: fixture.assets, + codec: fixture.codec, + }); + const nonlinear = new GitCasIntentStoreAdapter({ + history: fixedHistory(node.message, [SHA, 'b'.repeat(40)]), + cas: fixture.cas, + assets: fixture.assets, + codec: fixture.codec, + }); + + await expect(cycle.scan('events', 'queued', 'alice').collect()) + .rejects.toMatchObject({ code: 'E_INTENT_JOURNAL_CYCLE' }); + await expect(substituted.scan('events', 'admitted', 'alice').collect()) + .rejects.toMatchObject({ code: 'E_INTENT_JOURNAL_IDENTITY' }); + await expect(nonlinear.scan('events', 'queued', 'alice').collect()) + .rejects.toMatchObject({ code: 'E_INTENT_JOURNAL_NON_LINEAR' }); + }); + + it('rejects malformed journal trailers and channel values', async () => { + const fixture = createFixture(); + const malformedChannel = journalWithHandle( + fixture, + fixture.assets, + 'invalid', + ); + const missingTrailers = new GitCasIntentStoreAdapter({ + history: fixedHistory('warp:intent'), + cas: fixture.cas, + assets: fixture.assets, + codec: fixture.codec, + }); + + await expect((await malformedChannel).scan('events', 'queued', 'alice').collect()) + .rejects.toMatchObject({ code: 'E_INTENT_JOURNAL_MESSAGE' }); + await expect(missingTrailers.scan('events', 'queued', 'alice').collect()) + .rejects.toMatchObject({ code: 'E_INTENT_JOURNAL_MESSAGE' }); + }); + + it.each([ + null, + {}, + { ...descriptor('bad-guards'), precommitGuards: 'not-an-array' }, + { + ...descriptor('bad-op'), + precommitGuards: [{ + op: 'unknown', + nodeId: 'node:a', + failureTag: 'bad-op', + }], + }, + { + ...descriptor('bad-optional'), + precommitGuards: [{ + op: 'nodeStatus', + nodeId: 'node:a', + expected: '', + failureTag: 'bad-optional', + }], + }, + { + ...descriptor('missing-status'), + precommitGuards: [{ + op: 'nodeStatus', + nodeId: 'node:a', + failureTag: 'missing-status', + }], + }, + { + ...descriptor('missing-agent'), + precommitGuards: [{ + op: 'nodeUnassignedOrSelf', + nodeId: 'node:a', + failureTag: 'missing-agent', + }], + }, + { + ...descriptor('bad-payload'), + suffixTransform: { + op: 'append', + payload: { unsupported: new Set(['unsupported']) }, + }, + }, + ])('rejects malformed descriptor assets without partial hydration', async (value) => { + const fixture = createFixture(); + const bytes = fixture.codec.encode(value); + const staged = await fixture.assets.stage(singleChunk(bytes), { + slug: 'malformed-intent', + filename: 'intent.cbor', + }); + const journal = await journalWithHandle(fixture, fixture.assets, 'queued', staged.handle.toString()); + + await expect(journal.scan('events', 'queued', 'alice').collect()) + .rejects.toMatchObject({ code: 'E_INTENT_DESCRIPTOR_ASSET' }); + }); +}); + +function fixedHistory(message: string, parents: string[] = []) { + return { + readRef: async (): Promise => SHA, + getNodeInfo: async (): Promise<{ message: string; parents: string[] }> => ({ + message, + parents, + }), + }; +} + +async function journalWithHandle( + fixture: ReturnType, + assets: GitCasAssetStorageAdapter, + channel: string, + handle = 'unused', +): Promise { + const message = [ + 'warp:intent', + '', + 'eg-graph: events', + `eg-intent-channel: ${channel}`, + 'eg-intent-owner: alice', + `eg-intent-descriptor-handle: ${handle}`, + ].join('\n'); + return new GitCasIntentStoreAdapter({ + history: fixedHistory(message), + cas: fixture.cas, + assets, + codec: fixture.codec, + }); +} + +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} diff --git a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts index bf7b4a3d..1bdf5b49 100644 --- a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts @@ -12,6 +12,8 @@ import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/G import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import CryptoPort from '../../../../src/ports/CryptoPort.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; @@ -107,6 +109,9 @@ describe('GitCasRepositoryAdapter', () => { vi.spyOn(history, 'nodeExists').mockResolvedValue(true); vi.spyOn(history, 'readObjectType').mockResolvedValue('tree'); + const assetStorage = new InMemoryBlobStorageAdapter(); + const highLevelCas = new InMemoryGitCasFacade({ history, storage: assetStorage }); + const putAsset = vi.fn(highLevelCas.assets.put); const rootSet = createRootSet(); const store = vi.fn().mockResolvedValue({ slug: 'manifest', chunks: [] }); const createTree = vi.fn() @@ -115,6 +120,13 @@ describe('GitCasRepositoryAdapter', () => { .mockResolvedValueOnce('3'.repeat(40)) .mockResolvedValueOnce('4'.repeat(40)); const cas = { + assets: { + put: putAsset, + adopt: highLevelCas.assets.adopt, + open: highLevelCas.assets.open, + }, + bundles: highLevelCas.bundles, + publications: highLevelCas.publications, rootSets: { open: vi.fn(async () => rootSet) }, readManifest: vi.fn(), restore: vi.fn(), @@ -129,7 +141,7 @@ describe('GitCasRepositoryAdapter', () => { commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); - await services.content.store('content', { slug: 'content' }); + await services.content.stage(singleChunk('content'), { slug: 'content' }); const stateSnapshots = services.stateSnapshots; if (stateSnapshots === undefined) { throw new Error('Git repository storage must provide state snapshots'); @@ -165,9 +177,14 @@ describe('GitCasRepositoryAdapter', () => { null, ); - expect(store).toHaveBeenCalledTimes(3); - expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'content' })); + expect(putAsset).toHaveBeenCalledTimes(2); + expect(putAsset).toHaveBeenCalledWith(expect.objectContaining({ slug: 'content' })); + expect(putAsset).toHaveBeenCalledWith(expect.objectContaining({ slug: 'trust-record-hash' })); + expect(store).toHaveBeenCalledTimes(1); expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'snapshot-1' })); - expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'trust-record-hash' })); }); }); + +async function* singleChunk(value: string): AsyncGenerator { + yield new TextEncoder().encode(value); +} diff --git a/test/unit/infrastructure/adapters/GitCasStrandStoreAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasStrandStoreAdapter.test.ts new file mode 100644 index 00000000..cf8dac91 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasStrandStoreAdapter.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { buildStrandRef } from '../../../../src/domain/utils/RefLayout.ts'; +import GitCasAssetStorageAdapter from '../../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; +import GitCasStrandStoreAdapter from '../../../../src/infrastructure/adapters/GitCasStrandStoreAdapter.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; +import { + V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, +} from '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts'; + +const encoder = new TextEncoder(); + +function createFixture(options: { readonly compatibility?: boolean } = {}) { + const history = new InMemoryGraphAdapter(); + const backing = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history, storage: backing }); + const assets = new GitCasAssetStorageAdapter({ cas, legacyReader: history }); + const strands = new GitCasStrandStoreAdapter({ + history, + cas, + assets, + ...(options.compatibility === true + ? { compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY } + : {}), + }); + return { assets, backing, cas, history, strands }; +} + +describe('GitCasStrandStoreAdapter', () => { + it('publishes descriptor bundles with attachments and retention evidence', async () => { + const { assets, cas, strands } = createFixture(); + const attachment = await assets.stage(singleChunk(encoder.encode('attachment')), { + slug: 'attachment', + }); + const published = await strands.publishDescriptor({ + graphName: 'events', + strandId: 'draft', + descriptor: encoder.encode('{"version":1}'), + attachments: [attachment.handle, attachment.handle], + }); + + expect(published.retention).toMatchObject({ + policy: 'pinned', + reachability: 'anchored', + root: { kind: 'publication', generation: published.revision }, + }); + expect(cas.readBundleMembers(published.bundleHandle.toString())).toEqual([ + ['descriptor', published.descriptorAsset.handle.toString()], + ['attachments/00000000', attachment.handle.toString()], + ]); + await expect(strands.readDescriptor('events', 'draft')) + .resolves.toEqual(encoder.encode('{"version":1}')); + }); + + it('lists, probes, and deletes only direct strand descriptors', async () => { + const { history, strands } = createFixture(); + const alpha = await strands.publishDescriptor({ + graphName: 'events', + strandId: 'alpha', + descriptor: encoder.encode('alpha'), + attachments: [], + }); + await strands.publishDescriptor({ + graphName: 'events', + strandId: 'zeta', + descriptor: encoder.encode('zeta'), + attachments: [], + }); + await history.updateRef('refs/warp/events/strands/alpha/braids/other', alpha.revision); + + expect(await strands.listStrandIds('events')).toEqual(['alpha', 'zeta']); + expect(await strands.hasDescriptor('events', 'alpha')).toBe(true); + expect(await strands.deleteDescriptor('events', 'alpha')).toBe(true); + expect(await strands.deleteDescriptor('events', 'alpha')).toBe(false); + expect(await strands.hasDescriptor('events', 'alpha')).toBe(false); + }); + + it('reads legacy refs that point directly to descriptor blobs', async () => { + const { assets, cas, history, strands } = createFixture(); + const readObjectType = vi.spyOn(history, 'readObjectType'); + const readBlob = vi.spyOn(history, 'readBlob'); + const bytes = encoder.encode('legacy descriptor'); + const blob = await history.writeBlob(bytes); + await history.updateRef(buildStrandRef('events', 'legacy'), blob); + + await expect(strands.readDescriptor('events', 'legacy')) + .rejects.toMatchObject({ code: 'E_LEGACY_SUBSTRATE_DISABLED' }); + expect(readBlob).not.toHaveBeenCalled(); + + const compatible = new GitCasStrandStoreAdapter({ + history, + cas, + assets, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, + }); + await expect(compatible.readDescriptor('events', 'legacy')).resolves.toEqual(bytes); + expect(readObjectType).toHaveBeenCalledWith(blob); + await expect(strands.readDescriptor('events', 'missing')).resolves.toBeNull(); + }); + + it('does not delete a descriptor concurrently replaced after the read', async () => { + const { history, strands } = createFixture(); + await strands.publishDescriptor({ + graphName: 'events', + strandId: 'draft', + descriptor: encoder.encode('draft'), + attachments: [], + }); + const ref = buildStrandRef('events', 'draft'); + const replacement = 'd'.repeat(40); + const compareAndDelete = history.compareAndDeleteRef.bind(history); + vi.spyOn(history, 'compareAndDeleteRef').mockImplementationOnce(async (target, expected) => { + await history.updateRef(target, replacement); + return await compareAndDelete(target, expected); + }); + + await expect(strands.deleteDescriptor('events', 'draft')).resolves.toBe(false); + await expect(history.readRef(ref)).resolves.toBe(replacement); + }); + + it('replaces a legacy blob ref without treating the blob as a commit parent', async () => { + const { history, strands } = createFixture(); + const blob = await history.writeBlob(encoder.encode('legacy descriptor')); + await history.updateRef(buildStrandRef('events', 'legacy'), blob); + + const published = await strands.publishDescriptor({ + graphName: 'events', + strandId: 'legacy', + descriptor: encoder.encode('current descriptor'), + attachments: [], + }); + + await expect(history.getNodeInfo(published.revision)).resolves.toMatchObject({ + parents: [], + }); + }); + + it('rejects descriptor refs targeting unsupported object types', async () => { + const { history, strands } = createFixture(); + await history.updateRef(buildStrandRef('events', 'tree'), history.emptyTree); + + await expect(strands.readDescriptor('events', 'tree')) + .rejects.toMatchObject({ code: 'E_STRAND_CORRUPT' }); + }); + + it('rejects publication identity mismatches and missing descriptor trailers', async () => { + const { history, strands } = createFixture(); + const mismatched = await commitDescriptorMessage(history, [ + 'warp:strand-descriptor', + '', + 'eg-graph: other', + 'eg-strand: draft', + 'eg-strand-descriptor-handle: unused', + ].join('\n')); + await history.updateRef(buildStrandRef('events', 'draft'), mismatched); + + await expect(strands.readDescriptor('events', 'draft')) + .rejects.toMatchObject({ code: 'E_STRAND_CORRUPT' }); + + const malformed = await commitDescriptorMessage(history, 'warp:strand-descriptor'); + await history.updateRef(buildStrandRef('events', 'malformed'), malformed); + await expect(strands.readDescriptor('events', 'malformed')) + .rejects.toMatchObject({ code: 'E_STRAND_CORRUPT' }); + }); +}); + +async function commitDescriptorMessage( + history: InMemoryGraphAdapter, + message: string, +): Promise { + return await history.commitNodeWithTree({ + treeOid: history.emptyTree, + parents: [], + message, + }); +} + +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} diff --git a/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.ts b/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.ts index 579d316f..5f883386 100644 --- a/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.ts +++ b/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.ts @@ -479,15 +479,11 @@ describe('GitTimelineHistoryAdapter coverage', () => { }); }); - it('rejects unsupported git object types for content anchors', async () => { + it('reports object types without applying content-anchor policy', async () => { const oid = 'a'.repeat(40); mockPlumbing.execute.mockResolvedValue('commit\n'); - await expect(adapter.readObjectType(oid)) - .rejects.toMatchObject({ - code: 'E_UNSUPPORTED_CONTENT_ANCHOR_OBJECT_TYPE', - message: `Unsupported Git object type for content anchor ${oid}: commit`, - }); + await expect(adapter.readObjectType(oid)).resolves.toBe('commit'); }); it('wraps object-type read failures with object context', async () => { diff --git a/test/unit/infrastructure/adapters/GitTimelineHistoryAdapter.compareAndDeleteRef.test.ts b/test/unit/infrastructure/adapters/GitTimelineHistoryAdapter.compareAndDeleteRef.test.ts new file mode 100644 index 00000000..4c1981ec --- /dev/null +++ b/test/unit/infrastructure/adapters/GitTimelineHistoryAdapter.compareAndDeleteRef.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import PersistenceError from '../../../../src/domain/errors/PersistenceError.ts'; +import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; + +const REF = 'refs/warp/events/strands/draft'; +const EXPECTED = 'a'.repeat(40); +type Execute = (options: { args: string[]; input?: string | Buffer }) => Promise; + +describe('GitTimelineHistoryAdapter.compareAndDeleteRef', () => { + let execute: ReturnType>; + let adapter: GitTimelineHistoryAdapter; + + beforeEach(() => { + execute = vi.fn(); + adapter = new GitTimelineHistoryAdapter({ + plumbing: { + emptyTree: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + execute, + executeStream: vi.fn(), + }, + }); + }); + + it('deletes only the expected revision', async () => { + execute.mockResolvedValue(''); + await expect(adapter.compareAndDeleteRef(REF, EXPECTED)).resolves.toBe(true); + expect(execute).toHaveBeenCalledWith({ args: ['update-ref', '-d', REF, EXPECTED] }); + }); + + it('returns false when a concurrent writer replaced the ref', async () => { + execute.mockRejectedValueOnce(new Error('cannot lock ref')).mockResolvedValueOnce('b'.repeat(40)); + await expect(adapter.compareAndDeleteRef(REF, EXPECTED)).resolves.toBe(false); + }); + + it('preserves a delete failure while the expected revision remains current', async () => { + const failure = Object.assign(new Error('permission denied'), { + exitCode: 128, + details: { code: 128, stderr: 'permission denied' }, + }); + execute.mockRejectedValueOnce(failure).mockResolvedValueOnce(EXPECTED); + await expect(adapter.compareAndDeleteRef(REF, EXPECTED)) + .rejects.toMatchObject({ code: PersistenceError.E_REF_IO }); + }); +}); diff --git a/test/unit/infrastructure/adapters/GitTrustChainAdapter.test.ts b/test/unit/infrastructure/adapters/GitTrustChainAdapter.test.ts index e6dc119f..b9d89854 100644 --- a/test/unit/infrastructure/adapters/GitTrustChainAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GitTrustChainAdapter.test.ts @@ -1,461 +1,352 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { CborCodec } from '@git-stunts/git-cas'; +import { + AssetHandle, + type AssetCapability, + CborCodec, + RetentionWitness, + StagedAsset, +} from '@git-stunts/git-cas'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import TrustError from '../../../../src/domain/errors/TrustError.ts'; import { TrustRecord } from '../../../../src/domain/trust/TrustRecord.ts'; import GitTrustChainAdapter from '../../../../src/infrastructure/adapters/GitTrustChainAdapter.ts'; -import SubstrateCompatibilityPolicy from '../../../../src/infrastructure/adapters/SubstrateCompatibilityPolicy.ts'; import CryptoPort from '../../../../src/ports/CryptoPort.ts'; import TrustChainPort from '../../../../src/ports/TrustChainPort.ts'; +import { + V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, +} from '../../../../scripts/migrations/v17.0.0/SubstrateMigrationCompatibilityPolicy.ts'; + +const GRAPH = 'test-graph'; +const TIP = 'a'.repeat(40); +const PARENT = 'b'.repeat(40); +const TREE = 'c'.repeat(40); +const HANDLE = new AssetHandle({ codec: 'raw', oid: TREE }); +const OBSERVED_AT = '2026-01-01T00:00:00.000Z'; +const codec = new CborCodec(); -const mockReadManifest = vi.fn(); -const mockRestore = vi.fn(); -const mockStore = vi.fn(); -const mockCreateTree = vi.fn(); +const recordObject = { + schemaVersion: 1, + recordType: 'KEY_ADD', + recordId: 'expected-record-id-hash', + issuerKeyId: 'key-1', + issuedAt: OBSERVED_AT, + prev: null, + subject: { keyId: 'key-subject-1', publicKey: 'pubkey-1' }, + meta: { note: 'test' }, + signature: { alg: 'ed25519', sig: 'sig-1' }, +}; -class MockContentAddressableStore { - readManifest = mockReadManifest; - restore = mockRestore; - store = mockStore; - createTree = mockCreateTree; +class TestCrypto extends CryptoPort { + hash(): Promise { return Promise.resolve('expected-record-id-hash'); } + hmac(): Promise { return Promise.resolve(new Uint8Array()); } + timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean { return left.length === right.length; } } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +function stagedAsset(handle = HANDLE): StagedAsset { + return new StagedAsset({ + handle, + slug: 'trust-record', + filename: 'record.cbor', + size: codec.encode(recordObject).byteLength, + observedAt: OBSERVED_AT, + }); +} + +function retention(commitId: string): RetentionWitness { + return new RetentionWitness({ + handle: HANDLE, + policy: 'pinned', + reachability: 'anchored', + root: { + kind: 'publication', + namespace: GRAPH, + ref: `refs/warp/${GRAPH}/trust/records`, + generation: commitId, + path: '/', + }, + observedAt: OBSERVED_AT, + }); +} -function makePlumbing() { +function bytes(source: Uint8Array): AsyncIterable { return { - execute: vi.fn(), + async *[Symbol.asyncIterator]() { + yield source; + }, }; } -class TestCrypto extends CryptoPort { - hash(_algorithm: string, _data: string | Uint8Array): Promise { - return Promise.resolve('expected-record-id-hash'); - } - - hmac( - _algorithm: string, - _key: string | Uint8Array, - _data: string | Uint8Array, - ): Promise { - return Promise.resolve(new Uint8Array()); - } - - timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean { - return left.length === right.length; - } +function createCas() { + const assets = { + put: vi.fn(async (_request: Parameters[0]) => stagedAsset()), + adopt: vi.fn(async (_request: Parameters[0]) => stagedAsset()), + open: vi.fn((_request: Parameters[0]) => + bytes(codec.encode(recordObject))), + }; + const publications = { + commit: vi.fn(async () => ({ + operation: 'publication' as const, + commitId: TIP, + ref: `refs/warp/${GRAPH}/trust/records`, + root: HANDLE, + witness: retention(TIP), + })), + }; + return { assets, publications }; } -function makeCrypto(): CryptoPort { - return new TestCrypto(); +function sampleRecord(): TrustRecord { + return TrustRecord.fromDecoded({ + ...recordObject, + signaturePayload: new Uint8Array([1, 2, 3]), + }); } -const GRAPH_NAME = 'test-graph'; -const EXPECTED_TIP_SHA = 'a'.repeat(40); -const PARENT_SHA = 'b'.repeat(40); - -const SAMPLE_RECORD_OBJ = { - schemaVersion: 1, - recordType: 'KEY_ADD', - recordId: 'expected-record-id-hash', - issuerKeyId: 'key-1', - issuedAt: new Date().toISOString(), - prev: null, - subject: { keyId: 'key-subject-1', publicKey: 'pubkey-1' }, - meta: { note: 'test' }, - signature: { alg: 'ed25519', sig: 'sig-1' }, -}; - -const codec = new CborCodec(); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- +function createPlumbing() { + return { + execute: vi.fn(async (_options: { args: string[]; input?: string }): Promise => ''), + }; +} -describe('GitTrustChainAdapter', () => { - let plumbing: ReturnType; - let crypto: ReturnType; +describe('GitTrustChainAdapter high-level CAS boundary', () => { + let plumbing: ReturnType; + let cas: ReturnType; let adapter: GitTrustChainAdapter; beforeEach(() => { - vi.clearAllMocks(); - plumbing = makePlumbing(); - crypto = makeCrypto(); + plumbing = createPlumbing(); + cas = createCas(); adapter = new GitTrustChainAdapter({ plumbing, - crypto, - cas: new MockContentAddressableStore(), + crypto: new TestCrypto(), + cas, cbor: codec, }); }); - // ------------------------------------------------------------------------- - // Constructor & Initialization - // ------------------------------------------------------------------------- + it('implements TrustChainPort and returns null for a missing trust ref', async () => { + expect(adapter).toBeInstanceOf(TrustChainPort); + plumbing.execute.mockRejectedValueOnce(missingRefError()); + await expect(adapter.readTip(GRAPH)).resolves.toBeNull(); + }); - describe('constructor', () => { - it('extends TrustChainPort', () => { - expect(adapter).toBeInstanceOf(TrustChainPort); - }); + it('propagates rev-parse failures that are not missing refs', async () => { + const unavailable = new Error('repository unavailable'); + plumbing.execute.mockRejectedValueOnce(unavailable); + await expect(adapter.readTip(GRAPH)).rejects.toBe(unavailable); + plumbing.execute.mockRejectedValueOnce(unavailable); + const read = async (): Promise => { + for await (const _record of adapter.readRecords(GRAPH)) { + // No record may be yielded when ref resolution fails. + } + }; + await expect(read()).rejects.toBe(unavailable); }); - // ------------------------------------------------------------------------- - // readTip - // ------------------------------------------------------------------------- - - describe('readTip', () => { - it('returns null if resolveRef returns null', async () => { - plumbing.execute.mockRejectedValueOnce(new Error('ref not found')); - expect(await adapter.readTip(GRAPH_NAME)).toBeNull(); + it('reads a tip record by adopting and opening its asset tree', async () => { + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return TIP; + if (args[0] === 'cat-file') return `tree ${TREE}\nparent ${PARENT}\n\nmessage`; + return ''; }); - it('resolves tipSha and recordId successfully via CAS restore', async () => { - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\nparent ${PARENT_SHA}\n\ncommit message`; - return ''; - }); - - mockReadManifest.mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }); - mockRestore.mockResolvedValueOnce({ buffer: codec.encode(SAMPLE_RECORD_OBJ) }); - - const tip = await adapter.readTip(GRAPH_NAME); - expect(tip).toEqual({ - tipSha: EXPECTED_TIP_SHA, - recordId: 'expected-record-id-hash', - }); + await expect(adapter.readTip(GRAPH)).resolves.toEqual({ + tipSha: TIP, + recordId: 'expected-record-id-hash', }); + expect(cas.assets.adopt).toHaveBeenCalledWith({ treeOid: TREE }); + expect(cas.assets.open).toHaveBeenCalledWith({ handle: HANDLE }); + }); - it('throws E_LEGACY_SUBSTRATE_DISABLED if CAS restore fails and legacy policy is disabled', async () => { - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\nparent ${PARENT_SHA}\n\ncommit message`; - return ''; - }); - - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - await expect(adapter.readTip(GRAPH_NAME)).rejects.toThrow( - /Legacy trust record blob reads require the substrate migration compatibility policy/ - ); + it('streams records oldest-first through asset handles', async () => { + const oldest = '1'.repeat(40); + const newest = '2'.repeat(40); + const oldestTree = '3'.repeat(40); + const newestTree = '4'.repeat(40); + const oldestHandle = new AssetHandle({ codec: 'raw', oid: oldestTree }); + const newestHandle = new AssetHandle({ codec: 'raw', oid: newestTree }); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return newest; + if (args[0] === 'cat-file' && args[2] === newest) return `tree ${newestTree}\nparent ${oldest}\n\nmessage`; + if (args[0] === 'cat-file' && args[2] === oldest) return `tree ${oldestTree}\n\nmessage`; + return ''; }); - - it('falls back to ls-tree and cat-file blob if CAS restore fails and legacy policy is enabled', async () => { - const legacyAdapter = new GitTrustChainAdapter({ - plumbing, - crypto, - cas: new MockContentAddressableStore(), - cbor: codec, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyTrustRecordBlobReads: true, - }), - }); - - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\nparent ${PARENT_SHA}\n\ncommit message`; - if (args[0] === 'ls-tree') return '100644 blob blob-oid-1\trecord.cbor\n'; - if (args[0] === 'cat-file' && args[1] === 'blob') return Buffer.from(codec.encode(SAMPLE_RECORD_OBJ)).toString('binary'); - return ''; - }); - - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - const tip = await legacyAdapter.readTip(GRAPH_NAME); - expect(tip).toEqual({ - tipSha: EXPECTED_TIP_SHA, - recordId: 'expected-record-id-hash', - }); + cas.assets.adopt.mockImplementation(async ({ treeOid }) => + stagedAsset(treeOid === oldestTree ? oldestHandle : newestHandle)); + cas.assets.open.mockImplementation(({ handle }) => { + const parsed = AssetHandle.from(handle); + const isOldest = parsed.oid === oldestTree; + return bytes(codec.encode({ + ...recordObject, + issuedAt: isOldest ? '2026-01-01T00:00:00.000Z' : '2026-01-02T00:00:00.000Z', + })); }); - it('returns tipSha with null recordId in fallback if record.cbor is missing from ls-tree', async () => { - const legacyAdapter = new GitTrustChainAdapter({ - plumbing, - crypto, - cas: new MockContentAddressableStore(), - cbor: codec, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyTrustRecordBlobReads: true, - }), - }); - - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\nparent ${PARENT_SHA}\n\ncommit message`; - if (args[0] === 'ls-tree') return '100644 blob blob-oid-1\tother-file.txt\n'; // missing record.cbor - return ''; - }); - - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - expect(await legacyAdapter.readTip(GRAPH_NAME)).toEqual({ - tipSha: EXPECTED_TIP_SHA, - recordId: null, - }); - }); + const records: TrustRecord[] = []; + for await (const record of adapter.readRecords(GRAPH)) { + records.push(record); + } + expect(records.map((record) => record.issuedAt)).toEqual([ + '2026-01-01T00:00:00.000Z', + '2026-01-02T00:00:00.000Z', + ]); + }); - it('ignores blank and malformed legacy tree records', async () => { - const legacyAdapter = new GitTrustChainAdapter({ - plumbing, - crypto, - cas: new MockContentAddressableStore(), - cbor: codec, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyTrustRecordBlobReads: true, - }), - }); - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\n\ncommit message`; - if (args[0] === 'ls-tree') { - return 'malformed legacy row\n\n100644 blob blob-oid-1\tother-file.txt\n'; - } - return ''; - }); - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - await expect(legacyAdapter.readTip(GRAPH_NAME)).resolves.toEqual({ - tipSha: EXPECTED_TIP_SHA, - recordId: null, - }); + it('stages and causally publishes a trust record with retention evidence', async () => { + const result = await adapter.persistRecord(GRAPH, sampleRecord(), PARENT); + + expect(cas.assets.put).toHaveBeenCalledWith(expect.objectContaining({ + slug: 'trust-expected-rec', + filename: 'record.cbor', + source: expect.anything(), + })); + expect(cas.publications.commit).toHaveBeenCalledWith({ + root: HANDLE, + commit: { + message: 'trust: KEY_ADD expected-rec', + parents: [PARENT], + }, + ref: { + name: `refs/warp/${GRAPH}/trust/records`, + expected: PARENT, + }, }); - - it('returns tipSha with null recordId in fallback if cat-file blob throws', async () => { - const legacyAdapter = new GitTrustChainAdapter({ - plumbing, - crypto, - cas: new MockContentAddressableStore(), - cbor: codec, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyTrustRecordBlobReads: true, - }), - }); - - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\nparent ${PARENT_SHA}\n\ncommit message`; - if (args[0] === 'ls-tree') return '100644 blob blob-oid-1\trecord.cbor\n'; - if (args[0] === 'cat-file' && args[1] === 'blob') throw new Error('corrupted blob'); - return ''; - }); - - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - expect(await legacyAdapter.readTip(GRAPH_NAME)).toEqual({ - tipSha: EXPECTED_TIP_SHA, - recordId: null, - }); + expect(result).toMatchObject({ + commitSha: TIP, + retention: { policy: 'pinned', reachability: 'anchored' }, }); }); - // ------------------------------------------------------------------------- - // readRecords (Streaming) - // ------------------------------------------------------------------------- - - describe('readRecords', () => { - it('returns empty async iterable if resolveRef returns null', async () => { - plumbing.execute.mockRejectedValueOnce(new Error('ref not found')); - const records: TrustRecord[] = []; - for await (const rec of adapter.readRecords(GRAPH_NAME)) { - records.push(rec); - } - expect(records).toHaveLength(0); + it('preserves current-asset failures instead of misclassifying them as legacy', async () => { + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return TIP; + if (args[0] === 'cat-file') return `tree ${TREE}\n\nmessage`; + return ''; }); - - it('walks commit parent chain backward and yields records oldest-first via CAS restore', async () => { - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') { - if (args[2] === EXPECTED_TIP_SHA) return `tree tree-sha-2\nparent ${PARENT_SHA}\n\ncommit 2`; - if (args[2] === PARENT_SHA) return `tree tree-sha-1\n\ncommit 1`; - } - return ''; - }); - - const record1Obj = { ...SAMPLE_RECORD_OBJ, recordId: 'expected-record-id-hash', issuerKeyId: 'key-parent' }; - const record2Obj = { ...SAMPLE_RECORD_OBJ, recordId: 'expected-record-id-hash', issuerKeyId: 'key-tip' }; - - mockReadManifest - .mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }) - .mockResolvedValueOnce({ slug: 'manifest-2', chunks: [] }); - - mockRestore - .mockResolvedValueOnce({ buffer: codec.encode(record1Obj) }) // oldest first (PARENT_SHA) - .mockResolvedValueOnce({ buffer: codec.encode(record2Obj) }); // tip (EXPECTED_TIP_SHA) - - const records: TrustRecord[] = []; - for await (const rec of adapter.readRecords(GRAPH_NAME)) { - records.push(rec); - } - - expect(records).toHaveLength(2); - expect(records[0]?.issuerKeyId).toBe('key-parent'); - expect(records[1]?.issuerKeyId).toBe('key-tip'); + const corruption = new Error('current asset is corrupt'); + cas.assets.open.mockImplementation(() => { + throw corruption; }); - it('throws TrustError E_TRUST_RECORD_ID_MISMATCH if recordId does not match expected hash', async () => { - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\n\ncommit 1`; - return ''; - }); - - const mismatchObj = { ...SAMPLE_RECORD_OBJ, recordId: 'mismatched-id' }; - - mockReadManifest.mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }); - mockRestore.mockResolvedValueOnce({ buffer: codec.encode(mismatchObj) }); + await expect(adapter.readTip(GRAPH)).rejects.toBe(corruption); + }); - await expect(async () => { - for await (const rec of adapter.readRecords(GRAPH_NAME)) { - expect(rec).toBeDefined(); - } - }).rejects.toThrow(/RecordId mismatch/); + it('reads an explicitly allowed legacy trust-record blob', async () => { + adapter = new GitTrustChainAdapter({ + plumbing, + crypto: new TestCrypto(), + cas, + cbor: codec, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); - - it('falls back to raw blob decode if CAS restore fails and legacy policy is enabled', async () => { - const legacyAdapter = new GitTrustChainAdapter({ - plumbing, - crypto, - cas: new MockContentAddressableStore(), - cbor: codec, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyTrustRecordBlobReads: true, - }), - }); - - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\n\ncommit 1`; - if (args[0] === 'ls-tree') return '100644 blob blob-oid-1\trecord.cbor\n'; - if (args[0] === 'cat-file' && args[1] === 'blob') return Buffer.from(codec.encode(SAMPLE_RECORD_OBJ)).toString('binary'); - return ''; - }); - - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - const records: TrustRecord[] = []; - for await (const rec of legacyAdapter.readRecords(GRAPH_NAME)) { - records.push(rec); + cas.assets.adopt.mockRejectedValue( + Object.assign(new Error('not an asset tree'), { code: 'MANIFEST_NOT_FOUND' }), + ); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return TIP; + if (args[0] === 'cat-file' && args[1] === '-p') return `tree ${TREE}\n\nmessage`; + if (args[0] === 'ls-tree') { + return `100644 blob ${HANDLE.oid}\trecord.cbor\n`; } - expect(records).toHaveLength(1); - expect(records[0]?.recordId).toBe('expected-record-id-hash'); - }); - - it('skips commit if fallback blob is missing', async () => { - const legacyAdapter = new GitTrustChainAdapter({ - plumbing, - crypto, - cas: new MockContentAddressableStore(), - cbor: codec, - compatibilityPolicy: new SubstrateCompatibilityPolicy({ - legacyTrustRecordBlobReads: true, - }), - }); - - plumbing.execute.mockImplementation(async ({ args }) => { - if (args[0] === 'rev-parse') return EXPECTED_TIP_SHA; - if (args[0] === 'cat-file' && args[1] === '-p') return `tree tree-sha-1\n\ncommit 1`; - if (args[0] === 'ls-tree') return '100644 blob blob-oid-1\tother.txt\n'; // missing record.cbor - return ''; - }); - - mockReadManifest.mockRejectedValueOnce(new Error('manifest not found')); - - const records: TrustRecord[] = []; - for await (const rec of legacyAdapter.readRecords(GRAPH_NAME)) { - records.push(rec); + if (args[0] === 'cat-file' && args[1] === 'blob') { + return Buffer.from(codec.encode(recordObject)).toString('binary'); } - expect(records).toHaveLength(0); + return ''; }); - }); - - // ------------------------------------------------------------------------- - // persistRecord - // ------------------------------------------------------------------------- - describe('persistRecord', () => { - const sampleTrustRecord = TrustRecord.fromDecoded({ - schemaVersion: 1, - recordType: 'KEY_ADD', + await expect(adapter.readTip(GRAPH)).resolves.toEqual({ + tipSha: TIP, recordId: 'expected-record-id-hash', - issuerKeyId: 'key-1', - issuedAt: new Date().toISOString(), - prev: null, - subject: { keyId: 'key-subject-1', publicKey: 'pubkey-1' }, - meta: { note: 'test' }, - signature: { alg: 'ed25519', sig: 'sig-1' }, - signaturePayload: new Uint8Array([1, 2, 3]), }); + const records: TrustRecord[] = []; + for await (const record of adapter.readRecords(GRAPH, TIP)) { + records.push(record); + } + expect(records).toHaveLength(1); + expect(records[0]?.recordId).toBe('expected-record-id-hash'); + }); - it('persists record successfully, creates tree, creates commit, updates ref', async () => { - mockStore.mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }); - mockCreateTree.mockResolvedValueOnce('tree-oid-1'); - plumbing.execute - .mockResolvedValueOnce('new-commit-sha') // commit-tree - .mockResolvedValueOnce(''); // update-ref - - const commitSha = await adapter.persistRecord(GRAPH_NAME, sampleTrustRecord, PARENT_SHA); - expect(commitSha).toBe('new-commit-sha'); - expect(plumbing.execute).toHaveBeenCalledWith({ - args: ['commit-tree', 'tree-oid-1', '-m', 'trust: KEY_ADD expected-rec', '-p', PARENT_SHA], - }); - expect(plumbing.execute).toHaveBeenCalledWith({ - args: ['update-ref', `refs/warp/${GRAPH_NAME}/trust/records`, 'new-commit-sha', PARENT_SHA], - }); + it('rejects malformed legacy trees and returns null for undecodable legacy records', async () => { + adapter = new GitTrustChainAdapter({ + plumbing, + crypto: new TestCrypto(), + cas, + cbor: codec, + compatibilityPolicy: V17_SUBSTRATE_MIGRATION_COMPATIBILITY_POLICY, }); - - it('retries CAS update on transient failure and succeeds', async () => { - mockStore.mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }); - mockCreateTree.mockResolvedValueOnce('tree-oid-1'); - - plumbing.execute - .mockResolvedValueOnce('new-commit-sha') // commit-tree - .mockRejectedValueOnce(new Error('lock error')) // update-ref attempt 1 fails - .mockResolvedValueOnce(PARENT_SHA) // rev-parse verify returns expectedSha (transient) - .mockResolvedValueOnce(''); // update-ref attempt 2 succeeds - - const commitSha = await adapter.persistRecord(GRAPH_NAME, sampleTrustRecord, PARENT_SHA); - expect(commitSha).toBe('new-commit-sha'); - expect(plumbing.execute).toHaveBeenCalledTimes(4); + cas.assets.adopt.mockRejectedValue( + Object.assign(new Error('not an asset tree'), { code: 'MANIFEST_NOT_FOUND' }), + ); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return TIP; + if (args[0] === 'cat-file' && args[1] === '-p') return `tree ${TREE}\n\nmessage`; + if (args[0] === 'ls-tree') return `100644 blob ${HANDLE.oid}\tother.cbor`; + return ''; }); - - it('throws TrustError E_TRUST_CAS_EXHAUSTED on max transient CAS failures', async () => { - mockStore.mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }); - mockCreateTree.mockResolvedValueOnce('tree-oid-1'); - - plumbing.execute - .mockResolvedValueOnce('new-commit-sha') // commit-tree - .mockRejectedValueOnce(new Error('lock error')) // attempt 1 - .mockResolvedValueOnce(PARENT_SHA) - .mockRejectedValueOnce(new Error('lock error')) // attempt 2 - .mockResolvedValueOnce(PARENT_SHA) - .mockRejectedValueOnce(new Error('lock error')) // attempt 3 - .mockResolvedValueOnce(PARENT_SHA); - - await expect(adapter.persistRecord(GRAPH_NAME, sampleTrustRecord, PARENT_SHA)).rejects.toThrow( - /Trust CAS exhausted after 3 attempts/ - ); + await expect(adapter.readTip(GRAPH)).rejects.toMatchObject({ + code: 'E_TRUST_LEGACY_TREE_INVALID', }); - it('throws TrustError E_TRUST_CAS_CONFLICT on real CAS conflict (chain advanced)', async () => { - mockStore.mockResolvedValueOnce({ slug: 'manifest-1', chunks: [] }); - mockCreateTree.mockResolvedValueOnce('tree-oid-1'); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return TIP; + if (args[0] === 'cat-file' && args[1] === '-p') return `tree ${TREE}\n\nmessage`; + if (args[0] === 'ls-tree') return `100644 blob ${HANDLE.oid}\trecord.cbor`; + if (args[0] === 'cat-file' && args[1] === 'blob') return 'not cbor'; + return ''; + }); + await expect(adapter.readTip(GRAPH)).resolves.toEqual({ tipSha: TIP, recordId: null }); + }); - const advancedSha = 'c'.repeat(40); + it('rejects tampered record IDs and yields no records for a missing ref', async () => { + plumbing.execute.mockRejectedValueOnce(missingRefError()); + const records: TrustRecord[] = []; + for await (const record of adapter.readRecords(GRAPH)) { + records.push(record); + } + expect(records).toEqual([]); + + const tampered = { ...recordObject, recordId: 'tampered' }; + cas.assets.open.mockImplementation(() => bytes(codec.encode(tampered))); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'cat-file') return `tree ${TREE}\n\nmessage`; + return ''; + }); + const read = async (): Promise => { + for await (const _record of adapter.readRecords(GRAPH, TIP)) { + // The adapter must reject before yielding a tampered record. + } + }; + await expect(read()).rejects.toMatchObject({ code: 'E_TRUST_RECORD_ID_MISMATCH' }); + }); - plumbing.execute - .mockResolvedValueOnce('new-commit-sha') // commit-tree - .mockRejectedValueOnce(new Error('lock error')) // attempt 1 update-ref fails - .mockResolvedValueOnce(advancedSha) // rev-parse verify returns advancedSha (real conflict) - .mockResolvedValueOnce(`tree tree-sha-conflict\n\ncommit conflict`); // cat-file -p for _readRecordIdFromCommit + it('preserves publication errors that are not ref conflicts', async () => { + const upstream = new Error('object write failed'); + cas.publications.commit.mockRejectedValueOnce(upstream); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return PARENT; + return ''; + }); - mockReadManifest.mockResolvedValueOnce({ slug: 'manifest-conflict', chunks: [] }); - mockRestore.mockResolvedValueOnce({ buffer: codec.encode(SAMPLE_RECORD_OBJ) }); + await expect(adapter.persistRecord(GRAPH, sampleRecord(), PARENT)).rejects.toBe(upstream); + }); - await expect(adapter.persistRecord(GRAPH_NAME, sampleTrustRecord, PARENT_SHA)).rejects.toThrow( - /Trust CAS conflict: chain advanced/ - ); + it('maps publication races to a trust-specific CAS conflict', async () => { + cas.publications.commit.mockRejectedValueOnce( + Object.assign(new Error('conflict'), { code: 'PUBLICATION_CONFLICT' }), + ); + plumbing.execute.mockImplementation(async ({ args }: { args: string[] }) => { + if (args[0] === 'rev-parse') return TIP; + if (args[0] === 'cat-file') return `tree ${TREE}\n\nmessage`; + return ''; }); + + await expect(adapter.persistRecord(GRAPH, sampleRecord(), PARENT)) + .rejects.toBeInstanceOf(TrustError); + await expect(adapter.persistRecord(GRAPH, sampleRecord(), PARENT)) + .resolves.toMatchObject({ commitSha: TIP }); }); }); + +function missingRefError(): Error & { readonly exitCode: number } { + return Object.assign(new Error('missing ref'), { exitCode: 1 }); +} diff --git a/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts b/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts index 78a3d355..7bddd5c1 100644 --- a/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GraphModelMigrationDryRunRequestJsonAdapter.test.ts @@ -47,6 +47,21 @@ describe('GraphModelMigrationDryRunRequestJsonAdapter', () => { expect(request.requiredContentKeys).toEqual(['node:alpha:_content']); }); + it('accepts matching current and legacy content-handle aliases', () => { + const handle = 'fixture-content:node:alpha:_content'; + const request = parseGraphModelMigrationDryRunRequest(requestJson({ + inventory: inventoryJson({ + contentSources: [{ + legacyContentKey: 'node:alpha:_content', + contentHandle: handle, + contentOid: handle, + }], + }), + })); + + expect(request.inventory.contentSources[0]?.contentHandle).toBe(handle); + }); + it('rejects malformed JSON without leaking a platform SyntaxError', () => { expect(() => parseGraphModelMigrationDryRunRequest('{')).toThrow(/valid JSON/); }); @@ -137,7 +152,19 @@ describe('GraphModelMigrationDryRunRequestJsonAdapter', () => { ], }), }), - message: /contentOid.*required/, + message: /contentHandle.*required/, + }, + { + raw: requestJson({ + inventory: inventoryJson({ + contentSources: [{ + legacyContentKey: 'node:alpha:_content', + contentHandle: 'asset:current', + contentOid: 'asset:legacy', + }], + }), + }), + message: /contentHandle.*contentOid.*match/, }, ]); diff --git a/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts b/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts index bc0d9b1f..eb4428d7 100644 --- a/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts +++ b/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts @@ -1,147 +1,63 @@ -import { describe, it, expect } from 'vitest'; - -import InMemoryBlobStorageAdapter from '../../../../test/helpers/InMemoryBlobStorageAdapter.ts'; -import BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Collects an async iterable into a single Uint8Array. */ -async function collect(/** @type {AsyncIterable} */ stream) { - const chunks: any[] = []; - for await (const chunk of stream) { - chunks.push(chunk); - } - const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.byteLength; +import { describe, expect, it } from 'vitest'; +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import { collectAsyncIterable } from '../../../../src/domain/utils/streamUtils.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; + +async function* chunks(...values: string[]): AsyncGenerator { + const encoder = new TextEncoder(); + for (const value of values) { + yield encoder.encode(value); } - return result; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('InMemoryBlobStorageAdapter', () => { - it('extends BlobStoragePort', () => { - const adapter = new InMemoryBlobStorageAdapter(); - expect(adapter).toBeInstanceOf(BlobStoragePort); - }); - - describe('store() + retrieve() round-trip', () => { - it('stores and retrieves Uint8Array content', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const content = new Uint8Array([10, 20, 30, 40]); - const oid = await adapter.store(content); +describe('InMemoryBlobStorageAdapter asset semantics', () => { + it('stages a streaming asset behind an opaque handle', async () => { + const storage = new InMemoryBlobStorageAdapter(); - expect(typeof oid).toBe('string'); - expect(oid.length).toBeGreaterThan(0); - - const result = await adapter.retrieve(oid); - expect(result).toEqual(content); - }); - - it('stores and retrieves string content', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const oid = await adapter.store('hello world'); - - const result = await adapter.retrieve(oid); - expect(new TextDecoder().decode(result)).toBe('hello world'); + const staged = await storage.stage(chunks('hello', ' ', 'world'), { + slug: 'greeting', + filename: 'greeting.txt', + expectedSize: 11, }); - it('returns distinct OIDs for distinct content', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const oid1 = await adapter.store('aaa'); - const oid2 = await adapter.store('bbb'); - expect(oid1).not.toBe(oid2); - }); - - it('returns the same OID for identical content (content-addressed)', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const oid1 = await adapter.store('same'); - const oid2 = await adapter.store('same'); - expect(oid1).toBe(oid2); + expect(staged.handle).toBeInstanceOf(AssetHandle); + expect(staged.size).toBe(11); + expect(staged.retention).toEqual({ + reachability: 'unanchored', + protection: 'not-established', }); + await expect(collectAsyncIterable(storage.open(staged.handle))) + .resolves.toEqual(new TextEncoder().encode('hello world')); }); - describe('storeStream() + retrieveStream() round-trip', () => { - it('stores from an async iterable and retrieves as an async iterable', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const data = new TextEncoder().encode('streamed content'); - - async function* source() { - // Yield in two chunks - yield data.slice(0, 8); - yield data.slice(8); - } + it('deduplicates identical bytes to the same immutable handle', async () => { + const storage = new InMemoryBlobStorageAdapter(); - const oid = await adapter.storeStream(source()); + const first = await storage.stage(chunks('same'), { slug: 'first' }); + const second = await storage.stage(chunks('same'), { slug: 'second' }); - expect(typeof oid).toBe('string'); - expect(oid.length).toBeGreaterThan(0); - - const stream = adapter.retrieveStream(oid); - const result = await collect(stream); - expect(new TextDecoder().decode(result)).toBe('streamed content'); - }); - - it('stores single-chunk streams', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const data = new Uint8Array([1, 2, 3]); - - async function* source() { - yield data; - } - - const oid = await adapter.storeStream(source()); - const result = await collect(adapter.retrieveStream(oid)); - expect(result).toEqual(data); - }); + expect(second.handle.equals(first.handle)).toBe(true); }); - describe('cross-method compatibility', () => { - it('content stored via store() is retrievable via retrieveStream()', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const oid = await adapter.store('buffered write'); - - const result = await collect(adapter.retrieveStream(oid)); - expect(new TextDecoder().decode(result)).toBe('buffered write'); - }); - - it('content stored via storeStream() is retrievable via retrieve()', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - - async function* source() { - yield new TextEncoder().encode('stream write'); - } - - const oid = await adapter.storeStream(source()); - const result = await adapter.retrieve(oid); - expect(new TextDecoder().decode(result)).toBe('stream write'); - }); + it('rejects unknown handles when the stream is consumed', async () => { + const storage = new InMemoryBlobStorageAdapter(); + await expect(collectAsyncIterable(storage.open(new AssetHandle('missing')))) + .rejects.toThrow(/unknown asset/); + await expect(collectAsyncIterable(storage.retrieveStream('missing'))) + .rejects.toThrow(/unknown asset/); }); - describe('error cases', () => { - it('retrieve() throws for unknown OID', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - await expect(adapter.retrieve('nonexistent')).rejects.toThrow(); - }); + it('rejects content whose staged size differs from its declaration', async () => { + const storage = new InMemoryBlobStorageAdapter(); - it('retrieveStream() throws for unknown OID', () => { - const adapter = new InMemoryBlobStorageAdapter(); - // retrieveStream may throw synchronously or yield an error on first iteration - expect(() => { - const stream = adapter.retrieveStream('nonexistent'); - // Force iteration if it returns a lazy iterable - const iter = stream[Symbol.asyncIterator](); - return iter.next(); - }).toThrow(); + await expect(storage.stage(chunks('four'), { + slug: 'mismatch', + expectedSize: 5, + })).rejects.toMatchObject({ + code: 'E_ASSET_SIZE_MISMATCH', + expectedSize: 5, + actualSize: 4, }); }); }); diff --git a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.objectType.test.ts b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.objectType.test.ts new file mode 100644 index 00000000..96403be4 --- /dev/null +++ b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.objectType.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; + +describe('InMemoryGraphAdapter object types', () => { + it('reports blob, tree, empty-tree, and commit objects', async () => { + const adapter = new InMemoryGraphAdapter(); + const blobOid = await adapter.writeBlob('content'); + const treeOid = await adapter.writeTree([ + `100644 blob ${blobOid}\tcontent`, + ]); + const commitOid = await adapter.commitNodeWithTree({ + treeOid, + message: 'publish content', + }); + + await expect(adapter.readObjectType(blobOid)).resolves.toBe('blob'); + await expect(adapter.readObjectType(treeOid)).resolves.toBe('tree'); + await expect(adapter.readObjectType(adapter.emptyTree)).resolves.toBe('tree'); + await expect(adapter.readObjectType(commitOid)).resolves.toBe('commit'); + }); + + it('rejects an object that is absent from the repository', async () => { + const adapter = new InMemoryGraphAdapter(); + + await expect(adapter.readObjectType('abcd' + '0'.repeat(36))) + .rejects.toThrow(/Object not found/); + }); +}); diff --git a/test/unit/infrastructure/adapters/TrailerCommitMessageCodecAdapter.test.ts b/test/unit/infrastructure/adapters/TrailerCommitMessageCodecAdapter.test.ts new file mode 100644 index 00000000..5555a0d3 --- /dev/null +++ b/test/unit/infrastructure/adapters/TrailerCommitMessageCodecAdapter.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; + +import AssetHandle from '../../../../src/domain/storage/AssetHandle.ts'; +import { + TrailerCommitMessageCodecAdapter, + TRAILER_KEYS, + decodePatchMessage, + encodeAnchorMessage, + encodeCheckpointMessage, + encodePatchMessage, +} from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import { + LEGACY_GIT_BLOB_PATCH_STORAGE, + createGitCasPatchStorage, + createLegacyGitCasPatchStorage, +} from '../../../../src/ports/CommitMessageCodecPort.ts'; + +const OID = 'a'.repeat(40); +const STATE_HASH = 'b'.repeat(64); + +function replaceTrailer(message: string, key: string, value: string): string { + const prefix = `${key}: `; + const lines = message.split('\n'); + const index = lines.findIndex((line) => line.startsWith(prefix)); + if (index < 0) { + throw new Error(`missing fixture trailer ${key}`); + } + lines[index] = `${prefix}${value}`; + return lines.join('\n'); +} + +function appendTrailer(message: string, key: string, value: string): string { + return `${message.trimEnd()}\n${key}: ${value}\n`; +} + +function legacyPatchMessage(): string { + return encodePatchMessage({ + graph: 'events', + writer: 'alice', + lamport: 1, + patchOid: OID, + }); +} + +describe('TrailerCommitMessageCodecAdapter storage routing', () => { + it('round-trips the current git-cas asset route without a legacy OID trailer', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + const patchHandle = new AssetHandle('asset:current-patch'); + const encoded = adapter.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'alice', + lamport: 1, + patchHandle, + schema: 2, + storage: createGitCasPatchStorage({ encrypted: false }), + }); + + expect(encoded).toContain(`${TRAILER_KEYS.patchHandle}: ${patchHandle.toString()}`); + expect(encoded).not.toContain(`${TRAILER_KEYS.patchOid}:`); + expect(adapter.decodePatch(encoded)).toEqual({ + kind: 'patch', + graph: 'events', + writer: 'alice', + lamport: 1, + patchHandle, + schema: 2, + storage: createGitCasPatchStorage({ encrypted: false }), + }); + }); + + it('round-trips the explicit legacy git-cas compatibility route', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + const encoded = adapter.encodePatch({ + kind: 'patch', + graph: 'events', + writer: 'alice', + lamport: 1, + patchHandle: new AssetHandle(OID), + schema: 2, + storage: createLegacyGitCasPatchStorage({ encrypted: true }), + }); + + expect(adapter.decodePatch(encoded)).toMatchObject({ + storage: { + strategy: 'legacy-git-cas', + encrypted: true, + }, + }); + }); + + it('rejects partial and unknown git-cas storage trailer pairs', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + const partial = appendTrailer( + legacyPatchMessage(), + TRAILER_KEYS.storageVersion, + 'git-cas-asset-v1', + ); + const unknown = appendTrailer( + appendTrailer( + legacyPatchMessage(), + TRAILER_KEYS.storageVersion, + 'unknown-storage', + ), + TRAILER_KEYS.storageSchema, + 'unknown-schema', + ); + + expect(() => adapter.decodePatch(partial)).toThrow(/must be present together/); + expect(() => adapter.decodePatch(unknown)).toThrow(/invalid git-cas patch storage trailers/); + }); + + it('keeps the legacy helper from accepting opaque asset handles', () => { + expect(() => encodePatchMessage({ + graph: 'events', + writer: 'alice', + lamport: 1, + patchOid: OID, + storage: createGitCasPatchStorage({ encrypted: false }), + })).toThrow(/cannot encode asset handles/); + }); + + it('preserves encrypted legacy external-storage compatibility', () => { + const encoded = encodePatchMessage({ + graph: 'events', + writer: 'alice', + lamport: 1, + patchOid: OID, + encrypted: true, + }); + const decoded = decodePatchMessage(encoded); + const canonical = new TrailerCommitMessageCodecAdapter().decodePatch(encoded); + + expect(decoded).toMatchObject({ + encrypted: true, + storage: { strategy: 'legacy-external-storage' }, + }); + expect(canonical).not.toHaveProperty('patchOid'); + expect(canonical.patchHandle.toString()).toBe(OID); + }); +}); + +describe('TrailerCommitMessageCodecAdapter validation', () => { + it('rejects malformed patch scalar trailers', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + const encoded = legacyPatchMessage(); + + expect(() => adapter.decodePatch( + replaceTrailer(encoded, TRAILER_KEYS.lamport, '0'), + )).toThrow(/positive integer/); + expect(() => adapter.decodePatch( + replaceTrailer(encoded, TRAILER_KEYS.patchOid, 'not-an-oid'), + )).toThrow(/patchOid/); + expect(() => adapter.decodePatch( + replaceTrailer(encoded, TRAILER_KEYS.graph, '../events'), + )).toThrow(/graph/i); + }); + + it('rejects malformed checkpoint hashes and graph names', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + const encoded = encodeCheckpointMessage({ + graph: 'events', + stateHash: STATE_HASH, + frontierOid: OID, + indexOid: OID, + }); + + expect(() => adapter.decodeCheckpoint( + replaceTrailer(encoded, TRAILER_KEYS.stateHash, 'not-a-hash'), + )).toThrow(/stateHash/); + expect(() => adapter.decodeCheckpoint( + replaceTrailer(encoded, TRAILER_KEYS.graph, '../events'), + )).toThrow(/graph/i); + }); + + it('rejects invalid patch and anchor values before encoding', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + + expect(() => adapter.encodePatch({ + kind: 'patch', + graph: '../events', + writer: 'alice', + lamport: 1, + patchHandle: new AssetHandle(OID), + schema: 2, + storage: LEGACY_GIT_BLOB_PATCH_STORAGE, + })).toThrow(/graph/i); + expect(() => adapter.encodeAnchor({ + kind: 'anchor', + graph: '../events', + schema: 2, + })).toThrow(/graph/i); + }); + + it('rejects wrong-kind and invalid anchor messages', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + const patch = legacyPatchMessage(); + const anchor = encodeAnchorMessage({ graph: 'events' }); + + expect(() => adapter.decodePatch(anchor)).toThrow("must be 'patch'"); + expect(() => adapter.decodeAnchor(patch)).toThrow("must be 'anchor'"); + expect(() => adapter.decodeAnchor( + replaceTrailer(anchor, TRAILER_KEYS.graph, '../events'), + )).toThrow(/graph/i); + }); + + it('returns null when kind detection cannot decode the input', () => { + const adapter = new TrailerCommitMessageCodecAdapter(); + + expect(adapter.detectKind(null as unknown as string)).toBeNull(); + }); +}); diff --git a/test/unit/ports/BlobPort.test.ts b/test/unit/ports/BlobPort.test.ts deleted file mode 100644 index 983da703..00000000 --- a/test/unit/ports/BlobPort.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import BlobPort from '../../../src/ports/BlobPort.ts'; - -describe('BlobPort', () => { - it('abstract methods are not callable on base prototype', () => { - expect(BlobPort.prototype.writeBlob).toBeUndefined(); - expect(BlobPort.prototype.readBlob).toBeUndefined(); - }); - - it('concrete subclass satisfies the contract', async () => { - class TestBlob extends BlobPort { - async writeBlob(_content: Uint8Array | string) { return 'oid'; } - async readBlob(_oid: string) { return new Uint8Array([1]); } - } - const blob = new TestBlob(); - expect(blob).toBeInstanceOf(BlobPort); - expect(await blob.writeBlob(new Uint8Array())).toBe('oid'); - expect(await blob.readBlob('x')).toEqual(new Uint8Array([1])); - }); -}); diff --git a/test/unit/ports/BlobStoragePort.test.ts b/test/unit/ports/BlobStoragePort.test.ts deleted file mode 100644 index 57a3f81b..00000000 --- a/test/unit/ports/BlobStoragePort.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import BlobStoragePort from '../../../src/ports/BlobStoragePort.ts'; - -describe('BlobStoragePort', () => { - it('abstract methods are not callable on base prototype', () => { - expect(BlobStoragePort.prototype.store).toBeUndefined(); - expect(BlobStoragePort.prototype.retrieve).toBeUndefined(); - expect(BlobStoragePort.prototype.storeStream).toBeUndefined(); - expect(BlobStoragePort.prototype.retrieveStream).toBeUndefined(); - }); - - it('concrete subclass satisfies the contract', async () => { - class TestStorage extends BlobStoragePort { - async store(_content: Uint8Array | string) { return 'oid'; } - async retrieve(_oid: string) { return new Uint8Array([1]); } - async storeStream(_source: AsyncIterable) { return 'stream-oid'; } - async *retrieveStream(_oid: string) { yield new Uint8Array([2]); } - } - const s = new TestStorage(); - expect(s).toBeInstanceOf(BlobStoragePort); - expect(await s.store(new Uint8Array())).toBe('oid'); - expect(await s.retrieve('x')).toEqual(new Uint8Array([1])); - }); -}); diff --git a/test/unit/ports/CheckpointStorePort.test.ts b/test/unit/ports/CheckpointStorePort.test.ts index 366f1830..fc54726e 100644 --- a/test/unit/ports/CheckpointStorePort.test.ts +++ b/test/unit/ports/CheckpointStorePort.test.ts @@ -1,48 +1,81 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import VersionVector from '../../../src/domain/crdt/VersionVector.ts'; +import WarpState from '../../../src/domain/services/state/WarpState.ts'; +import BundleHandle from '../../../src/domain/storage/BundleHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../../src/domain/storage/StorageRetentionWitness.ts'; import CheckpointStorePort, { - type CheckpointRecord, + type CheckpointBasis, type CheckpointData, + type CheckpointMetadata, + type CheckpointRecord, } from '../../../src/ports/CheckpointStorePort.ts'; -import VersionVector from '../../../src/domain/crdt/VersionVector.ts'; -import WarpState from '../../../src/domain/services/state/WarpState.ts'; describe('CheckpointStorePort', () => { - it('abstract methods are not callable on base prototype', () => { - expect(CheckpointStorePort.prototype.writeCheckpoint).toBeUndefined(); - expect(CheckpointStorePort.prototype.readCheckpoint).toBeUndefined(); + it('declares a semantic checkpoint lifecycle', () => { + expect(CheckpointStorePort.prototype.publishCheckpoint).toBeUndefined(); + expect(CheckpointStorePort.prototype.resolveHead).toBeUndefined(); + expect(CheckpointStorePort.prototype.loadCheckpoint).toBeUndefined(); + expect(CheckpointStorePort.prototype.readMetadata).toBeUndefined(); + expect(CheckpointStorePort.prototype.loadBasis).toBeUndefined(); + expect(CheckpointStorePort.prototype.publishCoverage).toBeUndefined(); }); - it('concrete subclass satisfies the contract', async () => { + it('can publish and load checkpoints without physical tree metadata', async () => { + const data: CheckpointData = { + state: WarpState.empty(), + frontier: new Map(), + stateHash: 'state-hash', + schema: 5, + appliedVV: VersionVector.empty(), + indexShardHandles: null, + }; + const bundleHandle = new BundleHandle('checkpoint-bundle'); + const retention = new StorageRetentionWitness({ + handle: bundleHandle, + policy: 'pinned', + reachability: 'anchored', + root: new StorageRetentionRoot({ + kind: 'publication', + namespace: 'g', + locator: 'checkpoint:g', + generation: 'checkpoint-sha', + path: '/', + }), + observedAt: new Date(0).toISOString(), + }); class TestStore extends CheckpointStorePort { - async writeCheckpoint(_record: CheckpointRecord) { - return { - nodeAliveBlobOid: 'nodeAlive', - edgeAliveBlobOid: 'edgeAlive', - propBlobOid: 'prop', - observedFrontierBlobOid: 'observedFrontier', - edgeBirthEventBlobOid: 'edgeBirthEvent', - frontierBlobOid: 'frontier', - appliedVVBlobOid: 'appliedVV', - provenanceIndexBlobOid: null, - }; + async publishCheckpoint(_record: CheckpointRecord) { + return { checkpointSha: 'checkpoint-sha', bundleHandle, retention }; + } + async resolveHead(_graphName: string) { return 'checkpoint-sha'; } + async loadCheckpoint(_sha: string) { return data; } + async readMetadata(_sha: string): Promise { + return { checkpointSha: 'checkpoint-sha', stateHash: 'state-hash', schema: 5 }; } - async readCheckpoint(_treeOids: Record): Promise { + async loadBasis(_sha: string): Promise { return { - state: WarpState.empty(), + checkpointSha: 'checkpoint-sha', + stateHash: 'state-hash', + schema: 5, frontier: new Map(), - appliedVV: null, - indexShardOids: null, + indexShardHandles: {}, }; } + async publishCoverage() { return 'coverage-sha'; } } const store = new TestStore(); - expect(store).toBeInstanceOf(CheckpointStorePort); - const result = await store.writeCheckpoint({ + + const published = await store.publishCheckpoint({ + graphName: 'g', state: WarpState.empty(), frontier: new Map(), appliedVV: VersionVector.empty(), stateHash: 'state-hash', + parents: [], }); - expect(result.nodeAliveBlobOid).toBe('nodeAlive'); + expect(published).toEqual({ checkpointSha: 'checkpoint-sha', bundleHandle, retention }); + await expect(store.loadCheckpoint(published.checkpointSha)).resolves.toBe(data); }); }); diff --git a/test/unit/ports/CommitPort.test.ts b/test/unit/ports/CommitPort.test.ts index 58dd464e..2aee9bb1 100644 --- a/test/unit/ports/CommitPort.test.ts +++ b/test/unit/ports/CommitPort.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import CommitPort, { type CommitLogChunk, type CommitNodeOptions, - type CommitNodeWithTreeOptions, type LogNodesOptions, } from '../../../src/ports/CommitPort.ts'; import WarpStream from '../../../src/domain/stream/WarpStream.ts'; @@ -10,8 +9,7 @@ import WarpStream from '../../../src/domain/stream/WarpStream.ts'; describe('CommitPort', () => { const abstractMethods = [ 'commitNode', 'showNode', 'getNodeInfo', 'logNodes', - 'logNodesStream', 'countNodes', 'commitNodeWithTree', - 'nodeExists', 'getCommitTree', 'ping', + 'logNodesStream', 'countNodes', 'nodeExists', 'ping', ]; it('abstract methods are not callable on base prototype', () => { @@ -30,9 +28,7 @@ describe('CommitPort', () => { return WarpStream.of(); } async countNodes(_ref: string) { return 5; } - async commitNodeWithTree(_options: CommitNodeWithTreeOptions) { return 'sha2'; } async nodeExists(_sha: string) { return true; } - async getCommitTree(_sha: string) { return 'tree-oid'; } async ping() { return { ok: true, latencyMs: 1 }; } } const c = new TestCommit(); diff --git a/test/unit/ports/GraphPersistencePort.test.ts b/test/unit/ports/GraphPersistencePort.test.ts index 052d2f50..af325bc3 100644 --- a/test/unit/ports/GraphPersistencePort.test.ts +++ b/test/unit/ports/GraphPersistencePort.test.ts @@ -1,99 +1,48 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import WarpStream from '../../../src/domain/stream/WarpStream.ts'; +import type { CommitNodeOptions, LogNodesOptions } from '../../../src/ports/CommitPort.ts'; import GraphPersistencePort from '../../../src/ports/GraphPersistencePort.ts'; -import IndexStoragePort from '../../../src/ports/IndexStoragePort.ts'; +import type { ListRefsOptions } from '../../../src/ports/RefPort.ts'; -describe('GraphPersistencePort (abstract composite)', () => { +describe('GraphPersistencePort causal history boundary', () => { const expectedMethods = [ - // CommitPort 'commitNode', 'showNode', 'getNodeInfo', 'logNodes', - 'logNodesStream', 'countNodes', 'commitNodeWithTree', - 'nodeExists', 'getCommitTree', 'ping', - // BlobPort - 'writeBlob', 'readBlob', - // TreePort - 'writeTree', 'readTree', 'readTreeOids', - // RefPort + 'logNodesStream', 'countNodes', 'nodeExists', 'ping', 'updateRef', 'readRef', 'deleteRef', 'listRefs', 'compareAndSwapRef', ]; - it('abstract methods are not on the base prototype (TS abstract)', () => { - const proto = GraphPersistencePort.prototype; + it('declares only causal commit and ref capabilities', () => { + const prototype = GraphPersistencePort.prototype as unknown as Record; for (const method of expectedMethods) { - expect(((proto))[method]).toBeUndefined(); + expect(prototype[method]).toBeUndefined(); } + expect(prototype['writeBlob']).toBeUndefined(); + expect(prototype['writeTree']).toBeUndefined(); + expect(prototype['getCommitTree']).toBeUndefined(); }); - it('does not leak constructor from focused ports', () => { - // Even though GraphPersistencePort is abstract, JS allows instantiation - const Ctor = GraphPersistencePort as any; - const port = new Ctor(); - expect(port.constructor).toBe(GraphPersistencePort); - }); - - it('subclass can override methods normally', async () => { + it('can be implemented without raw object capabilities', async () => { class TestAdapter extends GraphPersistencePort { - async commitNode() { return 'sha'; } - async showNode() { return 'msg'; } - async getNodeInfo() { return ({} as any); } - async logNodes() { return 'log'; } - async logNodesStream(): Promise { return null; } - async countNodes() { return 1; } - async commitNodeWithTree() { return 'sha2'; } - async nodeExists() { return true; } - async getCommitTree() { return 'tree'; } + async commitNode(_options: CommitNodeOptions) { return 'sha'; } + async showNode(_sha: string) { return 'message'; } + async getNodeInfo(_sha: string) { + return { sha: 'sha', message: 'message', author: 'a', date: 'd', parents: [] }; + } + async logNodes(_options: LogNodesOptions) { return 'log'; } + async logNodesStream(_options: LogNodesOptions) { return WarpStream.of('log'); } + async countNodes(_ref: string) { return 1; } + async nodeExists(_sha: string) { return true; } async ping() { return { ok: true, latencyMs: 0 }; } - async writeBlob() { return 'blob'; } - async readBlob() { return new Uint8Array(); } - async writeTree() { return 'tree-oid'; } - async readTree() { return {}; } - async readTreeOids() { return {}; } - get emptyTree() { return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; } - async updateRef() { /* no-op */ } - async readRef() { return null; } - async deleteRef() { /* no-op */ } - async listRefs() { return []; } - async compareAndSwapRef() { /* no-op */ } - } - const adapter = new TestAdapter(); - expect(adapter).toBeInstanceOf(GraphPersistencePort); - const result = await adapter.ping(); - expect(result).toEqual({ ok: true, latencyMs: 0 }); - }); -}); - -describe('IndexStoragePort (abstract subset)', () => { - const expectedMethods = [ - 'writeBlob', 'readBlob', - 'writeTree', 'readTreeOids', - 'updateRef', 'readRef', - ]; - - it('abstract methods are not on the base prototype', () => { - const proto = IndexStoragePort.prototype; - for (const method of expectedMethods) { - expect(((proto))[method]).toBeUndefined(); + async updateRef(_ref: string, _oid: string) {} + async readRef(_ref: string) { return null; } + async deleteRef(_ref: string) {} + async listRefs(_prefix: string, _options?: ListRefsOptions) { return []; } + async compareAndSwapRef(_ref: string, _newOid: string, _expected: string | null) {} } - }); - it('does not include methods outside its subset', () => { - const Ctor = IndexStoragePort as any; - const port = new Ctor(); - expect(port.deleteRef).toBeUndefined(); - expect(port.listRefs).toBeUndefined(); - expect(port.readTree).toBeUndefined(); - }); - - it('subclass satisfies the contract', async () => { - class TestStorage extends IndexStoragePort { - async writeBlob() { return 'blob-oid'; } - async readBlob() { return new Uint8Array(); } - async writeTree() { return 'tree-oid'; } - async readTreeOids() { return {}; } - async updateRef() { /* no-op */ } - async readRef() { return null; } - } - const storage = new TestStorage(); - expect(storage).toBeInstanceOf(IndexStoragePort); - expect(await ((storage as any)).writeBlob(new Uint8Array())).toBe('blob-oid'); + const adapter = new TestAdapter(); + await expect(adapter.commitNode({ message: 'test' })).resolves.toBe('sha'); + expect(adapter).not.toHaveProperty('writeBlob'); + expect(adapter).not.toHaveProperty('writeTree'); }); }); diff --git a/test/unit/ports/IndexStorePort.test.ts b/test/unit/ports/IndexStorePort.test.ts index acf95646..99d35ada 100644 --- a/test/unit/ports/IndexStorePort.test.ts +++ b/test/unit/ports/IndexStorePort.test.ts @@ -1,28 +1,41 @@ import { describe, expect, it } from 'vitest'; -import IndexStorePort from '../../../src/ports/IndexStorePort.ts'; -import type WarpStream from '../../../src/domain/stream/WarpStream.ts'; import type { IndexShard } from '../../../src/domain/artifacts/IndexShard.ts'; +import AssetHandle from '../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../src/domain/storage/BundleHandle.ts'; +import WarpStream from '../../../src/domain/stream/WarpStream.ts'; import type CodecValue from '../../../src/domain/types/codec/CodecValue.ts'; +import IndexStorePort from '../../../src/ports/IndexStorePort.ts'; describe('IndexStorePort', () => { - it('abstract methods are not callable on base prototype', () => { + it('declares opaque-handle streaming methods', () => { expect(IndexStorePort.prototype.writeShards).toBeUndefined(); expect(IndexStorePort.prototype.scanShards).toBeUndefined(); - expect(IndexStorePort.prototype.readShardOids).toBeUndefined(); + expect(IndexStorePort.prototype.readShardHandles).toBeUndefined(); + expect(IndexStorePort.prototype.openShard).toBeUndefined(); expect(IndexStorePort.prototype.decodeShard).toBeUndefined(); }); - it('concrete subclass satisfies the contract', async () => { + it('can be implemented without object IDs', async () => { class TestStore extends IndexStorePort { - async writeShards(_shardStream: WarpStream) { return 'tree-oid'; } - scanShards(_treeOid: string) { return null as unknown as WarpStream; } - async readShardOids(_treeOid: string) { return { 'shard.cbor': 'blob-oid' }; } - async decodeShard(_blobOid: string): Promise { - return {} as TDecoded; + readonly bundleHandle = new BundleHandle('index:test'); + readonly shardHandle = new AssetHandle('index-shard:test'); + async writeShards(_shards: WarpStream) { return this.bundleHandle; } + scanShards(_handle: BundleHandle) { return WarpStream.of(); } + async readShardHandles(_handle: BundleHandle) { + return { 'shard.cbor': this.shardHandle }; + } + async *openShard(_handle: AssetHandle) { yield new Uint8Array([1]); } + async decodeShard( + _handle: AssetHandle, + ): Promise { + return Object.freeze({}) as TDecoded; } } + const store = new TestStore(); - expect(store).toBeInstanceOf(IndexStorePort); - expect(await store.writeShards(null as unknown as WarpStream)).toBe('tree-oid'); + await expect(store.writeShards(WarpStream.of())).resolves.toBe(store.bundleHandle); + await expect(store.readShardHandles(store.bundleHandle)).resolves.toEqual({ + 'shard.cbor': store.shardHandle, + }); }); }); diff --git a/test/unit/ports/PatchJournalPort.test.ts b/test/unit/ports/PatchJournalPort.test.ts index 602c70c8..c14a47d9 100644 --- a/test/unit/ports/PatchJournalPort.test.ts +++ b/test/unit/ports/PatchJournalPort.test.ts @@ -1,38 +1,71 @@ -import { describe, it, expect } from 'vitest'; -import PatchJournalPort, { type ReadPatchOptions } from '../../../src/ports/PatchJournalPort.ts'; -import type Patch from '../../../src/domain/types/Patch.ts'; -import type WarpStream from '../../../src/domain/stream/WarpStream.ts'; +import { describe, expect, it } from 'vitest'; import type PatchEntry from '../../../src/domain/artifacts/PatchEntry.ts'; +import AssetHandle from '../../../src/domain/storage/AssetHandle.ts'; +import BundleHandle from '../../../src/domain/storage/BundleHandle.ts'; +import StorageHandle from '../../../src/domain/storage/StorageHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../../src/domain/storage/StorageRetentionWitness.ts'; +import WarpStream from '../../../src/domain/stream/WarpStream.ts'; +import type Patch from '../../../src/domain/types/Patch.ts'; +import type { PatchCommitMessage } from '../../../src/ports/CommitMessageCodecPort.ts'; +import PatchJournalPort, { + type AppendPatchRequest, + type PublishedPatch, +} from '../../../src/ports/PatchJournalPort.ts'; describe('PatchJournalPort', () => { - it('abstract methods are not callable on base prototype', () => { - expect(PatchJournalPort.prototype.writePatch).toBeUndefined(); + it('declares semantic append, read, and range scan methods', () => { + expect(PatchJournalPort.prototype.appendPatch).toBeUndefined(); expect(PatchJournalPort.prototype.readPatch).toBeUndefined(); expect(PatchJournalPort.prototype.scanPatchRange).toBeUndefined(); }); - it('defaults usesExternalStorage to false', () => { + it('returns publication and retention evidence without a naked OID', async () => { + const patch = Object.freeze({ schema: 3, lamport: 1 }) as Patch; + const handle = new AssetHandle('asset:patch'); + const published: PublishedPatch = Object.freeze({ + sha: 'commit-sha', + bundleHandle: new BundleHandle('bundle:patch'), + stagedPatch: Object.freeze({ + handle, + size: 1, + observedAt: '1970-01-01T00:00:00.000Z', + retention: Object.freeze({ reachability: 'unanchored', protection: 'not-established' }), + }), + retention: new StorageRetentionWitness({ + handle: new StorageHandle('bundle:patch'), + policy: 'pinned', + reachability: 'anchored', + root: new StorageRetentionRoot({ + kind: 'publication', + namespace: 'g', + locator: 'refs/warp/g/writers/a', + generation: 'commit-sha', + path: '/', + }), + observedAt: '1970-01-01T00:00:00.000Z', + }), + }); class TestJournal extends PatchJournalPort { - async writePatch(_patch: Patch) { return 'oid'; } - async readPatch(_patchOid: string, _options?: ReadPatchOptions) { return {} as unknown as Patch; } - scanPatchRange(_writerId: string, _fromSha: string | null, _toSha: string) { - return null as unknown as WarpStream; + async appendPatch(_request: AppendPatchRequest) { return published; } + async readPatch(_message: PatchCommitMessage) { return patch; } + scanPatchRange(_writer: string, _from: string | null, _to: string) { + return WarpStream.of(); } } const journal = new TestJournal(); - expect(journal.usesExternalStorage).toBe(false); - }); - it('concrete subclass satisfies the contract', async () => { - class TestJournal extends PatchJournalPort { - async writePatch(_patch: Patch) { return 'oid'; } - async readPatch(_patchOid: string, _options?: ReadPatchOptions) { return {} as unknown as Patch; } - scanPatchRange(_writerId: string, _fromSha: string | null, _toSha: string) { - return null as unknown as WarpStream; - } - } - const journal = new TestJournal(); - expect(journal).toBeInstanceOf(PatchJournalPort); - expect(await journal.writePatch({} as unknown as Patch)).toBe('oid'); + const result = await journal.appendPatch({ + patch, + graph: 'g', + writer: 'a', + targetRef: 'refs/warp/g/writers/a', + expectedHead: null, + parent: null, + attachments: [], + }); + expect(result.stagedPatch.handle).toBe(handle); + expect(result.retention.reachability).toBe('anchored'); }); }); diff --git a/test/unit/ports/TreePort.test.ts b/test/unit/ports/TreePort.test.ts deleted file mode 100644 index 63eac0b1..00000000 --- a/test/unit/ports/TreePort.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import TreePort from '../../../src/ports/TreePort.ts'; - -describe('TreePort', () => { - it('abstract methods are not callable on base prototype', () => { - expect(TreePort.prototype.writeTree).toBeUndefined(); - expect(TreePort.prototype.readTree).toBeUndefined(); - expect(TreePort.prototype.readTreeOids).toBeUndefined(); - expect(Object.getOwnPropertyDescriptor(TreePort.prototype, 'emptyTree')).toBeUndefined(); - }); - - it('concrete subclass satisfies the contract', async () => { - class TestTree extends TreePort { - async writeTree(_entries: string[]) { return 'tree-oid'; } - async readTree(_treeOid: string) { return { 'file.txt': new Uint8Array([1]) }; } - async readTreeOids(_treeOid: string) { return { 'file.txt': 'blob-oid' }; } - get emptyTree() { return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; } - } - const tree = new TestTree(); - expect(tree).toBeInstanceOf(TreePort); - expect(await tree.writeTree(['100644 blob abc\tfile.txt'])).toBe('tree-oid'); - expect(tree.emptyTree).toBe('4b825dc642cb6eb9a060e54bf8d69288fbee4904'); - }); -}); diff --git a/test/unit/ports/WasmVerifiedAdmissionPort.test.ts b/test/unit/ports/WasmVerifiedAdmissionPort.test.ts index 3ce7ec69..967c42b0 100644 --- a/test/unit/ports/WasmVerifiedAdmissionPort.test.ts +++ b/test/unit/ports/WasmVerifiedAdmissionPort.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import type { WarpIntentDescriptor, WarpIntentOutcome } from '../../../src/domain/types/WarpIntentDescriptor.ts'; import type WarpWorldline from '../../../src/domain/WarpWorldline.ts'; import WasmVerifiedAdmissionService from '../../../src/domain/services/admission/WasmVerifiedAdmissionService.ts'; +import { testRetentionWitness } from '../../helpers/storageRetention.ts'; function createMockWarpWorldline(outcome: WarpIntentOutcome): WarpWorldline { return { admitIntent: async () => outcome } as unknown as WarpWorldline; @@ -47,6 +48,7 @@ describe('WasmVerifiedAdmissionPort & WasmVerifiedAdmissionService', () => { admitted: true, sha: 'blob:intent:sha123', intentId: validIntentDescriptor.intentId, + retention: testRetentionWitness('blob:intent:sha123'), }); const service = new WasmVerifiedAdmissionService(mockWorldline); @@ -64,6 +66,7 @@ describe('WasmVerifiedAdmissionPort & WasmVerifiedAdmissionService', () => { admitted: true, sha: 'blob:intent:sha123', intentId: validIntentDescriptor.intentId, + retention: testRetentionWitness('blob:intent:sha123'), }); const service = new WasmVerifiedAdmissionService(mockWorldline); diff --git a/test/unit/scripts/checkpoint-schema-upgrade.test.ts b/test/unit/scripts/checkpoint-schema-upgrade.test.ts index d2c2defd..9365ca0b 100644 --- a/test/unit/scripts/checkpoint-schema-upgrade.test.ts +++ b/test/unit/scripts/checkpoint-schema-upgrade.test.ts @@ -1,12 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import { Dot } from '../../../src/domain/crdt/Dot.ts'; +import VersionVector from '../../../src/domain/crdt/VersionVector.ts'; import { createEmptyState } from '../../../src/domain/services/JoinReducer.ts'; import { createFrontier, serializeFrontier, updateFrontier } from '../../../src/domain/services/Frontier.ts'; import { computeAppliedVV, serializeAppliedVV, + serializeCheckpointStateEnvelope, serializeFullState, } from '../../../src/domain/services/state/CheckpointSerializer.ts'; import { computeStateHash } from '../../../src/domain/services/state/StateSerializer.ts'; @@ -15,7 +17,18 @@ import { loadCheckpoint } from '../../../src/domain/services/state/checkpointLoa import { CURRENT_CHECKPOINT_SCHEMA } from '../../../src/domain/services/state/checkpointHelpers.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; +import { CborCheckpointStoreAdapter } from '../../../src/infrastructure/adapters/CborCheckpointStoreAdapter.ts'; +import GitCasAssetStorageAdapter from '../../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; import { buildCheckpointRef } from '../../../src/domain/utils/RefLayout.ts'; +import { collectAsyncIterable } from '../../../src/domain/utils/streamUtils.ts'; +import { textEncode } from '../../../src/domain/utils/bytes.ts'; +import { + CHECKPOINT_STORAGE_FORMAT, + LEGACY_CHECKPOINT_STORAGE_FORMAT, +} from '../../../src/ports/CommitMessageCodecPort.ts'; +import type AssetStoragePort from '../../../src/ports/AssetStoragePort.ts'; +import InMemoryBlobStorageAdapter from '../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../helpers/InMemoryGitCasFacade.ts'; import { CheckpointSchemaUpgradeError, upgradeCheckpointSchema, @@ -72,10 +85,9 @@ async function writeRetiredCheckpoint(options: { kind: 'checkpoint', graph: graphName, stateHash, - frontierOid, - indexOid: treeOid, schema: 4, - checkpointVersion: null, + checkpointVersion: LEGACY_CHECKPOINT_STORAGE_FORMAT, + bundleHandle: null, }); const checkpointSha = await options.persistence.commitNodeWithTree({ treeOid, @@ -89,6 +101,7 @@ async function writeRetiredCheckpoint(options: { describe('checkpoint schema upgrade script boundary', () => { it('dry-runs a retired checkpoint without moving the checkpoint ref', async () => { const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); const retiredCheckpointSha = await writeRetiredCheckpoint({ persistence, state: buildState(), @@ -100,6 +113,7 @@ describe('checkpoint schema upgrade script boundary', () => { graphName, dryRun: true, crypto, + ...migrationStorage, }); expect(result.status).toBe('would-upgrade'); @@ -110,6 +124,7 @@ describe('checkpoint schema upgrade script boundary', () => { it('writes a current checkpoint and updates the checkpoint ref after verification', async () => { const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); const retiredCheckpointSha = await writeRetiredCheckpoint({ persistence, state: buildState(), @@ -120,6 +135,7 @@ describe('checkpoint schema upgrade script boundary', () => { persistence, graphName, crypto, + ...migrationStorage, }); expect(result.status).toBe('upgraded'); @@ -132,17 +148,15 @@ describe('checkpoint schema upgrade script boundary', () => { if (upgradedSha === null) { throw new Error('Expected upgraded checkpoint SHA'); } - const loaded = await loadCheckpoint(persistence, upgradedSha, { - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, - codec: defaultCodec, - }); + const loaded = await loadCheckpoint(migrationStorage.checkpointStore, upgradedSha); expect(loaded.schema).toBe(CURRENT_CHECKPOINT_SCHEMA); expect(loaded.state.nodeAlive.contains('node:a')).toBe(true); - expect(loaded.indexShardOids).toEqual({ 'nodes/shard.cbor': expect.any(String) }); + expect(Object.keys(loaded.indexShardHandles ?? {})).toEqual(['nodes/shard.cbor']); }); it('does not move the checkpoint ref when the retired payload is incomplete', async () => { const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); const retiredCheckpointSha = await writeRetiredCheckpoint({ persistence, state: buildState(), @@ -153,34 +167,332 @@ describe('checkpoint schema upgrade script boundary', () => { persistence, graphName, crypto, + ...migrationStorage, })).rejects.toThrow(CheckpointSchemaUpgradeError); expect(await persistence.readRef(checkpointRef)).toBe(retiredCheckpointSha); }); + it('republishes a schema-5 checkpoint and its pointer-backed index as a v19 bundle', async () => { + const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); + const indexBytes = new Uint8Array([9, 8, 7, 6]); + const supportSha = await persistence.commitNode({ message: 'support', parents: [] }); + const legacyCheckpointSha = await writeLegacyCurrentCheckpoint({ + persistence, + assetStorage: migrationStorage.assetStorage, + state: buildState(), + indexBytes, + pointerBackedIndex: true, + parents: [supportSha], + }); + + const result = await upgradeCheckpointSchema({ + persistence, + graphName, + crypto, + ...migrationStorage, + }); + + expect(result.status).toBe('upgraded'); + expect(result.previousCheckpointSha).toBe(legacyCheckpointSha); + expect(result.previousSchema).toBe(CURRENT_CHECKPOINT_SCHEMA); + expect(result.previousStorageVersion).toBe(LEGACY_CHECKPOINT_STORAGE_FORMAT); + expect(result.currentStorageVersion).toBe(CHECKPOINT_STORAGE_FORMAT); + + const upgradedSha = result.upgradedCheckpointSha; + if (upgradedSha === null) { + throw new Error('Expected upgraded checkpoint SHA'); + } + const metadata = DEFAULT_COMMIT_MESSAGE_CODEC.decodeCheckpoint( + await persistence.showNode(upgradedSha), + ); + expect(metadata.checkpointVersion).toBe(CHECKPOINT_STORAGE_FORMAT); + expect(metadata.bundleHandle).not.toBeNull(); + expect((await persistence.getNodeInfo(upgradedSha)).parents).toEqual([supportSha]); + expect((await persistence.getNodeInfo(upgradedSha)).parents) + .not.toContain(legacyCheckpointSha); + + const loaded = await migrationStorage.checkpointStore.loadCheckpoint(upgradedSha); + expect(loaded.state.nodeAlive.contains('node:a')).toBe(true); + const indexHandle = loaded.indexShardHandles?.['nodes/shard.cbor']; + if (indexHandle === undefined) { + throw new Error('Expected migrated index shard handle'); + } + expect(await collectAsyncIterable(migrationStorage.assetStorage.open(indexHandle))) + .toEqual(indexBytes); + }); + + it('dry-runs schema-5 storage migration without moving the checkpoint ref', async () => { + const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); + const legacyCheckpointSha = await writeLegacyCurrentCheckpoint({ + persistence, + assetStorage: migrationStorage.assetStorage, + state: buildState(), + indexBytes: new Uint8Array([1, 3, 5]), + pointerBackedIndex: false, + }); + + const result = await upgradeCheckpointSchema({ + persistence, + graphName, + dryRun: true, + crypto, + ...migrationStorage, + }); + + expect(result.status).toBe('would-upgrade'); + expect(result.previousSchema).toBe(CURRENT_CHECKPOINT_SCHEMA); + expect(result.previousStorageVersion).toBe(LEGACY_CHECKPOINT_STORAGE_FORMAT); + expect(await persistence.readRef(checkpointRef)).toBe(legacyCheckpointSha); + }); + + it('does not overwrite a checkpoint ref advanced during migration', async () => { + const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); + const legacyCheckpointSha = await writeLegacyCurrentCheckpoint({ + persistence, + assetStorage: migrationStorage.assetStorage, + state: buildState(), + indexBytes: new Uint8Array([2, 4, 6]), + pointerBackedIndex: false, + }); + const readNodeInfo = persistence.getNodeInfo.bind(persistence); + let concurrentCheckpointSha: string | null = null; + vi.spyOn(persistence, 'getNodeInfo').mockImplementation(async (sha) => { + const info = await readNodeInfo(sha); + if (sha === legacyCheckpointSha && concurrentCheckpointSha === null) { + concurrentCheckpointSha = await persistence.commitNode({ + message: 'concurrent checkpoint', + parents: [], + }); + await persistence.updateRef(checkpointRef, concurrentCheckpointSha); + } + return info; + }); + + await expect(upgradeCheckpointSchema({ + persistence, + graphName, + crypto, + ...migrationStorage, + })).rejects.toThrow(); + expect(concurrentCheckpointSha).not.toBeNull(); + expect(await persistence.readRef(checkpointRef)).toBe(concurrentCheckpointSha); + }); + + it('rejects a checkpoint that declares v19 storage without a bundle handle', async () => { + const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); + const malformedCheckpointSha = await persistence.commitNode({ + message: malformedCurrentCheckpointMessage(), + parents: [], + }); + await persistence.updateRef(checkpointRef, malformedCheckpointSha); + + await expect(upgradeCheckpointSchema({ + persistence, + graphName, + crypto, + ...migrationStorage, + })).rejects.toThrow('has no bundle handle'); + expect(await persistence.readRef(checkpointRef)).toBe(malformedCheckpointSha); + }); + + it('rejects an unrecognized checkpoint storage version instead of treating it as legacy', async () => { + const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); + const malformedCheckpointSha = await persistence.commitNode({ + message: legacyCheckpointMessage().replace( + `eg-checkpoint: ${LEGACY_CHECKPOINT_STORAGE_FORMAT}`, + 'eg-checkpoint: v999', + ), + parents: [], + }); + await persistence.updateRef(checkpointRef, malformedCheckpointSha); + + await expect(upgradeCheckpointSchema({ + persistence, + graphName, + crypto, + ...migrationStorage, + })).rejects.toThrow('unsupported storage v999'); + expect(await persistence.readRef(checkpointRef)).toBe(malformedCheckpointSha); + }); + it('leaves an already-current checkpoint alone', async () => { const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); const state = buildState(); const frontier = createFrontier(); updateFrontier(frontier, 'writer-a', makeOid('patch-a')); const currentCheckpointSha = await createCheckpointEnvelope({ - persistence, + checkpointStore: migrationStorage.checkpointStore, graphName, state, frontier, crypto, codec: defaultCodec, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); - await persistence.updateRef(checkpointRef, currentCheckpointSha); const result = await upgradeCheckpointSchema({ persistence, graphName, crypto, + ...migrationStorage, }); expect(result.status).toBe('already-current'); expect(result.upgradedCheckpointSha).toBe(currentCheckpointSha); expect(await persistence.readRef(checkpointRef)).toBe(currentCheckpointSha); }); + + it('rejects a checkpoint ref that names another graph', async () => { + const persistence = new InMemoryGraphAdapter(); + const migrationStorage = createCheckpointMigrationStorage(persistence); + const foreignCheckpointSha = await createCheckpointEnvelope({ + checkpointStore: migrationStorage.checkpointStore, + graphName: 'other-graph', + state: buildState(), + frontier: createFrontier(), + crypto, + codec: defaultCodec, + }); + await persistence.updateRef(checkpointRef, foreignCheckpointSha); + + await expect(upgradeCheckpointSchema({ + persistence, + graphName, + crypto, + ...migrationStorage, + })).rejects.toMatchObject({ code: 'E_CHECKPOINT_GRAPH_MISMATCH' }); + expect(await persistence.readRef(checkpointRef)).toBe(foreignCheckpointSha); + }); }); + +function createCheckpointMigrationStorage( + persistence: InMemoryGraphAdapter, +): { + readonly checkpointStore: CborCheckpointStoreAdapter; + readonly assetStorage: GitCasAssetStorageAdapter; +} { + const backing = new InMemoryBlobStorageAdapter(); + const cas = new InMemoryGitCasFacade({ history: persistence, storage: backing }); + const assetStorage = new GitCasAssetStorageAdapter({ cas, legacyReader: persistence }); + return { + checkpointStore: new CborCheckpointStoreAdapter({ + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + history: persistence, + assetStorage, + cas, + }), + assetStorage, + }; +} + +async function writeLegacyCurrentCheckpoint(options: { + readonly persistence: InMemoryGraphAdapter; + readonly assetStorage: AssetStoragePort; + readonly state: WarpState; + readonly indexBytes: Uint8Array; + readonly pointerBackedIndex: boolean; + readonly parents?: string[]; +}): Promise { + const frontier = createFrontier(); + updateFrontier(frontier, 'writer-a', makeOid('patch-a')); + const envelope = serializeCheckpointStateEnvelope(options.state, { codec: defaultCodec }); + const stateEntries = await writeStateEnvelope(options.persistence, envelope); + const stateTreeOid = await options.persistence.writeTree(stateEntries); + const frontierOid = await options.persistence.writeBlob( + defaultCodec.encode(Object.fromEntries(frontier)), + ); + const appliedVVOid = await options.persistence.writeBlob( + defaultCodec.encode(VersionVector.serialize(computeAppliedVV(options.state))), + ); + const indexOid = options.pointerBackedIndex + ? await writeLegacyCasPointer(options.persistence, options.assetStorage, options.indexBytes) + : await options.persistence.writeBlob(options.indexBytes); + const indexTreeOid = await options.persistence.writeTree([ + `100644 blob ${indexOid}\tnodes/shard.cbor`, + ]); + const rootTreeOid = await options.persistence.writeTree([ + `100644 blob ${appliedVVOid}\tappliedVV.cbor`, + `100644 blob ${frontierOid}\tfrontier.cbor`, + `040000 tree ${indexTreeOid}\tindex`, + `040000 tree ${stateTreeOid}\tstate`, + ]); + const stateHash = await computeStateHash(options.state, { crypto, codec: defaultCodec }); + const checkpointSha = await options.persistence.commitNodeWithTree({ + treeOid: rootTreeOid, + parents: options.parents ?? [], + message: DEFAULT_COMMIT_MESSAGE_CODEC.encodeCheckpoint({ + kind: 'checkpoint', + graph: graphName, + stateHash, + schema: CURRENT_CHECKPOINT_SCHEMA, + checkpointVersion: LEGACY_CHECKPOINT_STORAGE_FORMAT, + bundleHandle: null, + }), + }); + await options.persistence.updateRef(checkpointRef, checkpointSha); + return checkpointSha; +} + +async function writeStateEnvelope( + persistence: InMemoryGraphAdapter, + envelope: ReturnType, +): Promise { + return await Promise.all([ + writeTreeEntry(persistence, 'edgeAlive', envelope.edgeAlive), + writeTreeEntry(persistence, 'edgeBirthEvent.cbor', envelope.edgeBirthEvent), + writeTreeEntry(persistence, 'nodeAlive', envelope.nodeAlive), + writeTreeEntry(persistence, 'observedFrontier.cbor', envelope.observedFrontier), + writeTreeEntry(persistence, 'prop.cbor', envelope.prop), + ]); +} + +async function writeTreeEntry( + persistence: InMemoryGraphAdapter, + path: string, + bytes: Uint8Array, +): Promise { + return `100644 blob ${await persistence.writeBlob(bytes)}\t${path}`; +} + +async function writeLegacyCasPointer( + persistence: InMemoryGraphAdapter, + assetStorage: AssetStoragePort, + bytes: Uint8Array, +): Promise { + const staged = await assetStorage.stage(singleChunk(bytes), { + slug: 'legacy-checkpoint-index', + filename: 'shard.cbor', + expectedSize: bytes.length, + }); + return await persistence.writeBlob( + textEncode(`git-warp:cas-pointer:v1:${staged.handle.toString()}`), + ); +} + +async function* singleChunk(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function malformedCurrentCheckpointMessage(): string { + return legacyCheckpointMessage().replace( + `eg-checkpoint: ${LEGACY_CHECKPOINT_STORAGE_FORMAT}`, + `eg-checkpoint: ${CHECKPOINT_STORAGE_FORMAT}`, + ); +} + +function legacyCheckpointMessage(): string { + return DEFAULT_COMMIT_MESSAGE_CODEC.encodeCheckpoint({ + kind: 'checkpoint', + graph: graphName, + stateHash: '0'.repeat(64), + schema: CURRENT_CHECKPOINT_SCHEMA, + checkpointVersion: LEGACY_CHECKPOINT_STORAGE_FORMAT, + bundleHandle: null, + }); +} diff --git a/test/unit/scripts/op-hydration-boundary-ratchet.test.ts b/test/unit/scripts/op-hydration-boundary-ratchet.test.ts index af27b9e5..31035005 100644 --- a/test/unit/scripts/op-hydration-boundary-ratchet.test.ts +++ b/test/unit/scripts/op-hydration-boundary-ratchet.test.ts @@ -9,19 +9,20 @@ function source(relativePath: string): string { } describe('op hydration boundary ratchet', () => { - it('keeps decoded patch hydration at storage and provenance boundaries', () => { + it('keeps decoded patch hydration at storage and codec 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(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));'); + expect(patchDiscovery).toContain("import type PatchJournalPort from '../../../ports/PatchJournalPort.ts';"); + expect(patchDiscovery).toContain('patch: await journal.readPatch(patchMeta)'); + expect(patchDiscovery).not.toContain('hydrateDecodedPatch'); }); it('keeps decoded ops runtime-backed before patch construction', () => { diff --git a/test/unit/scripts/pre-push-hook.test.ts b/test/unit/scripts/pre-push-hook.test.ts index c2a47ca2..5eca988e 100644 --- a/test/unit/scripts/pre-push-hook.test.ts +++ b/test/unit/scripts/pre-push-hook.test.ts @@ -49,8 +49,20 @@ function readLog(filePath) { } } -function runPrePushHook(options: { quick?: boolean; failCommand?: string | null; linkcheckAvailable?: boolean } = {}) { - const { quick = false, failCommand = null, linkcheckAvailable = true } = options; +function runPrePushHook(options: { + quick?: boolean; + failCommand?: string | null; + linkcheckAvailable?: boolean; + inheritedGitRepositoryRoot?: string | null; + assertGitEnvironmentCleared?: boolean; +} = {}) { + const { + quick = false, + failCommand = null, + linkcheckAvailable = true, + inheritedGitRepositoryRoot = null, + assertGitEnvironmentCleared = false, + } = options; const binDir = createTempDir(); const npmBin = join(binDir, 'npm'); const npmLog = join(binDir, 'npm.log'); @@ -69,6 +81,12 @@ function runPrePushHook(options: { quick?: boolean; failCommand?: string | null; 'fi', "printf '%s\\n' \"$cmd\" >> \"$WARP_NPM_LOG\"", "printf 'npm:%s\\n' \"$cmd\" >> \"$WARP_EVENT_LOG\"", + 'if [ "${WARP_ASSERT_GIT_ENV_CLEARED:-}" = "1" ]; then', + ' if [ -n "${GIT_DIR:-}" ] || [ -n "${GIT_WORK_TREE:-}" ] || [ -n "${GIT_COMMON_DIR:-}" ] || [ -n "${GIT_INDEX_FILE:-}" ]; then', + ' echo "repository-local Git environment leaked into npm gate" >&2', + ' exit 97', + ' fi', + 'fi', 'if [ "${WARP_FAIL_NPM_CMD:-}" = "$cmd" ]; then', ' echo "stub npm failing for $cmd" >&2', ' exit 1', @@ -92,7 +110,7 @@ function runPrePushHook(options: { quick?: boolean; failCommand?: string | null; ); } - const env = { + const env = { ...process.env, WARP_NPM_LOG: npmLog, WARP_LYCHEE_LOG: lycheeLog, @@ -111,6 +129,17 @@ function runPrePushHook(options: { quick?: boolean; failCommand?: string | null; env['WARP_FAIL_NPM_CMD'] = failCommand; } + if (inheritedGitRepositoryRoot) { + env['GIT_DIR'] = join(inheritedGitRepositoryRoot, '.git'); + env['GIT_WORK_TREE'] = inheritedGitRepositoryRoot; + env['GIT_COMMON_DIR'] = join(inheritedGitRepositoryRoot, '.git'); + env['GIT_INDEX_FILE'] = join(inheritedGitRepositoryRoot, '.git', 'test-index'); + } + + if (assertGitEnvironmentCleared) { + env['WARP_ASSERT_GIT_ENV_CLEARED'] = '1'; + } + const result = spawnSync('sh', [hookPath], { cwd: repoRoot, env, @@ -132,6 +161,23 @@ function runPrePushHook(options: { quick?: boolean; failCommand?: string | null; } describe('scripts/hooks/pre-push', () => { + it('clears inherited repository-local Git environment before running child gates', () => { + const inheritedGitRepositoryRoot = createTempDir(); + const init = spawnSync('git', ['init', '--quiet', inheritedGitRepositoryRoot], { + encoding: 'utf8', + }); + expect(init.status, init.stderr).toBe(0); + + const result = runPrePushHook({ + quick: true, + inheritedGitRepositoryRoot, + assertGitEnvironmentCleared: true, + }); + + expect(result.status).toBe(0); + expect(result.output).toContain('pre-push: cleared inherited Git repository environment'); + }); + it('runs the optional link check before the blocking npm gates', () => { const result = runPrePushHook({ quick: true }); diff --git a/test/unit/scripts/source-size-inventory-command.test.ts b/test/unit/scripts/source-size-inventory-command.test.ts index 6320e1a0..b587b856 100644 --- a/test/unit/scripts/source-size-inventory-command.test.ts +++ b/test/unit/scripts/source-size-inventory-command.test.ts @@ -17,7 +17,6 @@ const SOURCE_OVER_BUDGET_PATHS = Object.freeze([ '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/state/WarpState.ts', diff --git a/test/unit/scripts/source-version-name-policy.test.ts b/test/unit/scripts/source-version-name-policy.test.ts index eb9b7b40..5322dd77 100644 --- a/test/unit/scripts/source-version-name-policy.test.ts +++ b/test/unit/scripts/source-version-name-policy.test.ts @@ -51,6 +51,10 @@ describe('source version-name policy', () => { path: 'src/infrastructure/adapters/BunHttpAdapter.ts', source: "const family = host.includes(':') ? 'IPv6' : 'IPv4';", }, + { + path: 'src/ports/CommitMessageCodecPort.ts', + source: "const schema = 'git-cas-asset-patch-v1';", + }, ]); expect(violations).toEqual([]); diff --git a/test/unit/scripts/storage-ownership-boundary.test.ts b/test/unit/scripts/storage-ownership-boundary.test.ts index 13dcbca6..0bb26b50 100644 --- a/test/unit/scripts/storage-ownership-boundary.test.ts +++ b/test/unit/scripts/storage-ownership-boundary.test.ts @@ -6,6 +6,24 @@ import { describe, expect, it } from 'vitest'; const REPO_ROOT = new URL('../../../', import.meta.url); const PRODUCTION_ROOTS = ['src', 'bin'] as const; const PRODUCTION_ENTRYPOINTS = ['index.ts', 'storage.ts', 'advanced.ts', 'diagnostics.ts'] as const; +const DOMAIN_STORAGE_ROOTS = ['src/domain', 'src/ports'] as const; +const FORBIDDEN_DOMAIN_MODULES = new Set([ + '@git-stunts/git-cas', + '@git-stunts/plumbing', +]); +const FORBIDDEN_DOMAIN_STORAGE_IDENTIFIERS = new Set([ + 'BlobPort', + 'BlobStoragePort', + 'TreePort', + 'createTree', + 'hashObject', + 'readBlob', + 'readManifest', + 'readTree', + 'restoreStream', + 'writeBlob', + 'writeTree', +]); const REMOVED_PRODUCTION_SYMBOLS = new Set([ 'CachedValue', 'CasFirstMemoizationEngine', @@ -91,6 +109,50 @@ function forbiddenReferences( return [...violations]; } +function forbiddenDomainStorageReferences( + path: string, + sourceText = readFileSync(path, 'utf8'), +): string[] { + const source = ts.createSourceFile( + path, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const relativePath = relative(REPO_ROOT.pathname, path); + const violations = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && FORBIDDEN_DOMAIN_STORAGE_IDENTIFIERS.has(node.text)) { + violations.add(`${relativePath} exposes raw storage capability ${node.text}`); + } + const moduleName = importedModuleName(node); + if (moduleName !== null && FORBIDDEN_DOMAIN_MODULES.has(moduleName)) { + violations.add(`${relativePath} imports forbidden storage module ${moduleName}`); + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...violations]; +} + +function importedModuleName(node: ts.Node): string | null { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) + && node.moduleSpecifier !== undefined + && ts.isStringLiteral(node.moduleSpecifier)) { + return node.moduleSpecifier.text; + } + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) + && ts.isStringLiteral(node.argument.literal)) { + return node.argument.literal.text; + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + const argument = node.arguments[0]; + return argument !== undefined && ts.isStringLiteral(argument) ? argument.text : null; + } + return null; +} + describe('storage ownership boundary', () => { it('rejects removed symbols in arbitrary identifier positions', () => { const fixturePath = new URL('storage-ownership-fixture.ts', REPO_ROOT).pathname; @@ -119,4 +181,32 @@ describe('storage ownership boundary', () => { expect(violations).toEqual([]); }); + + it('rejects raw storage imports and capabilities in a mutation fixture', () => { + const fixturePath = new URL('domain-storage-boundary-fixture.ts', REPO_ROOT).pathname; + const violations = forbiddenDomainStorageReferences(fixturePath, ` + import type { AssetHandle } from '@git-stunts/git-cas'; + type Plumbing = import('@git-stunts/plumbing').default; + interface LeakyPort { + writeBlob(bytes: Uint8Array): Promise; + readTree(oid: string): Promise; + } + `).sort(); + + expect(violations).toEqual([ + 'domain-storage-boundary-fixture.ts exposes raw storage capability readTree', + 'domain-storage-boundary-fixture.ts exposes raw storage capability writeBlob', + 'domain-storage-boundary-fixture.ts imports forbidden storage module @git-stunts/git-cas', + 'domain-storage-boundary-fixture.ts imports forbidden storage module @git-stunts/plumbing', + ]); + }); + + it('keeps domain and port modules storage-substrate neutral', () => { + const productionFiles = DOMAIN_STORAGE_ROOTS.flatMap(productionTypeScriptFiles); + const violations = productionFiles + .flatMap((path) => forbiddenDomainStorageReferences(path)) + .sort(); + + expect(violations).toEqual([]); + }); }); diff --git a/test/unit/scripts/v16-to-v17-upgrade.test.ts b/test/unit/scripts/v16-to-v17-upgrade.test.ts index 1decf637..5e1cc798 100644 --- a/test/unit/scripts/v16-to-v17-upgrade.test.ts +++ b/test/unit/scripts/v16-to-v17-upgrade.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest'; import packageJson from '../../../package.json' with { type: 'json' }; import publishTsconfig from '../../../tsconfig.publish.json' with { type: 'json' }; -import { createEmptyState } from '../../../src/domain/services/JoinReducer.ts'; -import { createFrontier } from '../../../src/domain/services/Frontier.ts'; -import { createCheckpointEnvelope } from '../../../src/domain/services/state/checkpointCreate.ts'; import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; -import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../helpers/MemoryRuntimeStorageAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; +import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; +import { createEmptyState } from '../../../src/domain/services/JoinReducer.ts'; +import { createFrontier } from '../../../src/domain/services/Frontier.ts'; +import { createCheckpointEnvelope } from '../../../src/domain/services/state/checkpointCreate.ts'; import { formatHumanResult, parseArgs, @@ -51,6 +52,7 @@ describe('v16 to v17 top-level upgrade utility', () => { const result = await upgradeV16ToV17({ persistence, + runtimeStorage: new MemoryRuntimeStorageAdapter({ history: persistence }), graphNames: ['alpha'], dryRun: true, }); @@ -66,21 +68,26 @@ describe('v16 to v17 top-level upgrade utility', () => { it('deletes rebuildable cache refs while leaving checkpoint refs under checkpoint migration control', async () => { const persistence = new InMemoryGraphAdapter(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ history: persistence }); + const services = await runtimeStorage.createRuntimeStorageServices({ + timelineName: 'alpha', + codec: defaultCodec, + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); const checkpointSha = await createCheckpointEnvelope({ - persistence, + checkpointStore: services.checkpoints, graphName: 'alpha', state: createEmptyState(), frontier: createFrontier(), - crypto: new NodeCryptoAdapter(), codec: defaultCodec, - commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + crypto: new NodeCryptoAdapter(), }); - await persistence.updateRef('refs/warp/alpha/checkpoints/head', checkpointSha); await persistence.updateRef('refs/warp/alpha/coverage/head', oid('a')); await persistence.updateRef('refs/warp/alpha/seek-cache', oid('b')); const result = await upgradeV16ToV17({ persistence, + runtimeStorage, graphNames: ['alpha'], }); 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 200eaad5..0142c147 100644 --- a/test/unit/scripts/v18-content-property-closeout-audit.test.ts +++ b/test/unit/scripts/v18-content-property-closeout-audit.test.ts @@ -17,11 +17,8 @@ const RAW_COMPATIBILITY_PATTERN = /decodePropKey|decodeEdgePropKey|state\.prop|( const EXPECTED_RAW_COMPATIBILITY_FILES = Object.freeze([ 'src/domain/graph/LegacyContentPropertyKeys.ts', 'src/domain/services/KeyCodec.ts', - 'src/domain/services/PatchCommitter.ts', 'src/domain/services/state/StateDiff.ts', 'src/domain/services/state/WarpState.ts', - 'src/domain/services/state/checkpointHelpers.ts', - 'src/domain/services/strand/StrandPatchService.ts', ]); describe('v18 content/property closeout audit', () => { diff --git a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts index 32784810..58edfa62 100644 --- a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts +++ b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts @@ -47,7 +47,7 @@ describe('v18 v17 public-read legacy reading builder', () => { 'alice', ]); expect(reading.facts.find((fact) => fact.factKey === 'node:alpha:_content')?.value) - .toBe('92c0f5ef3874549768bceef59319608437cfc926'); + .toBe('git-cas:1:asset:manifest-tree:cbor:sha1:92c0f5ef3874549768bceef59319608437cfc926'); }); it('fails closed when a restored v17 writer ref drifts after restore', async () => {