diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79eb98169..e7fd44f8d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ roadmap state. │ intent · reading · tick · receipt │ ├──────────────────────────────────────────────────┤ │ Opaque storage composition │ -│ GitStorage.open() · MemoryStorage.create() │ +│ GitStorage.open() │ ├──────────┬───────────┬────────────┬──────────────┤ │ Query │ Patch │ Materialize│ Sync │ │Controller│Controller │ Controller │ Controller │ @@ -206,7 +206,6 @@ Abstract contracts between domain and infrastructure: - **CryptoPort** — hash, hmac, sign, verify - **ClockPort** — wall clock (injected, not ambient) - **LoggerPort** — structured logging -- **SeekCachePort** — persistent seek cache for time-travel - **PatchJournalPort** — streamed patch-entry scans - **CheckpointStorePort** — folded checkpoint state storage - **IndexStorePort** — streamed index shard storage @@ -218,11 +217,12 @@ Concrete implementations of ports: - **GitTimelineHistoryAdapter** — timeline-history Git commands via @git-stunts/plumbing - **GitCasRepositoryAdapter** — repository-scoped git-cas service composition -- **InMemoryGraphAdapter** — in-memory Maps for testing - **CborCodec** — CBOR encoding via cbor-x - **NodeCryptoAdapter / WebCryptoAdapter** — hash/sign via node:crypto or SubtleCrypto - **CasBlobAdapter** — content-addressable blob storage via @git-stunts/git-cas -- **CasSeekCacheAdapter** — persistent seek cache on git-cas + +In-memory persistence implementations live under `test/helpers/`; they are not +production adapters or package exports. ## Git storage model diff --git a/bin/cli/commands/check.ts b/bin/cli/commands/check.ts index 05d656e53..cac41f912 100644 --- a/bin/cli/commands/check.ts +++ b/bin/cli/commands/check.ts @@ -1,4 +1,3 @@ -import HealthCheckService from '../../../src/domain/services/HealthCheckService.ts'; import { buildCheckpointRef, buildCoverageRef } from '../../../src/domain/utils/RefLayout.ts'; import { EXIT_CODES } from '../infrastructure.ts'; import { openGraph, applyCursorCeiling, emitCursorWarning, readCheckpointDate, createHookInstaller } from '../shared.ts'; @@ -6,9 +5,29 @@ import type { CliOptions, Persistence, WarpGraphInstance } from '../types.ts'; import type HookPathPort from '../../../src/ports/HookPathPort.ts'; /** Performs a health check on the graph persistence. */ -async function getHealth(persistence: Persistence): Promise<{ status: string; components: { repository: { status: string; latencyMs: number }; index: { status: string; loaded: boolean; shardCount?: number } }; cachedAt?: string }> { - const healthService = new HealthCheckService({ persistence }); - return await healthService.getHealth(0); +async function getHealth(persistence: Persistence): Promise<{ status: string; components: { repository: { status: string; latencyMs: number }; index: { status: string; loaded: boolean; shardCount?: number } } }> { + try { + const ping = await persistence.ping(); + const repositoryStatus = ping.ok ? 'healthy' : 'unhealthy'; + return { + status: ping.ok ? 'degraded' : 'unhealthy', + components: { + repository: { + status: repositoryStatus, + latencyMs: Math.round(ping.latencyMs * 100) / 100, + }, + index: { status: 'degraded', loaded: false }, + }, + }; + } catch { + return { + status: 'unhealthy', + components: { + repository: { status: 'unhealthy', latencyMs: 0 }, + index: { status: 'degraded', loaded: false }, + }, + }; + } } /** Collects garbage collection metrics for the graph. */ diff --git a/bin/cli/commands/doctor/checks.ts b/bin/cli/commands/doctor/checks.ts index 2bc00c966..603bed77d 100644 --- a/bin/cli/commands/doctor/checks.ts +++ b/bin/cli/commands/doctor/checks.ts @@ -7,9 +7,7 @@ * @module cli/commands/doctor/checks */ -import HealthCheckService from '../../../../src/domain/services/HealthCheckService.ts'; import type { WarpStateSnapshotRecord } from '../../../../src/ports/WarpStateCachePort.ts'; -import type { CorePersistence } from '../../../../src/domain/types/WarpPersistence.ts'; import { buildCheckpointRef, buildCoverageRef, @@ -35,12 +33,11 @@ function internalError(id: string, err: unknown): DoctorFinding { // ── repo-accessible ───────────────────────────────────────────────────────── -/** Verify the repository is reachable via HealthCheckService. */ +/** Verify the repository is reachable. */ export async function checkRepoAccessible(ctx: DoctorContext): Promise { try { - const svc = new HealthCheckService({ persistence: ctx.persistence as unknown as CorePersistence }); - const health = await svc.getHealth(0); - if (health.components.repository.status === 'unhealthy') { + const health = await ctx.persistence.ping(); + if (!health.ok) { return { id: 'repo-accessible', status: 'fail', code: CODES.REPO_UNREACHABLE, impact: 'operability', message: 'Repository is not accessible', diff --git a/bin/cli/commands/seek.ts b/bin/cli/commands/seek.ts index bd1e18a8a..9fe37bc26 100644 --- a/bin/cli/commands/seek.ts +++ b/bin/cli/commands/seek.ts @@ -14,7 +14,7 @@ import { } from '../time-travel-shared.ts'; import { EXIT_CODES, usageError, notFoundError, parseCommandArgs } from '../infrastructure.ts'; import { seekSchema } from '../schemas.ts'; -import { openGraph, readActiveCursor, writeActiveCursor, wireSeekCache } from '../shared.ts'; +import { openGraph, readActiveCursor, writeActiveCursor } from '../shared.ts'; import type { WarpState } from '../../../src/domain/services/JoinReducer.ts'; import type { CliOptions, Persistence, WarpGraphInstance, WriterTickInfo, CursorBlob, SeekSpec } from '../types.ts'; @@ -29,8 +29,6 @@ const SEEK_OPTIONS = { load: { type: 'string' }, list: { type: 'boolean', default: false }, drop: { type: 'string' }, - 'clear-cache': { type: 'boolean', default: false }, - 'no-persistent-cache': { type: 'boolean', default: false }, diff: { type: 'boolean', default: false }, 'diff-limit': { type: 'string', default: '2000' }, }; @@ -248,19 +246,7 @@ async function handleSeekStatus({ graph, graphName, persistence, activeCursor, t /** Handles the `git warp seek` command across all sub-actions. */ export default async function handleSeek({ options, args }: { options: CliOptions; args: string[] }): Promise<{ payload: unknown; exitCode: number }> { const seekSpec = parseSeekArgs(args); - const { graph, graphName, persistence, createSeekCache } = await openGraph(options); - void wireSeekCache({ graph, createSeekCache, graphName, seekSpec }); - - // Handle --clear-cache before discovering ticks (no materialization needed) - if (seekSpec.action === 'clear-cache') { - if (graph.seekCache) { - await graph.seekCache.clear(); - } - return { - payload: { graph: graphName, action: 'clear-cache', message: 'Seek cache cleared.' }, - exitCode: EXIT_CODES.OK, - }; - } + const { graph, graphName, persistence } = await openGraph(options); const activeCursor = await readActiveCursor(persistence, graphName); const { ticks, maxTick, perWriter } = await graph.discoverTicks(); diff --git a/bin/cli/schemas.ts b/bin/cli/schemas.ts index 4f002106d..c60db6179 100644 --- a/bin/cli/schemas.ts +++ b/bin/cli/schemas.ts @@ -142,8 +142,6 @@ type SeekInput = { load?: string | undefined; list: boolean; drop?: string | undefined; - 'clear-cache': boolean; - 'no-persistent-cache': boolean; diff: boolean; 'diff-limit': number; }; @@ -161,7 +159,6 @@ function countSeekActions(val: SeekInput): number { val.load !== undefined, val.list, val.drop !== undefined, - val['clear-cache'], ].filter(Boolean).length; } @@ -186,7 +183,7 @@ function issueIf(ctx: z.RefinementCtx, condition: boolean, message: string): voi */ function refineSeekActions(val: SeekInput, ctx: z.RefinementCtx): void { issueIf(ctx, countSeekActions(val) > 1, - 'Only one seek action flag allowed at a time (--tick, --latest, --save, --load, --list, --drop, --clear-cache)'); + 'Only one seek action flag allowed at a time (--tick, --latest, --save, --load, --list, --drop)'); issueIf(ctx, val.diff && !hasDiffCompatibleAction(val), '--diff cannot be used without --tick, --latest, or --load'); issueIf(ctx, val['diff-limit'] !== 2000 && !val.diff, @@ -205,7 +202,6 @@ const SEEK_ACTION_TABLE: Array<{ key: keyof SeekInput; action: string; isBool: b { key: 'load', action: 'load', isBool: false }, { key: 'list', action: 'list', isBool: true }, { key: 'drop', action: 'drop', isBool: false }, - { key: 'clear-cache', action: 'clear-cache', isBool: true }, ]; /** @@ -244,7 +240,6 @@ function transformSeek(val: SeekInput) { action, tickValue, name, - noPersistentCache: val['no-persistent-cache'], diff: val.diff, diffLimit: val['diff-limit'], }; @@ -259,8 +254,6 @@ export const seekSchema = z.object({ load: z.string().min(1, 'Missing value for --load').optional(), list: z.boolean().default(false), drop: z.string().min(1, 'Missing value for --drop').optional(), - 'clear-cache': z.boolean().default(false), - 'no-persistent-cache': z.boolean().default(false), diff: z.boolean().default(false), 'diff-limit': z.coerce.number().int({ message: '--diff-limit must be a positive integer' }).positive({ message: '--diff-limit must be a positive integer' }).refine(n => Number.isFinite(n), { message: '--diff-limit must be a finite number' }).default(2000), }).strict().superRefine(refineSeekActions).transform((val) => transformSeek(val)); diff --git a/bin/cli/shared.ts b/bin/cli/shared.ts index 67c54d07b..f4e230848 100644 --- a/bin/cli/shared.ts +++ b/bin/cli/shared.ts @@ -17,17 +17,15 @@ import { usageError, notFoundError } from './infrastructure.ts'; import { GitStorage } from '../../storage.ts'; import { resolveWarpStorage } from '../../src/application/WarpStorageRegistry.ts'; import type RuntimeStorageProviderPort from '../../src/ports/RuntimeStorageProviderPort.ts'; -import type SeekCachePort from '../../src/ports/SeekCachePort.ts'; import type TrustChainPort from '../../src/ports/TrustChainPort.ts'; import type CryptoPort from '../../src/ports/CryptoPort.ts'; import type HookPathPort from '../../src/ports/HookPathPort.ts'; -import type { Persistence, WarpGraphInstance, CursorBlob, CliOptions, SeekSpec } from './types.ts'; +import type { Persistence, WarpGraphInstance, CursorBlob, CliOptions } from './types.ts'; export type CliStorageBinding = { readonly persistence: Persistence; readonly runtimeStorage: RuntimeStorageProviderPort; - readonly createSeekCache: (timelineName: string) => SeekCachePort; readonly createTrustChain: (crypto: CryptoPort) => TrustChainPort; readonly hookPaths: HookPathPort; }; @@ -39,7 +37,6 @@ export async function createPersistence(repoPath: string): Promise SeekCachePort; hookPaths: HookPathPort }> { - const { persistence, runtimeStorage, createSeekCache, hookPaths } = await createPersistence(options.repo); +export async function openGraph(options: CliOptions): Promise<{ graph: WarpGraphInstance; graphName: string; persistence: Persistence; runtimeStorage: RuntimeStorageProviderPort; hookPaths: HookPathPort }> { + const { persistence, runtimeStorage, hookPaths } = await createPersistence(options.repo); const graphName = await resolveGraphName(persistence, options.graph); if (typeof options.graph === 'string' && options.graph.length > 0) { const graphNames = await listGraphNames(persistence); @@ -114,7 +110,7 @@ export async function openGraph(options: CliOptions): Promise<{ graph: WarpGraph writerId: options.writer, crypto: new WebCryptoAdapter(), }); - return { graph, graphName, persistence, runtimeStorage, createSeekCache, hookPaths }; + return { graph, graphName, persistence, runtimeStorage, hookPaths }; } /** @@ -240,18 +236,3 @@ function readPackageVersion(rawJson: string): string { const obj = raw as { version: string }; return obj.version; } - -/** - * Attaches a persistent seek cache to a graph instance unless disabled by flags. - */ -export function wireSeekCache({ graph, createSeekCache, graphName, seekSpec }: { - graph: WarpGraphInstance; - createSeekCache: (timelineName: string) => SeekCachePort; - graphName: string; - seekSpec: SeekSpec; -}): void { - if (seekSpec.noPersistentCache) { - return; - } - graph.setSeekCache(createSeekCache(graphName)); -} diff --git a/bin/cli/types.ts b/bin/cli/types.ts index f0d938a3e..efab4129d 100644 --- a/bin/cli/types.ts +++ b/bin/cli/types.ts @@ -41,7 +41,6 @@ export type SeekSpec = { action: string; tickValue: string | null; name: string | null; - noPersistentCache: boolean; diff: boolean; diffLimit: number; }; diff --git a/bin/warp-graph.ts b/bin/warp-graph.ts index 4080d3935..bede0386c 100755 --- a/bin/warp-graph.ts +++ b/bin/warp-graph.ts @@ -121,7 +121,6 @@ async function main(): Promise { return; // Keep the process alive } - // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes). process.exit(normalized.exitCode); } diff --git a/docs/TECHNICAL_TEARDOWN.md b/docs/TECHNICAL_TEARDOWN.md index 87bddb207..1afd430a5 100644 --- a/docs/TECHNICAL_TEARDOWN.md +++ b/docs/TECHNICAL_TEARDOWN.md @@ -168,32 +168,24 @@ Application code usually starts with `index.ts`. At import time, `index.ts` does three major things: -1. Imports the public API classes, functions, ports, adapters, error classes, - and compatibility exports. -2. Calls `installDefaultRuntimeHostNodePorts()`. -3. Exports the API surface, including `GitGraphAdapter`, - `InMemoryGraphAdapter`, `openWarpWorldline()`, `openWarpGraph()`, - `WarpApp`, `WarpCore`, `PatchBuilder`, query/observer types, sync helpers, - trust and provenance helpers, and error classes. +1. Exports the first-use values `openWarp`, `intent`, and `reading`. +2. Exports application types for timelines, intents, readings, receipts, and + opaque storage handles. +3. Leaves storage construction, advanced optics, and diagnostics on explicit + package subpaths. ```mermaid flowchart TB app["Application import"] --> index["index.ts"] - index --> defaults["installDefaultRuntimeHostNodePorts()"] - defaults --> codec["default CBOR codec resolver"] - defaults --> crypto["NodeCryptoAdapter resolver"] - defaults --> trust["TrustCryptoAdapter resolver"] - defaults --> message["default commit-message codec"] index --> exports["Public exports"] - exports --> worldline["openWarpWorldline()"] - exports --> graph["openWarpGraph()"] - exports --> adapters["GitGraphAdapter / InMemoryGraphAdapter"] + exports --> warp["openWarp()"] + exports --> intents["intent builders"] + exports --> readings["reading builders"] + storage["storage subpath"] --> gitStorage["GitStorage"] ``` -This is a bootstrapping side effect, but a bounded one. The runtime still -constructs real graph objects only when the caller opens a worldline or graph. -The import installs resolver defaults so callers do not have to inject a codec -or crypto implementation in normal Node usage. +The root import has no storage bootstrap side effect. `GitStorage.open()` owns +the Node Git and git-cas composition used by first-use applications. ## System Architecture @@ -281,7 +273,6 @@ classDiagram _versionVector _materializedGraph _lastFrontier - _seekCache _stateCache _patchJournal _checkpointStore @@ -1470,8 +1461,6 @@ ambient environment variables. | `autoMaterialize` | Controls runtime eager materialization behavior. | Faster reads versus less background work. | | `checkpointPolicy.every` | Auto-create checkpoints after enough patches. | More checkpoint storage for shorter future replay. | | `gcPolicy` | Tombstone compaction thresholds and enablement. | Lower storage/scan cost versus risk of doing extra compaction work. | -| `adjacencyCacheSize` | Size of adjacency cache. Default is `3`. | More memory for fewer rebuilds. | -| `seekCache` | Persistent seek cache. | Faster time-travel reads versus cache storage. | | `stateCache` | Durable materialized snapshot cache. | More CAS storage for faster materialization. | | `blobStorage` | Content/CAS storage. | Enables attachments and git-cas patch storage. | | `patchJournal` | Patch serialization and storage boundary. | Custom storage can replace default CBOR journal. | diff --git a/docs/migrations/v19/README.md b/docs/migrations/v19/README.md index c59a3c214..8f876ce1e 100644 --- a/docs/migrations/v19/README.md +++ b/docs/migrations/v19/README.md @@ -245,7 +245,7 @@ and joins first. | `JoinReceiptOutcome` | root `JoinOutcome` | operation-specific outcome alias rename | | `EdgePropertyIntentFields` | removed | no edge-property intent ships in the v19 root | | `GitGraphAdapter` | `storage` `GitStorage.open({ cwd })` | plumbing and CAS composition are internal | -| `InMemoryGraphAdapter` | `storage` `MemoryStorage.create()` | graph name and adapter constructor removed | +| `InMemoryGraphAdapter` | removed | tests may use test helpers; apps use `GitStorage` | | `GraphPersistencePort` | root `WarpStorage` for app options | old graph-shaped port removed from public API | | `commit((patch) => ...)` | `timeline.write(intent.*)` | receipt-returning | | `PatchBuilder` | removed | replace with intent builders | diff --git a/docs/topics/api/README.md b/docs/topics/api/README.md index e91682e05..977099350 100644 --- a/docs/topics/api/README.md +++ b/docs/topics/api/README.md @@ -428,7 +428,7 @@ Each old root symbol needs one explicit disposition: | ------------------------ | ---------------------------------------------------------- | | `openWarpWorldline()` | `openWarp().timeline(name)` | | `GitGraphAdapter` | `GitStorage.open({ cwd })` from `storage` | -| `InMemoryGraphAdapter` | `MemoryStorage.create()` 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` | diff --git a/docs/topics/reference.md b/docs/topics/reference.md index f3795a073..64913fdea 100644 --- a/docs/topics/reference.md +++ b/docs/topics/reference.md @@ -87,15 +87,14 @@ WriteReceiptOptions @ index.ts#L62 ## Storage export surface -Git-backed and in-memory adapters for first-use applications. +Git-backed storage for first-use applications. ### Value exports -Source: `storage.ts`. Count: 2. +Source: `storage.ts`. Count: 1. ```text -GitStorage @ storage.ts#L18 -MemoryStorage @ storage.ts#L43 +GitStorage @ storage.ts#L16 ``` ### Type exports @@ -103,7 +102,7 @@ MemoryStorage @ storage.ts#L43 Source: `storage.ts`. Count: 1. ```text -GitStorageOptions @ storage.ts#L14 +GitStorageOptions @ storage.ts#L12 ``` ## Advanced export surface @@ -193,7 +192,7 @@ ReceiptSubstrateInspection @ diagnostics.ts#L15 Structured CLI errors for `--json` and `--ndjson` use the payload shape `{ error: { code, message, cause? } }` from the CLI entry point. -Source: `bin/warp-graph.ts#L132`. +Source: `bin/warp-graph.ts#L131`. ## Public error classes diff --git a/docs/topics/unmaterialized-intents.md b/docs/topics/unmaterialized-intents.md index afec6e8f7..59580974f 100644 --- a/docs/topics/unmaterialized-intents.md +++ b/docs/topics/unmaterialized-intents.md @@ -26,7 +26,9 @@ const outcome = await worldline.admitIntent({ }); ``` -`admitIntent()` verifies the precommit guards directly against `WarpWorldlineOpticBasis`. It does not execute `PatchBuilder` or run `JoinReducer` to materialize the whole graph. If a precommit guard fails, the admission fails closed with `admitted: false` and returns the typed obstruction tag (e.g., `QuestNotReady`). +`admitIntent()` verifies the precommit guards against the bounded worldline read basis. It does not execute `PatchBuilder`, run `JoinReducer` to materialize the whole graph, or store an unattached descriptor blob. If a precommit guard fails, the admission fails closed with `admitted: false` and returns the typed obstruction tag (e.g., `QuestNotReady`). + +This descriptor API is a compatibility admission surface, not a durable write. New application code that needs an admitted intent recorded in causal history must use `Timeline.write(intent)`. That path lowers the public intent into the normal committed patch history and returns a write receipt. ## Cost posture @@ -34,7 +36,7 @@ These labels describe current provider cost, not aspirational architecture. | Surface | Current posture | What to rely on | | --- | --- | --- | -| Unmaterialized intent admission | Bounded | Precommit guards verify localized properties via `SeekCachePort` without graph-wide materialization. | +| Unmaterialized intent admission | Bounded | Precommit guards verify localized properties through the bounded worldline read basis without graph-wide materialization or object allocation. | | Speculative strand intent queuing | Bounded | Intents accumulate in-memory on the strand descriptor with zero Git object allocation until tick admission. | | Whole-state `JoinReducer` ticks | Diagnostic | Imperative CRDT patch accumulation. Use for legacy compatibility or whole-graph diagnostic verification. | @@ -54,9 +56,8 @@ flowchart TD subgraph DeclarativeAdmission [Declarative Intent Admission] I1[worldline.admitIntent] -->|Passes| I2[WarpIntentDescriptor] - I2 -->|Evaluates Guards| I3[WarpWorldlineOpticBasis / SeekCachePort] - I3 -->|Unmaterialized Append| I4[GitWarpWitnessedSuffixAdmissionShell] - I4 -->|Direct Write| P4 + I2 -->|Evaluates Guards| I3[Bounded Worldline Read Basis] + I3 -->|Returns| I4[Admission Outcome] end ``` @@ -66,7 +67,7 @@ flowchart TD Edict calculates its cryptographic nutrition labels (`bundleHash`, `coreHash`) directly over canonical CBOR/JSON bytes (`edict.canonical-cbor/v1`). -When an intent passes its precommit guards, the runtime encapsulates `WarpIntentDescriptor` into a `GitWarpWitnessedSuffixAdmissionShell`. It serializes this deterministic document directly into `refs/warp//writers/`. By retaining canonical CBOR/JSON, `git-warp` preserves verifier proofs with zero serialization overhead and avoids the rigid 32-bit limits of Wesley LE-binary (`lr_raw`). +When a descriptor passes its precommit guards, the compatibility API returns an admission identity and does not retain a descriptor artifact. Use `Timeline.write(intent)` when the requested transformation must become durable causal history. The timeline write path lowers the intent into a committed patch and keeps storage ownership behind the configured `WarpStorage` implementation. ## See also diff --git a/eslint.config.ts b/eslint.config.ts index 39554dd85..63543a5cf 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -324,7 +324,6 @@ export default tseslint.config( // ── Relaxed complexity for algorithm-heavy modules ───────────────────────── { files: [ - "src/domain/services/index/IndexRebuildService.ts", "src/domain/services/index/BitmapNeighborProvider.ts", "src/domain/services/index/BitmapAccumulator.ts", "src/domain/services/index/BitmapIndexBuilder.ts", @@ -467,10 +466,8 @@ export default tseslint.config( "src/infrastructure/adapters/CborPatchJournalAdapter.ts", "src/infrastructure/adapters/GitGraphAdapter.ts", "src/infrastructure/adapters/GitTrustChainAdapter.ts", - "src/infrastructure/adapters/InMemoryGraphAdapter.ts", "src/infrastructure/adapters/IndexShardEncodeTransform.ts", "src/infrastructure/adapters/gitErrorClassification.ts", - "src/infrastructure/adapters/inMemoryHashing.ts", // CLI "bin/warp-graph.ts", "bin/cli/infrastructure.ts", diff --git a/scripts/check-source-backed-reference.ts b/scripts/check-source-backed-reference.ts index 9fc26a135..54e30ca1f 100644 --- a/scripts/check-source-backed-reference.ts +++ b/scripts/check-source-backed-reference.ts @@ -264,7 +264,7 @@ function generate(): string { '', ...exportSurface('Root API export surface', rootSource, 'First-use product API: `openWarp`, `intent`, `reading`, timelines, and receipts.'), '', - ...exportSurface('Storage export surface', storageSource, 'Git-backed and in-memory adapters for first-use applications.'), + ...exportSurface('Storage export surface', storageSource, 'Git-backed storage for first-use applications.'), '', ...exportSurface('Advanced export surface', advancedSource, 'Bounded coordinate capture, Optic, and Witness concepts for expert use.'), '', diff --git a/scripts/smoke-packed-artifact.sh b/scripts/smoke-packed-artifact.sh index 9b515580d..b45735ba8 100755 --- a/scripts/smoke-packed-artifact.sh +++ b/scripts/smoke-packed-artifact.sh @@ -45,12 +45,16 @@ for (const name of ['openWarp', 'intent', 'reading']) { } } -for (const name of ['GitStorage', 'MemoryStorage']) { +for (const name of ['GitStorage']) { if (!(name in storage)) { throw new PackedArtifactSmokeError(`storage subpath did not export ${name}`); } } +if ('MemoryStorage' in storage) { + throw new PackedArtifactSmokeError('storage subpath still exported MemoryStorage'); +} + if ('openWarpGraph' in mod) { throw new PackedArtifactSmokeError('package root still exported openWarpGraph'); } diff --git a/scripts/source-version-name-policy.ts b/scripts/source-version-name-policy.ts index aacdfa5c9..45a11ce7a 100644 --- a/scripts/source-version-name-policy.ts +++ b/scripts/source-version-name-policy.ts @@ -132,11 +132,6 @@ const sourceVersionNameExceptions: readonly SourceVersionNameException[] = Objec ')', ]), }), - Object.freeze({ - name: 'seek-cache-version-token', - reason: 'Seek-cache keys use a persisted version prefix to avoid cache-key collisions.', - pattern: /src\/domain\/utils\/seekCacheKey\.ts/, - }), ]); export const SOURCE_VERSION_NAME_EXCEPTIONS = sourceVersionNameExceptions; diff --git a/src/application/WarpStorageRegistry.ts b/src/application/WarpStorageRegistry.ts index f2cfbe5cb..7fbcbf900 100644 --- a/src/application/WarpStorageRegistry.ts +++ b/src/application/WarpStorageRegistry.ts @@ -2,7 +2,6 @@ import WarpError from '../domain/errors/WarpError.ts'; import type { CorePersistence } from '../domain/types/WarpPersistence.ts'; import type CryptoPort from '../ports/CryptoPort.ts'; import type RuntimeStorageProviderPort from '../ports/RuntimeStorageProviderPort.ts'; -import type SeekCachePort from '../ports/SeekCachePort.ts'; import type TrustChainPort from '../ports/TrustChainPort.ts'; import type HookPathPort from '../ports/HookPathPort.ts'; import type WarpStorage from './WarpStorage.ts'; @@ -10,7 +9,6 @@ import type WarpStorage from './WarpStorage.ts'; export type WarpStorageBinding = { readonly history: CorePersistence; readonly runtimeStorage: RuntimeStorageProviderPort; - readonly createSeekCache?: (timelineName: string) => SeekCachePort; readonly createTrustChain?: (crypto: CryptoPort) => TrustChainPort; readonly hookPaths?: HookPathPort; }; diff --git a/src/domain/RuntimeHost.ts b/src/domain/RuntimeHost.ts index 9fb755d4d..e3ffd88c6 100644 --- a/src/domain/RuntimeHost.ts +++ b/src/domain/RuntimeHost.ts @@ -19,7 +19,6 @@ import { import nullLogger from './utils/nullLogger.ts'; import LogicalTraversal from './services/query/LogicalTraversal.ts'; import LiveQueryReadModelProvider from './services/query/LiveQueryReadModelProvider.ts'; -import LRUCache from './utils/LRUCache.ts'; import SyncController from './services/controllers/SyncController.ts'; import StrandController from './services/controllers/StrandController.ts'; import ComparisonController from './services/controllers/ComparisonController.ts'; @@ -55,7 +54,6 @@ import type LoggerPort from '../ports/LoggerPort.ts'; import type CryptoPort from '../ports/CryptoPort.ts'; import type CodecPort from '../ports/CodecPort.ts'; import type TrustCryptoPort from '../ports/TrustCryptoPort.ts'; -import type SeekCachePort from '../ports/SeekCachePort.ts'; import type WarpStateCachePort from '../ports/WarpStateCachePort.ts'; import type BlobStoragePort from '../ports/BlobStoragePort.ts'; import type PatchJournalPort from '../ports/PatchJournalPort.ts'; @@ -75,7 +73,6 @@ import type QueryCapability from './capabilities/QueryCapability.ts'; import type AdjacencyMap from './capabilities/AdjacencyMap.ts'; import { - DEFAULT_ADJACENCY_CACHE_SIZE, normalizeTrustConfig, type TrustMode, type NormalizedTrustConfig, @@ -193,7 +190,6 @@ export default class RuntimeHost { _autoMaterialize: boolean; traverse: LogicalTraversal; _materializedGraph: MaterializedGraph | null; - _adjacencyCache: LRUCache | null; _lastFrontier: Map | null; _logger: LoggerPort | null; _crypto: CryptoPort; @@ -207,7 +203,6 @@ export default class RuntimeHost { _seekCeiling: number | null; _cachedCeiling: number | null; _cachedFrontier: Map | null; - _seekCache: SeekCachePort | null; _stateCache: WarpStateCachePort | null; _blobStorage: BlobStoragePort | null; _patchBlobStorage: BlobStoragePort | null; @@ -255,7 +250,6 @@ export default class RuntimeHost { graphName, writerId, gcPolicy = {}, - adjacencyCacheSize = DEFAULT_ADJACENCY_CACHE_SIZE, checkpointPolicy, autoMaterialize = true, onDeleteWithData = 'warn', @@ -263,7 +257,6 @@ export default class RuntimeHost { crypto, codec, trustCrypto, - seekCache, stateCache, audit = false, blobStorage, @@ -297,7 +290,6 @@ export default class RuntimeHost { this._checkpointing = false; this._autoMaterialize = autoMaterialize; this._materializedGraph = null; - this._adjacencyCache = adjacencyCacheSize > 0 ? new LRUCache(adjacencyCacheSize) : null; this._lastFrontier = null; this._logger = logger || null; this._crypto = crypto; @@ -311,7 +303,6 @@ export default class RuntimeHost { this._seekCeiling = null; this._cachedCeiling = null; this._cachedFrontier = null; - this._seekCache = seekCache || null; this._stateCache = stateCache || null; this._blobStorage = blobStorage || null; this._patchBlobStorage = patchBlobStorage || null; @@ -687,20 +678,6 @@ export default class RuntimeHost { syncWith: SyncController['syncWith'] = (...args) => this._syncController.syncWith(...args); serve: SyncController['serve'] = (...args) => this._syncController.serve(...args); - /** - * Returns the attached seek cache, or null if none is set. - */ - get seekCache(): SeekCachePort | null { - return this._seekCache; - } - - /** - * Attaches a persistent seek cache after construction. - */ - setSeekCache(cache: SeekCachePort): void { - this._seekCache = cache; - } - /** * Builds a SyncTrustGate from a resolved trust configuration. */ diff --git a/src/domain/WarpCore.ts b/src/domain/WarpCore.ts index 6c8e9acbd..5ec606a56 100644 --- a/src/domain/WarpCore.ts +++ b/src/domain/WarpCore.ts @@ -30,7 +30,6 @@ export default class WarpCore { declare readonly persistence: WarpCoreRuntimeSurface['persistence']; declare readonly onDeleteWithData: WarpCoreRuntimeSurface['onDeleteWithData']; declare readonly gcPolicy: WarpCoreRuntimeSurface['gcPolicy']; - declare readonly seekCache: WarpCoreRuntimeSurface['seekCache']; declare readonly hasNode: WarpCoreRuntimeSurface['hasNode']; declare readonly getNodeProps: WarpCoreRuntimeSurface['getNodeProps']; declare readonly getEdgeProps: WarpCoreRuntimeSurface['getEdgeProps']; @@ -134,7 +133,6 @@ export default class WarpCore { declare readonly planCoordinateTransfer: WarpCoreRuntimeSurface['planCoordinateTransfer']; declare readonly subscribe: WarpCoreRuntimeSurface['subscribe']; declare readonly watch: WarpCoreRuntimeSurface['watch']; - declare readonly setSeekCache: WarpCoreRuntimeSurface['setSeekCache']; declare readonly fork: WarpCoreRuntimeSurface['fork']; declare readonly createWormhole: WarpCoreRuntimeSurface['createWormhole']; declare _effectPipeline: EffectPipeline | null; diff --git a/src/domain/WarpGraph.ts b/src/domain/WarpGraph.ts index 4744d187a..19d98b8ce 100644 --- a/src/domain/WarpGraph.ts +++ b/src/domain/WarpGraph.ts @@ -31,7 +31,6 @@ 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 SeekCachePort from '../ports/SeekCachePort.ts'; import type BlobStoragePort from '../ports/BlobStoragePort.ts'; import type PatchJournalPort from '../ports/PatchJournalPort.ts'; import type CommitMessageCodecPort from '../ports/CommitMessageCodecPort.ts'; @@ -134,7 +133,7 @@ type TrustMode = 'off' | 'log-only' | 'enforce'; * - governing policy (trust, GC, checkpoint, onDeleteWithData) * - witness infrastructure (crypto, codec, audit) * - revelation regime (logger, effectSinks, externalizationPolicy) - * - optional accelerators (seekCache, blobStorage, indexStore) + * - optional semantic storage services (blobStorage, indexStore) */ export interface WarpGraphDeps { // Substrate @@ -165,14 +164,12 @@ export interface WarpGraphDeps { readonly effectSinks?: EffectSinkPort[]; readonly externalizationPolicy?: ExternalizationPolicy; - // Accelerators (optional — auto-constructed if absent) - readonly seekCache?: SeekCachePort; + // 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; - readonly adjacencyCacheSize?: number; } // --------------------------------------------------------------------------- diff --git a/src/domain/runtimeHelpers.ts b/src/domain/runtimeHelpers.ts index f0803ce63..677706b0d 100644 --- a/src/domain/runtimeHelpers.ts +++ b/src/domain/runtimeHelpers.ts @@ -13,8 +13,6 @@ import type { EffectPipeline } from './services/EffectPipeline.ts'; import type { MultiplexSink } from './services/MultiplexSink.ts'; import WarpError from './errors/WarpError.ts'; -export const DEFAULT_ADJACENCY_CACHE_SIZE = 3; - /** * Constructs an EffectPipeline from an array of sinks and an optional externalization lens. */ diff --git a/src/domain/services/HealthCheckService.ts b/src/domain/services/HealthCheckService.ts deleted file mode 100644 index 80a4c288b..000000000 --- a/src/domain/services/HealthCheckService.ts +++ /dev/null @@ -1,242 +0,0 @@ -import nullLogger from '../utils/nullLogger.ts'; -import CachedValue from '../utils/CachedValue.ts'; -import type CommitPort from '../../ports/CommitPort.ts'; -import type LoggerPort from '../../ports/LoggerPort.ts'; - -/** - * Default TTL for health check cache in lamport ticks. - * Invalidates after 50 patches have been observed. - */ -const DEFAULT_CACHE_TTL_TICKS = 50; - -/** - * Health status constants. - */ -export const HealthStatus = { - HEALTHY: 'healthy', - DEGRADED: 'degraded', - UNHEALTHY: 'unhealthy', -} as const; - -export type HealthStatusValue = typeof HealthStatus[keyof typeof HealthStatus]; - -export interface RepositoryHealth { - status: 'healthy' | 'unhealthy'; - latencyMs: number; -} - -export interface IndexHealth { - status: 'healthy' | 'degraded' | 'unhealthy'; - loaded: boolean; - shardCount?: number; -} - -export interface HealthResult { - status: 'healthy' | 'degraded' | 'unhealthy'; - components: { - repository: RepositoryHealth; - index: IndexHealth; - }; - cachedAtTick?: number; -} - -interface IndexReaderLike { // nosemgrep: ts-no-like-types -- 0025C - shardOids?: { size: number }; -} - -/** - * Service for performing health checks on the graph system. - * - * Follows hexagonal architecture by depending on ports, not adapters. - * Provides K8s-style probes (liveness vs readiness) and detailed component health. - * - * @example - * const healthService = new HealthCheckService({ - * persistence, - * cacheTtlTicks: 100, - * logger, - * }); - * - * // K8s liveness probe - am I running? - * const alive = await healthService.isAlive(currentLamport); - * - * // K8s readiness probe - can I serve requests? - * const ready = await healthService.isReady(currentLamport); - * - * // Detailed health breakdown - * const health = await healthService.getHealth(currentLamport); - * console.log(health.status); // 'healthy' | 'degraded' | 'unhealthy' - */ -export default class HealthCheckService { - private readonly _persistence: CommitPort; - private readonly _logger: LoggerPort; - private _indexReader: IndexReaderLike | null; // nosemgrep: ts-no-like-types -- 0025C - private readonly _healthCache: CachedValue<{ - status: 'healthy' | 'degraded' | 'unhealthy'; - components: { repository: RepositoryHealth; index: IndexHealth }; - }>; - - /** - * Creates a HealthCheckService instance. - */ - constructor({ - persistence, - cacheTtlTicks = DEFAULT_CACHE_TTL_TICKS, - logger = nullLogger, - }: { - persistence: CommitPort; - cacheTtlTicks?: number; - logger?: LoggerPort; - }) { - this._persistence = persistence; - this._logger = logger; - this._indexReader = null; - - this._healthCache = new CachedValue({ - ttlTicks: cacheTtlTicks, - compute: () => this._computeHealth(), - }); - } - - /** - * Sets the index reader for index health checks. - * Call this when an index is loaded. - * - * @param reader - The index reader, or null to clear - */ - setIndexReader(reader: IndexReaderLike | null): void { // nosemgrep: ts-no-like-types -- 0025C - this._indexReader = reader; - this._healthCache.invalidate(); - } - - /** - * K8s-style liveness probe: Is the service running? - * - * Returns true if the repository is accessible. - * A failed liveness check typically triggers a container restart. - */ - async isAlive(currentTick: number): Promise { - const health = await this.getHealth(currentTick); - // Alive if repository is reachable (even if degraded) - return health.components.repository.status !== HealthStatus.UNHEALTHY; - } - - /** - * K8s-style readiness probe: Can the service serve requests? - * - * Returns true if all critical components are healthy. - * A failed readiness check removes the pod from load balancer. - */ - async isReady(currentTick: number): Promise { - const health = await this.getHealth(currentTick); - return health.status === HealthStatus.HEALTHY; - } - - /** - * Gets detailed health information for all components. - * - * Results are cached for the configured tick TTL to prevent - * excessive health check calls under load. - */ - async getHealth(currentTick: number): Promise { - const { value, cachedAtTick, fromCache } = await this._healthCache.getWithMetadata(currentTick); - const result = value as HealthResult; - - if (fromCache && cachedAtTick > 0) { - return { ...result, cachedAtTick }; - } - - // Log only for fresh computations - if (!fromCache) { - this._logger.debug('Health check completed', { - operation: 'getHealth', - status: result.status, - repositoryStatus: result.components.repository.status, - indexStatus: result.components.index.status, - }); - } - - return result; - } - - /** - * Computes health by checking all components. - * This is called by CachedValue when the cache is stale. - */ - private async _computeHealth(): Promise<{ - status: 'healthy' | 'degraded' | 'unhealthy'; - components: { repository: RepositoryHealth; index: IndexHealth }; - }> { - const repositoryHealth = await this._checkRepository(); - const indexHealth = this._checkIndex(); - const status = this._computeOverallStatus(repositoryHealth, indexHealth); - - return { - status, - components: { - repository: repositoryHealth, - index: indexHealth, - }, - }; - } - - /** - * Checks repository health by pinging the persistence layer. - */ - private async _checkRepository(): Promise { - try { - const pingResult = await this._persistence.ping(); - return { - status: pingResult.ok ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY, - latencyMs: Math.round(pingResult.latencyMs * 100) / 100, - }; - } catch (err) { - this._logger.warn('Repository ping failed', { - operation: 'checkRepository', - error: err instanceof Error ? err.message : String(err), - }); - return { - status: HealthStatus.UNHEALTHY, - latencyMs: 0, - }; - } - } - - /** - * Checks index health based on loaded state and shard count. - */ - private _checkIndex(): IndexHealth { - if (!this._indexReader) { - return { - status: HealthStatus.DEGRADED, - loaded: false, - }; - } - - const shardCount = this._indexReader.shardOids?.size ?? 0; - - return { - status: HealthStatus.HEALTHY, - loaded: true, - shardCount, - }; - } - - /** - * Computes overall health status from component health. - */ - private _computeOverallStatus( - repositoryHealth: RepositoryHealth, - indexHealth: IndexHealth, - ): 'healthy' | 'degraded' | 'unhealthy' { - if (repositoryHealth.status === HealthStatus.UNHEALTHY) { - return HealthStatus.UNHEALTHY; - } - - if (indexHealth.status === HealthStatus.DEGRADED) { - return HealthStatus.DEGRADED; - } - - return HealthStatus.HEALTHY; - } -} diff --git a/src/domain/services/PatchBuilder.ts b/src/domain/services/PatchBuilder.ts index 76e301323..f69ca8366 100644 --- a/src/domain/services/PatchBuilder.ts +++ b/src/domain/services/PatchBuilder.ts @@ -282,7 +282,7 @@ export class PatchBuilder { assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); this._assertNodeExistsForContent(nodeId); if (!this._blobStorage) { - throw new WriterError('Cannot attach content without blob storage — inject blobStorage via open() or use InMemoryBlobStorageAdapter', { code: 'NO_BLOB_STORAGE' }); + throw new WriterError('Cannot attach content without blob storage; configure content storage when opening the runtime', { code: 'NO_BLOB_STORAGE' }); } const slug = `${this._graphName}/${nodeId}`; const payload = await storeContentAttachmentPayload({ @@ -320,7 +320,7 @@ export class PatchBuilder { assertNoReservedBytes(CONTENT_PROPERTY_KEY, 'key'); this._assertEdgeExists(from, to, label); if (!this._blobStorage) { - throw new WriterError('Cannot attach content without blob storage — inject blobStorage via open() or use InMemoryBlobStorageAdapter', { code: 'NO_BLOB_STORAGE' }); + throw new WriterError('Cannot attach content without blob storage; configure content storage when opening the runtime', { code: 'NO_BLOB_STORAGE' }); } const slug = `${this._graphName}/${from}/${to}/${label}`; const payload = await storeContentAttachmentPayload({ diff --git a/src/domain/services/controllers/ForkController.ts b/src/domain/services/controllers/ForkController.ts index 2f74f9ad8..afc585855 100644 --- a/src/domain/services/controllers/ForkController.ts +++ b/src/domain/services/controllers/ForkController.ts @@ -27,7 +27,6 @@ import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; import type GCPolicy from '../GCPolicy.ts'; import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts'; -const DEFAULT_ADJACENCY_CACHE_SIZE = 3; const HEX_CHARS = '0123456789abcdef'; type ForkRuntimeOpenOptions = RuntimeHostOpenOptions; type ForkedGraph = RuntimeHostProduct; @@ -49,7 +48,6 @@ type ForkHost = { _runtimeStorage: RuntimeStorageProviderPort; _graphName: string; _gcPolicy: GCPolicy; - _adjacencyCache: { maxSize?: number } | null; _checkpointPolicy: { every: number } | null; _autoMaterialize: boolean; _onDeleteWithData: 'reject' | 'cascade' | 'warn'; @@ -161,7 +159,6 @@ export default class ForkController { graphName: resolvedForkName, writerId: resolvedForkWriterId, gcPolicy: host._gcPolicy, - adjacencyCacheSize: (host._adjacencyCache as { maxSize?: number })?.maxSize ?? DEFAULT_ADJACENCY_CACHE_SIZE, ...(host._checkpointPolicy ? { checkpointPolicy: host._checkpointPolicy } : {}), autoMaterialize: host._autoMaterialize, onDeleteWithData: host._onDeleteWithData, diff --git a/src/domain/services/controllers/IntentController.ts b/src/domain/services/controllers/IntentController.ts index 2047c2d5e..822138121 100644 --- a/src/domain/services/controllers/IntentController.ts +++ b/src/domain/services/controllers/IntentController.ts @@ -11,9 +11,6 @@ import type ProjectionHandle from '../ProjectionHandle.ts'; export type IntentHost = { _graphName: string; _writerId: string; - _persistence: { - writeBlob?: (blob: Uint8Array) => Promise; - }; worldline: () => ProjectionHandle; }; @@ -37,12 +34,8 @@ export default class IntentController implements IntentCapability { return { admitted: false, obstruction, intentId: descriptor.intentId }; } } - const jsonBytes = new TextEncoder().encode(JSON.stringify(descriptor)); this._admitCounter += 1; - let sha = `blob:intent:${descriptor.intentId}:${this._host._writerId}:${this._admitCounter}`; - if (typeof this._host._persistence.writeBlob === 'function') { - sha = await this._host._persistence.writeBlob(jsonBytes); - } + const sha = `intent:${descriptor.intentId}:${this._host._writerId}:${this._admitCounter}`; return { admitted: true, sha, intentId: descriptor.intentId }; } diff --git a/src/domain/services/controllers/detachedOpen.ts b/src/domain/services/controllers/detachedOpen.ts index 1dfa54b6f..5de31df4e 100644 --- a/src/domain/services/controllers/detachedOpen.ts +++ b/src/domain/services/controllers/detachedOpen.ts @@ -11,7 +11,6 @@ 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 SeekCachePort from '../../../ports/SeekCachePort.ts'; import type { CorePersistence } from '../../types/WarpPersistence.ts'; import type { NormalizedTrustConfig } from '../../runtimeHelpers.ts'; import type GCPolicy from '../GCPolicy.ts'; @@ -31,7 +30,6 @@ export type DetachedOpenOptions = { audit: false; checkpointPolicy?: { every: number }; logger?: LoggerPort; - seekCache?: SeekCachePort; blobStorage?: BlobStoragePort; patchBlobStorage?: BlobStoragePort; trust?: NormalizedTrustConfig; @@ -50,7 +48,6 @@ export type DetachedOpenHost = { _gcPolicy: GCPolicy; _checkpointPolicy: { every: number } | null; _logger: LoggerPort | null; - _seekCache: SeekCachePort | null; _blobStorage: BlobStoragePort | null; _patchBlobStorage: BlobStoragePort | null; _trustConfig: NormalizedTrustConfig; @@ -77,10 +74,9 @@ function coreOptions(graph: DetachedOpenHost): DetachedOpenOptions { }; } -function addCachePorts(opts: DetachedOpenOptions, g: DetachedOpenHost): void { +function addReadPolicy(opts: DetachedOpenOptions, g: DetachedOpenHost): void { if (g._checkpointPolicy) { opts.checkpointPolicy = g._checkpointPolicy; } if (g._logger) { opts.logger = g._logger; } - if (g._seekCache) { opts.seekCache = g._seekCache; } } function addStoragePorts(opts: DetachedOpenOptions, g: DetachedOpenHost): void { @@ -104,7 +100,7 @@ export async function openDetachedGraph( open: DetachedGraphOpen, ): Promise { const opts = coreOptions(graph); - addCachePorts(opts, graph); + addReadPolicy(opts, graph); addStoragePorts(opts, graph); addConfigPorts(opts, graph); addStoresPorts(opts, graph); diff --git a/src/domain/services/index/IndexRebuildService.ts b/src/domain/services/index/IndexRebuildService.ts deleted file mode 100644 index 5516cc0a4..000000000 --- a/src/domain/services/index/IndexRebuildService.ts +++ /dev/null @@ -1,321 +0,0 @@ -import BitmapIndexBuilder from './BitmapIndexBuilder.ts'; -import BitmapIndexReader from './BitmapIndexReader.ts'; -import StreamingBitmapIndexBuilder from './StreamingBitmapIndexBuilder.ts'; -import { loadIndexFrontier, checkStaleness } from './IndexStalenessChecker.ts'; -import nullLogger from '../../utils/nullLogger.ts'; -import { checkAborted } from '../../utils/cancellation.ts'; -import IndexError from '../../errors/IndexError.ts'; -import { requireCodec } from '../codec/CodecRequirement.ts'; -import type IndexStoragePort from '../../../ports/IndexStoragePort.ts'; -import type StreamingIndexStoragePort from '../../../ports/StreamingIndexStoragePort.ts'; -import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; - -type GraphService = { - iterateNodes(opts: { ref: string; limit: number }): AsyncIterable<{ sha: string; parents: string[] }>; -}; - -type MaybeStreamingStorage = IndexStoragePort & { - writeBlobStream?: (source: AsyncIterable) => Promise; - readBlobStream?: (oid: string) => AsyncIterable; -}; - -type RebuildOptions = { - limit?: number; - maxMemoryBytes?: number; - onFlush?: (stats: { flushedBytes: number; totalFlushedBytes: number; flushCount: number }) => void; - onProgress?: (stats: { processedNodes: number; currentMemoryBytes: number | null }) => void; - signal?: AbortSignal; - frontier?: Map; -}; - -type InMemoryOptions = { - limit: number; - onProgress?: (stats: { processedNodes: number; currentMemoryBytes: number | null }) => void; - signal?: AbortSignal; - frontier?: Map; -}; - -type StreamingOptions = { - limit: number; - maxMemoryBytes: number; - onFlush?: (stats: { flushedBytes: number; totalFlushedBytes: number; flushCount: number }) => void; - onProgress?: (stats: { processedNodes: number; currentMemoryBytes: number }) => void; - signal?: AbortSignal; - frontier?: Map; -}; - -type LoadOptions = { - strict?: boolean; - currentFrontier?: Map; - autoRebuild?: boolean; - rebuildRef?: string; -}; - -function isStreamingStorage(storage: MaybeStreamingStorage): storage is StreamingIndexStoragePort { - return typeof storage.writeBlobStream === 'function' - && typeof storage.readBlobStream === 'function'; -} - -/** - * Service for building and loading the bitmap index from the graph. - * - * This service orchestrates index creation by walking the graph and persisting - * the resulting bitmap shards to storage via the IndexStoragePort. The bitmap - * index enables O(1) neighbor lookups (children/parents) after a one-time - * O(N) rebuild cost. - * - * @module domain/services/index/IndexRebuildService - * @see BitmapIndexBuilder - * @see BitmapIndexReader - * @see StreamingBitmapIndexBuilder - */ -export default class IndexRebuildService { - private readonly graphService: GraphService; - private readonly storage: IndexStoragePort & BlobPort & TreePort; - private readonly logger: LoggerPort; - private readonly _codec: CodecPort; - - constructor(options: { - graphService: GraphService; - storage: IndexStoragePort & BlobPort & TreePort; - logger?: LoggerPort; - codec?: CodecPort; - crypto?: unknown; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - }) { - const { graphService, storage, logger = nullLogger, codec, crypto } = options ?? {}; - if (graphService === undefined || graphService === null) { - throw new IndexError( - 'IndexRebuildService requires a graphService', - { code: 'E_INDEX_MISSING_GRAPH_SERVICE' }, - ); - } - if (storage === undefined || storage === null) { - throw new IndexError( - 'IndexRebuildService requires a storage adapter', - { code: 'E_INDEX_MISSING_STORAGE' }, - ); - } - this.graphService = graphService; - this.storage = storage; - this.logger = logger; - this._codec = requireCodec(codec, 'IndexRebuildService'); - void crypto; // reserved for future use - } - - /** - * Rebuilds the bitmap index by walking the graph from a ref. - * - * @returns OID of the created tree containing the index - */ - async rebuild(ref: string, options: RebuildOptions = {}): Promise { - const { limit = 10_000_000, maxMemoryBytes, onFlush, onProgress, signal, frontier } = options; - if (maxMemoryBytes !== undefined && maxMemoryBytes <= 0) { - throw new IndexError( - 'maxMemoryBytes must be a positive number', - { code: 'E_INDEX_INVALID_MEMORY_LIMIT', context: { maxMemoryBytes } }, - ); - } - const mode = maxMemoryBytes !== undefined ? 'streaming' : 'in-memory'; - this.logger.info('Starting index rebuild', { - operation: 'rebuild', - ref, - limit, - mode, - maxMemoryBytes: maxMemoryBytes ?? null, - }); - - let treeOid: string; - if (maxMemoryBytes !== undefined) { - // Build the required fields first, then attach optional ones only when present - // to satisfy exactOptionalPropertyTypes. - treeOid = await this._rebuildStreaming(ref, this._buildStreamOpts( - limit, maxMemoryBytes, onFlush, onProgress, signal, frontier, - )); - } else { - const memOpts: InMemoryOptions = { limit }; - if (onProgress) { memOpts.onProgress = onProgress; } - if (signal) { memOpts.signal = signal; } - if (frontier) { memOpts.frontier = frontier; } - treeOid = await this._rebuildInMemory(ref, memOpts); - } - - this.logger.info('Index rebuild complete', { - operation: 'rebuild', - ref, - mode, - treeOid, - }); - - return treeOid; - } - - private _buildStreamOpts( - limit: number, - maxMemoryBytes: number, - onFlush: RebuildOptions['onFlush'], - onProgress: RebuildOptions['onProgress'], - signal: AbortSignal | undefined, - frontier: Map | undefined, - ): StreamingOptions { - const base: StreamingOptions = { limit, maxMemoryBytes }; - if (onFlush !== undefined) { base.onFlush = onFlush; } - if (onProgress !== undefined) { - base.onProgress = onProgress as (stats: { processedNodes: number; currentMemoryBytes: number }) => void; - } - if (signal !== undefined) { base.signal = signal; } - if (frontier !== undefined) { base.frontier = frontier; } - return base; - } - - private _requireStreamingStorage(): StreamingIndexStoragePort { - const {storage} = this; - if (!isStreamingStorage(storage)) { - throw new IndexError( - 'streaming rebuild requires a streaming index storage adapter', - { code: 'E_INDEX_STREAMING_STORAGE_REQUIRED' }, - ); - } - return storage; - } - - private async _rebuildInMemory(ref: string, options: InMemoryOptions): Promise { - const { limit, onProgress, signal, frontier } = options; - const builder = new BitmapIndexBuilder({ codec: this._codec }); - let processedNodes = 0; - - for await (const node of this.graphService.iterateNodes({ ref, limit })) { - builder.registerNode(node.sha); - for (const parentSha of node.parents) { - builder.addEdge(parentSha, node.sha); - } - - processedNodes++; - if (processedNodes % 10000 === 0) { - checkAborted(signal, 'rebuild'); - if (onProgress) { - onProgress({ processedNodes, currentMemoryBytes: null }); - } - } - } - - return await this._persistIndex(builder, frontier ? { frontier } : {}); - } - - private async _rebuildStreaming(ref: string, options: StreamingOptions): Promise { - const { limit, maxMemoryBytes, onFlush, onProgress, signal, frontier } = options; - const streamOpts: ConstructorParameters[0] = { - storage: this._requireStreamingStorage(), - maxMemoryBytes, - codec: this._codec, - }; - if (onFlush) { streamOpts.onFlush = onFlush; } - const builder = new StreamingBitmapIndexBuilder(streamOpts); - - let processedNodes = 0; - - for await (const node of this.graphService.iterateNodes({ ref, limit })) { - await builder.registerNode(node.sha); - for (const parentSha of node.parents) { - await builder.addEdge(parentSha, node.sha); - } - - processedNodes++; - if (processedNodes % 10000 === 0) { - checkAborted(signal, 'rebuild'); - if (onProgress) { - const stats = builder.getMemoryStats() as { estimatedBitmapBytes: number }; - onProgress({ - processedNodes, - currentMemoryBytes: stats.estimatedBitmapBytes, - }); - } - } - } - - const finalizeOpts: { signal?: AbortSignal; frontier?: Map } = {}; - if (signal) { finalizeOpts.signal = signal; } - if (frontier) { finalizeOpts.frontier = frontier; } - return await builder.finalize(finalizeOpts); - } - - private async _persistIndex(builder: BitmapIndexBuilder, options?: { frontier?: Map }): Promise { - const { frontier } = options ?? {}; - const treeStructure = builder.serialize(frontier ? { frontier } : {}); - const flatEntries: string[] = []; - for (const [path, buffer] of Object.entries(treeStructure)) { - const oid = await this.storage.writeBlob(buffer); - flatEntries.push(`100644 blob ${oid}\t${path}`); - } - return await this.storage.writeTree(flatEntries); - } - - /** - * Loads a previously built index from a tree OID. - * - * @returns Configured reader ready for O(1) queries. - */ - async load(treeOid: string, options: LoadOptions = {}): Promise { - const { strict = true, currentFrontier, autoRebuild = false, rebuildRef } = options; - this.logger.debug('Loading index', { - operation: 'load', - treeOid, - strict, - }); - - if (autoRebuild && (rebuildRef === undefined || rebuildRef.length === 0)) { - throw new IndexError( - 'rebuildRef is required when autoRebuild is true', - { code: 'E_INDEX_MISSING_REBUILD_REF' }, - ); - } - - const shardOids = await this.storage.readTreeOids(treeOid); - const shardCount = Object.keys(shardOids).length; - - // Staleness check - if (currentFrontier) { - const indexFrontier = await loadIndexFrontier( - shardOids, - this.storage, - { codec: this._codec }, - ); - if (indexFrontier) { - const result = checkStaleness(indexFrontier, currentFrontier); - if (result.stale) { - this.logger.warn('Index is stale', { - operation: 'load', - reason: result.reason, - hint: 'Rebuild the index or pass autoRebuild: true', - }); - if (autoRebuild && rebuildRef !== undefined && rebuildRef.length > 0) { - const newTreeOid = await this.rebuild(rebuildRef, { frontier: currentFrontier }); - return await this.load(newTreeOid, { strict }); - } - } - } else { - this.logger.debug('No frontier in index (legacy); skipping staleness check', { - operation: 'load', - }); - } - } - - const reader = new BitmapIndexReader({ - storage: this.storage, - strict, - logger: this.logger.child({ component: 'BitmapIndexReader' }), - codec: this._codec, - }); - reader.setup(shardOids); - - this.logger.debug('Index loaded', { - operation: 'load', - treeOid, - shardCount, - }); - - return reader; - } -} diff --git a/src/domain/services/index/IndexStalenessChecker.ts b/src/domain/services/index/IndexStalenessChecker.ts deleted file mode 100644 index e61f1a1c7..000000000 --- a/src/domain/services/index/IndexStalenessChecker.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * IndexStalenessChecker - Detects stale bitmap indexes by comparing - * frontier metadata stored at build time against current writer refs. - */ - -import IndexError from '../../errors/IndexError.ts'; -import { requireCodec } from '../codec/CodecRequirement.ts'; -import type BlobPort from '../../../ports/BlobPort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; -import type IndexStoragePort from '../../../ports/IndexStoragePort.ts'; -import type IndexStorePort from '../../../ports/IndexStorePort.ts'; - -function isNonNullObject(value: unknown): value is Record { // nosemgrep: ts-no-record-string-unknown-outside-adapters -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B - return value !== null && value !== undefined && typeof value === 'object'; -} - -function isFrontierEnvelope(envelope: unknown): envelope is { frontier: Record } { - if (!isNonNullObject(envelope)) { - return false; - } - return 'frontier' in envelope && isNonNullObject(envelope['frontier']); -} - -function validateEnvelope(envelope: unknown, label: string): asserts envelope is { frontier: Record } { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (!isFrontierEnvelope(envelope)) { - throw new IndexError(`invalid frontier envelope for ${label}`, { code: 'E_INDEX_INVALID_FRONTIER' }); - } -} - -type CborDeps = { - storage: BlobPort; - codec: CodecPort; - indexStore?: IndexStorePort; -}; - -/** - * Loads the frontier from an index tree's shard OIDs. - * - * @returns Frontier map, or null if not present (legacy index) - */ -export async function loadIndexFrontier( - shardOids: Record, - storage: IndexStoragePort & BlobPort, - options?: { codec?: CodecPort; indexStore?: IndexStorePort }, -): Promise | null> { - const { codec, indexStore } = options ?? {}; - const deps = buildCborDeps(storage, codec, indexStore); - return await loadCborFrontier(shardOids, deps) - ?? await loadJsonFrontier(shardOids, storage) - ?? null; -} - -function buildCborDeps( - storage: BlobPort, - codec?: CodecPort, - indexStore?: IndexStorePort, -): CborDeps { - const deps: CborDeps = { storage, codec: requireCodec(codec, 'loadIndexFrontier') }; - if (indexStore) { - deps.indexStore = indexStore; - } - return deps; -} - -async function loadCborFrontier( - shardOids: Record, - { storage, codec, indexStore }: CborDeps, -): Promise | null> { - const oid = shardOids['frontier.cbor']; - if (typeof oid !== 'string' || oid.length === 0) { - return null; - } - let envelope: unknown; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B - if (indexStore) { - envelope = await indexStore.decodeShard(oid); - } else { - const buffer = await storage.readBlob(oid); - envelope = codec.decode(buffer); - } - validateEnvelope(envelope, 'frontier.cbor'); - return new Map(Object.entries(envelope.frontier)); -} - -async function loadJsonFrontier( - shardOids: Record, - storage: BlobPort, -): Promise | null> { - const oid = shardOids['frontier.json']; - if (typeof oid !== 'string' || oid.length === 0) { - return null; - } - const buffer = await storage.readBlob(oid); - const text = new TextDecoder().decode(buffer); - const parsed: unknown = JSON.parse(text); // nosemgrep: ts-no-json-parse-in-core -- 0025B; nosemgrep: ts-no-unknown-outside-adapters -- 0025B - validateEnvelope(parsed, 'frontier.json'); - return new Map(Object.entries(parsed.frontier)); -} - -export interface StalenessResult { - stale: boolean; - reason: string; - advancedWriters: string[]; - newWriters: string[]; - removedWriters: string[]; -} - -function buildReason(opts: { - stale: boolean; - advancedWriters: string[]; - newWriters: string[]; - removedWriters: string[]; -}): string { - const { stale, advancedWriters, newWriters, removedWriters } = opts; - if (!stale) { - return 'index is current'; - } - const parts: string[] = []; - if (advancedWriters.length > 0) { - parts.push(`${advancedWriters.length} writer(s) advanced`); - } - if (newWriters.length > 0) { - parts.push(`${newWriters.length} new writer(s)`); - } - if (removedWriters.length > 0) { - parts.push(`${removedWriters.length} writer(s) removed`); - } - return parts.join(', '); -} - -/** - * Compares index frontier against current frontier to detect staleness. - */ -export function checkStaleness( - indexFrontier: Map, - currentFrontier: Map, -): StalenessResult { - const advancedWriters = findAdvancedWriters(indexFrontier, currentFrontier); - const newWriters = findNewWriters(indexFrontier, currentFrontier); - const removedWriters = findRemovedWriters(indexFrontier, currentFrontier); - - const stale = advancedWriters.length > 0 || newWriters.length > 0 || removedWriters.length > 0; - const reason = buildReason({ stale, advancedWriters, newWriters, removedWriters }); - - return { stale, reason, advancedWriters, newWriters, removedWriters }; -} - -function findAdvancedWriters( - indexFrontier: Map, - currentFrontier: Map, -): string[] { - const result: string[] = []; - for (const [writerId, tipSha] of currentFrontier) { - const indexTip = indexFrontier.get(writerId); - if (indexTip !== undefined && indexTip !== tipSha) { - result.push(writerId); - } - } - return result; -} - -function findNewWriters( - indexFrontier: Map, - currentFrontier: Map, -): string[] { - const result: string[] = []; - for (const writerId of currentFrontier.keys()) { - if (!indexFrontier.has(writerId)) { - result.push(writerId); - } - } - return result; -} - -function findRemovedWriters( - indexFrontier: Map, - currentFrontier: Map, -): string[] { - const result: string[] = []; - for (const writerId of indexFrontier.keys()) { - if (!currentFrontier.has(writerId)) { - result.push(writerId); - } - } - return result; -} diff --git a/src/domain/services/index/StreamingBitmapIndexBuilder.ts b/src/domain/services/index/StreamingBitmapIndexBuilder.ts deleted file mode 100644 index 57de74ae9..000000000 --- a/src/domain/services/index/StreamingBitmapIndexBuilder.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * Streaming bitmap index builder with memory-bounded operation. - * - * Flushes bitmap data to storage when memory exceeds a threshold, - * then merges multi-chunk shards at finalization. Delegates bitmap - * accumulation to BitmapAccumulator. - * - * Shard format: plain CBOR via CodecPort. No envelopes, no checksums — - * git-cas handles integrity at the storage layer. - * - * @module domain/services/index/StreamingBitmapIndexBuilder - */ - -import type CodecPort from '../../../ports/CodecPort.ts'; -import type LoggerPort from '../../../ports/LoggerPort.ts'; -import type StreamingIndexStoragePort from '../../../ports/StreamingIndexStoragePort.ts'; -import nullLogger from '../../utils/nullLogger.ts'; -import { checkAborted } from '../../utils/cancellation.ts'; -import { canonicalStringify } from '../../utils/canonicalStringify.ts'; -import { textEncode } from '../../utils/bytes.ts'; -import { normalizeToAsyncIterable } from '../../utils/streamUtils.ts'; -import IndexError from '../../errors/IndexError.ts'; -import { requireCodec } from '../codec/CodecRequirement.ts'; -import BitmapAccumulator from './BitmapAccumulator.ts'; - -/** Default memory threshold before flushing (50 MB). */ -const DEFAULT_MAX_MEMORY_BYTES = 50 * 1024 * 1024; - -export type FlushStats = { - flushedBytes: number; - totalFlushedBytes: number; - flushCount: number; -}; - -export type BuilderOptions = { - storage: StreamingIndexStoragePort; - maxMemoryBytes?: number; - onFlush?: (stats: FlushStats) => void; - logger?: LoggerPort; - codec?: CodecPort; -}; - -export default class StreamingBitmapIndexBuilder { - private readonly _storage: StreamingIndexStoragePort; - private readonly _codec: CodecPort | null; - private readonly _logger: LoggerPort; - private readonly _maxMemoryBytes: number; - private readonly _onFlush: ((stats: FlushStats) => void) | undefined; - private readonly _accumulator: BitmapAccumulator; - private readonly _flushedChunks: Map = new Map(); - private _totalFlushedBytes: number = 0; - private _flushCount: number = 0; - - constructor(options: BuilderOptions) { - const { storage, maxMemoryBytes } = StreamingBitmapIndexBuilder._validate(options); - this._storage = storage; - this._maxMemoryBytes = maxMemoryBytes; - this._codec = options.codec ?? null; - this._logger = options.logger ?? nullLogger; - this._onFlush = options.onFlush; - this._accumulator = new BitmapAccumulator(); - } - - private static _validate(options: BuilderOptions): { storage: StreamingIndexStoragePort; maxMemoryBytes: number } { - StreamingBitmapIndexBuilder._assertStorage(options.storage); - const maxMem = options.maxMemoryBytes ?? DEFAULT_MAX_MEMORY_BYTES; - StreamingBitmapIndexBuilder._assertMaxMemory(maxMem); - return { storage: options.storage, maxMemoryBytes: maxMem }; - } - - private static _assertStorage( - storage: StreamingIndexStoragePort | null | undefined, - ): asserts storage is StreamingIndexStoragePort { - if ( - storage === null - || storage === undefined - || typeof storage.writeBlobStream !== 'function' - || typeof storage.readBlobStream !== 'function' - ) { - throw new IndexError( - 'StreamingBitmapIndexBuilder requires a streaming storage adapter', - { code: 'E_INDEX_INVALID_OPTIONS', context: { field: 'storage' } }, - ); - } - } - - private static _assertMaxMemory(maxMem: number): void { - if (typeof maxMem !== 'number' || maxMem <= 0) { - throw new IndexError( - 'maxMemoryBytes must be a positive number', - { code: 'E_INDEX_INVALID_OPTIONS', context: { field: 'maxMemoryBytes', value: maxMem } }, - ); - } - } - - /** Registers a node and returns its numeric ID. */ - registerNode(sha: string): Promise { - return Promise.resolve(this._accumulator.registerNode(sha)); - } - - /** Adds a directed edge. May trigger flush if memory threshold exceeded. */ - async addEdge(srcSha: string, tgtSha: string): Promise { - this._accumulator.addEdge(srcSha, tgtSha); - if (this._accumulator.estimatedBitmapBytes >= this._maxMemoryBytes) { - await this.flush(); - } - } - - /** Flushes in-memory bitmaps to storage as CBOR shard blobs. */ - async flush(): Promise { - if (this._accumulator.bitmapCount === 0) { - return; - } - - const flushedBytes = this._accumulator.estimatedBitmapBytes; - const shards = this._accumulator.serializeBitmapsToShards(); - await this._writeShardsToStorage(shards); - - this._accumulator.clearBitmaps(); - this._totalFlushedBytes += flushedBytes; - this._flushCount++; - - this._logger.debug('Flushed bitmap data', { - operation: 'flush', - flushedBytes, - totalFlushedBytes: this._totalFlushedBytes, - flushCount: this._flushCount, - }); - - if (this._onFlush) { - this._onFlush({ - flushedBytes, - totalFlushedBytes: this._totalFlushedBytes, - flushCount: this._flushCount, - }); - } - } - - /** Returns current memory statistics for monitoring. */ - getMemoryStats(): { - estimatedBitmapBytes: number; - estimatedMappingBytes: number; - totalFlushedBytes: number; - flushCount: number; - nodeCount: number; - bitmapCount: number; - } { - return { - estimatedBitmapBytes: this._accumulator.estimatedBitmapBytes, - estimatedMappingBytes: this._accumulator.estimatedMappingBytes, - totalFlushedBytes: this._totalFlushedBytes, - flushCount: this._flushCount, - nodeCount: this._accumulator.nodeCount, - bitmapCount: this._accumulator.bitmapCount, - }; - } - - /** - * Finalizes the index: flush remaining data, merge chunks, write tree. - * Returns the OID of the Git tree containing the complete index. - */ - async finalize(options?: { - signal?: AbortSignal; - frontier?: Map; - }): Promise { - const { signal, frontier } = options ?? {}; - this._logger.debug('Finalizing index', { - operation: 'finalize', - nodeCount: this._accumulator.nodeCount, - totalFlushedBytes: this._totalFlushedBytes, - flushCount: this._flushCount, - }); - const flatEntries = await this._buildAllEntries(signal); - if (frontier) { - await this._writeFrontierEntries(frontier, flatEntries); - } - return await this._writeAndLogTree(flatEntries); - } - - private async _buildAllEntries(signal?: AbortSignal): Promise { - checkAborted(signal, 'finalize'); - await this.flush(); - checkAborted(signal, 'finalize'); - const metaEntries = await this._writeMetaShards(); - checkAborted(signal, 'finalize'); - const bitmapEntries = this._processBitmapShards(signal); - return [...metaEntries, ...bitmapEntries]; - } - - private async _writeAndLogTree(flatEntries: string[]): Promise { - const treeOid = await this._storage.writeTree(flatEntries); - this._logger.debug('Index finalized', { - operation: 'finalize', - treeOid, - shardCount: flatEntries.length, - nodeCount: this._accumulator.nodeCount, - }); - return treeOid; - } - - private async _writeShardsToStorage(shards: { - fwd: Record>; - rev: Record>; - }): Promise { - const codec = requireCodec(this._codec, 'StreamingBitmapIndexBuilder'); - const tasks: Promise[] = []; - for (const dir of ['fwd', 'rev'] as const) { - for (const [prefix, data] of Object.entries(shards[dir])) { - const path = `shards_${dir}_${prefix}.cbor`; - tasks.push( - (async (): Promise => { - const encoded = codec.encode(data); - const oid = await this._storage.writeBlobStream( - normalizeToAsyncIterable(encoded), - { slug: path, mime: 'application/cbor', size: encoded.length }, - ); - if (!this._flushedChunks.has(path)) { - this._flushedChunks.set(path, []); - } - this._flushedChunks.get(path)!.push(oid); - })(), - ); - } - } - await Promise.all(tasks); - } - - private async _writeMetaShards(): Promise { - const codec = requireCodec(this._codec, 'StreamingBitmapIndexBuilder'); - const entries: string[] = []; - const chunkOrdinal = new Map(); - const chunkLimit = Math.max(1, Math.floor(this._maxMemoryBytes / 256)); - for (const { prefix, entries: shardEntries } of this._accumulator.iterateMetaShardChunks(chunkLimit)) { - const chunkIndex = chunkOrdinal.get(prefix) ?? 0; - const encoded = codec.encode(Object.fromEntries(shardEntries)); - const chunkPath = this._chunkPath(`meta_${prefix}.cbor`, chunkIndex); - const oid = await this._storage.writeBlobStream( - normalizeToAsyncIterable(encoded), - { slug: chunkPath, mime: 'application/cbor', size: encoded.length }, - ); - entries.push(`100644 blob ${oid}\t${chunkPath}`); - chunkOrdinal.set(prefix, chunkIndex + 1); - } - return entries; - } - - private _processBitmapShards( - signal?: AbortSignal, - ): string[] { - const entries: string[] = []; - for (const [path, oids] of this._flushedChunks.entries()) { - checkAborted(signal, 'processBitmapShards'); - for (const [index, oid] of oids.entries()) { - entries.push(`100644 blob ${oid}\t${this._chunkPath(path, index)}`); - } - } - return entries; - } - - private _chunkPath(path: string, index: number): string { - const base = path.endsWith('.cbor') ? path.slice(0, -5) : path; - return `${base}.chunk-${String(index).padStart(6, '0')}.cbor`; - } - - private async _writeFrontierEntries( - frontier: Map, - entries: string[], - ): Promise { - const sorted: Record = {}; - for (const key of Array.from(frontier.keys()).sort()) { - sorted[key] = frontier.get(key); - } - const envelope = { version: 1, writerCount: frontier.size, frontier: sorted }; - const codec = requireCodec(this._codec, 'StreamingBitmapIndexBuilder'); - const cborOid = await this._storage.writeBlob(codec.encode(envelope)); - entries.push(`100644 blob ${cborOid}\tfrontier.cbor`); - const jsonOid = await this._storage.writeBlob(textEncode(canonicalStringify(envelope))); - entries.push(`100644 blob ${jsonOid}\tfrontier.json`); - } -} diff --git a/src/domain/services/materialize/CasFirstMemoizationEngine.ts b/src/domain/services/materialize/CasFirstMemoizationEngine.ts deleted file mode 100644 index ac0041525..000000000 --- a/src/domain/services/materialize/CasFirstMemoizationEngine.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type BlobStoragePort from '../../../ports/BlobStoragePort.ts'; -import type CryptoPort from '../../../ports/CryptoPort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; -import WarpError from '../../errors/WarpError.ts'; - -export interface CasFirstMemoizationDeps { - blobStorage: BlobStoragePort; - crypto: CryptoPort; - codec: CodecPort; -} - -export interface MaterializationRequest { - /** Deterministic coordinate/seed parameters defining the materialization */ - coordinateKeyParams: string; - /** Lazy materialization streaming callback if not found in git-cas */ - materializeStream: () => AsyncIterable; - /** Decode the retrieved/streamed buffer into the final domain object */ - decodeObject: (_buffer: Uint8Array) => Promise | T; -} - -export interface CasMaterializationResult { - object: T; - casTreeOid: string; - hit: boolean; -} - -export default class CasFirstMemoizationEngine { - private readonly _blobStorage: BlobStoragePort; - private readonly _crypto: CryptoPort; - private readonly _codec: CodecPort; - - constructor(deps: CasFirstMemoizationDeps) { - if (deps.blobStorage === null || deps.blobStorage === undefined) { - throw new WarpError('blobStorage is required for CAS-first memoization', 'E_INVALID_ARG'); - } - this._blobStorage = deps.blobStorage; - this._crypto = deps.crypto; - this._codec = deps.codec; - } - - /** - * Executes the CAS-First Memoization lifecycle: - * 2.1. Is object already in git-cas? - * 2.2. No? Materialize via streaming - * 2.3. Write materialized git-object to git-cas always - */ - async materialize(request: MaterializationRequest): Promise> { - void this._codec; - const key = await this._crypto.hash('sha256', request.coordinateKeyParams); - - const hitResult = await this._tryCasHit(key, request); - if (hitResult !== null) { - return hitResult; - } - - return await this._materializeAndStore(key, request); - } - - private async _tryCasHit( - key: string, - request: MaterializationRequest, - ): Promise | null> { - if (typeof this._blobStorage.has === 'function') { - const exists = await this._blobStorage.has(key); - if (exists) { - const buffer = await this._blobStorage.retrieve(key); - const object = await request.decodeObject(buffer); - return { object, casTreeOid: key, hit: true }; - } - return null; - } - try { - const buffer = await this._blobStorage.retrieve(key); - const object = await request.decodeObject(buffer); - return { object, casTreeOid: key, hit: true }; - } catch { - return null; - } - } - - private async _materializeAndStore( - key: string, - request: MaterializationRequest, - ): Promise> { - const rawStream = request.materializeStream(); - const chunks: Uint8Array[] = []; - - const teeStream = (async function* () { - for await (const chunk of rawStream) { - chunks.push(chunk); - yield chunk; - } - })(); - - const casTreeOid = await this._blobStorage.storeStream(teeStream, { slug: key }); - - const totalLength = chunks.reduce((acc, c) => acc + c.byteLength, 0); - const fullBuffer = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - fullBuffer.set(chunk, offset); - offset += chunk.byteLength; - } - - const object = await request.decodeObject(fullBuffer); - return { object, casTreeOid, hit: false }; - } -} diff --git a/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts b/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts deleted file mode 100644 index cf6d16042..000000000 --- a/src/domain/services/optic/StreamingCheckpointBasisBuilder.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type BlobPort from '../../../ports/BlobPort.ts'; -import type CodecPort from '../../../ports/CodecPort.ts'; -import type TreePort from '../../../ports/TreePort.ts'; -import QueryError from '../../errors/QueryError.ts'; -import type MemoryBudgetLease from '../../memory/MemoryBudgetLease.ts'; -import WarpMemoryPool from '../../memory/WarpMemoryPool.ts'; -import { requireCodec } from '../codec/CodecRequirement.ts'; -import { CheckpointBasisFact, type CheckpointBasisFactShardFamily } from './CheckpointBasisFact.ts'; -import CheckpointBasisManifest, { - CheckpointBasisChunking, - CheckpointBasisCompleteness, - type CheckpointBasisRootFamily, - CheckpointBasisShardGeometry, - CheckpointBasisShardRootMap, - CheckpointBasisSupportPosture, -} from './CheckpointBasisManifest.ts'; - -export type CheckpointBasisPublicationStorage = - Pick & Pick; - -export type StreamingCheckpointBasisBuilderOptions = { - readonly graphName: string; - readonly checkpointSha: string; - readonly frontier: Map; - readonly storage: CheckpointBasisPublicationStorage; - readonly codec?: CodecPort; - readonly pool?: WarpMemoryPool; - readonly maxFactsPerShard: number; - readonly layoutFamily?: string; - readonly payloadLayout?: string; - readonly shardKeyStrategy?: string; -}; - -type CheckpointBasisRootStore = { - readonly family: CheckpointBasisFactShardFamily; - readonly roots: Map; -}; - -type CheckpointBasisPublicationRuntime = { - readonly storage: CheckpointBasisPublicationStorage; - readonly codec: CodecPort; - readonly pool: WarpMemoryPool | null; -}; - -type CheckpointBasisPublicationView = { - readonly flushCount: number; - readonly shardWriteCount: number; - rootMap(family: 'node-liveness'): CheckpointBasisShardRootMap; - rootMap(family: 'node-property'): CheckpointBasisShardRootMap; - rootMap(family: 'outgoing-adjacency'): CheckpointBasisShardRootMap; - rootMap(family: 'incoming-adjacency'): CheckpointBasisShardRootMap; - rootMap(family: 'edge-fact'): CheckpointBasisShardRootMap; - hasFamily(family: 'provenance' | 'content-anchor'): boolean; -}; - -const CHECKPOINT_BASIS_PENDING_FACT_SCOPE = 'checkpoint-basis-pending-fact'; - -export class StreamingCheckpointBasisBuildResult { - readonly manifest: CheckpointBasisManifest; - readonly rootTreeOid: string; - readonly flushCount: number; - readonly shardWriteCount: number; - readonly treeEntries: readonly string[]; - - constructor(options: { - readonly manifest: CheckpointBasisManifest; - readonly rootTreeOid: string; - readonly flushCount: number; - readonly shardWriteCount: number; - readonly treeEntries: readonly string[]; - }) { - this.manifest = options.manifest; - this.rootTreeOid = options.rootTreeOid; - this.flushCount = options.flushCount; - this.shardWriteCount = options.shardWriteCount; - this.treeEntries = Object.freeze([...options.treeEntries]); - Object.freeze(this); - } -} - -export default class StreamingCheckpointBasisBuilder { - private readonly _graphName: string; - private readonly _checkpointSha: string; - private readonly _frontier: Map; - private readonly _storage: CheckpointBasisPublicationStorage; - private readonly _codec: CodecPort; - private readonly _pool: WarpMemoryPool | null; - private readonly _maxFactsPerShard: number; - private readonly _layoutFamily: string; - private readonly _payloadLayout: string; - private readonly _shardKeyStrategy: string; - - constructor(options: StreamingCheckpointBasisBuilderOptions) { - const validOptions = requireBuilderOptions(options); - validateText(validOptions.graphName, 'graphName'); - validateText(validOptions.checkpointSha, 'checkpointSha'); - validateFrontier(validOptions.frontier); - validateStorage(validOptions.storage); - this._graphName = validOptions.graphName; - this._checkpointSha = validOptions.checkpointSha; - this._frontier = copyFrontier(validOptions.frontier); - this._storage = validOptions.storage; - this._codec = requireCodec(validOptions.codec, 'StreamingCheckpointBasisBuilder'); - this._pool = poolOrNull(validOptions.pool); - this._maxFactsPerShard = validatePositiveInteger(validOptions.maxFactsPerShard, 'maxFactsPerShard'); - this._layoutFamily = validOptions.layoutFamily ?? 'checkpoint-basis-shards'; - this._payloadLayout = validOptions.payloadLayout ?? 'basis-facts-v1'; - this._shardKeyStrategy = validOptions.shardKeyStrategy ?? 'hex-prefix-2'; - Object.freeze(this); - } - - async build(facts: AsyncIterable): Promise { - validateFactStream(facts); - const publication = createCheckpointBasisPublication(this._maxFactsPerShard); - const runtime = publicationRuntime(this._storage, this._codec, this._pool); - try { - for await (const fact of facts) { - await publication.addFact(fact, runtime); - } - await publication.flushAll(runtime); - const treeEntries = publication.treeEntries(); - const rootTreeOid = await this._storage.writeTree([...treeEntries]); - return new StreamingCheckpointBasisBuildResult({ - manifest: this._manifest(publication, rootTreeOid), - rootTreeOid, - flushCount: publication.flushCount, - shardWriteCount: publication.shardWriteCount, - treeEntries, - }); - } finally { - publication.releaseAllLeases(); - } - } - - private _manifest( - publication: CheckpointBasisPublicationView, - rootTreeOid: string, - ): CheckpointBasisManifest { - const shardCount = Math.max(1, publication.shardWriteCount); - const chunkCount = Math.max(1, publication.flushCount); - return new CheckpointBasisManifest({ - graphName: this._graphName, - checkpointSha: this._checkpointSha, - frontier: this._frontier, - appliedVersionVector: appliedVersionVectorFromFrontier(this._frontier), - basisIdentity: `basis:${this._graphName}:${this._checkpointSha}:${rootTreeOid}`, - semanticReadingIdentity: `reading-basis:${this._graphName}:${this._checkpointSha}:streamed-facts-v1`, - livenessRoots: publication.rootMap('node-liveness'), - propertyRoots: publication.rootMap('node-property'), - outgoingAdjacencyRoots: publication.rootMap('outgoing-adjacency'), - incomingAdjacencyRoots: publication.rootMap('incoming-adjacency'), - edgeFactRoots: publication.rootMap('edge-fact'), - ...publicationPostures(publication, rootTreeOid), - shardGeometry: this._shardGeometry(shardCount), - chunking: new CheckpointBasisChunking({ maxFactsPerShard: this._maxFactsPerShard, chunkCount }), - completeness: publication.shardWriteCount > 0 - ? CheckpointBasisCompleteness.complete() - : CheckpointBasisCompleteness.partial('empty-fact-stream'), - }); - } - - private _shardGeometry(shardCount: number): CheckpointBasisShardGeometry { - return new CheckpointBasisShardGeometry({ - layoutFamily: this._layoutFamily, - payloadLayout: this._payloadLayout, - shardKeyStrategy: this._shardKeyStrategy, - shardCount, - }); - } -} - -class CheckpointBasisPendingShard { - readonly family: CheckpointBasisFactShardFamily; - readonly basePath: string; - readonly pendingKey: string; - private readonly _facts: CheckpointBasisFact[]; - private readonly _leases: MemoryBudgetLease[]; - - constructor(options: { - readonly family: CheckpointBasisFactShardFamily; - readonly basePath: string; - readonly pendingKey: string; - }) { - this.family = options.family; - this.basePath = options.basePath; - this.pendingKey = options.pendingKey; - this._facts = []; - this._leases = []; - } - - get size(): number { - return this._facts.length; - } - - add(fact: CheckpointBasisFact, pool: WarpMemoryPool | null): void { - const lease = pool?.acquire({ scope: CHECKPOINT_BASIS_PENDING_FACT_SCOPE, amount: 1 }) ?? null; - this._facts.push(fact); - if (lease !== null) { - this._leases.push(lease); - } - } - - takeSortedFacts(): readonly CheckpointBasisFact[] { - const facts = [...this._facts].sort((left, right) => compareText(left.sortKey(), right.sortKey())); - this._facts.length = 0; - return Object.freeze(facts); - } - - releaseLeases(): void { - for (const lease of this._leases) { - lease.release(); - } - this._leases.length = 0; - } -} - -class CheckpointBasisPublication { - private readonly _maxFactsPerShard: number; - private readonly _pending: Map; - private readonly _chunkIndexes: Map; - private readonly _treeEntries: string[]; - private readonly _livenessRoots: Map; - private readonly _propertyRoots: Map; - private readonly _outgoingAdjacencyRoots: Map; - private readonly _incomingAdjacencyRoots: Map; - private readonly _edgeFactRoots: Map; - private readonly _provenanceRoots: Map; - private readonly _contentAnchorRoots: Map; - flushCount: number; - shardWriteCount: number; - - constructor(options: { readonly maxFactsPerShard: number }) { - this._maxFactsPerShard = options.maxFactsPerShard; - this._pending = new Map(); - this._chunkIndexes = new Map(); - this._treeEntries = []; - this._livenessRoots = new Map(); - this._propertyRoots = new Map(); - this._outgoingAdjacencyRoots = new Map(); - this._incomingAdjacencyRoots = new Map(); - this._edgeFactRoots = new Map(); - this._provenanceRoots = new Map(); - this._contentAnchorRoots = new Map(); - this.flushCount = 0; - this.shardWriteCount = 0; - } - - async addFact( - fact: CheckpointBasisFact, - runtime: CheckpointBasisPublicationRuntime, - ): Promise { - validateFact(fact); - const pendingShard = this._pendingShard(fact); - pendingShard.add(fact, runtime.pool); - if (pendingShard.size >= this._maxFactsPerShard) { - await this._flush(pendingShard, runtime); - } - } - - async flushAll(runtime: CheckpointBasisPublicationRuntime): Promise { - for (const pendingShard of this._pending.values()) { - if (pendingShard.size > 0) { - await this._flush(pendingShard, runtime); - } - } - } - - releaseAllLeases(): void { - for (const pendingShard of this._pending.values()) { - pendingShard.releaseLeases(); - } - } - - rootMap(family: 'node-liveness'): CheckpointBasisShardRootMap; - rootMap(family: 'node-property'): CheckpointBasisShardRootMap; - rootMap(family: 'outgoing-adjacency'): CheckpointBasisShardRootMap; - rootMap(family: 'incoming-adjacency'): CheckpointBasisShardRootMap; - rootMap(family: 'edge-fact'): CheckpointBasisShardRootMap; - rootMap(family: CheckpointBasisFactShardFamily): CheckpointBasisShardRootMap { - return new CheckpointBasisShardRootMap({ - family: manifestRootFamily(family), - roots: this._rootsForFamily(family), - }); - } - - hasFamily(family: 'provenance' | 'content-anchor'): boolean { - return this._rootsForFamily(family).size > 0; - } - - treeEntries(): readonly string[] { - return Object.freeze([...this._treeEntries].sort(compareTreeEntries)); - } - - private _pendingShard(fact: CheckpointBasisFact): CheckpointBasisPendingShard { - const family = fact.shardFamily(); - const basePath = fact.shardPath(); - const pendingKey = `${family}/${basePath}`; - const existing = this._pending.get(pendingKey); - if (existing !== undefined) { - return existing; - } - const created = new CheckpointBasisPendingShard({ family, basePath, pendingKey }); - this._pending.set(pendingKey, created); - return created; - } - - private async _flush( - pendingShard: CheckpointBasisPendingShard, - runtime: CheckpointBasisPublicationRuntime, - ): Promise { - const facts = pendingShard.takeSortedFacts(); - if (facts.length === 0) { - return; - } - const nextChunkIndex = this._nextChunkIndex(pendingShard.pendingKey); - const chunkPath = chunkedPath(pendingShard.family, pendingShard.basePath, nextChunkIndex); - try { - const oid = await runtime.storage.writeBlob(runtime.codec.encode(facts.map((fact) => fact.toTransport()))); - this._rootsForFamily(pendingShard.family).set(chunkPath, oid); - this._treeEntries.push(`100644 blob ${oid}\t${chunkPath}`); - this.flushCount += 1; - this.shardWriteCount += 1; - } finally { - pendingShard.releaseLeases(); - } - } - - private _nextChunkIndex(pendingKey: string): number { - const current = this._chunkIndexes.get(pendingKey) ?? 0; - const next = current + 1; - this._chunkIndexes.set(pendingKey, next); - return next; - } - - private _rootsForFamily(family: CheckpointBasisFactShardFamily): Map { - const store = this._rootStores().find((candidate) => candidate.family === family); - if (store === undefined) { - throwBuilderError('family', 'unsupported-root-family'); - } - return store.roots; - } - - private _rootStores(): readonly CheckpointBasisRootStore[] { - return Object.freeze([ - { family: 'node-liveness', roots: this._livenessRoots }, - { family: 'node-property', roots: this._propertyRoots }, - { family: 'outgoing-adjacency', roots: this._outgoingAdjacencyRoots }, - { family: 'incoming-adjacency', roots: this._incomingAdjacencyRoots }, - { family: 'edge-fact', roots: this._edgeFactRoots }, - { family: 'provenance', roots: this._provenanceRoots }, - { family: 'content-anchor', roots: this._contentAnchorRoots }, - ]); - } -} - -function createCheckpointBasisPublication(maxFactsPerShard: number): CheckpointBasisPublication { return new CheckpointBasisPublication({ maxFactsPerShard }); } - -function publicationRuntime( - storage: CheckpointBasisPublicationStorage, - codec: CodecPort, - pool: WarpMemoryPool | null, -): CheckpointBasisPublicationRuntime { - return Object.freeze({ storage, codec, pool }); -} - -function poolOrNull(pool: WarpMemoryPool | undefined): WarpMemoryPool | null { - if (pool === undefined || pool instanceof WarpMemoryPool) { - return pool ?? null; - } - return throwBuilderError('pool', 'invalid-pool'); -} - -function requireBuilderOptions( - options: StreamingCheckpointBasisBuilderOptions | null | undefined, -): StreamingCheckpointBasisBuilderOptions { - if (options === null || typeof options !== 'object') { - throwBuilderError('options', 'invalid-options'); - } - return options; -} - -function publicationPostures( - publication: CheckpointBasisPublicationView, - rootTreeOid: string, -): { - readonly provenancePosture: CheckpointBasisSupportPosture; - readonly contentAnchorPosture: CheckpointBasisSupportPosture; -} { - return { - provenancePosture: publication.hasFamily('provenance') - ? CheckpointBasisSupportPosture.present(`tree:${rootTreeOid}:provenance`) - : CheckpointBasisSupportPosture.unavailable('no-provenance-facts'), - contentAnchorPosture: publication.hasFamily('content-anchor') - ? CheckpointBasisSupportPosture.present(`tree:${rootTreeOid}:content-anchor`) - : CheckpointBasisSupportPosture.unavailable('no-content-anchor-facts'), - }; -} - -function chunkedPath( - family: CheckpointBasisFactShardFamily, - basePath: string, - chunkIndex: number, -): string { - return `${family}/${basePath}.chunk-${String(chunkIndex).padStart(6, '0')}`; -} - -const MANIFEST_ROOT_FAMILIES: readonly CheckpointBasisRootFamily[] = Object.freeze([ - 'node-liveness', - 'node-property', - 'outgoing-adjacency', - 'incoming-adjacency', - 'edge-fact', -]); - -function manifestRootFamily(family: CheckpointBasisFactShardFamily): CheckpointBasisRootFamily { - if (isManifestRootFamily(family)) { - return family; - } - return throwBuilderError('family', 'not-a-manifest-root-family'); -} - -function isManifestRootFamily(family: CheckpointBasisFactShardFamily): family is CheckpointBasisRootFamily { - return MANIFEST_ROOT_FAMILIES.includes(family as CheckpointBasisRootFamily); -} -function appliedVersionVectorFromFrontier(frontier: Map): Map { - const versionVector = new Map(); - for (const writerId of [...frontier.keys()].sort()) { - versionVector.set(writerId, 0); - } - return versionVector; -} - -function copyFrontier(frontier: Map): Map { - return new Map([...frontier.entries()].sort(([left], [right]) => compareText(left, right))); -} - -function compareTreeEntries(left: string, right: string): number { - const leftPath = left.slice(left.indexOf('\t') + 1); - const rightPath = right.slice(right.indexOf('\t') + 1); - return compareText(leftPath, rightPath); -} - -function compareText(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -function validateFactStream(facts: AsyncIterable): void { - if (facts === null || facts === undefined || typeof facts[Symbol.asyncIterator] !== 'function') { - throwBuilderError('facts', 'invalid-async-fact-stream'); - } -} - -function validateFact(fact: CheckpointBasisFact): void { - if (!(fact instanceof CheckpointBasisFact)) { - throwBuilderError('fact', 'invalid-fact'); - } -} - -function validateStorage(storage: CheckpointBasisPublicationStorage): void { - if ( - storage === null - || storage === undefined - || typeof storage.writeBlob !== 'function' - || typeof storage.writeTree !== 'function' - ) { - throwBuilderError('storage', 'invalid-storage'); - } -} - -function validateFrontier(frontier: Map): void { - if (!(frontier instanceof Map) || frontier.size === 0) { - throwBuilderError('frontier', 'invalid-frontier'); - } - for (const [writerId, patchSha] of frontier) { - validateText(writerId, 'frontier.writerId'); - validateText(patchSha, 'frontier.patchSha'); - } -} - -function validateText(value: string, field: string): string { - if (typeof value !== 'string' || value.length === 0) { - throwBuilderError(field, 'empty-string'); - } - return value; -} - -function validatePositiveInteger(value: number, field: string): number { - if (!Number.isInteger(value) || value <= 0) { - throwBuilderError(field, 'invalid-positive-integer'); - } - return value; -} - -function throwBuilderError(field: string, reason: string): never { - throw new QueryError('Streaming checkpoint basis builder input is invalid.', { - code: 'E_STREAMING_CHECKPOINT_BASIS_BUILDER', - context: { field, reason }, - }); -} diff --git a/src/domain/utils/CachedValue.ts b/src/domain/utils/CachedValue.ts deleted file mode 100644 index cedd9cafc..000000000 --- a/src/domain/utils/CachedValue.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * A tick-based caching utility for a single value. - * - * Caches the result of an expensive operation and reuses it until - * the configured tick threshold is exceeded. Ticks are monotonic - * counters (typically lamport ticks) passed by the caller — the - * cache never reaches for wall-clock time. - * - * @example - * const cache = new CachedValue({ - * ttlTicks: 100, - * compute: async () => await expensiveOperation(), - * }); - * - * // First call computes the value - * const value1 = await cache.get(currentLamport); - * - * // Subsequent calls within tick threshold return cached value - * const value2 = await cache.get(currentLamport + 50); // Same as value1 - * - * // Force recompute - * cache.invalidate(); - * const value3 = await cache.get(currentLamport + 200); // Fresh value - */ - -import WarpError from '../errors/WarpError.ts'; - -interface CachedValueOptions { - readonly ttlTicks: number; - readonly compute: () => T | Promise; -} - -class CachedValue { - private readonly _ttlTicks: number; - private readonly _compute: () => T | Promise; - private _value: T | null; - private _inflight: Promise | null; - private _generation: number; - private _cachedAtTick: number; - - /** - * Creates a CachedValue instance. - * - * @throws {WarpError} If ttlTicks is not a positive number - */ - constructor({ ttlTicks, compute }: CachedValueOptions) { - if (typeof ttlTicks !== 'number' || ttlTicks <= 0) { - throw new WarpError('CachedValue ttlTicks must be a positive number', 'E_INVALID_ARG'); - } - if (typeof compute !== 'function') { - throw new WarpError('CachedValue compute must be a function', 'E_INVALID_ARG'); - } - this._ttlTicks = ttlTicks; - this._compute = compute; - this._value = null; - this._inflight = null; - this._generation = 0; - this._cachedAtTick = 0; - } - - /** Gets the cached value, computing it if stale or not present. */ - async get(currentTick: number): Promise { - if (this._isValid(currentTick)) { - return this._value!; - } - if (this._inflight) { - return await this._inflight; - } - this._inflight = this._computeAndCache(this._generation, currentTick); - return await this._inflight; - } - - /** - * Runs the compute function and caches the result if the generation is still current. - */ - private async _computeAndCache(generation: number, currentTick: number): Promise { - try { - const value = await this._compute(); - if (generation === this._generation) { - this._value = value; - this._cachedAtTick = currentTick; - this._inflight = null; - } - return value; - } catch (err) { - if (generation === this._generation) { - this._inflight = null; - } - throw err; - } - } - - /** Gets the cached value with metadata about when it was cached. */ - async getWithMetadata(currentTick: number): Promise<{ value: T; cachedAtTick: number; fromCache: boolean }> { - const wasValid = this._isValid(currentTick); - const value = await this.get(currentTick); - - return { - value, - cachedAtTick: wasValid ? this._cachedAtTick : 0, - fromCache: wasValid, - }; - } - - /** Invalidates the cached value, forcing recomputation on next get(). */ - invalidate(): void { - this._generation += 1; - this._value = null; - this._inflight = null; - this._cachedAtTick = 0; - } - - /** Checks if the cached value is still valid. */ - private _isValid(currentTick: number): boolean { - if (this._value === null) { - return false; - } - return currentTick - this._cachedAtTick < this._ttlTicks; - } - - /** - * Gets the tick at which the value was cached. - * Returns 0 if no value is cached. - */ - get cachedAtTick(): number { - return this._cachedAtTick; - } - - /** Checks if a value is currently cached (regardless of validity). */ - get hasValue(): boolean { - return this._value !== null; - } -} - -export default CachedValue; diff --git a/src/domain/utils/RefLayout.ts b/src/domain/utils/RefLayout.ts index b016dd739..ad0ef486a 100644 --- a/src/domain/utils/RefLayout.ts +++ b/src/domain/utils/RefLayout.ts @@ -375,21 +375,6 @@ export function buildAuditPrefix(graphName: string): string { return `${REF_PREFIX}/${graphName}/audit/`; } -/** - * Builds the seek cache ref path for the given graph. - * - * The seek cache ref points to a blob containing a JSON index of - * cached materialization states, keyed by (ceiling, frontier) tuples. - * - * @example - * buildSeekCacheRef('events'); - * // => 'refs/warp/events/seek-cache' - */ -export function buildSeekCacheRef(graphName: string): string { - validateGraphName(graphName); - return `${REF_PREFIX}/${graphName}/seek-cache`; -} - /** * Builds the state cache ref path for the given graph. * diff --git a/src/domain/utils/seekCacheKey.ts b/src/domain/utils/seekCacheKey.ts deleted file mode 100644 index 56b997abe..000000000 --- a/src/domain/utils/seekCacheKey.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Deterministic cache key for seek materialization cache. - * - * Key format: `v1:t-` - * where frontierHash = hex SHA-256 of sorted writerId:tipSha pairs. - * - * The `v1` prefix ensures future schema/codec changes produce distinct keys - * without needing to flush existing caches. - * - * @module domain/utils/seekCacheKey - */ - -import { requireCrypto } from '../services/crypto/CryptoRequirement.ts'; -import type CryptoPort from '../../ports/CryptoPort.ts'; - -const KEY_VERSION = 'v1'; - -/** - * Builds a deterministic, collision-resistant cache key from a ceiling tick - * and writer frontier snapshot. - * - * This function is intentionally async — WebCrypto's `digest()` is async-only, - * and WebCrypto-backed ports use it. Both call sites are already async. - */ -export async function buildSeekCacheKey( - ceiling: number, - frontier: Map, - options: { crypto?: CryptoPort } = {}, -): Promise { - const sorted = [...frontier.entries()].sort((a, b) => - a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0 - ); - const payload = sorted.map(([w, sha]) => `${w}:${sha}`).join('\n'); - const hash = await requireCrypto(options.crypto, 'buildSeekCacheKey').hash('sha256', payload); - return `${KEY_VERSION}:t${ceiling}-${hash}`; -} diff --git a/src/domain/warp/RuntimeHostBoot.ts b/src/domain/warp/RuntimeHostBoot.ts index 7029dc8ad..f89d5d507 100644 --- a/src/domain/warp/RuntimeHostBoot.ts +++ b/src/domain/warp/RuntimeHostBoot.ts @@ -24,7 +24,6 @@ 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 SeekCachePort from '../../ports/SeekCachePort.ts'; import type WarpStateCachePort from '../../ports/WarpStateCachePort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type PatchJournalPort from '../../ports/PatchJournalPort.ts'; @@ -49,7 +48,6 @@ export type RuntimeHostConstructionOptions = { graphName: string; writerId: string; gcPolicy?: GCPolicyConfig | GCPolicy; - adjacencyCacheSize?: number; checkpointPolicy?: { every: number }; autoMaterialize?: boolean; onDeleteWithData?: DeletePolicy; @@ -57,7 +55,6 @@ export type RuntimeHostConstructionOptions = { crypto: CryptoPort; codec: CodecPort; trustCrypto?: TrustCryptoPort; - seekCache?: SeekCachePort; stateCache?: WarpStateCachePort | null; audit?: boolean; blobStorage?: BlobStoragePort; @@ -81,7 +78,6 @@ export type RuntimeHostOpenOptions = { graphName: string; writerId: string; gcPolicy?: GCPolicyConfig | GCPolicy; - adjacencyCacheSize?: number; checkpointPolicy?: { every: number } | null; autoMaterialize?: boolean; onDeleteWithData?: DeletePolicy; @@ -89,7 +85,6 @@ export type RuntimeHostOpenOptions = { crypto?: CryptoPort; codec?: CodecPort; trustCrypto?: TrustCryptoPort; - seekCache?: SeekCachePort; stateCache?: WarpStateCachePort | null; audit?: boolean; blobStorage?: BlobStoragePort; @@ -112,7 +107,6 @@ export class WarpOpenOptions { readonly graphName: string; readonly writerId: string; readonly gcPolicy: GCPolicyConfig | GCPolicy; - readonly adjacencyCacheSize?: number; readonly checkpointPolicy?: { every: number }; readonly autoMaterialize?: boolean; readonly onDeleteWithData?: DeletePolicy; @@ -120,7 +114,6 @@ export class WarpOpenOptions { readonly crypto?: CryptoPort; readonly codec?: CodecPort; readonly trustCrypto?: TrustCryptoPort; - readonly seekCache?: SeekCachePort; readonly stateCache?: WarpStateCachePort | null; readonly audit?: boolean; readonly blobStorage?: BlobStoragePort; @@ -154,9 +147,6 @@ export class WarpOpenOptions { if (options.codec !== undefined) { this.codec = options.codec; } if (options.trustCrypto !== undefined) { this.trustCrypto = options.trustCrypto; } - if (options.adjacencyCacheSize !== undefined) { - this.adjacencyCacheSize = options.adjacencyCacheSize; - } const checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy); if (checkpointPolicy !== undefined) { this.checkpointPolicy = checkpointPolicy; @@ -172,7 +162,6 @@ export class WarpOpenOptions { this.onDeleteWithData = normalizeDeletePolicy(options.onDeleteWithData); } if (options.logger !== undefined) { this.logger = options.logger; } - if (options.seekCache !== undefined) { this.seekCache = options.seekCache; } if (options.stateCache !== undefined) { this.stateCache = options.stateCache; } if (options.audit !== undefined) { this.audit = normalizeBooleanOption(options.audit, 'audit', 'E_AUDIT_TYPE'); @@ -280,7 +269,6 @@ export async function resolveRuntimeHostConstructionOptions( graphName, writerId, gcPolicy, - adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, @@ -288,7 +276,6 @@ export async function resolveRuntimeHostConstructionOptions( crypto, codec, trustCrypto, - seekCache, stateCache, audit, blobStorage, @@ -392,7 +379,6 @@ export async function resolveRuntimeHostConstructionOptions( graphName, writerId, gcPolicy, - ...(adjacencyCacheSize !== undefined ? { adjacencyCacheSize } : {}), ...(checkpointPolicy !== undefined ? { checkpointPolicy } : {}), ...(autoMaterialize !== undefined ? { autoMaterialize } : {}), ...(onDeleteWithData !== undefined ? { onDeleteWithData } : {}), @@ -400,7 +386,6 @@ export async function resolveRuntimeHostConstructionOptions( crypto: resolvedCrypto, codec: resolvedCodec, ...(resolvedTrustCrypto !== undefined ? { trustCrypto: resolvedTrustCrypto } : {}), - ...(seekCache !== undefined ? { seekCache } : {}), ...(resolvedStateCache !== undefined ? { stateCache: resolvedStateCache } : {}), ...(audit !== undefined ? { audit } : {}), blobStorage: resolvedBlobStorage, diff --git a/src/domain/warp/RuntimeHostProduct.ts b/src/domain/warp/RuntimeHostProduct.ts index adef0bdbb..fb61bc48b 100644 --- a/src/domain/warp/RuntimeHostProduct.ts +++ b/src/domain/warp/RuntimeHostProduct.ts @@ -1,4 +1,3 @@ -import type SeekCachePort from '../../ports/SeekCachePort.ts'; import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; import type CryptoPort from '../../ports/CryptoPort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; @@ -130,8 +129,6 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & { _runtimeStorage: RuntimeStorageProviderPort; readonly onDeleteWithData: 'reject' | 'cascade' | 'warn'; readonly gcPolicy: GCPolicy; - readonly seekCache: SeekCachePort | null; - _seekCache: SeekCachePort | null; _cachedState: WarpState | null; _stateDirty: boolean; _materializedGraph: RuntimeHostMaterializedGraph | null; @@ -150,7 +147,6 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & { readonly _syncController: SyncController; readonly provenanceIndex: ProvenanceIndex | null; readonly temporal: TemporalQuery; - setSeekCache(cache: SeekCachePort): void; readonly fork: (_request: RuntimeForkRequest) => Promise; readonly createWormhole: (_fromSha: string, _toSha: string) => Promise; materialize(options: { receipts: true; ceiling?: number | null }): Promise; diff --git a/src/domain/warp/WarpCoreRuntimeProduct.ts b/src/domain/warp/WarpCoreRuntimeProduct.ts index 7253256ff..349736674 100644 --- a/src/domain/warp/WarpCoreRuntimeProduct.ts +++ b/src/domain/warp/WarpCoreRuntimeProduct.ts @@ -1,4 +1,3 @@ -import type SeekCachePort from '../../ports/SeekCachePort.ts'; import type MaterializeCapability from '../capabilities/MaterializeCapability.ts'; import type { EffectPipeline } from '../services/EffectPipeline.ts'; import type { WarpGraphRuntimeSurface } from './WarpGraphRuntimeProduct.ts'; @@ -34,8 +33,6 @@ export type WarpCoreRuntimeSurface = WarpGraphRuntimeSurface & MaterializeCapabi readonly persistence: RuntimeHostProduct['persistence']; readonly onDeleteWithData: RuntimeHostProduct['onDeleteWithData']; readonly gcPolicy: RuntimeHostProduct['gcPolicy']; - readonly seekCache: SeekCachePort | null; - setSeekCache(cache: SeekCachePort | null): void; readonly fork: RuntimeHostProduct['fork']; readonly createWormhole: RuntimeHostProduct['createWormhole']; _effectPipeline: EffectPipeline | null; @@ -133,16 +130,6 @@ export function buildWarpCoreRuntimeSurface(runtime: RuntimeHostProduct): WarpCo get gcPolicy() { return runtime.gcPolicy; }, - get seekCache() { - return runtime.seekCache; - }, - setSeekCache(cache: SeekCachePort | null): void { - if (cache === null) { - runtime._seekCache = null; - return; - } - runtime.setSeekCache(cache); - }, fork: runtime.fork.bind(runtime), createWormhole: runtime.createWormhole.bind(runtime), get _effectPipeline() { diff --git a/src/infrastructure/adapters/CasIndexStorageAdapter.ts b/src/infrastructure/adapters/CasIndexStorageAdapter.ts deleted file mode 100644 index f3a7afd1b..000000000 --- a/src/infrastructure/adapters/CasIndexStorageAdapter.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type BlobPort from '../../ports/BlobPort.ts'; -import type TreePort from '../../ports/TreePort.ts'; -import type RefPort from '../../ports/RefPort.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; -import type { BlobStorageOptions } from '../../ports/BlobStoragePort.ts'; -import StreamingIndexStoragePort from '../../ports/StreamingIndexStoragePort.ts'; -import WarpError from '../../domain/errors/WarpError.ts'; -import { - decodeCasPayloadPointer, - encodeCasPayloadPointer, - readPayloadBlob, - writePayloadBlob, -} from './CasPayloadPointer.ts'; - -type CasIndexStorageAdapterOptions = { - blobPort: BlobPort; - treePort: TreePort; - refPort: RefPort; - blobStorage: BlobStoragePort; -}; - -export class CasIndexStorageAdapter extends StreamingIndexStoragePort { - private readonly _blobPort: BlobPort; - private readonly _treePort: TreePort; - private readonly _refPort: RefPort; - private readonly _blobStorage: BlobStoragePort; - - constructor(options: CasIndexStorageAdapterOptions) { - super(); - this._blobPort = options.blobPort; - this._treePort = options.treePort; - this._refPort = options.refPort; - this._blobStorage = options.blobStorage; - } - - override async writeBlob(content: Uint8Array | string): Promise { - const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; - return await writePayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - bytes, - }); - } - - override async readBlob(oid: string): Promise { - return await readPayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - oid, - }); - } - - override async writeBlobStream( - source: AsyncIterable, - options?: BlobStorageOptions, - ): Promise { - const storageOid = await this._blobStorage.storeStream(source, options); - return await this._blobPort.writeBlob(encodeCasPayloadPointer(storageOid)); - } - - override readBlobStream(oid: string): AsyncIterable { - const adapter = this; - return { - async *[Symbol.asyncIterator](): AsyncIterator { - const pointerBytes = await adapter._blobPort.readBlob(oid); - const storageOid = decodeCasPayloadPointer(pointerBytes); - if (storageOid === null) { - throw new WarpError( - `Inline index payload blob ${oid} requires the substrate migration compatibility policy`, - 'E_LEGACY_SUBSTRATE_DISABLED', - ); - } - for await (const chunk of adapter._blobStorage.retrieveStream(storageOid)) { - yield chunk; - } - }, - }; - } - - override async writeTree(entries: string[]): Promise { - return await this._treePort.writeTree(entries); - } - - override async readTreeOids(treeOid: string): Promise> { - return await this._treePort.readTreeOids(treeOid); - } - - override async updateRef(ref: string, oid: string): Promise { - await this._refPort.updateRef(ref, oid); - } - - override async readRef(ref: string): Promise { - return await this._refPort.readRef(ref); - } -} diff --git a/src/infrastructure/adapters/CasSeekCacheAdapter.ts b/src/infrastructure/adapters/CasSeekCacheAdapter.ts deleted file mode 100644 index 73c5edd6d..000000000 --- a/src/infrastructure/adapters/CasSeekCacheAdapter.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * CAS-backed seek materialization cache adapter. - * - * Implements SeekCachePort using @git-stunts/git-cas for persistent storage - * of serialized WarpState snapshots. Each cached state is stored as a CAS - * asset (chunked blobs + manifest tree), and an index ref tracks the mapping - * from cache keys to tree OIDs. - * - * Index ref: `refs/warp//seek-cache` → blob containing JSON index. - * - * Blobs are loose Git objects — `git gc` prunes them using the configured - * prune expiry (default ~2 weeks). Use vault pinning for GC-safe persistence. - * - * **Requires Node >= 22.0.0** (inherited from `@git-stunts/git-cas`). - * - * @module infrastructure/adapters/CasSeekCacheAdapter - */ - -import SeekCachePort, { type SeekCacheEntry, type SeekCacheSetOptions } from '../../ports/SeekCachePort.ts'; -import { buildSeekCacheRef } from '../../domain/utils/RefLayout.ts'; -import CacheError from '../../domain/errors/CacheError.ts'; -import { textEncode, textDecode, concatBytes } from '../../domain/utils/bytes.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' | 'restoreStream' | 'store' | 'createTree' ->; - -interface CachePersistence { - readRef(ref: string): Promise; - readBlob(oid: string): Promise; - writeBlob(data: Uint8Array): Promise; - updateRef(ref: string, oid: string): Promise; - deleteRef(ref: string): Promise; -} - -interface IndexEntry { - treeOid: string; - createdAt: string; - ceiling: number; - frontierHash: string; - sizeBytes: number; - codec: string; - schemaVersion: number; - lastAccessedAt?: string; - indexTreeOid?: string; -} - -interface CacheIndex { - schemaVersion: number; - entries: Record; -} - -const DEFAULT_MAX_ENTRIES = 200; -const INDEX_SCHEMA_VERSION = 1; -const MAX_CAS_RETRIES = 3; - -function _emptyEntries(): Record { - return Object.create(null) as Record; -} - -function _emptyIndex(): CacheIndex { - return { schemaVersion: INDEX_SCHEMA_VERSION, entries: _emptyEntries() }; -} - -function _isObjectRecord(value: unknown): value is object { - return typeof value === 'object' && value !== null; -} - -function _normalizeIndexEntries(entries: unknown): Record { - const normalized = _emptyEntries(); - if (!_isObjectRecord(entries)) { - return normalized; - } - for (const [key, value] of Object.entries(entries)) { - if (_isObjectRecord(value)) { - normalized[key] = value as IndexEntry; - } - } - return normalized; -} - -function _parseIndexBlob(buf: Uint8Array): CacheIndex { - const parsed: unknown = JSON.parse(textDecode(buf)); - const candidate = parsed as { schemaVersion?: unknown; entries?: unknown }; - if ( - typeof parsed === 'object' && - parsed !== null && - candidate.schemaVersion === INDEX_SCHEMA_VERSION - ) { - return { - schemaVersion: INDEX_SCHEMA_VERSION, - entries: _normalizeIndexEntries(candidate.entries), - }; - } - return _emptyIndex(); -} - -export default class CasSeekCacheAdapter extends SeekCachePort { - private readonly _persistence: CachePersistence; - private readonly _cas: CasStore; - private readonly _maxEntries: number; - private readonly _ref: string; - private readonly _encryptionKey: Uint8Array | undefined; - private readonly _contentEncryption: CasContentEncryptionPolicy; - - constructor({ persistence, cas, graphName, maxEntries, encryptionKey, contentEncryption }: { - persistence: CachePersistence; - cas: CasStore; - graphName: string; - maxEntries?: number; - encryptionKey?: Uint8Array; - contentEncryption?: CasContentEncryptionPolicy; - }) { - super(); - this._persistence = persistence; - this._cas = cas; - this._maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES; - this._ref = buildSeekCacheRef(graphName); - this._encryptionKey = encryptionKey; - this._contentEncryption = resolveContentEncryption(contentEncryption, this._encryptionKey); - } - - // --------------------------------------------------------------------------- - // Index management - // --------------------------------------------------------------------------- - - private async _readIndex(): Promise { - const oid = await this._persistence.readRef(this._ref); - if (typeof oid !== 'string' || oid.length === 0) { - return _emptyIndex(); - } - try { - const buf = await this._persistence.readBlob(oid); - return _parseIndexBlob(buf); - } catch { - return _emptyIndex(); - } - } - - private async _writeIndex(index: CacheIndex): Promise { - const json = JSON.stringify(index); - const oid = await this._persistence.writeBlob(textEncode(json)); - await this._persistence.updateRef(this._ref, oid); - } - - private async _mutateIndex(mutate: (index: CacheIndex) => CacheIndex): Promise { - let lastErr: unknown; - for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) { - const index = await this._readIndex(); - const mutated = mutate(index); - try { - await this._writeIndex(mutated); - return mutated; - } catch (err) { - lastErr = err; - if (attempt === MAX_CAS_RETRIES - 1) { - throw new CacheError(`CasSeekCacheAdapter: index update failed after retries: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`); - } - } - } - /* c8 ignore next - unreachable */ - throw new CacheError('CasSeekCacheAdapter: index update failed'); - } - - private _entryTimestamp(entry: IndexEntry): string { - if (typeof entry.lastAccessedAt === 'string' && entry.lastAccessedAt.length > 0) { - return entry.lastAccessedAt; - } - if (typeof entry.createdAt === 'string' && entry.createdAt.length > 0) { - return entry.createdAt; - } - return ''; - } - - private _enforceMaxEntries(index: CacheIndex): CacheIndex { - const keys = Object.keys(index.entries); - if (keys.length <= this._maxEntries) { - return index; - } - const sorted = keys.sort((a, b) => { - const entryA = index.entries[a]; - const entryB = index.entries[b]; - if (entryA === undefined || entryB === undefined) { return 0; } - const ta = this._entryTimestamp(entryA); - const tb = this._entryTimestamp(entryB); - if (ta < tb) { return -1; } - return ta > tb ? 1 : 0; - }); - const toEvict = sorted.slice(0, keys.length - this._maxEntries); - for (const k of toEvict) { - delete index.entries[k]; - } - return index; - } - - private _parseKey(key: string): { ceiling: number; frontierHash: string } { - const colonIdx = key.indexOf(':'); - const rest = colonIdx >= 0 ? key.slice(colonIdx + 1) : key; - const dashIdx = rest.indexOf('-'); - const ceiling = parseInt(rest.slice(1, dashIdx), 10); - const frontierHash = rest.slice(dashIdx + 1); - return { ceiling, frontierHash }; - } - - // --------------------------------------------------------------------------- - // Restore helpers - // --------------------------------------------------------------------------- - - private async _restoreBuffer(cas: CasStore, restoreOpts: { manifest: Manifest } & CasRestoreEncryptionArguments): Promise { - const chunks: Uint8Array[] = []; - for await (const chunk of cas.restoreStream(restoreOpts)) { - chunks.push(chunk); - } - if (chunks.length === 1 && chunks[0] !== undefined) { - return chunks[0]; - } - return concatBytes(...chunks); - } - - // --------------------------------------------------------------------------- - // SeekCachePort implementation - // --------------------------------------------------------------------------- - - override async get(key: string): Promise { - const cas = this._cas; - const index = await this._readIndex(); - const entry = index.entries[key]; - if (entry === null || entry === undefined) { - return null; - } - - try { - return await this._getEntry(cas, key, entry); - } catch (err) { - const encryptionError = mapCasContentEncryptionError(err, 'seek-cache'); - if (encryptionError !== null) { - throw encryptionError; - } - await this._mutateIndex((idx) => { - delete idx.entries[key]; - return idx; - }); - return null; - } - } - - private async _getEntry(cas: CasStore, key: string, entry: IndexEntry): Promise { - const manifest = await cas.readManifest({ treeOid: entry.treeOid }); - const restoreOpts: { manifest: Manifest } & CasRestoreEncryptionArguments = { - manifest, - ...this._contentEncryption.toRestoreOptions(), - }; - const buffer = await this._restoreBuffer(cas, restoreOpts); - await this._mutateIndex((idx) => { - const tracked = idx.entries[key]; - if (tracked !== null && tracked !== undefined) { - tracked.lastAccessedAt = new Date().toISOString(); - } - return idx; - }); - const result: SeekCacheEntry = { buffer }; - if (typeof entry.indexTreeOid === 'string' && entry.indexTreeOid.length > 0) { - result.indexTreeOid = entry.indexTreeOid; - } - return result; - } - - override async set(key: string, buffer: Uint8Array, options?: SeekCacheSetOptions): Promise { - const cas = this._cas; - const { ceiling, frontierHash } = this._parseKey(key); - - const { treeOid } = await this._storeCasAsset(cas, key, buffer); - - await this._mutateIndex((index) => { - const entry: IndexEntry = { - treeOid, - createdAt: new Date().toISOString(), - ceiling, - frontierHash, - sizeBytes: buffer.length, - codec: 'cbor-v1', - schemaVersion: INDEX_SCHEMA_VERSION, - }; - if (typeof options?.indexTreeOid === 'string' && options.indexTreeOid.length > 0) { - entry.indexTreeOid = options.indexTreeOid; - } - index.entries[key] = entry; - return this._enforceMaxEntries(index); - }); - } - - private async _storeCasAsset(cas: CasStore, key: string, buffer: Uint8Array): Promise<{ manifest: Manifest; treeOid: string }> { - const source = Readable.from([buffer]); - const storeOpts: { source: Readable; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { - source, - slug: key, - filename: 'state.cbor', - ...this._contentEncryption.toStoreOptions(), - }; - const manifest = await cas.store(storeOpts); - const treeOid = await cas.createTree({ manifest }); - return { manifest, treeOid }; - } - - override async has(key: string): Promise { - const index = await this._readIndex(); - return Object.hasOwn(index.entries, key); - } - - override async keys(): Promise { - const index = await this._readIndex(); - return Object.keys(index.entries); - } - - override async delete(key: string): Promise { - let existed = false; - await this._mutateIndex((index) => { - existed = Object.hasOwn(index.entries, key); - delete index.entries[key]; - return index; - }); - return existed; - } - - override async clear(): Promise { - try { - await this._persistence.deleteRef(this._ref); - } catch { - // Ref may not exist — that's fine - } - } -} - -function resolveContentEncryption( - contentEncryption: CasContentEncryptionPolicy | undefined, - encryptionKey: Uint8Array | undefined, -): CasContentEncryptionPolicy { - if (contentEncryption !== undefined) { - return contentEncryption; - } - if (encryptionKey !== undefined) { - return CasContentEncryptionPolicy.fromInternalResolvedKey({ encryptionKey }); - } - return CasContentEncryptionPolicy.disabled(); -} diff --git a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts index f71fdb639..4f1838459 100644 --- a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts +++ b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts @@ -10,7 +10,6 @@ import type { } from '../../ports/RuntimeStorageProviderPort.ts'; import CasBlobAdapter from './CasBlobAdapter.ts'; import type CasContentEncryptionPolicy from './CasContentEncryptionPolicy.ts'; -import CasSeekCacheAdapter from './CasSeekCacheAdapter.ts'; import { CborCheckpointStoreAdapter } from './CborCheckpointStoreAdapter.ts'; import { CborIndexStoreAdapter } from './CborIndexStoreAdapter.ts'; import { CborPatchJournalAdapter } from './CborPatchJournalAdapter.ts'; @@ -127,17 +126,6 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo }); } - createSeekCache(timelineName: string): CasSeekCacheAdapter { - return new CasSeekCacheAdapter({ - cas: this._cas, - persistence: this._history, - graphName: timelineName, - ...(this._contentEncryption === undefined - ? {} - : { contentEncryption: this._contentEncryption }), - }); - } - createTrustChain(crypto: CryptoPort): GitTrustChainAdapter { return new GitTrustChainAdapter({ cas: this._cas, diff --git a/src/infrastructure/adapters/sha1sync.ts b/src/infrastructure/adapters/sha1sync.ts deleted file mode 100644 index c477aa23b..000000000 --- a/src/infrastructure/adapters/sha1sync.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Synchronous SHA-1 for browser use with InMemoryGraphAdapter. - * - * This is a minimal, standards-compliant SHA-1 implementation used - * solely for Git content addressing (blob/tree/commit object IDs). - * It is NOT used for security purposes. - * - * @module infrastructure/adapters/sha1sync - */ - -/** - * Left-rotate a 32-bit integer by n bits. - */ -function rotl(x: number, n: number): number { - return ((x << n) | (x >>> (32 - n))) >>> 0; -} - -/** - * Pads and parses a message into 512-bit blocks for SHA-1. - */ -function preprocess(msg: Uint8Array): Uint32Array[] { - const bitLen = msg.length * 8; - const totalBytes = msg.length + 1 + ((119 - (msg.length % 64)) % 64) + 8; - const padded = new Uint8Array(totalBytes); - padded.set(msg); - padded[msg.length] = 0x80; - const dv = new DataView(padded.buffer); - // Set low 32 bits of 64-bit big-endian message length (high 32 are zero-init). - dv.setUint32(totalBytes - 4, bitLen, false); - - const blocks: Uint32Array[] = []; - for (let i = 0; i < totalBytes; i += 64) { - const block = new Uint32Array(80); - for (let j = 0; j < 16; j++) { - block[j] = dv.getUint32(i + j * 4, false); - } - for (let j = 16; j < 80; j++) { - const b3 = block[j - 3] as number; - const b8 = block[j - 8] as number; - const b14 = block[j - 14] as number; - const b16 = block[j - 16] as number; - block[j] = rotl(b3 ^ b8 ^ b14 ^ b16, 1); - } - blocks.push(block); - } - return blocks; -} - -/** - * Returns the SHA-1 round constant for a given round index. - */ -function roundK(i: number): number { - if (i < 20) { return 0x5A827999; } - if (i < 40) { return 0x6ED9EBA1; } - if (i < 60) { return 0x8F1BBCDC; } - return 0xCA62C1D6; -} - -/** - * Computes the SHA-1 round function f(b, c, d) for a given round index. - */ -function roundF(i: number, vars: number[]): number { - const b = vars[1] as number; - const c = vars[2] as number; - const d = vars[3] as number; - if (i < 20) { return (b & c) | (~b & d); } - if (i < 40) { return b ^ c ^ d; } - if (i < 60) { return (b & c) | (b & d) | (c & d); } - return b ^ c ^ d; -} - -/** - * Processes a single 512-bit block, updating the hash state in-place. - */ -function processBlock(state: number[], w: Uint32Array): void { - const v: number[] = [ - state[0] as number, - state[1] as number, - state[2] as number, - state[3] as number, - state[4] as number, - ]; - - for (let i = 0; i < 80; i++) { - const f = roundF(i, v); - const k = roundK(i); - const temp = (rotl(v[0] as number, 5) + f + (v[4] as number) + k + (w[i] as number)) >>> 0; - v[4] = v[3] as number; - v[3] = v[2] as number; - v[2] = rotl(v[1] as number, 30); - v[1] = v[0] as number; - v[0] = temp; - } - - for (let i = 0; i < 5; i++) { - state[i] = ((state[i] as number) + (v[i] as number)) >>> 0; - } -} - -/** - * Computes the SHA-1 hash of a Uint8Array, returning a 40-char hex string. - * - * @param data - The data to hash - * @returns 40-character lowercase hex SHA-1 digest - * - * @example - * import { sha1sync } from './sha1sync.ts'; - * const hex = sha1sync(new TextEncoder().encode('hello')); - * // => 'aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d' - */ -export function sha1sync(data: Uint8Array): string { - if (data.length >= 0x20000000) { - throw new RangeError('sha1sync: input exceeds 512 MB limit'); - } - const blocks = preprocess(data); - const state = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; - - for (const w of blocks) { - processBlock(state, w); - } - - return state.map(v => (v).toString(16).padStart(8, '0')).join(''); -} diff --git a/src/ports/SeekCachePort.ts b/src/ports/SeekCachePort.ts deleted file mode 100644 index 4d3abe375..000000000 --- a/src/ports/SeekCachePort.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Port interface for seek materialization cache operations. - * - * Defines the contract for caching and retrieving serialized WarpState - * snapshots keyed by (ceiling, frontier) tuples. Used by the seek time-travel - * feature to avoid full re-materialization for previously-visited ticks. - * - * Concrete adapters (e.g., CasSeekCacheAdapter) implement this interface - * to store cached states in different backends (git-cas, filesystem, etc.). - */ - -export interface SeekCacheEntry { - buffer: Uint8Array; - indexTreeOid?: string; -} - -export interface SeekCacheSetOptions { - indexTreeOid?: string; -} - -/** Port for seek materialization cache operations. */ -export default abstract class SeekCachePort { - /** Retrieves a cached state buffer by key, or null on miss. */ - abstract get(_key: string): Promise; - - /** Stores a state buffer under the given key. */ - abstract set(_key: string, _buffer: Uint8Array, _options?: SeekCacheSetOptions): Promise; - - /** Checks whether a key exists in the cache. */ - abstract has(_key: string): Promise; - - /** - * Lists all keys currently in the cache index. - * Note: keys may reference GC'd blobs; callers should handle miss on get(). - */ - abstract keys(): Promise; - - /** Removes a single entry from the cache. Returns true if the entry existed. */ - abstract delete(_key: string): Promise; - - /** Removes all entries from the cache. */ - abstract clear(): Promise; -} diff --git a/src/ports/StreamingIndexStoragePort.ts b/src/ports/StreamingIndexStoragePort.ts deleted file mode 100644 index 2a5bb51a6..000000000 --- a/src/ports/StreamingIndexStoragePort.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { BlobStorageOptions } from './BlobStoragePort.ts'; -import IndexStoragePort from './IndexStoragePort.ts'; - -/** - * Stronger index-storage seam for streaming rebuild paths. - * - * `IndexStoragePort` is enough for bounded single-blob reads and writes. - * Streaming rebuild paths need a stronger contract: blob payloads can be - * written and read as async byte streams without forcing a whole-blob buffer - * step in the domain layer. - */ -export default abstract class StreamingIndexStoragePort extends IndexStoragePort { - /** Writes blob content from a stream and returns the resulting OID. */ - abstract writeBlobStream( - _source: AsyncIterable, - _options?: BlobStorageOptions, - ): Promise; - - /** Reads blob content as a stream of byte chunks. */ - abstract readBlobStream(_oid: string): AsyncIterable; -} diff --git a/storage.ts b/storage.ts index 657d28269..9e94cd02e 100644 --- a/storage.ts +++ b/storage.ts @@ -4,8 +4,6 @@ import path from 'node:path'; import GitPlumbing from '@git-stunts/plumbing'; import GitCasRepositoryAdapter from './src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; import GitTimelineHistoryAdapter from './src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; -import InMemoryGraphAdapter from './src/infrastructure/adapters/InMemoryGraphAdapter.ts'; -import MemoryRuntimeStorageAdapter from './src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts'; import PlumbingHookPathAdapter from './src/infrastructure/adapters/PlumbingHookPathAdapter.ts'; import AdapterValidationError from './src/domain/errors/AdapterValidationError.ts'; import WarpStorage from './src/application/WarpStorage.ts'; @@ -32,24 +30,9 @@ export class GitStorage extends WarpStorage { bindWarpStorage(storage, { history, runtimeStorage: repository, - createSeekCache: (timelineName) => repository.createSeekCache(timelineName), createTrustChain: (crypto) => repository.createTrustChain(crypto), hookPaths: new PlumbingHookPathAdapter({ plumbing, path }), }); return storage; } } - -export class MemoryStorage extends WarpStorage { - private constructor() { - super(); - } - - static create(): MemoryStorage { - const history = new InMemoryGraphAdapter(); - const runtimeStorage = new MemoryRuntimeStorageAdapter({ history }); - const storage = new MemoryStorage(); - bindWarpStorage(storage, { history, runtimeStorage }); - return storage; - } -} diff --git a/test/benchmark/detachedReadBenchmark.fixture.ts b/test/benchmark/detachedReadBenchmark.fixture.ts index 06ab797e7..2799437f5 100644 --- a/test/benchmark/detachedReadBenchmark.fixture.ts +++ b/test/benchmark/detachedReadBenchmark.fixture.ts @@ -3,7 +3,7 @@ import WarpCore from '../../src/domain/WarpCore.ts'; import { Dot } from '../../src/domain/crdt/Dot.ts'; import VersionVector from '../../src/domain/crdt/VersionVector.ts'; -import MemoryRuntimeStorageAdapter from '../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../test/helpers/MemoryRuntimeStorageAdapter.ts'; /** @typedef {any} WarpCoreRuntime */ diff --git a/test/conformance/fixtures/V17CheckpointTailOpticGraphFixture.ts b/test/conformance/fixtures/V17CheckpointTailOpticGraphFixture.ts index 1ec6dc133..1fec4001d 100644 --- a/test/conformance/fixtures/V17CheckpointTailOpticGraphFixture.ts +++ b/test/conformance/fixtures/V17CheckpointTailOpticGraphFixture.ts @@ -1,4 +1,4 @@ -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { type RuntimeHostOpenOptions, type RuntimeHostProduct, diff --git a/test/conformance/post-v17/graphQueryBoundedProvider.blocked.test.ts b/test/conformance/post-v17/graphQueryBoundedProvider.blocked.test.ts index 20c5e6260..c6a6a0e43 100644 --- a/test/conformance/post-v17/graphQueryBoundedProvider.blocked.test.ts +++ b/test/conformance/post-v17/graphQueryBoundedProvider.blocked.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; const BASE_NODE_ID = 'bounded:base'; const TAIL_NODE_ID = 'bounded:tail'; diff --git a/test/conformance/v18BoundedMemoryLargeGraphGate.test.ts b/test/conformance/v18BoundedMemoryLargeGraphGate.test.ts index 75ffee870..daed49ad2 100644 --- a/test/conformance/v18BoundedMemoryLargeGraphGate.test.ts +++ b/test/conformance/v18BoundedMemoryLargeGraphGate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import MemoryBudgetError from '../../src/domain/errors/MemoryBudgetError.ts'; import BoundedQueryReadModel from '../../src/domain/services/query/BoundedQueryReadModel.ts'; -import InMemoryGraphAdapter from '../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryWarpWorldline as openWarpWorldline } from '../helpers/MemoryRuntimeHost.ts'; import V18LargeGraphOverSmallPoolFixture from './fixtures/V18LargeGraphOverSmallPoolFixture.ts'; diff --git a/test/conformance/v18CoordinateOpticPublicPath.test.ts b/test/conformance/v18CoordinateOpticPublicPath.test.ts index 903cb75ac..f14465017 100644 --- a/test/conformance/v18CoordinateOpticPublicPath.test.ts +++ b/test/conformance/v18CoordinateOpticPublicPath.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import InMemoryGraphAdapter from '../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct, openMemoryWarpWorldline as openWarpWorldline, diff --git a/test/conformance/v18FirstUseOpticsHonesty.test.ts b/test/conformance/v18FirstUseOpticsHonesty.test.ts index d49e34a09..45f954d4b 100644 --- a/test/conformance/v18FirstUseOpticsHonesty.test.ts +++ b/test/conformance/v18FirstUseOpticsHonesty.test.ts @@ -7,8 +7,8 @@ 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 InMemoryGraphAdapter from '../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; -import MemoryRuntimeStorageAdapter from '../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.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'; diff --git a/test/helpers/BoundedReadBasis.ts b/test/helpers/BoundedReadBasis.ts index b2c1d1f90..7c1bc98eb 100644 --- a/test/helpers/BoundedReadBasis.ts +++ b/test/helpers/BoundedReadBasis.ts @@ -1,5 +1,5 @@ import { resolveWarpStorage } from '../../src/application/WarpStorageRegistry.ts'; -import type { MemoryStorage } from '../../storage.ts'; +import type MemoryStorage from './MemoryStorage.ts'; import { openMemoryRuntimeHostProduct } from './MemoryRuntimeHost.ts'; export async function createBoundedReadBasis( diff --git a/src/infrastructure/adapters/InMemoryBlobStorageAdapter.ts b/test/helpers/InMemoryBlobStorageAdapter.ts similarity index 61% rename from src/infrastructure/adapters/InMemoryBlobStorageAdapter.ts rename to test/helpers/InMemoryBlobStorageAdapter.ts index 72fe087d9..43b5c2736 100644 --- a/src/infrastructure/adapters/InMemoryBlobStorageAdapter.ts +++ b/test/helpers/InMemoryBlobStorageAdapter.ts @@ -1,45 +1,27 @@ /** - * In-memory blob storage adapter for browser and test paths. + * 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 infrastructure/adapters/InMemoryBlobStorageAdapter + * @module test/helpers/InMemoryBlobStorageAdapter */ -import StorageError from '../../domain/errors/StorageError.ts'; -import BlobStoragePort from '../../ports/BlobStoragePort.ts'; -import { hexEncode } from '../../domain/utils/bytes.ts'; -import { collectAsyncIterable } from '../../domain/utils/streamUtils.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'; const _encoder = new TextEncoder(); /** * Simple content-addressed hash using Web Crypto SHA-256. - * Falls back to a synchronous FNV-1a for environments without - * crypto.subtle (plain HTTP in Docker, etc.). */ async function contentHash(bytes: Uint8Array): Promise { - if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle !== undefined && globalThis.crypto.subtle !== null) { - const buf = (bytes.buffer as ArrayBuffer).slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - const digest = await globalThis.crypto.subtle.digest('SHA-256', buf); - return hexEncode(new Uint8Array(digest)); - } - // FNV-1a 64-bit (as two 32-bit halves) — not cryptographic, just deterministic. - // Produces 16-char hex OIDs (shorter than SHA). Acceptable because - // InMemoryBlobStorageAdapter OIDs never leave the process boundary. - let h1 = 0x811c9dc5; - let h2 = 0xcbf29ce4; - for (let i = 0; i < bytes.length; i++) { - const b = bytes[i]!; - h1 ^= b; - h1 = Math.imul(h1, 0x01000193); - h2 ^= b; - h2 = Math.imul(h2, 0x01000193); - } - return (h1 >>> 0).toString(16).padStart(8, '0') - + (h2 >>> 0).toString(16).padStart(8, '0'); + const buffer = new Uint8Array(bytes).buffer; + const digest = await globalThis.crypto.subtle.digest('SHA-256', buffer); + return hexEncode(new Uint8Array(digest)); } interface StorageOptions { @@ -49,7 +31,7 @@ interface StorageOptions { } /** - * In-memory content-addressed blob storage for browser and test environments. + * In-memory content-addressed blob storage for tests. */ export default class InMemoryBlobStorageAdapter extends BlobStoragePort { private readonly _store: Map; diff --git a/src/infrastructure/adapters/InMemoryGraphAdapter.ts b/test/helpers/InMemoryGraphAdapter.ts similarity index 93% rename from src/infrastructure/adapters/InMemoryGraphAdapter.ts rename to test/helpers/InMemoryGraphAdapter.ts index 60bd02772..25aad3e49 100644 --- a/src/infrastructure/adapters/InMemoryGraphAdapter.ts +++ b/test/helpers/InMemoryGraphAdapter.ts @@ -1,23 +1,22 @@ -/* eslint-disable @typescript-eslint/require-await -- all async methods match the port contract */ /** * In-memory persistence adapter for WARP graph storage. * * 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 '../../ports/CommitPort.ts'; -import type { ListRefsOptions } from '../../ports/RefPort.ts'; -import GraphPersistencePort from '../../ports/GraphPersistencePort.ts'; -import WarpStream from '../../domain/stream/WarpStream.ts'; -import PersistenceError from '../../domain/errors/PersistenceError.ts'; -import WarpError from '../../domain/errors/WarpError.ts'; -import TreeEntryFound from '../../domain/tree/TreeEntryFound.ts'; -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 { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.ts'; +import type { CommitLogChunk, CommitNodeOptions, CommitNodeWithTreeOptions, 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'; +import PersistenceError from '../../src/domain/errors/PersistenceError.ts'; +import WarpError from '../../src/domain/errors/WarpError.ts'; +import TreeEntryFound from '../../src/domain/tree/TreeEntryFound.ts'; +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 { validateOid, validateRef, validateLimit, validateConfigKey } from '../../src/infrastructure/adapters/adapterValidation.ts'; import { type HashFn, type TreeEntry, diff --git a/test/helpers/MemoryRuntimeHost.ts b/test/helpers/MemoryRuntimeHost.ts index fa1b95858..3a87635fa 100644 --- a/test/helpers/MemoryRuntimeHost.ts +++ b/test/helpers/MemoryRuntimeHost.ts @@ -3,7 +3,7 @@ import WarpApp from '../../src/domain/WarpApp.ts'; import WarpCore from '../../src/domain/WarpCore.ts'; import { openWarpGraph as openProductionWarpGraph } from '../../src/domain/WarpGraph.ts'; import { openWarpWorldline as openProductionWarpWorldline } from '../../src/domain/WarpWorldline.ts'; -import MemoryRuntimeStorageAdapter from '../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../test/helpers/MemoryRuntimeStorageAdapter.ts'; import type { CorePersistence } from '../../src/domain/types/WarpPersistence.ts'; import type RuntimeStorageProviderPort from '../../src/ports/RuntimeStorageProviderPort.ts'; diff --git a/src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts b/test/helpers/MemoryRuntimeStorageAdapter.ts similarity index 74% rename from src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts rename to test/helpers/MemoryRuntimeStorageAdapter.ts index 3d5539fbb..6359ffe76 100644 --- a/src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts +++ b/test/helpers/MemoryRuntimeStorageAdapter.ts @@ -1,17 +1,17 @@ -import type { CorePersistence } from '../../domain/types/WarpPersistence.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; +import type { CorePersistence } from '../../src/domain/types/WarpPersistence.ts'; +import type BlobStoragePort from '../../src/ports/BlobStoragePort.ts'; import { LEGACY_EXTERNAL_PATCH_STORAGE, LEGACY_GIT_BLOB_PATCH_STORAGE, -} from '../../ports/CommitMessageCodecPort.ts'; -import type RuntimeStorageProviderPort from '../../ports/RuntimeStorageProviderPort.ts'; +} from '../../src/ports/CommitMessageCodecPort.ts'; +import type RuntimeStorageProviderPort from '../../src/ports/RuntimeStorageProviderPort.ts'; import type { RuntimeStorageRequest, RuntimeStorageServices, -} from '../../ports/RuntimeStorageProviderPort.ts'; -import { CborCheckpointStoreAdapter } from './CborCheckpointStoreAdapter.ts'; -import { CborIndexStoreAdapter } from './CborIndexStoreAdapter.ts'; -import { CborPatchJournalAdapter } from './CborPatchJournalAdapter.ts'; +} from '../../src/ports/RuntimeStorageProviderPort.ts'; +import { CborCheckpointStoreAdapter } from '../../src/infrastructure/adapters/CborCheckpointStoreAdapter.ts'; +import { CborIndexStoreAdapter } from '../../src/infrastructure/adapters/CborIndexStoreAdapter.ts'; +import { CborPatchJournalAdapter } from '../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; import InMemoryBlobStorageAdapter from './InMemoryBlobStorageAdapter.ts'; export type MemoryRuntimeStorageAdapterOptions = { diff --git a/test/helpers/MemoryStorage.ts b/test/helpers/MemoryStorage.ts new file mode 100644 index 000000000..2dd2919b0 --- /dev/null +++ b/test/helpers/MemoryStorage.ts @@ -0,0 +1,19 @@ +import WarpStorage from '../../src/application/WarpStorage.ts'; +import { bindWarpStorage } from '../../src/application/WarpStorageRegistry.ts'; +import InMemoryGraphAdapter from './InMemoryGraphAdapter.ts'; +import MemoryRuntimeStorageAdapter from './MemoryRuntimeStorageAdapter.ts'; + +/** Test-only storage composition; production applications use GitStorage. */ +export default class MemoryStorage extends WarpStorage { + private constructor() { + super(); + } + + static create(): MemoryStorage { + const history = new InMemoryGraphAdapter(); + const runtimeStorage = new MemoryRuntimeStorageAdapter({ history }); + const storage = new MemoryStorage(); + bindWarpStorage(storage, { history, runtimeStorage }); + return storage; + } +} diff --git a/test/helpers/MockIndexStorage.ts b/test/helpers/MockIndexStorage.ts new file mode 100644 index 000000000..8f847154d --- /dev/null +++ b/test/helpers/MockIndexStorage.ts @@ -0,0 +1,62 @@ +import { vi } from 'vitest'; +import IndexStoragePort from '../../src/ports/IndexStoragePort.ts'; + +function cloneBytes(bytes: Uint8Array): Uint8Array { + return new Uint8Array(bytes); +} + +const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +/** 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'); + const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; + this.blobStore.set(oid, cloneBytes(bytes)); + return oid; + }); + + readonly readBlob = vi.fn(async (oid: string) => { + const bytes = this.blobStore.get(oid); + if (bytes === undefined) { + throw new Error(`Blob not found: ${oid}`); + } + return cloneBytes(bytes); + }); + + 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; + }); + + 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 }; + }); + + readonly updateRef = vi.fn(async (ref: string, oid: string) => { + this.refs.set(ref, oid); + }); + + readonly readRef = vi.fn(async (ref: string) => this.refs.get(ref) ?? null); +} diff --git a/test/helpers/MockStreamingIndexStorage.ts b/test/helpers/MockStreamingIndexStorage.ts deleted file mode 100644 index 4c872c1ee..000000000 --- a/test/helpers/MockStreamingIndexStorage.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { vi } from 'vitest'; -import StreamingIndexStoragePort from '../../src/ports/StreamingIndexStoragePort.ts'; -import { collectAsyncIterable, normalizeToAsyncIterable } from '../../src/domain/utils/streamUtils.ts'; - -function cloneBytes(bytes: Uint8Array): Uint8Array { - return new Uint8Array(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); -} - -function singleChunkStream(bytes: Uint8Array): AsyncIterable { - return normalizeToAsyncIterable(bytes); -} - -const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - -export default class MockStreamingIndexStorage extends StreamingIndexStoragePort { - private readonly _blobStore: Map = new Map(); - private readonly _treeStore: Map> = new Map(); - private readonly _refs: Map = new Map(); - private _blobCounter: number = 0; - private _treeCounter: number = 0; - - constructor() { - super(); - this._treeStore.set(EMPTY_TREE_OID, {}); - } - - get emptyTree(): string { - return EMPTY_TREE_OID; - } - - writeBlob = vi.fn(async (content: Uint8Array | string) => { - const oid = String(this._blobCounter++).padStart(40, '0'); - const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; - this._blobStore.set(oid, cloneBytes(bytes)); - return oid; - }); - - readBlob = vi.fn(async (oid: string) => { - const bytes = this._blobStore.get(oid); - if (bytes === undefined) { - throw new Error(`Blob not found: ${oid}`); - } - return cloneBytes(bytes); - }); - - writeBlobStream = vi.fn(async (source: AsyncIterable) => { - const bytes = await collectAsyncIterable(source); - return await this.writeBlob(bytes); - }); - - readBlobStream = vi.fn((oid: string) => { - const bytes = this._blobStore.get(oid); - if (bytes === undefined) { - throw new Error(`Blob not found: ${oid}`); - } - return singleChunkStream(cloneBytes(bytes)); - }); - - 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 parts = entry.slice(0, tabIndex).split(' '); - const blobOid = parts[2]; - if (blobOid === undefined) { - throw new Error(`Invalid tree entry: ${entry}`); - } - oidMap[path] = blobOid; - } - this._treeStore.set(oid, oidMap); - return oid; - }); - - readTreeOids = vi.fn(async (treeOid: string) => { - const tree = this._treeStore.get(treeOid); - if (tree === undefined) { - throw new Error(`Tree not found: ${treeOid}`); - } - return { ...tree }; - }); - - readTree = vi.fn(async (treeOid: string) => { - const tree = await this.readTreeOids(treeOid); - const files: Record = {}; - for (const [path, oid] of Object.entries(tree)) { - files[path] = await this.readBlob(oid); - } - return files; - }); - - updateRef = vi.fn(async (ref: string, oid: string) => { - this._refs.set(ref, oid); - }); - - readRef = vi.fn(async (ref: string) => this._refs.get(ref) ?? null); -} diff --git a/test/helpers/WarpGraphTestRepositories.ts b/test/helpers/WarpGraphTestRepositories.ts index 1e26ecd00..773a75394 100644 --- a/test/helpers/WarpGraphTestRepositories.ts +++ b/test/helpers/WarpGraphTestRepositories.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Plumbing from '@git-stunts/plumbing'; import GitTimelineHistoryAdapter from '../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; -import InMemoryGraphAdapter from '../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../test/helpers/InMemoryGraphAdapter.ts'; type TestPlumbing = Awaited>; diff --git a/src/infrastructure/adapters/inMemoryHashing.ts b/test/helpers/inMemoryHashing.ts similarity index 95% rename from src/infrastructure/adapters/inMemoryHashing.ts rename to test/helpers/inMemoryHashing.ts index 7aca07057..938844234 100644 --- a/src/infrastructure/adapters/inMemoryHashing.ts +++ b/test/helpers/inMemoryHashing.ts @@ -4,9 +4,9 @@ * Computes Git-format SHA-1 hashes for blobs, trees, and commits so that * content addresses are deterministic and debuggable against real Git. */ -import { concatBytes, hexDecode, textEncode } from '../../domain/utils/bytes.ts'; -import PersistenceError from '../../domain/errors/PersistenceError.ts'; -import WarpError from '../../domain/errors/WarpError.ts'; +import { concatBytes, hexDecode, textEncode } from '../../src/domain/utils/bytes.ts'; +import PersistenceError from '../../src/domain/errors/PersistenceError.ts'; +import WarpError from '../../src/domain/errors/WarpError.ts'; // --------------------------------------------------------------------------- // Input coercion diff --git a/test/helpers/mockHost.ts b/test/helpers/mockHost.ts index abfa0b56f..c1a1227b5 100644 --- a/test/helpers/mockHost.ts +++ b/test/helpers/mockHost.ts @@ -51,7 +51,6 @@ export interface MockHost { _patchJournal: unknown; _patchBlobStorage: unknown; _blobStorage: unknown; - _seekCache: unknown; _checkpointStore: unknown; _auditService: unknown; _auditSkipCount: number; @@ -141,7 +140,6 @@ export function createMockHost(options: MockHostOptions = {}): MockHost { _patchJournal: null, _patchBlobStorage: null, _blobStorage: null, - _seekCache: null, _checkpointStore: null, _auditService: null, _auditSkipCount: 0, diff --git a/test/type-check/v19-consumer.ts b/test/type-check/v19-consumer.ts index a53600769..e5ccee35a 100644 --- a/test/type-check/v19-consumer.ts +++ b/test/type-check/v19-consumer.ts @@ -34,9 +34,9 @@ import { type WriteReceipt, type WriteOutcome, } from '../../index.ts'; -import { MemoryStorage } from '../../storage.ts'; +import { GitStorage } from '../../storage.ts'; -const storage = MemoryStorage.create(); +const storage = await GitStorage.open({ cwd: '.' }); const publicStorage: WarpStorage = storage; const options: OpenWarpOptions = { diff --git a/test/type-check/v19-first-use.ts b/test/type-check/v19-first-use.ts index bb45d138b..20957ba78 100644 --- a/test/type-check/v19-first-use.ts +++ b/test/type-check/v19-first-use.ts @@ -6,10 +6,10 @@ */ import { intent, openWarp, reading } from '../../index.ts'; -import { MemoryStorage } from '../../storage.ts'; +import { GitStorage } from '../../storage.ts'; const warp = await openWarp({ - storage: MemoryStorage.create(), + storage: await GitStorage.open({ cwd: '.' }), writer: 'agent-1', }); const timeline = await warp.timeline('events'); diff --git a/test/type-check/v19-subpaths.ts b/test/type-check/v19-subpaths.ts index 708649317..63d2bc36b 100644 --- a/test/type-check/v19-subpaths.ts +++ b/test/type-check/v19-subpaths.ts @@ -5,7 +5,7 @@ * named expert surfaces. */ -import { GitStorage, MemoryStorage, type GitStorageOptions } from '../../storage.ts'; +import { GitStorage, type GitStorageOptions } from '../../storage.ts'; import { type Receipt, type Timeline } from '../../index.ts'; import { captureCoordinate, Coordinate, Optic, type Witness } from '../../advanced.ts'; import { @@ -17,7 +17,6 @@ import { declare const gitStorageOptions: GitStorageOptions; -const memoryStorage = MemoryStorage.create(); const gitStorage = await GitStorage.open(gitStorageOptions); declare const timeline: Timeline; const coordinate: InstanceType = await captureCoordinate(timeline); @@ -25,14 +24,13 @@ const optic: InstanceType = coordinate.optic(); const node = await optic.node('user:alice').read(); const witness: Witness = node.readIdentity; declare const receipt: Receipt; -const inspectionOptions: InspectReceiptOptions = { storage: memoryStorage }; +const inspectionOptions: InspectReceiptOptions = { storage: gitStorage }; const inspection: ReceiptInspection = inspectReceipt(receipt, inspectionOptions); const substrate: ReceiptSubstrateInspection = inspection.substrate; // @ts-expect-error diagnostics require explicit storage context. inspectReceipt(receipt); -void memoryStorage; void gitStorage; void optic; void witness; diff --git a/test/unit/CasFirstMemoization.test.ts b/test/unit/CasFirstMemoization.test.ts deleted file mode 100644 index 6dc6bf143..000000000 --- a/test/unit/CasFirstMemoization.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import CasFirstMemoizationEngine from '../../src/domain/services/materialize/CasFirstMemoizationEngine.ts'; -import type BlobStoragePort from '../../src/ports/BlobStoragePort.ts'; -import type CryptoPort from '../../src/ports/CryptoPort.ts'; -import type CodecPort from '../../src/ports/CodecPort.ts'; - -describe('CasFirstMemoizationEngine', () => { - const dummyCrypto: CryptoPort = { - hash: vi.fn(async (_algorithm: string, data: string | Uint8Array) => `hash:${data as string}`), - hmac: vi.fn(), - timingSafeEqual: vi.fn(), - }; - - const dummyCodec: CodecPort = { - encode: vi.fn(), - decode: vi.fn(), - }; - - it('2.1. Is object already in git-cas? (Hit -> bypasses materialization)', async () => { - const mockBlobStorage: BlobStoragePort = { - store: vi.fn(), - retrieve: vi.fn(async () => new Uint8Array([1, 2, 3])), - storeStream: vi.fn(), - retrieveStream: vi.fn(), - has: vi.fn(async () => true), - }; - - const engine = new CasFirstMemoizationEngine({ - blobStorage: mockBlobStorage, - crypto: dummyCrypto, - codec: dummyCodec, - }); - - const materializeStream = vi.fn(async function* () { - yield new Uint8Array([9, 9, 9]); - }); - - const decodeObject = vi.fn((buf: Uint8Array) => ({ data: Array.from(buf) })); - - const result = await engine.materialize({ - coordinateKeyParams: 'frontier-1:optic-lens-abc', - materializeStream, - decodeObject, - }); - - expect(mockBlobStorage.has).toHaveBeenCalledWith('hash:frontier-1:optic-lens-abc'); - expect(mockBlobStorage.retrieve).toHaveBeenCalledWith('hash:frontier-1:optic-lens-abc'); - expect(materializeStream).not.toHaveBeenCalled(); - expect(result.hit).toBe(true); - expect(result.object).toEqual({ data: [1, 2, 3] }); - expect(result.casTreeOid).toBe('hash:frontier-1:optic-lens-abc'); - }); - - it('2.2 & 2.3. No? Materialize via streaming and write materialized git-object to git-cas always', async () => { - const mockBlobStorage: BlobStoragePort = { - store: vi.fn(), - retrieve: vi.fn(), - storeStream: vi.fn(async (source: AsyncIterable) => { - // Consume stream to simulate storage - let count = 0; - for await (const _chunk of source) { - count += 1; - } - void count; - return 'tree-oid-new-123'; - }), - retrieveStream: vi.fn(), - has: vi.fn(async () => false), - }; - - const engine = new CasFirstMemoizationEngine({ - blobStorage: mockBlobStorage, - crypto: dummyCrypto, - codec: dummyCodec, - }); - - const materializeStream = vi.fn(async function* () { - yield new Uint8Array([10, 20]); - yield new Uint8Array([30, 40]); - }); - - const decodeObject = vi.fn((buf: Uint8Array) => ({ data: Array.from(buf) })); - - const result = await engine.materialize({ - coordinateKeyParams: 'frontier-2:optic-lens-xyz', - materializeStream, - decodeObject, - }); - - expect(mockBlobStorage.has).toHaveBeenCalledWith('hash:frontier-2:optic-lens-xyz'); - expect(materializeStream).toHaveBeenCalled(); - expect(mockBlobStorage.storeStream).toHaveBeenCalled(); - expect(result.hit).toBe(false); - expect(result.object).toEqual({ data: [10, 20, 30, 40] }); - expect(result.casTreeOid).toBe('tree-oid-new-123'); - }); - - it('handles fallback when has() is not implemented on older BlobStoragePort adapters', async () => { - const mockBlobStorage: BlobStoragePort = { - store: vi.fn(), - retrieve: vi.fn(async () => { - throw new Error('Not found'); - }), - storeStream: vi.fn(async (source: AsyncIterable) => { - let count = 0; - for await (const _chunk of source) { - count += 1; - } - void count; - return 'tree-oid-fallback'; - }), - retrieveStream: vi.fn(), - // has is omitted - }; - - const engine = new CasFirstMemoizationEngine({ - blobStorage: mockBlobStorage, - crypto: dummyCrypto, - codec: dummyCodec, - }); - - const materializeStream = vi.fn(async function* () { - yield new Uint8Array([5, 5]); - }); - - const decodeObject = vi.fn((buf: Uint8Array) => ({ data: Array.from(buf) })); - - const result = await engine.materialize({ - coordinateKeyParams: 'frontier-3:optic-lens-def', - materializeStream, - decodeObject, - }); - - expect(materializeStream).toHaveBeenCalled(); - expect(result.hit).toBe(false); - expect(result.object).toEqual({ data: [5, 5] }); - expect(result.casTreeOid).toBe('tree-oid-fallback'); - }); -}); diff --git a/test/unit/application/WarpStorageRegistry.test.ts b/test/unit/application/WarpStorageRegistry.test.ts index 9be953462..a59800c5b 100644 --- a/test/unit/application/WarpStorageRegistry.test.ts +++ b/test/unit/application/WarpStorageRegistry.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { openWarp } from '../../../index.ts'; -import { MemoryStorage } from '../../../storage.ts'; +import MemoryStorage from '../../helpers/MemoryStorage.ts'; import WarpStorage from '../../../src/application/WarpStorage.ts'; import { bindWarpStorage, diff --git a/test/unit/cli/doctor.test.ts b/test/unit/cli/doctor.test.ts index 18687badb..be77419f0 100644 --- a/test/unit/cli/doctor.test.ts +++ b/test/unit/cli/doctor.test.ts @@ -16,18 +16,6 @@ vi.mock('../../../bin/cli/shared.ts', () => ({ createHookInstaller: vi.fn(), })); -// Mock HealthCheckService -vi.mock('../../../src/domain/services/HealthCheckService.ts', () => ({ - default: vi.fn().mockImplementation(function () { - return { - getHealth: vi.fn().mockResolvedValue({ - status: 'healthy', - components: { repository: { status: 'healthy', latencyMs: 1 } }, - }), - }; - }), -})); - // Mock ClockAdapter vi.mock('../../../src/infrastructure/adapters/ClockAdapter.ts', () => ({ default: { global: vi.fn().mockReturnValue({}) }, diff --git a/test/unit/cli/schemas.test.ts b/test/unit/cli/schemas.test.ts index 51a3bcebf..64f7fff64 100644 --- a/test/unit/cli/schemas.test.ts +++ b/test/unit/cli/schemas.test.ts @@ -240,11 +240,6 @@ describe('seekSchema', () => { expect(result.name).toBe('old'); }); - it('parses --clear-cache', () => { - const result = seekSchema.parse({ 'clear-cache': true }); - expect(result.action).toBe('clear-cache'); - }); - it('parses --diff with --tick', () => { const result = seekSchema.parse({ tick: '3', diff: true }); expect(result.action).toBe('tick'); @@ -256,9 +251,9 @@ describe('seekSchema', () => { expect(result.diffLimit).toBe(100); }); - it('parses --no-persistent-cache', () => { - const result = seekSchema.parse({ 'no-persistent-cache': true }); - expect(result.noPersistentCache).toBe(true); + it('rejects removed WARP-owned cache flags', () => { + expect(() => seekSchema.parse({ 'clear-cache': true })).toThrow(); + expect(() => seekSchema.parse({ 'no-persistent-cache': true })).toThrow(); }); it('rejects multiple action flags', () => { diff --git a/test/unit/domain/ReceiptDiagnostics.test.ts b/test/unit/domain/ReceiptDiagnostics.test.ts index 9e07c17de..d2d566c0c 100644 --- a/test/unit/domain/ReceiptDiagnostics.test.ts +++ b/test/unit/domain/ReceiptDiagnostics.test.ts @@ -7,7 +7,7 @@ import { intent } from '../../../src/domain/api/IntentBuilders.ts'; import { reading } from '../../../src/domain/api/ReadingBuilders.ts'; import WriteReceipt from '../../../src/domain/api/WriteReceipt.ts'; import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; -import { MemoryStorage } from '../../../storage.ts'; +import MemoryStorage from '../../helpers/MemoryStorage.ts'; import { createBoundedReadBasis } from '../../helpers/BoundedReadBasis.ts'; describe('receipt diagnostics', () => { diff --git a/test/unit/domain/WarpApp.facade.test.ts b/test/unit/domain/WarpApp.facade.test.ts index 6c053149c..f0f37101d 100644 --- a/test/unit/domain/WarpApp.facade.test.ts +++ b/test/unit/domain/WarpApp.facade.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import WarpApp from '../../../src/domain/WarpApp.ts'; import WarpCore from '../../../src/domain/WarpCore.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryWarpApp } from '../../helpers/MemoryRuntimeHost.ts'; describe('WarpApp facade', () => { diff --git a/test/unit/domain/WarpCore.apiSurface.test.ts b/test/unit/domain/WarpCore.apiSurface.test.ts index 1703aa0ef..61de9aca6 100644 --- a/test/unit/domain/WarpCore.apiSurface.test.ts +++ b/test/unit/domain/WarpCore.apiSurface.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import WarpCore from '../../../src/domain/WarpCore.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryWarpCore } from '../../helpers/MemoryRuntimeHost.ts'; describe('WarpCore API surface', () => { diff --git a/test/unit/domain/WarpCore.blobAutoConstruct.test.ts b/test/unit/domain/WarpCore.blobAutoConstruct.test.ts index da0f0c772..134cea9a4 100644 --- a/test/unit/domain/WarpCore.blobAutoConstruct.test.ts +++ b/test/unit/domain/WarpCore.blobAutoConstruct.test.ts @@ -1,7 +1,7 @@ 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 '../../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../../test/helpers/MemoryRuntimeStorageAdapter.ts'; /** * Spec tests for runtime content storage composition. diff --git a/test/unit/domain/WarpCore.effectPipeline.test.ts b/test/unit/domain/WarpCore.effectPipeline.test.ts index 441ffde02..1d7303956 100644 --- a/test/unit/domain/WarpCore.effectPipeline.test.ts +++ b/test/unit/domain/WarpCore.effectPipeline.test.ts @@ -4,7 +4,7 @@ import type WarpCore from '../../../src/domain/WarpCore.ts'; import { EffectPipeline } from '../../../src/domain/services/EffectPipeline.ts'; import { MultiplexSink } from '../../../src/domain/services/MultiplexSink.ts'; import { LIVE_LENS, REPLAY_LENS } from '../../../src/domain/types/ExternalizationPolicy.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { NoOpEffectSink } from '../../../src/infrastructure/adapters/NoOpEffectSink.ts'; async function openCore(extra = {}): Promise { diff --git a/test/unit/domain/WarpCore.emit.test.ts b/test/unit/domain/WarpCore.emit.test.ts index 6bf7efc58..f23f47adc 100644 --- a/test/unit/domain/WarpCore.emit.test.ts +++ b/test/unit/domain/WarpCore.emit.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { openMemoryWarpCore } from '../../helpers/MemoryRuntimeHost.ts'; import type WarpCore from '../../../src/domain/WarpCore.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { EFFECT_NODE_PREFIX } from '../../../src/domain/services/KeyCodec.ts'; type WarpCoreWired = Awaited>; diff --git a/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts b/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts index d17169c5d..d235bf1b3 100644 --- a/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts +++ b/test/unit/domain/WarpCore.stateSessionAutoConstruct.test.ts @@ -4,7 +4,7 @@ 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 "../../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts"; +import MemoryRuntimeStorageAdapter from "../../../test/helpers/MemoryRuntimeStorageAdapter.ts"; import type RuntimeStorageProviderPort from "../../../src/ports/RuntimeStorageProviderPort.ts"; import type { RuntimeStorageRequest } from "../../../src/ports/RuntimeStorageProviderPort.ts"; import WarpStateCachePort, { diff --git a/test/unit/domain/WarpFacade.test.ts b/test/unit/domain/WarpFacade.test.ts index 88c1364dd..bfb6046a4 100644 --- a/test/unit/domain/WarpFacade.test.ts +++ b/test/unit/domain/WarpFacade.test.ts @@ -15,7 +15,7 @@ import { requireTimelineRuntime } from '../../../src/domain/api/TimelineRuntime. import { requireTickCoordinate } from '../../../src/domain/api/TickRuntime.ts'; import Warp from '../../../src/domain/api/Warp.ts'; import { MAX_WRITER_ID_LENGTH } from '../../../src/domain/utils/RefLayout.ts'; -import { MemoryStorage } from '../../../storage.ts'; +import MemoryStorage from '../../helpers/MemoryStorage.ts'; import { createBoundedReadBasis } from '../../helpers/BoundedReadBasis.ts'; const FORBIDDEN_ROOT_SUBSTRATE_EXPORTS = Object.freeze([ diff --git a/test/unit/domain/WarpGraph.audit.test.ts b/test/unit/domain/WarpGraph.audit.test.ts index 9f3742e75..2dbc56bc6 100644 --- a/test/unit/domain/WarpGraph.audit.test.ts +++ b/test/unit/domain/WarpGraph.audit.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi } from 'vitest'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; describe('WarpCore — audit mode', () => { it('rejects audit: "yes" (non-boolean truthy)', async () => { diff --git a/test/unit/domain/WarpGraph.content.test.ts b/test/unit/domain/WarpGraph.content.test.ts index aa2a240f9..c83ebb230 100644 --- a/test/unit/domain/WarpGraph.content.test.ts +++ b/test/unit/domain/WarpGraph.content.test.ts @@ -227,9 +227,8 @@ describe('WarpCore content attachment (query methods)', () => { }); it('uses auto-constructed blobStorage when none explicitly provided', async () => { - // OG-014: blob storage is always present (auto-constructed) - // The auto-constructed InMemoryBlobStorageAdapter won't have this OID, - // so we inject a mock to verify the read path goes through blobStorage + // Runtime storage supplies the content port. Inject a mock here to + // verify that reads stay behind that port. const rawBuf = new TextEncoder().encode('raw blob'); const blobStorage = { store: vi.fn(), diff --git a/test/unit/domain/WarpGraph.coverageGaps.test.ts b/test/unit/domain/WarpGraph.coverageGaps.test.ts index db8d58768..38091aed6 100644 --- a/test/unit/domain/WarpGraph.coverageGaps.test.ts +++ b/test/unit/domain/WarpGraph.coverageGaps.test.ts @@ -75,78 +75,6 @@ describe('WarpCore coverage gaps', () => { persistence = createMockPersistence(); }); - // -------------------------------------------------------------------------- - // 1. seekCache getter - // -------------------------------------------------------------------------- - describe('get seekCache', () => { - it('returns null when no seek cache is set', async () => { - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test-graph', - writerId: 'writer-1', - crypto, - }); - - expect(graph.seekCache).toBeNull(); - }); - - it('returns the seek cache passed at construction', async () => { - const mockCache = ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }) as any; - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test-graph', - writerId: 'writer-1', - crypto, - seekCache: mockCache, - }); - - expect(graph.seekCache).toBe(mockCache); - }); - }); - - // -------------------------------------------------------------------------- - // 2. setSeekCache() - // -------------------------------------------------------------------------- - describe('setSeekCache', () => { - it('sets the seek cache after construction', async () => { - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test-graph', - writerId: 'writer-1', - crypto, - }); - - expect(graph.seekCache).toBeNull(); - - const mockCache = ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }) as any; - graph.setSeekCache(mockCache); - - expect(graph.seekCache).toBe(mockCache); - }); - - it('replaces an existing seek cache', async () => { - const cache1 = ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }) as any; - const cache2 = ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }) as any; - - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test-graph', - writerId: 'writer-1', - crypto, - seekCache: cache1, - }); - - expect(graph.seekCache).toBe(cache1); - - graph.setSeekCache(cache2); - - expect(graph.seekCache).toBe(cache2); - }); - }); - - // -------------------------------------------------------------------------- - // 3. join() - // -------------------------------------------------------------------------- describe('join', () => { it('throws E_NO_STATE when no cached state exists', async () => { const graph = await openRuntimeHostProduct({ diff --git a/test/unit/domain/WarpWorldline.test.ts b/test/unit/domain/WarpWorldline.test.ts index 173b82a63..2d1d36342 100644 --- a/test/unit/domain/WarpWorldline.test.ts +++ b/test/unit/domain/WarpWorldline.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import WarpWorldline, { type WarpWorldlinePatchBuild } from '../../../src/domain/WarpWorldline.ts'; import { openMemoryWarpWorldline as openWarpWorldline } from '../../helpers/MemoryRuntimeHost.ts'; import ProjectionHandle from '../../../src/domain/services/ProjectionHandle.ts'; diff --git a/test/unit/domain/internalReadingSurface.behavior.test.ts b/test/unit/domain/internalReadingSurface.behavior.test.ts index c6208efc6..4da998df5 100644 --- a/test/unit/domain/internalReadingSurface.behavior.test.ts +++ b/test/unit/domain/internalReadingSurface.behavior.test.ts @@ -8,7 +8,7 @@ import { openMemoryWarpGraph as openWarpGraph, openMemoryWarpWorldline as openWarpWorldline, } from '../../helpers/MemoryRuntimeHost.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; function openOptions( graphName: string, diff --git a/test/unit/domain/runtimeProductExecutableSurface.test.ts b/test/unit/domain/runtimeProductExecutableSurface.test.ts index 4c7a10eab..0118d54a6 100644 --- a/test/unit/domain/runtimeProductExecutableSurface.test.ts +++ b/test/unit/domain/runtimeProductExecutableSurface.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; +import { buildWarpCoreRuntimeSurface } from '../../../src/domain/warp/WarpCoreRuntimeProduct.ts'; + +import type { WarpIntentDescriptor } from '../../../src/domain/types/WarpIntentDescriptor.ts'; const HEX_OBJECT_ID = /^[0-9a-f]{40}$/u; @@ -31,7 +34,7 @@ describe('runtime product executable surface', () => { writerId: 'agent-1', }); - await runtime.patch((patch) => { + const patchSha = await runtime.patch((patch) => { patch .addNode('node:visible') .setProperty('node:visible', 'kind', 'surface') @@ -45,13 +48,56 @@ describe('runtime product executable surface', () => { match: 'node:*', expose: ['kind'], }); + const verification = runtime.verifyIndex({ seed: 7, sampleRate: 1 }); + const checkpointState = await runtime.materializeAt(checkpointSha); + const cachedState = runtime._cachedState; + if (cachedState === null) { + throw new RuntimeProductExecutableSurfaceTestError('materializeAt did not retain state'); + } + const rebuiltGraph = await Reflect.apply( + requireRuntimeMethod(runtime, '_materializedGraphFromCachedState'), + runtime, + [], + ); + const reusedGraph = await runtime._materializeGraph(); + const coreSurface = buildWarpCoreRuntimeSurface(runtime); await expect(runtime.hasNode('node:visible')).resolves.toBe(true); await expect(observer.getNodeProps('node:visible')).resolves.toEqual({ kind: 'surface', }); expect(checkpointSha).toMatch(HEX_OBJECT_ID); + expect(patchSha).toMatch(HEX_OBJECT_ID); expect(requireFrontierEntry(frontier, 'agent-1')).toMatch(HEX_OBJECT_ID); + expect(verification.failed).toBe(0); + expect(checkpointState.nodeAlive.contains('node:visible')).toBe(true); + expect(rebuiltGraph).toBe(reusedGraph); + expect(coreSurface.persistence).toBe(runtime.persistence); + expect(coreSurface.onDeleteWithData).toBe(runtime.onDeleteWithData); + expect(coreSurface.gcPolicy).toBe(runtime.gcPolicy); + + const frontierEquals = requireRuntimeMethod(runtime, '_frontierEquals'); + expect(Reflect.apply(frontierEquals, runtime, [ + cachedState.observedFrontier, + cachedState.observedFrontier.clone(), + ])).toBe(true); + await expect( + Reflect.apply(requireRuntimeMethod(runtime, '_hasSchema1Patches'), runtime, []), + ).resolves.toBe(false); + await expect( + Reflect.apply(requireRuntimeMethod(runtime, '_computeBackwardCone'), runtime, ['node:visible']), + ).resolves.toBeInstanceOf(Map); + await expect( + Reflect.apply(requireRuntimeMethod(runtime, '_loadPatchBySha'), runtime, [patchSha]), + ).resolves.toMatchObject({ writer: 'agent-1' }); + await expect( + Reflect.apply(requireRuntimeMethod(runtime, '_loadPatchesBySha'), runtime, [[patchSha]]), + ).resolves.toEqual([ + expect.objectContaining({ sha: patchSha }), + ]); + await expect( + Reflect.apply(requireRuntimeMethod(runtime, '_nextLamport'), runtime, []), + ).resolves.toMatchObject({ lamport: expect.any(Number) }); }); it('invalidates index metadata and fails closed without cached state', async () => { @@ -67,8 +113,49 @@ describe('runtime product executable surface', () => { expect(Reflect.get(runtime, '_cachedIndexTree')).toBeNull(); expect(Reflect.get(runtime, '_cachedViewHash')).toBeNull(); + expect(() => runtime.verifyIndex()).toThrow('Cannot verify index'); await expect( Reflect.apply(requireRuntimeMethod(runtime, '_materializedGraphFromCachedState'), runtime, []), ).rejects.toMatchObject({ code: 'E_NO_STATE' }); }); + + it('keeps retained intent, comparison, sync, and GC capabilities executable', async () => { + const runtime = await openRuntimeHostProduct({ + persistence: new InMemoryGraphAdapter(), + graphName: 'runtime-retained-capabilities', + writerId: 'agent-1', + }); + const descriptor = { + intentId: 'assign-alice', + nutritionLabel: { + bundleHash: 'bundle', + coreHash: 'core', + profile: 'default', + budget: 'bounded', + }, + precommitGuards: [], + suffixTransform: { + op: 'property.set', + payload: { subject: 'user:alice', key: 'role', value: 'admin' }, + }, + } satisfies WarpIntentDescriptor; + + await expect(runtime.admitIntent(descriptor)).resolves.toMatchObject({ + admitted: true, + intentId: 'assign-alice', + }); + await expect(runtime.queueIntent('draft:admin', descriptor)).resolves.toMatchObject({ + admitted: true, + intentId: 'assign-alice', + }); + await expect(runtime.getWriterIntents('draft:admin')).resolves.toEqual([descriptor]); + expect(runtime.buildPatchDivergence([], [], null)).toMatchObject({}); + await expect(runtime.createSyncRequest()).resolves.toMatchObject({ type: 'sync-request' }); + + await runtime.materialize(); + expect(runtime.runGC()).toMatchObject({ + nodesCompacted: 0, + edgesCompacted: 0, + }); + }); }); diff --git a/test/unit/domain/seekCache.test.ts b/test/unit/domain/seekCache.test.ts deleted file mode 100644 index 8e72b81d5..000000000 --- a/test/unit/domain/seekCache.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; -import { buildSeekCacheKey } from '../../../src/domain/utils/seekCacheKey.ts'; -import { encode } from '../../../src/infrastructure/codecs/CborCodec.ts'; -import { encodePatchMessage } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; -import { createMockPersistence } from '../../helpers/warpGraphTestUtils.ts'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** @param {string} writer @param {number} lamport @param {string} nodeId */ -function createPatch(writer, lamport, nodeId) { - return { - schema: 2, - writer, - lamport, - context: { [writer]: lamport }, - ops: [{ type: 'NodeAdd', node: nodeId, dot: { writerId: writer, counter: lamport } }], - }; -} - -/** @param {string} label */ -function fakeSha(label) { - const hex = Buffer.from(String(label)).toString('hex'); - return hex.padEnd(40, 'a').slice(0, 40); -} - -/** @param {any} persistence @param {any} writerSpecs @param {string} [graphName] */ -function setupPersistence(persistence, writerSpecs, graphName = 'test') { - const nodeInfoMap = new Map(); - const blobMap = new Map(); - const writerTips = ({}) as Record; - - for (const [writer, count] of Object.entries(writerSpecs)) { - const shas: string[] = []; - for (let i = 1; i <= (count as any); i++) { - shas.push(fakeSha(`${writer}${i}`)); - } - writerTips[writer] = shas[0] ?? ''; - - for (let j = 0; j < (count as any); j++) { - const lamport = (count as any) - j; - const patchOid = fakeSha(`blob-${writer}-${lamport}`); - const message = encodePatchMessage({ - graph: graphName, - writer, - lamport, - patchOid, - schema: 2, - }); - const parents = j < (count as any) - 1 ? [shas[j + 1]] : []; - nodeInfoMap.set(shas[j], { message, parents }); - const patch = createPatch(writer, lamport, `n:${writer}:${lamport}`); - blobMap.set(patchOid, encode(patch)); - } - } - - const writerRefs = Object.keys(writerSpecs).map( - (w) => `refs/warp/${graphName}/writers/${w}` - ); - - persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { - const info = nodeInfoMap.get(sha); - if (info) { - return Promise.resolve(info); - } - return Promise.resolve({ message: '', parents: [] }); - }); - - persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { - const buf = blobMap.get(oid); - if (buf) { - return Promise.resolve(buf); - } - return Promise.resolve(Buffer.alloc(0)); - }); - - persistence.readRef.mockImplementation((/** @type {any} */ ref) => { - if (ref === `refs/warp/${graphName}/checkpoints/head`) { - return Promise.resolve(null); - } - for (const [writer, tip] of Object.entries(writerTips)) { - if (ref === `refs/warp/${graphName}/writers/${writer}`) { - return Promise.resolve(tip); - } - } - return Promise.resolve(null); - }); - - persistence.listRefs.mockImplementation((/** @type {any} */ prefix) => { - if (prefix.startsWith(`refs/warp/${graphName}/writers`)) { - return Promise.resolve(writerRefs); - } - return Promise.resolve([]); - }); - - return writerTips; -} - -/** - * Creates an in-memory SeekCachePort mock. - * - * Stores entries as `{ buffer, indexTreeOid? }` objects matching the - * updated SeekCachePort contract. - */ -function createMockSeekCache() { - /** @type {Map} */ - const store = new Map(); - return { - get: vi.fn(async (/** @type {string} */ key) => store.get(key) ?? null), - set: vi.fn(async (/** @type {string} */ key, /** @type {Buffer} */ buf, /** @type {{ indexTreeOid?: string }} */ opts) => { - /** @type {{ buffer: Buffer, indexTreeOid?: string }} */ - const entry = { buffer: buf }; - if (opts?.indexTreeOid) { - (entry as any).indexTreeOid = opts.indexTreeOid; - } - store.set(key, entry); - }), - has: vi.fn(async (/** @type {string} */ key) => store.has(key)), - keys: vi.fn(async () => [...store.keys()]), - delete: vi.fn(async (/** @type {string} */ key) => store.delete(key)), - clear: vi.fn(async () => { store.clear(); }), - _store: store, - }; -} - -// =========================================================================== -// seekCacheKey utility -// =========================================================================== - -describe('buildSeekCacheKey', () => { - async function buildTestSeekCacheKey( - ceiling: number, - frontier: Map, - ): Promise { - return await buildSeekCacheKey(ceiling, frontier, { crypto: defaultCrypto }); - } - - it('produces deterministic keys for identical inputs', async () => { - const frontier = new Map([['alice', 'aaa'], ['bob', 'bbb']]); - const k1 = await buildTestSeekCacheKey(5, frontier); - const k2 = await buildTestSeekCacheKey(5, frontier); - expect(k1).toBe(k2); - }); - - it('starts with version prefix', async () => { - const key = await buildTestSeekCacheKey(10, new Map([['w1', 'sha1']])); - expect(key).toMatch(/^v1:t10-/); - }); - - it('uses full 64-char SHA-256 hex digest', async () => { - const key = await buildTestSeekCacheKey(1, new Map([['w', 's']])); - // v1:t1-<64 hex chars> - const hash = key.split('-').slice(1).join('-'); - expect(hash).toHaveLength(64); - expect(hash).toMatch(/^[0-9a-f]{64}$/); - }); - - it('differs when ceiling changes', async () => { - const f = new Map([['w', 'sha']]); - expect(await buildTestSeekCacheKey(1, f)).not.toBe(await buildTestSeekCacheKey(2, f)); - }); - - it('differs when frontier changes', async () => { - const f1 = new Map([['w', 'sha1']]); - const f2 = new Map([['w', 'sha2']]); - expect(await buildTestSeekCacheKey(1, f1)).not.toBe(await buildTestSeekCacheKey(1, f2)); - }); - - it('is order-independent for frontier entries', async () => { - const f1 = new Map([['alice', 'a'], ['bob', 'b']]); - const f2 = new Map([['bob', 'b'], ['alice', 'a']]); - expect(await buildTestSeekCacheKey(1, f1)).toBe(await buildTestSeekCacheKey(1, f2)); - }); -}); - -// =========================================================================== -// WarpCore seek cache compatibility surface -// =========================================================================== - -describe('WarpCore seek cache compatibility surface', () => { - let persistence; - let seekCache; - - beforeEach(() => { - persistence = createMockPersistence(); - persistence.writeBlob.mockResolvedValue('mock-blob-oid'); - persistence.writeTree.mockResolvedValue('mock-tree-oid'); - seekCache = createMockSeekCache(); - }); - - it('accepts a seekCache option without consulting it during materialization', async () => { - setupPersistence(persistence, { w1: 3 }); - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test', - writerId: 'w1', - seekCache, - }); - - const result = await graph.materialize({ ceiling: 2 }); - - expect(result).toBeDefined(); - expect(graph.seekCache).toBe(seekCache); - expect(seekCache.get).not.toHaveBeenCalled(); - expect(seekCache.set).not.toHaveBeenCalled(); - }); - - it('works without seekCache', async () => { - setupPersistence(persistence, { w1: 3 }); - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test', - writerId: 'w1', - }); - - const result = await graph.materialize({ ceiling: 2 }); - - expect(result).toBeDefined(); - expect(graph.seekCache).toBeNull(); - }); - - it('setSeekCache(null) detaches the compatibility surface', async () => { - setupPersistence(persistence, { w1: 3 }); - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'test', - writerId: 'w1', - seekCache, - }); - - expect(graph.seekCache).toBe(seekCache); - graph.setSeekCache((null as any)); - expect(graph.seekCache).toBeNull(); - - const result = await graph.materialize({ ceiling: 2 }); - - expect(result).toBeDefined(); - expect(seekCache.get).not.toHaveBeenCalled(); - expect(seekCache.set).not.toHaveBeenCalled(); - }); -}); diff --git a/test/unit/domain/services/AuditReceiptService.coverage.test.ts b/test/unit/domain/services/AuditReceiptService.coverage.test.ts index 1c102b9e8..12fb18c51 100644 --- a/test/unit/domain/services/AuditReceiptService.coverage.test.ts +++ b/test/unit/domain/services/AuditReceiptService.coverage.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { createHash } from 'node:crypto'; import { AuditReceiptService } from '../../../../src/domain/services/audit/AuditReceiptService.ts'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; const testCrypto = { diff --git a/test/unit/domain/services/AuditReceiptService.test.ts b/test/unit/domain/services/AuditReceiptService.test.ts index caa895b77..eba486588 100644 --- a/test/unit/domain/services/AuditReceiptService.test.ts +++ b/test/unit/domain/services/AuditReceiptService.test.ts @@ -18,7 +18,7 @@ import { import { encode as cborEncode, } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; // ── Test crypto adapter ────────────────────────────────────────────────── diff --git a/test/unit/domain/services/AuditVerifierService.bench.ts b/test/unit/domain/services/AuditVerifierService.bench.ts index b7b895999..555d168cd 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 '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.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'; diff --git a/test/unit/domain/services/AuditVerifierService.test.ts b/test/unit/domain/services/AuditVerifierService.test.ts index a84cfaa4b..d7df72122 100644 --- a/test/unit/domain/services/AuditVerifierService.test.ts +++ b/test/unit/domain/services/AuditVerifierService.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createHash } from 'node:crypto'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.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'; diff --git a/test/unit/domain/services/BitmapIndexReader.chunked.test.ts b/test/unit/domain/services/BitmapIndexReader.chunked.test.ts index c2dbe9851..df481ae2c 100644 --- a/test/unit/domain/services/BitmapIndexReader.chunked.test.ts +++ b/test/unit/domain/services/BitmapIndexReader.chunked.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import BitmapIndexReader from '../../../../src/domain/services/index/BitmapIndexReader.ts'; -import MockStreamingIndexStorage from '../../../helpers/MockStreamingIndexStorage.ts'; +import MockIndexStorage from '../../../helpers/MockIndexStorage.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import { getRoaringBitmap32 } from '../../../../src/domain/utils/roaring.ts'; @@ -15,7 +15,7 @@ function encodeBitmap(ids: number[]): Uint8Array { describe('BitmapIndexReader chunked shard support', () => { it('looks up IDs and unions edge bitmaps across chunked shard paths', async () => { - const storage = new MockStreamingIndexStorage(); + const storage = new MockIndexStorage(); const metaAaChunk0 = await storage.writeBlob(defaultCodec.encode({ aa0001: 0 })); const metaBbChunk0 = await storage.writeBlob(defaultCodec.encode({ bb0001: 1 })); const metaBbChunk1 = await storage.writeBlob(defaultCodec.encode({ bb0002: 2 })); diff --git a/test/unit/domain/services/HealthCheckService.test.ts b/test/unit/domain/services/HealthCheckService.test.ts deleted file mode 100644 index 8f1ec8c8a..000000000 --- a/test/unit/domain/services/HealthCheckService.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import HealthCheckService, { HealthStatus } from '../../../../src/domain/services/HealthCheckService.ts'; - -describe('HealthCheckService', () => { - let service: HealthCheckService; - let mockPersistence: { ping: ReturnType }; - let mockIndexReader: { shardOids: Map }; - let mockLogger: Record>; - - beforeEach(() => { - mockPersistence = { - ping: vi.fn().mockResolvedValue({ ok: true, latencyMs: 1.5 }), - }; - - mockIndexReader = { - shardOids: new Map([ - ['meta_00.json', 'oid1'], - ['meta_01.json', 'oid2'], - ['shards_fwd_00.json', 'oid3'], - ]), - }; - - mockLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - child: vi.fn().mockReturnThis(), - }; - - service = new HealthCheckService(({ - persistence: mockPersistence, - cacheTtlTicks: 100, - logger: mockLogger, - }) as unknown as ConstructorParameters[0]); - }); - - describe('constructor', () => { - it('accepts persistence and optional parameters', () => { - const s = new HealthCheckService(({ persistence: mockPersistence }) as unknown as ConstructorParameters[0]); - expect(s).toBeDefined(); - }); - - it('uses default cache TTL of 50 ticks', async () => { - const s = new HealthCheckService(({ persistence: mockPersistence }) as unknown as ConstructorParameters[0]); - await s.getHealth(10); - - // Call again at same tick — should be cached - mockPersistence.ping.mockClear(); - await s.getHealth(10); - expect(mockPersistence.ping).not.toHaveBeenCalled(); - - // Advance past default TTL (50 ticks) - await s.getHealth(61); - expect(mockPersistence.ping).toHaveBeenCalled(); - }); - - it('allows custom cache TTL in ticks', async () => { - const s = new HealthCheckService(({ - persistence: mockPersistence, - cacheTtlTicks: 20, - }) as unknown as ConstructorParameters[0]); - await s.getHealth(10); - - // Advance 25 ticks — should expire - mockPersistence.ping.mockClear(); - await s.getHealth(35); - expect(mockPersistence.ping).toHaveBeenCalled(); - }); - }); - - describe('getHealth() - healthy scenario', () => { - it('returns healthy status when repository and index are working', async () => { - service.setIndexReader(mockIndexReader); - - const health = await service.getHealth(1); - - expect(health.status).toBe(HealthStatus.HEALTHY); - expect(health.components.repository.status).toBe(HealthStatus.HEALTHY); - expect(health.components.repository.latencyMs).toBeCloseTo(1.5, 1); - expect(health.components.index.status).toBe(HealthStatus.HEALTHY); - expect(health.components.index.loaded).toBe(true); - expect(health.components.index.shardCount).toBe(3); - }); - - it('includes shard count from index reader', async () => { - const readerWith5Shards = { - shardOids: new Map([ - ['meta_00.json', 'a'], - ['meta_01.json', 'b'], - ['meta_02.json', 'c'], - ['shards_fwd_00.json', 'd'], - ['shards_rev_00.json', 'e'], - ]), - }; - service.setIndexReader(readerWith5Shards); - - const health = await service.getHealth(1); - - expect(health.components.index.shardCount).toBe(5); - }); - }); - - describe('getHealth() - degraded scenario', () => { - it('returns degraded status when index is not loaded', async () => { - const health = await service.getHealth(1); - - expect(health.status).toBe(HealthStatus.DEGRADED); - expect(health.components.repository.status).toBe(HealthStatus.HEALTHY); - expect(health.components.index.status).toBe(HealthStatus.DEGRADED); - expect(health.components.index.loaded).toBe(false); - expect(health.components.index.shardCount).toBeUndefined(); - }); - - it('clears index when setIndexReader is called with null', async () => { - service.setIndexReader(mockIndexReader); - let health = await service.getHealth(1); - expect(health.components.index.loaded).toBe(true); - - service.setIndexReader(null); - health = await service.getHealth(2); - - expect(health.status).toBe(HealthStatus.DEGRADED); - expect(health.components.index.loaded).toBe(false); - }); - }); - - describe('getHealth() - unhealthy scenario', () => { - it('returns unhealthy status when repository ping fails', async () => { - mockPersistence.ping.mockResolvedValue({ ok: false, latencyMs: 50 }); - - const health = await service.getHealth(1); - - expect(health.status).toBe(HealthStatus.UNHEALTHY); - expect(health.components.repository.status).toBe(HealthStatus.UNHEALTHY); - expect(health.components.repository.latencyMs).toBe(50); - }); - - it('returns unhealthy status when ping throws an error', async () => { - mockPersistence.ping.mockRejectedValue(new Error('Connection refused')); - - const health = await service.getHealth(1); - - expect(health.status).toBe(HealthStatus.UNHEALTHY); - expect(health.components.repository.status).toBe(HealthStatus.UNHEALTHY); - expect(health.components.repository.latencyMs).toBe(0); - expect(mockLogger['warn']).toHaveBeenCalledWith( - 'Repository ping failed', - expect.objectContaining({ error: 'Connection refused' }), - ); - }); - - it('unhealthy repository takes precedence over degraded index', async () => { - mockPersistence.ping.mockResolvedValue({ ok: false, latencyMs: 100 }); - - const health = await service.getHealth(1); - - expect(health.status).toBe(HealthStatus.UNHEALTHY); - }); - }); - - describe('caching behavior', () => { - it('caches health results for tick TTL duration', async () => { - service.setIndexReader(mockIndexReader); - - const health1 = await service.getHealth(10); - expect(mockPersistence.ping).toHaveBeenCalledTimes(1); - - // Second call at tick 20 — within 100-tick threshold - const health2 = await service.getHealth(20); - expect(mockPersistence.ping).toHaveBeenCalledTimes(1); - expect(health2.cachedAtTick).toBeDefined(); - - expect(health1.status).toBe(health2.status); - }); - - it('refreshes health after tick TTL expires', async () => { - await service.getHealth(10); - expect(mockPersistence.ping).toHaveBeenCalledTimes(1); - - // Advance past 100-tick threshold - await service.getHealth(120); - expect(mockPersistence.ping).toHaveBeenCalledTimes(2); - }); - - it('includes cachedAtTick for cached results', async () => { - await service.getHealth(42); - - const health = await service.getHealth(50); - expect(health.cachedAtTick).toBe(42); - }); - - it('invalidates cache when index reader changes', async () => { - await service.getHealth(10); - expect(mockPersistence.ping).toHaveBeenCalledTimes(1); - - service.setIndexReader(mockIndexReader); - - await service.getHealth(11); - expect(mockPersistence.ping).toHaveBeenCalledTimes(2); - }); - }); - - describe('isReady()', () => { - it('returns true when all components are healthy', async () => { - service.setIndexReader(mockIndexReader); - - const ready = await service.isReady(1); - - expect(ready).toBe(true); - }); - - it('returns false when index is not loaded (degraded)', async () => { - const ready = await service.isReady(1); - - expect(ready).toBe(false); - }); - - it('returns false when repository is unhealthy', async () => { - mockPersistence.ping.mockResolvedValue({ ok: false, latencyMs: 10 }); - service.setIndexReader(mockIndexReader); - - const ready = await service.isReady(1); - - expect(ready).toBe(false); - }); - }); - - describe('isAlive()', () => { - it('returns true when repository is healthy', async () => { - const alive = await service.isAlive(1); - - expect(alive).toBe(true); - }); - - it('returns true even when index is degraded', async () => { - const alive = await service.isAlive(1); - - expect(alive).toBe(true); - }); - - it('returns false when repository is unhealthy', async () => { - mockPersistence.ping.mockResolvedValue({ ok: false, latencyMs: 10 }); - - const alive = await service.isAlive(1); - - expect(alive).toBe(false); - }); - - it('returns false when ping throws an error', async () => { - mockPersistence.ping.mockRejectedValue(new Error('Network error')); - - const alive = await service.isAlive(1); - - expect(alive).toBe(false); - }); - }); - - describe('latency rounding', () => { - it('rounds latency to 2 decimal places', async () => { - mockPersistence.ping.mockResolvedValue({ ok: true, latencyMs: 1.23456789 }); - - const health = await service.getHealth(1); - - expect(health.components.repository.latencyMs).toBe(1.23); - }); - - it('rounds high latency values', async () => { - mockPersistence.ping.mockResolvedValue({ ok: true, latencyMs: 99.999 }); - - const health = await service.getHealth(1); - - expect(health.components.repository.latencyMs).toBe(100); - }); - }); - - describe('logging', () => { - it('logs debug message for fresh health computation', async () => { - await service.getHealth(1); - - expect(mockLogger['debug']).toHaveBeenCalledWith( - 'Health check completed', - expect.objectContaining({ - operation: 'getHealth', - status: expect.any(String), - }), - ); - }); - - it('does not log for cached results', async () => { - await service.getHealth(1); - mockLogger['debug']!.mockClear(); - - await service.getHealth(2); - - expect(mockLogger['debug']).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/test/unit/domain/services/IndexRebuildService.deep.test.ts b/test/unit/domain/services/IndexRebuildService.deep.test.ts deleted file mode 100644 index 12f374add..000000000 --- a/test/unit/domain/services/IndexRebuildService.deep.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import IndexRebuildService from '../../../../src/domain/services/index/IndexRebuildService.ts'; -import GraphNode from '../../../../src/domain/entities/GraphNode.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; - -describe('IndexRebuildService Deep DAG Test', () => { - it('handles 10,000 node chain without stack overflow', { timeout: 30000 }, async () => { - const CHAIN_LENGTH = 10_000; - - // Generate a linear chain: node0 <- node1 <- node2 <- ... <- node9999 - const chain = ([]) as GraphNode[]; - for (let i = 0; i < CHAIN_LENGTH; i++) { - chain.push(new GraphNode({ - sha: `sha${i.toString().padStart(6, '0')}`, - author: 'test', - date: '2026-01-28', - message: `Node ${i}`, - parents: i > 0 ? [`sha${(i - 1).toString().padStart(6, '0')}`] : [] - })); - } - - const mockGraphService = { - async *iterateNodes(/** @type {any} */ { ref: _ref, limit: _limit }) { - for (const node of chain) { - yield node; - } - } - }; - - let blobCounter = 0; - - const mockStorage = { - writeBlob: vi.fn().mockImplementation(async () => { - return `blob${++blobCounter}`; - }), - writeTree: vi.fn().mockResolvedValue('tree-oid-deep') - }; - - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - codec: defaultCodec, - }) as any)); - - // This should complete without stack overflow - const treeOid = await service.rebuild('HEAD'); - - expect(treeOid).toBe('tree-oid-deep'); - - // Verify all nodes were processed - // Should have meta shards + fwd shards + rev shards - expect(mockStorage.writeBlob).toHaveBeenCalled(); - expect(mockStorage.writeTree).toHaveBeenCalledTimes(1); - - // Verify tree entries were created for all shards - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as any[]); - expect(treeEntries.length).toBeGreaterThan(0); - - // All entries should be valid tree format - treeEntries.forEach(/** @param {any} entry */ entry => { - expect(entry).toMatch(/^100644 blob blob\d+\t(meta|shards)_.+\.cbor$/); - }); - }); - - it('handles wide DAG (node with 1000 parents) without issues', async () => { - const PARENT_COUNT = 1000; - - // Create 1000 parent nodes and 1 child with all of them as parents - const nodes = ([]) as GraphNode[]; - const parentShas: string[] = []; - - for (let i = 0; i < PARENT_COUNT; i++) { - const sha = `parent${i.toString().padStart(4, '0')}`; - parentShas.push(sha); - nodes.push(new GraphNode({ - sha, - author: 'test', - date: '2026-01-28', - message: `Parent ${i}`, - parents: [] - })); - } - - // Add the mega-merge node - nodes.push(new GraphNode({ - sha: 'megamerge', - author: 'test', - date: '2026-01-28', - message: 'Mega merge commit', - parents: parentShas - })); - - const mockGraphService = { - async *iterateNodes() { - for (const node of nodes) { - yield node; - } - } - }; - - const mockStorage = { - writeBlob: vi.fn().mockResolvedValue('blob-oid'), - writeTree: vi.fn().mockResolvedValue('tree-oid-wide') - }; - - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - codec: defaultCodec, - }) as any)); - - const treeOid = await service.rebuild('HEAD'); - - expect(treeOid).toBe('tree-oid-wide'); - expect(mockStorage.writeBlob).toHaveBeenCalled(); - }); -}); diff --git a/test/unit/domain/services/IndexRebuildService.streaming.test.ts b/test/unit/domain/services/IndexRebuildService.streaming.test.ts deleted file mode 100644 index b1285bf9f..000000000 --- a/test/unit/domain/services/IndexRebuildService.streaming.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import IndexRebuildService from '../../../../src/domain/services/index/IndexRebuildService.ts'; -import GraphNode from '../../../../src/domain/entities/GraphNode.ts'; -import MockStreamingIndexStorage from '../../../helpers/MockStreamingIndexStorage.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; - -const ROOT_SHA = 'a'.repeat(40); -const CHILD_SHA = 'b'.repeat(40); - -describe('IndexRebuildService streaming mode', () => { - let service; - let mockStorage; - let mockGraphService; - - beforeEach(() => { - mockStorage = new MockStreamingIndexStorage(); - }); - - describe('rebuild with maxMemoryBytes', () => { - it('uses streaming builder when maxMemoryBytes is specified', async () => { - mockGraphService = { - async *iterateNodes() { - yield new GraphNode({ sha: 'aa1111', author: 'test', date: '2026-01-28', message: 'msg1', parents: [] }); - yield new GraphNode({ sha: 'bb2222', author: 'test', date: '2026-01-28', message: 'msg2', parents: ['aa1111'] }); - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - const treeOid = await service.rebuild('main', { maxMemoryBytes: 50 * 1024 * 1024 }); - - expect(treeOid).toMatch(/^tree_/); - expect(mockStorage.writeTree).toHaveBeenCalled(); - }); - - it('invokes onFlush callback during streaming rebuild', async () => { - // Generate enough nodes to trigger flush - mockGraphService = { - async *iterateNodes() { - for (let i = 0; i < 100; i++) { - const sha = `${(i % 256).toString(16).padStart(2, '0')}${i.toString().padStart(6, '0')}`; - const parents = i > 0 ? [`${((i-1) % 256).toString(16).padStart(2, '0')}${(i-1).toString().padStart(6, '0')}`] : []; - yield new GraphNode({ sha, author: 'test', date: '2026-01-28', message: `msg${i}`, parents }); - } - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - const flushCalls = ([]) as any[]; - await service.rebuild('main', { - maxMemoryBytes: 1000, // Low threshold to trigger flushes - onFlush: (/** @type {any} */ data) => flushCalls.push(data), - }); - - expect(flushCalls.length).toBeGreaterThan(0); - expect(flushCalls[0]).toHaveProperty('flushCount'); - expect(flushCalls[0]).toHaveProperty('flushedBytes'); - }); - - it('invokes onProgress callback during streaming rebuild', async () => { - // Generate enough nodes to trigger progress callback - mockGraphService = { - async *iterateNodes() { - for (let i = 0; i < 25000; i++) { - const sha = `${(i % 256).toString(16).padStart(2, '0')}${i.toString().padStart(6, '0')}`; - yield new GraphNode({ sha, author: 'test', date: '2026-01-28', message: `msg${i}`, parents: [] }); - } - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - const progressCalls = ([]) as any[]; - await service.rebuild('main', { - maxMemoryBytes: 50 * 1024 * 1024, - onProgress: (/** @type {any} */ data) => progressCalls.push(data), - }); - - // Should have received progress callbacks - expect(progressCalls.length).toBeGreaterThan(0); - // Verify all progress calls have valid data - for (const call of progressCalls) { - expect(call).toHaveProperty('processedNodes'); - expect(call).toHaveProperty('currentMemoryBytes'); - } - }); - - it('produces valid index that can be loaded', async () => { - mockGraphService = { - async *iterateNodes() { - yield new GraphNode({ sha: ROOT_SHA, author: 'test', date: '2026-01-28', message: 'root', parents: [] }); - yield new GraphNode({ sha: CHILD_SHA, author: 'test', date: '2026-01-28', message: 'child', parents: [ROOT_SHA] }); - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - const treeOid = await service.rebuild('main', { maxMemoryBytes: 50 * 1024 * 1024 }); - - // Verify tree structure was created - const treeEntries = mockStorage.writeTree.mock.calls[0][0]; - expect(treeEntries.some((/** @type {any} */ e) => e.includes('meta_'))).toBe(true); - expect(treeEntries.some((/** @type {any} */ e) => e.includes('shards_'))).toBe(true); - - const reader = await service.load(treeOid); - await expect(reader.getParents(CHILD_SHA)).resolves.toEqual([ROOT_SHA]); - await expect(reader.getChildren(ROOT_SHA)).resolves.toEqual([CHILD_SHA]); - }); - }); - - describe('backward compatibility', () => { - it('uses in-memory builder when maxMemoryBytes is not specified', async () => { - mockGraphService = { - async *iterateNodes() { - yield new GraphNode({ sha: 'aa1111', author: 'test', date: '2026-01-28', message: 'msg1', parents: [] }); - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - // Without maxMemoryBytes, should use in-memory builder (original behavior) - const treeOid = await service.rebuild('main'); - - expect(treeOid).toMatch(/^tree_/); - expect(mockStorage.writeTree).toHaveBeenCalled(); - }); - - it('in-memory mode still supports onProgress', async () => { - mockGraphService = { - async *iterateNodes() { - for (let i = 0; i < 15000; i++) { - yield new GraphNode({ - sha: `${i.toString(16).padStart(8, '0')}`, - author: 'test', - date: '2026-01-28', - message: `msg${i}`, - parents: [] - }); - } - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - const progressCalls = ([]) as any[]; - await service.rebuild('main', { - onProgress: (/** @type {any} */ data) => progressCalls.push(data), - }); - - expect(progressCalls.length).toBeGreaterThanOrEqual(1); // Progress called at 10000-node intervals - expect(progressCalls[0].processedNodes).toBe(10000); - expect(progressCalls[0].currentMemoryBytes).toBeNull(); // in-memory mode doesn't track - }); - }); - - describe('memory guard integration', () => { - it('rebuild does not exceed configured memory threshold', async () => { - const memoryThreshold = 10000; // 10KB - let maxMemorySeen = 0; - - // Generate a moderate number of nodes with edges - mockGraphService = { - async *iterateNodes() { - for (let i = 0; i < 200; i++) { - const prefix = (i % 256).toString(16).padStart(2, '0'); - const sha = `${prefix}${i.toString().padStart(6, '0')}`; - const parents: string[] = []; - - // Add parent edges - if (i > 0) { - const parentPrefix = ((i - 1) % 256).toString(16).padStart(2, '0'); - parents.push(`${parentPrefix}${(i - 1).toString().padStart(6, '0')}`); - } - if (i > 5) { - const parentPrefix = ((i - 5) % 256).toString(16).padStart(2, '0'); - parents.push(`${parentPrefix}${(i - 5).toString().padStart(6, '0')}`); - } - - yield new GraphNode({ sha, author: 'test', date: '2026-01-28', message: `msg${i}`, parents }); - } - } - }; - - service = new IndexRebuildService(({ storage: mockStorage, graphService: mockGraphService, codec: defaultCodec } as any)); - - let flushCount = 0; - await service.rebuild('main', { - maxMemoryBytes: memoryThreshold, - onFlush: () => flushCount++, - onProgress: (/** @type {any} */ { currentMemoryBytes }) => { - if (currentMemoryBytes !== null) { - maxMemorySeen = Math.max(maxMemorySeen, currentMemoryBytes); - } - }, - }); - - // Should have flushed multiple times - expect(flushCount).toBeGreaterThan(0); - - // Memory should stay bounded (with some tolerance for batch processing). - // We use a generous 50% tolerance because: - // 1. Batch processing overhead - nodes are processed in batches before memory is checked - // 2. Flush timing - the memory check happens after adding nodes, so a batch may - // temporarily exceed the threshold before the flush occurs - // 3. Shard data structures - internal bookkeeping (Maps, arrays) adds overhead - // beyond the raw node/edge data being tracked - const tolerance = memoryThreshold * 0.5; - expect(maxMemorySeen).toBeLessThan(memoryThreshold + tolerance); - }); - }); -}); diff --git a/test/unit/domain/services/IndexRebuildService.test.ts b/test/unit/domain/services/IndexRebuildService.test.ts deleted file mode 100644 index 007e41e60..000000000 --- a/test/unit/domain/services/IndexRebuildService.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import IndexRebuildService from '../../../../src/domain/services/index/IndexRebuildService.ts'; -import GraphNode from '../../../../src/domain/entities/GraphNode.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import MockStreamingIndexStorage from '../../../helpers/MockStreamingIndexStorage.ts'; - -const ROOT_SHA = 'a'.repeat(40); -const CHILD_SHA = 'b'.repeat(40); -const GRANDCHILD_SHA = 'c'.repeat(40); - -function createReadableIndexHarness() { - const storage = new MockStreamingIndexStorage(); - const graphService = { - async *iterateNodes() { - yield { sha: ROOT_SHA, parents: [] }; - yield { sha: CHILD_SHA, parents: [ROOT_SHA] }; - yield { sha: GRANDCHILD_SHA, parents: [CHILD_SHA] }; - }, - }; - const indexService = new IndexRebuildService({ - storage, - graphService, - codec: defaultCodec, - }); - return { indexService, storage }; -} - -describe('IndexRebuildService', () => { - let service; - let mockStorage; - let mockGraphService; - - beforeEach(() => { - mockStorage = new MockStreamingIndexStorage(); - mockStorage.writeTree.mockResolvedValue('tree-oid'); - mockStorage.readTreeOids.mockResolvedValue({}); - mockStorage.readBlob.mockResolvedValue(Buffer.from('{}')); - - // Mock iterateNodes as an async generator - mockGraphService = { - async *iterateNodes(/** @type {any} */ { ref: _ref, limit: _limit }) { - yield new GraphNode({ sha: 'sha1', author: 'test', date: '2026-01-08', message: 'msg1', parents: [] }); - yield new GraphNode({ sha: 'sha2', author: 'test', date: '2026-01-08', message: 'msg2', parents: ['sha1'] }); - } - }; - - service = new IndexRebuildService((({ - storage: mockStorage, - graphService: mockGraphService, - codec: defaultCodec, - }) as any)); - }); - - describe('constructor validation', () => { - it('throws when graphService is not provided', () => { - expect(() => new IndexRebuildService(({ storage: mockStorage, codec: defaultCodec } as any))) - .toThrow('IndexRebuildService requires a graphService'); - }); - - it('throws when storage is not provided', () => { - expect(() => new IndexRebuildService(({ graphService: mockGraphService, codec: defaultCodec } as any))) - .toThrow('IndexRebuildService requires a storage adapter'); - }); - - it('throws when called with empty options', () => { - expect(() => new IndexRebuildService(({} as any))) - .toThrow('IndexRebuildService requires a graphService'); - }); - }); - - it('rebuilds, persists, and reloads a readable index', async () => { - const { indexService, storage } = createReadableIndexHarness(); - - const treeOid = await indexService.rebuild('main'); - const reader = await indexService.load(treeOid); - - expect(storage.writeBlob).toHaveBeenCalled(); - expect(storage.writeTree).toHaveBeenCalled(); - await expect(reader.getParents(CHILD_SHA)).resolves.toEqual([ROOT_SHA]); - await expect(reader.getChildren(ROOT_SHA)).resolves.toEqual([CHILD_SHA]); - }); - - it('loads an index from a tree OID and answers relationship lookups', async () => { - const { indexService } = createReadableIndexHarness(); - const treeOid = await indexService.rebuild('main'); - - const reader = await indexService.load(treeOid); - - await expect(reader.getParents(GRANDCHILD_SHA)).resolves.toEqual([CHILD_SHA]); - await expect(reader.getChildren(CHILD_SHA)).resolves.toEqual([GRANDCHILD_SHA]); - }); - - it('persists shard blobs and tree entries during rebuild', async () => { - const { indexService, storage } = createReadableIndexHarness(); - - const treeOid = await indexService.rebuild('main'); - const treeCall = storage.writeTree.mock.calls[0]; - if (treeCall === undefined) { - throw new Error('expected writeTree call'); - } - const treeEntries = treeCall[0]; - - expect(treeOid).toMatch(/^tree_/); - expect(storage.writeBlob.mock.calls.length).toBeGreaterThanOrEqual(2); - expect(storage.writeTree).toHaveBeenCalledTimes(1); - expect(treeEntries.length).toBeGreaterThanOrEqual(2); - expect(treeEntries.every((entry: string) => entry.startsWith('100644 blob'))).toBe(true); - }); - - describe('rebuild validation', () => { - it('throws when maxMemoryBytes is zero', async () => { - await expect(service.rebuild('main', { maxMemoryBytes: 0 })) - .rejects.toThrow('maxMemoryBytes must be a positive number'); - }); - - it('throws when maxMemoryBytes is negative', async () => { - await expect(service.rebuild('main', { maxMemoryBytes: -100 })) - .rejects.toThrow('maxMemoryBytes must be a positive number'); - }); - - it('accepts positive maxMemoryBytes', async () => { - const treeOid = await service.rebuild('main', { maxMemoryBytes: 1024 }); - expect(treeOid).toBe('tree-oid'); - }); - }); - - describe('integrity verification', () => { - it('load() uses strict mode by default', async () => { - const reader = await service.load('tree-oid'); - expect(reader.strict).toBe(true); - }); - - it('load() allows non-strict mode via option', async () => { - const reader = await service.load('tree-oid', { strict: false }); - expect(reader.strict).toBe(false); - }); - - it('strict mode throws ShardCorruptionError on invalid CBOR', async () => { - mockStorage.readTreeOids.mockResolvedValue({ - 'meta_ab.cbor': 'c0aac0aac0aac0aac0aac0aac0aac0aac0aac0aa' - }); - mockStorage.readBlob.mockResolvedValue(Buffer.from('not valid cbor')); - - const reader = await service.load('tree-oid'); - - const { ShardCorruptionError } = await import('../../../../src/domain/errors/index.ts'); - - await expect(reader.lookupId('ab123456')).rejects.toThrow(ShardCorruptionError); - }); - - it('non-strict mode returns empty on decode failure instead of throwing', async () => { - mockStorage.readTreeOids.mockResolvedValue({ - 'meta_ab.cbor': 'bad0bad0bad0bad0bad0bad0bad0bad0bad0bad0' - }); - mockStorage.readBlob.mockResolvedValue(Buffer.from('invalid')); - - const reader = await service.load('tree-oid', { strict: false }); - - // Should not throw, returns undefined for unknown ID - const id = await reader.lookupId('ab123456'); - expect(id).toBeUndefined(); - }); - - it('returns data from valid CBOR-encoded shard', async () => { - const shardData = { 'ab123456abcdef0123456789abcdef0123456789': 0 }; - const encoded = defaultCodec.encode(shardData); - - mockStorage.readTreeOids.mockResolvedValue({ - 'meta_ab.cbor': 'aabbccddaabbccddaabbccddaabbccddaabbccdd' - }); - mockStorage.readBlob.mockResolvedValue(Buffer.from(encoded)); - - const reader = await service.load('tree-oid'); - const id = await reader.lookupId('ab123456abcdef0123456789abcdef0123456789'); - expect(id).toBe(0); - }); - }); -}); diff --git a/test/unit/domain/services/IndexStalenessChecker.test.ts b/test/unit/domain/services/IndexStalenessChecker.test.ts deleted file mode 100644 index 8e5f1c155..000000000 --- a/test/unit/domain/services/IndexStalenessChecker.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { loadIndexFrontier, checkStaleness } from '../../../../src/domain/services/index/IndexStalenessChecker.ts'; -import defaultCodec, { encode as cborEncode } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import IndexRebuildService from '../../../../src/domain/services/index/IndexRebuildService.ts'; - -/** - * GK/IDX/2 — Detect and report index staleness on load. - */ - -describe('loadIndexFrontier', () => { - it('with CBOR present → correct Map', async () => { - const envelope = { version: 1, writerCount: 2, frontier: { alice: 'sha-a', bob: 'sha-b' } }; - const cborBuffer = Buffer.from(cborEncode(envelope)); - const storage = { readBlob: vi.fn().mockResolvedValue(cborBuffer) }; - const shardOids = { 'frontier.cbor': 'cbor-oid' }; - - const result = await loadIndexFrontier(shardOids, (storage as any), { codec: defaultCodec }); - - expect(result).toBeInstanceOf(Map); - expect(result!.get('alice')).toBe('sha-a'); - expect(result!.get('bob')).toBe('sha-b'); - expect(result!.size).toBe(2); - }); - - it('with JSON fallback → correct Map', async () => { - const envelope = { version: 1, writerCount: 1, frontier: { alice: 'sha-a' } }; - const jsonBuffer = Buffer.from(JSON.stringify(envelope)); - const storage = { readBlob: vi.fn().mockResolvedValue(jsonBuffer) }; - const shardOids = { 'frontier.json': 'json-oid' }; - - const result = await loadIndexFrontier(shardOids, (storage as any), { codec: defaultCodec }); - - expect(result).toBeInstanceOf(Map); - expect(result!.get('alice')).toBe('sha-a'); - }); - - it('with indexStore → decodes CBOR frontier via port', async () => { - const envelope = { version: 1, writerCount: 2, frontier: { alice: 'sha-a', bob: 'sha-b' } }; - const mockIndexStore = ((({ - decodeShard: vi.fn().mockResolvedValue(envelope), - })) as any); - const shardOids = { 'frontier.cbor': 'cbor-oid' }; - - const result = await loadIndexFrontier(shardOids, ({} as any), { codec: defaultCodec, indexStore: mockIndexStore }); - - expect(result).toBeInstanceOf(Map); - expect(result!.get('alice')).toBe('sha-a'); - expect(result!.get('bob')).toBe('sha-b'); - expect(mockIndexStore.decodeShard).toHaveBeenCalledWith('cbor-oid'); - }); - - it('with neither → null', async () => { - const storage = { readBlob: vi.fn() }; - const result = await loadIndexFrontier({}, (storage as any), { codec: defaultCodec }); - expect(result).toBeNull(); - }); -}); - -describe('checkStaleness', () => { - it('identical → stale: false', () => { - const index = new Map([['a', 'sha1'], ['b', 'sha2']]); - const current = new Map([['a', 'sha1'], ['b', 'sha2']]); - - const result = checkStaleness(index, current); - - expect(result.stale).toBe(false); - expect(result.advancedWriters).toEqual([]); - expect(result.newWriters).toEqual([]); - expect(result.removedWriters).toEqual([]); - }); - - it('writer advanced → stale: true, advancedWriters populated', () => { - const index = new Map([['alice', 'sha-old']]); - const current = new Map([['alice', 'sha-new']]); - - const result = checkStaleness(index, current); - - expect(result.stale).toBe(true); - expect(result.advancedWriters).toEqual(['alice']); - }); - - it('new writer → newWriters populated', () => { - const index = new Map([['alice', 'sha-a']]); - const current = new Map([['alice', 'sha-a'], ['bob', 'sha-b']]); - - const result = checkStaleness(index, current); - - expect(result.stale).toBe(true); - expect(result.newWriters).toEqual(['bob']); - }); - - it('writer removed → removedWriters populated', () => { - const index = new Map([['alice', 'sha-a'], ['bob', 'sha-b']]); - const current = new Map([['alice', 'sha-a']]); - - const result = checkStaleness(index, current); - - expect(result.stale).toBe(true); - expect(result.removedWriters).toEqual(['bob']); - }); - - it('all changes combined', () => { - const index = new Map([['alice', 'sha-old'], ['charlie', 'sha-c']]); - const current = new Map([['alice', 'sha-new'], ['bob', 'sha-b']]); - - const result = checkStaleness(index, current); - - expect(result.stale).toBe(true); - expect(result.advancedWriters).toEqual(['alice']); - expect(result.newWriters).toEqual(['bob']); - expect(result.removedWriters).toEqual(['charlie']); - }); - - it('reason string describes changes', () => { - const index = new Map([['a', 'old']]); - const current = new Map([['a', 'new'], ['b', 'sha-b']]); - - const result = checkStaleness(index, current); - - expect(result.reason).toContain('1 writer(s) advanced'); - expect(result.reason).toContain('1 new writer(s)'); - }); -}); - -describe('IndexRebuildService.load() staleness integration', () => { - let storage; - let logger; - let graphService; - - beforeEach(() => { - storage = { - readTreeOids: vi.fn(), - readBlob: vi.fn(), - writeBlob: vi.fn(), - writeTree: vi.fn(), - }; - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - child: vi.fn().mockReturnThis(), - }; - graphService = { - iterateNodes: vi.fn(), - }; - }); - - it('logs warning on stale index', async () => { - const envelope = { version: 1, writerCount: 1, frontier: { alice: 'sha-old' } }; - const cborBuffer = Buffer.from(cborEncode(envelope)); - - storage.readTreeOids.mockResolvedValue({ - 'meta_aa.json': 'aaa1aaa2aaa3aaa4aaa5aaa6aaa7aaa8aaa9aaa0', - 'frontier.cbor': 'bbb1bbb2bbb3bbb4bbb5bbb6bbb7bbb8bbb9bbb0', - }); - storage.readBlob.mockResolvedValue(cborBuffer); - - const service = new IndexRebuildService(({ graphService, storage, logger, codec: defaultCodec } as any)); - const currentFrontier = new Map([['alice', 'sha-new']]); - - await service.load('tree-oid', { currentFrontier }); - - expect(logger.warn).toHaveBeenCalledWith( - 'Index is stale', - expect.objectContaining({ reason: expect.stringContaining('1 writer(s) advanced') }), - ); - }); - - it('no warning on current index', async () => { - const envelope = { version: 1, writerCount: 1, frontier: { alice: 'sha-a' } }; - const cborBuffer = Buffer.from(cborEncode(envelope)); - - storage.readTreeOids.mockResolvedValue({ - 'meta_aa.json': 'aaa1aaa2aaa3aaa4aaa5aaa6aaa7aaa8aaa9aaa0', - 'frontier.cbor': 'bbb1bbb2bbb3bbb4bbb5bbb6bbb7bbb8bbb9bbb0', - }); - storage.readBlob.mockResolvedValue(cborBuffer); - - const service = new IndexRebuildService(({ graphService, storage, logger, codec: defaultCodec } as any)); - const currentFrontier = new Map([['alice', 'sha-a']]); - - await service.load('tree-oid', { currentFrontier }); - - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it('no frontier (legacy) → debug log, no warning', async () => { - storage.readTreeOids.mockResolvedValue({ - 'meta_aa.json': 'aaa1aaa2aaa3aaa4aaa5aaa6aaa7aaa8aaa9aaa0', - }); - - const service = new IndexRebuildService(({ graphService, storage, logger, codec: defaultCodec } as any)); - const currentFrontier = new Map([['alice', 'sha-a']]); - - await service.load('tree-oid', { currentFrontier }); - - expect(logger.warn).not.toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith( - expect.stringContaining('legacy'), - expect.any(Object), - ); - }); - - it('autoRebuild: true triggers rebuild on stale index', async () => { - const envelope = { version: 1, writerCount: 1, frontier: { alice: 'sha-old' } }; - const cborBuffer = Buffer.from(cborEncode(envelope)); - - // First call: stale index - storage.readTreeOids.mockResolvedValueOnce({ - 'meta_aa.json': 'aaa1aaa2aaa3aaa4aaa5aaa6aaa7aaa8aaa9aaa0', - 'frontier.cbor': 'bbb1bbb2bbb3bbb4bbb5bbb6bbb7bbb8bbb9bbb0', - }); - storage.readBlob.mockResolvedValueOnce(cborBuffer); - - // rebuild() returns new tree OID - // Second call: rebuilt index (no frontier = no staleness check) - storage.readTreeOids.mockResolvedValueOnce({ - 'meta_aa.json': 'ccc1ccc2ccc3ccc4ccc5ccc6ccc7ccc8ccc9ccc0', - }); - - const currentFrontier = new Map([['alice', 'sha-new']]); - - // Mock graphService.iterateNodes to yield nothing (empty graph) - graphService.iterateNodes = function* () { /* empty */ }; - - const service = new IndexRebuildService(({ graphService, storage, logger, codec: defaultCodec } as any)); - - const reader = await service.load('tree-oid', { - currentFrontier, - autoRebuild: true, - rebuildRef: 'HEAD', - }); - - expect(reader).toBeDefined(); - // The warn should have been called for the stale detection - expect(logger.warn).toHaveBeenCalled(); - }); -}); diff --git a/test/unit/domain/services/StreamingBitmapIndexBuilder.chunked.test.ts b/test/unit/domain/services/StreamingBitmapIndexBuilder.chunked.test.ts deleted file mode 100644 index b6e675f5b..000000000 --- a/test/unit/domain/services/StreamingBitmapIndexBuilder.chunked.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import StreamingBitmapIndexBuilder from '../../../../src/domain/services/index/StreamingBitmapIndexBuilder.ts'; -import MockStreamingIndexStorage from '../../../helpers/MockStreamingIndexStorage.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; - -describe('StreamingBitmapIndexBuilder chunked finalize', () => { - it('writes flushed shard chunks through streaming storage and never reads them back during finalize', async () => { - const storage = new MockStreamingIndexStorage(); - const builder = new StreamingBitmapIndexBuilder({ - storage, - codec: defaultCodec, - maxMemoryBytes: 1, - }); - - await builder.addEdge('aa0001', 'bb0001'); - await builder.addEdge('aa0002', 'bb0002'); - await builder.finalize(); - - expect(storage.writeBlobStream).toHaveBeenCalled(); - expect(storage.readBlobStream).not.toHaveBeenCalled(); - - const entries = storage.writeTree.mock.calls[0]?.[0]; - expect(entries).toBeDefined(); - const safeEntries = entries ?? []; - expect(safeEntries.some((entry: string) => entry.includes('shards_fwd_aa.chunk-000000.cbor'))).toBe(true); - expect(safeEntries.some((entry: string) => entry.includes('shards_rev_bb.chunk-000000.cbor'))).toBe(true); - }); -}); diff --git a/test/unit/domain/services/StreamingBitmapIndexBuilder.test.ts b/test/unit/domain/services/StreamingBitmapIndexBuilder.test.ts deleted file mode 100644 index fcb5c847a..000000000 --- a/test/unit/domain/services/StreamingBitmapIndexBuilder.test.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import StreamingBitmapIndexBuilder from '../../../../src/domain/services/index/StreamingBitmapIndexBuilder.ts'; -import BitmapIndexReader from '../../../../src/domain/services/index/BitmapIndexReader.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import { collectAsyncIterable, normalizeToAsyncIterable } from '../../../../src/domain/utils/streamUtils.ts'; - -function createMockStorage() { - const blobStore = new Map(); - let blobCounter = 0; - const storage = { - writeBlob: vi.fn().mockImplementation(async (/** @type {Uint8Array} */ buffer) => { - const oid = String(blobCounter++).padStart(40, '0'); - blobStore.set(oid, buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)); - return oid; - }), - writeBlobStream: vi.fn().mockImplementation(async (source) => { - const buffer = await collectAsyncIterable(source); - return await storage.writeBlob(buffer); - }), - writeTree: vi.fn().mockResolvedValue('tree-oid'), - readBlob: vi.fn().mockImplementation(async (/** @type {string} */ oid) => { - const buf = blobStore.get(oid); - if (buf) { return buf; } - return defaultCodec.encode({}); - }), - readBlobStream: vi.fn().mockImplementation((/** @type {string} */ oid) => ({ - [Symbol.asyncIterator]: () => normalizeToAsyncIterable(blobStore.get(oid) || defaultCodec.encode({}))[Symbol.asyncIterator](), - })), - }; - return { blobStore, storage }; -} - -describe('StreamingBitmapIndexBuilder', () => { - let mockStorage; - let writtenBlobs; - - beforeEach(() => { - const fixture = createMockStorage(); - writtenBlobs = fixture.blobStore; - mockStorage = fixture.storage; - }); - - describe('constructor', () => { - it('requires storage adapter', () => { - expect(() => new StreamingBitmapIndexBuilder(({} as any))).toThrow('requires a streaming storage adapter'); - }); - - it('throws when maxMemoryBytes is zero', () => { - expect(() => new StreamingBitmapIndexBuilder((({ - storage: mockStorage, - maxMemoryBytes: 0, - }) as any))).toThrow('maxMemoryBytes must be a positive number'); - }); - - it('throws when maxMemoryBytes is negative', () => { - expect(() => new StreamingBitmapIndexBuilder((({ - storage: mockStorage, - maxMemoryBytes: -100, - }) as any))).toThrow('maxMemoryBytes must be a positive number'); - }); - }); - - describe('registerNode', () => { - it('assigns sequential IDs to nodes', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - const id1 = await builder.registerNode('abc123'); - const id2 = await builder.registerNode('def456'); - const id3 = await builder.registerNode('abc123'); // duplicate - - expect(id1).toBe(0); - expect(id2).toBe(1); - expect(id3).toBe(0); - }); - }); - - describe('addEdge', () => { - it('registers both nodes and creates bitmaps', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('parent1', 'child1'); - const stats = builder.getMemoryStats(); - - expect(stats.nodeCount).toBe(2); - expect(stats.bitmapCount).toBe(2); - }); - }); - - describe('flush', () => { - it('writes bitmap shards to storage', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('aa1111', 'bb2222'); - await builder.flush(); - - expect(mockStorage.writeBlob).toHaveBeenCalled(); - expect(builder.getMemoryStats().bitmapCount).toBe(0); - }); - - it('invokes onFlush callback', async () => { - const onFlush = vi.fn(); - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - onFlush, - }); - - await builder.addEdge('aa1111', 'bb2222'); - await builder.flush(); - - expect(onFlush).toHaveBeenCalledWith({ - flushedBytes: expect.any(Number), - totalFlushedBytes: expect.any(Number), - flushCount: 1, - }); - }); - - it('does nothing when bitmaps are empty', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.flush(); - - expect(mockStorage.writeBlob).not.toHaveBeenCalled(); - }); - - it('preserves SHA→ID mappings after flush', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('aa1111', 'bb2222'); - const statsBefore = builder.getMemoryStats(); - await builder.flush(); - const statsAfter = builder.getMemoryStats(); - - expect(statsAfter.nodeCount).toBe(statsBefore.nodeCount); - }); - }); - - describe('finalize', () => { - it('creates tree with meta and bitmap shards', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('aa1111', 'bb2222'); - const treeOid = await builder.finalize(); - - expect(treeOid).toBe('tree-oid'); - expect(mockStorage.writeTree).toHaveBeenCalled(); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - expect(treeEntries.some((e) => e.includes('meta_'))).toBe(true); - expect(treeEntries.some((e) => e.includes('shards_fwd_'))).toBe(true); - expect(treeEntries.some((e) => e.includes('shards_rev_'))).toBe(true); - }); - - it('uses .cbor extension for shards', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('aa1111', 'bb2222'); - await builder.finalize(); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - for (const entry of treeEntries) { - if (entry.includes('meta_') || entry.includes('shards_')) { - expect(entry).toContain('.cbor'); - } - } - }); - - it('writes sorted frontier metadata when provided', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('aa1111', 'bb2222'); - await builder.finalize({ - frontier: new Map([ - ['writer-b', 'bbbb'], - ['writer-a', 'aaaa'], - ]), - }); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - expect(treeEntries.find((e) => e.includes('\tfrontier.json'))).toBeDefined(); - expect(treeEntries.find((e) => e.includes('\tfrontier.cbor'))).toBeDefined(); - }); - }); - - describe('getMemoryStats', () => { - it('returns current memory statistics', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: (mockStorage as any), codec: defaultCodec }); - - await builder.addEdge('aa1111', 'bb2222'); - const stats = builder.getMemoryStats(); - - expect(stats.nodeCount).toBe(2); - expect(stats.bitmapCount).toBe(2); - expect(stats.estimatedBitmapBytes).toBeGreaterThan(0); - expect(stats.flushCount).toBe(0); - }); - }); - - describe('automatic flush on memory threshold', () => { - it('flushes when memory exceeds threshold', async () => { - const onFlush = vi.fn(); - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 200, - onFlush, - }); - - for (let i = 0; i < 10; i++) { - await builder.addEdge(`aa${i.toString().padStart(4, '0')}`, `bb${i.toString().padStart(4, '0')}`); - } - - expect(onFlush).toHaveBeenCalled(); - expect(builder.getMemoryStats().flushCount).toBeGreaterThan(0); - }); - }); - - describe('chunk merging', () => { - it('writes multiple chunk entries for the same shard', async () => { - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 100, - }); - - await builder.addEdge('aa1111', 'aa2222'); - await builder.flush(); - await builder.addEdge('aa3333', 'aa4444'); - await builder.flush(); - await builder.finalize(); - - expect(mockStorage.writeTree).toHaveBeenCalled(); - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - expect(treeEntries.filter((entry) => entry.includes('shards_fwd_aa.chunk-')).length).toBeGreaterThan(1); - }); - - it('keeps multi-chunk shard queries correct through BitmapIndexReader', async () => { - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 1, - }); - - await builder.addEdge('aa0001', 'bb0001'); - await builder.addEdge('aa0002', 'bb0002'); - - const treeOid = await builder.finalize(); - expect(treeOid).toBe('tree-oid'); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - const shardOids: Record = {}; - treeEntries.forEach((entry) => { - const match = entry.match(/100644 blob (\S+)\t(\S+)/); - const oid = match?.[1]; - const path = match?.[2]; - if (oid !== undefined && path !== undefined) { - shardOids[path] = oid; - } - }); - const reader = new BitmapIndexReader({ storage: (mockStorage as any), codec: defaultCodec }); - reader.setup(shardOids); - await expect(reader.getChildren('aa0001')).resolves.toContain('bb0001'); - await expect(reader.getChildren('aa0002')).resolves.toContain('bb0002'); - - expect(builder.getMemoryStats().nodeCount).toBe(4); - }); - }); -}); - -describe('StreamingBitmapIndexBuilder memory guard', () => { - it('bitmap memory stays below threshold during large build', async () => { - let maxMemorySeen = 0; - const memoryThreshold = 5000; - const { storage: mockStorage } = createMockStorage(); - - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: memoryThreshold, - }); - - for (let i = 0; i < 500; i++) { - const sha = `${(i % 256).toString(16).padStart(2, '0')}${i.toString().padStart(6, '0')}`; - await builder.registerNode(sha); - - const numParents = (i % 3) + 1; - for (let p = 0; p < numParents && i > p; p++) { - const parentIdx = Math.max(0, i - p - 1); - const parentSha = `${(parentIdx % 256).toString(16).padStart(2, '0')}${parentIdx.toString().padStart(6, '0')}`; - await builder.addEdge(parentSha, sha); - } - - maxMemorySeen = Math.max(maxMemorySeen, builder.getMemoryStats().estimatedBitmapBytes); - } - - await builder.finalize(); - - const allowedOvershoot = memoryThreshold * 0.5; - expect(maxMemorySeen).toBeLessThan(memoryThreshold + allowedOvershoot); - expect(builder.getMemoryStats().flushCount).toBeGreaterThan(0); - expect(mockStorage.writeTree).toHaveBeenCalled(); - }); - - it('produces correct index despite multiple flushes', async () => { - const { storage: mockStorage } = createMockStorage(); - - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 500, - }); - - const nodes = ['aa0001', 'aa0002', 'aa0003', 'bb0001', 'bb0002']; - const edges = [['aa0001', 'aa0002'], ['aa0002', 'aa0003'], ['aa0001', 'bb0001'], ['bb0001', 'bb0002']]; - - for (const sha of nodes) { await builder.registerNode(sha); } - for (const [parent, child] of edges) { await builder.addEdge((parent as string), (child as string)); } - await builder.finalize(); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - const metaEntries = treeEntries.filter((e) => e.includes('meta_')); - expect(metaEntries.length).toBeGreaterThan(0); - expect(builder.getMemoryStats().nodeCount).toBe(5); - }); -}); - -describe('StreamingBitmapIndexBuilder extreme stress tests', () => { - it('handles 1000 nodes with 512-byte memory limit', async () => { - let flushCount = 0; - const { storage: mockStorage } = createMockStorage(); - - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 512, - onFlush: () => { flushCount++; }, - }); - - const nodeCount = 1000; - for (let i = 0; i < nodeCount; i++) { - const sha = `${(i % 256).toString(16).padStart(2, '0')}${i.toString(16).padStart(6, '0')}`; - await builder.registerNode(sha); - if (i > 0) { - const prevSha = `${((i - 1) % 256).toString(16).padStart(2, '0')}${(i - 1).toString(16).padStart(6, '0')}`; - await builder.addEdge(prevSha, sha); - } - } - - const treeOid = await builder.finalize(); - expect(treeOid).toBe('tree-oid'); - expect(builder.getMemoryStats().nodeCount).toBe(nodeCount); - expect(flushCount).toBeGreaterThan(10); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - expect(treeEntries.some((e) => e.includes('meta_'))).toBe(true); - expect(treeEntries.some((e) => e.includes('shards_fwd_'))).toBe(true); - expect(treeEntries.some((e) => e.includes('shards_rev_'))).toBe(true); - }); - - it('throws clean error when storage.writeBlob fails mid-flush', async () => { - let writeCallCount = 0; - - const mockStorage = { - writeBlob: vi.fn().mockImplementation(async () => { - writeCallCount++; - if (writeCallCount === 3) { - throw new Error('Storage write failed: disk full'); - } - return `blob-${writeCallCount}`; - }), - writeTree: vi.fn().mockResolvedValue('tree-oid'), - readBlob: vi.fn().mockResolvedValue(defaultCodec.encode({})), - writeBlobStream: vi.fn().mockImplementation(async (source) => { - await collectAsyncIterable(source); - return await mockStorage.writeBlob(); - }), - readBlobStream: vi.fn().mockImplementation(() => normalizeToAsyncIterable(defaultCodec.encode({}))), - }; - - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 50000, - }); - - await builder.addEdge('aa0001', 'bb0001'); - await builder.addEdge('cc0001', 'dd0001'); - await builder.addEdge('ee0001', 'ff0001'); - - await expect(builder.flush()).rejects.toThrow('Storage write failed: disk full'); - }); - - it('supports same-prefix queries across 10 different flushed chunks', async () => { - const { storage: mockStorage } = createMockStorage(); - - const builder = new StreamingBitmapIndexBuilder({ - storage: (mockStorage as any), - codec: defaultCodec, - maxMemoryBytes: 1, - }); - - const sourceNode = 'aa0001'; - const targetNodes: string[] = []; - for (let i = 0; i < 10; i++) { - const targetNode = `bb${i.toString().padStart(4, '0')}`; - targetNodes.push(targetNode); - await builder.addEdge(sourceNode, targetNode); - } - - await builder.finalize(); - - const treeEntries = ((mockStorage.writeTree.mock.calls[0] as any[])[0] as string[]); - const shardOids: Record = {}; - treeEntries.forEach((entry) => { - const match = entry.match(/100644 blob (\S+)\t(\S+)/); - const oid = match?.[1]; - const path = match?.[2]; - if (oid !== undefined && path !== undefined) { - shardOids[path] = oid; - } - }); - const reader = new BitmapIndexReader({ storage: (mockStorage as any), codec: defaultCodec }); - reader.setup(shardOids); - await expect(reader.getChildren(sourceNode)).resolves.toHaveLength(10); - }); -}); diff --git a/test/unit/domain/services/TreeConstruction.determinism.test.ts b/test/unit/domain/services/TreeConstruction.determinism.test.ts index e99789f32..00baf5394 100644 --- a/test/unit/domain/services/TreeConstruction.determinism.test.ts +++ b/test/unit/domain/services/TreeConstruction.determinism.test.ts @@ -9,8 +9,8 @@ import { createEmptyState, encodeEdgeKey as encodeEdgeKeyV5, encodePropKey as en 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 '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; -import InMemoryBlobStorageAdapter from '../../../../src/infrastructure/adapters/InMemoryBlobStorageAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; +import InMemoryBlobStorageAdapter from '../../../../test/helpers/InMemoryBlobStorageAdapter.ts'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import { CborPatchJournalAdapter } from '../../../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; diff --git a/test/unit/domain/services/controllers/ForkController.test.ts b/test/unit/domain/services/controllers/ForkController.test.ts index 0303f399e..d2693374e 100644 --- a/test/unit/domain/services/controllers/ForkController.test.ts +++ b/test/unit/domain/services/controllers/ForkController.test.ts @@ -54,7 +54,6 @@ function createMockHost(overrides = {}) { _crypto: null, _codec: null, _checkpointPolicy: null, - _adjacencyCache: { maxSize: 3 }, ...overrides, }; } diff --git a/test/unit/domain/services/controllers/IntentController.test.ts b/test/unit/domain/services/controllers/IntentController.test.ts new file mode 100644 index 000000000..24f63e2ac --- /dev/null +++ b/test/unit/domain/services/controllers/IntentController.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from 'vitest'; + +import IntentController, { + type IntentHost, +} from '../../../../../src/domain/services/controllers/IntentController.ts'; +import type { WarpIntentDescriptor } from '../../../../../src/domain/types/WarpIntentDescriptor.ts'; + +const descriptor: WarpIntentDescriptor = { + intentId: 'assign-alice', + nutritionLabel: { + bundleHash: 'bundle', + coreHash: 'core', + profile: 'default', + budget: 'bounded', + }, + precommitGuards: [], + suffixTransform: { + op: 'property.set', + payload: { subject: 'user:alice', key: 'role', value: 'admin' }, + }, +}; + +function withGuards( + precommitGuards: WarpIntentDescriptor['precommitGuards'], +): WarpIntentDescriptor { + return { ...descriptor, precommitGuards }; +} + +function createController( + getNodeProps: ReturnType, +): IntentController { + return new IntentController({ + _graphName: 'events', + _writerId: 'agent-1', + worldline: () => ({ getNodeProps }), + } as unknown as IntentHost); +} + +describe('IntentController', () => { + it('admits a descriptor without writing an unattached persistence blob', async () => { + const writeBlob = vi.fn(async () => 'a'.repeat(40)); + const host = { + _graphName: 'events', + _writerId: 'agent-1', + _persistence: { writeBlob }, + worldline: () => ({ getNodeProps: vi.fn() }), + } as unknown as IntentHost; + const controller = new IntentController(host); + + await expect(controller.admitIntent(descriptor)).resolves.toEqual({ + admitted: true, + intentId: 'assign-alice', + sha: 'intent:assign-alice:agent-1:1', + }); + expect(writeBlob).not.toHaveBeenCalled(); + }); + + it('enforces node status guards without persisting the descriptor', async () => { + const getNodeProps = vi + .fn() + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'active' }) + .mockResolvedValueOnce({ status: 7 }); + const controller = createController(getNodeProps); + const guarded = withGuards([{ + op: 'nodeStatus', + nodeId: 'user:alice', + expected: 'active', + failureTag: 'wrong-status', + }]); + + await expect(controller.admitIntent(guarded)).resolves.toEqual({ + admitted: false, + obstruction: { + tag: 'wrong-status', + nodeId: 'user:alice', + actual: 'pending', + }, + intentId: 'assign-alice', + }); + await expect(controller.admitIntent(guarded)).resolves.toEqual({ + admitted: true, + intentId: 'assign-alice', + sha: 'intent:assign-alice:agent-1:1', + }); + await expect(controller.admitIntent(guarded)).resolves.toMatchObject({ + admitted: false, + obstruction: { actual: 'ABSENT' }, + }); + }); + + it('accepts an unassigned or matching agent and obstructs another agent', async () => { + const getNodeProps = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ agentId: 7 }) + .mockResolvedValueOnce({ agentId: 'agent-1' }) + .mockResolvedValueOnce({ agentId: 'agent-2' }); + const controller = createController(getNodeProps); + const guarded = withGuards([{ + op: 'nodeUnassignedOrSelf', + nodeId: 'task:1', + agentId: 'agent-1', + failureTag: 'assigned-elsewhere', + }]); + + for (let counter = 1; counter <= 4; counter += 1) { + await expect(controller.admitIntent(guarded)).resolves.toEqual({ + admitted: true, + intentId: 'assign-alice', + sha: `intent:assign-alice:agent-1:${counter}`, + }); + } + await expect(controller.admitIntent(guarded)).resolves.toEqual({ + admitted: false, + obstruction: { + tag: 'assigned-elsewhere', + nodeId: 'task:1', + actual: 'agent-2', + }, + intentId: 'assign-alice', + }); + }); + + it('queues descriptors by strand without writing storage', async () => { + const controller = createController(vi.fn()); + + await expect(controller.queueIntent('draft:admin', descriptor)).resolves.toEqual({ + admitted: true, + intentId: 'assign-alice', + sha: 'queued:draft:admin:assign-alice', + }); + await expect(controller.getWriterIntents('draft:admin')).resolves.toEqual([descriptor]); + await expect(controller.getWriterIntents('missing')).resolves.toEqual([]); + }); +}); diff --git a/test/unit/domain/services/controllers/MaterializeController.test.ts b/test/unit/domain/services/controllers/MaterializeController.test.ts index e2bb15bfd..1928f2efe 100644 --- a/test/unit/domain/services/controllers/MaterializeController.test.ts +++ b/test/unit/domain/services/controllers/MaterializeController.test.ts @@ -129,7 +129,6 @@ function makeDeps({ patchesOverrides = {}, persistenceOverrides = {}, depsOverri hmac: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), }, persistence, - getSeekCache: () => null, patches, graphCloner: { openReadOnly: vi.fn() }, graphName: 'test', 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 7638eda52..a2487c7bf 100644 --- a/test/unit/domain/services/controllers/StrandController.host-interface.test.ts +++ b/test/unit/domain/services/controllers/StrandController.host-interface.test.ts @@ -4,7 +4,7 @@ import StrandController, { type StrandHost, } from '../../../../../src/domain/services/controllers/StrandController.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; -import InMemoryGraphAdapter from '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; import CryptoPort from '../../../../../src/ports/CryptoPort.ts'; import type Patch from '../../../../../src/domain/types/Patch.ts'; diff --git a/test/unit/domain/services/logging.integration.test.ts b/test/unit/domain/services/logging.integration.test.ts deleted file mode 100644 index d151711ae..000000000 --- a/test/unit/domain/services/logging.integration.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import IndexRebuildService from '../../../../src/domain/services/index/IndexRebuildService.ts'; -import BitmapIndexReader from '../../../../src/domain/services/index/BitmapIndexReader.ts'; -import GraphNode from '../../../../src/domain/entities/GraphNode.ts'; -import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; -import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import MockStreamingIndexStorage from '../../../helpers/MockStreamingIndexStorage.ts'; - -const crypto = new NodeCryptoAdapter(); - -/** - * Creates a mock logger that tracks all calls. - */ -/** @returns {any} */ -function createMockLogger() { - const calls: { debug: any[]; info: any[]; warn: any[]; error: any[]; child: any[] } = { - debug: [], - info: [], - warn: [], - error: [], - child: [], - }; - - const createLogger = (parentCalls = calls) => ({ - debug: vi.fn((msg, ctx) => parentCalls['debug'].push({ msg, ctx })), - info: vi.fn((msg, ctx) => parentCalls['info'].push({ msg, ctx })), - warn: vi.fn((msg, ctx) => parentCalls['warn'].push({ msg, ctx })), - error: vi.fn((msg, ctx) => parentCalls['error'].push({ msg, ctx })), - child: vi.fn((ctx) => { - parentCalls['child'].push({ ctx }); - return createLogger(parentCalls); - }), - _calls: parentCalls, - }); - - return createLogger(); -} - -describe('Service Logging Integration', () => { - describe('IndexRebuildService', () => { - let mockGraphService; - let mockStorage; - let mockLogger; - - beforeEach(() => { - mockGraphService = { - iterateNodes: vi.fn().mockImplementation(async function* () { - yield new GraphNode({ sha: 'sha1', message: 'msg1', parents: [] }); - yield new GraphNode({ sha: 'sha2', message: 'msg2', parents: ['sha1'] }); - }) - }; - mockStorage = new MockStreamingIndexStorage(); - mockStorage.writeBlob.mockResolvedValue('blob-oid'); - mockStorage.writeTree.mockResolvedValue('tree-oid'); - mockStorage.readTreeOids.mockResolvedValue({ 'meta_ab.json': 'aaa1bbb2ccc3ddd4eee5fff6aaa1bbb2ccc3ddd4' }); - mockLogger = createMockLogger(); - }); - - describe('rebuild', () => { - it('logs info at start and completion', async () => { - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - logger: mockLogger, - codec: defaultCodec, - }) as any)); - - await service.rebuild('HEAD'); - - expect(mockLogger._calls.info.length).toBe(2); - - const startLog = mockLogger._calls.info[0]; - expect(startLog.msg).toBe('Starting index rebuild'); - expect(startLog.ctx.operation).toBe('rebuild'); - expect(startLog.ctx.ref).toBe('HEAD'); - expect(startLog.ctx.mode).toBe('in-memory'); - - const endLog = mockLogger._calls.info[1]; - expect(endLog.msg).toBe('Index rebuild complete'); - expect(endLog.ctx.treeOid).toBe('tree-oid'); - // durationMs removed — timing no longer tracked in domain services - }); - - it('propagates errors from iterateNodes', async () => { - mockGraphService.iterateNodes = vi.fn().mockImplementation(async function* () { - yield new GraphNode({ sha: 'dummy', message: 'dummy', parents: [] }); - throw new Error('Graph error'); - }); - - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - logger: mockLogger, - codec: defaultCodec, - }) as any)); - - await expect(service.rebuild('HEAD')).rejects.toThrow('Graph error'); - }); - - it('indicates streaming mode in logs', async () => { - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - logger: mockLogger, - codec: defaultCodec, - }) as any)); - - await service.rebuild('HEAD', { maxMemoryBytes: 1024 * 1024 }); - - const startLog = mockLogger._calls.info[0]; - expect(startLog.ctx.mode).toBe('streaming'); - expect(startLog.ctx.maxMemoryBytes).toBe(1024 * 1024); - }); - }); - - describe('load', () => { - it('logs debug on load', async () => { - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - logger: mockLogger, - codec: defaultCodec, - }) as any)); - - await service.load('tree-oid'); - - expect(mockLogger._calls.debug.length).toBe(2); - - const startLog = mockLogger._calls.debug[0]; - expect(startLog.msg).toBe('Loading index'); - expect(startLog.ctx.treeOid).toBe('tree-oid'); - - const endLog = mockLogger._calls.debug[1]; - expect(endLog.msg).toBe('Index loaded'); - expect(endLog.ctx.shardCount).toBe(1); - // durationMs removed — timing no longer tracked in domain services - }); - - it('creates child logger for BitmapIndexReader', async () => { - const service = new IndexRebuildService((({ - graphService: mockGraphService, - storage: mockStorage, - logger: mockLogger, - codec: defaultCodec, - }) as any)); - - await service.load('tree-oid'); - - expect(mockLogger._calls.child.length).toBe(1); - expect(mockLogger._calls.child[0].ctx.component).toBe('BitmapIndexReader'); - }); - }); - }); - - describe('BitmapIndexReader', () => { - let mockStorage; - let mockLogger; - - beforeEach(() => { - mockStorage = { - readBlob: vi.fn().mockRejectedValue(new Error('Blob not found')), - }; - mockLogger = createMockLogger(); - }); - - describe('validation warnings', () => { - it('logs warn on validation failure in non-strict mode', async () => { - // Create a shard with invalid checksum - mockStorage.readBlob = vi.fn().mockResolvedValue( - Buffer.from(JSON.stringify({ - version: 1, - checksum: 'invalid-checksum', - data: { sha123: 42 } - })) - ); - - const reader = new BitmapIndexReader((({ - storage: mockStorage, - strict: false, - logger: mockLogger, - crypto, - }) as any)); - reader.setup({ 'meta_sh.cbor': 'aaa1bbb2ccc3ddd4eee5fff6aaa7bbb8ccc9ddd0' }); - - const id = await reader.lookupId('sha123'); - - // Should return empty due to validation failure - expect(id).toBeUndefined(); - - // Should have logged the warning - expect(mockLogger._calls.warn.length).toBe(1); - const logEntry = mockLogger._calls.warn[0]; - expect(logEntry.ctx.operation).toBe('loadShard'); - expect(logEntry.ctx.shardPath).toBe('meta_sh.cbor'); - }); - }); - }); -}); diff --git a/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts b/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts index 3c3ac4f8c..4e52edff0 100644 --- a/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts +++ b/test/unit/domain/services/optic/CheckpointPatchFactStream.test.ts @@ -24,7 +24,7 @@ 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 '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.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'; diff --git a/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts b/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts index 49fb91246..8e1571edb 100644 --- a/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts +++ b/test/unit/domain/services/optic/CheckpointShardFactReader.manifest.test.ts @@ -20,7 +20,7 @@ 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 computeShardKey from '../../../../../src/domain/utils/shardKey.ts'; -import InMemoryGraphAdapter from '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.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'; diff --git a/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts b/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts index b220207e8..cba41795c 100644 --- a/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts +++ b/test/unit/domain/services/optic/CheckpointTailBasisVerifier.test.ts @@ -12,7 +12,7 @@ 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 '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../../test/helpers/InMemoryGraphAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC, encodeCheckpointMessage, diff --git a/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts b/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts index 460c05ab0..0210bb4b2 100644 --- a/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts +++ b/test/unit/domain/services/optic/CoordinateCheckpointTailOpticSource.test.ts @@ -8,7 +8,7 @@ import CheckpointTailOpticSource, { } 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 '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.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'; diff --git a/test/unit/domain/services/optic/StreamingCheckpointBasisBuilder.test.ts b/test/unit/domain/services/optic/StreamingCheckpointBasisBuilder.test.ts deleted file mode 100644 index a1a906027..000000000 --- a/test/unit/domain/services/optic/StreamingCheckpointBasisBuilder.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import MemoryBudgetError from '../../../../../src/domain/errors/MemoryBudgetError.ts'; -import QueryError from '../../../../../src/domain/errors/QueryError.ts'; -import MemoryBudget from '../../../../../src/domain/memory/MemoryBudget.ts'; -import WarpMemoryPool from '../../../../../src/domain/memory/WarpMemoryPool.ts'; -import { - CheckpointNodeLivenessFact, - type CheckpointBasisFact, - type CheckpointBasisFactTransport, -} from '../../../../../src/domain/services/optic/CheckpointBasisFact.ts'; -import StreamingCheckpointBasisBuilder from '../../../../../src/domain/services/optic/StreamingCheckpointBasisBuilder.ts'; -import { EventId } from '../../../../../src/domain/utils/EventId.ts'; -import { createFakeCodecPort } from '../../../../helpers/mockPorts.ts'; - -const codec = createFakeCodecPort(); - -describe('StreamingCheckpointBasisBuilder', () => { - it('flushes sorted fact chunks to storage and emits a manifest', async () => { - const storage = new RecordingCheckpointBasisStorage(); - const builder = builderFixture(storage, 2); - - const result = await builder.build(facts([ - nodeAliveWithPatch(2, 'bbbb'), - nodeAliveWithPatch(1, 'aaaa'), - nodeAliveWithPatch(3, 'cccc'), - nodeAliveWithPatch(4, 'dddd'), - nodeAliveWithPatch(5, 'eeee'), - ])); - - expect(result.flushCount).toBe(3); - expect(result.shardWriteCount).toBe(3); - expect(result.rootTreeOid).toBe('tree-0001'); - expect(result.treeEntries).toHaveLength(3); - expect(result.treeEntries[0]).toContain('node-liveness/liveness_'); - expect(result.treeEntries[0]).toContain('.chunk-000001'); - expect(result.manifest.livenessRoots.size).toBe(3); - expect(result.manifest.propertyRoots.size).toBe(0); - expect(result.manifest.provenancePosture.kind).toBe('unavailable'); - expect(result.manifest.contentAnchorPosture.kind).toBe('unavailable'); - expect(result.manifest.completeness.kind).toBe('complete'); - - const firstChunk = decodeChunk(storage.requiredBlob(0)); - expect(firstChunk.map((fact) => fact.kind)).toEqual(['node-liveness', 'node-liveness']); - expect(firstChunk.map((fact) => eventPatchSha(fact))).toEqual(['aaaa', 'bbbb']); - }); - - it('builds checkpoint basis shards while releasing pending-fact leases', async () => { - const storage = new RecordingCheckpointBasisStorage(); - const pool = new WarpMemoryPool({ name: 'streaming-checkpoint-basis', budget: MemoryBudget.facts(2) }); - const builder = builderFixture(storage, 2, pool); - - const result = await builder.build(facts([ - nodeAlive('task:bounded', 1), - nodeAlive('task:bounded', 2), - nodeAlive('task:bounded', 3), - nodeAlive('task:bounded', 4), - nodeAlive('task:bounded', 5), - ])); - - expect(result.flushCount).toBe(3); - expect(result.shardWriteCount).toBe(3); - expect(result.manifest.chunking.maxFactsPerShard).toBe(2); - expect(result.manifest.chunking.chunkCount).toBe(3); - expect(result.manifest.completeness.kind).toBe('complete'); - expect(storage.blobWriteCount()).toBe(3); - expect(storage.treeWriteCount()).toBe(1); - expect(pool.snapshot()).toMatchObject({ leased: 0, peak: 2, rejected: 0 }); - }); - - it('rejects basis construction that would exceed pending-fact budget', async () => { - const storage = new RecordingCheckpointBasisStorage(); - const pool = new WarpMemoryPool({ name: 'streaming-checkpoint-basis', budget: MemoryBudget.facts(2) }); - const builder = builderFixture(storage, 3, pool); - - await expect(builder.build(facts([ - nodeAlive('task:bounded', 1), - nodeAlive('task:bounded', 2), - nodeAlive('task:bounded', 3), - ]))).rejects.toBeInstanceOf(MemoryBudgetError); - expect(storage.blobWriteCount()).toBe(0); - expect(storage.treeWriteCount()).toBe(0); - expect(pool.snapshot()).toMatchObject({ leased: 0, peak: 2, rejected: 1 }); - }); - - it('rejects invalid memory thresholds with a typed obstruction', () => { - expect(() => builderFixture(new RecordingCheckpointBasisStorage(), 0)).toThrow(QueryError); - }); -}); - -class RecordingCheckpointBasisStorage { - private readonly _blobOids: string[]; - private readonly _blobWrites: Uint8Array[]; - private readonly _treeOids: string[]; - - constructor() { - this._blobOids = []; - this._blobWrites = []; - this._treeOids = []; - } - - async writeBlob(content: Uint8Array | string): Promise { - const oid = `blob-${String(this._blobOids.length + 1).padStart(4, '0')}`; - this._blobOids.push(oid); - this._blobWrites.push(contentBytes(content)); - return oid; - } - - async writeTree(_entries: readonly string[]): Promise { - const oid = `tree-${String(this._treeOids.length + 1).padStart(4, '0')}`; - this._treeOids.push(oid); - return oid; - } - - blobWriteCount(): number { - return this._blobOids.length; - } - - treeWriteCount(): number { - return this._treeOids.length; - } - - requiredBlob(index: number): Uint8Array { - const blob = this._blobWrites[index]; - if (blob !== undefined) { - return blob; - } - throw new Error(`missing recorded blob ${index}`); - } -} - -function builderFixture( - storage: RecordingCheckpointBasisStorage, - maxFactsPerShard: number, - pool?: WarpMemoryPool, -): StreamingCheckpointBasisBuilder { - const options = { - graphName: 'v18-bounded-memory', - checkpointSha: 'checkpoint-bounded-memory', - frontier: new Map([['writer-a', 'patch-0005']]), - storage, - maxFactsPerShard, - codec, - }; - if (pool === undefined) { - return new StreamingCheckpointBasisBuilder(options); - } - return new StreamingCheckpointBasisBuilder({ - ...options, - pool, - }); -} - -async function* facts(values: readonly CheckpointBasisFact[]): AsyncIterable { - for (const value of values) { - yield value; - } -} - -function nodeAlive(nodeId: string, lamport: number): CheckpointNodeLivenessFact { - return new CheckpointNodeLivenessFact({ nodeId, alive: true, eventId: event(lamport) }); -} - -function nodeAliveWithPatch(lamport: number, patchSha: string): CheckpointNodeLivenessFact { - return new CheckpointNodeLivenessFact({ - nodeId: 'node:streamed', - alive: true, - eventId: new EventId(lamport, 'writer-a', patchSha, 0), - }); -} - -function event(lamport: number): EventId { - return new EventId(lamport, 'writer-a', lamport.toString(16).padStart(4, '0'), 0); -} - -function decodeChunk(bytes: Uint8Array): readonly CheckpointBasisFactTransport[] { - return codec.decode(bytes); -} - -function eventPatchSha(fact: CheckpointBasisFactTransport): string { - if ('eventId' in fact) { - return fact.eventId.patchSha; - } - throw new Error('expected event-backed checkpoint basis fact'); -} - -function contentBytes(content: Uint8Array | string): Uint8Array { - if (content instanceof Uint8Array) { - return content; - } - return new TextEncoder().encode(content); -} diff --git a/test/unit/domain/services/optic/TraversalOptic.test.ts b/test/unit/domain/services/optic/TraversalOptic.test.ts index b5e41a6bd..e82827cf7 100644 --- a/test/unit/domain/services/optic/TraversalOptic.test.ts +++ b/test/unit/domain/services/optic/TraversalOptic.test.ts @@ -15,7 +15,7 @@ import { DEFAULT_COMMIT_MESSAGE_CODEC, } from '../../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../../src/infrastructure/codecs/CborCodec.ts'; -import InMemoryGraphAdapter from '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.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 type CodecPort from '../../../../../src/ports/CodecPort.ts'; diff --git a/test/unit/domain/services/optic/WorldlineOptic.test.ts b/test/unit/domain/services/optic/WorldlineOptic.test.ts index f6c077f93..88ef82675 100644 --- a/test/unit/domain/services/optic/WorldlineOptic.test.ts +++ b/test/unit/domain/services/optic/WorldlineOptic.test.ts @@ -16,7 +16,7 @@ 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 '../../../../../src/infrastructure/adapters/InMemoryGraphAdapter.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'; diff --git a/test/unit/domain/services/strand/StrandService.test.ts b/test/unit/domain/services/strand/StrandService.test.ts index 17f80629d..086323e5b 100644 --- a/test/unit/domain/services/strand/StrandService.test.ts +++ b/test/unit/domain/services/strand/StrandService.test.ts @@ -306,7 +306,6 @@ function storeDescriptor(descriptor) { * _lastFrontier: Map, * _writerId: string, * _checkpointPolicy?: unknown, - * _seekCache?: unknown, * getFrontier: ReturnType, * _loadPatchChainFromSha: ReturnType, * _setMaterializedState: ReturnType, diff --git a/test/unit/domain/strandAndRuntimeSeams.test.ts b/test/unit/domain/strandAndRuntimeSeams.test.ts index a0afa9339..433984694 100644 --- a/test/unit/domain/strandAndRuntimeSeams.test.ts +++ b/test/unit/domain/strandAndRuntimeSeams.test.ts @@ -8,8 +8,8 @@ import { import StrandError from '../../../src/domain/errors/StrandError.ts'; import RuntimeDetachedFactory from '../../../src/domain/warp/RuntimeDetachedFactory.ts'; import RuntimePatchCollector from '../../../src/domain/warp/RuntimePatchCollector.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; -import MemoryRuntimeStorageAdapter from '../../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.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'; @@ -136,7 +136,6 @@ describe('strand and runtime host seams', () => { codec: defaultCodec, audit: false, }); - expect(options.seekCache).toBeUndefined(); expect(options.blobStorage).toBeUndefined(); expect(options.patchBlobStorage).toBeUndefined(); expect(options.trust).toEqual({ mode: 'off', pin: null }); @@ -177,7 +176,6 @@ function createDetachedHost(): DetachedOpenHost { _gcPolicy: GCPolicy.DEFAULT, _checkpointPolicy: null, _logger: null, - _seekCache: null, _blobStorage: null, _patchBlobStorage: null, _trustConfig: { mode: 'off', pin: null }, diff --git a/test/unit/domain/utils/CachedValue.test.ts b/test/unit/domain/utils/CachedValue.test.ts deleted file mode 100644 index 7c5bb3882..000000000 --- a/test/unit/domain/utils/CachedValue.test.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import CachedValue from '../../../../src/domain/utils/CachedValue.ts'; - -describe('CachedValue', () => { - describe('constructor', () => { - it('creates cache with valid options', () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - expect(cache.hasValue).toBe(false); - expect(cache.cachedAtTick).toBe(0); - }); - - it('throws when ttlTicks is not a positive number', () => { - const compute = () => 'value'; - - expect(() => new CachedValue({ ttlTicks: 0, compute })).toThrow( - 'CachedValue ttlTicks must be a positive number', - ); - expect(() => new CachedValue({ ttlTicks: -1, compute })).toThrow( - 'CachedValue ttlTicks must be a positive number', - ); - expect(() => new CachedValue({ ttlTicks: 'invalid' as unknown as number, compute })).toThrow( - 'CachedValue ttlTicks must be a positive number', - ); - expect(() => new CachedValue({ ttlTicks: null as unknown as number, compute })).toThrow( - 'CachedValue ttlTicks must be a positive number', - ); - }); - - it('throws when compute is not a function', () => { - expect(() => new CachedValue({ ttlTicks: 100, compute: 'not a function' as unknown as () => string })).toThrow( - 'CachedValue compute must be a function', - ); - expect(() => new CachedValue({ ttlTicks: 100, compute: null as unknown as () => string })).toThrow( - 'CachedValue compute must be a function', - ); - }); - }); - - describe('get', () => { - it('computes value on first call', async () => { - const compute = vi.fn().mockResolvedValue('computed'); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const value = await cache.get(1); - - expect(value).toBe('computed'); - expect(compute).toHaveBeenCalledTimes(1); - }); - - it('returns cached value within tick threshold', async () => { - const compute = vi.fn().mockResolvedValue('computed'); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - await cache.get(10); - const value = await cache.get(109); // Just under threshold - - expect(value).toBe('computed'); - expect(compute).toHaveBeenCalledTimes(1); - }); - - it('recomputes value after tick threshold exceeded', async () => { - const compute = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const first = await cache.get(10); - const second = await cache.get(111); // Past threshold - - expect(first).toBe('first'); - expect(second).toBe('second'); - expect(compute).toHaveBeenCalledTimes(2); - }); - - it('supports synchronous compute functions', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'sync value', - }); - - const value = await cache.get(1); - - expect(value).toBe('sync value'); - }); - - it('supports async compute functions', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: async () => { - return 'async value'; - }, - }); - - const value = await cache.get(1); - - expect(value).toBe('async value'); - }); - - it('memoizes in-flight compute for concurrent get calls', async () => { - let resolveCompute: (value: string) => void = () => {}; - const compute = vi.fn().mockImplementation(() => { - return new Promise((resolve) => { - resolveCompute = resolve; - }); - }); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const first = cache.get(1); - const second = cache.get(1); - - expect(compute).toHaveBeenCalledTimes(1); - resolveCompute('computed'); - - const [firstValue, secondValue] = await Promise.all([first, second]); - expect(firstValue).toBe('computed'); - expect(secondValue).toBe('computed'); - expect(compute).toHaveBeenCalledTimes(1); - }); - }); - - describe('getWithMetadata', () => { - it('returns fromCache false on first call', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - const result = await cache.getWithMetadata(1); - - expect(result.value).toBe('value'); - expect(result.fromCache).toBe(false); - expect(result.cachedAtTick).toBe(0); - }); - - it('returns fromCache true and cachedAtTick for cached results', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - await cache.get(10); - const result = await cache.getWithMetadata(20); - - expect(result.value).toBe('value'); - expect(result.fromCache).toBe(true); - expect(result.cachedAtTick).toBe(10); - }); - }); - - describe('invalidate', () => { - it('clears cached value', async () => { - const compute = vi.fn().mockResolvedValue('computed'); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - await cache.get(1); - cache.invalidate(); - - expect(cache.hasValue).toBe(false); - expect(cache.cachedAtTick).toBe(0); - }); - - it('forces recomputation on next get', async () => { - const compute = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const first = await cache.get(1); - cache.invalidate(); - const second = await cache.get(2); - - expect(first).toBe('first'); - expect(second).toBe('second'); - expect(compute).toHaveBeenCalledTimes(2); - }); - - it('does not re-cache stale in-flight result after invalidate', async () => { - let resolveCompute: (value: string) => void = () => {}; - const compute = vi.fn() - .mockImplementationOnce(() => { - return new Promise((resolve) => { - resolveCompute = resolve; - }); - }) - .mockResolvedValueOnce('fresh'); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const first = cache.get(1); - cache.invalidate(); - resolveCompute('stale'); - - expect(await first).toBe('stale'); - expect(cache.hasValue).toBe(false); - - const second = await cache.get(2); - expect(second).toBe('fresh'); - expect(cache.hasValue).toBe(true); - expect(compute).toHaveBeenCalledTimes(2); - }); - }); - - describe('hasValue', () => { - it('returns false before first computation', () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - expect(cache.hasValue).toBe(false); - }); - - it('returns true after computation', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - await cache.get(1); - - expect(cache.hasValue).toBe(true); - }); - - it('returns true even after tick threshold expires (value is stale but still cached)', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - await cache.get(1); - - // Value is stale but still present - expect(cache.hasValue).toBe(true); - }); - - it('returns false after invalidate', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - await cache.get(1); - cache.invalidate(); - - expect(cache.hasValue).toBe(false); - }); - }); - - describe('cachedAtTick', () => { - it('returns 0 before first computation', () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - expect(cache.cachedAtTick).toBe(0); - }); - - it('returns the tick at which the value was cached', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - await cache.get(42); - - expect(cache.cachedAtTick).toBe(42); - }); - - it('updates tick after recomputation', async () => { - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => 'value', - }); - - await cache.get(10); - const firstTick = cache.cachedAtTick; - - await cache.get(200); // Past threshold, recomputes - const secondTick = cache.cachedAtTick; - - expect(firstTick).toBe(10); - expect(secondTick).toBe(200); - }); - }); - - describe('edge cases', () => { - it('handles null return value from compute', async () => { - const compute = vi.fn().mockResolvedValue(null); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const value = await cache.get(1); - - expect(value).toBeNull(); - // Note: hasValue returns false for null since we check _value === null - expect(cache.hasValue).toBe(false); - }); - - it('handles compute function that throws', async () => { - const compute = vi.fn().mockRejectedValue(new Error('compute failed')); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - await expect(cache.get(1)).rejects.toThrow('compute failed'); - expect(cache.hasValue).toBe(false); - }); - - it('handles very small tick threshold', async () => { - const compute = vi.fn().mockResolvedValueOnce('first').mockResolvedValueOnce('second'); - const cache = new CachedValue({ ttlTicks: 1, compute }); - - await cache.get(1); - const second = await cache.get(3); - - expect(second).toBe('second'); - expect(compute).toHaveBeenCalledTimes(2); - }); - - it('handles very large tick threshold', async () => { - const compute = vi.fn().mockResolvedValue('value'); - const cache = new CachedValue({ ttlTicks: Number.MAX_SAFE_INTEGER, compute }); - - await cache.get(1); - await cache.get(1000000); - - expect(compute).toHaveBeenCalledTimes(1); - }); - - it('caches object values correctly', async () => { - const obj = { nested: { deeply: true }, array: [1, 2, 3] }; - const cache = new CachedValue({ - ttlTicks: 100, - compute: () => obj, - }); - - const value = await cache.get(1); - - expect(value).toBe(obj); - expect(value.nested.deeply).toBe(true); - expect(value.array).toEqual([1, 2, 3]); - }); - }); - - // ----------------------------------------------------------------------- - // Null-payload semantics - // - // Returning `null` from compute means "no value available." This is an - // intentional design contract: null is the sentinel that _isValid() checks, - // so a null result is never cached. Every subsequent get() recomputes, and - // the cache reports itself as empty. This prevents stale "absence" from - // being treated as a valid cached answer. - // ----------------------------------------------------------------------- - describe('null-payload semantics', () => { - it('null return triggers recomputation on every get()', async () => { - const compute = vi.fn().mockResolvedValue(null); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - const first = await cache.get(1); - const second = await cache.get(2); - - expect(first).toBeNull(); - expect(second).toBeNull(); - expect(compute).toHaveBeenCalledTimes(2); - }); - - it('getWithMetadata returns fromCache=false for null', async () => { - const compute = vi.fn().mockResolvedValue(null); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - await cache.get(1); - const result = await cache.getWithMetadata(2); - - expect(result.value).toBeNull(); - expect(result.fromCache).toBe(false); - }); - - it('hasValue returns false when compute returned null', async () => { - const compute = vi.fn().mockResolvedValue(null); - const cache = new CachedValue({ ttlTicks: 100, compute }); - - await cache.get(1); - - expect(cache.hasValue).toBe(false); - }); - }); -}); diff --git a/test/unit/domain/utils/RefLayout.test.ts b/test/unit/domain/utils/RefLayout.test.ts index dea450cdf..1e2fc4ac9 100644 --- a/test/unit/domain/utils/RefLayout.test.ts +++ b/test/unit/domain/utils/RefLayout.test.ts @@ -7,7 +7,6 @@ import { buildCheckpointRef, buildCoverageRef, buildWritersPrefix, - buildSeekCacheRef, buildCursorActiveRef, buildCursorSavedRef, buildCursorSavedPrefix, @@ -402,17 +401,6 @@ describe('RefLayout', () => { }); }); - describe('buildSeekCacheRef', () => { - it('builds correct ref path', () => { - expect(buildSeekCacheRef('events')).toBe('refs/warp/events/seek-cache'); - }); - - it('validates graph name', () => { - expect(() => buildSeekCacheRef('')).toThrow(); - expect(() => buildSeekCacheRef('../bad')).toThrow(); - }); - }); - describe('buildCursorActiveRef', () => { it('builds correct cursor active ref path', () => { expect(buildCursorActiveRef('events')).toBe('refs/warp/events/cursor/active'); diff --git a/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts b/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts index 082c4b80b..b729d0844 100644 --- a/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts +++ b/test/unit/domain/warp/RuntimeHostPortResolvers.test.ts @@ -8,8 +8,8 @@ import CommitMessageCodecPort, { type PatchCommitMessage, } from '../../../../src/ports/CommitMessageCodecPort.ts'; import TrustCryptoPort, { type TrustSignatureVerification } from '../../../../src/ports/TrustCryptoPort.ts'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; -import MemoryRuntimeStorageAdapter from '../../../../src/infrastructure/adapters/MemoryRuntimeStorageAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; +import MemoryRuntimeStorageAdapter from '../../../../test/helpers/MemoryRuntimeStorageAdapter.ts'; import { createFakeCodecPort, createMockCrypto } from '../../../helpers/mockPorts.ts'; import type { NormalizedTrustConfig } from '../../../../src/domain/runtimeHelpers.ts'; diff --git a/test/unit/domain/warp/Writer.sameWriterRace.test.ts b/test/unit/domain/warp/Writer.sameWriterRace.test.ts index 159a1d714..888e2ed9a 100644 --- a/test/unit/domain/warp/Writer.sameWriterRace.test.ts +++ b/test/unit/domain/warp/Writer.sameWriterRace.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../../helpers/MemoryRuntimeHost.ts'; import { buildWriterRef } from '../../../../src/domain/utils/RefLayout.ts'; diff --git a/test/unit/domain/warp/buildView.test.ts b/test/unit/domain/warp/buildView.test.ts index c14b35d26..1b52b60c8 100644 --- a/test/unit/domain/warp/buildView.test.ts +++ b/test/unit/domain/warp/buildView.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../../helpers/MemoryRuntimeHost.ts'; import { createEmptyState } from '../../../../src/domain/services/JoinReducer.ts'; diff --git a/test/unit/domain/warp/hydrateCheckpointIndex.regression.test.ts b/test/unit/domain/warp/hydrateCheckpointIndex.regression.test.ts index 7026222e7..2f5235209 100644 --- a/test/unit/domain/warp/hydrateCheckpointIndex.regression.test.ts +++ b/test/unit/domain/warp/hydrateCheckpointIndex.regression.test.ts @@ -51,7 +51,6 @@ describe('materialize stale-checkpoint regression', () => { codec: defaultCodec, crypto: defaultCrypto, persistence: {}, - getSeekCache: () => null, graphName: 'test', graphCloner: {}, patches: { diff --git a/test/unit/domain/worldlineExecutableExamples.test.ts b/test/unit/domain/worldlineExecutableExamples.test.ts index 890f74f1c..03d17faa3 100644 --- a/test/unit/domain/worldlineExecutableExamples.test.ts +++ b/test/unit/domain/worldlineExecutableExamples.test.ts @@ -4,7 +4,7 @@ import WarpApp from '../../../src/domain/WarpApp.ts'; import Observer from '../../../src/domain/services/query/Observer.ts'; import type { Aperture } from '../../../src/domain/types/Aperture.ts'; import WarpWorldline from '../../../src/domain/WarpWorldline.ts'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryWarpApp, openMemoryWarpGraph as openWarpGraph, diff --git a/test/unit/domain/worldlineReadExecutablePaths.test.ts b/test/unit/domain/worldlineReadExecutablePaths.test.ts index 67e51b802..28307f385 100644 --- a/test/unit/domain/worldlineReadExecutablePaths.test.ts +++ b/test/unit/domain/worldlineReadExecutablePaths.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct, openMemoryWarpWorldline as openWarpWorldline, diff --git a/test/unit/helpers/MockStreamingIndexStorage.test.ts b/test/unit/helpers/MockStreamingIndexStorage.test.ts deleted file mode 100644 index ac53c17b5..000000000 --- a/test/unit/helpers/MockStreamingIndexStorage.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import MockStreamingIndexStorage from '../../helpers/MockStreamingIndexStorage.ts'; - -describe('MockStreamingIndexStorage', () => { - it('serves the canonical empty tree', async () => { - const storage = new MockStreamingIndexStorage(); - - await expect(storage.readTreeOids(storage.emptyTree)).resolves.toEqual({}); - await expect(storage.readTree(storage.emptyTree)).resolves.toEqual({}); - }); -}); diff --git a/test/unit/infrastructure/CasIndexStorageAdapter.streamingIndexBuilder.test.ts b/test/unit/infrastructure/CasIndexStorageAdapter.streamingIndexBuilder.test.ts deleted file mode 100644 index 678d5d62c..000000000 --- a/test/unit/infrastructure/CasIndexStorageAdapter.streamingIndexBuilder.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import IndexRebuildService from '../../../src/domain/services/index/IndexRebuildService.ts'; -import BlobStoragePort, { type BlobStorageOptions } from '../../../src/ports/BlobStoragePort.ts'; -import RefPort from '../../../src/ports/RefPort.ts'; -import { CasIndexStorageAdapter } from '../../../src/infrastructure/adapters/CasIndexStorageAdapter.ts'; -import { decodeCasPayloadPointer } from '../../../src/infrastructure/adapters/CasPayloadPointer.ts'; -import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; -import MockBlobPort from '../../helpers/MockBlobPort.ts'; -import MockTreePort from '../../helpers/MockTreePort.ts'; - -const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; - -type IteratedNode = { - readonly sha: string; - readonly parents: string[]; -}; - -class RecordingBlobStorage extends BlobStoragePort { - private readonly _store: Map = new Map(); - private _counter: number = 0; - readonly streamWriteOptions: BlobStorageOptions[] = []; - - override async store(content: Uint8Array | string): Promise { - const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; - const oid = `cas_${String(this._counter).padStart(4, '0')}`; - this._counter += 1; - this._store.set(oid, new Uint8Array(bytes)); - return oid; - } - - override async retrieve(oid: string): Promise { - const bytes = this._store.get(oid); - if (bytes === undefined) { - throw new Error(`missing CAS payload ${oid}`); - } - return new Uint8Array(bytes); - } - - override async storeStream( - source: AsyncIterable, - options?: BlobStorageOptions, - ): Promise { - if (options !== undefined) { - this.streamWriteOptions.push(options); - } - const chunks: Uint8Array[] = []; - for await (const chunk of source) { - chunks.push(chunk); - } - const byteLength = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - const bytes = new Uint8Array(byteLength); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return await this.store(bytes); - } - - override retrieveStream(oid: string): AsyncIterable { - const storage = this; - return { - async *[Symbol.asyncIterator](): AsyncIterator { - yield await storage.retrieve(oid); - }, - }; - } -} - -class MemoryRefPort extends RefPort { - private readonly _refs: Map = new Map(); - - override async updateRef(ref: string, oid: string): Promise { - this._refs.set(ref, oid); - } - - override async readRef(ref: string): Promise { - return this._refs.get(ref) ?? null; - } - - override async deleteRef(ref: string): Promise { - this._refs.delete(ref); - } - - override async listRefs(prefix: string): Promise { - return Array.from(this._refs.keys()).filter((ref) => ref.startsWith(prefix)); - } - - override async compareAndSwapRef( - ref: string, - newOid: string, - expectedOid: string | null, - ): Promise { - const current = this._refs.get(ref) ?? null; - if (current !== expectedOid) { - throw new Error(`ref ${ref} compare-and-swap mismatch`); - } - this._refs.set(ref, newOid); - } -} - -class ReadableCasIndexStorageAdapter extends CasIndexStorageAdapter { - get emptyTree(): string { - return EMPTY_TREE_OID; - } - - async readTree(treeOid: string): Promise> { - const blobOids = await this.readTreeOids(treeOid); - const blobs = new Map(); - for (const [path, oid] of Object.entries(blobOids)) { - blobs.set(path, await this.readBlob(oid)); - } - return Object.fromEntries(blobs); - } -} - -class StaticGraphService { - constructor(private readonly nodes: readonly IteratedNode[]) {} - - async *iterateNodes(): AsyncIterable { - for (const node of this.nodes) { - yield node; - } - } -} - -describe('CasIndexStorageAdapter with streaming index rebuilds', () => { - it('persists streaming index shards as CAS payload pointers', async () => { - const blobPort = new MockBlobPort(); - const treePort = new MockTreePort(); - const blobStorage = new RecordingBlobStorage(); - const storage = new ReadableCasIndexStorageAdapter({ - blobPort, - treePort, - refPort: new MemoryRefPort(), - blobStorage, - }); - const service = new IndexRebuildService({ - graphService: new StaticGraphService([ - { sha: 'aa0001', parents: [] }, - { sha: 'bb0001', parents: ['aa0001'] }, - { sha: 'bb0002', parents: ['aa0001'] }, - ]), - storage, - codec: defaultCodec, - }); - - const treeOid = await service.rebuild('main', { maxMemoryBytes: 1 }); - const tree = await storage.readTreeOids(treeOid); - const paths = Object.keys(tree); - - expect(paths.some((path) => path.includes('shards_fwd_aa.chunk-'))).toBe(true); - expect(paths.some((path) => path.includes('shards_rev_bb.chunk-'))).toBe(true); - expect(blobStorage.streamWriteOptions.length).toBeGreaterThan(0); - - for (const oid of Object.values(tree)) { - const pointerBytes = await blobPort.readBlob(oid); - expect(decodeCasPayloadPointer(pointerBytes)).not.toBeNull(); - } - }); -}); diff --git a/test/unit/infrastructure/CasIndexStorageAdapter.test.ts b/test/unit/infrastructure/CasIndexStorageAdapter.test.ts deleted file mode 100644 index 716ed6a54..000000000 --- a/test/unit/infrastructure/CasIndexStorageAdapter.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import BlobStoragePort from '../../../src/ports/BlobStoragePort.ts'; -import { decodeCasPayloadPointer } from '../../../src/infrastructure/adapters/CasPayloadPointer.ts'; -import { CasIndexStorageAdapter } from '../../../src/infrastructure/adapters/CasIndexStorageAdapter.ts'; -import MockBlobPort from '../../helpers/MockBlobPort.ts'; -import MockTreePort from '../../helpers/MockTreePort.ts'; -import RefPort from '../../../src/ports/RefPort.ts'; - -class MemoryBlobStorage extends BlobStoragePort { - private readonly _store: Map = new Map(); - private _counter: number = 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 storage = this; - return { - async *[Symbol.asyncIterator](): AsyncIterator { - yield await storage.retrieve(oid); - }, - }; - } -} - -class MockRefPort extends RefPort { - private readonly _refs: Map = new Map(); - - override async updateRef(ref: string, oid: string): Promise { - this._refs.set(ref, oid); - } - - override async readRef(ref: string): Promise { - return this._refs.get(ref) ?? null; - } - - override async deleteRef(ref: string): Promise { - this._refs.delete(ref); - } - - override async listRefs(prefix: string): Promise { - return Array.from(this._refs.keys()).filter((ref) => ref.startsWith(prefix)); - } - - override async compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise { - const current = this._refs.get(ref) ?? null; - if (current !== expectedOid) { - throw new Error('CAS mismatch'); - } - this._refs.set(ref, newOid); - } -} - -describe('CasIndexStorageAdapter', () => { - it('stores streamed blobs behind CAS pointer blobs and retrieves them as streams', async () => { - const blobPort = new MockBlobPort(); - const treePort = new MockTreePort(); - const refPort = new MockRefPort(); - const blobStorage = new MemoryBlobStorage(); - const adapter = new CasIndexStorageAdapter({ - blobPort, - treePort, - refPort, - blobStorage, - }); - - const storeStreamSpy = vi.spyOn(blobStorage, 'storeStream'); - const retrieveStreamSpy = vi.spyOn(blobStorage, 'retrieveStream'); - const oid = await adapter.writeBlobStream((async function* () { - yield new Uint8Array([1, 2]); - yield new Uint8Array([3, 4]); - })()); - - const pointerBytes = await blobPort.readBlob(oid); - const storageOid = decodeCasPayloadPointer(pointerBytes); - expect(storageOid).not.toBeNull(); - expect(storeStreamSpy).toHaveBeenCalled(); - - const chunks: Uint8Array[] = []; - for await (const chunk of adapter.readBlobStream(oid)) { - chunks.push(chunk); - } - - expect(retrieveStreamSpy).toHaveBeenCalledWith(storageOid); - expect(chunks).toEqual([new Uint8Array([1, 2, 3, 4])]); - }); -}); diff --git a/test/unit/infrastructure/adapters/CasSeekCacheAdapter.eviction.test.ts b/test/unit/infrastructure/adapters/CasSeekCacheAdapter.eviction.test.ts deleted file mode 100644 index fe2265427..000000000 --- a/test/unit/infrastructure/adapters/CasSeekCacheAdapter.eviction.test.ts +++ /dev/null @@ -1,216 +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: any; - restore: any; - restoreStream: any; - store: any; - createTree: any; - - constructor() { - this.readManifest = mockReadManifest; - this.restore = mockRestore; - this.restoreStream = mockRestoreStream; - this.store = mockStore; - this.createTree = mockCreateTree; - } -} - -class MockCborCodec {} - -vi.mock('@git-stunts/git-cas', () => ({ - default: MockContentAddressableStore, - CborCodec: MockCborCodec, -})); - -const { default: CasSeekCacheAdapter } = await import( - '../../../../src/infrastructure/adapters/CasSeekCacheAdapter.ts' -); - -function makePersistence() { - return { - readRef: vi.fn().mockResolvedValue(null), - readBlob: vi.fn().mockResolvedValue(new TextEncoder().encode('{}')), - writeBlob: vi.fn().mockResolvedValue('blob-oid-1'), - updateRef: vi.fn().mockResolvedValue(undefined), - deleteRef: vi.fn().mockResolvedValue(undefined), - }; -} - -function indexBuffer(entries = {}) { - return new TextEncoder().encode(JSON.stringify({ schemaVersion: 1, entries })); -} - -const GRAPH_NAME = 'test-graph'; - -describe('CasSeekCacheAdapter LRU eviction', () => { - let persistence; - - beforeEach(() => { - vi.clearAllMocks(); - persistence = makePersistence(); - }); - - it('does not evict when under maxEntries', () => { - const smallAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 5, - }); - - const index = { - schemaVersion: 1, - entries: { - 'v1:t1-a': { createdAt: '2025-01-01T00:00:00Z' }, - 'v1:t2-b': { createdAt: '2025-01-02T00:00:00Z' }, - }, - }; - - const result = (smallAdapter as any)._enforceMaxEntries(index); - expect(Object.keys(result.entries)).toHaveLength(2); - }); - - it('evicts oldest entries when exceeding maxEntries', () => { - const smallAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 2, - }); - - const index = { - schemaVersion: 1, - entries: { - 'v1:t1-oldest': { createdAt: '2025-01-01T00:00:00Z' }, - 'v1:t2-middle': { createdAt: '2025-01-02T00:00:00Z' }, - 'v1:t3-newest': { createdAt: '2025-01-03T00:00:00Z' }, - 'v1:t4-latest': { createdAt: '2025-01-04T00:00:00Z' }, - }, - }; - - const result = (smallAdapter as any)._enforceMaxEntries(index); - const remaining = Object.keys(result.entries); - expect(remaining).toHaveLength(2); - expect(remaining).toContain('v1:t3-newest'); - expect(remaining).toContain('v1:t4-latest'); - expect(remaining).not.toContain('v1:t1-oldest'); - expect(remaining).not.toContain('v1:t2-middle'); - }); - - it('evicts exactly the overshoot count', () => { - const smallAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 3, - }); - - const index = { - schemaVersion: 1, - entries: { - 'v1:t1-a': { createdAt: '2025-01-01T00:00:00Z' }, - 'v1:t2-b': { createdAt: '2025-01-02T00:00:00Z' }, - 'v1:t3-c': { createdAt: '2025-01-03T00:00:00Z' }, - 'v1:t4-d': { createdAt: '2025-01-04T00:00:00Z' }, - 'v1:t5-e': { createdAt: '2025-01-05T00:00:00Z' }, - }, - }; - - const result = (smallAdapter as any)._enforceMaxEntries(index); - expect(Object.keys(result.entries)).toHaveLength(3); - }); - - it('prefers lastAccessedAt over createdAt for LRU ordering', () => { - const smallAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 2, - }); - - const index = { - schemaVersion: 1, - entries: { - 'v1:t1-old-but-used': { - createdAt: '2025-01-01T00:00:00Z', - lastAccessedAt: '2025-01-10T00:00:00Z', - }, - 'v1:t2-new-unused': { - createdAt: '2025-01-05T00:00:00Z', - }, - 'v1:t3-newest': { - createdAt: '2025-01-06T00:00:00Z', - }, - }, - }; - - const result = (smallAdapter as any)._enforceMaxEntries(index); - const remaining = Object.keys(result.entries); - expect(remaining).toHaveLength(2); - expect(remaining).toContain('v1:t1-old-but-used'); - expect(remaining).toContain('v1:t3-newest'); - expect(remaining).not.toContain('v1:t2-new-unused'); - }); - - it('handles entries with missing createdAt gracefully', () => { - const smallAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 1, - }); - - const index = { - schemaVersion: 1, - entries: { - 'v1:t1-nodate': {}, - 'v1:t2-hasdate': { createdAt: '2025-06-01T00:00:00Z' }, - }, - }; - - const result = (smallAdapter as any)._enforceMaxEntries(index); - expect(Object.keys(result.entries)).toHaveLength(1); - }); - - it('evicts via set() when maxEntries exceeded', async () => { - const tinyAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 1, - }); - - const existing = { - 'v1:t1-old': { - treeOid: 'old-tree', - createdAt: '2025-01-01T00:00:00Z', - ceiling: 1, - frontierHash: 'old', - sizeBytes: 10, - codec: 'cbor-v1', - schemaVersion: 1, - }, - }; - - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue(indexBuffer(existing)); - mockStore.mockResolvedValue({ chunks: [] }); - mockCreateTree.mockResolvedValue('new-tree'); - - await tinyAdapter.set('v1:t99-newhash', new TextEncoder().encode('new')); - - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(Object.keys(writtenJson.entries)).toHaveLength(1); - expect(writtenJson.entries['v1:t99-newhash']).toBeDefined(); - expect(writtenJson.entries['v1:t1-old']).toBeUndefined(); - }); -}); diff --git a/test/unit/infrastructure/adapters/CasSeekCacheAdapter.test.ts b/test/unit/infrastructure/adapters/CasSeekCacheAdapter.test.ts deleted file mode 100644 index 928bd41c2..000000000 --- a/test/unit/infrastructure/adapters/CasSeekCacheAdapter.test.ts +++ /dev/null @@ -1,763 +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: any; - restore: any; - store: any; - createTree: any; - restoreStream: any; - constructor() { - this.readManifest = mockReadManifest; - this.restore = mockRestore; - this.store = mockStore; - this.createTree = mockCreateTree; - this.restoreStream = (options: any) => { - const configured = mockRestoreStream(options); - if (configured !== undefined) { - return configured; - } - return (async function* () { - const restored = await mockRestore(options); - yield restored.buffer; - })(); - }; - } -} - -const { default: CasSeekCacheAdapter } = await import( - '../../../../src/infrastructure/adapters/CasSeekCacheAdapter.ts' -); -const { default: SeekCachePort } = await import( - '../../../../src/ports/SeekCachePort.ts' -); -const { default: CasContentEncryptionPolicy } = await import( - '../../../../src/infrastructure/adapters/CasContentEncryptionPolicy.ts' -); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Builds a minimal mock persistence port with vi.fn() stubs. */ -function makePersistence() { - return { - readRef: vi.fn().mockResolvedValue(null), - readBlob: vi.fn().mockResolvedValue(new TextEncoder().encode('{}')), - writeBlob: vi.fn().mockResolvedValue('blob-oid-1'), - updateRef: vi.fn().mockResolvedValue(undefined), - deleteRef: vi.fn().mockResolvedValue(undefined), - }; -} - -/** Builds a JSON index buffer for readBlob to return. */ -function indexBuffer(entries = {}) { - return new TextEncoder().encode(JSON.stringify({ schemaVersion: 1, entries })); -} - -const GRAPH_NAME = 'test-graph'; -const EXPECTED_REF = `refs/warp/${GRAPH_NAME}/seek-cache`; -const SAMPLE_KEY = 'v1:t42-abcdef0123456789'; -const SAMPLE_BUFFER = new TextEncoder().encode('serialized-state-data'); - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('CasSeekCacheAdapter', () => { - let persistence; - let adapter; - - beforeEach(() => { - vi.clearAllMocks(); - persistence = makePersistence(); - adapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - }); - }); - - // ------------------------------------------------------------------------- - // Constructor - // ------------------------------------------------------------------------- - - describe('constructor', () => { - it('extends SeekCachePort', () => { - expect(adapter).toBeInstanceOf(SeekCachePort); - }); - - it('defaults maxEntries to 200', () => { - expect((adapter as any)._maxEntries).toBe(200); - }); - - it('respects custom maxEntries', () => { - const custom = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - maxEntries: 50, - }); - expect((custom as any)._maxEntries).toBe(50); - }); - - it('builds the correct ref path', () => { - expect(adapter._ref).toBe(EXPECTED_REF); - }); - - it('stores encryptionKey when provided', () => { - const key = new Uint8Array(32).fill(0xab); - const encrypted = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - encryptionKey: key, - }); - expect((encrypted as any)._encryptionKey).toBe(key); - }); - - it('defaults encryptionKey to undefined', () => { - expect((adapter as any)._encryptionKey).toBeUndefined(); - }); - - }); - - // ------------------------------------------------------------------------- - // _parseKey - // ------------------------------------------------------------------------- - - describe('_parseKey()', () => { - it('extracts ceiling and frontierHash from v1 key', () => { - const result = adapter._parseKey('v1:t42-abcdef0123456789'); - expect(result).toEqual({ ceiling: 42, frontierHash: 'abcdef0123456789' }); - }); - - it('handles large ceiling values', () => { - const result = adapter._parseKey('v1:t99999-deadbeef'); - expect(result).toEqual({ ceiling: 99999, frontierHash: 'deadbeef' }); - }); - - it('handles ceiling of zero', () => { - const result = adapter._parseKey('v1:t0-abc123'); - expect(result).toEqual({ ceiling: 0, frontierHash: 'abc123' }); - }); - - it('handles long frontierHash with dashes', () => { - const result = adapter._parseKey('v1:t7-aa-bb-cc'); - expect(result).toEqual({ ceiling: 7, frontierHash: 'aa-bb-cc' }); - }); - }); - - // ------------------------------------------------------------------------- - // get() - // ------------------------------------------------------------------------- - - describe('get()', () => { - it('returns null on cache miss (key not in index)', async () => { - persistence.readRef.mockResolvedValue(null); - const result = await adapter.get(SAMPLE_KEY); - expect(result).toBeNull(); - }); - - it('returns buffer on cache hit', async () => { - const treeOid = 'tree-oid-abc'; - const manifest = { chunks: ['c1'] }; - const stateBuffer = new TextEncoder().encode('restored-state'); - - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid, createdAt: new Date().toISOString() } }) - ); - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockResolvedValue({ buffer: stateBuffer }); - - const result = await adapter.get(SAMPLE_KEY); - - expect(result).toEqual({ buffer: stateBuffer }); - expect(mockReadManifest).toHaveBeenCalledWith({ treeOid }); - expect(mockRestore).toHaveBeenCalledWith({ manifest }); - }); - - it('returns retained index tree metadata on a cache hit', async () => { - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue(indexBuffer({ - [SAMPLE_KEY]: { - treeOid: 'tree-oid', - indexTreeOid: 'index-tree-oid', - createdAt: new Date().toISOString(), - }, - })); - mockReadManifest.mockResolvedValue({ chunks: [] }); - mockRestore.mockResolvedValue({ buffer: SAMPLE_BUFFER }); - - await expect(adapter.get(SAMPLE_KEY)).resolves.toEqual({ - buffer: SAMPLE_BUFFER, - indexTreeOid: 'index-tree-oid', - }); - }); - - it('updates lastAccessedAt on successful cache hit', async () => { - const treeOid = 'tree-oid-abc'; - const manifest = { chunks: ['c1'] }; - const stateBuffer = new TextEncoder().encode('restored-state'); - const originalEntry = { - treeOid, - createdAt: '2025-01-01T00:00:00Z', - }; - - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: originalEntry }) - ); - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockResolvedValue({ buffer: stateBuffer }); - - await adapter.get(SAMPLE_KEY); - - // Verify index was written back with lastAccessedAt - expect(persistence.writeBlob).toHaveBeenCalled(); - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(writtenJson.entries[SAMPLE_KEY].lastAccessedAt).toBeDefined(); - expect(writtenJson.entries[SAMPLE_KEY].createdAt).toBe('2025-01-01T00:00:00Z'); - }); - - it('self-heals on corrupted/GC-d blob by removing the dead entry', async () => { - const treeOid = 'dead-tree-oid'; - - persistence.readRef.mockResolvedValue('index-oid'); - // First readBlob call returns index with the dead entry - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid, createdAt: new Date().toISOString() } }) - ); - mockReadManifest.mockRejectedValue(new Error('object not found')); - - const result = await adapter.get(SAMPLE_KEY); - - expect(result).toBeNull(); - // Verify it attempted to mutate the index to remove the dead entry - expect(persistence.writeBlob).toHaveBeenCalled(); - expect(persistence.updateRef).toHaveBeenCalled(); - }); - - it('self-heals when restore fails', async () => { - const treeOid = 'bad-tree'; - const manifest = { chunks: ['c1'] }; - - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid, createdAt: new Date().toISOString() } }) - ); - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockRejectedValue(new Error('corrupt chunk')); - - const result = await adapter.get(SAMPLE_KEY); - expect(result).toBeNull(); - }); - - it('preserves encryption failures instead of deleting the cache entry', async () => { - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue(indexBuffer({ - [SAMPLE_KEY]: { treeOid: 'encrypted-tree', createdAt: new Date().toISOString() }, - })); - mockReadManifest.mockResolvedValue({ chunks: [] }); - mockRestore.mockRejectedValue(Object.assign( - new Error('Vault passphrase verification failed'), - { code: 'INTEGRITY_ERROR' }, - )); - - await expect(adapter.get(SAMPLE_KEY)).rejects.toMatchObject({ - code: 'E_CAS_VAULT_PASSPHRASE_FAILED', - }); - expect(persistence.writeBlob).not.toHaveBeenCalled(); - }); - - it('passes encryptionKey to cas.restore when configured', async () => { - const encKey = new Uint8Array(32).fill(0xab); - const encAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - encryptionKey: encKey, - }); - const treeOid = 'tree-oid-enc'; - const manifest = { chunks: ['c1'] }; - const stateBuffer = new TextEncoder().encode('encrypted-state'); - - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid, createdAt: new Date().toISOString() } }) - ); - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockResolvedValue({ buffer: stateBuffer }); - - await encAdapter.get(SAMPLE_KEY); - - expect(mockRestore).toHaveBeenCalledWith({ - manifest, - encryptionKey: encKey, - }); - }); - - it('does not pass encryptionKey to cas.restore when not configured', async () => { - const treeOid = 'tree-oid-plain'; - const manifest = { chunks: ['c1'] }; - - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid, createdAt: new Date().toISOString() } }) - ); - mockReadManifest.mockResolvedValue(manifest); - mockRestore.mockResolvedValue({ buffer: new TextEncoder().encode('plain') }); - - await adapter.get(SAMPLE_KEY); - - expect(mockRestore).toHaveBeenCalledWith({ manifest }); - }); - - it('uses restoreStream() when available, concatenating chunks', async () => { - const streamAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - }); - - const treeOid = 'tree-stream'; - const manifest = { chunks: ['c1', 'c2'] }; - const chunk1 = new TextEncoder().encode('hello-'); - const chunk2 = new TextEncoder().encode('world'); - - persistence.readRef.mockResolvedValue('index-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid, createdAt: new Date().toISOString() } }) - ); - mockReadManifest.mockResolvedValue(manifest); - // restoreStream returns an async iterable of chunks - mockRestoreStream.mockReturnValue((async function* () { - yield chunk1; - yield chunk2; - })()); - - const result = await streamAdapter.get(SAMPLE_KEY); - - expect(result).not.toBeNull(); - expect(new TextDecoder().decode((result!).buffer)).toBe('hello-world'); - expect(mockRestoreStream).toHaveBeenCalledWith({ manifest }); - // Should NOT fall back to cas.restore() - expect(mockRestore).not.toHaveBeenCalled(); - }); - - }); - - // ------------------------------------------------------------------------- - // set() - // ------------------------------------------------------------------------- - - describe('set()', () => { - it('stores buffer via CAS and updates the index', async () => { - const manifest = { chunks: ['c1'] }; - const treeOid = 'new-tree-oid'; - - mockStore.mockResolvedValue(manifest); - mockCreateTree.mockResolvedValue(treeOid); - persistence.readRef.mockResolvedValue(null); - - await adapter.set(SAMPLE_KEY, SAMPLE_BUFFER); - - // CAS store - expect(mockStore).toHaveBeenCalledWith( - expect.objectContaining({ slug: SAMPLE_KEY, filename: 'state.cbor' }) - ); - expect(mockCreateTree).toHaveBeenCalledWith({ manifest }); - - // Index updated - expect(persistence.writeBlob).toHaveBeenCalled(); - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - const entry = writtenJson.entries[SAMPLE_KEY]; - expect(entry.treeOid).toBe(treeOid); - expect(entry.ceiling).toBe(42); - expect(entry.frontierHash).toBe('abcdef0123456789'); - expect(entry.sizeBytes).toBe(SAMPLE_BUFFER.length); - expect(entry.codec).toBe('cbor-v1'); - expect(entry.schemaVersion).toBe(1); - expect(entry.createdAt).toBeDefined(); - }); - - it('stores prototype-like keys as data properties', async () => { - const protoKey = '__proto__'; - const treeOid = 'proto-tree-oid'; - - mockStore.mockResolvedValue({ chunks: [] }); - mockCreateTree.mockResolvedValue(treeOid); - persistence.readRef.mockResolvedValue(null); - - await adapter.set(protoKey, SAMPLE_BUFFER); - - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(Object.hasOwn(writtenJson.entries, protoKey)).toBe(true); - expect(writtenJson.entries[protoKey].treeOid).toBe(treeOid); - }); - - it('preserves existing entries in the index', async () => { - const existingKey = 'v1:t10-existinghash'; - const existingEntry = { - treeOid: 'existing-tree', - createdAt: '2025-01-01T00:00:00.000Z', - ceiling: 10, - frontierHash: 'existinghash', - sizeBytes: 100, - codec: 'cbor-v1', - schemaVersion: 1, - }; - - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [existingKey]: existingEntry }) - ); - mockStore.mockResolvedValue({ chunks: [] }); - mockCreateTree.mockResolvedValue('new-tree'); - - await adapter.set(SAMPLE_KEY, SAMPLE_BUFFER); - - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(writtenJson.entries[existingKey]).toEqual(existingEntry); - expect(writtenJson.entries[SAMPLE_KEY]).toBeDefined(); - }); - - it('passes encryptionKey to cas.store when configured', async () => { - const encKey = new Uint8Array(32).fill(0xab); - const encAdapter = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - encryptionKey: encKey, - }); - - mockStore.mockResolvedValue({ chunks: [] }); - mockCreateTree.mockResolvedValue('enc-tree'); - persistence.readRef.mockResolvedValue(null); - - await encAdapter.set(SAMPLE_KEY, SAMPLE_BUFFER); - - expect(mockStore).toHaveBeenCalledWith( - expect.objectContaining({ encryptionKey: encKey }) - ); - }); - - it('does not pass encryptionKey to cas.store when not configured', async () => { - mockStore.mockResolvedValue({ chunks: [] }); - mockCreateTree.mockResolvedValue('plain-tree'); - persistence.readRef.mockResolvedValue(null); - - await adapter.set(SAMPLE_KEY, SAMPLE_BUFFER); - - const storeArg = (mockStore.mock.calls[0] as any[])[0]; - expect(storeArg.encryptionKey).toBeUndefined(); - }); - - it('stores index metadata with an explicit content-encryption policy', async () => { - const contentEncryption = CasContentEncryptionPolicy.fromResolvedVaultKey({ - encryptionKey: new Uint8Array(32).fill(7), - scheme: 'framed', - frameBytes: 65536, - vault: { - vaultSlug: 'graphs/test/seek-cache', - keyId: 'seek-key-1', - verification: 'verified', - rotationEpoch: 1, - encryptionCount: 1, - encryptionCountLimit: 100, - privacyMode: true, - }, - }); - const encrypted = new CasSeekCacheAdapter({ - persistence, - cas: new MockContentAddressableStore(), - graphName: GRAPH_NAME, - contentEncryption, - }); - mockStore.mockResolvedValue({ chunks: [] }); - mockCreateTree.mockResolvedValue('encrypted-tree'); - persistence.readRef.mockResolvedValue(null); - - await encrypted.set(SAMPLE_KEY, SAMPLE_BUFFER, { indexTreeOid: 'logical-index-tree' }); - - expect(mockStore).toHaveBeenCalledWith(expect.objectContaining({ - encryption: { scheme: 'framed', frameBytes: 65536 }, - })); - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(writtenJson.entries[SAMPLE_KEY].indexTreeOid).toBe('logical-index-tree'); - }); - }); - - // ------------------------------------------------------------------------- - // has() - // ------------------------------------------------------------------------- - - describe('has()', () => { - it('returns false when index is empty', async () => { - persistence.readRef.mockResolvedValue(null); - expect(await adapter.has(SAMPLE_KEY)).toBe(false); - }); - - it('returns true when key exists in the index', async () => { - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid: 't1' } }) - ); - expect(await adapter.has(SAMPLE_KEY)).toBe(true); - }); - - it('returns false for a different key', async () => { - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ 'v1:t99-otherhash': { treeOid: 't1' } }) - ); - expect(await adapter.has(SAMPLE_KEY)).toBe(false); - }); - - it('does not treat inherited object names as cache hits', async () => { - persistence.readRef.mockResolvedValue(null); - expect(await adapter.has('toString')).toBe(false); - }); - }); - - // ------------------------------------------------------------------------- - // keys() - // ------------------------------------------------------------------------- - - describe('keys()', () => { - it('returns empty array when index is empty', async () => { - persistence.readRef.mockResolvedValue(null); - expect(await adapter.keys()).toEqual([]); - }); - - it('returns all keys from the index', async () => { - const entries = { - 'v1:t1-aaa': { treeOid: 't1' }, - 'v1:t2-bbb': { treeOid: 't2' }, - 'v1:t3-ccc': { treeOid: 't3' }, - }; - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue(indexBuffer(entries)); - - const result = await adapter.keys(); - expect(result).toEqual(expect.arrayContaining(['v1:t1-aaa', 'v1:t2-bbb', 'v1:t3-ccc'])); - expect(result).toHaveLength(3); - }); - }); - - // ------------------------------------------------------------------------- - // delete() - // ------------------------------------------------------------------------- - - describe('delete()', () => { - it('returns true when key existed and was removed', async () => { - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ [SAMPLE_KEY]: { treeOid: 't1' } }) - ); - - const result = await adapter.delete(SAMPLE_KEY); - expect(result).toBe(true); - - // Verify the written index no longer contains the key - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(writtenJson.entries[SAMPLE_KEY]).toBeUndefined(); - }); - - it('returns false when key did not exist', async () => { - persistence.readRef.mockResolvedValue(null); - - const result = await adapter.delete(SAMPLE_KEY); - expect(result).toBe(false); - }); - - it('preserves other entries when deleting one', async () => { - const otherKey = 'v1:t5-otherhash'; - persistence.readRef.mockResolvedValue('idx-oid'); - persistence.readBlob.mockResolvedValue( - indexBuffer({ - [SAMPLE_KEY]: { treeOid: 't1' }, - [otherKey]: { treeOid: 't2' }, - }) - ); - - await adapter.delete(SAMPLE_KEY); - - const writtenJson = JSON.parse( - new TextDecoder().decode(persistence.writeBlob.mock.calls[0][0]) - ); - expect(writtenJson.entries[otherKey]).toBeDefined(); - expect(writtenJson.entries[SAMPLE_KEY]).toBeUndefined(); - }); - - it('returns false when deleting an inherited object name', async () => { - persistence.readRef.mockResolvedValue(null); - - const result = await adapter.delete('toString'); - expect(result).toBe(false); - }); - }); - - // ------------------------------------------------------------------------- - // clear() - // ------------------------------------------------------------------------- - - describe('clear()', () => { - it('deletes the index ref', async () => { - await adapter.clear(); - expect(persistence.deleteRef).toHaveBeenCalledWith(EXPECTED_REF); - }); - - it('swallows error when ref does not exist', async () => { - persistence.deleteRef.mockRejectedValue(new Error('ref not found')); - await expect(adapter.clear()).resolves.toBeUndefined(); - }); - }); - - // ------------------------------------------------------------------------- - // Retry logic (_mutateIndex) - // ------------------------------------------------------------------------- - - describe('retry logic (_mutateIndex)', () => { - it('succeeds on first attempt when no error', async () => { - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob.mockResolvedValue('oid'); - - await adapter._mutateIndex((/** @type {any} */ idx) => idx); - expect(persistence.writeBlob).toHaveBeenCalledTimes(1); - }); - - it('retries on transient write failure and succeeds', async () => { - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob - .mockRejectedValueOnce(new Error('lock contention')) - .mockResolvedValueOnce('oid-ok'); - - await adapter._mutateIndex((/** @type {any} */ idx) => idx); - expect(persistence.writeBlob).toHaveBeenCalledTimes(2); - }); - - it('retries up to MAX_CAS_RETRIES (3) then throws', async () => { - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob.mockRejectedValue(new Error('persistent failure')); - - await expect(adapter._mutateIndex((/** @type {any} */ idx) => idx)).rejects.toThrow( - /index update failed after retries/ - ); - expect(persistence.writeBlob).toHaveBeenCalledTimes(3); - }); - - it('re-reads the index on each retry attempt', async () => { - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob - .mockRejectedValueOnce(new Error('fail-1')) - .mockRejectedValueOnce(new Error('fail-2')) - .mockResolvedValueOnce('oid'); - - await adapter._mutateIndex((/** @type {any} */ idx) => idx); - // 3 attempts means 3 readRef calls (one per fresh read) - expect(persistence.readRef).toHaveBeenCalledTimes(3); - }); - - it('returns the mutated index on success', async () => { - persistence.readRef.mockResolvedValue(null); - persistence.writeBlob.mockResolvedValue('oid'); - - const result = await adapter._mutateIndex((/** @type {any} */ idx) => { - idx.entries['test'] = { treeOid: 'x' }; - return idx; - }); - expect(result.entries['test']).toEqual({ treeOid: 'x' }); - }); - }); - - // ------------------------------------------------------------------------- - // _readIndex edge cases - // ------------------------------------------------------------------------- - - describe('_readIndex()', () => { - it('returns empty index when ref does not exist', async () => { - persistence.readRef.mockResolvedValue(null); - const result = await adapter._readIndex(); - expect(result).toEqual({ schemaVersion: 1, entries: {} }); - }); - - it('returns empty index when blob is invalid JSON', async () => { - persistence.readRef.mockResolvedValue('oid'); - persistence.readBlob.mockResolvedValue(new TextEncoder().encode('not-json!!!')); - const result = await adapter._readIndex(); - expect(result).toEqual({ schemaVersion: 1, entries: {} }); - }); - - it('returns empty index when schemaVersion mismatches', async () => { - persistence.readRef.mockResolvedValue('oid'); - persistence.readBlob.mockResolvedValue( - new TextEncoder().encode(JSON.stringify({ schemaVersion: 999, entries: { x: {} } })) - ); - const result = await adapter._readIndex(); - expect(result).toEqual({ schemaVersion: 1, entries: {} }); - }); - - it('returns parsed index when valid', async () => { - const entries = { 'v1:t1-abc': { treeOid: 't1' } }; - persistence.readRef.mockResolvedValue('oid'); - persistence.readBlob.mockResolvedValue(indexBuffer(entries)); - - const result = await adapter._readIndex(); - expect(result).toEqual({ schemaVersion: 1, entries }); - }); - - it('normalizes non-object index entries to an empty index', async () => { - persistence.readRef.mockResolvedValue('oid'); - persistence.readBlob.mockResolvedValue( - new TextEncoder().encode(JSON.stringify({ schemaVersion: 1, entries: null })) - ); - - await expect(adapter._readIndex()).resolves.toEqual({ schemaVersion: 1, entries: {} }); - }); - - it('returns empty index when readBlob throws', async () => { - persistence.readRef.mockResolvedValue('oid'); - persistence.readBlob.mockRejectedValue(new Error('blob missing')); - - const result = await adapter._readIndex(); - expect(result).toEqual({ schemaVersion: 1, entries: {} }); - }); - }); - - // ------------------------------------------------------------------------- - // _writeIndex - // ------------------------------------------------------------------------- - - describe('_writeIndex()', () => { - it('serialises index to JSON, writes blob, and updates ref', async () => { - const index = { schemaVersion: 1, entries: { k: { treeOid: 'x' } } }; - persistence.writeBlob.mockResolvedValue('written-oid'); - - await adapter._writeIndex(index); - - expect(persistence.writeBlob).toHaveBeenCalledTimes(1); - const buf = persistence.writeBlob.mock.calls[0][0]; - expect(JSON.parse(new TextDecoder().decode(buf))).toEqual(index); - expect(persistence.updateRef).toHaveBeenCalledWith(EXPECTED_REF, 'written-oid'); - }); - }); -}); diff --git a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts index e62b9a866..bf7b4a3dd 100644 --- a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts @@ -130,10 +130,6 @@ describe('GitCasRepositoryAdapter', () => { }); await services.content.store('content', { slug: 'content' }); - await repository.createSeekCache('events').set( - 'v1:t1-frontier', - new Uint8Array([1]), - ); const stateSnapshots = services.stateSnapshots; if (stateSnapshots === undefined) { throw new Error('Git repository storage must provide state snapshots'); @@ -169,9 +165,8 @@ describe('GitCasRepositoryAdapter', () => { null, ); - expect(store).toHaveBeenCalledTimes(4); + expect(store).toHaveBeenCalledTimes(3); expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'content' })); - expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'v1:t1-frontier' })); expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'snapshot-1' })); expect(store).toHaveBeenCalledWith(expect.objectContaining({ slug: 'trust-record-hash' })); }); diff --git a/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts b/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts index 1e4b77024..bc0d9b1fb 100644 --- a/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts +++ b/test/unit/infrastructure/adapters/InMemoryBlobStorageAdapter.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import InMemoryBlobStorageAdapter from '../../../../src/infrastructure/adapters/InMemoryBlobStorageAdapter.ts'; +import InMemoryBlobStorageAdapter from '../../../../test/helpers/InMemoryBlobStorageAdapter.ts'; import BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; // --------------------------------------------------------------------------- @@ -125,26 +125,6 @@ describe('InMemoryBlobStorageAdapter', () => { expect(new TextDecoder().decode(result)).toBe('stream write'); }); - it('uses the fallback hash path when web crypto is unavailable', async () => { - const adapter = new InMemoryBlobStorageAdapter(); - const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); - - Object.defineProperty(globalThis, 'crypto', { - value: undefined, - configurable: true, - }); - - try { - const oid = await adapter.store('fallback-hash'); - expect(oid).toMatch(/^[0-9a-f]{16}$/); - } finally { - if (originalDescriptor) { - Object.defineProperty(globalThis, 'crypto', originalDescriptor); - } else { - Reflect.deleteProperty(globalThis, 'crypto'); - } - } - }); }); describe('error cases', () => { diff --git a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.ts b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.ts deleted file mode 100644 index 12b91e1e7..000000000 --- a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.browser.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; -import { sha1sync } from '../../../../src/infrastructure/adapters/sha1sync.ts'; -import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../../helpers/MemoryRuntimeHost.ts'; -import WebCryptoAdapter from '../../../../src/infrastructure/adapters/WebCryptoAdapter.ts'; - -describe('InMemoryGraphAdapter with injected hash (browser simulation)', () => { - it('basic operations work with sha1sync hash function', async () => { - const adapter = new InMemoryGraphAdapter({ hash: sha1sync }); - - const blobOid = await adapter.writeBlob('hello'); - const content = await adapter.readBlob(blobOid); - expect(new TextDecoder().decode(content)).toBe('hello'); - - const sha = await adapter.commitNode({ message: 'test commit' }); - expect(sha).toMatch(/^[0-9a-f]{40}$/); - - const info = await adapter.getNodeInfo(sha); - expect(info.message).toBe('test commit'); - }); - - it('produces identical SHAs to default node:crypto hash', async () => { - const clock = { now: () => 42 }; - const injected = new InMemoryGraphAdapter({ hash: sha1sync, clock }); - const defaultAdapter = new InMemoryGraphAdapter({ clock }); - - const sha1 = await injected.commitNode({ message: 'deterministic' }); - const sha2 = await defaultAdapter.commitNode({ message: 'deterministic' }); - expect(sha1).toBe(sha2); - }); - - it('WarpCore works with injected hash and WebCryptoAdapter', async () => { - const persistence = new InMemoryGraphAdapter({ hash: sha1sync }); - const crypto = new WebCryptoAdapter(); - const graph = await openRuntimeHostProduct({ - persistence, - graphName: 'browser-test', - writerId: 'alice', - crypto, - }); - - const patch = await graph.createPatch(); - patch.addNode('user:alice'); - patch.setProperty('user:alice', 'name', 'Alice'); - await patch.commit(); - - const state = await graph.materialize(); - expect(state.nodeAlive.contains('user:alice')).toBe(true); - }); -}); diff --git a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.integration.test.ts b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.integration.test.ts index f37163750..be444818c 100644 --- a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.integration.test.ts +++ b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.integration.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../../helpers/MemoryRuntimeHost.ts'; describe('InMemoryGraphAdapter integration smoke test', () => { diff --git a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.pathKeys.test.ts b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.pathKeys.test.ts index 97aa56e8e..95b77e35a 100644 --- a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.pathKeys.test.ts +++ b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.pathKeys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; const PROTOTYPE_PATHS = ['__proto__', 'constructor'] as const; diff --git a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.test.ts b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.test.ts index 4febbec80..db1d0acb2 100644 --- a/test/unit/infrastructure/adapters/InMemoryGraphAdapter.test.ts +++ b/test/unit/infrastructure/adapters/InMemoryGraphAdapter.test.ts @@ -3,7 +3,7 @@ import TreeEntryFound from '../../../../src/domain/tree/TreeEntryFound.ts'; import TreeEntryLimit from '../../../../src/domain/tree/TreeEntryLimit.ts'; import TreeEntryMissing from '../../../../src/domain/tree/TreeEntryMissing.ts'; import TreeEntryPath from '../../../../src/domain/tree/TreeEntryPath.ts'; -import InMemoryGraphAdapter from '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../../test/helpers/InMemoryGraphAdapter.ts'; import { describeAdapterConformance } from './AdapterConformance.ts'; class TreeOidMapForbiddenAdapter extends InMemoryGraphAdapter { diff --git a/test/unit/infrastructure/adapters/inMemoryHashing.test.ts b/test/unit/infrastructure/adapters/inMemoryHashing.test.ts deleted file mode 100644 index a6ac2a60a..000000000 --- a/test/unit/infrastructure/adapters/inMemoryHashing.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { - defaultHash, - initCryptoReady, -} from '../../../../src/infrastructure/adapters/inMemoryHashing.ts'; - -describe('inMemoryHashing', () => { - afterEach(() => { - vi.doUnmock('node:crypto'); - vi.resetModules(); - }); - - it('treats node crypto probing as an explicit readiness boundary', async () => { - await expect(initCryptoReady(undefined)).resolves.toBe(true); - - expect(defaultHash(new Uint8Array([1, 2, 3]))) - .toMatch(/^[0-9a-f]{40}$/); - }); - - it('does not probe node crypto when a hash function is injected', async () => { - const injectedHash = () => '0'.repeat(40); - - await expect(initCryptoReady(injectedHash)).resolves.toBe(true); - }); - - it('reports E_NO_HASH at the first hash boundary when node crypto is unavailable', async () => { - vi.resetModules(); - vi.doMock('node:crypto', () => { - throw new Error('node crypto unavailable'); - }); - const { default: InMemoryGraphAdapter } = await import( - '../../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts' - ); - - const adapter = new InMemoryGraphAdapter(); - - await expect(adapter.writeBlob('payload')).rejects.toMatchObject({ - code: 'E_NO_HASH', - }); - }); -}); diff --git a/test/unit/infrastructure/adapters/sha1sync.test.ts b/test/unit/infrastructure/adapters/sha1sync.test.ts deleted file mode 100644 index 8abd6b1fe..000000000 --- a/test/unit/infrastructure/adapters/sha1sync.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createHash } from 'node:crypto'; -import { sha1sync } from '../../../../src/infrastructure/adapters/sha1sync.ts'; - -describe('sha1sync', () => { - it('matches node:crypto for empty input', () => { - const expected = createHash('sha1').update(Buffer.alloc(0)).digest('hex'); - expect(sha1sync(new Uint8Array(0))).toBe(expected); - }); - - it('matches node:crypto for "hello"', () => { - const data = new TextEncoder().encode('hello'); - const expected = createHash('sha1').update(data).digest('hex'); - expect(sha1sync(data)).toBe(expected); - }); - - it('matches node:crypto for a Git blob header', () => { - const content = 'hello world'; - const blob = `blob ${content.length}\0${content}`; - const data = new TextEncoder().encode(blob); - const expected = createHash('sha1').update(data).digest('hex'); - expect(sha1sync(data)).toBe(expected); - }); - - it('matches node:crypto for binary data', () => { - const data = new Uint8Array(256); - for (let i = 0; i < 256; i++) data[i] = i; - const expected = createHash('sha1').update(data).digest('hex'); - expect(sha1sync(data)).toBe(expected); - }); - - it('matches node:crypto for exactly 64-byte input (one block)', () => { - const data = new Uint8Array(64).fill(0x41); // 64 'A' bytes - const expected = createHash('sha1').update(data).digest('hex'); - expect(sha1sync(data)).toBe(expected); - }); - - it('matches node:crypto for multi-block input', () => { - const data = new Uint8Array(1000).fill(0xFF); - const expected = createHash('sha1').update(data).digest('hex'); - expect(sha1sync(data)).toBe(expected); - }); - - it('throws RangeError for inputs >= 512 MB', () => { - // Don't actually allocate 512 MB — verify the guard triggers based on length - const fakeHuge = { length: 0x20000000 }; - expect(() => sha1sync((fakeHuge as any))).toThrow(RangeError); - expect(() => sha1sync((fakeHuge as any))).toThrow('512 MB'); - }); -}); diff --git a/test/unit/ports/RefPort.compareAndSwapRef.test.ts b/test/unit/ports/RefPort.compareAndSwapRef.test.ts index 9a6f88cfe..9f3c7ac8a 100644 --- a/test/unit/ports/RefPort.compareAndSwapRef.test.ts +++ b/test/unit/ports/RefPort.compareAndSwapRef.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; describe('compareAndSwapRef', () => { it('genesis CAS — null expected, ref does not exist → succeeds', async () => { diff --git a/test/unit/ports/SeekCachePort.test.ts b/test/unit/ports/SeekCachePort.test.ts deleted file mode 100644 index 4155254fd..000000000 --- a/test/unit/ports/SeekCachePort.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import SeekCachePort, { - type SeekCacheEntry, - type SeekCacheSetOptions, -} from '../../../src/ports/SeekCachePort.ts'; - -describe('SeekCachePort', () => { - it('abstract methods are not callable on base prototype', () => { - expect(SeekCachePort.prototype.get).toBeUndefined(); - expect(SeekCachePort.prototype.set).toBeUndefined(); - expect(SeekCachePort.prototype.has).toBeUndefined(); - expect(SeekCachePort.prototype.keys).toBeUndefined(); - expect(SeekCachePort.prototype.delete).toBeUndefined(); - expect(SeekCachePort.prototype.clear).toBeUndefined(); - }); - - it('concrete subclass satisfies the contract', async () => { - class TestCache extends SeekCachePort { - async get(_key: string): Promise { return null; } - async set(_key: string, _buffer: Uint8Array, _options?: SeekCacheSetOptions) { /* no-op */ } - async has(_key: string) { return false; } - async keys() { return []; } - async delete(_key: string) { return false; } - async clear() { /* no-op */ } - } - const cache = new TestCache(); - expect(cache).toBeInstanceOf(SeekCachePort); - expect(await cache.get('key')).toBeNull(); - expect(await cache.keys()).toEqual([]); - }); -}); diff --git a/test/unit/scripts/checkpoint-schema-upgrade.test.ts b/test/unit/scripts/checkpoint-schema-upgrade.test.ts index ea42d32ea..d2c2defdf 100644 --- a/test/unit/scripts/checkpoint-schema-upgrade.test.ts +++ b/test/unit/scripts/checkpoint-schema-upgrade.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import InMemoryGraphAdapter from '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import { Dot } from '../../../src/domain/crdt/Dot.ts'; import { createEmptyState } from '../../../src/domain/services/JoinReducer.ts'; diff --git a/test/unit/scripts/storage-ownership-boundary.test.ts b/test/unit/scripts/storage-ownership-boundary.test.ts new file mode 100644 index 000000000..13dcbca6a --- /dev/null +++ b/test/unit/scripts/storage-ownership-boundary.test.ts @@ -0,0 +1,122 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import ts from 'typescript'; +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 REMOVED_PRODUCTION_SYMBOLS = new Set([ + 'CachedValue', + 'CasFirstMemoizationEngine', + 'CasIndexStorageAdapter', + 'CasSeekCacheAdapter', + 'HealthCheckService', + 'InMemoryBlobStorageAdapter', + 'InMemoryGraphAdapter', + 'IndexRebuildService', + 'IndexStalenessChecker', + 'MemoryRuntimeStorageAdapter', + 'MemoryStorage', + 'SeekCachePort', + 'StreamingBitmapIndexBuilder', + 'StreamingCheckpointBasisBuilder', + 'StreamingIndexStoragePort', +]); +const REMOVED_PRODUCTION_IDENTIFIERS = new Set([ + '_adjacencyCache', + '_seekCache', + 'adjacencyCacheSize', + 'buildSeekCacheRef', + 'createSeekCache', + 'defaultBlobStorage', + 'seekCache', + 'setSeekCache', + 'wireSeekCache', +]); + +function productionTypeScriptFiles(relativeRoot: string): string[] { + const absoluteRoot = new URL(`${relativeRoot}/`, REPO_ROOT).pathname; + return walk(absoluteRoot); +} + +function walk(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...walk(path)); + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + files.push(path); + } + } + return files; +} + +function forbiddenReferences( + path: string, + sourceText = readFileSync(path, 'utf8'), +): string[] { + const source = ts.createSourceFile( + path, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); + const violations = new Set(); + const visit = (node: ts.Node): void => { + if ( + ts.isIdentifier(node) && + (REMOVED_PRODUCTION_IDENTIFIERS.has(node.text) || REMOVED_PRODUCTION_SYMBOLS.has(node.text)) + ) { + violations.add(`${relative(REPO_ROOT.pathname, path)} uses ${node.text}`); + } + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + const moduleSpecifier = node.moduleSpecifier; + if (moduleSpecifier === undefined || !ts.isStringLiteral(moduleSpecifier)) { + ts.forEachChild(node, visit); + return; + } + const removed = [...REMOVED_PRODUCTION_SYMBOLS].find((symbol) => + moduleSpecifier.text.includes(symbol) + ); + if (removed !== undefined) { + violations.add(`${relative(REPO_ROOT.pathname, path)} imports ${removed}`); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...violations]; +} + +describe('storage ownership boundary', () => { + it('rejects removed symbols in arbitrary identifier positions', () => { + const fixturePath = new URL('storage-ownership-fixture.ts', REPO_ROOT).pathname; + const violations = forbiddenReferences(fixturePath, ` + const CasSeekCacheAdapter = 1; + function SeekCachePort() { return CasSeekCacheAdapter; } + const active = SeekCachePort; + export { active as CachedValue }; + `).sort(); + + expect(violations).toEqual([ + 'storage-ownership-fixture.ts uses CachedValue', + 'storage-ownership-fixture.ts uses CasSeekCacheAdapter', + 'storage-ownership-fixture.ts uses SeekCachePort', + ]); + }); + + it('keeps removed caches and in-memory storage implementations out of production', () => { + const productionFiles = [ + ...PRODUCTION_ROOTS.flatMap(productionTypeScriptFiles), + ...PRODUCTION_ENTRYPOINTS.map((path) => new URL(path, REPO_ROOT).pathname), + ]; + const violations = productionFiles + .flatMap((path) => forbiddenReferences(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 159571dad..1decf637a 100644 --- a/test/unit/scripts/v16-to-v17-upgrade.test.ts +++ b/test/unit/scripts/v16-to-v17-upgrade.test.ts @@ -4,7 +4,7 @@ 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 '../../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts'; import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; diff --git a/test/unit/scripts/v19-public-api-boundary.test.ts b/test/unit/scripts/v19-public-api-boundary.test.ts index 05c5612e8..8d843ea2b 100644 --- a/test/unit/scripts/v19-public-api-boundary.test.ts +++ b/test/unit/scripts/v19-public-api-boundary.test.ts @@ -211,7 +211,7 @@ describe('v19 public API boundary', () => { it('keeps the storage subpath limited to application adapters', () => { const surface = moduleSurface('storage.ts'); expect(surface.starExports).toEqual([]); - expect(surface.valueExports).toEqual(['GitStorage', 'MemoryStorage']); + expect(surface.valueExports).toEqual(['GitStorage']); expect(surface.typeExports).toEqual(['GitStorageOptions']); }); diff --git a/test/unit/v7-guards.test.ts b/test/unit/v7-guards.test.ts index 9b19372b6..fdbf8c01e 100644 --- a/test/unit/v7-guards.test.ts +++ b/test/unit/v7-guards.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { openMemoryWarpCore } from '../helpers/MemoryRuntimeHost.ts'; import type WarpCore from '../../src/domain/WarpCore.ts'; import { PatchBuilder } from '../../src/domain/services/PatchBuilder.ts'; -import InMemoryGraphAdapter from '../../src/infrastructure/adapters/InMemoryGraphAdapter.ts'; +import InMemoryGraphAdapter from '../../test/helpers/InMemoryGraphAdapter.ts'; function openCore(graphName: string): Promise { return openMemoryWarpCore({