diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05e3b399..245c6d15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,12 +19,16 @@ jobs: cache: 'npm' - run: npm install - run: npm run lint + - name: TypeScript + run: npm run typecheck + - name: TS policy + run: npm run typecheck:policy test-node: runs-on: ubuntu-latest strategy: matrix: - node: [20, 22] + node: [22] steps: - uses: actions/checkout@v4 - name: Run unit + integration tests diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 79dbed46..dbf09d72 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -14,10 +14,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Setup Node 20 + - name: Setup Node 22 uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm - name: Install @@ -26,6 +26,12 @@ jobs: - name: Lint run: npm run lint --if-present + - name: TypeScript + run: npm run typecheck + + - name: TS policy + run: npm run typecheck:policy + - name: Test run: npm run test:local --if-present diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b43991c..5b29cc0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,10 +30,10 @@ jobs: with: ref: ${{ github.event.workflow_run.head_branch }} - - name: Setup Node 20 + - name: Setup Node 22 uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: npm - name: Install diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 61a32112..cb357d43 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -5,6 +5,7 @@ WarpGraph is a graph database built on Git. It uses a patch-based CRDT model where Git commits represent patch objects containing graph operations, with commit messages encoding patch metadata and parent relationships linking patch history. This architecture enables: + - Content-addressable storage with built-in deduplication - Git's proven durability and integrity guarantees - Standard Git tooling compatibility @@ -21,6 +22,7 @@ The codebase follows hexagonal architecture to isolate domain logic from infrast - **Domain services** contain pure business logic with injected dependencies This enables: + - Easy testing via mock adapters - Swappable infrastructure (different Git implementations, logging backends) - Clear separation of concerns @@ -28,6 +30,7 @@ This enables: ### Domain-Driven Design The domain layer models the graph database concepts: + - `GraphNode` - Immutable value object representing a node - `WarpGraph` - Node CRUD operations (the main API class) - `TraversalService` - Graph algorithms (BFS, DFS, shortest path) @@ -40,6 +43,7 @@ The domain layer models the graph database concepts: ### Dependency Injection All services accept their dependencies via constructor options: + - Persistence adapters - Loggers - Clocks @@ -50,10 +54,10 @@ This enables testing with mocks and flexible runtime configuration. ## Layer Diagram ```text -+-------------------------------------------------------------+ ++--------------------------------------------------------------+ | WarpGraph | <- Main API | (WarpGraph.js) | -+-------------------------------------------------------------+ ++--------------------------------------------------------------+ | Supporting Services | | +---------------+ +--------------------+ | | | IndexRebuild | | TraversalService | | @@ -63,11 +67,11 @@ This enables testing with mocks and flexible runtime configuration. | | HealthCheck | | BitmapIndex | | BitmapIndex | | | | Service | | Builder | | Reader | | | +-------------+ +---------------+ +--------------------+ | -| +---------------+ +---------------+ +--------------------+ | +| +---------------+ +--------------------+ | | | GitLogParser | | Streaming | | | | | | BitmapIndexBuilder | | | +---------------+ +--------------------+ | -+-------------------------------------------------------------+ ++--------------------------------------------------------------+ | Ports | | +-------------------+ +---------------------------+ | | | GraphPersistence | | IndexStoragePort | | @@ -76,7 +80,7 @@ This enables testing with mocks and flexible runtime configuration. | +-------------------+ +---------------------------+ | | | LoggerPort | | ClockPort | | | +-------------------+ +---------------------------+ | -+-------------------------------------------------------------+ ++--------------------------------------------------------------+ | Adapters | | +-------------------+ +---------------------------+ | | | GitGraphAdapter | | ConsoleLogger | | @@ -86,7 +90,7 @@ This enables testing with mocks and flexible runtime configuration. | | PerformanceClock | | | | GlobalClock | | | +-------------------+ | -+-------------------------------------------------------------+ ++--------------------------------------------------------------+ ``` ## Directory Structure @@ -137,6 +141,7 @@ src/ ### Main API: WarpGraph The main entry point (`WarpGraph.js`) provides: + - Direct graph database API - `open()` factory for managed mode with automatic durability - Batch API for efficient bulk writes @@ -148,6 +153,7 @@ The main entry point (`WarpGraph.js`) provides: #### WarpGraph Core node operations: + - `createNode()` - Create a single node - `createNodes()` - Bulk creation with placeholder references (`$0`, `$1`) - `readNode()` / `getNode()` - Retrieve node data @@ -160,6 +166,7 @@ Message validation enforces size limits (default 1MB) and non-empty content. #### IndexRebuildService Orchestrates index creation: + - **In-memory mode**: Fast, O(N) memory, single serialization pass - **Streaming mode**: Memory-bounded, flushes to storage periodically @@ -168,6 +175,7 @@ Supports cancellation via `AbortSignal` and progress callbacks. #### TraversalService Graph algorithms using O(1) bitmap lookups: + - `bfs()` / `dfs()` - Traversal generators - `ancestors()` / `descendants()` - Transitive closures - `findPath()` - Any path between nodes @@ -179,6 +187,7 @@ Graph algorithms using O(1) bitmap lookups: - `commonAncestors()` - Find shared ancestors of multiple nodes All traversals support: + - `maxNodes` / `maxDepth` limits - Cancellation via `AbortSignal` - Direction control (forward/reverse) @@ -188,11 +197,13 @@ All traversals support: Roaring bitmap-based indexes for O(1) neighbor lookups: **Builder**: + - `registerNode()` - Assign numeric ID to SHA - `addEdge()` - Record parent/child relationship - `serialize()` - Output sharded JSON structure **Reader**: + - `setup()` - Configure with shard OID mappings - `getParents()` / `getChildren()` - O(1) lookups - Lazy loading with LRU cache for bounded memory @@ -201,6 +212,7 @@ Roaring bitmap-based indexes for O(1) neighbor lookups: #### StreamingBitmapIndexBuilder Memory-bounded variant of BitmapIndexBuilder: + - Flushes bitmap data to storage when threshold exceeded - SHA-to-ID mappings remain in memory (required for consistency) - Merges chunks at finalization via bitmap OR operations @@ -210,6 +222,7 @@ Memory-bounded variant of BitmapIndexBuilder: #### GraphPersistencePort Git operations contract: + - `commitNode()` - Create commit pointing to empty tree - `showNode()` / `getNodeInfo()` - Retrieve commit data - `logNodesStream()` - Stream commit history @@ -223,6 +236,7 @@ Also includes blob/tree operations for index storage. #### IndexStoragePort Index persistence contract: + - `writeBlob()` / `readBlob()` - Blob I/O - `writeTree()` / `readTreeOids()` - Tree I/O - `updateRef()` / `readRef()` - Index ref management @@ -230,12 +244,14 @@ Index persistence contract: #### LoggerPort Structured logging contract: + - `debug()`, `info()`, `warn()`, `error()` - Log levels - `child()` - Create scoped logger with inherited context #### ClockPort Timing abstraction: + - `now()` - High-resolution timestamp (ms) - `timestamp()` - ISO 8601 wall-clock time @@ -244,6 +260,7 @@ Timing abstraction: #### GitGraphAdapter Implements both `GraphPersistencePort` and `IndexStoragePort`: + - Uses `@git-stunts/plumbing` for git command execution - Retry logic with exponential backoff for transient errors - Input validation to prevent command injection @@ -302,11 +319,13 @@ SHA: 4b825dc642cb6eb9a060e54bf8d69288fbee4904 This is the well-known SHA of an empty Git tree, automatically available in every repository. **How it works:** + - **Data**: Stored in commit message (arbitrary payload up to 1MB default) - **Edges**: Commit parent relationships (directed, multi-parent supported) - **Identity**: Commit SHA (content-addressable) **Benefits:** + - Introduces no files into the repository working tree - Content-addressable with automatic deduplication - Git's proven durability and integrity (SHA verification) @@ -331,6 +350,7 @@ index-tree/ ``` **Shard envelope format:** + ```json { "version": 2, @@ -340,6 +360,7 @@ index-tree/ ``` **Meta shard content:** + ```json { "00a1b2c3d4e5f6789...": 0, @@ -348,6 +369,7 @@ index-tree/ ``` **Edge shard content:** + ```json { "00a1b2c3d4e5f6789...": "OjAAAAEAAAAAAAEAEAAAABAAAA==" @@ -369,12 +391,14 @@ Git garbage collection (GC) prunes commits that are not reachable from any ref. ### Modes #### Managed Mode (Default) + In managed mode, WarpGraph guarantees durability for all writes. - Every write operation updates the graph ref (or creates an anchor commit). - Reachability from the ref is maintained automatically. - Users do not need to manage refs or call sync manually. #### Manual Mode + In manual mode, WarpGraph provides no automatic ref management. - Writes create commits but do not update refs. - User is responsible for calling `sync()` to persist reachability. @@ -386,6 +410,7 @@ In manual mode, WarpGraph provides no automatic ref management. Anchor commits are the mechanism used to maintain reachability for disconnected graphs (e.g., disjoint roots or imported history). #### The Problem + In a linear history, every new commit points to the previous tip, maintaining a single chain reachable from the ref. However, graph operations can create disconnected commits: - Creating a new root node (no parents). - Merging unrelated graph histories. @@ -394,6 +419,7 @@ In a linear history, every new commit points to the previous tip, maintaining a If the ref simply moves to the new commit, the old history becomes unreachable and will be GC'd. #### The Solution + An anchor commit is a special infrastructure commit that: - Has multiple **parents**: The previous ref tip AND the new commit(s). - Has an **Empty Tree** (like all WarpGraph nodes). @@ -444,6 +470,7 @@ However, anchors do impact **Materialization** (scanning Git history to build st ### Sync Algorithm (V7) In V7 Multi-Writer mode: + 1. Each writer maintains their own ref (`refs/.../writers/`), pointing to a chain of **Patch Commits**. 2. **Durability** is ensured because writes update these refs. 3. **Global Reachability** (optional) is maintained via `syncCoverage()`, which creates an **Octopus Anchor** commit pointed to by `refs/.../coverage/head`. This anchor has all writer tips as parents, ensuring they aren't GC'd even if individual writer refs are deleted (e.g. during a clone). @@ -495,6 +522,7 @@ for await (const node of graph.iterateNodes({ ``` Supported operations: + - `iterateNodes()` - `rebuildIndex()` - All traversal methods (BFS, DFS, shortest path, etc.) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6731b33..51b35b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [10.4.2] — 2026-02-10 — TS policy enforcement (B3) + +### Added + +- **`scripts/ts-policy-check.js`**: Standalone policy checker that walks `src/`, `bin/`, `scripts/` and enforces two rules: (1) no `@ts-ignore` — use `@ts-expect-error` instead, (2) every inline `@type {*}` / `@type {any}` cast must carry a `// TODO(ts-cleanup): reason` tag. +- **`typecheck:policy` npm script**: Runs the policy checker (`node scripts/ts-policy-check.js`). +- **CI enforcement**: Policy check step added to both `ci.yml` (lint job) and `release-pr.yml` (preflight job), after the existing TypeScript step. +- **Pre-push hook**: Policy check runs in parallel with lint and typecheck. +- **BATS test timing**: `STARTING TEST` / `ENDED TEST` instrumentation in BATS helpers for diagnosing slow tests. + +### Changed + +- **Node.js >= 22.0.0**: Minimum engine bumped from 20 to 22, matching `@git-stunts/git-cas` requirement. CI matrix, release workflows, and documentation updated accordingly. +- **`@git-stunts/git-cas`**: Moved from `optionalDependencies` to `dependencies` now that Node 22 is the minimum. +- **Seek cache write is fire-and-forget**: `WarpGraph.materialize({ ceiling })` no longer awaits the persistent cache write — the CLI exits immediately after emitting output instead of blocking on background I/O (~30s → <1s for seek commands). +- **CLI uses `process.exit()`**: Ensures the process terminates promptly after emitting output, preventing fire-and-forget I/O from holding the event loop open. +- **Pre-push hook**: Removed BATS E2E tests (now CI-only) to keep pre-push fast. +- **`@ts-ignore` → `@ts-expect-error`** across 3 source files and 4 test files. `@ts-expect-error` is strictly better: it errors when the suppression becomes unnecessary. +- **~108 wildcard casts tagged** with `// TODO(ts-cleanup): reason` across ~30 source files in `src/`, `bin/`, and `scripts/`. Categorized reasons: `needs options type`, `type error`, `narrow port type`, `type patch array`, `type CLI payload`, `type http callback`, `type sync protocol`, `type lazy singleton`, `type observer cast`, and others. +- **`TYPESCRIPT_ZERO.md`**: B3 (Policy enforcement) marked complete. + +## [10.4.1] — 2026-02-10 — Default crypto & join() fix + +### Added + +- **`defaultCrypto.js`** (`src/domain/utils/defaultCrypto.js`): Domain-local default crypto adapter wrapping `node:crypto` directly, following the `defaultCodec.js` / `defaultClock.js` pattern. Completes the BULKHEAD port injection pattern — all ports now have domain-local defaults. + +### Fixed + +- **`WarpGraph.join()`**: Replaced 4 references to non-existent `.elements.size` on ORSet with `orsetElements(...).length`. The `join()` happy path was always throwing a TypeError. + +### Changed + +- **`WarpGraph` constructor**: `this._crypto` now falls back to `defaultCrypto` when no crypto adapter is injected (same pattern as `this._codec = codec || defaultCodec`). +- **`BitmapIndexBuilder`**, **`StreamingBitmapIndexBuilder`**, **`BitmapIndexReader`**: Removed `if (!crypto) { return null; }` null guards from `computeChecksum`. Checksums are now always computed. +- **`BitmapIndexReader._validateShard`**: Removed `actualChecksum !== null &&` guard — checksum validation now always runs. +- **`StateSerializerV5.computeStateHashV5`**: Removed `crypto ? ... : null` ternary — always returns a hash string. + +## [10.4.0] — 2026-02-09 — RECALL: Seek Materialization Cache + +Caches materialized `WarpStateV5` at each visited ceiling tick as content-addressed blobs via `@git-stunts/git-cas`, enabling near-instant restoration for previously-visited ticks during seek exploration. Blobs are loose Git objects subject to Git GC (default prune expiry ~2 weeks, configurable) unless pinned to a vault. + +### Added + +- **`SeekCachePort`** (`src/ports/SeekCachePort.js`): Abstract port for seek materialization cache with `get`, `set`, `has`, `keys`, `delete`, `clear` methods. +- **`CasSeekCacheAdapter`** (`src/infrastructure/adapters/CasSeekCacheAdapter.js`): Git-CAS backed adapter with rich index metadata (treeOid, createdAt, ceiling, frontierHash, sizeBytes, codec, schemaVersion), LRU eviction (default max 200 entries), self-healing on read miss (removes dead entries when blobs are GC'd), and retry loop for transient write failures. **Requires Node >= 22.0.0** (inherited from `@git-stunts/git-cas`). +- **`seekCacheKey`** (`src/domain/utils/seekCacheKey.js`): Deterministic cache key builder producing `v1:t-` keys. Uses SHA-256 via `node:crypto` with no fallback. +- **`buildSeekCacheRef`** in `RefLayout.js`: Builds `refs/warp//seek-cache` ref path for the cache index. +- **`WarpGraph.open({ seekCache })`** / **`graph.setSeekCache(cache)`**: Optional `SeekCachePort` for persistent seek cache injection. Cache is checked after in-memory miss and stored after full materialization in `_materializeWithCeiling`. +- **`--clear-cache` flag** on `git warp seek`: Purges the persistent seek cache. +- **`--no-persistent-cache` flag** on `git warp seek`: Bypasses persistent cache for a single invocation (useful for full provenance access or performance testing). +- **Provenance degradation guardrails**: `_provenanceDegraded` flag on WarpGraph, set on persistent cache hit. `patchesFor()` and `materializeSlice()` throw `E_PROVENANCE_DEGRADED` with clear instructions to re-seek with `--no-persistent-cache`. +- **`SeekCachePort` export** from main entry point (`index.js`) and TypeScript definitions (`index.d.ts`). +- **Unit tests** (`test/unit/domain/seekCache.test.js`, 16 tests): Cache key determinism, WarpGraph integration with mock cache (hit/miss/error/degradation), provenance guardrails. +- **ROADMAP milestone RECALL** (v10.4.0): 6 tasks, all closed. + ## [10.3.2] — 2026-02-09 — Seek CLI fixes & demo portability ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fff3d122..6f8ff9b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,10 +44,9 @@ npm test -- # Run specific tests # Multi-runtime test matrix (Docker) npm run test:node22 # Node 22: unit + integration + BATS CLI -npm run test:node20 # Node 20: unit + integration + BATS CLI npm run test:bun # Bun: API integration tests npm run test:deno # Deno: API integration tests -npm run test:matrix # All four runtimes in parallel +npm run test:matrix # All runtimes in parallel ``` ### No-Coordination Invariant diff --git a/README.md b/README.md index f92b3719..432ceb70 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,8 @@ git warp seek --tick=-1 # step backward one tick git warp seek --save before-refactor # bookmark current position git warp seek --load before-refactor # restore bookmark git warp seek --latest # return to present +git warp seek --clear-cache # purge persistent seek cache +git warp seek --no-persistent-cache --tick 5 # skip cache for one invocation # Visualize query results (ascii output by default) git warp query --match 'user:*' --outgoing manages --view @@ -439,15 +441,17 @@ The codebase follows hexagonal architecture with ports and adapters: - `CryptoPort` -- hash/HMAC operations - `LoggerPort` -- structured logging - `ClockPort` -- time measurement +- `SeekCachePort` -- persistent seek materialization cache **Adapters** implement the ports: - `GitGraphAdapter` -- wraps `@git-stunts/plumbing` for Git operations - `ClockAdapter` -- unified clock (factory: `ClockAdapter.node()`, `ClockAdapter.global()`) - `NodeCryptoAdapter` -- cryptographic operations via `node:crypto` -- `WebCryptoAdapter` -- cryptographic operations via Web Crypto API (browsers, Deno, Bun, Node 20+) +- `WebCryptoAdapter` -- cryptographic operations via Web Crypto API (browsers, Deno, Bun, Node 22+) - `NodeHttpAdapter` / `BunHttpAdapter` / `DenoHttpAdapter` -- HTTP server per runtime - `ConsoleLogger` / `NoOpLogger` -- logging implementations - `CborCodec` -- CBOR serialization for patches +- `CasSeekCacheAdapter` -- persistent seek cache via `@git-stunts/git-cas` **Domain** contains the core logic: - `WarpGraph` -- public API facade @@ -483,10 +487,9 @@ npm run lint # eslint # Multi-runtime test matrix (Docker) npm run test:node22 # Node 22: unit + integration + BATS CLI -npm run test:node20 # Node 20: unit + integration + BATS CLI npm run test:bun # Bun: API integration tests npm run test:deno # Deno: API integration tests -npm run test:matrix # All four runtimes in parallel +npm run test:matrix # All runtimes in parallel ``` ## AIΩN Foundations Series diff --git a/ROADMAP.md b/ROADMAP.md index 89de1a51..c38033f4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -200,6 +200,27 @@ Hardens the architecture against runtime coupling. Creates missing port interfac - `GraphPersistencePort` split into `CommitPort`, `BlobPort`, `TreePort`, `RefPort`, `ConfigPort` — existing `GitGraphAdapter` implements all (backward compatible composite). - No behavioral changes. All existing tests must continue to pass. +### v10.4.0 — RECALL + +**Seek Materialization Cache** + +Caches `WarpStateV5` at each visited ceiling tick as content-addressed blobs via `@git-stunts/git-cas`, enabling near-instant restoration for previously-visited ticks during seek exploration. Blobs are loose Git objects that naturally GC unless pinned to a vault. + +**Features (recommended order):** +- RC/PORT — SeekCachePort + seekCacheKey utility + RefLayout builder +- RC/ADAPT — CasSeekCacheAdapter (git-cas storage, index ref, LRU eviction, self-healing) +- RC/WIRE — WarpGraph integration (persistent cache check/store in `_materializeWithCeiling`) +- RC/PROV — Provenance degradation guardrails (E_PROVENANCE_DEGRADED on cache hit) +- RC/CLI — CLI wiring (`--clear-cache`, `--no-persistent-cache` flags on seek) +- RC/TEST — Unit tests (mock cache integration, seekCacheKey, RefLayout) + +**User-Facing Changes:** +- `git warp seek --tick N` automatically caches materialized state for revisited ticks. +- `git warp seek --clear-cache` purges the persistent seek cache. +- `git warp seek --no-persistent-cache --tick N` bypasses persistent cache for a single invocation. +- Provenance queries (`patchesFor`, `materializeSlice`) throw `E_PROVENANCE_DEGRADED` when state was restored from cache; re-seek with `--no-persistent-cache` to get full provenance. +- `WarpGraph.open()` accepts optional `seekCache` port for persistent seek cache injection. + --- ## Milestone Summary @@ -216,6 +237,7 @@ Hardens the architecture against runtime coupling. Creates missing port interfac | 8 | **HOLOGRAM** | v8.0.0 | Provenance & Holography | Complete | | 9 | **ECHO** | v9.0.0 | Observer Geometry | Complete | | 10 | **BULKHEAD** | v10.0.0 | Hexagonal Purity & Structural Integrity | Complete | +| 11 | **RECALL** | v10.4.0 | Seek Materialization Cache | Complete | --- @@ -233,6 +255,8 @@ COMPASS (independent) │ LIGHTHOUSE ────────────────→ HOLOGRAM ──→ ECHO BULKHEAD (independent) + +RECALL (independent — uses git-cas + BULKHEAD ports) ``` - GROUNDSKEEPER depends on AUTOPILOT (auto-materialize foundation). @@ -241,6 +265,7 @@ BULKHEAD (independent) - HOLOGRAM depends on LIGHTHOUSE (tick receipts as foundation). - ECHO depends on HOLOGRAM (provenance payloads). - WEIGHTED, COMPASS, HANDSHAKE can proceed independently. +- RECALL can proceed independently (uses BULKHEAD ports but no hard dependency). --- @@ -337,6 +362,14 @@ BULKHEAD (v10.0.0) █████████████████ ■ BK/WIRE/3 → BK/DRY/2, BK/SRP/4 ■ BK/WIRE/4 +RECALL (v10.4.0) ████████████████████ 100% (6/6) + ■ RC/ADAPT/1 → RC/CLI/1 + ■ RC/CLI/1 + ■ RC/PORT/1 → RC/ADAPT/1, RC/WIRE/1 + ■ RC/PROV/1 → RC/TEST/1 + ■ RC/TEST/1 + ■ RC/WIRE/1 → RC/PROV/1, RC/TEST/1 + Cross-Milestone Dependencies: AP/CKPT/2 → LH/STATUS/1 (LIGHTHOUSE) AP/INVAL/1 → LH/STATUS/1 (LIGHTHOUSE) @@ -2411,6 +2444,95 @@ The architecture claims hexagonal design but has significant boundary violations - Regression: existing Writer tests pass. - Backward compat: import from Writer.js still works. +### RECALL — Task Details + +#### RC/PORT/1 — SeekCachePort + seekCacheKey utility + RefLayout builder + +- **Status:** `CLOSED` +- **User Story:** As a developer, I want a port interface for seek caching so the domain layer doesn't depend on a specific storage backend. +- **Requirements:** + - Create `src/ports/SeekCachePort.js` with abstract methods: `get(key)`, `set(key, buffer)`, `has(key)`, `keys()`, `delete(key)`, `clear()`. + - Create `src/domain/utils/seekCacheKey.js` with `buildSeekCacheKey(ceiling, frontier)` producing versioned, collision-resistant keys using SHA-256 (no fallback). + - Add `buildSeekCacheRef(graphName)` to `src/domain/utils/RefLayout.js`. +- **Scope:** Port interface + utility function + ref builder. +- **Estimated Hours:** 2 +- **Estimated LOC:** ~80 prod + ~40 test +- **Blocked by:** None +- **Blocking:** RC/ADAPT/1, RC/WIRE/1 + +#### RC/ADAPT/1 — CasSeekCacheAdapter + +- **Status:** `CLOSED` +- **User Story:** As a CLI user, I want materialized states cached persistently in Git so revisited ticks restore instantly. +- **Requirements:** + - Create `src/infrastructure/adapters/CasSeekCacheAdapter.js` implementing SeekCachePort. + - Uses `@git-stunts/git-cas` for chunked blob storage. + - Index ref at `refs/warp//seek-cache` with rich metadata (treeOid, createdAt, ceiling, frontierHash, sizeBytes, codec, schemaVersion). + - LRU eviction (default maxEntries=200). + - Self-healing: removes dead entries on read miss (GC'd blobs). + - Optimistic retry loop for index ref updates. +- **Scope:** Adapter implementation. +- **Estimated Hours:** 3 +- **Estimated LOC:** ~200 prod + ~60 test +- **Blocked by:** RC/PORT/1 +- **Blocking:** RC/CLI/1 + +#### RC/WIRE/1 — WarpGraph integration + +- **Status:** `CLOSED` +- **User Story:** As a developer, I want the seek cache automatically consulted during ceiling materialization. +- **Requirements:** + - Add `seekCache` param to `WarpGraph` constructor and `open()`. + - Hook into `_materializeWithCeiling`: persistent cache check after in-memory miss, store after full materialization. + - Cache skipped when `collectReceipts` is true. + - Graceful degradation: cache get/set failures are non-fatal. +- **Scope:** WarpGraph modifications. +- **Estimated Hours:** 2 +- **Estimated LOC:** ~40 prod + ~80 test +- **Blocked by:** RC/PORT/1 +- **Blocking:** RC/PROV/1 + +#### RC/PROV/1 — Provenance degradation guardrails + +- **Status:** `CLOSED` +- **User Story:** As a user, I want clear error messages when provenance is unavailable due to cached seek state. +- **Requirements:** + - Add `_provenanceDegraded` flag to WarpGraph. + - Set flag on persistent cache hit; clear on full materialize. + - Guard `patchesFor()` and `materializeSlice()` with `E_PROVENANCE_DEGRADED` error. +- **Scope:** Error handling + flag management. +- **Estimated Hours:** 1 +- **Estimated LOC:** ~20 prod + ~30 test +- **Blocked by:** RC/WIRE/1 +- **Blocking:** None + +#### RC/CLI/1 — CLI wiring + flags + +- **Status:** `CLOSED` +- **User Story:** As a CLI user, I want seek cache management flags. +- **Requirements:** + - Wire `CasSeekCacheAdapter` in `handleSeek` for all seek commands. + - Add `--clear-cache` flag to purge the seek cache. + - Add `--no-persistent-cache` flag to bypass persistent cache for a single invocation. +- **Scope:** CLI modifications. +- **Estimated Hours:** 2 +- **Estimated LOC:** ~40 prod +- **Blocked by:** RC/ADAPT/1 +- **Blocking:** None + +#### RC/TEST/1 — Unit tests + +- **Status:** `CLOSED` +- **User Story:** As a developer, I want comprehensive tests for the seek cache feature. +- **Requirements:** + - `test/unit/domain/seekCache.test.js`: seekCacheKey determinism, WarpGraph integration with mock cache (hit/miss/error/degraded provenance). + - `test/unit/domain/utils/RefLayout.test.js`: buildSeekCacheRef tests. +- **Scope:** Unit tests only (adapter integration tests deferred to Phase 2). +- **Estimated Hours:** 2 +- **Estimated LOC:** ~250 test +- **Blocked by:** RC/WIRE/1, RC/PROV/1 +- **Blocking:** None + --- ## Non-Goals @@ -2438,7 +2560,8 @@ Things this project should not try to become: | HOLOGRAM | 6 | 7 | 36 | ~1,780 | | ECHO | 3 | 3 | 17 | ~820 | | BULKHEAD | 5 | 15 | 49 | ~2,580 | -| **Total** | **40** | **67** | **230** | **~11,510** | +| RECALL | 6 | 6 | 12 | ~840 | +| **Total** | **46** | **73** | **242** | **~12,350** | --- @@ -2450,4 +2573,4 @@ parking lot so they aren't forgotten. | Idea | Description | |------|-------------| | **Structural seek diff** | Full `diffStates()` between arbitrary ticks returning added/removed nodes, edges, and properties — not just count deltas. Would power a `--diff` flag on `git warp seek` showing exactly what changed at each tick. | -| **git-cas materialization cache** | Cache `WarpStateV5` at each visited ceiling tick as content-addressed blobs via `@git-stunts/git-cas`, enabling O(1) restoration for previously-visited ticks during seek exploration. Blobs naturally GC unless pinned to a vault. | +| **git-cas materialization cache** | ~~Promoted to milestone RECALL (v10.4.0).~~ | diff --git a/TYPESCRIPT_ZERO.md b/TYPESCRIPT_ZERO.md new file mode 100644 index 00000000..5bf64958 --- /dev/null +++ b/TYPESCRIPT_ZERO.md @@ -0,0 +1,81 @@ +# TYPESCRIPT_ZERO — Zero TS Errors Checklist + +> Mantra: "Fast commits, strict pushes, ruthless CI, zero drift." + +Starting errors: **src: 1,513 | test: 7,123 | total: 7,461** +Current errors: **src: 0 | test: 0 | total: 0** + +## Stage A — Infrastructure + +- [x] **A1. Split typecheck configs** + - [x] `tsconfig.base.json` — shared compiler options + - [x] `tsconfig.src.json` — strictest, `src/` + `bin/` + `scripts/` + - [x] `tsconfig.test.json` — extends base, adds `test/` + - [x] Keep existing `tsconfig.json` as the "everything" config (extends base) + +- [x] **A2. npm scripts** + - [x] `"typecheck": "tsc --noEmit"` + - [x] `"typecheck:src": "tsc --noEmit -p tsconfig.src.json"` + - [x] `"typecheck:test": "tsc --noEmit -p tsconfig.test.json"` + +- [x] **A3. Error baseline + ratchet** + - [x] `scripts/ts-ratchet.js` — parse `tsc --pretty false`, count errors by config + - [x] `ts-error-baseline.json` — `{ "src": 0, "test": 0, "total": 0 }` + - [x] CI step: fail if error count > baseline + +- [x] **A4. Git hooks** + - [x] pre-commit: ESLint staged files only (no change needed) + - [x] pre-push: add `npm run typecheck:ratchet` step + +- [x] **A5. CI enforcement** + - [x] `.github/workflows/ci.yml` lint job: add typecheck ratchet step + - [x] `.github/workflows/release-pr.yml`: add typecheck step + +## Stage B — Source Cleanup (`src/` + `bin/` + `scripts/`) + +- [x] **B1. Shared type foundations** + - [x] JSDoc `@typedef` for key types defined inline across files + +- [x] **B2. Source files** (0 remaining) + - [x] `src/domain/services/` batch (0 errors) + - [x] `src/domain/crdt/` batch (0 errors) + - [x] `src/domain/entities/` batch (0 errors) + - [x] `src/domain/errors/` batch (0 errors) + - [x] `src/domain/utils/` batch (0 errors) + - [x] `src/domain/warp/` batch (0 errors) + - [x] `src/domain/types/` batch (0 errors) + - [x] `src/domain/WarpGraph.js` (0 errors) + - [x] `src/ports/` batch (0 errors) + - [x] `src/infrastructure/` batch (0 errors) + - [x] `src/visualization/` batch (0 errors) + - [x] `bin/warp-graph.js` (0 errors) + - [x] `scripts/` batch (0 errors) + +- [x] **B3. Policy enforcement** + - [x] `@ts-expect-error` over `@ts-ignore` for all suppression comments + - [x] Any `@type {*}` MUST have `// TODO(ts-cleanup): reason` + - [x] CI policy check fails on untagged wildcard casts (`scripts/ts-policy-check.js`) + +## Stage C — Test Cleanup (`test/`) + +- [x] **C1. Test helper typing** + - [x] Type annotations on mock factories and test helpers + +- [x] **C2. Test files** (0 remaining) + - [x] `test/unit/domain/services/` batch (0 errors) + - [x] `test/unit/domain/crdt/` batch (0 errors) + - [x] `test/unit/domain/` (root-level test files) (0 errors) + - [x] `test/unit/infrastructure/` batch (0 errors) + - [x] `test/unit/visualization/` batch (0 errors) + - [x] `test/integration/` batch (0 errors) + - [x] `test/benchmark/` batch (0 errors) + +## Stage D — Final Gate + +- [x] `npm run typecheck` exits 0 +- [x] `npm run lint` passes +- [x] `npm run test:local` passes +- [x] Pre-push hook works +- [x] CI pipeline passes +- [x] Remove baseline ratchet (zero is absolute) +- [x] Hard gate: `tsc --noEmit` exit code in CI diff --git a/bin/warp-graph.js b/bin/warp-graph.js index 015fdbf1..f8e6c448 100755 --- a/bin/warp-graph.js +++ b/bin/warp-graph.js @@ -6,6 +6,7 @@ import path from 'node:path'; import process from 'node:process'; import readline from 'node:readline'; import { execFileSync } from 'node:child_process'; +// @ts-expect-error — no type declarations for @git-stunts/plumbing import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing'; import WarpGraph from '../src/domain/WarpGraph.js'; import GitGraphAdapter from '../src/infrastructure/adapters/GitGraphAdapter.js'; @@ -22,6 +23,7 @@ import { buildCursorSavedRef, buildCursorSavedPrefix, } from '../src/domain/utils/RefLayout.js'; +import CasSeekCacheAdapter from '../src/infrastructure/adapters/CasSeekCacheAdapter.js'; import { HookInstaller, classifyExistingHook } from '../src/domain/services/HookInstaller.js'; import { renderInfoView } from '../src/visualization/renderers/ascii/info.js'; import { renderCheckView } from '../src/visualization/renderers/ascii/check.js'; @@ -34,6 +36,85 @@ import { renderGraphView } from '../src/visualization/renderers/ascii/graph.js'; import { renderSvg } from '../src/visualization/renderers/svg/index.js'; import { layoutGraph, queryResultToGraphData, pathResultToGraphData } from '../src/visualization/layouts/index.js'; +/** + * @typedef {Object} Persistence + * @property {(prefix: string) => Promise} listRefs + * @property {(ref: string) => Promise} readRef + * @property {(ref: string, oid: string) => Promise} updateRef + * @property {(ref: string) => Promise} deleteRef + * @property {(oid: string) => Promise} readBlob + * @property {(buf: Buffer) => Promise} writeBlob + * @property {(sha: string) => Promise<{date?: string|null}>} getNodeInfo + * @property {(sha: string, coverageSha: string) => Promise} isAncestor + * @property {() => Promise<{ok: boolean}>} ping + * @property {*} plumbing + */ + +/** + * @typedef {Object} WarpGraphInstance + * @property {(opts?: {ceiling?: number}) => Promise} materialize + * @property {() => Promise>} getNodes + * @property {() => Promise>} getEdges + * @property {() => Promise} createCheckpoint + * @property {() => *} query + * @property {{ shortestPath: Function }} traverse + * @property {(writerId: string) => Promise>} getWriterPatches + * @property {() => Promise<{frontier: Record}>} status + * @property {() => Promise>} getFrontier + * @property {() => {totalTombstones: number, tombstoneRatio: number}} getGCMetrics + * @property {() => Promise} getPropertyCount + * @property {() => Promise<{ticks: number[], maxTick: number, perWriter: Map}>} discoverTicks + * @property {(sha: string) => Promise<{ops?: any[]}>} loadPatchBySha + * @property {(cache: any) => void} setSeekCache + * @property {*} seekCache + * @property {number} [_seekCeiling] + * @property {boolean} [_provenanceDegraded] + */ + +/** + * @typedef {Object} WriterTickInfo + * @property {number[]} ticks + * @property {string|null} tipSha + * @property {Record} [tickShas] + */ + +/** + * @typedef {Object} CursorBlob + * @property {number} tick + * @property {string} [mode] + * @property {number} [nodes] + * @property {number} [edges] + * @property {string} [frontierHash] + */ + +/** + * @typedef {Object} CliOptions + * @property {string} repo + * @property {boolean} json + * @property {string|null} view + * @property {string|null} graph + * @property {string} writer + * @property {boolean} help + */ + +/** + * @typedef {Object} GraphInfoResult + * @property {string} name + * @property {{count: number, ids?: string[]}} writers + * @property {{ref: string, sha: string|null, date?: string|null}} [checkpoint] + * @property {{ref: string, sha: string|null}} [coverage] + * @property {Record} [writerPatches] + * @property {{active: boolean, tick?: number, mode?: string}} [cursor] + */ + +/** + * @typedef {Object} SeekSpec + * @property {string} action + * @property {string|null} tickValue + * @property {string|null} name + * @property {boolean} noPersistentCache + */ + const EXIT_CODES = { OK: 0, USAGE: 1, @@ -111,20 +192,25 @@ class CliError extends Error { } } +/** @param {string} message */ function usageError(message) { return new CliError(message, { code: 'E_USAGE', exitCode: EXIT_CODES.USAGE }); } +/** @param {string} message */ function notFoundError(message) { return new CliError(message, { code: 'E_NOT_FOUND', exitCode: EXIT_CODES.NOT_FOUND }); } +/** @param {*} value */ function stableStringify(value) { + /** @param {*} input @returns {*} */ const normalize = (input) => { if (Array.isArray(input)) { return input.map(normalize); } if (input && typeof input === 'object') { + /** @type {Record} */ const sorted = {}; for (const key of Object.keys(input).sort()) { sorted[key] = normalize(input[key]); @@ -137,8 +223,10 @@ function stableStringify(value) { return JSON.stringify(normalize(value), null, 2); } +/** @param {string[]} argv */ function parseArgs(argv) { const options = createDefaultOptions(); + /** @type {string[]} */ const positionals = []; const optionDefs = [ { flag: '--repo', shortFlag: '-r', key: 'repo' }, @@ -169,6 +257,14 @@ function createDefaultOptions() { }; } +/** + * @param {Object} params + * @param {string[]} params.argv + * @param {number} params.index + * @param {Record} params.options + * @param {Array<{flag: string, shortFlag?: string, key: string}>} params.optionDefs + * @param {string[]} params.positionals + */ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) { const arg = argv[index]; @@ -220,8 +316,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) { shortFlag: matched.shortFlag, allowEmpty: false, }); - options[matched.key] = result.value; - return { consumed: result.consumed }; + if (result) { + options[matched.key] = result.value; + return { consumed: result.consumed }; + } } if (arg.startsWith('-')) { @@ -232,6 +330,10 @@ function consumeBaseArg({ argv, index, options, optionDefs, positionals }) { return { consumed: argv.length - index - 1, done: true }; } +/** + * @param {string} arg + * @param {Array<{flag: string, shortFlag?: string, key: string}>} optionDefs + */ function matchOptionDef(arg, optionDefs) { return optionDefs.find((def) => arg === def.flag || @@ -240,6 +342,7 @@ function matchOptionDef(arg, optionDefs) { ); } +/** @param {string} repoPath @returns {Promise<{persistence: Persistence}>} */ async function createPersistence(repoPath) { const runner = ShellRunnerFactory.create(); const plumbing = new GitPlumbing({ cwd: repoPath, runner }); @@ -251,6 +354,7 @@ async function createPersistence(repoPath) { return { persistence }; } +/** @param {Persistence} persistence @returns {Promise} */ async function listGraphNames(persistence) { if (typeof persistence.listRefs !== 'function') { return []; @@ -273,6 +377,11 @@ async function listGraphNames(persistence) { return [...names].sort(); } +/** + * @param {Persistence} persistence + * @param {string|null} explicitGraph + * @returns {Promise} + */ async function resolveGraphName(persistence, explicitGraph) { if (explicitGraph) { return explicitGraph; @@ -289,14 +398,14 @@ async function resolveGraphName(persistence, explicitGraph) { /** * Collects metadata about a single graph (writer count, refs, patches, checkpoint). - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the graph to inspect * @param {Object} [options] * @param {boolean} [options.includeWriterIds=false] - Include writer ID list * @param {boolean} [options.includeRefs=false] - Include checkpoint/coverage refs * @param {boolean} [options.includeWriterPatches=false] - Include per-writer patch counts * @param {boolean} [options.includeCheckpointDate=false] - Include checkpoint date - * @returns {Promise} Graph info object + * @returns {Promise} Graph info object */ async function getGraphInfo(persistence, graphName, { includeWriterIds = false, @@ -308,11 +417,12 @@ async function getGraphInfo(persistence, graphName, { const writerRefs = typeof persistence.listRefs === 'function' ? await persistence.listRefs(writersPrefix) : []; - const writerIds = writerRefs + const writerIds = /** @type {string[]} */ (writerRefs .map((ref) => parseWriterIdFromRef(ref)) .filter(Boolean) - .sort(); + .sort()); + /** @type {GraphInfoResult} */ const info = { name: graphName, writers: { @@ -328,6 +438,7 @@ async function getGraphInfo(persistence, graphName, { const checkpointRef = buildCheckpointRef(graphName); const checkpointSha = await persistence.readRef(checkpointRef); + /** @type {{ref: string, sha: string|null, date?: string|null}} */ const checkpoint = { ref: checkpointRef, sha: checkpointSha || null }; if (includeCheckpointDate && checkpointSha) { @@ -351,10 +462,11 @@ async function getGraphInfo(persistence, graphName, { writerId: 'cli', crypto: new NodeCryptoAdapter(), }); + /** @type {Record} */ const writerPatches = {}; for (const writerId of writerIds) { const patches = await graph.getWriterPatches(writerId); - writerPatches[writerId] = patches.length; + writerPatches[/** @type {string} */ (writerId)] = patches.length; } info.writerPatches = writerPatches; } @@ -364,11 +476,8 @@ async function getGraphInfo(persistence, graphName, { /** * Opens a WarpGraph for the given CLI options. - * @param {Object} options - Parsed CLI options - * @param {string} [options.repo] - Repository path - * @param {string} [options.graph] - Explicit graph name - * @param {string} [options.writer] - Writer ID - * @returns {Promise<{graph: Object, graphName: string, persistence: Object}>} + * @param {CliOptions} options - Parsed CLI options + * @returns {Promise<{graph: WarpGraphInstance, graphName: string, persistence: Persistence}>} * @throws {CliError} If the specified graph is not found */ async function openGraph(options) { @@ -380,15 +489,16 @@ async function openGraph(options) { throw notFoundError(`Graph not found: ${options.graph}`); } } - const graph = await WarpGraph.open({ + const graph = /** @type {WarpGraphInstance} */ (/** @type {*} */ (await WarpGraph.open({ // TODO(ts-cleanup): narrow port type persistence, graphName, writerId: options.writer, crypto: new NodeCryptoAdapter(), - }); + }))); return { graph, graphName, persistence }; } +/** @param {string[]} args */ function parseQueryArgs(args) { const spec = { match: null, @@ -407,6 +517,11 @@ function parseQueryArgs(args) { return spec; } +/** + * @param {string[]} args + * @param {number} index + * @param {{match: string|null, select: string[]|null, steps: Array<{type: string, label?: string, key?: string, value?: string}>}} spec + */ function consumeQueryArg(args, index, spec) { const stepResult = readTraversalStep(args, index); if (stepResult) { @@ -450,6 +565,7 @@ function consumeQueryArg(args, index, spec) { return null; } +/** @param {string} value */ function parseWhereProp(value) { const [key, ...rest] = value.split('='); if (!key || rest.length === 0) { @@ -458,6 +574,7 @@ function parseWhereProp(value) { return { type: 'where-prop', key, value: rest.join('=') }; } +/** @param {string} value */ function parseSelectFields(value) { if (value === '') { return []; @@ -465,6 +582,10 @@ function parseSelectFields(value) { return value.split(',').map((field) => field.trim()).filter(Boolean); } +/** + * @param {string[]} args + * @param {number} index + */ function readTraversalStep(args, index) { const arg = args[index]; if (arg !== '--outgoing' && arg !== '--incoming') { @@ -476,6 +597,9 @@ function readTraversalStep(args, index) { return { step: { type: arg.slice(2), label }, consumed }; } +/** + * @param {{args: string[], index: number, flag: string, shortFlag?: string, allowEmpty?: boolean}} params + */ function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) { const arg = args[index]; if (matchesOptionFlag(arg, flag, shortFlag)) { @@ -489,10 +613,16 @@ function readOptionValue({ args, index, flag, shortFlag, allowEmpty = false }) { return null; } +/** + * @param {string} arg + * @param {string} flag + * @param {string} [shortFlag] + */ function matchesOptionFlag(arg, flag, shortFlag) { return arg === flag || (shortFlag && arg === shortFlag); } +/** @param {{args: string[], index: number, flag: string, allowEmpty?: boolean}} params */ function readNextOptionValue({ args, index, flag, allowEmpty }) { const value = args[index + 1]; if (value === undefined || (!allowEmpty && value === '')) { @@ -501,6 +631,7 @@ function readNextOptionValue({ args, index, flag, allowEmpty }) { return { value, consumed: 1 }; } +/** @param {{arg: string, flag: string, allowEmpty?: boolean}} params */ function readInlineOptionValue({ arg, flag, allowEmpty }) { const value = arg.slice(flag.length + 1); if (!allowEmpty && value === '') { @@ -509,9 +640,12 @@ function readInlineOptionValue({ arg, flag, allowEmpty }) { return { value, consumed: 0 }; } +/** @param {string[]} args */ function parsePathArgs(args) { const options = createPathOptions(); + /** @type {string[]} */ const labels = []; + /** @type {string[]} */ const positionals = []; for (let i = 0; i < args.length; i += 1) { @@ -523,6 +657,7 @@ function parsePathArgs(args) { return options; } +/** @returns {{from: string|null, to: string|null, dir: string|undefined, labelFilter: string|string[]|undefined, maxDepth: number|undefined}} */ function createPathOptions() { return { from: null, @@ -533,8 +668,12 @@ function createPathOptions() { }; } +/** + * @param {{args: string[], index: number, options: ReturnType, labels: string[], positionals: string[]}} params + */ function consumePathArg({ args, index, options, labels, positionals }) { const arg = args[index]; + /** @type {Array<{flag: string, apply: (value: string) => void}>} */ const handlers = [ { flag: '--from', apply: (value) => { options.from = value; } }, { flag: '--to', apply: (value) => { options.to = value; } }, @@ -559,6 +698,11 @@ function consumePathArg({ args, index, options, labels, positionals }) { return { consumed: 0 }; } +/** + * @param {ReturnType} options + * @param {string[]} labels + * @param {string[]} positionals + */ function finalizePathOptions(options, labels, positionals) { if (!options.from) { options.from = positionals[0] || null; @@ -579,10 +723,12 @@ function finalizePathOptions(options, labels, positionals) { } } +/** @param {string} value */ function parseLabels(value) { return value.split(',').map((label) => label.trim()).filter(Boolean); } +/** @param {string} value */ function parseMaxDepth(value) { const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed)) { @@ -591,7 +737,9 @@ function parseMaxDepth(value) { return parsed; } +/** @param {string[]} args */ function parseHistoryArgs(args) { + /** @type {{node: string|null}} */ const options = { node: null }; for (let i = 0; i < args.length; i += 1) { @@ -622,6 +770,10 @@ function parseHistoryArgs(args) { return options; } +/** + * @param {*} patch + * @param {string} nodeId + */ function patchTouchesNode(patch, nodeId) { const ops = Array.isArray(patch?.ops) ? patch.ops : []; for (const op of ops) { @@ -635,6 +787,7 @@ function patchTouchesNode(patch, nodeId) { return false; } +/** @param {*} payload */ function renderInfo(payload) { const lines = [`Repo: ${payload.repo}`]; lines.push(`Graphs: ${payload.graphs.length}`); @@ -654,6 +807,7 @@ function renderInfo(payload) { return `${lines.join('\n')}\n`; } +/** @param {*} payload */ function renderQuery(payload) { const lines = [ `Graph: ${payload.graph}`, @@ -672,6 +826,7 @@ function renderQuery(payload) { return `${lines.join('\n')}\n`; } +/** @param {*} payload */ function renderPath(payload) { const lines = [ `Graph: ${payload.graph}`, @@ -694,6 +849,7 @@ const ANSI_RED = '\x1b[31m'; const ANSI_DIM = '\x1b[2m'; const ANSI_RESET = '\x1b[0m'; +/** @param {string} state */ function colorCachedState(state) { if (state === 'fresh') { return `${ANSI_GREEN}${state}${ANSI_RESET}`; @@ -704,6 +860,7 @@ function colorCachedState(state) { return `${ANSI_RED}${ANSI_DIM}${state}${ANSI_RESET}`; } +/** @param {*} payload */ function renderCheck(payload) { const lines = [ `Graph: ${payload.graph}`, @@ -754,6 +911,7 @@ function renderCheck(payload) { return `${lines.join('\n')}\n`; } +/** @param {*} hook */ function formatHookStatusLine(hook) { if (!hook.installed && hook.foreign) { return "Hook: foreign hook present — run 'git warp install-hooks'"; @@ -767,6 +925,7 @@ function formatHookStatusLine(hook) { return `Hook: installed (v${hook.version}) — upgrade available, run 'git warp install-hooks'`; } +/** @param {*} payload */ function renderHistory(payload) { const lines = [ `Graph: ${payload.graph}`, @@ -785,6 +944,7 @@ function renderHistory(payload) { return `${lines.join('\n')}\n`; } +/** @param {*} payload */ function renderError(payload) { return `Error: ${payload.error.message}\n`; } @@ -803,11 +963,8 @@ function writeHtmlExport(filePath, svgContent) { * Writes a command result to stdout/stderr in the appropriate format. * Dispatches to JSON, SVG file, HTML file, ASCII view, or plain text * based on the combination of flags. - * @param {Object} payload - Command result payload - * @param {Object} options - * @param {boolean} options.json - Emit JSON to stdout - * @param {string} options.command - Command name (info, query, path, etc.) - * @param {string|boolean} options.view - View mode (true for ascii, 'svg:PATH', 'html:PATH', 'browser') + * @param {*} payload - Command result payload + * @param {{json: boolean, command: string, view: string|null}} options */ function emit(payload, { json, command, view }) { if (json) { @@ -925,9 +1082,8 @@ function emit(payload, { json, command, view }) { /** * Handles the `info` command: summarizes graphs in the repository. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @returns {Promise<{repo: string, graphs: Object[]}>} Info payload + * @param {{options: CliOptions}} params + * @returns {Promise<{repo: string, graphs: GraphInfoResult[]}>} Info payload * @throws {CliError} If the specified graph is not found */ async function handleInfo({ options }) { @@ -974,10 +1130,8 @@ async function handleInfo({ options }) { /** * Handles the `query` command: runs a logical graph query. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @param {string[]} params.args - Remaining positional arguments (query spec) - * @returns {Promise<{payload: Object, exitCode: number}>} Query result payload + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: *, exitCode: number}>} Query result payload * @throws {CliError} On invalid query options or query execution errors */ async function handleQuery({ options, args }) { @@ -1021,6 +1175,10 @@ async function handleQuery({ options, args }) { } } +/** + * @param {*} builder + * @param {Array<{type: string, label?: string, key?: string, value?: string}>} steps + */ function applyQuerySteps(builder, steps) { let current = builder; for (const step of steps) { @@ -1029,6 +1187,10 @@ function applyQuerySteps(builder, steps) { return current; } +/** + * @param {*} builder + * @param {{type: string, label?: string, key?: string, value?: string}} step + */ function applyQueryStep(builder, step) { if (step.type === 'outgoing') { return builder.outgoing(step.label); @@ -1037,11 +1199,16 @@ function applyQueryStep(builder, step) { return builder.incoming(step.label); } if (step.type === 'where-prop') { - return builder.where((node) => matchesPropFilter(node, step.key, step.value)); + return builder.where((/** @type {*} */ node) => matchesPropFilter(node, /** @type {string} */ (step.key), /** @type {string} */ (step.value))); // TODO(ts-cleanup): type CLI payload } return builder; } +/** + * @param {*} node + * @param {string} key + * @param {string} value + */ function matchesPropFilter(node, key, value) { const props = node.props || {}; if (!Object.prototype.hasOwnProperty.call(props, key)) { @@ -1050,6 +1217,11 @@ function matchesPropFilter(node, key, value) { return String(props[key]) === value; } +/** + * @param {string} graphName + * @param {*} result + * @returns {{graph: string, stateHash: *, nodes: *, _renderedSvg?: string, _renderedAscii?: string}} + */ function buildQueryPayload(graphName, result) { return { graph: graphName, @@ -1058,6 +1230,7 @@ function buildQueryPayload(graphName, result) { }; } +/** @param {*} error */ function mapQueryError(error) { if (error && error.code && String(error.code).startsWith('E_QUERY')) { throw usageError(error.message); @@ -1067,10 +1240,8 @@ function mapQueryError(error) { /** * Handles the `path` command: finds a shortest path between two nodes. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @param {string[]} params.args - Remaining positional arguments (path spec) - * @returns {Promise<{payload: Object, exitCode: number}>} Path result payload + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: *, exitCode: number}>} Path result payload * @throws {CliError} If --from/--to are missing or a node is not found */ async function handlePath({ options, args }) { @@ -1107,7 +1278,7 @@ async function handlePath({ options, args }) { payload, exitCode: result.found ? EXIT_CODES.OK : EXIT_CODES.NOT_FOUND, }; - } catch (error) { + } catch (/** @type {*} */ error) { // TODO(ts-cleanup): type error if (error && error.code === 'NODE_NOT_FOUND') { throw notFoundError(error.message); } @@ -1117,9 +1288,8 @@ async function handlePath({ options, args }) { /** * Handles the `check` command: reports graph health, GC, and hook status. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @returns {Promise<{payload: Object, exitCode: number}>} Health check payload + * @param {{options: CliOptions}} params + * @returns {Promise<{payload: *, exitCode: number}>} Health check payload */ async function handleCheck({ options }) { const { graph, graphName, persistence } = await openGraph(options); @@ -1149,17 +1319,20 @@ async function handleCheck({ options }) { }; } +/** @param {Persistence} persistence */ async function getHealth(persistence) { const clock = ClockAdapter.node(); - const healthService = new HealthCheckService({ persistence, clock }); + const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type return await healthService.getHealth(); } +/** @param {WarpGraphInstance} graph */ async function getGcMetrics(graph) { await graph.materialize(); return graph.getGCMetrics(); } +/** @param {WarpGraphInstance} graph */ async function collectWriterHeads(graph) { const frontier = await graph.getFrontier(); return [...frontier.entries()] @@ -1167,6 +1340,10 @@ async function collectWriterHeads(graph) { .map(([writerId, sha]) => ({ writerId, sha })); } +/** + * @param {Persistence} persistence + * @param {string} graphName + */ async function loadCheckpointInfo(persistence, graphName) { const checkpointRef = buildCheckpointRef(graphName); const checkpointSha = await persistence.readRef(checkpointRef); @@ -1181,6 +1358,10 @@ async function loadCheckpointInfo(persistence, graphName) { }; } +/** + * @param {Persistence} persistence + * @param {string|null} checkpointSha + */ async function readCheckpointDate(persistence, checkpointSha) { if (!checkpointSha) { return null; @@ -1189,6 +1370,7 @@ async function readCheckpointDate(persistence, checkpointSha) { return info.date || null; } +/** @param {string|null} checkpointDate */ function computeAgeSeconds(checkpointDate) { if (!checkpointDate) { return null; @@ -1200,6 +1382,11 @@ function computeAgeSeconds(checkpointDate) { return Math.max(0, Math.floor((Date.now() - parsed) / 1000)); } +/** + * @param {Persistence} persistence + * @param {string} graphName + * @param {Array<{writerId: string, sha: string}>} writerHeads + */ async function loadCoverageInfo(persistence, graphName, writerHeads) { const coverageRef = buildCoverageRef(graphName); const coverageSha = await persistence.readRef(coverageRef); @@ -1214,6 +1401,11 @@ async function loadCoverageInfo(persistence, graphName, writerHeads) { }; } +/** + * @param {Persistence} persistence + * @param {Array<{writerId: string, sha: string}>} writerHeads + * @param {string} coverageSha + */ async function findMissingWriters(persistence, writerHeads, coverageSha) { const missing = []; for (const head of writerHeads) { @@ -1225,6 +1417,9 @@ async function findMissingWriters(persistence, writerHeads, coverageSha) { return missing; } +/** + * @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params + */ function buildCheckPayload({ repo, graphName, @@ -1254,10 +1449,8 @@ function buildCheckPayload({ /** * Handles the `history` command: shows patch history for a writer. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @param {string[]} params.args - Remaining positional arguments (history options) - * @returns {Promise<{payload: Object, exitCode: number}>} History payload + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: *, exitCode: number}>} History payload * @throws {CliError} If no patches are found for the writer */ async function handleHistory({ options, args }) { @@ -1269,15 +1462,15 @@ async function handleHistory({ options, args }) { const writerId = options.writer; let patches = await graph.getWriterPatches(writerId); if (cursorInfo.active) { - patches = patches.filter(({ patch }) => patch.lamport <= cursorInfo.tick); + patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload } if (patches.length === 0) { throw notFoundError(`No patches found for writer: ${writerId}`); } const entries = patches - .filter(({ patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) - .map(({ patch, sha }) => ({ + .filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload + .map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload sha, schema: patch.schema, lamport: patch.lamport, @@ -1299,12 +1492,8 @@ async function handleHistory({ options, args }) { * Materializes a single graph, creates a checkpoint, and returns summary stats. * When a ceiling tick is provided (seek cursor active), the checkpoint step is * skipped because the user is exploring historical state, not persisting it. - * @param {Object} params - * @param {Object} params.persistence - GraphPersistencePort adapter - * @param {string} params.graphName - Name of the graph to materialize - * @param {string} params.writerId - Writer ID for the CLI session - * @param {number} [params.ceiling] - Optional seek ceiling tick - * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Object, patchCount: number}>} + * @param {{persistence: Persistence, graphName: string, writerId: string, ceiling?: number}} params + * @returns {Promise<{graph: string, nodes: number, edges: number, properties: number, checkpoint: string|null, writers: Record, patchCount: number}>} */ async function materializeOneGraph({ persistence, graphName, writerId, ceiling }) { const graph = await WarpGraph.open({ persistence, graphName, writerId, crypto: new NodeCryptoAdapter() }); @@ -1315,6 +1504,7 @@ async function materializeOneGraph({ persistence, graphName, writerId, ceiling } const status = await graph.status(); // Build per-writer patch counts for the view renderer + /** @type {Record} */ const writers = {}; let totalPatchCount = 0; for (const wId of Object.keys(status.frontier)) { @@ -1338,9 +1528,8 @@ async function materializeOneGraph({ persistence, graphName, writerId, ceiling } /** * Handles the `materialize` command: materializes and checkpoints all graphs. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @returns {Promise<{payload: Object, exitCode: number}>} Materialize result payload + * @param {{options: CliOptions}} params + * @returns {Promise<{payload: *, exitCode: number}>} Materialize result payload * @throws {CliError} If the specified graph is not found */ async function handleMaterialize({ options }) { @@ -1387,13 +1576,14 @@ async function handleMaterialize({ options }) { } } - const allFailed = results.every((r) => r.error); + const allFailed = results.every((r) => /** @type {*} */ (r).error); // TODO(ts-cleanup): type CLI payload return { payload: { graphs: results }, exitCode: allFailed ? EXIT_CODES.INTERNAL : EXIT_CODES.OK, }; } +/** @param {*} payload */ function renderMaterialize(payload) { if (payload.graphs.length === 0) { return 'No graphs found in repo.\n'; @@ -1410,6 +1600,7 @@ function renderMaterialize(payload) { return `${lines.join('\n')}\n`; } +/** @param {*} payload */ function renderInstallHooks(payload) { if (payload.action === 'up-to-date') { return `Hook: already up to date (v${payload.version}) at ${payload.hookPath}\n`; @@ -1430,7 +1621,7 @@ function createHookInstaller() { const templateDir = path.resolve(__dirname, '..', 'hooks'); const { version } = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8')); return new HookInstaller({ - fs, + fs: /** @type {*} */ (fs), // TODO(ts-cleanup): narrow port type execGitConfig: execGitConfigValue, version, templateDir, @@ -1438,6 +1629,11 @@ function createHookInstaller() { }); } +/** + * @param {string} repoPath + * @param {string} key + * @returns {string|null} + */ function execGitConfigValue(repoPath, key) { try { if (key === '--git-dir') { @@ -1457,6 +1653,7 @@ function isInteractive() { return Boolean(process.stderr.isTTY); } +/** @param {string} question @returns {Promise} */ function promptUser(question) { const rl = readline.createInterface({ input: process.stdin, @@ -1470,6 +1667,7 @@ function promptUser(question) { }); } +/** @param {string[]} args */ function parseInstallHooksArgs(args) { const options = { force: false }; for (const arg of args) { @@ -1482,6 +1680,10 @@ function parseInstallHooksArgs(args) { return options; } +/** + * @param {*} classification + * @param {{force: boolean}} hookOptions + */ async function resolveStrategy(classification, hookOptions) { if (hookOptions.force) { return 'replace'; @@ -1498,6 +1700,7 @@ async function resolveStrategy(classification, hookOptions) { return await promptForForeignStrategy(); } +/** @param {*} classification */ async function promptForOursStrategy(classification) { const installer = createHookInstaller(); if (classification.version === installer._version) { @@ -1539,10 +1742,8 @@ async function promptForForeignStrategy() { /** * Handles the `install-hooks` command: installs or upgrades the post-merge git hook. - * @param {Object} params - * @param {Object} params.options - Parsed CLI options - * @param {string[]} params.args - Remaining positional arguments (install-hooks options) - * @returns {Promise<{payload: Object, exitCode: number}>} Install result payload + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: *, exitCode: number}>} Install result payload * @throws {CliError} If an existing hook is found and the session is not interactive */ async function handleInstallHooks({ options, args }) { @@ -1578,6 +1779,7 @@ async function handleInstallHooks({ options, args }) { }; } +/** @param {string} hookPath */ function readHookContent(hookPath) { try { return fs.readFileSync(hookPath, 'utf8'); @@ -1586,6 +1788,7 @@ function readHookContent(hookPath) { } } +/** @param {string} repoPath */ function getHookStatusForCheck(repoPath) { try { const installer = createHookInstaller(); @@ -1602,10 +1805,9 @@ function getHookStatusForCheck(repoPath) { /** * Reads the active seek cursor for a graph from Git ref storage. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph - * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if no active cursor + * @returns {Promise} Cursor object, or null if no active cursor * @throws {Error} If the stored blob is corrupted or not valid JSON */ async function readActiveCursor(persistence, graphName) { @@ -1624,10 +1826,9 @@ async function readActiveCursor(persistence, graphName) { * Serializes the cursor as JSON, stores it as a Git blob, and points * the active cursor ref at that blob. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph - * @param {{tick: number, mode?: string}} cursor - Cursor state to persist + * @param {CursorBlob} cursor - Cursor state to persist * @returns {Promise} */ async function writeActiveCursor(persistence, graphName, cursor) { @@ -1642,8 +1843,7 @@ async function writeActiveCursor(persistence, graphName, cursor) { * * No-op if no active cursor exists. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph * @returns {Promise} */ @@ -1658,11 +1858,10 @@ async function clearActiveCursor(persistence, graphName) { /** * Reads a named saved cursor from Git ref storage. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph * @param {string} name - Saved cursor name - * @returns {Promise<{tick: number, mode?: string}|null>} Cursor object, or null if not found + * @returns {Promise} Cursor object, or null if not found * @throws {Error} If the stored blob is corrupted or not valid JSON */ async function readSavedCursor(persistence, graphName, name) { @@ -1681,11 +1880,10 @@ async function readSavedCursor(persistence, graphName, name) { * Serializes the cursor as JSON, stores it as a Git blob, and points * the named saved-cursor ref at that blob. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph * @param {string} name - Saved cursor name - * @param {{tick: number, mode?: string}} cursor - Cursor state to persist + * @param {CursorBlob} cursor - Cursor state to persist * @returns {Promise} */ async function writeSavedCursor(persistence, graphName, name, cursor) { @@ -1700,8 +1898,7 @@ async function writeSavedCursor(persistence, graphName, name, cursor) { * * No-op if the named cursor does not exist. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph * @param {string} name - Saved cursor name to delete * @returns {Promise} @@ -1717,8 +1914,7 @@ async function deleteSavedCursor(persistence, graphName, name) { /** * Lists all saved cursors for a graph, reading each blob to include full cursor state. * - * @private - * @param {Object} persistence - GraphPersistencePort adapter + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph * @returns {Promise>} Array of saved cursors with their names * @throws {Error} If any stored blob is corrupted or not valid JSON @@ -1745,23 +1941,33 @@ async function listSavedCursors(persistence, graphName) { // Seek Arg Parser // ============================================================================ +/** + * @param {string} arg + * @param {SeekSpec} spec + */ +function handleSeekBooleanFlag(arg, spec) { + if (arg === '--clear-cache') { + if (spec.action !== 'status') { + throw usageError('--clear-cache cannot be combined with other seek flags'); + } + spec.action = 'clear-cache'; + } else if (arg === '--no-persistent-cache') { + spec.noPersistentCache = true; + } +} + /** * Parses CLI arguments for the `seek` command into a structured spec. - * - * Supports mutually exclusive actions: `--tick `, `--latest`, - * `--save `, `--load `, `--list`, `--drop `. - * Defaults to `status` when no flags are provided. - * - * @private * @param {string[]} args - Raw CLI arguments following the `seek` subcommand - * @returns {{action: string, tickValue: string|null, name: string|null}} Parsed spec - * @throws {CliError} If arguments are invalid or flags are combined + * @returns {SeekSpec} Parsed spec */ function parseSeekArgs(args) { + /** @type {SeekSpec} */ const spec = { - action: 'status', // status, tick, latest, save, load, list, drop + action: 'status', // status, tick, latest, save, load, list, drop, clear-cache tickValue: null, name: null, + noPersistentCache: false, }; for (let i = 0; i < args.length; i++) { @@ -1854,6 +2060,8 @@ function parseSeekArgs(args) { if (!spec.name) { throw usageError('Missing name for --drop'); } + } else if (arg === '--clear-cache' || arg === '--no-persistent-cache') { + handleSeekBooleanFlag(arg, spec); } else if (arg.startsWith('-')) { throw usageError(`Unknown seek option: ${arg}`); } @@ -1909,28 +2117,44 @@ function resolveTickValue(tickValue, currentTick, ticks, maxTick) { // Seek Handler // ============================================================================ +/** + * @param {WarpGraphInstance} graph + * @param {Persistence} persistence + * @param {string} graphName + * @param {SeekSpec} seekSpec + */ +function wireSeekCache(graph, persistence, graphName, seekSpec) { + if (seekSpec.noPersistentCache) { + return; + } + graph.setSeekCache(new CasSeekCacheAdapter({ + persistence, + plumbing: persistence.plumbing, + graphName, + })); +} + /** * Handles the `git warp seek` command across all sub-actions. - * - * Dispatches to the appropriate logic based on the parsed action: - * - `status`: show current cursor position or "no cursor" state - * - `tick`: set the cursor to an absolute or relative Lamport tick - * - `latest`: clear the cursor, returning to present state - * - `save`: persist the active cursor under a name - * - `load`: restore a named cursor as the active cursor - * - `list`: enumerate all saved cursors - * - `drop`: delete a named saved cursor - * - * @private - * @param {Object} params - Command parameters - * @param {Object} params.options - CLI options (repo, graph, writer, json) - * @param {string[]} params.args - Raw CLI arguments following the `seek` subcommand - * @returns {Promise<{payload: Object, exitCode: number}>} Command result with payload and exit code - * @throws {CliError} On invalid arguments or missing cursors + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: *, exitCode: number}>} */ async function handleSeek({ options, args }) { const seekSpec = parseSeekArgs(args); const { graph, graphName, persistence } = await openGraph(options); + void wireSeekCache(graph, persistence, 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 activeCursor = await readActiveCursor(persistence, graphName); const { ticks, maxTick, perWriter } = await graph.discoverTicks(); const frontierHash = computeFrontierHash(perWriter); @@ -1948,11 +2172,12 @@ async function handleSeek({ options, args }) { }; } if (seekSpec.action === 'drop') { - const existing = await readSavedCursor(persistence, graphName, seekSpec.name); + const dropName = /** @type {string} */ (seekSpec.name); + const existing = await readSavedCursor(persistence, graphName, dropName); if (!existing) { - throw notFoundError(`Saved cursor not found: ${seekSpec.name}`); + throw notFoundError(`Saved cursor not found: ${dropName}`); } - await deleteSavedCursor(persistence, graphName, seekSpec.name); + await deleteSavedCursor(persistence, graphName, dropName); return { payload: { graph: graphName, @@ -1992,7 +2217,7 @@ async function handleSeek({ options, args }) { if (!activeCursor) { throw usageError('No active cursor to save. Use --tick first.'); } - await writeSavedCursor(persistence, graphName, seekSpec.name, activeCursor); + await writeSavedCursor(persistence, graphName, /** @type {string} */ (seekSpec.name), activeCursor); return { payload: { graph: graphName, @@ -2004,9 +2229,10 @@ async function handleSeek({ options, args }) { }; } if (seekSpec.action === 'load') { - const saved = await readSavedCursor(persistence, graphName, seekSpec.name); + const loadName = /** @type {string} */ (seekSpec.name); + const saved = await readSavedCursor(persistence, graphName, loadName); if (!saved) { - throw notFoundError(`Saved cursor not found: ${seekSpec.name}`); + throw notFoundError(`Saved cursor not found: ${loadName}`); } await graph.materialize({ ceiling: saved.tick }); const nodes = await graph.getNodes(); @@ -2035,7 +2261,7 @@ async function handleSeek({ options, args }) { } if (seekSpec.action === 'tick') { const currentTick = activeCursor ? activeCursor.tick : null; - const resolvedTick = resolveTickValue(seekSpec.tickValue, currentTick, ticks, maxTick); + const resolvedTick = resolveTickValue(/** @type {string} */ (seekSpec.tickValue), currentTick, ticks, maxTick); await graph.materialize({ ceiling: resolvedTick }); const nodes = await graph.getNodes(); const edges = await graph.getEdges(); @@ -2117,11 +2343,11 @@ async function handleSeek({ options, args }) { /** * Converts the per-writer Map from discoverTicks() into a plain object for JSON output. * - * @private - * @param {Map} perWriter - Per-writer tick data - * @returns {Object} Plain object keyed by writer ID + * @param {Map} perWriter - Per-writer tick data + * @returns {Record} Plain object keyed by writer ID */ function serializePerWriter(perWriter) { + /** @type {Record} */ const result = {}; for (const [writerId, info] of perWriter) { result[writerId] = { ticks: info.ticks, tipSha: info.tipSha, tickShas: info.tickShas }; @@ -2132,9 +2358,8 @@ function serializePerWriter(perWriter) { /** * Counts the total number of patches across all writers at or before the given tick. * - * @private * @param {number} tick - Lamport tick ceiling (inclusive) - * @param {Map} perWriter - Per-writer tick data + * @param {Map} perWriter - Per-writer tick data * @returns {number} Total patch count at or before the given tick */ function countPatchesAtTick(tick, perWriter) { @@ -2155,11 +2380,11 @@ function countPatchesAtTick(tick, perWriter) { * Used to suppress seek diffs when graph history may have changed since the * previous cursor snapshot (e.g. new writers/patches, rewritten refs). * - * @private - * @param {Map} perWriter - Per-writer metadata from discoverTicks() + * @param {Map} perWriter - Per-writer metadata from discoverTicks() * @returns {string} Hex digest of the frontier fingerprint */ function computeFrontierHash(perWriter) { + /** @type {Record} */ const tips = {}; for (const [writerId, info] of perWriter) { tips[writerId] = info?.tipSha || null; @@ -2173,8 +2398,7 @@ function computeFrontierHash(perWriter) { * Counts may be missing for older cursors (pre-diff support). In that case * callers should treat the counts as unknown and suppress diffs. * - * @private - * @param {Object|null} cursor - Parsed cursor blob object + * @param {CursorBlob|null} cursor - Parsed cursor blob object * @returns {{nodes: number|null, edges: number|null}} Parsed counts */ function readSeekCounts(cursor) { @@ -2192,8 +2416,7 @@ function readSeekCounts(cursor) { * * Returns null if the previous cursor is missing cached counts. * - * @private - * @param {Object|null} prevCursor - Cursor object read before updating the position + * @param {CursorBlob|null} prevCursor - Cursor object read before updating the position * @param {{nodes: number, edges: number}} next - Current materialized counts * @param {string} frontierHash - Frontier fingerprint of the current graph * @returns {{nodes: number, edges: number}|null} Diff object or null when unknown @@ -2220,22 +2443,19 @@ function computeSeekStateDiff(prevCursor, next, frontierHash) { * summarizes patch ops. Typically only a handful of writers have a patch at any * single Lamport tick. * - * @private - * @param {Object} params - * @param {number} params.tick - Lamport tick to summarize - * @param {Map} params.perWriter - Per-writer tick metadata from discoverTicks() - * @param {Object} params.graph - WarpGraph instance - * @returns {Promise|null>} Map of writerId → { sha, opSummary }, or null if empty + * @param {{tick: number, perWriter: Map, graph: WarpGraphInstance}} params + * @returns {Promise|null>} Map of writerId to { sha, opSummary }, or null if empty */ async function buildTickReceipt({ tick, perWriter, graph }) { if (!Number.isInteger(tick) || tick <= 0) { return null; } + /** @type {Record} */ const receipt = {}; for (const [writerId, info] of perWriter) { - const sha = info?.tickShas?.[tick]; + const sha = /** @type {*} */ (info?.tickShas)?.[tick]; // TODO(ts-cleanup): type CLI payload if (!sha) { continue; } @@ -2253,12 +2473,11 @@ async function buildTickReceipt({ tick, perWriter, graph }) { * * Handles all seek actions: list, drop, save, latest, load, tick, and status. * - * @private - * @param {Object} payload - Seek result payload from handleSeek + * @param {*} payload - Seek result payload from handleSeek * @returns {string} Formatted output string (includes trailing newline) */ function renderSeek(payload) { - const formatDelta = (n) => { + const formatDelta = (/** @type {*} */ n) => { // TODO(ts-cleanup): type CLI payload if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) { return ''; } @@ -2266,7 +2485,7 @@ function renderSeek(payload) { return ` (${sign}${n})`; }; - const formatOpSummaryPlain = (summary) => { + const formatOpSummaryPlain = (/** @type {*} */ summary) => { // TODO(ts-cleanup): type CLI payload const order = [ ['NodeAdd', '+', 'node'], ['EdgeAdd', '+', 'edge'], @@ -2286,7 +2505,7 @@ function renderSeek(payload) { return parts.length > 0 ? parts.join(' ') : '(empty)'; }; - const appendReceiptSummary = (baseLine) => { + const appendReceiptSummary = (/** @type {string} */ baseLine) => { const tickReceipt = payload?.tickReceipt; if (!tickReceipt || typeof tickReceipt !== 'object') { return `${baseLine}\n`; @@ -2322,6 +2541,10 @@ function renderSeek(payload) { }; }; + if (payload.action === 'clear-cache') { + return `${payload.message}\n`; + } + if (payload.action === 'list') { if (payload.cursors.length === 0) { return 'No saved cursors.\n'; @@ -2381,9 +2604,8 @@ function renderSeek(payload) { * Called by non-seek commands (query, path, check, etc.) that should * honour an active seek cursor. * - * @private - * @param {Object} graph - WarpGraph instance - * @param {Object} persistence - GraphPersistencePort adapter + * @param {WarpGraphInstance} graph - WarpGraph instance + * @param {Persistence} persistence - GraphPersistencePort adapter * @param {string} graphName - Name of the WARP graph * @returns {Promise<{active: boolean, tick: number|null, maxTick: number|null}>} Cursor info — maxTick is always null; non-seek commands intentionally skip discoverTicks() for performance */ @@ -2405,7 +2627,6 @@ async function applyCursorCeiling(graph, persistence, graphName) { * maxTick to avoid the cost of discoverTicks(); the banner then omits the * "of {maxTick}" suffix. Only the seek handler itself populates maxTick. * - * @private * @param {{active: boolean, tick: number|null, maxTick: number|null}} cursorInfo - Result from applyCursorCeiling * @param {number|null} maxTick - Maximum Lamport tick (from discoverTicks), or null if unknown * @returns {void} @@ -2417,6 +2638,10 @@ function emitCursorWarning(cursorInfo, maxTick) { } } +/** + * @param {{options: CliOptions, args: string[]}} params + * @returns {Promise<{payload: *, exitCode: number}>} + */ async function handleView({ options, args }) { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw usageError('view command requires an interactive terminal (TTY)'); @@ -2427,13 +2652,14 @@ async function handleView({ options, args }) { : 'list'; try { + // @ts-expect-error — optional peer dependency, may not be installed const { startTui } = await import('@git-stunts/git-warp-tui'); await startTui({ repo: options.repo || '.', graph: options.graph || 'default', mode: viewMode, }); - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (err.code === 'ERR_MODULE_NOT_FOUND' || (err.message && err.message.includes('Cannot find module'))) { throw usageError( 'Interactive TUI requires @git-stunts/git-warp-tui.\n' + @@ -2445,7 +2671,8 @@ async function handleView({ options, args }) { return { payload: undefined, exitCode: 0 }; } -const COMMANDS = new Map([ +/** @type {Map} */ +const COMMANDS = new Map(/** @type {[string, Function][]} */ ([ ['info', handleInfo], ['query', handleQuery], ['path', handlePath], @@ -2455,7 +2682,7 @@ const COMMANDS = new Map([ ['seek', handleSeek], ['view', handleView], ['install-hooks', handleInstallHooks], -]); +])); /** * CLI entry point. Parses arguments, dispatches to the appropriate command handler, @@ -2492,12 +2719,13 @@ async function main() { throw usageError(`--view is not supported for '${command}'. Supported commands: ${VIEW_SUPPORTED_COMMANDS.join(', ')}`); } - const result = await handler({ + const result = await /** @type {Function} */ (handler)({ command, args: positionals.slice(1), options, }); + /** @type {{payload: *, exitCode: number}} */ const normalized = result && typeof result === 'object' && 'payload' in result ? result : { payload: result, exitCode: EXIT_CODES.OK }; @@ -2505,13 +2733,15 @@ async function main() { if (normalized.payload !== undefined) { emit(normalized.payload, { json: options.json, command, view: options.view }); } - process.exitCode = normalized.exitCode ?? EXIT_CODES.OK; + // Use process.exit() to avoid waiting for fire-and-forget I/O (e.g. seek cache writes). + process.exit(normalized.exitCode ?? EXIT_CODES.OK); } main().catch((error) => { const exitCode = error instanceof CliError ? error.exitCode : EXIT_CODES.INTERNAL; const code = error instanceof CliError ? error.code : 'E_INTERNAL'; const message = error instanceof Error ? error.message : 'Unknown error'; + /** @type {{error: {code: string, message: string, cause?: *}}} */ const payload = { error: { code, message } }; if (error && error.cause) { @@ -2523,5 +2753,5 @@ main().catch((error) => { } else { process.stderr.write(renderError(payload)); } - process.exitCode = exitCode; + process.exit(exitCode); }); diff --git a/docs/GUIDE.md b/docs/GUIDE.md index f72ce03f..a448e4ca 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -12,7 +12,7 @@ WarpGraph is a multi-writer graph database that uses Git commits as its storage ## Prerequisites -- Node.js >= 20.0.0 +- Node.js >= 22.0.0 - Git >= 2.0 ## Installation @@ -938,6 +938,18 @@ git warp seek **How it works:** The cursor is stored as a lightweight Git ref at `refs/warp//cursor/active`. Saved bookmarks live under `refs/warp//cursor/saved/`. When a cursor is active, `materialize()` replays only patches with `lamport <= tick`, and auto-checkpoint is skipped to avoid writing snapshots of past state. +**Materialization cache:** Previously-visited ticks are cached as content-addressed blobs via `@git-stunts/git-cas` (requires Node >= 22), enabling near-instant restoration. The cache is keyed by `(ceiling, frontier)` so it invalidates automatically when new patches arrive. Loose blobs are subject to Git GC (default prune expiry ~2 weeks, configurable) unless pinned to a vault. + +```bash +# Purge the persistent seek cache +git warp seek --clear-cache + +# Bypass cache for a single invocation (enables full provenance access) +git warp seek --no-persistent-cache --tick 5 +``` + +> **Note:** When state is restored from cache, provenance queries (`patchesFor`, `materializeSlice`) are unavailable because the provenance index isn't populated. Use `--no-persistent-cache` if you need provenance data. + **Programmatic API:** ```javascript diff --git a/examples/WALKTHROUGH.md b/examples/WALKTHROUGH.md index 757fc028..0ead3ff3 100644 --- a/examples/WALKTHROUGH.md +++ b/examples/WALKTHROUGH.md @@ -15,7 +15,7 @@ Let's dive in! ## Prerequisites - Docker and Docker Compose installed -- Node.js 20+ (for local development) +- Node.js 22+ (for local development) - About 10 minutes of your time --- diff --git a/index.d.ts b/index.d.ts index 7b70f6e2..57ef5da6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -475,6 +475,30 @@ export class GlobalClockAdapter extends ClockPort { timestamp(): string; } +/** + * Port interface for seek materialization cache operations. + * + * Implementations store serialized WarpStateV5 snapshots keyed by + * (ceiling, frontier) tuples for near-instant restoration of + * previously-visited ticks during seek exploration. + * + * @abstract + */ +export 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: Buffer): Promise; + /** Checks whether a key exists in the cache index. */ + abstract has(key: string): Promise; + /** Lists all keys currently in the cache index. */ + abstract keys(): Promise; + /** Removes a single entry from the cache. */ + abstract delete(key: string): Promise; + /** Removes all entries from the cache. */ + abstract clear(): Promise; +} + /** * Port interface for structured logging operations. * @abstract @@ -1450,6 +1474,7 @@ export default class WarpGraph { clock?: ClockPort; crypto?: CryptoPort; codec?: unknown; + seekCache?: SeekCachePort; }): Promise; /** @@ -1462,6 +1487,12 @@ export default class WarpGraph { */ readonly writerId: string; + /** Returns the attached seek cache, or null if none is set. */ + readonly seekCache: SeekCachePort | null; + + /** Attaches (or detaches, with null) a persistent seek cache. */ + setSeekCache(cache: SeekCachePort | null): void; + /** * Creates a new patch for adding operations. */ diff --git a/index.js b/index.js index eeb8a453..2b874fe5 100644 --- a/index.js +++ b/index.js @@ -32,6 +32,7 @@ import GraphPersistencePort from './src/ports/GraphPersistencePort.js'; import IndexStoragePort from './src/ports/IndexStoragePort.js'; import LoggerPort from './src/ports/LoggerPort.js'; import ClockPort from './src/ports/ClockPort.js'; +import SeekCachePort from './src/ports/SeekCachePort.js'; import NoOpLogger from './src/infrastructure/adapters/NoOpLogger.js'; import ConsoleLogger, { LogLevel } from './src/infrastructure/adapters/ConsoleLogger.js'; import ClockAdapter from './src/infrastructure/adapters/ClockAdapter.js'; @@ -125,6 +126,9 @@ export { // Clock infrastructure ClockPort, + + // Seek cache (RECALL) + SeekCachePort, ClockAdapter, PerformanceClockAdapter, GlobalClockAdapter, diff --git a/jsr.json b/jsr.json index 27dc5698..48ff7c09 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-warp", - "version": "10.3.2", + "version": "10.4.2", "exports": { ".": "./index.js", "./node": "./src/domain/entities/GraphNode.js", diff --git a/package-lock.json b/package-lock.json index 4bf64d92..9f7ad7c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@git-stunts/git-warp", - "version": "10.2.1", + "version": "10.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@git-stunts/git-warp", - "version": "10.2.1", + "version": "10.4.2", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", @@ -42,7 +42,10 @@ "vitest": "^2.1.8" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" + }, + "optionalDependencies": { + "@git-stunts/git-cas": "^3.0.0" } }, "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { @@ -684,6 +687,36 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@git-stunts/git-cas": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-3.0.0.tgz", + "integrity": "sha512-5uqIsTukE+8f1h317ZmGneYpTJ1ecBxg16QJxvF3kNrfQR3/DcAH4fQyMRkCIQtSHEz2p6UpOwpM10R9dEQm/w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@git-stunts/alfred": "^0.10.0", + "@git-stunts/plumbing": "^2.8.0", + "cbor-x": "^1.6.0", + "commander": "^14.0.3", + "zod": "^3.24.1" + }, + "bin": { + "git-cas": "bin/git-cas.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@git-stunts/git-cas/node_modules/@git-stunts/alfred": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@git-stunts/alfred/-/alfred-0.10.3.tgz", + "integrity": "sha512-dvy7Ej9Jyv9gPh4PtQuMfsZnUa7ycIwoFFnXLrQutRdoTTY4F4OOD2kcSJOs3w8UZhwOyLsHO7PcetaKB9g32w==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@git-stunts/plumbing": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@git-stunts/plumbing/-/plumbing-2.8.0.tgz", @@ -2144,6 +2177,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index cb2e6ea2..328e2048 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@git-stunts/git-warp", - "version": "10.3.2", + "version": "10.4.2", "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.", "type": "module", "license": "Apache-2.0", @@ -14,7 +14,7 @@ "url": "https://github.com/git-stunts/git-warp/issues" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" }, "sideEffects": false, "main": "./index.js", @@ -84,10 +84,15 @@ "test:node22": "docker compose -f docker-compose.test.yml --profile node22 run --build --rm test-node22", "test:bun": "docker compose -f docker-compose.test.yml --profile bun run --build --rm test-bun", "test:deno": "docker compose -f docker-compose.test.yml --profile deno run --build --rm test-deno", - "test:matrix": "docker compose -f docker-compose.test.yml --profile full up --build --abort-on-container-exit" + "test:matrix": "docker compose -f docker-compose.test.yml --profile full up --build --abort-on-container-exit", + "typecheck": "tsc --noEmit", + "typecheck:src": "tsc --noEmit -p tsconfig.src.json", + "typecheck:test": "tsc --noEmit -p tsconfig.test.json", + "typecheck:policy": "node scripts/ts-policy-check.js" }, "dependencies": { "@git-stunts/alfred": "^0.4.0", + "@git-stunts/git-cas": "^3.0.0", "@git-stunts/plumbing": "^2.8.0", "@git-stunts/trailer-codec": "^2.1.1", "boxen": "^7.1.1", diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 3f35f923..99f66085 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -18,7 +18,7 @@ echo "Running eslint on staged files..." # Run eslint using NUL-safe xargs # Use -- to prevent filenames starting with '-' from being treated as options -xargs -0 npx eslint -- < "$TMPFILE" +xargs -0 npx eslint --no-warn-ignored -- < "$TMPFILE" ESLINT_EXIT=$? diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index b5a02bbd..414478c2 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -8,22 +8,26 @@ if [ -z "$ROOT" ]; then fi cd "$ROOT" -if ! command -v docker >/dev/null 2>&1; then - echo "pre-push: docker is required to run git-warp CLI bats tests" >&2 - exit 1 -fi - echo "Running pre-push link check..." if command -v lychee >/dev/null 2>&1; then lychee --config .lychee.toml '**/*.md' else echo " skipped (lychee not installed — brew install lychee)" fi -echo "Running pre-push lint..." -npm run lint + +# Run lint + typecheck + policy in parallel (all are read-only) +echo "Running pre-push lint + typecheck + policy (parallel)..." +npm run lint & +LINT_PID=$! +npm run typecheck & +TC_PID=$! +npm run typecheck:policy & +POLICY_PID=$! +wait $LINT_PID || { echo "Lint failed"; exit 1; } +wait $TC_PID || { echo "Typecheck failed"; exit 1; } +wait $POLICY_PID || { echo "TS policy check failed"; exit 1; } + echo "Running pre-push unit tests..." -npm test -echo "Running pre-push benchmarks..." -npm run benchmark -echo "Running pre-push git-warp CLI bats tests..." -docker compose run --build --rm test bats test/bats/ +npm run test:local + +# Docker builds, benchmarks, and BATS E2E tests run in CI only diff --git a/scripts/roadmap.js b/scripts/roadmap.js index fd84d438..1661adcc 100755 --- a/scripts/roadmap.js +++ b/scripts/roadmap.js @@ -32,32 +32,47 @@ const MILESTONES = [ { code: 'HG', name: 'HOLOGRAM', version: 'v8.0.0' }, { code: 'EC', name: 'ECHO', version: 'v9.0.0' }, { code: 'BK', name: 'BULKHEAD', version: 'v10.0.0' }, + { code: 'RC', name: 'RECALL', version: 'v10.4.0' }, ]; // ── Helpers ────────────────────────────────────────────────────────────────── +/** @param {string} s */ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -/** Extract task IDs (XX/YYY/N pattern) from a string, ignoring parenthetical notes. */ +/** + * Extract task IDs (XX/YYY/N pattern) from a string, ignoring parenthetical notes. + * @param {string} str + */ function extractTaskIds(str) { if (!str || str.trim() === 'None') return []; return [...str.matchAll(/[A-Z]{2}\/[A-Z]+\/\d+/g)].map(m => m[0]); } +/** @param {string} taskId */ function getMilestone(taskId) { const prefix = taskId.split('/')[0]; return MILESTONES.find(m => m.code === prefix); } +/** + * @param {string} s + * @param {number} n + */ function pad(s, n) { return s.length >= n ? s : s + ' '.repeat(n - s.length); } // ── Parsing ────────────────────────────────────────────────────────────────── +/** + * @param {string} content + * @returns {Map} + */ function parseTasks(content) { + /** @type {Map} */ const tasks = new Map(); const regex = /^####\s+([\w/]+)\s+—\s+(.+)$/gm; const headers = []; @@ -94,9 +109,23 @@ function parseTasks(content) { return tasks; } +/** + * @typedef {{ + * id: string, + * title: string, + * status: string | null, + * blockedBy: string[], + * blocking: string[], + * hours: number, + * milestone: string, + * milestoneCode: string, + * }} Task + */ + /** * Normalize the graph: ensure blocking/blockedBy are symmetric. * Some cross-milestone edges are only recorded on one side in the ROADMAP. + * @param {Map} tasks */ function normalizeGraph(tasks) { for (const [id, task] of tasks) { @@ -122,6 +151,9 @@ function normalizeGraph(tasks) { /** * Set (or insert) a task's Status field in the ROADMAP content string. * Searches by task ID pattern so it works even after prior edits shift positions. + * @param {string} content + * @param {string} taskId + * @param {string} newStatus */ function setTaskStatus(content, taskId, newStatus) { const escapedId = escapeRegex(taskId); @@ -181,6 +213,7 @@ function setTaskStatus(content, taskId, newStatus) { // ── DAG generation ─────────────────────────────────────────────────────────── +/** @param {string | null} status */ function statusIcon(status) { switch (status) { case 'CLOSED': @@ -194,11 +227,17 @@ function statusIcon(status) { } } +/** + * @param {number} done + * @param {number} total + * @param {number} [width] + */ function progressBar(done, total, width = 20) { const filled = total > 0 ? Math.round((done / total) * width) : 0; return '█'.repeat(filled) + '░'.repeat(width - filled); } +/** @param {Map} tasks */ function generateDagMarkdown(tasks) { const lines = []; lines.push('```'); @@ -254,6 +293,10 @@ function generateDagMarkdown(tasks) { return lines.join('\n'); } +/** + * @param {string} content + * @param {Map} tasks + */ function updateDag(content, tasks) { const dag = generateDagMarkdown(tasks); const startIdx = content.indexOf(DAG_START); @@ -277,6 +320,7 @@ function updateDag(content, tasks) { // ── Commands ───────────────────────────────────────────────────────────────── +/** @param {string} taskId */ function cmdClose(taskId) { let content = readFileSync(ROADMAP_PATH, 'utf8'); const tasks = parseTasks(content); @@ -454,6 +498,7 @@ function cmdShow() { const BOLD = '\x1b[1m'; const CYAN = '\x1b[36m'; + /** @param {string | null} status */ function colorIcon(status) { switch (status) { case 'CLOSED': @@ -467,6 +512,10 @@ function cmdShow() { } } + /** + * @param {string} id + * @param {string | null} status + */ function colorId(id, status) { switch (status) { case 'CLOSED': diff --git a/scripts/setup-hooks.js b/scripts/setup-hooks.js index 5c7394d0..4ec92251 100644 --- a/scripts/setup-hooks.js +++ b/scripts/setup-hooks.js @@ -33,6 +33,6 @@ try { console.log('✅ Git hooks configured successfully'); console.log(` Hooks directory: ${hooksDir}`); } catch (err) { - console.error('❌ Failed to configure git hooks:', err.message); + console.error('❌ Failed to configure git hooks:', /** @type {any} */ (err).message); // TODO(ts-cleanup): type error process.exit(1); } diff --git a/scripts/ts-policy-check.js b/scripts/ts-policy-check.js new file mode 100644 index 00000000..6b4c24bb --- /dev/null +++ b/scripts/ts-policy-check.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +/** + * TS policy checker — enforces two rules in source files (src/, bin/, scripts/): + * + * 1. Ban @ts-ignore — use @ts-expect-error instead. + * 2. Require TODO(ts-cleanup) tag on every inline wildcard cast. + * + * Exit 0 when clean, 1 when violations found. + */ + +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); +const DIRS = ['src', 'bin', 'scripts']; +const SELF = relative(ROOT, new URL(import.meta.url).pathname); + +/** @param {string} dir @returns {AsyncGenerator} */ +async function* walkJs(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + yield* walkJs(full); + } else if (entry.name.endsWith('.js')) { + yield full; + } + } +} + +/* eslint-disable no-control-regex */ +const TS_IGNORE_RE = /@ts-ignore\b/; +const WILDCARD_CAST_RE = /@type\s+\{(\*|any)\}/; +const TAG_RE = /TODO\(ts-cleanup\)/; +/* eslint-enable no-control-regex */ + +async function check() { + const violations = []; + + for (const dir of DIRS) { + const abs = join(ROOT, dir); + for await (const filePath of walkJs(abs)) { + const rel = relative(ROOT, filePath); + if (rel === SELF) { + continue; + } + const content = await readFile(filePath, 'utf8'); + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (TS_IGNORE_RE.test(line)) { + violations.push(`${rel}:${i + 1}: error: use @ts-expect-error instead of @ts-ignore`); + } + if (WILDCARD_CAST_RE.test(line) && !TAG_RE.test(line)) { + violations.push(`${rel}:${i + 1}: error: wildcard cast missing TODO(ts-cleanup) tag`); + } + } + } + } + + if (violations.length > 0) { + for (const v of violations) { + console.error(v); + } + console.error(`\n${violations.length} policy violation(s) found.`); + process.exit(1); + } + + console.log('TS policy check passed.'); +} + +check(); diff --git a/src/domain/WarpGraph.js b/src/domain/WarpGraph.js index 61f76941..8777d955 100644 --- a/src/domain/WarpGraph.js +++ b/src/domain/WarpGraph.js @@ -17,13 +17,14 @@ import { ProvenancePayload } from './services/ProvenancePayload.js'; import { diffStates, isEmptyDiff } from './services/StateDiff.js'; import { orsetContains, orsetElements } from './crdt/ORSet.js'; import defaultCodec from './utils/defaultCodec.js'; +import defaultCrypto from './utils/defaultCrypto.js'; import { decodePatchMessage, detectMessageKind, encodeAnchorMessage } from './services/WarpMessageCodec.js'; import { loadCheckpoint, materializeIncremental, create as createCheckpointCommit } from './services/CheckpointService.js'; import { createFrontier, updateFrontier } from './services/Frontier.js'; import { createVersionVector, vvClone, vvIncrement } from './crdt/VersionVector.js'; import { DEFAULT_GC_POLICY, shouldRunGC, executeGC } from './services/GCPolicy.js'; import { collectGCMetrics } from './services/GCMetrics.js'; -import { computeAppliedVV } from './services/CheckpointSerializerV5.js'; +import { computeAppliedVV, serializeFullStateV5, deserializeFullStateV5 } from './services/CheckpointSerializerV5.js'; import { computeStateHashV5 } from './services/StateSerializerV5.js'; import { createSyncRequest, @@ -48,8 +49,13 @@ import OperationAbortedError from './errors/OperationAbortedError.js'; import { compareEventIds } from './utils/EventId.js'; import { TemporalQuery } from './services/TemporalQuery.js'; import HttpSyncServer from './services/HttpSyncServer.js'; +import { buildSeekCacheKey } from './utils/seekCacheKey.js'; import defaultClock from './utils/defaultClock.js'; +/** + * @typedef {import('../ports/GraphPersistencePort.js').default & import('../ports/RefPort.js').default & import('../ports/CommitPort.js').default & import('../ports/BlobPort.js').default & import('../ports/TreePort.js').default & import('../ports/ConfigPort.js').default} FullPersistence + */ + const DEFAULT_SYNC_SERVER_MAX_BYTES = 4 * 1024 * 1024; const DEFAULT_SYNC_WITH_RETRIES = 3; const DEFAULT_SYNC_WITH_BASE_DELAY_MS = 250; @@ -99,10 +105,11 @@ export default class WarpGraph { * @param {import('../ports/ClockPort.js').default} [options.clock] - Clock for timing instrumentation (defaults to performance-based clock) * @param {import('../ports/CryptoPort.js').default} [options.crypto] - Crypto adapter for hashing * @param {import('../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec) + * @param {import('../ports/SeekCachePort.js').default} [options.seekCache] - Persistent cache for seek materialization (optional) */ - constructor({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize = DEFAULT_ADJACENCY_CACHE_SIZE, checkpointPolicy, autoMaterialize = false, onDeleteWithData = 'warn', logger, clock, crypto, codec }) { - /** @type {import('../ports/GraphPersistencePort.js').default} */ - this._persistence = persistence; + constructor({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize = DEFAULT_ADJACENCY_CACHE_SIZE, checkpointPolicy, autoMaterialize = false, onDeleteWithData = 'warn', logger, clock, crypto, codec, seekCache }) { + /** @type {FullPersistence} */ + this._persistence = /** @type {FullPersistence} */ (persistence); /** @type {string} */ this._graphName = graphName; @@ -146,7 +153,7 @@ export default class WarpGraph { /** @type {MaterializedGraph|null} */ this._materializedGraph = null; - /** @type {import('./utils/LRUCache.js').default|null} */ + /** @type {import('./utils/LRUCache.js').default>, incoming: Map>}>|null} */ this._adjacencyCache = adjacencyCacheSize > 0 ? new LRUCache(adjacencyCacheSize) : null; /** @type {Map|null} */ @@ -158,8 +165,8 @@ export default class WarpGraph { /** @type {import('../ports/ClockPort.js').default} */ this._clock = clock || defaultClock; - /** @type {import('../ports/CryptoPort.js').default|undefined} */ - this._crypto = crypto; + /** @type {import('../ports/CryptoPort.js').default} */ + this._crypto = crypto || defaultCrypto; /** @type {import('../ports/CodecPort.js').default} */ this._codec = codec || defaultCodec; @@ -167,7 +174,7 @@ export default class WarpGraph { /** @type {'reject'|'cascade'|'warn'} */ this._onDeleteWithData = onDeleteWithData; - /** @type {Array<{onChange: Function, onError?: Function}>} */ + /** @type {Array<{onChange: Function, onError?: Function, pendingReplay?: boolean}>} */ this._subscribers = []; /** @type {import('./services/JoinReducer.js').WarpStateV5|null} */ @@ -187,6 +194,32 @@ export default class WarpGraph { /** @type {Map|null} */ this._cachedFrontier = null; + + /** @type {import('../ports/SeekCachePort.js').default|null} */ + this._seekCache = seekCache || null; + + /** @type {boolean} */ + this._provenanceDegraded = false; + } + + /** + * Returns the attached seek cache, or null if none is set. + * @returns {import('../ports/SeekCachePort.js').default|null} + */ + get seekCache() { + return this._seekCache; + } + + /** + * Attaches a persistent seek cache after construction. + * + * Useful when the cache adapter cannot be created until after the + * graph is opened (e.g. the CLI wires it based on flags). + * + * @param {import('../ports/SeekCachePort.js').default} cache - SeekCachePort implementation + */ + setSeekCache(cache) { + this._seekCache = cache; } /** @@ -227,6 +260,7 @@ export default class WarpGraph { * @param {import('../ports/ClockPort.js').default} [options.clock] - Clock for timing instrumentation (defaults to performance-based clock) * @param {import('../ports/CryptoPort.js').default} [options.crypto] - Crypto adapter for hashing * @param {import('../ports/CodecPort.js').default} [options.codec] - Codec for CBOR serialization (defaults to domain-local codec) + * @param {import('../ports/SeekCachePort.js').default} [options.seekCache] - Persistent cache for seek materialization (optional) * @returns {Promise} The opened graph instance * @throws {Error} If graphName, writerId, checkpointPolicy, or onDeleteWithData is invalid * @@ -237,7 +271,7 @@ export default class WarpGraph { * writerId: 'node-1' * }); */ - static async open({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, logger, clock, crypto, codec }) { + static async open({ persistence, graphName, writerId, gcPolicy = {}, adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, logger, clock, crypto, codec, seekCache }) { // Validate inputs validateGraphName(graphName); validateWriterId(writerId); @@ -269,7 +303,7 @@ export default class WarpGraph { } } - const graph = new WarpGraph({ persistence, graphName, writerId, gcPolicy, adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, logger, clock, crypto, codec }); + const graph = new WarpGraph({ persistence, graphName, writerId, gcPolicy, adjacencyCacheSize, checkpointPolicy, autoMaterialize, onDeleteWithData, logger, clock, crypto, codec, seekCache }); // Validate migration boundary await graph._validateMigrationBoundary(); @@ -295,7 +329,7 @@ export default class WarpGraph { /** * Gets the persistence adapter. - * @returns {import('../ports/GraphPersistencePort.js').default} The persistence adapter + * @returns {FullPersistence} The persistence adapter */ get persistence() { return this._persistence; @@ -337,9 +371,9 @@ export default class WarpGraph { getCurrentState: () => this._cachedState, expectedParentSha: parentSha, onDeleteWithData: this._onDeleteWithData, - onCommitSuccess: (opts) => this._onPatchCommitted(this._writerId, opts), + onCommitSuccess: (/** @type {{patch?: import('./types/WarpTypesV2.js').PatchV2, sha?: string}} */ opts) => this._onPatchCommitted(this._writerId, opts), codec: this._codec, - logger: this._logger, + logger: this._logger || undefined, }); } @@ -348,7 +382,7 @@ export default class WarpGraph { * * @param {string} writerId - The writer ID to load patches for * @param {string|null} [stopAtSha=null] - Stop walking when reaching this SHA (exclusive) - * @returns {Promise>} Array of patches + * @returns {Promise>} Array of patches */ async getWriterPatches(writerId, stopAtSha = null) { return await this._loadWriterPatches(writerId, stopAtSha); @@ -399,7 +433,7 @@ export default class WarpGraph { * * @param {string} writerId - The writer ID to load patches for * @param {string|null} [stopAtSha=null] - Stop walking when reaching this SHA (exclusive) - * @returns {Promise>} Array of patches + * @returns {Promise>} Array of patches * @private */ async _loadWriterPatches(writerId, stopAtSha = null) { @@ -430,7 +464,7 @@ export default class WarpGraph { // Read the patch blob const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid); - const patch = this._codec.decode(patchBuffer); + const patch = /** @type {import('./types/WarpTypesV2.js').PatchV2} */ (this._codec.decode(patchBuffer)); patches.push({ patch, sha: currentSha }); @@ -474,8 +508,8 @@ export default class WarpGraph { incoming.get(to).push({ neighborId: from, label }); } - const sortNeighbors = (list) => { - list.sort((a, b) => { + const sortNeighbors = (/** @type {Array<{neighborId: string, label: string}>} */ list) => { + list.sort((/** @type {{neighborId: string, label: string}} */ a, /** @type {{neighborId: string, label: string}} */ b) => { if (a.neighborId !== b.neighborId) { return a.neighborId < b.neighborId ? -1 : 1; } @@ -529,7 +563,7 @@ export default class WarpGraph { * provenance index, and frontier tracking. * * @param {string} writerId - The writer ID that committed the patch - * @param {{patch?: Object, sha?: string}} [opts] - Commit details + * @param {{patch?: import('./types/WarpTypesV2.js').PatchV2, sha?: string}} [opts] - Commit details * @private */ async _onPatchCommitted(writerId, { patch, sha } = {}) { @@ -538,11 +572,11 @@ export default class WarpGraph { // Eager re-materialize: apply the just-committed patch to cached state // Only when the cache is clean — applying a patch to stale state would be incorrect if (this._cachedState && !this._stateDirty && patch && sha) { - joinPatch(this._cachedState, patch, sha); + joinPatch(this._cachedState, /** @type {any} */ (patch), sha); // TODO(ts-cleanup): type patch array await this._setMaterializedState(this._cachedState); // Update provenance index with new patch if (this._provenanceIndex) { - this._provenanceIndex.addPatch(sha, patch.reads, patch.writes); + this._provenanceIndex.addPatch(sha, /** @type {string[]|undefined} */ (patch.reads), /** @type {string[]|undefined} */ (patch.writes)); } // Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale if (this._lastFrontier) { @@ -561,9 +595,9 @@ export default class WarpGraph { async _materializeGraph() { const state = await this.materialize(); if (!this._materializedGraph || this._materializedGraph.state !== state) { - await this._setMaterializedState(state); + await this._setMaterializedState(/** @type {import('./services/JoinReducer.js').WarpStateV5} */ (state)); } - return this._materializedGraph; + return /** @type {MaterializedGraph} */ (this._materializedGraph); } /** @@ -602,14 +636,16 @@ export default class WarpGraph { // When ceiling is active, delegate to ceiling-aware path (with its own cache) if (ceiling !== null) { - return await this._materializeWithCeiling(ceiling, collectReceipts, t0); + return await this._materializeWithCeiling(ceiling, !!collectReceipts, t0); } try { // Check for checkpoint const checkpoint = await this._loadLatestCheckpoint(); + /** @type {import('./services/JoinReducer.js').WarpStateV5|undefined} */ let state; + /** @type {import('./types/TickReceipt.js').TickReceipt[]|undefined} */ let receipts; let patchCount = 0; @@ -617,20 +653,21 @@ export default class WarpGraph { if (checkpoint?.schema === 2 || checkpoint?.schema === 3) { const patches = await this._loadPatchesSince(checkpoint); if (collectReceipts) { - const result = reduceV5(patches, checkpoint.state, { receipts: true }); + const result = /** @type {{state: import('./services/JoinReducer.js').WarpStateV5, receipts: import('./types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (patches), /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (checkpoint.state), { receipts: true })); // TODO(ts-cleanup): type patch array state = result.state; receipts = result.receipts; } else { - state = reduceV5(patches, checkpoint.state); + state = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (patches), /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (checkpoint.state))); // TODO(ts-cleanup): type patch array } patchCount = patches.length; // Build provenance index: start from checkpoint index if present, then add new patches - this._provenanceIndex = checkpoint.provenanceIndex - ? checkpoint.provenanceIndex.clone() + const ckPI = /** @type {any} */ (checkpoint).provenanceIndex; // TODO(ts-cleanup): type checkpoint cast + this._provenanceIndex = ckPI + ? ckPI.clone() : new ProvenanceIndex(); for (const { patch, sha } of patches) { - this._provenanceIndex.addPatch(sha, patch.reads, patch.writes); + /** @type {import('./services/ProvenanceIndex.js').ProvenanceIndex} */ (this._provenanceIndex).addPatch(sha, patch.reads, patch.writes); } } else { // 1. Discover all writers @@ -661,11 +698,11 @@ export default class WarpGraph { } else { // 5. Reduce all patches to state if (collectReceipts) { - const result = reduceV5(allPatches, undefined, { receipts: true }); + const result = /** @type {{state: import('./services/JoinReducer.js').WarpStateV5, receipts: import('./types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (allPatches), undefined, { receipts: true })); // TODO(ts-cleanup): type patch array state = result.state; receipts = result.receipts; } else { - state = reduceV5(allPatches); + state = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (allPatches))); // TODO(ts-cleanup): type patch array } patchCount = allPatches.length; @@ -679,6 +716,7 @@ export default class WarpGraph { } await this._setMaterializedState(state); + this._provenanceDegraded = false; this._cachedCeiling = null; this._cachedFrontier = null; this._lastFrontier = await this.getFrontier(); @@ -712,11 +750,11 @@ export default class WarpGraph { this._logTiming('materialize', t0, { metrics: `${patchCount} patches` }); if (collectReceipts) { - return { state, receipts }; + return { state, receipts: /** @type {import('./types/TickReceipt.js').TickReceipt[]} */ (receipts) }; } return state; } catch (err) { - this._logTiming('materialize', t0, { error: err }); + this._logTiming('materialize', t0, { error: /** @type {Error} */ (err) }); throw err; } } @@ -770,12 +808,13 @@ export default class WarpGraph { // Cache hit: same ceiling, clean state, AND frontier unchanged. // Bypass cache when collectReceipts is true — cached path has no receipts. + const cf = this._cachedFrontier; if ( this._cachedState && !this._stateDirty && ceiling === this._cachedCeiling && !collectReceipts && - this._cachedFrontier !== null && - this._cachedFrontier.size === frontier.size && - [...frontier].every(([w, sha]) => this._cachedFrontier.get(w) === sha) + cf !== null && + cf.size === frontier.size && + [...frontier].every(([w, sha]) => cf.get(w) === sha) ) { return this._cachedState; } @@ -785,6 +824,7 @@ export default class WarpGraph { if (writerIds.length === 0 || ceiling <= 0) { const state = createEmptyStateV5(); this._provenanceIndex = new ProvenanceIndex(); + this._provenanceDegraded = false; await this._setMaterializedState(state); this._cachedCeiling = ceiling; this._cachedFrontier = frontier; @@ -795,6 +835,32 @@ export default class WarpGraph { return state; } + // Persistent cache check — skip when collectReceipts is requested + let cacheKey; + if (this._seekCache && !collectReceipts) { + cacheKey = buildSeekCacheKey(ceiling, frontier); + try { + const cached = await this._seekCache.get(cacheKey); + if (cached) { + try { + const state = deserializeFullStateV5(cached, { codec: this._codec }); + this._provenanceIndex = new ProvenanceIndex(); + this._provenanceDegraded = true; + await this._setMaterializedState(state); + this._cachedCeiling = ceiling; + this._cachedFrontier = frontier; + this._logTiming('materialize', t0, { metrics: `cache hit (ceiling=${ceiling})` }); + return state; + } catch { + // Corrupted payload — self-heal by removing the bad entry + try { await this._seekCache.delete(cacheKey); } catch { /* best-effort */ } + } + } + } catch { + // Cache read failed — fall through to full materialization + } + } + const allPatches = []; for (const writerId of writerIds) { const writerPatches = await this._loadWriterPatches(writerId); @@ -805,7 +871,9 @@ export default class WarpGraph { } } + /** @type {import('./services/JoinReducer.js').WarpStateV5|undefined} */ let state; + /** @type {import('./types/TickReceipt.js').TickReceipt[]|undefined} */ let receipts; if (allPatches.length === 0) { @@ -814,27 +882,37 @@ export default class WarpGraph { receipts = []; } } else if (collectReceipts) { - const result = reduceV5(allPatches, undefined, { receipts: true }); + const result = /** @type {{state: import('./services/JoinReducer.js').WarpStateV5, receipts: import('./types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(/** @type {any} */ (allPatches), undefined, { receipts: true })); // TODO(ts-cleanup): type patch array state = result.state; receipts = result.receipts; } else { - state = reduceV5(allPatches); + state = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {any} */ (allPatches))); // TODO(ts-cleanup): type patch array } this._provenanceIndex = new ProvenanceIndex(); for (const { patch, sha } of allPatches) { - this._provenanceIndex.addPatch(sha, patch.reads, patch.writes); + this._provenanceIndex.addPatch(sha, /** @type {string[]|undefined} */ (patch.reads), /** @type {string[]|undefined} */ (patch.writes)); } + this._provenanceDegraded = false; await this._setMaterializedState(state); this._cachedCeiling = ceiling; this._cachedFrontier = frontier; + // Store to persistent cache (fire-and-forget — failure is non-fatal) + if (this._seekCache && !collectReceipts && allPatches.length > 0) { + if (!cacheKey) { + cacheKey = buildSeekCacheKey(ceiling, frontier); + } + const buf = serializeFullStateV5(state, { codec: this._codec }); + this._seekCache.set(cacheKey, /** @type {Buffer} */ (buf)).catch(() => {}); + } + // Skip auto-checkpoint and GC — this is an exploratory read this._logTiming('materialize', t0, { metrics: `${allPatches.length} patches (ceiling=${ceiling})` }); if (collectReceipts) { - return { state, receipts }; + return { state, receipts: /** @type {import('./types/TickReceipt.js').TickReceipt[]} */ (receipts) }; } return state; } @@ -883,16 +961,16 @@ export default class WarpGraph { } // Capture pre-merge counts for receipt - const beforeNodes = this._cachedState.nodeAlive.elements.size; - const beforeEdges = this._cachedState.edgeAlive.elements.size; + const beforeNodes = orsetElements(this._cachedState.nodeAlive).length; + const beforeEdges = orsetElements(this._cachedState.edgeAlive).length; const beforeFrontierSize = this._cachedState.observedFrontier.size; // Perform the join const mergedState = joinStates(this._cachedState, otherState); // Calculate receipt - const afterNodes = mergedState.nodeAlive.elements.size; - const afterEdges = mergedState.edgeAlive.elements.size; + const afterNodes = orsetElements(mergedState.nodeAlive).length; + const afterEdges = orsetElements(mergedState.edgeAlive).length; const afterFrontierSize = mergedState.observedFrontier.size; // Count property changes (keys that existed in both but have different values) @@ -954,7 +1032,7 @@ export default class WarpGraph { * @example * // Time-travel to a previous checkpoint * const oldState = await graph.materializeAt('abc123'); - * console.log('Nodes at checkpoint:', [...oldState.nodeAlive.elements.keys()]); + * console.log('Nodes at checkpoint:', orsetElements(oldState.nodeAlive)); */ async materializeAt(checkpointSha) { // 1. Discover current writers to build target frontier @@ -971,7 +1049,7 @@ export default class WarpGraph { } // 3. Create a patch loader function for incremental materialization - const patchLoader = async (writerId, fromSha, toSha) => { + const patchLoader = async (/** @type {string} */ writerId, /** @type {string|null} */ fromSha, /** @type {string} */ toSha) => { // Load patches from fromSha (exclusive) to toSha (inclusive) // Walk from toSha back to fromSha const patches = []; @@ -1004,7 +1082,7 @@ export default class WarpGraph { // 4. Call materializeIncremental with the checkpoint and target frontier const state = await materializeIncremental({ - persistence: this._persistence, + persistence: /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type graphName: this._graphName, checkpointSha, targetFrontier, @@ -1048,23 +1126,24 @@ export default class WarpGraph { // 3. Materialize current state (reuse cached if fresh, guard against recursion) const prevCheckpointing = this._checkpointing; this._checkpointing = true; + /** @type {import('./services/JoinReducer.js').WarpStateV5} */ let state; try { - state = (this._cachedState && !this._stateDirty) + state = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ ((this._cachedState && !this._stateDirty) ? this._cachedState - : await this.materialize(); + : await this.materialize()); } finally { this._checkpointing = prevCheckpointing; } // 4. Call CheckpointService.create() with provenance index if available const checkpointSha = await createCheckpointCommit({ - persistence: this._persistence, + persistence: /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type graphName: this._graphName, state, frontier, parents, - provenanceIndex: this._provenanceIndex, + provenanceIndex: this._provenanceIndex || undefined, crypto: this._crypto, codec: this._codec, }); @@ -1078,7 +1157,7 @@ export default class WarpGraph { // 6. Return checkpoint SHA return checkpointSha; } catch (err) { - this._logTiming('createCheckpoint', t0, { error: err }); + this._logTiming('createCheckpoint', t0, { error: /** @type {Error} */ (err) }); throw err; } } @@ -1172,6 +1251,7 @@ export default class WarpGraph { */ async discoverTicks() { const writerIds = await this.discoverWriters(); + /** @type {Set} */ const globalTickSet = new Set(); const perWriter = new Map(); @@ -1179,6 +1259,7 @@ export default class WarpGraph { const writerRef = buildWriterRef(this._graphName, writerId); const tipSha = await this._persistence.readRef(writerRef); const writerTicks = []; + /** @type {Record} */ const tickShas = {}; if (tipSha) { @@ -1256,7 +1337,7 @@ export default class WarpGraph { /** * Loads the latest checkpoint for this graph. * - * @returns {Promise<{state: Object, frontier: Map, stateHash: string, schema: number}|null>} The checkpoint or null + * @returns {Promise<{state: import('./services/JoinReducer.js').WarpStateV5, frontier: Map, stateHash: string, schema: number, provenanceIndex?: import('./services/ProvenanceIndex.js').ProvenanceIndex}|null>} The checkpoint or null * @private */ async _loadLatestCheckpoint() { @@ -1298,7 +1379,7 @@ export default class WarpGraph { if (kind === 'patch') { const patchMeta = decodePatchMessage(nodeInfo.message); const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid); - const patch = this._codec.decode(patchBuffer); + const patch = /** @type {{schema?: number}} */ (this._codec.decode(patchBuffer)); // If any patch has schema:1, we have v1 history if (patch.schema === 1 || patch.schema === undefined) { @@ -1313,8 +1394,8 @@ export default class WarpGraph { /** * Loads patches since a checkpoint for incremental materialization. * - * @param {{state: Object, frontier: Map, stateHash: string, schema: number}} checkpoint - The checkpoint to start from - * @returns {Promise>} Patches since checkpoint + * @param {{state: import('./services/JoinReducer.js').WarpStateV5, frontier: Map, stateHash: string, schema: number}} checkpoint - The checkpoint to start from + * @returns {Promise>} Patches since checkpoint * @private */ async _loadPatchesSince(checkpoint) { @@ -1396,7 +1477,7 @@ export default class WarpGraph { * * @param {string} writerId - The writer ID for this patch * @param {string} incomingSha - The incoming patch commit SHA - * @param {{state: Object, frontier: Map, stateHash: string, schema: number}} checkpoint - The checkpoint to validate against + * @param {{state: import('./services/JoinReducer.js').WarpStateV5, frontier: Map, stateHash: string, schema: number}} checkpoint - The checkpoint to validate against * @returns {Promise} * @throws {Error} If patch is behind/same as checkpoint frontier (backfill rejected) * @throws {Error} If patch does not extend checkpoint head (writer fork detected) @@ -1444,18 +1525,19 @@ export default class WarpGraph { _maybeRunGC(state) { try { const metrics = collectGCMetrics(state); + /** @type {import('./services/GCPolicy.js').GCInputMetrics} */ const inputMetrics = { ...metrics, patchesSinceCompaction: this._patchesSinceGC, timeSinceCompaction: Date.now() - this._lastGCTime, }; - const { shouldRun, reasons } = shouldRunGC(inputMetrics, this._gcPolicy); + const { shouldRun, reasons } = shouldRunGC(inputMetrics, /** @type {import('./services/GCPolicy.js').GCPolicy} */ (this._gcPolicy)); if (!shouldRun) { return; } - if (this._gcPolicy.enabled) { + if (/** @type {import('./services/GCPolicy.js').GCPolicy} */ (this._gcPolicy).enabled) { const appliedVV = computeAppliedVV(state); const result = executeGC(state, appliedVV); this._lastGCTime = Date.now(); @@ -1494,11 +1576,15 @@ export default class WarpGraph { return { ran: false, result: null, reasons: [] }; } - const metrics = collectGCMetrics(this._cachedState); - metrics.patchesSinceCompaction = this._patchesSinceGC; - metrics.lastCompactionTime = this._lastGCTime; + const rawMetrics = collectGCMetrics(this._cachedState); + /** @type {import('./services/GCPolicy.js').GCInputMetrics} */ + const metrics = { + ...rawMetrics, + patchesSinceCompaction: this._patchesSinceGC, + timeSinceCompaction: this._lastGCTime > 0 ? Date.now() - this._lastGCTime : 0, + }; - const { shouldRun, reasons } = shouldRunGC(metrics, this._gcPolicy); + const { shouldRun, reasons } = shouldRunGC(metrics, /** @type {import('./services/GCPolicy.js').GCPolicy} */ (this._gcPolicy)); if (!shouldRun) { return { ran: false, result: null, reasons: [] }; @@ -1545,7 +1631,7 @@ export default class WarpGraph { return result; } catch (err) { - this._logTiming('runGC', t0, { error: err }); + this._logTiming('runGC', t0, { error: /** @type {Error} */ (err) }); throw err; } } @@ -1567,10 +1653,15 @@ export default class WarpGraph { return null; } - const metrics = collectGCMetrics(this._cachedState); - metrics.patchesSinceCompaction = this._patchesSinceGC; - metrics.lastCompactionTime = this._lastGCTime; - return metrics; + const rawMetrics = collectGCMetrics(this._cachedState); + return { + ...rawMetrics, + nodeCount: rawMetrics.nodeLiveDots, + edgeCount: rawMetrics.edgeLiveDots, + tombstoneCount: rawMetrics.totalTombstones, + patchesSinceCompaction: this._patchesSinceGC, + lastCompactionTime: this._lastGCTime, + }; } /** @@ -1653,6 +1744,7 @@ export default class WarpGraph { */ async status() { // Determine cachedState + /** @type {'fresh' | 'stale' | 'none'} */ let cachedState; if (this._cachedState === null) { cachedState = 'none'; @@ -1702,7 +1794,7 @@ export default class WarpGraph { * One handler's error does not prevent other handlers from being called. * * @param {Object} options - Subscription options - * @param {(diff: import('./services/StateDiff.js').StateDiff) => void} options.onChange - Called with diff when graph changes + * @param {(diff: import('./services/StateDiff.js').StateDiffResult) => void} options.onChange - Called with diff when graph changes * @param {(error: Error) => void} [options.onError] - Called if onChange throws an error * @param {boolean} [options.replay=false] - If true, immediately fires onChange with initial state diff * @returns {{unsubscribe: () => void}} Subscription handle @@ -1745,7 +1837,7 @@ export default class WarpGraph { } catch (err) { if (onError) { try { - onError(err); + onError(/** @type {Error} */ (err)); } catch { // onError itself threw — swallow to prevent cascade } @@ -1782,7 +1874,7 @@ export default class WarpGraph { * * @param {string} pattern - Glob pattern (e.g., 'user:*', 'order:123', '*') * @param {Object} options - Watch options - * @param {(diff: import('./services/StateDiff.js').StateDiff) => void} options.onChange - Called with filtered diff when matching changes occur + * @param {(diff: import('./services/StateDiff.js').StateDiffResult) => void} options.onChange - Called with filtered diff when matching changes occur * @param {(error: Error) => void} [options.onError] - Called if onChange throws an error * @param {number} [options.poll] - Poll interval in ms (min 1000); checks frontier and auto-materializes * @returns {{unsubscribe: () => void}} Subscription handle @@ -1823,31 +1915,32 @@ export default class WarpGraph { // Pattern matching: same logic as QueryBuilder.match() // Pre-compile pattern matcher once for performance + /** @type {(nodeId: string) => boolean} */ let matchesPattern; if (pattern === '*') { matchesPattern = () => true; } else if (pattern.includes('*')) { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`); - matchesPattern = (nodeId) => regex.test(nodeId); + matchesPattern = (/** @type {string} */ nodeId) => regex.test(nodeId); } else { - matchesPattern = (nodeId) => nodeId === pattern; + matchesPattern = (/** @type {string} */ nodeId) => nodeId === pattern; } // Filtered onChange that only passes matching changes - const filteredOnChange = (diff) => { + const filteredOnChange = (/** @type {import('./services/StateDiff.js').StateDiffResult} */ diff) => { const filteredDiff = { nodes: { added: diff.nodes.added.filter(matchesPattern), removed: diff.nodes.removed.filter(matchesPattern), }, edges: { - added: diff.edges.added.filter(e => matchesPattern(e.from) || matchesPattern(e.to)), - removed: diff.edges.removed.filter(e => matchesPattern(e.from) || matchesPattern(e.to)), + added: diff.edges.added.filter((/** @type {import('./services/StateDiff.js').EdgeChange} */ e) => matchesPattern(e.from) || matchesPattern(e.to)), + removed: diff.edges.removed.filter((/** @type {import('./services/StateDiff.js').EdgeChange} */ e) => matchesPattern(e.from) || matchesPattern(e.to)), }, props: { - set: diff.props.set.filter(p => matchesPattern(p.nodeId)), - removed: diff.props.removed.filter(p => matchesPattern(p.nodeId)), + set: diff.props.set.filter((/** @type {import('./services/StateDiff.js').PropSet} */ p) => matchesPattern(p.nodeId)), + removed: diff.props.removed.filter((/** @type {import('./services/StateDiff.js').PropRemoved} */ p) => matchesPattern(p.nodeId)), }, }; @@ -1869,6 +1962,7 @@ export default class WarpGraph { const subscription = this.subscribe({ onChange: filteredOnChange, onError }); // Polling: periodically check frontier and auto-materialize if changed + /** @type {ReturnType|null} */ let pollIntervalId = null; let pollInFlight = false; if (poll) { @@ -1950,7 +2044,7 @@ export default class WarpGraph { * Creates a sync request to send to a remote peer. * The request contains the local frontier for comparison. * - * @returns {Promise<{type: 'sync-request', frontier: Map}>} The sync request + * @returns {Promise} The sync request * @throws {Error} If listing refs fails * * @example @@ -1965,8 +2059,8 @@ export default class WarpGraph { /** * Processes an incoming sync request and returns patches the requester needs. * - * @param {{type: 'sync-request', frontier: Map}} request - The incoming sync request - * @returns {Promise<{type: 'sync-response', frontier: Map, patches: Map}>} The sync response + * @param {import('./services/SyncProtocol.js').SyncRequest} request - The incoming sync request + * @returns {Promise} The sync response * @throws {Error} If listing refs or reading patches fails * * @example @@ -1979,7 +2073,7 @@ export default class WarpGraph { return await processSyncRequest( request, localFrontier, - this._persistence, + /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type this._graphName, { codec: this._codec } ); @@ -1991,8 +2085,8 @@ export default class WarpGraph { * * **Requires a cached state.** * - * @param {{type: 'sync-response', frontier: Map, patches: Map}} response - The sync response - * @returns {{state: Object, frontier: Map, applied: number}} Result with updated state + * @param {import('./services/SyncProtocol.js').SyncResponse} response - The sync response + * @returns {{state: import('./services/JoinReducer.js').WarpStateV5, applied: number}} Result with updated state * @throws {QueryError} If no cached state exists (code: `E_NO_STATE`) * * @example @@ -2007,8 +2101,8 @@ export default class WarpGraph { }); } - const currentFrontier = this._cachedState.observedFrontier; - const result = applySyncResponse(response, this._cachedState, currentFrontier); + const currentFrontier = /** @type {any} */ (this._cachedState.observedFrontier); // TODO(ts-cleanup): narrow port type + const result = /** @type {{state: import('./services/JoinReducer.js').WarpStateV5, frontier: Map, applied: number}} */ (applySyncResponse(response, this._cachedState, currentFrontier)); // Update cached state this._cachedState = result.state; @@ -2079,7 +2173,7 @@ export default class WarpGraph { let targetUrl = null; if (!isDirectPeer) { try { - targetUrl = remote instanceof URL ? new URL(remote.toString()) : new URL(remote); + targetUrl = remote instanceof URL ? new URL(remote.toString()) : new URL(/** @type {string} */ (remote)); } catch { throw new SyncError('Invalid remote URL', { code: 'E_SYNC_REMOTE_URL', @@ -2104,13 +2198,13 @@ export default class WarpGraph { } let attempt = 0; - const emit = (type, payload = {}) => { + const emit = (/** @type {string} */ type, /** @type {Record} */ payload = {}) => { if (typeof onStatus === 'function') { - onStatus({ type, attempt, ...payload }); + onStatus(/** @type {any} */ ({ type, attempt, ...payload })); // TODO(ts-cleanup): type sync protocol } }; - const shouldRetry = (err) => { + const shouldRetry = (/** @type {any} */ err) => { // TODO(ts-cleanup): type error if (isDirectPeer) { return false; } if (err instanceof SyncError) { return ['E_SYNC_REMOTE', 'E_SYNC_TIMEOUT', 'E_SYNC_NETWORK'].includes(err.code); @@ -2140,7 +2234,7 @@ export default class WarpGraph { const combinedSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal; - return fetch(targetUrl.toString(), { + return fetch(/** @type {URL} */ (targetUrl).toString(), { method: 'POST', headers: { 'content-type': 'application/json', @@ -2151,7 +2245,7 @@ export default class WarpGraph { }); }); } catch (err) { - if (err?.name === 'AbortError') { + if (/** @type {any} */ (err)?.name === 'AbortError') { // TODO(ts-cleanup): type error throw new OperationAbortedError('syncWith', { reason: 'Signal received' }); } if (err instanceof TimeoutError) { @@ -2162,7 +2256,7 @@ export default class WarpGraph { } throw new SyncError('Network error', { code: 'E_SYNC_NETWORK', - context: { message: err?.message }, + context: { message: /** @type {any} */ (err)?.message }, // TODO(ts-cleanup): type error }); } @@ -2223,9 +2317,9 @@ export default class WarpGraph { jitter: 'decorrelated', signal, shouldRetry, - onRetry: (error, attemptNumber, delayMs) => { + onRetry: (/** @type {Error} */ error, /** @type {number} */ attemptNumber, /** @type {number} */ delayMs) => { if (typeof onStatus === 'function') { - onStatus({ type: 'retrying', attempt: attemptNumber, delayMs, error }); + onStatus(/** @type {any} */ ({ type: 'retrying', attempt: attemptNumber, delayMs, error })); // TODO(ts-cleanup): type sync protocol } }, }); @@ -2234,12 +2328,12 @@ export default class WarpGraph { if (materializeAfterSync) { if (!this._cachedState) { await this.materialize(); } - return { ...syncResult, state: this._cachedState }; + return { ...syncResult, state: /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState) }; } return syncResult; } catch (err) { - this._logTiming('syncWith', t0, { error: err }); - if (err?.name === 'AbortError') { + this._logTiming('syncWith', t0, { error: /** @type {Error} */ (err) }); + if (/** @type {any} */ (err)?.name === 'AbortError') { // TODO(ts-cleanup): type error const abortedError = new OperationAbortedError('syncWith', { reason: 'Signal received' }); if (typeof onStatus === 'function') { onStatus({ type: 'failed', attempt, error: abortedError }); @@ -2247,14 +2341,14 @@ export default class WarpGraph { throw abortedError; } if (err instanceof RetryExhaustedError) { - const cause = err.cause || err; + const cause = /** @type {Error} */ (err.cause || err); if (typeof onStatus === 'function') { onStatus({ type: 'failed', attempt: err.attempts, error: cause }); } throw cause; } if (typeof onStatus === 'function') { - onStatus({ type: 'failed', attempt, error: err }); + onStatus({ type: 'failed', attempt, error: /** @type {Error} */ (err) }); } throw err; } @@ -2273,7 +2367,7 @@ export default class WarpGraph { * @throws {Error} If port is not a number * @throws {Error} If httpPort adapter is not provided */ - async serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort } = {}) { + async serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort } = /** @type {any} */ ({})) { // TODO(ts-cleanup): needs options type if (typeof port !== 'number') { throw new Error('serve() requires a numeric port'); } @@ -2318,8 +2412,8 @@ export default class WarpGraph { */ async writer(writerId) { // Build config adapters for resolveWriterId - const configGet = async (key) => await this._persistence.configGet(key); - const configSet = async (key, value) => await this._persistence.configSet(key, value); + const configGet = async (/** @type {string} */ key) => await this._persistence.configGet(key); + const configSet = async (/** @type {string} */ key, /** @type {string} */ value) => await this._persistence.configSet(key, value); // Resolve the writer ID const resolvedWriterId = await resolveWriterId({ @@ -2330,13 +2424,13 @@ export default class WarpGraph { }); return new Writer({ - persistence: this._persistence, + persistence: /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type graphName: this._graphName, writerId: resolvedWriterId, versionVector: this._versionVector, - getCurrentState: () => this._cachedState, + getCurrentState: () => /** @type {any} */ (this._cachedState), // TODO(ts-cleanup): narrow port type onDeleteWithData: this._onDeleteWithData, - onCommitSuccess: (opts) => this._onPatchCommitted(resolvedWriterId, opts), + onCommitSuccess: (/** @type {any} */ opts) => this._onPatchCommitted(resolvedWriterId, opts), // TODO(ts-cleanup): type sync protocol codec: this._codec, }); } @@ -2384,13 +2478,13 @@ export default class WarpGraph { } return new Writer({ - persistence: this._persistence, + persistence: /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type graphName: this._graphName, writerId: freshWriterId, versionVector: this._versionVector, - getCurrentState: () => this._cachedState, + getCurrentState: () => /** @type {any} */ (this._cachedState), // TODO(ts-cleanup): narrow port type onDeleteWithData: this._onDeleteWithData, - onCommitSuccess: (commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts), + onCommitSuccess: (/** @type {any} */ commitOpts) => this._onPatchCommitted(freshWriterId, commitOpts), // TODO(ts-cleanup): type sync protocol codec: this._codec, }); } @@ -2545,7 +2639,8 @@ export default class WarpGraph { */ async hasNode(nodeId) { await this._ensureFreshState(); - return orsetContains(this._cachedState.nodeAlive, nodeId); + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); + return orsetContains(s.nodeAlive, nodeId); } /** @@ -2570,15 +2665,16 @@ export default class WarpGraph { */ async getNodeProps(nodeId) { await this._ensureFreshState(); + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); // Check if node exists - if (!orsetContains(this._cachedState.nodeAlive, nodeId)) { + if (!orsetContains(s.nodeAlive, nodeId)) { return null; } // Collect all properties for this node const props = new Map(); - for (const [propKey, register] of this._cachedState.prop) { + for (const [propKey, register] of s.prop) { const decoded = decodePropKey(propKey); if (decoded.nodeId === nodeId) { props.set(decoded.propKey, register.value); @@ -2612,26 +2708,28 @@ export default class WarpGraph { */ async getEdgeProps(from, to, label) { await this._ensureFreshState(); + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); // Check if edge exists const edgeKey = encodeEdgeKey(from, to, label); - if (!orsetContains(this._cachedState.edgeAlive, edgeKey)) { + if (!orsetContains(s.edgeAlive, edgeKey)) { return null; } // Check node liveness for both endpoints - if (!orsetContains(this._cachedState.nodeAlive, from) || - !orsetContains(this._cachedState.nodeAlive, to)) { + if (!orsetContains(s.nodeAlive, from) || + !orsetContains(s.nodeAlive, to)) { return null; } // Determine the birth EventId for clean-slate filtering - const birthEvent = this._cachedState.edgeBirthEvent?.get(edgeKey); + const birthEvent = s.edgeBirthEvent?.get(edgeKey); // Collect all properties for this edge, filtering out stale props // (props set before the edge's most recent re-add) + /** @type {Record} */ const props = {}; - for (const [propKey, register] of this._cachedState.prop) { + for (const [propKey, register] of s.prop) { if (!isEdgePropKey(propKey)) { continue; } @@ -2673,11 +2771,13 @@ export default class WarpGraph { */ async neighbors(nodeId, direction = 'both', edgeLabel = undefined) { await this._ensureFreshState(); + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); + /** @type {Array<{nodeId: string, label: string, direction: 'outgoing' | 'incoming'}>} */ const neighbors = []; // Iterate over all visible edges - for (const edgeKey of orsetElements(this._cachedState.edgeAlive)) { + for (const edgeKey of orsetElements(s.edgeAlive)) { const { from, to, label } = decodeEdgeKey(edgeKey); // Filter by label if specified @@ -2688,15 +2788,15 @@ export default class WarpGraph { // Check edge direction and collect neighbors if ((direction === 'outgoing' || direction === 'both') && from === nodeId) { // Ensure target node is visible - if (orsetContains(this._cachedState.nodeAlive, to)) { - neighbors.push({ nodeId: to, label, direction: 'outgoing' }); + if (orsetContains(s.nodeAlive, to)) { + neighbors.push({ nodeId: to, label, direction: /** @type {const} */ ('outgoing') }); } } if ((direction === 'incoming' || direction === 'both') && to === nodeId) { // Ensure source node is visible - if (orsetContains(this._cachedState.nodeAlive, from)) { - neighbors.push({ nodeId: from, label, direction: 'incoming' }); + if (orsetContains(s.nodeAlive, from)) { + neighbors.push({ nodeId: from, label, direction: /** @type {const} */ ('incoming') }); } } } @@ -2721,7 +2821,8 @@ export default class WarpGraph { */ async getNodes() { await this._ensureFreshState(); - return [...orsetElements(this._cachedState.nodeAlive)]; + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); + return [...orsetElements(s.nodeAlive)]; } /** @@ -2744,12 +2845,13 @@ export default class WarpGraph { */ async getEdges() { await this._ensureFreshState(); + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); // Pre-collect edge props into a lookup: "from\0to\0label" → {propKey: value} // Filters out stale props using full EventId ordering via compareEventIds // against the edge's birth EventId (clean-slate semantics on re-add) const edgePropsByKey = new Map(); - for (const [propKey, register] of this._cachedState.prop) { + for (const [propKey, register] of s.prop) { if (!isEdgePropKey(propKey)) { continue; } @@ -2757,7 +2859,7 @@ export default class WarpGraph { const ek = encodeEdgeKey(decoded.from, decoded.to, decoded.label); // Clean-slate filter: skip props from before the edge's current incarnation - const birthEvent = this._cachedState.edgeBirthEvent?.get(ek); + const birthEvent = s.edgeBirthEvent?.get(ek); if (birthEvent && register.eventId && compareEventIds(register.eventId, birthEvent) < 0) { continue; } @@ -2771,11 +2873,11 @@ export default class WarpGraph { } const edges = []; - for (const edgeKey of orsetElements(this._cachedState.edgeAlive)) { + for (const edgeKey of orsetElements(s.edgeAlive)) { const { from, to, label } = decodeEdgeKey(edgeKey); // Only include edges where both endpoints are visible - if (orsetContains(this._cachedState.nodeAlive, from) && - orsetContains(this._cachedState.nodeAlive, to)) { + if (orsetContains(s.nodeAlive, from) && + orsetContains(s.nodeAlive, to)) { const props = edgePropsByKey.get(edgeKey) || {}; edges.push({ from, to, label, props }); } @@ -2794,7 +2896,8 @@ export default class WarpGraph { */ async getPropertyCount() { await this._ensureFreshState(); - return this._cachedState.prop.size; + const s = /** @type {import('./services/JoinReducer.js').WarpStateV5} */ (this._cachedState); + return s.prop.size; } // ============================================================================ @@ -2913,9 +3016,9 @@ export default class WarpGraph { try { validateGraphName(resolvedForkName); } catch (err) { - throw new ForkError(`Invalid fork name: ${err.message}`, { + throw new ForkError(`Invalid fork name: ${/** @type {Error} */ (err).message}`, { code: 'E_FORK_NAME_INVALID', - context: { forkName: resolvedForkName, originalError: err.message }, + context: { forkName: resolvedForkName, originalError: /** @type {Error} */ (err).message }, }); } @@ -2934,9 +3037,9 @@ export default class WarpGraph { try { validateWriterId(resolvedForkWriterId); } catch (err) { - throw new ForkError(`Invalid fork writer ID: ${err.message}`, { + throw new ForkError(`Invalid fork writer ID: ${/** @type {Error} */ (err).message}`, { code: 'E_FORK_WRITER_ID_INVALID', - context: { forkWriterId: resolvedForkWriterId, originalError: err.message }, + context: { forkWriterId: resolvedForkWriterId, originalError: /** @type {Error} */ (err).message }, }); } @@ -2951,10 +3054,10 @@ export default class WarpGraph { writerId: resolvedForkWriterId, gcPolicy: this._gcPolicy, adjacencyCacheSize: this._adjacencyCache?.maxSize ?? DEFAULT_ADJACENCY_CACHE_SIZE, - checkpointPolicy: this._checkpointPolicy, + checkpointPolicy: this._checkpointPolicy || undefined, autoMaterialize: this._autoMaterialize, onDeleteWithData: this._onDeleteWithData, - logger: this._logger, + logger: this._logger || undefined, clock: this._clock, crypto: this._crypto, codec: this._codec, @@ -2966,7 +3069,7 @@ export default class WarpGraph { return forkGraph; } catch (err) { - this._logTiming('fork', t0, { error: err }); + this._logTiming('fork', t0, { error: /** @type {Error} */ (err) }); throw err; } } @@ -3020,13 +3123,13 @@ export default class WarpGraph { const t0 = this._clock.now(); try { - const wormhole = await createWormholeImpl({ + const wormhole = await createWormholeImpl(/** @type {any} */ ({ // TODO(ts-cleanup): needs options type persistence: this._persistence, graphName: this._graphName, fromSha, toSha, codec: this._codec, - }); + })); this._logTiming('createWormhole', t0, { metrics: `${wormhole.patchCount} patches from=${fromSha.slice(0, 7)} to=${toSha.slice(0, 7)}`, @@ -3034,7 +3137,7 @@ export default class WarpGraph { return wormhole; } catch (err) { - this._logTiming('createWormhole', t0, { error: err }); + this._logTiming('createWormhole', t0, { error: /** @type {Error} */ (err) }); throw err; } } @@ -3068,6 +3171,12 @@ export default class WarpGraph { async patchesFor(entityId) { await this._ensureFreshState(); + if (this._provenanceDegraded) { + throw new QueryError('Provenance unavailable for cached seek. Re-seek with --no-persistent-cache or call materialize({ ceiling }) directly.', { + code: 'E_PROVENANCE_DEGRADED', + }); + } + if (!this._provenanceIndex) { throw new QueryError('No provenance index. Call materialize() first.', { code: 'E_NO_STATE', @@ -3129,6 +3238,12 @@ export default class WarpGraph { // Ensure fresh state before accessing provenance index await this._ensureFreshState(); + if (this._provenanceDegraded) { + throw new QueryError('Provenance unavailable for cached seek. Re-seek with --no-persistent-cache or call materialize({ ceiling }) directly.', { + code: 'E_PROVENANCE_DEGRADED', + }); + } + if (!this._provenanceIndex) { throw new QueryError('No provenance index. Call materialize() first.', { code: 'E_NO_STATE', @@ -3163,7 +3278,7 @@ export default class WarpGraph { this._logTiming('materializeSlice', t0, { metrics: `${sortedPatches.length} patches` }); if (collectReceipts) { - const result = reduceV5(sortedPatches, undefined, { receipts: true }); + const result = /** @type {{state: import('./services/JoinReducer.js').WarpStateV5, receipts: import('./types/TickReceipt.js').TickReceipt[]}} */ (reduceV5(sortedPatches, undefined, { receipts: true })); return { state: result.state, patchCount: sortedPatches.length, @@ -3177,7 +3292,7 @@ export default class WarpGraph { patchCount: sortedPatches.length, }; } catch (err) { - this._logTiming('materializeSlice', t0, { error: err }); + this._logTiming('materializeSlice', t0, { error: /** @type {Error} */ (err) }); throw err; } } @@ -3214,7 +3329,7 @@ export default class WarpGraph { visited.add(entityId); // Get all patches that affected this entity - const patchShas = this._provenanceIndex.patchesFor(entityId); + const patchShas = /** @type {import('./services/ProvenanceIndex.js').ProvenanceIndex} */ (this._provenanceIndex).patchesFor(entityId); for (const sha of patchShas) { if (cone.has(sha)) { @@ -3226,8 +3341,9 @@ export default class WarpGraph { cone.set(sha, patch); // Add read dependencies to the queue - if (patch && patch.reads) { - for (const readEntity of patch.reads) { + const patchReads = /** @type {any} */ (patch)?.reads; // TODO(ts-cleanup): type patch array + if (patchReads) { + for (const readEntity of patchReads) { if (!visited.has(readEntity)) { queue.push(readEntity); } @@ -3274,7 +3390,7 @@ export default class WarpGraph { const patchMeta = decodePatchMessage(nodeInfo.message); const patchBuffer = await this._persistence.readBlob(patchMeta.patchOid); - return this._codec.decode(patchBuffer); + return /** @type {Object} */ (this._codec.decode(patchBuffer)); } /** @@ -3302,8 +3418,8 @@ export default class WarpGraph { * Sort order: Lamport timestamp (ascending), then writer ID, then SHA. * This ensures deterministic ordering regardless of discovery order. * - * @param {Array<{patch: Object, sha: string}>} patches - Unsorted patch entries - * @returns {Array<{patch: Object, sha: string}>} Sorted patch entries + * @param {Array<{patch: any, sha: string}>} patches - Unsorted patch entries + * @returns {Array<{patch: any, sha: string}>} Sorted patch entries * @private */ _sortPatchesCausally(patches) { diff --git a/src/domain/crdt/LWW.js b/src/domain/crdt/LWW.js index c82f0729..f2c87907 100644 --- a/src/domain/crdt/LWW.js +++ b/src/domain/crdt/LWW.js @@ -131,7 +131,7 @@ export function lwwMax(a, b) { return null; } if (a === null || a === undefined) { - return b; + return /** @type {LWWRegister} */ (b); } if (b === null || b === undefined) { return a; diff --git a/src/domain/crdt/ORSet.js b/src/domain/crdt/ORSet.js index 7e0b5cc2..714a2c25 100644 --- a/src/domain/crdt/ORSet.js +++ b/src/domain/crdt/ORSet.js @@ -118,11 +118,13 @@ export function createORSet() { export function orsetAdd(set, element, dot) { const encoded = encodeDot(dot); - if (!set.entries.has(element)) { - set.entries.set(element, new Set()); + let dots = set.entries.get(element); + if (!dots) { + dots = new Set(); + set.entries.set(element, dots); } - set.entries.get(element).add(encoded); + dots.add(encoded); } /** @@ -226,10 +228,11 @@ export function orsetJoin(a, b) { // Union entries from b for (const [element, dots] of b.entries) { - if (!result.entries.has(element)) { - result.entries.set(element, new Set()); + let resultDots = result.entries.get(element); + if (!resultDots) { + resultDots = new Set(); + result.entries.set(element, resultDots); } - const resultDots = result.entries.get(element); for (const dot of dots) { resultDots.add(dot); } @@ -312,6 +315,7 @@ export function orsetCompact(set, includedVV) { */ export function orsetSerialize(set) { // Serialize entries: convert Map to array of [element, sortedDots] + /** @type {Array<[any, string[]]>} */ const entriesArray = []; for (const [element, dots] of set.entries) { const sortedDots = [...dots].sort((a, b) => { diff --git a/src/domain/crdt/VersionVector.js b/src/domain/crdt/VersionVector.js index fd422ceb..a9665f79 100644 --- a/src/domain/crdt/VersionVector.js +++ b/src/domain/crdt/VersionVector.js @@ -158,11 +158,15 @@ export function vvContains(vv, dot) { * @returns {Object} */ export function vvSerialize(vv) { + /** @type {Record} */ const obj = {}; const sortedKeys = [...vv.keys()].sort(); for (const key of sortedKeys) { - obj[key] = vv.get(key); + const val = vv.get(key); + if (val !== undefined) { + obj[key] = val; + } } return obj; diff --git a/src/domain/errors/EmptyMessageError.js b/src/domain/errors/EmptyMessageError.js index cab1e19f..70502982 100644 --- a/src/domain/errors/EmptyMessageError.js +++ b/src/domain/errors/EmptyMessageError.js @@ -12,7 +12,7 @@ import IndexError from './IndexError.js'; * @property {string} name - The error name ('EmptyMessageError') * @property {string} code - Error code ('EMPTY_MESSAGE') * @property {string} operation - The operation that failed due to empty message - * @property {Object} context - Serializable context object for debugging + * @property {Record} context - Serializable context object for debugging * * @example * if (!message || message.trim() === '') { @@ -27,9 +27,7 @@ export default class EmptyMessageError extends IndexError { * Creates a new EmptyMessageError. * * @param {string} message - Human-readable error message - * @param {Object} [options={}] - Error options - * @param {string} [options.operation] - The operation that failed - * @param {Object} [options.context={}] - Additional context for debugging + * @param {{ operation?: string, context?: Record }} [options={}] - Error options */ constructor(message, options = {}) { const context = { diff --git a/src/domain/errors/ForkError.js b/src/domain/errors/ForkError.js index 7584e5dd..68401e55 100644 --- a/src/domain/errors/ForkError.js +++ b/src/domain/errors/ForkError.js @@ -24,6 +24,10 @@ import WarpError from './WarpError.js'; * @property {Object} context - Serializable context object with error details */ export default class ForkError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'FORK_ERROR', options); } diff --git a/src/domain/errors/IndexError.js b/src/domain/errors/IndexError.js index 9b0ab474..e582412c 100644 --- a/src/domain/errors/IndexError.js +++ b/src/domain/errors/IndexError.js @@ -17,6 +17,10 @@ import WarpError from './WarpError.js'; * }); */ export default class IndexError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'INDEX_ERROR', options); } diff --git a/src/domain/errors/OperationAbortedError.js b/src/domain/errors/OperationAbortedError.js index 648335c1..aad0c1bc 100644 --- a/src/domain/errors/OperationAbortedError.js +++ b/src/domain/errors/OperationAbortedError.js @@ -13,6 +13,10 @@ import WarpError from './WarpError.js'; * @property {Object} context - Serializable context object for debugging */ export default class OperationAbortedError extends WarpError { + /** + * @param {string} operation + * @param {{ code?: string, context?: Object, reason?: string }} [options={}] + */ constructor(operation, options = {}) { const reason = options.reason || 'Operation was aborted'; super(`Operation '${operation}' aborted: ${reason}`, 'OPERATION_ABORTED', options); diff --git a/src/domain/errors/QueryError.js b/src/domain/errors/QueryError.js index 27da2378..39cafdd6 100644 --- a/src/domain/errors/QueryError.js +++ b/src/domain/errors/QueryError.js @@ -33,6 +33,10 @@ import WarpError from './WarpError.js'; * @property {Object} context - Serializable context object with error details */ export default class QueryError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'QUERY_ERROR', options); } diff --git a/src/domain/errors/SchemaUnsupportedError.js b/src/domain/errors/SchemaUnsupportedError.js index 5064196f..6f26900c 100644 --- a/src/domain/errors/SchemaUnsupportedError.js +++ b/src/domain/errors/SchemaUnsupportedError.js @@ -11,6 +11,10 @@ import WarpError from './WarpError.js'; * @property {Object} context - Serializable context object for debugging */ export default class SchemaUnsupportedError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'E_SCHEMA_UNSUPPORTED', options); } diff --git a/src/domain/errors/ShardCorruptionError.js b/src/domain/errors/ShardCorruptionError.js index 7d047df3..9d1ac29f 100644 --- a/src/domain/errors/ShardCorruptionError.js +++ b/src/domain/errors/ShardCorruptionError.js @@ -14,7 +14,7 @@ import IndexError from './IndexError.js'; * @property {string} shardPath - Path to the corrupted shard file * @property {string} oid - Object ID associated with the shard * @property {string} reason - Reason for corruption (e.g., 'invalid_checksum', 'invalid_version', 'parse_error') - * @property {Object} context - Serializable context object for debugging + * @property {Record} context - Serializable context object for debugging * * @example * if (!validateChecksum(data)) { @@ -30,11 +30,7 @@ export default class ShardCorruptionError extends IndexError { * Creates a new ShardCorruptionError. * * @param {string} message - Human-readable error message - * @param {Object} [options={}] - Error options - * @param {string} [options.shardPath] - Path to the corrupted shard file - * @param {string} [options.oid] - Object ID associated with the shard - * @param {string} [options.reason] - Reason for corruption (e.g., 'invalid_checksum', 'invalid_version', 'parse_error') - * @param {Object} [options.context={}] - Additional context for debugging + * @param {{ shardPath?: string, oid?: string, reason?: string, context?: Record }} [options={}] - Error options */ constructor(message, options = {}) { const context = { diff --git a/src/domain/errors/ShardLoadError.js b/src/domain/errors/ShardLoadError.js index 06400d39..5b2879ae 100644 --- a/src/domain/errors/ShardLoadError.js +++ b/src/domain/errors/ShardLoadError.js @@ -14,7 +14,7 @@ import IndexError from './IndexError.js'; * @property {string} shardPath - Path to the shard file that failed to load * @property {string} oid - Object ID associated with the shard * @property {Error} cause - The original error that caused the load failure - * @property {Object} context - Serializable context object for debugging + * @property {Record} context - Serializable context object for debugging * * @example * try { @@ -32,11 +32,7 @@ export default class ShardLoadError extends IndexError { * Creates a new ShardLoadError. * * @param {string} message - Human-readable error message - * @param {Object} [options={}] - Error options - * @param {string} [options.shardPath] - Path to the shard file - * @param {string} [options.oid] - Object ID associated with the shard - * @param {Error} [options.cause] - The original error that caused the failure - * @param {Object} [options.context={}] - Additional context for debugging + * @param {{ shardPath?: string, oid?: string, cause?: Error, context?: Record }} [options={}] - Error options */ constructor(message, options = {}) { const context = { diff --git a/src/domain/errors/ShardValidationError.js b/src/domain/errors/ShardValidationError.js index f8cf5af3..4ebf63f9 100644 --- a/src/domain/errors/ShardValidationError.js +++ b/src/domain/errors/ShardValidationError.js @@ -15,7 +15,7 @@ import IndexError from './IndexError.js'; * @property {*} expected - The expected value for the field * @property {*} actual - The actual value found in the shard * @property {string} field - The field that failed validation (e.g., 'checksum', 'version') - * @property {Object} context - Serializable context object for debugging + * @property {Record} context - Serializable context object for debugging * * @example * if (shard.version !== EXPECTED_VERSION) { @@ -32,12 +32,7 @@ export default class ShardValidationError extends IndexError { * Creates a new ShardValidationError. * * @param {string} message - Human-readable error message - * @param {Object} [options={}] - Error options - * @param {string} [options.shardPath] - Path to the shard file - * @param {*} [options.expected] - The expected value - * @param {*} [options.actual] - The actual value found - * @param {string} [options.field] - The field that failed validation (e.g., 'checksum', 'version') - * @param {Object} [options.context={}] - Additional context for debugging + * @param {{ shardPath?: string, expected?: *, actual?: *, field?: string, context?: Record }} [options={}] - Error options */ constructor(message, options = {}) { const context = { diff --git a/src/domain/errors/StorageError.js b/src/domain/errors/StorageError.js index 7d983027..147bd5f9 100644 --- a/src/domain/errors/StorageError.js +++ b/src/domain/errors/StorageError.js @@ -14,7 +14,7 @@ import IndexError from './IndexError.js'; * @property {string} operation - The operation that failed ('read' or 'write') * @property {string} oid - Object ID associated with the operation * @property {Error} cause - The original error that caused the failure - * @property {Object} context - Serializable context object for debugging + * @property {Record} context - Serializable context object for debugging * * @example * try { @@ -32,11 +32,7 @@ export default class StorageError extends IndexError { * Creates a new StorageError. * * @param {string} message - Human-readable error message - * @param {Object} [options={}] - Error options - * @param {string} [options.operation] - The operation that failed ('read' or 'write') - * @param {string} [options.oid] - Object ID associated with the operation - * @param {Error} [options.cause] - The original error that caused the failure - * @param {Object} [options.context={}] - Additional context for debugging + * @param {{ operation?: string, oid?: string, cause?: Error, context?: Record }} [options={}] - Error options */ constructor(message, options = {}) { const context = { diff --git a/src/domain/errors/SyncError.js b/src/domain/errors/SyncError.js index 95479f8c..e2a2a869 100644 --- a/src/domain/errors/SyncError.js +++ b/src/domain/errors/SyncError.js @@ -24,6 +24,10 @@ import WarpError from './WarpError.js'; * @property {Object} context - Serializable context object with error details */ export default class SyncError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'SYNC_ERROR', options); } diff --git a/src/domain/errors/TraversalError.js b/src/domain/errors/TraversalError.js index f8566c47..745c56c1 100644 --- a/src/domain/errors/TraversalError.js +++ b/src/domain/errors/TraversalError.js @@ -17,6 +17,10 @@ import WarpError from './WarpError.js'; * }); */ export default class TraversalError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'TRAVERSAL_ERROR', options); } diff --git a/src/domain/errors/WarpError.js b/src/domain/errors/WarpError.js index 05a1a30b..54dc331d 100644 --- a/src/domain/errors/WarpError.js +++ b/src/domain/errors/WarpError.js @@ -10,15 +10,13 @@ * * @property {string} name - Error name (set from constructor.name) * @property {string} code - Machine-readable error code - * @property {Object} context - Serializable context for debugging + * @property {Record} context - Serializable context for debugging */ export default class WarpError extends Error { /** * @param {string} message - Human-readable error message * @param {string} defaultCode - Default error code if not overridden by options - * @param {Object} [options={}] - Error options - * @param {string} [options.code] - Override error code - * @param {Object} [options.context={}] - Serializable context for debugging + * @param {{ code?: string, context?: Record }} [options={}] - Error options */ constructor(message, defaultCode, options = {}) { super(message); diff --git a/src/domain/errors/WormholeError.js b/src/domain/errors/WormholeError.js index a2c27039..643ef284 100644 --- a/src/domain/errors/WormholeError.js +++ b/src/domain/errors/WormholeError.js @@ -22,6 +22,10 @@ import WarpError from './WarpError.js'; * @property {Object} context - Serializable context object with error details */ export default class WormholeError extends WarpError { + /** + * @param {string} message + * @param {{ code?: string, context?: Object }} [options={}] + */ constructor(message, options = {}) { super(message, 'WORMHOLE_ERROR', options); } diff --git a/src/domain/services/AnchorMessageCodec.js b/src/domain/services/AnchorMessageCodec.js index 8b99a88f..7d51e70b 100644 --- a/src/domain/services/AnchorMessageCodec.js +++ b/src/domain/services/AnchorMessageCodec.js @@ -56,10 +56,7 @@ export function encodeAnchorMessage({ graph, schema = 2 }) { * Decodes an anchor commit message. * * @param {string} message - The raw commit message - * @returns {Object} The decoded anchor message - * @returns {string} return.kind - Always 'anchor' - * @returns {string} return.graph - The graph name - * @returns {number} return.schema - The schema version + * @returns {{ kind: 'anchor', graph: string, schema: number }} The decoded anchor message * @throws {Error} If the message is not a valid anchor message * * @example diff --git a/src/domain/services/BitmapIndexBuilder.js b/src/domain/services/BitmapIndexBuilder.js index 42ce55d2..bb78ec6d 100644 --- a/src/domain/services/BitmapIndexBuilder.js +++ b/src/domain/services/BitmapIndexBuilder.js @@ -1,4 +1,5 @@ import defaultCodec from '../utils/defaultCodec.js'; +import defaultCrypto from '../utils/defaultCrypto.js'; import { getRoaringBitmap32, getNativeRoaringAvailable } from '../utils/roaring.js'; import { canonicalStringify } from '../utils/canonicalStringify.js'; import { SHARD_VERSION } from '../utils/shardVersion.js'; @@ -13,10 +14,9 @@ export { SHARD_VERSION }; * * @param {Object} data - The data object to checksum * @param {import('../../ports/CryptoPort.js').default} crypto - CryptoPort instance - * @returns {Promise} Hex-encoded SHA-256 hash + * @returns {Promise} Hex-encoded SHA-256 hash */ const computeChecksum = async (data, crypto) => { - if (!crypto) { return null; } const json = canonicalStringify(data); return await crypto.hash('sha256', json); }; @@ -51,6 +51,7 @@ const wrapShard = async (data, crypto) => ({ * @param {import('../../ports/CodecPort.js').default} codec - Codec for CBOR serialization */ function serializeFrontierToTree(frontier, tree, codec) { + /** @type {Record} */ const sorted = {}; for (const key of Array.from(frontier.keys()).sort()) { sorted[key] = frontier.get(key); @@ -95,14 +96,14 @@ export default class BitmapIndexBuilder { */ constructor({ crypto, codec } = {}) { /** @type {import('../../ports/CryptoPort.js').default} */ - this._crypto = crypto; - /** @type {import('../../ports/CodecPort.js').default|undefined} */ + this._crypto = crypto || defaultCrypto; + /** @type {import('../../ports/CodecPort.js').default} */ this._codec = codec || defaultCodec; /** @type {Map} */ this.shaToId = new Map(); /** @type {string[]} */ this.idToSha = []; - /** @type {Map} */ + /** @type {Map} */ this.bitmaps = new Map(); } @@ -148,9 +149,11 @@ export default class BitmapIndexBuilder { * @returns {Promise>} Map of path → serialized content */ async serialize({ frontier } = {}) { + /** @type {Record} */ const tree = {}; // Serialize ID mappings (sharded by prefix) + /** @type {Record>} */ const idShards = {}; for (const [sha, id] of this.shaToId) { const prefix = sha.substring(0, 2); @@ -165,6 +168,7 @@ export default class BitmapIndexBuilder { // Serialize bitmaps (sharded by prefix, per-node within shard) // Keys are constructed as '${type}_${sha}' by _addToBitmap (e.g., 'fwd_abc123', 'rev_def456') + /** @type {Record>>} */ const bitmapShards = { fwd: {}, rev: {} }; for (const [key, bitmap] of this.bitmaps) { const [type, sha] = [key.substring(0, 3), key.substring(4)]; @@ -198,7 +202,7 @@ export default class BitmapIndexBuilder { */ _getOrCreateId(sha) { if (this.shaToId.has(sha)) { - return this.shaToId.get(sha); + return /** @type {number} */ (this.shaToId.get(sha)); } const id = this.idToSha.length; this.idToSha.push(sha); diff --git a/src/domain/services/BitmapIndexReader.js b/src/domain/services/BitmapIndexReader.js index c96aeda2..578ce983 100644 --- a/src/domain/services/BitmapIndexReader.js +++ b/src/domain/services/BitmapIndexReader.js @@ -1,9 +1,14 @@ import { ShardLoadError, ShardCorruptionError, ShardValidationError } from '../errors/index.js'; +import defaultCrypto from '../utils/defaultCrypto.js'; import nullLogger from '../utils/nullLogger.js'; import LRUCache from '../utils/LRUCache.js'; import { getRoaringBitmap32 } from '../utils/roaring.js'; import { canonicalStringify } from '../utils/canonicalStringify.js'; +/** @typedef {import('../../ports/IndexStoragePort.js').default} IndexStoragePort */ +/** @typedef {import('../../ports/LoggerPort.js').default} LoggerPort */ +/** @typedef {import('../../ports/CryptoPort.js').default} CryptoPort */ + /** * Supported shard format versions for backward compatibility. * Version 1: Original format using JSON.stringify for checksums @@ -25,10 +30,9 @@ const DEFAULT_MAX_CACHED_SHARDS = 100; * @param {Object} data - The data object to checksum * @param {number} version - Shard version (1 uses JSON.stringify, 2+ uses canonicalStringify) * @param {import('../../ports/CryptoPort.js').default} crypto - CryptoPort instance - * @returns {Promise} Hex-encoded SHA-256 hash + * @returns {Promise} Hex-encoded SHA-256 hash */ const computeChecksum = async (data, version, crypto) => { - if (!crypto) { return null; } const json = version === 1 ? JSON.stringify(data) : canonicalStringify(data); return await crypto.hash('sha256', json); }; @@ -77,16 +81,15 @@ export default class BitmapIndexReader { /** * Creates a BitmapIndexReader instance. * @param {Object} options - * @param {import('../../ports/IndexStoragePort.js').default} options.storage - Storage adapter for reading index data + * @param {IndexStoragePort} options.storage - Storage adapter for reading index data * @param {boolean} [options.strict=false] - If true, throw errors on validation failures; if false, log warnings and return empty shards * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging. * Defaults to NoOpLogger (no logging). * @param {number} [options.maxCachedShards=100] - Maximum number of shards to keep in the LRU cache. * When exceeded, least recently used shards are evicted to free memory. * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance for checksum verification. - * When not provided, checksum validation is skipped. */ - constructor({ storage, strict = false, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = {}) { + constructor({ storage, strict = false, logger = nullLogger, maxCachedShards = DEFAULT_MAX_CACHED_SHARDS, crypto } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type if (!storage) { throw new Error('BitmapIndexReader requires a storage adapter'); } @@ -95,9 +98,10 @@ export default class BitmapIndexReader { this.logger = logger; this.maxCachedShards = maxCachedShards; /** @type {import('../../ports/CryptoPort.js').default} */ - this._crypto = crypto; + this._crypto = crypto || defaultCrypto; this.shardOids = new Map(); // path -> OID this.loadedShards = new LRUCache(maxCachedShards); // path -> Data + /** @type {string[]|null} */ this._idToShaCache = null; // Lazy-built reverse mapping } @@ -191,7 +195,7 @@ export default class BitmapIndexReader { shardPath, oid: this.shardOids.get(shardPath), reason: 'bitmap_deserialize_error', - originalError: err.message, + context: { originalError: /** @type {any} */ (err).message }, // TODO(ts-cleanup): type error }); this._handleShardError(corruptionError, { path: shardPath, @@ -243,10 +247,10 @@ export default class BitmapIndexReader { /** * Validates a shard envelope for version and checksum integrity. * - * @param {Object} envelope - The shard envelope to validate + * @param {{ data?: any, version?: number, checksum?: string }} envelope - The shard envelope to validate * @param {string} path - Shard path (for error context) * @param {string} oid - Object ID (for error context) - * @returns {Promise} The validated data from the envelope + * @returns {Promise} The validated data from the envelope * @throws {ShardCorruptionError} If envelope format is invalid * @throws {ShardValidationError} If version or checksum validation fails * @private @@ -267,7 +271,7 @@ export default class BitmapIndexReader { reason: 'missing_or_invalid_data', }); } - if (!SUPPORTED_SHARD_VERSIONS.includes(envelope.version)) { + if (!SUPPORTED_SHARD_VERSIONS.includes(/** @type {number} */ (envelope.version))) { throw new ShardValidationError('Unsupported version', { shardPath: path, expected: SUPPORTED_SHARD_VERSIONS, @@ -276,8 +280,8 @@ export default class BitmapIndexReader { }); } // Use version-appropriate checksum computation for backward compatibility - const actualChecksum = await computeChecksum(envelope.data, envelope.version, this._crypto); - if (actualChecksum !== null && envelope.checksum !== actualChecksum) { + const actualChecksum = await computeChecksum(envelope.data, /** @type {number} */ (envelope.version), this._crypto); + if (envelope.checksum !== actualChecksum) { throw new ShardValidationError('Checksum mismatch', { shardPath: path, expected: envelope.checksum, @@ -295,7 +299,7 @@ export default class BitmapIndexReader { * @param {string} context.path - Shard path * @param {string} context.oid - Object ID * @param {string} context.format - 'json' or 'bitmap' - * @returns {Object|RoaringBitmap32} Empty shard (non-strict mode only) + * @returns {any} Empty shard (non-strict mode only) * @throws {ShardCorruptionError|ShardValidationError} In strict mode * @private */ @@ -303,15 +307,17 @@ export default class BitmapIndexReader { if (this.strict) { throw err; } + /** @type {any} */ // TODO(ts-cleanup): type lazy singleton + const errAny = err; this.logger.warn('Shard validation warning', { operation: 'loadShard', shardPath: path, oid, error: err.message, code: err.code, - field: err.field, - expected: err.expected, - actual: err.actual, + field: errAny.field, + expected: errAny.expected, + actual: errAny.actual, }); const emptyShard = format === 'json' ? {} : new (getRoaringBitmap32())(); this.loadedShards.set(path, emptyShard); @@ -343,12 +349,12 @@ export default class BitmapIndexReader { */ async _loadShardBuffer(path, oid) { try { - return await this.storage.readBlob(oid); + return await /** @type {any} */ (this.storage).readBlob(oid); // TODO(ts-cleanup): narrow port type } catch (cause) { throw new ShardLoadError('Failed to load shard from storage', { shardPath: path, oid, - cause, + cause: /** @type {Error} */ (cause), }); } } @@ -376,12 +382,12 @@ export default class BitmapIndexReader { /** * Attempts to handle a shard error based on its type. * Returns handled result for validation/corruption errors, null otherwise. - * @param {Error} err - The error to handle + * @param {any} err - The error to handle * @param {Object} context - Error context * @param {string} context.path - Shard path * @param {string} context.oid - Object ID * @param {string} context.format - 'json' or 'bitmap' - * @returns {Object|RoaringBitmap32|null} Handled result or null if error should be re-thrown + * @returns {any} Handled result or null if error should be re-thrown * @private */ _tryHandleShardError(err, context) { @@ -400,7 +406,7 @@ export default class BitmapIndexReader { * * @param {string} path - Shard path * @param {string} format - 'json' or 'bitmap' - * @returns {Promise} + * @returns {Promise} * @throws {ShardLoadError} When storage.readBlob fails * @throws {ShardCorruptionError} When shard format is invalid (strict mode only) * @throws {ShardValidationError} When version or checksum validation fails (strict mode only) diff --git a/src/domain/services/BoundaryTransitionRecord.js b/src/domain/services/BoundaryTransitionRecord.js index 1d2e0369..6eadc394 100644 --- a/src/domain/services/BoundaryTransitionRecord.js +++ b/src/domain/services/BoundaryTransitionRecord.js @@ -82,10 +82,10 @@ const BTR_VERSION = 1; * @param {string} fields.h_in - Hash of input state * @param {string} fields.h_out - Hash of output state * @param {Uint8Array} fields.U_0 - Serialized initial state - * @param {Array} fields.P - Serialized provenance payload + * @param {Array<*>} fields.P - Serialized provenance payload * @param {string} fields.t - ISO timestamp * @param {string|Uint8Array} key - HMAC key - * @param {import('../../ports/CryptoPort.js').default} crypto - CryptoPort instance + * @param {{ crypto: import('../../ports/CryptoPort.js').default, codec?: import('../../ports/CodecPort.js').default }} deps - Dependencies * @returns {Promise} Hex-encoded HMAC tag * @private */ @@ -111,7 +111,7 @@ async function computeHmac(fields, key, { crypto, codec }) { * @property {string} h_in - Hash of input state (hex SHA-256) * @property {string} h_out - Hash of output state (hex SHA-256) * @property {Uint8Array} U_0 - Serialized initial state (CBOR) - * @property {Array} P - Serialized provenance payload + * @property {Array<*>} P - Serialized provenance payload * @property {string} t - ISO 8601 timestamp * @property {string} kappa - Authentication tag (hex HMAC-SHA256) */ @@ -155,6 +155,7 @@ async function computeHmac(fields, key, { crypto, codec }) { * @param {string|Uint8Array} options.key - HMAC key for authentication * @param {string} [options.timestamp] - ISO timestamp (defaults to now) * @param {import('../../ports/CryptoPort.js').default} options.crypto - CryptoPort instance + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization * @returns {Promise} The created BTR * @throws {TypeError} If payload is not a ProvenancePayload */ @@ -212,7 +213,7 @@ function validateBTRStructure(btr) { * * @param {BTR} btr - The BTR to verify * @param {string|Uint8Array} key - HMAC key - * @param {import('../../ports/CryptoPort.js').default} crypto - CryptoPort instance + * @param {{ crypto: import('../../ports/CryptoPort.js').default, codec?: import('../../ports/CodecPort.js').default }} deps - Dependencies * @returns {Promise} True if the HMAC tag matches * @private */ @@ -243,10 +244,13 @@ async function verifyHmac(btr, key, { crypto, codec }) { * Verifies replay produces expected h_out. * * @param {BTR} btr - The BTR to verify + * @param {Object} [deps] - Dependencies + * @param {import('../../ports/CryptoPort.js').default} [deps.crypto] - CryptoPort instance + * @param {import('../../ports/CodecPort.js').default} [deps.codec] - Codec * @returns {Promise} Error message if replay mismatch, null if valid * @private */ -async function verifyReplayHash(btr, { crypto, codec } = {}) { +async function verifyReplayHash(btr, { crypto, codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type try { const result = await replayBTR(btr, { crypto, codec }); if (result.h_out !== btr.h_out) { @@ -254,7 +258,7 @@ async function verifyReplayHash(btr, { crypto, codec } = {}) { } return null; } catch (err) { - return `Replay failed: ${err.message}`; + return `Replay failed: ${/** @type {any} */ (err).message}`; // TODO(ts-cleanup): type error } } @@ -272,10 +276,11 @@ async function verifyReplayHash(btr, { crypto, codec } = {}) { * @param {string|Uint8Array} key - HMAC key * @param {Object} [options] - Verification options * @param {boolean} [options.verifyReplay=false] - Also verify replay produces h_out - * @param {import('../../ports/CryptoPort.js').default} options.crypto - CryptoPort instance + * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization * @returns {Promise} Verification result with valid flag and optional reason */ -export async function verifyBTR(btr, key, options = {}) { +export async function verifyBTR(btr, key, options = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const { crypto, codec } = options; const structureError = validateBTRStructure(btr); @@ -285,7 +290,7 @@ export async function verifyBTR(btr, key, options = {}) { let hmacValid; try { - hmacValid = await verifyHmac(btr, key, { crypto, codec }); + hmacValid = await verifyHmac(btr, key, { crypto: /** @type {import('../../ports/CryptoPort.js').default} */ (crypto), codec }); } catch (err) { if (err instanceof RangeError) { return { valid: false, reason: `Invalid hex in authentication tag: ${err.message}` }; @@ -313,11 +318,12 @@ export async function verifyBTR(btr, key, options = {}) { * encoding (U_0, P), replay uniquely determines the interior worldline. * * @param {BTR} btr - The BTR to replay + * @param {{ crypto?: import('../../ports/CryptoPort.js').default, codec?: import('../../ports/CodecPort.js').default }} deps - Dependencies * @returns {Promise<{ state: import('./JoinReducer.js').WarpStateV5, h_out: string }>} * The final state and its hash * @throws {Error} If replay fails */ -export async function replayBTR(btr, { crypto, codec } = {}) { +export async function replayBTR(btr, { crypto, codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type // Deserialize initial state from U_0 // Note: U_0 is the full serialized state (via serializeFullStateV5) const initialState = deserializeInitialState(btr.U_0, { codec }); @@ -329,7 +335,7 @@ export async function replayBTR(btr, { crypto, codec } = {}) { const finalState = payload.replay(initialState); // Compute h_out - const h_out = await computeStateHashV5(finalState, { crypto, codec }); + const h_out = await computeStateHashV5(finalState, { crypto: /** @type {import('../../ports/CryptoPort.js').default} */ (crypto), codec }); return { state: finalState, h_out }; } @@ -345,10 +351,11 @@ export async function replayBTR(btr, { crypto, codec } = {}) { * the correct h_out hash. * * @param {Uint8Array} U_0 - Serialized full state + * @param {{ codec?: import('../../ports/CodecPort.js').default }} options * @returns {import('./JoinReducer.js').WarpStateV5} The deserialized state * @private */ -function deserializeInitialState(U_0, { codec } = {}) { +function deserializeInitialState(U_0, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type return deserializeFullStateV5(U_0, { codec }); } @@ -363,7 +370,7 @@ function deserializeInitialState(U_0, { codec } = {}) { * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization * @returns {Uint8Array} CBOR-encoded BTR */ -export function serializeBTR(btr, { codec } = {}) { +export function serializeBTR(btr, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const c = codec || defaultCodec; return c.encode({ version: btr.version, @@ -385,9 +392,9 @@ export function serializeBTR(btr, { codec } = {}) { * @returns {BTR} The deserialized BTR * @throws {Error} If the bytes are not valid CBOR or missing required fields */ -export function deserializeBTR(bytes, { codec } = {}) { +export function deserializeBTR(bytes, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const c = codec || defaultCodec; - const obj = c.decode(bytes); + const obj = /** @type {Record} */ (c.decode(bytes)); // Validate structure (reuse module-level constant for consistency with validateBTRStructure) for (const field of REQUIRED_FIELDS) { diff --git a/src/domain/services/CheckpointMessageCodec.js b/src/domain/services/CheckpointMessageCodec.js index c1737cb3..dfa620ca 100644 --- a/src/domain/services/CheckpointMessageCodec.js +++ b/src/domain/services/CheckpointMessageCodec.js @@ -79,13 +79,7 @@ export function encodeCheckpointMessage({ graph, stateHash, frontierOid, indexOi * Decodes a checkpoint commit message. * * @param {string} message - The raw commit message - * @returns {Object} The decoded checkpoint message - * @returns {string} return.kind - Always 'checkpoint' - * @returns {string} return.graph - The graph name - * @returns {string} return.stateHash - The SHA-256 state hash - * @returns {string} return.frontierOid - The frontier blob OID - * @returns {string} return.indexOid - The index tree OID - * @returns {number} return.schema - The schema version + * @returns {{ kind: 'checkpoint', graph: string, stateHash: string, frontierOid: string, indexOid: string, schema: number, checkpointVersion: string|null }} The decoded checkpoint message * @throws {Error} If the message is not a valid checkpoint message * * @example diff --git a/src/domain/services/CheckpointSerializerV5.js b/src/domain/services/CheckpointSerializerV5.js index a4a6b215..59242048 100644 --- a/src/domain/services/CheckpointSerializerV5.js +++ b/src/domain/services/CheckpointSerializerV5.js @@ -37,9 +37,9 @@ import { createEmptyStateV5 } from './JoinReducer.js'; * @param {import('./JoinReducer.js').WarpStateV5} state * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization - * @returns {Buffer} CBOR-encoded full state + * @returns {Buffer|Uint8Array} CBOR-encoded full state */ -export function serializeFullStateV5(state, { codec } = {}) { +export function serializeFullStateV5(state, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const c = codec || defaultCodec; // Serialize ORSets using existing serialization const nodeAliveObj = orsetSerialize(state.nodeAlive); @@ -84,20 +84,20 @@ export function serializeFullStateV5(state, { codec } = {}) { /** * Deserializes full V5 state. Used for resume. * - * @param {Buffer} buffer - CBOR-encoded full state + * @param {Buffer|Uint8Array} buffer - CBOR-encoded full state * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization * @returns {import('./JoinReducer.js').WarpStateV5} */ // eslint-disable-next-line complexity -export function deserializeFullStateV5(buffer, { codec: codecOpt } = {}) { +export function deserializeFullStateV5(buffer, { codec: codecOpt } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const codec = codecOpt || defaultCodec; // Handle null/undefined buffer before attempting decode if (buffer === null || buffer === undefined) { return createEmptyStateV5(); } - const obj = codec.decode(buffer); + const obj = /** @type {Record} */ (codec.decode(buffer)); // Handle null/undefined decoded result: return empty state if (obj === null || obj === undefined) { @@ -117,7 +117,7 @@ export function deserializeFullStateV5(buffer, { codec: codecOpt } = {}) { edgeAlive: orsetDeserialize(obj.edgeAlive || {}), prop: deserializeProps(obj.prop), observedFrontier: vvDeserialize(obj.observedFrontier || {}), - edgeBirthEvent: deserializeEdgeBirthEvent(obj), + edgeBirthEvent: /** @type {Map} */ (deserializeEdgeBirthEvent(obj)), }; } @@ -170,9 +170,9 @@ export function computeAppliedVV(state) { * @param {Map} vv - Version vector (Map) * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization - * @returns {Buffer} CBOR-encoded version vector + * @returns {Buffer|Uint8Array} CBOR-encoded version vector */ -export function serializeAppliedVV(vv, { codec } = {}) { +export function serializeAppliedVV(vv, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const c = codec || defaultCodec; const obj = vvSerialize(vv); return c.encode(obj); @@ -181,14 +181,14 @@ export function serializeAppliedVV(vv, { codec } = {}) { /** * Deserializes appliedVV from CBOR format. * - * @param {Buffer} buffer - CBOR-encoded version vector + * @param {Buffer|Uint8Array} buffer - CBOR-encoded version vector * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization * @returns {Map} Version vector */ -export function deserializeAppliedVV(buffer, { codec } = {}) { +export function deserializeAppliedVV(buffer, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const c = codec || defaultCodec; - const obj = c.decode(buffer); + const obj = /** @type {{ [x: string]: number }} */ (c.decode(buffer)); return vvDeserialize(obj); } @@ -198,8 +198,8 @@ export function deserializeAppliedVV(buffer, { codec } = {}) { /** * Deserializes the props array from checkpoint format. - * @param {Array} propArray - Array of [key, registerObj] pairs - * @returns {Map} + * @param {Array<*>} propArray - Array of [key, registerObj] pairs + * @returns {Map>} */ function deserializeProps(propArray) { const prop = new Map(); @@ -213,10 +213,11 @@ function deserializeProps(propArray) { /** * Deserializes edge birth event data, supporting both legacy and current formats. - * @param {Object} obj - The decoded checkpoint object - * @returns {Map} + * @param {Record} obj - The decoded checkpoint object + * @returns {Map} */ function deserializeEdgeBirthEvent(obj) { + /** @type {Map} */ const edgeBirthEvent = new Map(); const birthData = obj.edgeBirthEvent || obj.edgeBirthLamport; if (birthData && Array.isArray(birthData)) { @@ -239,8 +240,8 @@ function deserializeEdgeBirthEvent(obj) { * Serializes an LWW register for CBOR encoding. * EventId is serialized as a plain object with sorted keys. * - * @param {import('../crdt/LWW.js').LWWRegister} register - * @returns {Object} + * @param {import('../crdt/LWW.js').LWWRegister<*>} register + * @returns {{ eventId: { lamport: number, opIndex: number, patchSha: string, writerId: string }, value: * } | null} */ function serializeLWWRegister(register) { if (!register) { @@ -261,8 +262,8 @@ function serializeLWWRegister(register) { /** * Deserializes an LWW register from CBOR. * - * @param {Object} obj - * @returns {import('../crdt/LWW.js').LWWRegister} + * @param {{ eventId: { lamport: number, writerId: string, patchSha: string, opIndex: number }, value: * } | null} obj + * @returns {import('../crdt/LWW.js').LWWRegister<*> | null} */ function deserializeLWWRegister(obj) { if (!obj) { diff --git a/src/domain/services/CheckpointService.js b/src/domain/services/CheckpointService.js index d579338b..0d7e47b3 100644 --- a/src/domain/services/CheckpointService.js +++ b/src/domain/services/CheckpointService.js @@ -46,7 +46,7 @@ import { ProvenanceIndex } from './ProvenanceIndex.js'; * ``` * * @param {Object} options - Checkpoint creation options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} options.persistence - Git persistence adapter * @param {string} options.graphName - Name of the graph * @param {import('./JoinReducer.js').WarpStateV5} options.state - The V5 state to checkpoint * @param {import('./Frontier.js').Frontier} options.frontier - Writer frontier map @@ -75,7 +75,7 @@ export async function create({ persistence, graphName, state, frontier, parents * ``` * * @param {Object} options - Checkpoint creation options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} options.persistence - Git persistence adapter * @param {string} options.graphName - Name of the graph * @param {import('./JoinReducer.js').WarpStateV5} options.state - The V5 state to checkpoint * @param {import('./Frontier.js').Frontier} options.frontier - Writer frontier map @@ -113,23 +113,23 @@ export async function createV5({ // 4. Serialize visible projection (CACHE) const visibleBuffer = serializeStateV5(checkpointState, { codec }); - const stateHash = await computeStateHashV5(checkpointState, { codec, crypto }); + const stateHash = await computeStateHashV5(checkpointState, { codec, crypto: /** @type {import('../../ports/CryptoPort.js').default} */ (crypto) }); // 5. Serialize frontier and appliedVV - const frontierBuffer = serializeFrontier(frontier, { codec }); - const appliedVVBuffer = serializeAppliedVV(appliedVV, { codec }); + const frontierBuffer = serializeFrontier(frontier, { codec: /** @type {import('../../ports/CodecPort.js').default} */ (codec) }); + const appliedVVBuffer = serializeAppliedVV(appliedVV, { codec: /** @type {import('../../ports/CodecPort.js').default} */ (codec) }); // 6. Write blobs to git - const stateBlobOid = await persistence.writeBlob(stateBuffer); - const visibleBlobOid = await persistence.writeBlob(visibleBuffer); - const frontierBlobOid = await persistence.writeBlob(frontierBuffer); - const appliedVVBlobOid = await persistence.writeBlob(appliedVVBuffer); + const stateBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (stateBuffer)); + const visibleBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (visibleBuffer)); + const frontierBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (frontierBuffer)); + const appliedVVBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (appliedVVBuffer)); // 6b. Optionally serialize and write provenance index let provenanceIndexBlobOid = null; if (provenanceIndex) { const provenanceIndexBuffer = provenanceIndex.serialize({ codec }); - provenanceIndexBlobOid = await persistence.writeBlob(provenanceIndexBuffer); + provenanceIndexBlobOid = await persistence.writeBlob(/** @type {Buffer} */ (provenanceIndexBuffer)); } // 7. Create tree with sorted entries @@ -189,17 +189,17 @@ export async function createV5({ * Schema:1 checkpoints are not supported and will throw an error. * Use MigrationService to upgrade schema:1 checkpoints first. * - * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} persistence - Git persistence adapter * @param {string} checkpointSha - The checkpoint commit SHA to load * @param {Object} [options] - Load options * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for CBOR deserialization - * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: import('./Frontier.js').Frontier, stateHash: string, schema: number, appliedVV?: Map, provenanceIndex?: import('./ProvenanceIndex.js').ProvenanceIndex}>} The loaded checkpoint data + * @returns {Promise<{state: import('./JoinReducer.js').WarpStateV5, frontier: import('./Frontier.js').Frontier, stateHash: string, schema: number, appliedVV: Map|null, provenanceIndex?: import('./ProvenanceIndex.js').ProvenanceIndex}>} The loaded checkpoint data * @throws {Error} If checkpoint is schema:1 (migration required) */ -export async function loadCheckpoint(persistence, checkpointSha, { codec } = {}) { +export async function loadCheckpoint(persistence, checkpointSha, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type // 1. Read commit message and decode const message = await persistence.showNode(checkpointSha); - const decoded = decodeCheckpointMessage(message); + const decoded = /** @type {{ schema: number, stateHash: string, indexOid: string }} */ (decodeCheckpointMessage(message)); // 2. Reject schema:1 checkpoints - migration required if (decoded.schema !== 2 && decoded.schema !== 3) { @@ -218,7 +218,7 @@ export async function loadCheckpoint(persistence, checkpointSha, { codec } = {}) throw new Error(`Checkpoint ${checkpointSha} missing frontier.cbor in tree`); } const frontierBuffer = await persistence.readBlob(frontierOid); - const frontier = deserializeFrontier(frontierBuffer, { codec }); + const frontier = deserializeFrontier(frontierBuffer, { codec: /** @type {import('../../ports/CodecPort.js').default} */ (codec) }); // 5. Read state.cbor blob and deserialize as V5 full state const stateOid = treeOids['state.cbor']; @@ -252,7 +252,7 @@ export async function loadCheckpoint(persistence, checkpointSha, { codec } = {}) stateHash: decoded.stateHash, schema: decoded.schema, appliedVV, - provenanceIndex, + provenanceIndex: provenanceIndex || undefined, }; } @@ -270,7 +270,7 @@ export async function loadCheckpoint(persistence, checkpointSha, { codec } = {}) * loadCheckpoint to throw an error. * * @param {Object} options - Materialization options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default & import('../../ports/CommitPort.js').default} options.persistence - Git persistence adapter * @param {string} options.graphName - Name of the graph * @param {string} options.checkpointSha - The schema:2 checkpoint commit SHA to start from * @param {import('./Frontier.js').Frontier} options.targetFrontier - The target frontier to materialize to @@ -313,7 +313,7 @@ export async function materializeIncremental({ } // 5. Apply new patches using V5 reducer with checkpoint state as initial - const finalState = reduceV5(allPatches, initialState); + const finalState = /** @type {import('./JoinReducer.js').WarpStateV5} */ (reduceV5(allPatches, initialState)); return finalState; } diff --git a/src/domain/services/CommitDagTraversalService.js b/src/domain/services/CommitDagTraversalService.js index 371b8c6e..af21be62 100644 --- a/src/domain/services/CommitDagTraversalService.js +++ b/src/domain/services/CommitDagTraversalService.js @@ -39,7 +39,7 @@ export default class CommitDagTraversalService { * @param {import('./BitmapIndexReader.js').default} options.indexReader - Index reader for O(1) lookups * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger instance */ - constructor({ indexReader, logger = nullLogger } = {}) { + constructor({ indexReader, logger = nullLogger } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type if (!indexReader) { throw new Error('CommitDagTraversalService requires an indexReader'); } @@ -56,6 +56,7 @@ export default class CommitDagTraversalService { /** * Breadth-first traversal from a starting node. + * @param {*} options * @see DagTraversal#bfs */ bfs(options) { @@ -64,6 +65,7 @@ export default class CommitDagTraversalService { /** * Depth-first pre-order traversal from a starting node. + * @param {*} options * @see DagTraversal#dfs */ dfs(options) { @@ -72,6 +74,7 @@ export default class CommitDagTraversalService { /** * Yields all ancestors of a node. + * @param {*} options * @see DagTraversal#ancestors */ ancestors(options) { @@ -80,6 +83,7 @@ export default class CommitDagTraversalService { /** * Yields all descendants of a node. + * @param {*} options * @see DagTraversal#descendants */ descendants(options) { @@ -88,6 +92,7 @@ export default class CommitDagTraversalService { /** * Checks if there is any path from one node to another. + * @param {*} options * @see DagTraversal#isReachable */ isReachable(options) { @@ -98,6 +103,7 @@ export default class CommitDagTraversalService { /** * Finds ANY path between two nodes using BFS. + * @param {*} options * @see DagPathFinding#findPath */ findPath(options) { @@ -106,6 +112,7 @@ export default class CommitDagTraversalService { /** * Finds the shortest path using bidirectional BFS. + * @param {*} options * @see DagPathFinding#shortestPath */ shortestPath(options) { @@ -114,6 +121,7 @@ export default class CommitDagTraversalService { /** * Finds shortest path using Dijkstra's algorithm. + * @param {*} options * @see DagPathFinding#weightedShortestPath */ weightedShortestPath(options) { @@ -122,6 +130,7 @@ export default class CommitDagTraversalService { /** * Finds shortest path using A* with heuristic guidance. + * @param {*} options * @see DagPathFinding#aStarSearch */ aStarSearch(options) { @@ -130,6 +139,7 @@ export default class CommitDagTraversalService { /** * Bi-directional A* search. + * @param {*} options * @see DagPathFinding#bidirectionalAStar */ bidirectionalAStar(options) { @@ -140,6 +150,7 @@ export default class CommitDagTraversalService { /** * Finds common ancestors of multiple nodes. + * @param {*} options * @see DagTopology#commonAncestors */ commonAncestors(options) { @@ -148,6 +159,7 @@ export default class CommitDagTraversalService { /** * Yields nodes in topological order using Kahn's algorithm. + * @param {*} options * @see DagTopology#topologicalSort */ topologicalSort(options) { diff --git a/src/domain/services/DagPathFinding.js b/src/domain/services/DagPathFinding.js index 425dbae6..07972f52 100644 --- a/src/domain/services/DagPathFinding.js +++ b/src/domain/services/DagPathFinding.js @@ -41,7 +41,7 @@ export default class DagPathFinding { * @param {import('./BitmapIndexReader.js').default} options.indexReader - Index reader for O(1) lookups * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger instance */ - constructor({ indexReader, logger = nullLogger } = {}) { + constructor(/** @type {{ indexReader: import('./BitmapIndexReader.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ { indexReader, logger = nullLogger } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type if (!indexReader) { throw new Error('DagPathFinding requires an indexReader'); } @@ -85,7 +85,7 @@ export default class DagPathFinding { checkAborted(signal, 'findPath'); } - const current = queue.shift(); + const current = /** @type {{sha: string, depth: number}} */ (queue.shift()); if (current.depth > maxDepth) { continue; } if (visited.has(current.sha)) { continue; } @@ -200,7 +200,7 @@ export default class DagPathFinding { * @param {Object} options - Path finding options * @param {string} options.from - Starting SHA * @param {string} options.to - Target SHA - * @param {Function} [options.weightProvider] - Async callback `(fromSha, toSha) => number` + * @param {(from: string, to: string) => number|Promise} [options.weightProvider] - Async callback `(fromSha, toSha) => number` * @param {string} [options.direction='children'] - Edge direction: 'children' or 'parents' * @param {AbortSignal} [options.signal] - Optional AbortSignal for cancellation * @returns {Promise<{path: string[], totalCost: number}>} Path and cost @@ -277,8 +277,8 @@ export default class DagPathFinding { * @param {Object} options - Path finding options * @param {string} options.from - Starting SHA * @param {string} options.to - Target SHA - * @param {Function} [options.weightProvider] - Async callback `(fromSha, toSha) => number` - * @param {Function} [options.heuristicProvider] - Callback `(sha, targetSha) => number` + * @param {(from: string, to: string) => number|Promise} [options.weightProvider] - Async callback `(fromSha, toSha) => number` + * @param {(sha: string, target: string) => number} [options.heuristicProvider] - Callback `(sha, targetSha) => number` * @param {string} [options.direction='children'] - Edge direction: 'children' or 'parents' * @param {AbortSignal} [options.signal] - Optional AbortSignal for cancellation * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number}>} Path result @@ -367,9 +367,9 @@ export default class DagPathFinding { * @param {Object} options - Path finding options * @param {string} options.from - Starting SHA * @param {string} options.to - Target SHA - * @param {Function} [options.weightProvider] - Async callback `(fromSha, toSha) => number` - * @param {Function} [options.forwardHeuristic] - Callback for forward search - * @param {Function} [options.backwardHeuristic] - Callback for backward search + * @param {(from: string, to: string) => number|Promise} [options.weightProvider] - Async callback `(fromSha, toSha) => number` + * @param {(sha: string, target: string) => number} [options.forwardHeuristic] - Callback for forward search + * @param {(sha: string, target: string) => number} [options.backwardHeuristic] - Callback for backward search * @param {AbortSignal} [options.signal] - Optional AbortSignal for cancellation * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number}>} Path result * @throws {TraversalError} With code 'NO_PATH' if no path exists @@ -463,6 +463,17 @@ export default class DagPathFinding { * Expands the forward frontier by one node in bidirectional A*. * * @param {Object} state - Forward expansion state + * @param {import('../utils/MinHeap.js').default} state.fwdHeap + * @param {Set} state.fwdVisited + * @param {Map} state.fwdGScore + * @param {Map} state.fwdPrevious + * @param {Set} state.bwdVisited + * @param {Map} state.bwdGScore + * @param {(from: string, to: string) => number|Promise} state.weightProvider + * @param {(sha: string, target: string) => number} state.forwardHeuristic + * @param {string} state.to + * @param {number} state.mu + * @param {string|null} state.meetingPoint * @returns {Promise<{explored: number, mu: number, meetingPoint: string|null}>} * @private */ @@ -484,7 +495,7 @@ export default class DagPathFinding { explored = 1; if (bwdVisited.has(current)) { - const totalCost = fwdGScore.get(current) + bwdGScore.get(current); + const totalCost = /** @type {number} */ (fwdGScore.get(current)) + /** @type {number} */ (bwdGScore.get(current)); if (totalCost < bestMu) { bestMu = totalCost; bestMeeting = current; @@ -498,8 +509,8 @@ export default class DagPathFinding { } const edgeWeight = await weightProvider(current, child); - const tentativeG = fwdGScore.get(current) + edgeWeight; - const currentG = fwdGScore.has(child) ? fwdGScore.get(child) : Infinity; + const tentativeG = /** @type {number} */ (fwdGScore.get(current)) + edgeWeight; + const currentG = fwdGScore.has(child) ? /** @type {number} */ (fwdGScore.get(child)) : Infinity; if (tentativeG < currentG) { fwdPrevious.set(child, current); @@ -509,7 +520,7 @@ export default class DagPathFinding { fwdHeap.insert(child, f); if (bwdGScore.has(child)) { - const totalCost = tentativeG + bwdGScore.get(child); + const totalCost = tentativeG + /** @type {number} */ (bwdGScore.get(child)); if (totalCost < bestMu) { bestMu = totalCost; bestMeeting = child; @@ -525,6 +536,17 @@ export default class DagPathFinding { * Expands the backward frontier by one node in bidirectional A*. * * @param {Object} state - Backward expansion state + * @param {import('../utils/MinHeap.js').default} state.bwdHeap + * @param {Set} state.bwdVisited + * @param {Map} state.bwdGScore + * @param {Map} state.bwdNext + * @param {Set} state.fwdVisited + * @param {Map} state.fwdGScore + * @param {(from: string, to: string) => number|Promise} state.weightProvider + * @param {(sha: string, target: string) => number} state.backwardHeuristic + * @param {string} state.from + * @param {number} state.mu + * @param {string|null} state.meetingPoint * @returns {Promise<{explored: number, mu: number, meetingPoint: string|null}>} * @private */ @@ -546,7 +568,7 @@ export default class DagPathFinding { explored = 1; if (fwdVisited.has(current)) { - const totalCost = fwdGScore.get(current) + bwdGScore.get(current); + const totalCost = /** @type {number} */ (fwdGScore.get(current)) + /** @type {number} */ (bwdGScore.get(current)); if (totalCost < bestMu) { bestMu = totalCost; bestMeeting = current; @@ -560,8 +582,8 @@ export default class DagPathFinding { } const edgeWeight = await weightProvider(parent, current); - const tentativeG = bwdGScore.get(current) + edgeWeight; - const currentG = bwdGScore.has(parent) ? bwdGScore.get(parent) : Infinity; + const tentativeG = /** @type {number} */ (bwdGScore.get(current)) + edgeWeight; + const currentG = bwdGScore.has(parent) ? /** @type {number} */ (bwdGScore.get(parent)) : Infinity; if (tentativeG < currentG) { bwdNext.set(parent, current); @@ -571,7 +593,7 @@ export default class DagPathFinding { bwdHeap.insert(parent, f); if (fwdGScore.has(parent)) { - const totalCost = fwdGScore.get(parent) + tentativeG; + const totalCost = /** @type {number} */ (fwdGScore.get(parent)) + tentativeG; if (totalCost < bestMu) { bestMu = totalCost; bestMeeting = parent; @@ -691,7 +713,7 @@ export default class DagPathFinding { const forwardPath = [meeting]; let current = meeting; while (fwdParent.has(current) && fwdParent.get(current) !== undefined) { - current = fwdParent.get(current); + current = /** @type {string} */ (fwdParent.get(current)); forwardPath.unshift(current); } if (forwardPath[0] !== from) { @@ -700,7 +722,7 @@ export default class DagPathFinding { current = meeting; while (bwdParent.has(current) && bwdParent.get(current) !== undefined) { - current = bwdParent.get(current); + current = /** @type {string} */ (bwdParent.get(current)); forwardPath.push(current); } if (forwardPath[forwardPath.length - 1] !== to) { diff --git a/src/domain/services/DagTopology.js b/src/domain/services/DagTopology.js index c85a50ed..33deaf4b 100644 --- a/src/domain/services/DagTopology.js +++ b/src/domain/services/DagTopology.js @@ -37,7 +37,7 @@ export default class DagTopology { * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger instance * @param {import('./DagTraversal.js').default} [options.traversal] - Traversal service for ancestor enumeration */ - constructor({ indexReader, logger = nullLogger, traversal } = {}) { + constructor(/** @type {{ indexReader: import('./BitmapIndexReader.js').default, logger?: import('../../ports/LoggerPort.js').default, traversal?: import('./DagTraversal.js').default }} */ { indexReader, logger = nullLogger, traversal } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type if (!indexReader) { throw new Error('DagTopology requires an indexReader'); } @@ -76,9 +76,10 @@ export default class DagTopology { */ async commonAncestors({ shas, maxResults = 100, maxDepth = DEFAULT_MAX_DEPTH, signal }) { if (shas.length === 0) { return []; } + const traversal = /** @type {import('./DagTraversal.js').default} */ (this._traversal); if (shas.length === 1) { const ancestors = []; - for await (const node of this._traversal.ancestors({ sha: shas[0], maxNodes: maxResults, maxDepth, signal })) { + for await (const node of traversal.ancestors({ sha: shas[0], maxNodes: maxResults, maxDepth, signal })) { ancestors.push(node.sha); } return ancestors; @@ -92,7 +93,7 @@ export default class DagTopology { for (const sha of shas) { checkAborted(signal, 'commonAncestors'); const visited = new Set(); - for await (const node of this._traversal.ancestors({ sha, maxDepth, signal })) { + for await (const node of traversal.ancestors({ sha, maxDepth, signal })) { if (!visited.has(node.sha)) { visited.add(node.sha); ancestorCounts.set(node.sha, (ancestorCounts.get(node.sha) || 0) + 1); @@ -149,7 +150,7 @@ export default class DagTopology { checkAborted(signal, 'topologicalSort'); } - const sha = queue.shift(); + const sha = /** @type {string} */ (queue.shift()); const neighbors = await this._getNeighbors(sha, direction); edges.set(sha, neighbors); @@ -182,7 +183,7 @@ export default class DagTopology { checkAborted(signal, 'topologicalSort'); } - const sha = ready.shift(); + const sha = /** @type {string} */ (ready.shift()); const depth = depthMap.get(sha) || 0; nodesYielded++; @@ -190,7 +191,7 @@ export default class DagTopology { const neighbors = edges.get(sha) || []; for (const neighbor of neighbors) { - const newDegree = inDegree.get(neighbor) - 1; + const newDegree = /** @type {number} */ (inDegree.get(neighbor)) - 1; inDegree.set(neighbor, newDegree); if (!depthMap.has(neighbor)) { diff --git a/src/domain/services/DagTraversal.js b/src/domain/services/DagTraversal.js index bf33bed4..84cee0f9 100644 --- a/src/domain/services/DagTraversal.js +++ b/src/domain/services/DagTraversal.js @@ -43,7 +43,7 @@ export default class DagTraversal { * @param {import('./BitmapIndexReader.js').default} options.indexReader - Index reader for O(1) lookups * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger instance */ - constructor({ indexReader, logger = nullLogger } = {}) { + constructor(/** @type {{ indexReader: import('./BitmapIndexReader.js').default, logger?: import('../../ports/LoggerPort.js').default }} */ { indexReader, logger = nullLogger } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type if (!indexReader) { throw new Error('DagTraversal requires an indexReader'); } @@ -89,6 +89,7 @@ export default class DagTraversal { signal, }) { const visited = new Set(); + /** @type {TraversalNode[]} */ const queue = [{ sha: start, depth: 0, parent: null }]; let nodesYielded = 0; @@ -99,7 +100,7 @@ export default class DagTraversal { checkAborted(signal, 'bfs'); } - const current = queue.shift(); + const current = /** @type {TraversalNode} */ (queue.shift()); if (visited.has(current.sha)) { continue; } if (current.depth > maxDepth) { continue; } @@ -142,6 +143,7 @@ export default class DagTraversal { signal, }) { const visited = new Set(); + /** @type {TraversalNode[]} */ const stack = [{ sha: start, depth: 0, parent: null }]; let nodesYielded = 0; @@ -152,7 +154,7 @@ export default class DagTraversal { checkAborted(signal, 'dfs'); } - const current = stack.pop(); + const current = /** @type {TraversalNode} */ (stack.pop()); if (visited.has(current.sha)) { continue; } if (current.depth > maxDepth) { continue; } diff --git a/src/domain/services/Frontier.js b/src/domain/services/Frontier.js index 27669b98..ecd8f985 100644 --- a/src/domain/services/Frontier.js +++ b/src/domain/services/Frontier.js @@ -50,12 +50,13 @@ export function getWriters(frontier) { * Keys are sorted for determinism. * @param {Frontier} frontier * @param {Object} [options] - * @param {import('../../ports/CodecPort.js').default} options.codec - Codec for serialization - * @returns {Buffer} + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization + * @returns {Buffer|Uint8Array} */ -export function serializeFrontier(frontier, { codec } = {}) { +export function serializeFrontier(frontier, { codec } = /** @type {{codec?: import('../../ports/CodecPort.js').default}} */ ({})) { const c = codec || defaultCodec; // Convert Map to sorted object for deterministic encoding + /** @type {Record} */ const obj = {}; const sortedKeys = Array.from(frontier.keys()).sort(); for (const key of sortedKeys) { @@ -68,12 +69,12 @@ export function serializeFrontier(frontier, { codec } = {}) { * Deserializes frontier from CBOR bytes. * @param {Buffer} buffer * @param {Object} [options] - * @param {import('../../ports/CodecPort.js').default} options.codec - Codec for deserialization + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization * @returns {Frontier} */ -export function deserializeFrontier(buffer, { codec } = {}) { +export function deserializeFrontier(buffer, { codec } = /** @type {{codec?: import('../../ports/CodecPort.js').default}} */ ({})) { const c = codec || defaultCodec; - const obj = c.decode(buffer); + const obj = /** @type {Record} */ (c.decode(buffer)); const frontier = new Map(); for (const [writerId, patchSha] of Object.entries(obj)) { frontier.set(writerId, patchSha); diff --git a/src/domain/services/HealthCheckService.js b/src/domain/services/HealthCheckService.js index e90c96d3..924dc42e 100644 --- a/src/domain/services/HealthCheckService.js +++ b/src/domain/services/HealthCheckService.js @@ -46,7 +46,7 @@ export default class HealthCheckService { /** * Creates a HealthCheckService instance. * @param {Object} options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Persistence port for repository checks + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default} options.persistence - Persistence port for repository checks * @param {import('../../ports/ClockPort.js').default} options.clock - Clock port for timing operations * @param {number} [options.cacheTtlMs=5000] - How long to cache health results in milliseconds * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging @@ -132,22 +132,23 @@ export default class HealthCheckService { */ async getHealth() { const { value, cachedAt, fromCache } = await this._healthCache.getWithMetadata(); + const result = /** @type {HealthResult} */ (value); if (cachedAt) { - return { ...value, cachedAt }; + return { ...result, cachedAt }; } // Log only for fresh computations if (!fromCache) { this._logger.debug('Health check completed', { operation: 'getHealth', - status: value.status, - repositoryStatus: value.components.repository.status, - indexStatus: value.components.index.status, + status: result.status, + repositoryStatus: result.components.repository.status, + indexStatus: result.components.index.status, }); } - return value; + return result; } /** @@ -184,16 +185,16 @@ export default class HealthCheckService { try { const pingResult = await this._persistence.ping(); return { - status: pingResult.ok ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY, + status: /** @type {'healthy'|'unhealthy'} */ (pingResult.ok ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY), latencyMs: Math.round(pingResult.latencyMs * 100) / 100, // Round to 2 decimal places }; } catch (err) { this._logger.warn('Repository ping failed', { operation: 'checkRepository', - error: err.message, + error: /** @type {any} */ (err).message, // TODO(ts-cleanup): type error }); return { - status: HealthStatus.UNHEALTHY, + status: /** @type {'healthy'|'unhealthy'} */ (HealthStatus.UNHEALTHY), latencyMs: 0, }; } @@ -207,7 +208,7 @@ export default class HealthCheckService { _checkIndex() { if (!this._indexReader) { return { - status: HealthStatus.DEGRADED, + status: /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.DEGRADED), loaded: false, }; } @@ -216,7 +217,7 @@ export default class HealthCheckService { const shardCount = this._indexReader.shardOids?.size ?? 0; return { - status: HealthStatus.HEALTHY, + status: /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.HEALTHY), loaded: true, shardCount, }; @@ -232,15 +233,15 @@ export default class HealthCheckService { _computeOverallStatus(repositoryHealth, indexHealth) { // If repository is unhealthy, overall is unhealthy if (repositoryHealth.status === HealthStatus.UNHEALTHY) { - return HealthStatus.UNHEALTHY; + return /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.UNHEALTHY); } // If index is degraded (not loaded), overall is degraded if (indexHealth.status === HealthStatus.DEGRADED) { - return HealthStatus.DEGRADED; + return /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.DEGRADED); } // All components healthy - return HealthStatus.HEALTHY; + return /** @type {'healthy'|'degraded'|'unhealthy'} */ (HealthStatus.HEALTHY); } } diff --git a/src/domain/services/HookInstaller.js b/src/domain/services/HookInstaller.js index 4b2b35f5..35cb4043 100644 --- a/src/domain/services/HookInstaller.js +++ b/src/domain/services/HookInstaller.js @@ -7,6 +7,21 @@ * @module domain/services/HookInstaller */ +/** + * @typedef {Object} FsAdapter + * @property {(path: string, content: string | Buffer, options?: Object) => void} writeFileSync + * @property {(path: string, mode: number) => void} chmodSync + * @property {(path: string, encoding?: string) => string} readFileSync + * @property {(path: string) => boolean} existsSync + * @property {(path: string, options?: Object) => void} mkdirSync + */ + +/** + * @typedef {Object} PathUtils + * @property {(...segments: string[]) => string} join + * @property {(...segments: string[]) => string} resolve + */ + const DELIMITER_START_PREFIX = '# --- @git-stunts/git-warp post-merge hook'; const DELIMITER_END = '# --- end @git-stunts/git-warp ---'; const VERSION_MARKER_PREFIX = '# warp-hook-version:'; @@ -59,17 +74,22 @@ export class HookInstaller { * Creates a new HookInstaller. * * @param {Object} deps - Injected dependencies - * @param {Object} deps.fs - Filesystem adapter with methods: readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync + * @param {FsAdapter} deps.fs - Filesystem adapter with methods: readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync * @param {(repoPath: string, key: string) => string|null} deps.execGitConfig - Function to read git config values * @param {string} deps.version - Package version * @param {string} deps.templateDir - Directory containing hook templates - * @param {{ join: (...segments: string[]) => string, resolve: (...segments: string[]) => string }} deps.path - Path utilities (join and resolve) + * @param {PathUtils} deps.path - Path utilities (join and resolve) */ - constructor({ fs, execGitConfig, version, templateDir, path } = {}) { + constructor({ fs, execGitConfig, version, templateDir, path } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type + /** @type {FsAdapter} */ this._fs = fs; + /** @type {(repoPath: string, key: string) => string|null} */ this._execGitConfig = execGitConfig; + /** @type {string} */ this._templateDir = templateDir; + /** @type {string} */ this._version = version; + /** @type {PathUtils} */ this._path = path; } @@ -134,7 +154,11 @@ export class HookInstaller { throw new Error(`Unknown install strategy: ${strategy}`); } - /** @private */ + /** + * @param {string} hookPath + * @param {string} content + * @private + */ _freshInstall(hookPath, content) { this._fs.writeFileSync(hookPath, content, { mode: 0o755 }); this._fs.chmodSync(hookPath, 0o755); @@ -145,13 +169,17 @@ export class HookInstaller { }; } - /** @private */ + /** + * @param {string} hookPath + * @param {string} stamped + * @private + */ _upgradeInstall(hookPath, stamped) { const existing = this._readFile(hookPath); const classification = classifyExistingHook(existing); if (classification.appended) { - const updated = replaceDelimitedSection(existing, stamped); + const updated = replaceDelimitedSection(/** @type {string} */ (existing), stamped); // If delimiters were corrupted, replaceDelimitedSection returns unchanged content — fall back to overwrite if (updated === existing) { this._fs.writeFileSync(hookPath, stamped, { mode: 0o755 }); @@ -170,7 +198,11 @@ export class HookInstaller { }; } - /** @private */ + /** + * @param {string} hookPath + * @param {string} stamped + * @private + */ _appendInstall(hookPath, stamped) { const existing = this._readFile(hookPath) || ''; const body = stripShebang(stamped); @@ -184,7 +216,11 @@ export class HookInstaller { }; } - /** @private */ + /** + * @param {string} hookPath + * @param {string} stamped + * @private + */ _replaceInstall(hookPath, stamped) { const existing = this._readFile(hookPath); let backupPath; @@ -210,12 +246,18 @@ export class HookInstaller { return this._fs.readFileSync(templatePath, 'utf8'); } - /** @private */ + /** + * @param {string} template + * @private + */ _stampVersion(template) { return template.replaceAll(VERSION_PLACEHOLDER, this._version); } - /** @private */ + /** + * @param {string} repoPath + * @private + */ _resolveHooksDir(repoPath) { const customPath = this._execGitConfig(repoPath, 'core.hooksPath'); if (customPath) { @@ -230,12 +272,18 @@ export class HookInstaller { return this._path.join(repoPath, '.git', 'hooks'); } - /** @private */ + /** + * @param {string} repoPath + * @private + */ _resolveHookPath(repoPath) { return this._path.join(this._resolveHooksDir(repoPath), 'post-merge'); } - /** @private */ + /** + * @param {string} filePath + * @private + */ _readFile(filePath) { try { return this._fs.readFileSync(filePath, 'utf8'); @@ -244,7 +292,10 @@ export class HookInstaller { } } - /** @private */ + /** + * @param {string} dirPath + * @private + */ _ensureDir(dirPath) { if (!this._fs.existsSync(dirPath)) { this._fs.mkdirSync(dirPath, { recursive: true }); diff --git a/src/domain/services/HttpSyncServer.js b/src/domain/services/HttpSyncServer.js index ad5bbf82..6f8c2eea 100644 --- a/src/domain/services/HttpSyncServer.js +++ b/src/domain/services/HttpSyncServer.js @@ -22,9 +22,10 @@ function canonicalizeJson(value) { return value.map(canonicalizeJson); } if (value && typeof value === 'object') { + /** @type {{ [x: string]: * }} */ const sorted = {}; for (const key of Object.keys(value).sort()) { - sorted[key] = canonicalizeJson(value[key]); + sorted[key] = canonicalizeJson(/** @type {{ [x: string]: * }} */ (value)[key]); } return sorted; } @@ -97,7 +98,7 @@ function isValidSyncRequest(parsed) { * Checks the content-type header. Returns an error response if the * content type is present but not application/json, otherwise null. * - * @param {Object} headers - Request headers + * @param {{ [x: string]: string }} headers - Request headers * @returns {{ status: number, headers: Object, body: string }|null} * @private */ @@ -113,7 +114,7 @@ function checkContentType(headers) { * Parses the request URL and validates the path and method. * Returns an error response on failure, or null if valid. * - * @param {{ method: string, url: string, headers: Object }} request + * @param {{ method: string, url: string, headers: { [x: string]: string } }} request * @param {string} expectedPath * @param {string} defaultHost * @returns {{ status: number, headers: Object, body: string }|null} @@ -171,12 +172,12 @@ export default class HttpSyncServer { /** * @param {Object} options * @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server port abstraction - * @param {Object} options.graph - WarpGraph instance (must expose processSyncRequest) + * @param {{ processSyncRequest: (request: *) => Promise<*> }} options.graph - WarpGraph instance (must expose processSyncRequest) * @param {string} [options.path='/sync'] - URL path to handle sync requests on * @param {string} [options.host='127.0.0.1'] - Host to bind * @param {number} [options.maxRequestBytes=4194304] - Maximum request body size in bytes */ - constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES } = {}) { + constructor({ httpPort, graph, path = '/sync', host = '127.0.0.1', maxRequestBytes = DEFAULT_MAX_REQUEST_BYTES } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type this._httpPort = httpPort; this._graph = graph; this._path = path && path.startsWith('/') ? path : `/${path || 'sync'}`; @@ -188,7 +189,7 @@ export default class HttpSyncServer { /** * Handles an incoming HTTP request through the port abstraction. * - * @param {{ method: string, url: string, headers: Object, body: Buffer|undefined }} request + * @param {{ method: string, url: string, headers: { [x: string]: string }, body: Buffer|undefined }} request * @returns {Promise<{ status: number, headers: Object, body: string }>} * @private */ @@ -212,7 +213,7 @@ export default class HttpSyncServer { const response = await this._graph.processSyncRequest(parsed); return jsonResponse(response); } catch (err) { - return errorResponse(500, err?.message || 'Sync failed'); + return errorResponse(500, /** @type {any} */ (err)?.message || 'Sync failed'); // TODO(ts-cleanup): type error } } @@ -228,18 +229,18 @@ export default class HttpSyncServer { throw new Error('listen() requires a numeric port'); } - const server = this._httpPort.createServer((request) => this._handleRequest(request)); + const server = this._httpPort.createServer((/** @type {*} */ request) => this._handleRequest(request)); // TODO(ts-cleanup): type http callback this._server = server; - await new Promise((resolve, reject) => { - server.listen(port, this._host, (err) => { + await /** @type {Promise} */ (new Promise((resolve, reject) => { + server.listen(port, this._host, (/** @type {*} */ err) => { // TODO(ts-cleanup): type http callback if (err) { reject(err); } else { resolve(); } }); - }); + })); const address = server.address(); const actualPort = typeof address === 'object' && address ? address.port : port; @@ -248,15 +249,15 @@ export default class HttpSyncServer { return { url, close: () => - new Promise((resolve, reject) => { - server.close((err) => { + /** @type {Promise} */ (new Promise((resolve, reject) => { + server.close((/** @type {*} */ err) => { // TODO(ts-cleanup): type http callback if (err) { reject(err); } else { resolve(); } }); - }), + })), }; } } diff --git a/src/domain/services/IndexRebuildService.js b/src/domain/services/IndexRebuildService.js index bf17c3ab..5ca2bff1 100644 --- a/src/domain/services/IndexRebuildService.js +++ b/src/domain/services/IndexRebuildService.js @@ -40,17 +40,17 @@ export default class IndexRebuildService { * Creates an IndexRebuildService instance. * * @param {Object} options - Configuration options - * @param {Object} options.graphService - Graph service providing node iteration. - * Must implement `iterateNodes({ ref, limit }) => AsyncGenerator`. + * @param {{ iterateNodes: (opts: { ref: string, limit: number }) => AsyncIterable<{ sha: string, parents: string[] }> }} options.graphService - Graph service providing node iteration. * @param {import('../../ports/IndexStoragePort.js').default} options.storage - Storage adapter * for persisting index blobs and trees. Typically GitGraphAdapter. * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for * structured logging. Defaults to null logger (no logging). + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - Crypto adapter for checksums * @throws {Error} If graphService is not provided * @throws {Error} If storage adapter is not provided */ - constructor({ graphService, storage, logger = nullLogger, codec, crypto }) { + constructor({ graphService, storage, logger = nullLogger, codec, crypto } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type if (!graphService) { throw new Error('IndexRebuildService requires a graphService'); } @@ -156,7 +156,7 @@ export default class IndexRebuildService { operation: 'rebuild', ref, mode, - error: err.message, + error: /** @type {any} */ (err).message, // TODO(ts-cleanup): type error durationMs, }); throw err; @@ -247,12 +247,12 @@ export default class IndexRebuildService { * @private */ async _rebuildStreaming(ref, { limit, maxMemoryBytes, onFlush, onProgress, signal, frontier }) { - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {*} */ ({ // TODO(ts-cleanup): narrow port type storage: this.storage, maxMemoryBytes, onFlush, crypto: this._crypto, - }); + })); let processedNodes = 0; @@ -266,7 +266,7 @@ export default class IndexRebuildService { if (processedNodes % 10000 === 0) { checkAborted(signal, 'rebuild'); if (onProgress) { - const stats = builder.getMemoryStats(); + const stats = /** @type {any} */ (builder).getMemoryStats(); // TODO(ts-cleanup): narrow port type onProgress({ processedNodes, currentMemoryBytes: stats.estimatedBitmapBytes, @@ -275,7 +275,7 @@ export default class IndexRebuildService { } } - return await builder.finalize({ signal, frontier }); + return await /** @type {any} */ (builder).finalize({ signal, frontier }); // TODO(ts-cleanup): narrow port type } /** @@ -302,10 +302,10 @@ export default class IndexRebuildService { const treeStructure = await builder.serialize({ frontier }); const flatEntries = []; for (const [path, buffer] of Object.entries(treeStructure)) { - const oid = await this.storage.writeBlob(buffer); + const oid = await /** @type {import('../../ports/BlobPort.js').default} */ (/** @type {unknown} */ (this.storage)).writeBlob(buffer); flatEntries.push(`100644 blob ${oid}\t${path}`); } - return await this.storage.writeTree(flatEntries); + return await /** @type {import('../../ports/TreePort.js').default} */ (/** @type {unknown} */ (this.storage)).writeTree(flatEntries); } /** @@ -384,12 +384,12 @@ export default class IndexRebuildService { } const startTime = performance.now(); - const shardOids = await this.storage.readTreeOids(treeOid); + const shardOids = await /** @type {import('../../ports/TreePort.js').default} */ (/** @type {unknown} */ (this.storage)).readTreeOids(treeOid); const shardCount = Object.keys(shardOids).length; // Staleness check if (currentFrontier) { - const indexFrontier = await loadIndexFrontier(shardOids, this.storage, { codec: this._codec }); + const indexFrontier = await loadIndexFrontier(shardOids, /** @type {*} */ (this.storage), { codec: this._codec }); // TODO(ts-cleanup): narrow port type if (indexFrontier) { const result = checkStaleness(indexFrontier, currentFrontier); if (result.stale) { diff --git a/src/domain/services/IndexStalenessChecker.js b/src/domain/services/IndexStalenessChecker.js index 1f43080e..c7d4c72e 100644 --- a/src/domain/services/IndexStalenessChecker.js +++ b/src/domain/services/IndexStalenessChecker.js @@ -5,7 +5,11 @@ import defaultCodec from '../utils/defaultCodec.js'; -/** @private */ +/** + * @param {*} envelope + * @param {string} label + * @private + */ function validateEnvelope(envelope, label) { if (!envelope || typeof envelope !== 'object' || !envelope.frontier || typeof envelope.frontier !== 'object') { throw new Error(`invalid frontier envelope for ${label}`); @@ -16,17 +20,17 @@ function validateEnvelope(envelope, label) { * Loads the frontier from an index tree's shard OIDs. * * @param {Record} shardOids - Map of path → blob OID from readTreeOids - * @param {import('../../ports/IndexStoragePort.js').default} storage - Storage adapter + * @param {import('../../ports/IndexStoragePort.js').default & import('../../ports/BlobPort.js').default} storage - Storage adapter * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization * @returns {Promise|null>} Frontier map, or null if not present (legacy index) */ -export async function loadIndexFrontier(shardOids, storage, { codec } = {}) { +export async function loadIndexFrontier(shardOids, storage, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const c = codec || defaultCodec; const cborOid = shardOids['frontier.cbor']; if (cborOid) { const buffer = await storage.readBlob(cborOid); - const envelope = c.decode(buffer); + const envelope = /** @type {{ frontier: Record }} */ (c.decode(buffer)); validateEnvelope(envelope, 'frontier.cbor'); return new Map(Object.entries(envelope.frontier)); } @@ -34,7 +38,7 @@ export async function loadIndexFrontier(shardOids, storage, { codec } = {}) { const jsonOid = shardOids['frontier.json']; if (jsonOid) { const buffer = await storage.readBlob(jsonOid); - const envelope = JSON.parse(buffer.toString('utf-8')); + const envelope = /** @type {{ frontier: Record }} */ (JSON.parse(buffer.toString('utf-8'))); validateEnvelope(envelope, 'frontier.json'); return new Map(Object.entries(envelope.frontier)); } @@ -51,7 +55,10 @@ export async function loadIndexFrontier(shardOids, storage, { codec } = {}) { * @property {string[]} removedWriters - Writers in index but not current */ -/** @private */ +/** + * @param {{ stale: boolean, advancedWriters: string[], newWriters: string[], removedWriters: string[] }} opts + * @private + */ function buildReason({ stale, advancedWriters, newWriters, removedWriters }) { if (!stale) { return 'index is current'; diff --git a/src/domain/services/JoinReducer.js b/src/domain/services/JoinReducer.js index 6856021c..b8a2be44 100644 --- a/src/domain/services/JoinReducer.js +++ b/src/domain/services/JoinReducer.js @@ -29,7 +29,7 @@ export { * @typedef {Object} WarpStateV5 * @property {import('../crdt/ORSet.js').ORSet} nodeAlive - ORSet of alive nodes * @property {import('../crdt/ORSet.js').ORSet} edgeAlive - ORSet of alive edges - * @property {Map} prop - Properties with LWW + * @property {Map>} prop - Properties with LWW * @property {import('../crdt/VersionVector.js').VersionVector} observedFrontier - Observed version vector * @property {Map} edgeBirthEvent - EdgeKey → EventId of most recent EdgeAdd (for clean-slate prop visibility) */ @@ -88,14 +88,14 @@ export function createEmptyStateV5() { export function applyOpV2(state, op, eventId) { switch (op.type) { case 'NodeAdd': - orsetAdd(state.nodeAlive, op.node, op.dot); + orsetAdd(state.nodeAlive, /** @type {string} */ (op.node), /** @type {import('../crdt/Dot.js').Dot} */ (op.dot)); break; case 'NodeRemove': - orsetRemove(state.nodeAlive, op.observedDots); + orsetRemove(state.nodeAlive, /** @type {Set} */ (/** @type {unknown} */ (op.observedDots))); break; case 'EdgeAdd': { - const edgeKey = encodeEdgeKey(op.from, op.to, op.label); - orsetAdd(state.edgeAlive, edgeKey, op.dot); + const edgeKey = encodeEdgeKey(/** @type {string} */ (op.from), /** @type {string} */ (op.to), /** @type {string} */ (op.label)); + orsetAdd(state.edgeAlive, edgeKey, /** @type {import('../crdt/Dot.js').Dot} */ (op.dot)); // Track the EventId at which this edge incarnation was born. // On re-add after remove, the greater EventId replaces the old one, // allowing the query layer to filter out stale properties. @@ -108,13 +108,13 @@ export function applyOpV2(state, op, eventId) { break; } case 'EdgeRemove': - orsetRemove(state.edgeAlive, op.observedDots); + orsetRemove(state.edgeAlive, /** @type {Set} */ (/** @type {unknown} */ (op.observedDots))); break; case 'PropSet': { // Uses EventId-based LWW, same as v4 - const key = encodePropKey(op.node, op.key); + const key = encodePropKey(/** @type {string} */ (op.node), /** @type {string} */ (op.key)); const current = state.prop.get(key); - state.prop.set(key, lwwMax(current, lwwSet(eventId, op.value))); + state.prop.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<*>} */ (lwwMax(current, lwwSet(eventId, op.value)))); break; } default: @@ -290,7 +290,7 @@ function edgeRemoveOutcome(orset, op) { * - `superseded`: An existing value with higher EventId wins * - `redundant`: Exact same write (identical EventId) * - * @param {Map} propMap - The properties map keyed by encoded prop keys + * @param {Map>} propMap - The properties map keyed by encoded prop keys * @param {Object} op - The PropSet operation * @param {string} op.node - Node ID owning the property * @param {string} op.key - Property key/name @@ -347,8 +347,8 @@ function propSetOutcome(propMap, op, eventId) { * @param {Object} patch - The patch to apply * @param {string} patch.writer - Writer ID who created this patch * @param {number} patch.lamport - Lamport timestamp of this patch - * @param {Object[]} patch.ops - Array of operations to apply - * @param {Map|Object} patch.context - Version vector context (Map or serialized form) + * @param {Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: *, oid?: string}>} patch.ops - Array of operations to apply + * @param {Map|{[x: string]: number}} patch.context - Version vector context (Map or serialized form) * @param {string} patchSha - The Git SHA of the patch commit (used for EventId creation) * @param {boolean} [collectReceipts=false] - When true, computes and returns receipt data * @returns {WarpStateV5|{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}} @@ -370,30 +370,32 @@ export function join(state, patch, patchSha, collectReceipts) { } // Receipt-enabled path + /** @type {import('../types/TickReceipt.js').OpOutcome[]} */ const opResults = []; for (let i = 0; i < patch.ops.length; i++) { const op = patch.ops[i]; const eventId = createEventId(patch.lamport, patch.writer, patchSha, i); // Determine outcome BEFORE applying the op (state is pre-op) + /** @type {{target: string, result: string, reason?: string}} */ let outcome; switch (op.type) { case 'NodeAdd': - outcome = nodeAddOutcome(state.nodeAlive, op); + outcome = nodeAddOutcome(state.nodeAlive, /** @type {{node: string, dot: import('../crdt/Dot.js').Dot}} */ (op)); break; case 'NodeRemove': - outcome = nodeRemoveOutcome(state.nodeAlive, op); + outcome = nodeRemoveOutcome(state.nodeAlive, /** @type {{node?: string, observedDots: string[]}} */ (op)); break; case 'EdgeAdd': { - const edgeKey = encodeEdgeKey(op.from, op.to, op.label); - outcome = edgeAddOutcome(state.edgeAlive, op, edgeKey); + const edgeKey = encodeEdgeKey(/** @type {string} */ (op.from), /** @type {string} */ (op.to), /** @type {string} */ (op.label)); + outcome = edgeAddOutcome(state.edgeAlive, /** @type {{from: string, to: string, label: string, dot: import('../crdt/Dot.js').Dot}} */ (op), edgeKey); break; } case 'EdgeRemove': - outcome = edgeRemoveOutcome(state.edgeAlive, op); + outcome = edgeRemoveOutcome(state.edgeAlive, /** @type {{from?: string, to?: string, label?: string, observedDots: string[]}} */ (op)); break; case 'PropSet': - outcome = propSetOutcome(state.prop, op, eventId); + outcome = propSetOutcome(state.prop, /** @type {{node: string, key: string, value: *}} */ (op), eventId); break; default: // Unknown or BlobValue — always applied @@ -404,12 +406,13 @@ export function join(state, patch, patchSha, collectReceipts) { // Apply the op (mutates state) applyOpV2(state, op, eventId); - const receiptOp = RECEIPT_OP_TYPE[op.type] || op.type; + const receiptOp = /** @type {Record} */ (RECEIPT_OP_TYPE)[op.type] || op.type; // Skip unknown/forward-compatible op types that aren't valid receipt ops if (!VALID_RECEIPT_OPS.has(receiptOp)) { continue; } - const entry = { op: receiptOp, target: outcome.target, result: outcome.result }; + /** @type {import('../types/TickReceipt.js').OpOutcome} */ + const entry = { op: receiptOp, target: outcome.target, result: /** @type {'applied'|'superseded'|'redundant'} */ (outcome.result) }; if (outcome.reason) { entry.reason = outcome.reason; } @@ -467,16 +470,16 @@ export function joinStates(a, b) { * * This is a pure function that does not mutate its inputs. * - * @param {Map} a - First property map - * @param {Map} b - Second property map - * @returns {Map} New map containing merged properties + * @param {Map>} a - First property map + * @param {Map>} b - Second property map + * @returns {Map>} New map containing merged properties */ function mergeProps(a, b) { const result = new Map(a); for (const [key, regB] of b) { const regA = result.get(key); - result.set(key, lwwMax(regA, regB)); + result.set(key, /** @type {import('../crdt/LWW.js').LWWRegister<*>} */ (lwwMax(regA, regB))); } return result; @@ -527,9 +530,7 @@ function mergeEdgeBirthEvent(a, b) { * - When `options.receipts` is true, returns a TickReceipt per patch for * provenance tracking and debugging. * - * @param {Array<{patch: Object, sha: string}>} patches - Array of patch objects with their Git SHAs - * @param {Object} patches[].patch - The decoded patch object (writer, lamport, ops, context) - * @param {string} patches[].sha - The Git SHA of the patch commit + * @param {Array<{patch: {writer: string, lamport: number, ops: Array<{type: string, node?: string, dot?: import('../crdt/Dot.js').Dot, observedDots?: string[], from?: string, to?: string, label?: string, key?: string, value?: *, oid?: string}>, context: Map|{[x: string]: number}}, sha: string}>} patches - Array of patch objects with their Git SHAs * @param {WarpStateV5} [initialState] - Optional starting state (for incremental materialization from checkpoint) * @param {Object} [options] - Optional configuration * @param {boolean} [options.receipts=false] - When true, collect and return TickReceipts @@ -544,7 +545,7 @@ export function reduceV5(patches, initialState, options) { if (options && options.receipts) { const receipts = []; for (const { patch, sha } of patches) { - const result = join(state, patch, sha, true); + const result = /** @type {{state: WarpStateV5, receipt: import('../types/TickReceipt.js').TickReceipt}} */ (join(state, patch, sha, true)); receipts.push(result.receipt); } return { state, receipts }; diff --git a/src/domain/services/LogicalTraversal.js b/src/domain/services/LogicalTraversal.js index 16959de7..614c47d2 100644 --- a/src/domain/services/LogicalTraversal.js +++ b/src/domain/services/LogicalTraversal.js @@ -145,14 +145,14 @@ export default class LogicalTraversal { * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow * @param {string|string[]} [options.labelFilter] - Edge label(s) to include * @param {number} [options.maxDepth] - Maximum depth to traverse - * @returns {Promise<{dir: 'out'|'in'|'both', labelSet: Set|null, adjacency: Object, depthLimit: number}>} + * @returns {Promise<{dir: 'out'|'in'|'both', labelSet: Set|null, adjacency: {outgoing: Map>, incoming: Map>}, depthLimit: number}>} * The normalized traversal parameters * @throws {TraversalError} If the start node is not found (NODE_NOT_FOUND) * @throws {TraversalError} If the direction is invalid (INVALID_DIRECTION) * @throws {TraversalError} If the labelFilter is invalid (INVALID_LABEL_FILTER) */ async _prepare(start, { dir, labelFilter, maxDepth }) { - const materialized = await this._graph._materializeGraph(); + const materialized = await /** @type {any} */ (this._graph)._materializeGraph(); // TODO(ts-cleanup): narrow port type if (!(await this._graph.hasNode(start))) { throw new TraversalError(`Start node not found: ${start}`, { @@ -187,7 +187,7 @@ export default class LogicalTraversal { const result = []; while (queue.length > 0) { - const current = queue.shift(); + const current = /** @type {{nodeId: string, depth: number}} */ (queue.shift()); if (visited.has(current.nodeId)) { continue; } @@ -237,7 +237,7 @@ export default class LogicalTraversal { const result = []; while (stack.length > 0) { - const current = stack.pop(); + const current = /** @type {{nodeId: string, depth: number}} */ (stack.pop()); if (visited.has(current.nodeId)) { continue; } @@ -298,7 +298,7 @@ export default class LogicalTraversal { visited.add(from); while (queue.length > 0) { - const current = queue.shift(); + const current = /** @type {{nodeId: string, depth: number}} */ (queue.shift()); if (current.depth >= depthLimit) { continue; } @@ -319,10 +319,11 @@ export default class LogicalTraversal { if (edge.neighborId === to) { const path = [to]; + /** @type {string|undefined} */ let cursor = current.nodeId; while (cursor) { path.push(cursor); - cursor = parent.get(cursor) || null; + cursor = parent.get(cursor); } path.reverse(); return { found: true, path, length: path.length - 1 }; diff --git a/src/domain/services/MessageCodecInternal.js b/src/domain/services/MessageCodecInternal.js index 1574c08a..baa0854a 100644 --- a/src/domain/services/MessageCodecInternal.js +++ b/src/domain/services/MessageCodecInternal.js @@ -12,6 +12,7 @@ * @private */ +// @ts-expect-error -- no declaration file for @git-stunts/trailer-codec import { TrailerCodec, TrailerCodecService } from '@git-stunts/trailer-codec'; // ----------------------------------------------------------------------------- @@ -62,6 +63,7 @@ const SHA256_PATTERN = /^[0-9a-f]{64}$/; // ----------------------------------------------------------------------------- // Lazy singleton codec instance +/** @type {*} */ // TODO(ts-cleanup): type lazy singleton let _codec = null; /** diff --git a/src/domain/services/ObserverView.js b/src/domain/services/ObserverView.js index bfe5645e..dfa296c3 100644 --- a/src/domain/services/ObserverView.js +++ b/src/domain/services/ObserverView.js @@ -102,7 +102,7 @@ export default class ObserverView { this._graph = graph; /** @type {LogicalTraversal} */ - this.traverse = new LogicalTraversal(this); + this.traverse = new LogicalTraversal(/** @type {*} */ (this)); // TODO(ts-cleanup): type observer cast } /** @@ -124,11 +124,11 @@ export default class ObserverView { * Builds a filtered adjacency structure that only includes edges * where both endpoints pass the match filter. * - * @returns {Promise<{state: *, stateHash: string, adjacency: {outgoing: Map, incoming: Map}}>} + * @returns {Promise<{state: *, stateHash: string, adjacency: {outgoing: Map, incoming: Map}}>} * @private */ async _materializeGraph() { - const materialized = await this._graph._materializeGraph(); + const materialized = await /** @type {*} */ (this._graph)._materializeGraph(); // TODO(ts-cleanup): narrow port type const { state, stateHash } = materialized; // Build filtered adjacency: only edges where both endpoints match @@ -159,8 +159,8 @@ export default class ObserverView { incoming.get(to).push({ neighborId: from, label }); } - const sortNeighbors = (list) => { - list.sort((a, b) => { + const sortNeighbors = (/** @type {{ neighborId: string, label: string }[]} */ list) => { + list.sort((/** @type {{ neighborId: string, label: string }} */ a, /** @type {{ neighborId: string, label: string }} */ b) => { if (a.neighborId !== b.neighborId) { return a.neighborId < b.neighborId ? -1 : 1; } @@ -260,6 +260,6 @@ export default class ObserverView { * @returns {QueryBuilder} A query builder scoped to this observer */ query() { - return new QueryBuilder(this); + return new QueryBuilder(/** @type {*} */ (this)); // TODO(ts-cleanup): type observer cast } } diff --git a/src/domain/services/PatchBuilderV2.js b/src/domain/services/PatchBuilderV2.js index f4b074e8..c29974a1 100644 --- a/src/domain/services/PatchBuilderV2.js +++ b/src/domain/services/PatchBuilderV2.js @@ -85,8 +85,8 @@ export class PatchBuilderV2 { * @param {{ warn: Function }} [options.logger] - Logger for non-fatal warnings */ constructor({ persistence, graphName, writerId, lamport, versionVector, getCurrentState, expectedParentSha = null, onCommitSuccess = null, onDeleteWithData = 'warn', codec, logger }) { - /** @type {import('../../ports/GraphPersistencePort.js').default} */ - this._persistence = persistence; + /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default & import('../../ports/TreePort.js').default} */ + this._persistence = /** @type {*} */ (persistence); // TODO(ts-cleanup): narrow port type /** @type {string} */ this._graphName = graphName; @@ -214,7 +214,7 @@ export class PatchBuilderV2 { const { edges } = findAttachedData(state, nodeId); for (const edgeKey of edges) { const [from, to, label] = edgeKey.split('\0'); - const edgeDots = [...orsetGetDots(state.edgeAlive, edgeKey)]; + const edgeDots = /** @type {import('../crdt/Dot.js').Dot[]} */ (/** @type {unknown} */ ([...orsetGetDots(state.edgeAlive, edgeKey)])); this._ops.push(createEdgeRemoveV2(from, to, label, edgeDots)); // Provenance: cascade-generated EdgeRemove reads the edge key (to observe its dots) this._reads.add(edgeKey); @@ -251,7 +251,7 @@ export class PatchBuilderV2 { } } - const observedDots = state ? [...orsetGetDots(state.nodeAlive, nodeId)] : []; + const observedDots = /** @type {import('../crdt/Dot.js').Dot[]} */ (/** @type {unknown} */ (state ? [...orsetGetDots(state.nodeAlive, nodeId)] : [])); this._ops.push(createNodeRemoveV2(nodeId, observedDots)); // Provenance: NodeRemove reads the node (to observe its dots) this._reads.add(nodeId); @@ -325,7 +325,7 @@ export class PatchBuilderV2 { // Get observed dots from current state (orsetGetDots returns already-encoded dot strings) const state = this._getCurrentState(); const edgeKey = encodeEdgeKey(from, to, label); - const observedDots = state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : []; + const observedDots = /** @type {import('../crdt/Dot.js').Dot[]} */ (/** @type {unknown} */ (state ? [...orsetGetDots(state.edgeAlive, edgeKey)] : [])); this._ops.push(createEdgeRemoveV2(from, to, label, observedDots)); // Provenance: EdgeRemove reads the edge key (to observe its dots) this._reads.add(edgeKey); @@ -454,7 +454,7 @@ export class PatchBuilderV2 { schema, writer: this._writerId, lamport: this._lamport, - context: this._vv, + context: /** @type {*} */ (this._vv), // TODO(ts-cleanup): narrow port type ops: this._ops, reads: [...this._reads].sort(), writes: [...this._writes].sort(), @@ -515,10 +515,10 @@ export class PatchBuilderV2 { const currentRefSha = await this._persistence.readRef(writerRef); if (currentRefSha !== this._expectedParentSha) { - const err = new WriterError( + const err = /** @type {WriterError & { expectedSha: string|null, actualSha: string|null }} */ (new WriterError( 'WRITER_CAS_CONFLICT', 'Commit failed: writer ref was updated by another process. Re-materialize and retry.' - ); + )); err.expectedSha = this._expectedParentSha; err.actualSha = currentRefSha; throw err; @@ -556,7 +556,7 @@ export class PatchBuilderV2 { const patchCbor = this._codec.encode(patch); // 5. Write patch.cbor blob - const patchBlobOid = await this._persistence.writeBlob(patchCbor); + const patchBlobOid = await this._persistence.writeBlob(/** @type {Buffer} */ (patchCbor)); // 6. Create tree with the blob // Format for mktree: "mode type oid\tpath" diff --git a/src/domain/services/PatchMessageCodec.js b/src/domain/services/PatchMessageCodec.js index f685da5d..035cf893 100644 --- a/src/domain/services/PatchMessageCodec.js +++ b/src/domain/services/PatchMessageCodec.js @@ -72,13 +72,7 @@ export function encodePatchMessage({ graph, writer, lamport, patchOid, schema = * Decodes a patch commit message. * * @param {string} message - The raw commit message - * @returns {Object} The decoded patch message - * @returns {string} return.kind - Always 'patch' - * @returns {string} return.graph - The graph name - * @returns {string} return.writer - The writer ID - * @returns {number} return.lamport - The Lamport timestamp - * @returns {string} return.patchOid - The patch blob OID - * @returns {number} return.schema - The schema version + * @returns {{ kind: 'patch', graph: string, writer: string, lamport: number, patchOid: string, schema: number }} The decoded patch message * @throws {Error} If the message is not a valid patch message * * @example diff --git a/src/domain/services/ProvenanceIndex.js b/src/domain/services/ProvenanceIndex.js index d42e4db2..300ec78f 100644 --- a/src/domain/services/ProvenanceIndex.js +++ b/src/domain/services/ProvenanceIndex.js @@ -52,7 +52,6 @@ class ProvenanceIndex { /** * Internal index mapping nodeId/edgeKey to Set of patch SHAs. * @type {Map>} - * @private */ #index; @@ -120,7 +119,6 @@ class ProvenanceIndex { * * @param {string} entityId - The node ID or edge key * @param {string} patchSha - The patch SHA - * @private */ #addEntry(entityId, patchSha) { let shas = this.#index.get(entityId); @@ -227,12 +225,12 @@ class ProvenanceIndex { * Returns sorted entries for deterministic output. * * @returns {Array<[string, string[]]>} Sorted array of [entityId, sortedShas[]] pairs - * @private */ #sortedEntries() { + /** @type {Array<[string, string[]]>} */ const entries = []; for (const [entityId, shas] of this.#index) { - entries.push([entityId, [...shas].sort()]); + entries.push(/** @type {[string, string[]]} */ ([entityId, [...shas].sort()])); } entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); return entries; @@ -246,7 +244,7 @@ class ProvenanceIndex { * * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization - * @returns {Buffer} CBOR-encoded index + * @returns {Buffer|Uint8Array} CBOR-encoded index */ serialize({ codec } = {}) { const c = codec || defaultCodec; @@ -258,7 +256,6 @@ class ProvenanceIndex { * * @param {Array<[string, string[]]>} entries - Array of [entityId, shas[]] pairs * @returns {Map>} The built index - * @private */ static #buildIndex(entries) { const index = new Map(); @@ -279,7 +276,8 @@ class ProvenanceIndex { */ static deserialize(buffer, { codec } = {}) { const c = codec || defaultCodec; - const obj = c.decode(buffer); + /** @type {{ version?: number, entries?: Array<[string, string[]]> }} */ + const obj = /** @type {any} */ (c.decode(buffer)); // TODO(ts-cleanup): narrow port type if (obj.version !== 1) { throw new Error(`Unsupported ProvenanceIndex version: ${obj.version}`); @@ -304,7 +302,7 @@ class ProvenanceIndex { /** * Creates a ProvenanceIndex from a JSON representation. * - * @param {Object} json - Object with version and entries array + * @param {{ version?: number, entries?: Array<[string, string[]]> }} json - Object with version and entries array * @returns {ProvenanceIndex} The deserialized index * @throws {Error} If the JSON contains an unsupported version */ diff --git a/src/domain/services/ProvenancePayload.js b/src/domain/services/ProvenancePayload.js index 7c52ecfc..b778f71d 100644 --- a/src/domain/services/ProvenancePayload.js +++ b/src/domain/services/ProvenancePayload.js @@ -68,7 +68,6 @@ class ProvenancePayload { /** * The internal array of patch entries. Frozen after construction. * @type {ReadonlyArray} - * @private */ #patches; @@ -173,7 +172,7 @@ class ProvenancePayload { // Use JoinReducer's reduceV5 for deterministic materialization. // Note: reduceV5 returns { state, receipts } when options.receipts is truthy, // but returns bare WarpStateV5 when no options passed (as here). - return reduceV5(this.#patches, initialState); + return /** @type {import('./JoinReducer.js').WarpStateV5} */ (reduceV5(/** @type {*} */ (this.#patches), initialState)); // TODO(ts-cleanup): type patch array } /** diff --git a/src/domain/services/QueryBuilder.js b/src/domain/services/QueryBuilder.js index 9a3052ed..db4e66d5 100644 --- a/src/domain/services/QueryBuilder.js +++ b/src/domain/services/QueryBuilder.js @@ -12,8 +12,8 @@ const DEFAULT_PATTERN = '*'; * @typedef {Object} QueryNodeSnapshot * @property {string} id - The unique identifier of the node * @property {Record} props - Frozen snapshot of node properties - * @property {Array<{label: string, to: string}>} edgesOut - Outgoing edges sorted by label then target - * @property {Array<{label: string, from: string}>} edgesIn - Incoming edges sorted by label then source + * @property {ReadonlyArray<{label: string, to?: string, from?: string}>} edgesOut - Outgoing edges sorted by label then target + * @property {ReadonlyArray<{label: string, to?: string, from?: string}>} edgesIn - Incoming edges sorted by label then source */ /** @@ -271,6 +271,7 @@ function cloneValue(value) { * @private */ function buildPropsSnapshot(propsMap) { + /** @type {Record} */ const props = {}; const keys = [...propsMap.keys()].sort(); for (const key of keys) { @@ -299,8 +300,8 @@ function buildEdgesSnapshot(edges, directionKey) { if (a.label !== b.label) { return a.label < b.label ? -1 : 1; } - const aPeer = a[directionKey]; - const bPeer = b[directionKey]; + const aPeer = /** @type {string} */ (a[directionKey]); + const bPeer = /** @type {string} */ (b[directionKey]); return aPeer < bPeer ? -1 : aPeer > bPeer ? 1 : 0; }); return deepFreeze(list); @@ -493,9 +494,13 @@ export default class QueryBuilder { */ constructor(graph) { this._graph = graph; + /** @type {string|null} */ this._pattern = null; + /** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */ this._operations = []; + /** @type {string[]|null} */ this._select = null; + /** @type {AggregateSpec|null} */ this._aggregate = null; } @@ -531,7 +536,7 @@ export default class QueryBuilder { */ where(fn) { assertPredicate(fn); - const predicate = isPlainObject(fn) ? objectToPredicate(fn) : fn; + const predicate = isPlainObject(fn) ? objectToPredicate(/** @type {Record} */ (fn)) : /** @type {(node: QueryNodeSnapshot) => boolean} */ (fn); this._operations.push({ type: 'where', fn: predicate }); return this; } @@ -628,11 +633,6 @@ export default class QueryBuilder { * The "props." prefix is optional and will be stripped automatically. * * @param {AggregateSpec} spec - Aggregation specification - * @param {boolean} [spec.count] - If true, include count of matched nodes - * @param {string} [spec.sum] - Property path to sum - * @param {string} [spec.avg] - Property path to average - * @param {string} [spec.min] - Property path to find minimum - * @param {string} [spec.max] - Property path to find maximum * @returns {QueryBuilder} This builder for chaining * @throws {QueryError} If spec is not a plain object (code: E_QUERY_AGGREGATE_TYPE) * @throws {QueryError} If numeric aggregation keys are not strings (code: E_QUERY_AGGREGATE_TYPE) @@ -646,11 +646,12 @@ export default class QueryBuilder { }); } const numericKeys = ['sum', 'avg', 'min', 'max']; + const specAny = /** @type {Record} */ (/** @type {unknown} */ (spec)); for (const key of numericKeys) { - if (spec[key] !== undefined && typeof spec[key] !== 'string') { + if (specAny[key] !== undefined && typeof specAny[key] !== 'string') { throw new QueryError(`aggregate() expects ${key} to be a string path`, { code: 'E_QUERY_AGGREGATE_TYPE', - context: { key, receivedType: typeof spec[key] }, + context: { key, receivedType: typeof specAny[key] }, }); } } @@ -674,7 +675,7 @@ export default class QueryBuilder { * @throws {QueryError} If an unknown select field is specified (code: E_QUERY_SELECT_FIELD) */ async run() { - const materialized = await this._graph._materializeGraph(); + const materialized = await /** @type {any} */ (this._graph)._materializeGraph(); // TODO(ts-cleanup): narrow port type const { adjacency, stateHash } = materialized; const allNodes = sortIds(await this._graph.getNodes()); @@ -696,15 +697,16 @@ export default class QueryBuilder { }; }) ); + const predicate = /** @type {(node: QueryNodeSnapshot) => boolean} */ (op.fn); const filtered = snapshots - .filter(({ snapshot }) => op.fn(snapshot)) + .filter(({ snapshot }) => predicate(snapshot)) .map(({ nodeId }) => nodeId); workingSet = sortIds(filtered); continue; } if (op.type === 'outgoing' || op.type === 'incoming') { - const [minD, maxD] = op.depth; + const [minD, maxD] = /** @type {[number, number]} */ (op.depth); if (minD === 1 && maxD === 1) { workingSet = applyHop({ direction: op.type, @@ -718,7 +720,7 @@ export default class QueryBuilder { label: op.label, workingSet, adjacency, - depth: op.depth, + depth: /** @type {[number, number]} */ (op.depth), }); } } @@ -778,21 +780,24 @@ export default class QueryBuilder { * @private */ async _runAggregate(workingSet, stateHash) { - const spec = this._aggregate; + const spec = /** @type {AggregateSpec} */ (this._aggregate); + /** @type {AggregateResult} */ const result = { stateHash }; + const specRec = /** @type {Record} */ (/** @type {unknown} */ (spec)); if (spec.count) { result.count = workingSet.length; } const numericAggs = ['sum', 'avg', 'min', 'max']; - const activeAggs = numericAggs.filter((key) => spec[key]); + const activeAggs = numericAggs.filter((key) => specRec[key]); if (activeAggs.length > 0) { + /** @type {Map} */ const propsByAgg = new Map(); for (const key of activeAggs) { propsByAgg.set(key, { - segments: spec[key].replace(/^props\./, '').split('.'), + segments: /** @type {string} */ (specRec[key]).replace(/^props\./, '').split('.'), values: [], }); } @@ -800,6 +805,7 @@ export default class QueryBuilder { for (const nodeId of workingSet) { const propsMap = (await this._graph.getNodeProps(nodeId)) || new Map(); for (const { segments, values } of propsByAgg.values()) { + /** @type {*} */ // TODO(ts-cleanup): type deep property traversal let value = propsMap.get(segments[0]); for (let i = 1; i < segments.length; i++) { if (value && typeof value === 'object') { @@ -817,15 +823,15 @@ export default class QueryBuilder { for (const [key, { values }] of propsByAgg) { if (key === 'sum') { - result.sum = values.length > 0 ? values.reduce((a, b) => a + b, 0) : 0; + result.sum = values.length > 0 ? values.reduce((/** @type {number} */ a, /** @type {number} */ b) => a + b, 0) : 0; } else if (key === 'avg') { - result.avg = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; + result.avg = values.length > 0 ? values.reduce((/** @type {number} */ a, /** @type {number} */ b) => a + b, 0) / values.length : 0; } else if (key === 'min') { result.min = - values.length > 0 ? values.reduce((m, v) => (v < m ? v : m), Infinity) : 0; + values.length > 0 ? values.reduce((/** @type {number} */ m, /** @type {number} */ v) => (v < m ? v : m), Infinity) : 0; } else if (key === 'max') { result.max = - values.length > 0 ? values.reduce((m, v) => (v > m ? v : m), -Infinity) : 0; + values.length > 0 ? values.reduce((/** @type {number} */ m, /** @type {number} */ v) => (v > m ? v : m), -Infinity) : 0; } } } diff --git a/src/domain/services/StateDiff.js b/src/domain/services/StateDiff.js index f3726106..d9356bbe 100644 --- a/src/domain/services/StateDiff.js +++ b/src/domain/services/StateDiff.js @@ -86,8 +86,8 @@ function compareProps(a, b) { /** * Checks if two arrays are deeply equal. - * @param {Array} a - * @param {Array} b + * @param {Array<*>} a + * @param {Array<*>} b * @returns {boolean} */ function arraysEqual(a, b) { @@ -104,8 +104,8 @@ function arraysEqual(a, b) { /** * Checks if two objects are deeply equal. - * @param {Object} a - * @param {Object} b + * @param {Record} a + * @param {Record} b * @returns {boolean} */ function objectsEqual(a, b) { @@ -155,9 +155,9 @@ function deepEqual(a, b) { /** * Computes set difference: elements in `after` not in `before`. - * @param {Set} before - * @param {Set} after - * @returns {Array} + * @param {Set} before + * @param {Set} after + * @returns {Array} */ function setAdded(before, after) { const result = []; diff --git a/src/domain/services/StateSerializerV5.js b/src/domain/services/StateSerializerV5.js index 290aa6a0..a1695c57 100644 --- a/src/domain/services/StateSerializerV5.js +++ b/src/domain/services/StateSerializerV5.js @@ -1,4 +1,5 @@ import defaultCodec from '../utils/defaultCodec.js'; +import defaultCrypto from '../utils/defaultCrypto.js'; import { orsetContains, orsetElements } from '../crdt/ORSet.js'; import { decodeEdgeKey, decodePropKey } from './KeyCodec.js'; @@ -75,7 +76,7 @@ export function propVisibleV5(state, propKey) { * @param {import('./JoinReducer.js').WarpStateV5} state * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization - * @returns {Buffer} + * @returns {Buffer|Uint8Array} */ export function serializeStateV5(state, { codec } = {}) { const c = codec || defaultCodec; @@ -122,13 +123,14 @@ export function serializeStateV5(state, { codec } = {}) { * Computes SHA-256 hash of canonical state bytes. * @param {import('./JoinReducer.js').WarpStateV5} state * @param {Object} [options] - Options - * @param {import('../../ports/CryptoPort.js').default} options.crypto - CryptoPort instance + * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization - * @returns {Promise} Hex-encoded SHA-256 hash, or null if no crypto + * @returns {Promise} Hex-encoded SHA-256 hash */ -export async function computeStateHashV5(state, { crypto, codec } = {}) { +export async function computeStateHashV5(state, { crypto, codec } = /** @type {{crypto?: import('../../ports/CryptoPort.js').default, codec?: import('../../ports/CodecPort.js').default}} */ ({})) { + const c = crypto || defaultCrypto; const serialized = serializeStateV5(state, { codec }); - return crypto ? await crypto.hash('sha256', serialized) : null; + return await c.hash('sha256', serialized); } /** @@ -141,7 +143,7 @@ export async function computeStateHashV5(state, { crypto, codec } = {}) { */ export function deserializeStateV5(buffer, { codec } = {}) { const c = codec || defaultCodec; - return c.decode(buffer); + return /** @type {{nodes: string[], edges: Array<{from: string, to: string, label: string}>, props: Array<{node: string, key: string, value: *}>}} */ (c.decode(buffer)); } // ============================================================================ diff --git a/src/domain/services/StreamingBitmapIndexBuilder.js b/src/domain/services/StreamingBitmapIndexBuilder.js index a22aab8d..c1e7d32e 100644 --- a/src/domain/services/StreamingBitmapIndexBuilder.js +++ b/src/domain/services/StreamingBitmapIndexBuilder.js @@ -1,4 +1,5 @@ import defaultCodec from '../utils/defaultCodec.js'; +import defaultCrypto from '../utils/defaultCrypto.js'; import ShardCorruptionError from '../errors/ShardCorruptionError.js'; import ShardValidationError from '../errors/ShardValidationError.js'; import nullLogger from '../utils/nullLogger.js'; @@ -36,10 +37,9 @@ const BITMAP_BASE_OVERHEAD = 64; * * @param {Object} data - The data object to checksum * @param {import('../../ports/CryptoPort.js').default} crypto - CryptoPort instance - * @returns {Promise} Hex-encoded SHA-256 hash + * @returns {Promise} Hex-encoded SHA-256 hash */ const computeChecksum = async (data, crypto) => { - if (!crypto) { return null; } const json = canonicalStringify(data); return await crypto.hash('sha256', json); }; @@ -89,6 +89,8 @@ export default class StreamingBitmapIndexBuilder { * Receives { flushedBytes, totalFlushedBytes, flushCount }. * @param {import('../../ports/LoggerPort.js').default} [options.logger] - Logger for structured logging. * Defaults to NoOpLogger (no logging). + * @param {import('../../ports/CryptoPort.js').default} [options.crypto] - CryptoPort instance for hashing + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for serialization */ constructor({ storage, maxMemoryBytes = DEFAULT_MAX_MEMORY_BYTES, onFlush, logger = nullLogger, crypto, codec }) { if (!storage) { @@ -99,9 +101,9 @@ export default class StreamingBitmapIndexBuilder { } /** @type {import('../../ports/CryptoPort.js').default} */ - this._crypto = crypto; + this._crypto = crypto || defaultCrypto; - /** @type {import('../../ports/CodecPort.js').default|undefined} */ + /** @type {import('../../ports/CodecPort.js').default} */ this._codec = codec || defaultCodec; /** @type {Object} */ @@ -122,7 +124,7 @@ export default class StreamingBitmapIndexBuilder { /** @type {string[]} ID → SHA reverse mapping (kept in memory) */ this.idToSha = []; - /** @type {Map} Current in-memory bitmaps */ + /** @type {Map} Current in-memory bitmaps */ this.bitmaps = new Map(); /** @type {number} Estimated bytes used by current bitmaps */ @@ -137,8 +139,8 @@ export default class StreamingBitmapIndexBuilder { /** @type {number} Number of flush operations performed */ this.flushCount = 0; - /** @type {typeof import('roaring').RoaringBitmap32} Cached constructor */ - this._RoaringBitmap32 = getRoaringBitmap32(); + /** @type {any} Cached Roaring bitmap constructor */ // TODO(ts-cleanup): type lazy singleton + this._RoaringBitmap32 = getRoaringBitmap32(); // TODO(ts-cleanup): type lazy singleton } /** @@ -189,11 +191,12 @@ export default class StreamingBitmapIndexBuilder { * Groups bitmaps by type ('fwd' or 'rev') and SHA prefix (first 2 hex chars). * Each bitmap is serialized to a portable format and base64-encoded. * - * @returns {{fwd: Object>, rev: Object>}} + * @returns {Record>>} * Object with 'fwd' and 'rev' keys, each mapping prefix to SHA→base64Bitmap entries * @private */ _serializeBitmapsToShards() { + /** @type {Record>>} */ const bitmapShards = { fwd: {}, rev: {} }; for (const [key, bitmap] of this.bitmaps) { const type = key.substring(0, 3); @@ -215,7 +218,7 @@ export default class StreamingBitmapIndexBuilder { * The resulting blob OIDs are tracked in `flushedChunks` for later merging. * Writes are performed in parallel for efficiency. * - * @param {{fwd: Object>, rev: Object>}} bitmapShards + * @param {Record>>} bitmapShards * Object with 'fwd' and 'rev' keys containing prefix-grouped bitmap data * @returns {Promise} Resolves when all shards have been written * @async @@ -235,11 +238,11 @@ export default class StreamingBitmapIndexBuilder { data: shardData, }; const buffer = Buffer.from(JSON.stringify(envelope)); - const oid = await this.storage.writeBlob(buffer); + const oid = await /** @type {any} */ (this.storage).writeBlob(buffer); // TODO(ts-cleanup): narrow port type if (!this.flushedChunks.has(path)) { this.flushedChunks.set(path, []); } - this.flushedChunks.get(path).push(oid); + /** @type {string[]} */ (this.flushedChunks.get(path)).push(oid); }) ); } @@ -310,6 +313,7 @@ export default class StreamingBitmapIndexBuilder { * @private */ _buildMetaShards() { + /** @type {Record>} */ const idShards = {}; for (const [sha, id] of this.shaToId) { const prefix = sha.substring(0, 2); @@ -344,7 +348,7 @@ export default class StreamingBitmapIndexBuilder { data: map, }; const buffer = Buffer.from(JSON.stringify(envelope)); - const oid = await this.storage.writeBlob(buffer); + const oid = await /** @type {any} */ (this.storage).writeBlob(buffer); // TODO(ts-cleanup): narrow port type return `100644 blob ${oid}\t${path}`; }) ); @@ -436,18 +440,19 @@ export default class StreamingBitmapIndexBuilder { // Store frontier metadata for staleness detection if (frontier) { + /** @type {Record} */ const sorted = {}; for (const key of Array.from(frontier.keys()).sort()) { sorted[key] = frontier.get(key); } const envelope = { version: 1, writerCount: frontier.size, frontier: sorted }; - const cborOid = await this.storage.writeBlob(Buffer.from(this._codec.encode(envelope))); + const cborOid = await /** @type {any} */ (this.storage).writeBlob(Buffer.from(/** @type {any} */ (this._codec).encode(envelope))); // TODO(ts-cleanup): narrow port type flatEntries.push(`100644 blob ${cborOid}\tfrontier.cbor`); - const jsonOid = await this.storage.writeBlob(Buffer.from(canonicalStringify(envelope))); + const jsonOid = await /** @type {any} */ (this.storage).writeBlob(Buffer.from(canonicalStringify(envelope))); // TODO(ts-cleanup): narrow port type flatEntries.push(`100644 blob ${jsonOid}\tfrontier.json`); } - const treeOid = await this.storage.writeTree(flatEntries); + const treeOid = await /** @type {any} */ (this.storage).writeTree(flatEntries); // TODO(ts-cleanup): narrow port type this.logger.debug('Index finalized', { operation: 'finalize', @@ -501,7 +506,7 @@ export default class StreamingBitmapIndexBuilder { */ _getOrCreateId(sha) { if (this.shaToId.has(sha)) { - return this.shaToId.get(sha); + return /** @type {number} */ (this.shaToId.get(sha)); } const id = this.idToSha.length; this.idToSha.push(sha); @@ -564,7 +569,7 @@ export default class StreamingBitmapIndexBuilder { * @private */ async _loadAndValidateChunk(oid) { - const buffer = await this.storage.readBlob(oid); + const buffer = await /** @type {any} */ (this.storage).readBlob(oid); // TODO(ts-cleanup): narrow port type let envelope; try { envelope = JSON.parse(buffer.toString('utf-8')); @@ -572,14 +577,13 @@ export default class StreamingBitmapIndexBuilder { throw new ShardCorruptionError('Failed to parse shard JSON', { oid, reason: 'invalid_format', - originalError: err.message, + context: { originalError: /** @type {any} */ (err).message }, // TODO(ts-cleanup): type error }); } // Validate version if (envelope.version !== SHARD_VERSION) { throw new ShardValidationError('Shard version mismatch', { - oid, expected: SHARD_VERSION, actual: envelope.version, field: 'version', @@ -610,7 +614,7 @@ export default class StreamingBitmapIndexBuilder { * it using `orInPlace` to combine edge sets. * * @param {Object} opts - Options object - * @param {Object} opts.merged - Object mapping SHA to + * @param {Record} opts.merged - Object mapping SHA to * RoaringBitmap32 instances (mutated in place) * @param {string} opts.sha - The SHA key for this bitmap (40-character hex string) * @param {string} opts.base64Bitmap - Base64-encoded serialized RoaringBitmap32 data @@ -627,7 +631,7 @@ export default class StreamingBitmapIndexBuilder { throw new ShardCorruptionError('Failed to deserialize bitmap', { oid, reason: 'invalid_bitmap', - originalError: err.message, + context: { originalError: /** @type {any} */ (err).message }, // TODO(ts-cleanup): type error }); } @@ -671,6 +675,7 @@ export default class StreamingBitmapIndexBuilder { */ async _mergeChunks(oids, { signal } = {}) { // Load all chunks and merge bitmaps by SHA + /** @type {Record} */ const merged = {}; for (const oid of oids) { @@ -683,6 +688,7 @@ export default class StreamingBitmapIndexBuilder { } // Serialize merged result + /** @type {Record} */ const result = {}; for (const [sha, bitmap] of Object.entries(merged)) { result[sha] = bitmap.serialize(true).toString('base64'); @@ -701,9 +707,9 @@ export default class StreamingBitmapIndexBuilder { } catch (err) { throw new ShardCorruptionError('Failed to serialize merged shard', { reason: 'serialization_error', - originalError: err.message, + context: { originalError: /** @type {any} */ (err).message }, // TODO(ts-cleanup): type error }); } - return this.storage.writeBlob(serialized); + return /** @type {any} */ (this.storage).writeBlob(serialized); // TODO(ts-cleanup): narrow port type } } diff --git a/src/domain/services/SyncProtocol.js b/src/domain/services/SyncProtocol.js index 65755e6d..435a4498 100644 --- a/src/domain/services/SyncProtocol.js +++ b/src/domain/services/SyncProtocol.js @@ -56,18 +56,16 @@ import { vvDeserialize } from '../crdt/VersionVector.js'; * **Mutation**: This function mutates the input patch object for efficiency. * The original object reference is returned. * - * @param {Object} patch - The raw decoded patch from CBOR - * @param {Object|Map} [patch.context] - The causal context (version vector). - * If present as a plain object, will be converted to a Map. - * @param {Array} patch.ops - The patch operations (not modified) - * @returns {Object} The same patch object with context converted to Map + * @param {{ context?: Object | Map, ops: any[] }} patch - The raw decoded patch from CBOR. + * If context is present as a plain object, it will be converted to a Map. + * @returns {{ context?: Object | Map, ops: any[] }} The same patch object with context converted to Map * @private */ function normalizePatch(patch) { // Convert context from plain object to Map (VersionVector) // CBOR deserialization returns plain objects, but join() expects a Map if (patch.context && !(patch.context instanceof Map)) { - patch.context = vvDeserialize(patch.context); + patch.context = vvDeserialize(/** @type {{ [x: string]: number }} */ (patch.context)); } return patch; } @@ -85,12 +83,12 @@ function normalizePatch(patch) { * **Commit message format**: The message is encoded using WarpMessageCodec * and contains metadata (schema version, writer info) plus the patch OID. * - * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence layer * (uses CommitPort.showNode() + BlobPort.readBlob() methods) * @param {string} sha - The 40-character commit SHA to load the patch from * @param {Object} [options] * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization - * @returns {Promise} The decoded and normalized patch object containing: + * @returns {Promise<{ context?: Object | Map, ops: any[] }>} The decoded and normalized patch object containing: * - `ops`: Array of patch operations * - `context`: VersionVector (Map) of causal dependencies * - `writerId`: The writer who created this patch @@ -101,7 +99,7 @@ function normalizePatch(patch) { * @throws {Error} If the patch blob cannot be CBOR-decoded (corrupted data) * @private */ -async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) { +async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const codec = codecOpt || defaultCodec; // Read commit message to extract patch OID const message = await persistence.showNode(sha); @@ -109,7 +107,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) { // Read and decode the patch blob const patchBuffer = await persistence.readBlob(decoded.patchOid); - const patch = codec.decode(patchBuffer); + const patch = /** @type {{ context?: Object | Map, ops: any[] }} */ (codec.decode(patchBuffer)); // Normalize the patch (convert context from object to Map) return normalizePatch(patch); @@ -129,7 +127,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) { * **Performance**: O(N) where N is the number of commits between fromSha and toSha. * Each commit requires two reads: commit info (for parent) and patch blob. * - * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence layer + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence layer * (uses CommitPort.getNodeInfo()/showNode() + BlobPort.readBlob() methods) * @param {string} graphName - Graph name (used in error messages, not for lookups) * @param {string} writerId - Writer ID (used in error messages, not for lookups) @@ -154,7 +152,7 @@ async function loadPatchFromCommit(persistence, sha, { codec: codecOpt } = {}) { * // Load ALL patches for a new writer * const patches = await loadPatchRange(persistence, 'events', 'new-writer', null, tipSha); */ -export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = {}) { +export async function loadPatchRange(persistence, graphName, writerId, fromSha, toSha, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type const patches = []; let cur = toSha; @@ -172,9 +170,9 @@ export async function loadPatchRange(persistence, graphName, writerId, fromSha, // If fromSha was specified but we didn't reach it, we have divergence if (fromSha && cur === null) { - const err = new Error( + const err = /** @type {Error & { code: string }} */ (new Error( `Divergence detected: ${toSha} does not descend from ${fromSha} for writer ${writerId}` - ); + )); err.code = 'E_SYNC_DIVERGENCE'; throw err; } @@ -214,11 +212,7 @@ export async function loadPatchRange(persistence, graphName, writerId, fromSha, * Maps writerId to the SHA of their latest patch commit. * @param {Map} remoteFrontier - Remote writer heads. * Maps writerId to the SHA of their latest patch commit. - * @returns {Object} Sync delta containing: - * - `needFromRemote`: Map - Patches local needs - * - `needFromLocal`: Map - Patches remote needs - * - `newWritersForLocal`: string[] - Writers that local has never seen - * - `newWritersForRemote`: string[] - Writers that remote has never seen + * @returns {{ needFromRemote: Map, needFromLocal: Map, newWritersForLocal: string[], newWritersForRemote: string[] }} Sync delta * * @example * const local = new Map([['w1', 'sha-a'], ['w2', 'sha-b']]); @@ -333,13 +327,14 @@ export function computeSyncDelta(localFrontier, remoteFrontier) { */ export function createSyncRequest(frontier) { // Convert Map to plain object for serialization + /** @type {{ [x: string]: string }} */ const frontierObj = {}; for (const [writerId, sha] of frontier) { frontierObj[writerId] = sha; } return { - type: 'sync-request', + type: /** @type {'sync-request'} */ ('sync-request'), frontier: frontierObj, }; } @@ -363,7 +358,7 @@ export function createSyncRequest(frontier) { * * @param {SyncRequest} request - Incoming sync request containing the requester's frontier * @param {Map} localFrontier - Local frontier (what this node has) - * @param {import('../../ports/GraphPersistencePort.js').default} persistence - Git persistence + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} persistence - Git persistence * layer for loading patches (uses CommitPort + BlobPort methods) * @param {string} graphName - Graph name for error messages and logging * @returns {Promise} Response containing local frontier and patches. @@ -379,7 +374,7 @@ export function createSyncRequest(frontier) { * res.json(response); * }); */ -export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = {}) { +export async function processSyncRequest(request, localFrontier, persistence, graphName, { codec } = /** @type {*} */ ({})) { // TODO(ts-cleanup): needs options type // Convert incoming frontier from object to Map const remoteFrontier = new Map(Object.entries(request.frontier)); @@ -406,7 +401,7 @@ export async function processSyncRequest(request, localFrontier, persistence, gr } catch (err) { // If we detect divergence, skip this writer // The requester may need to handle this separately - if (err.code === 'E_SYNC_DIVERGENCE' || err.message.includes('Divergence detected')) { + if (/** @type {any} */ (err).code === 'E_SYNC_DIVERGENCE' || /** @type {any} */ (err).message?.includes('Divergence detected')) { // TODO(ts-cleanup): type error continue; } throw err; @@ -414,13 +409,14 @@ export async function processSyncRequest(request, localFrontier, persistence, gr } // Convert local frontier to plain object + /** @type {{ [x: string]: string }} */ const frontierObj = {}; for (const [writerId, sha] of localFrontier) { frontierObj[writerId] = sha; } return { - type: 'sync-response', + type: /** @type {'sync-response'} */ ('sync-response'), frontier: frontierObj, patches, }; @@ -495,7 +491,7 @@ export function applySyncResponse(response, state, frontier) { // will prevent silent data loss until the reader is upgraded. assertOpsCompatible(normalizedPatch.ops, SCHEMA_V3); // Apply patch to state - join(newState, normalizedPatch, sha); + join(newState, /** @type {*} */ (normalizedPatch), sha); // TODO(ts-cleanup): type patch array applied++; } @@ -580,13 +576,14 @@ export function syncNeeded(localFrontier, remoteFrontier) { * } */ export function createEmptySyncResponse(frontier) { + /** @type {{ [x: string]: string }} */ const frontierObj = {}; for (const [writerId, sha] of frontier) { frontierObj[writerId] = sha; } return { - type: 'sync-response', + type: /** @type {'sync-response'} */ ('sync-response'), frontier: frontierObj, patches: [], }; diff --git a/src/domain/services/TemporalQuery.js b/src/domain/services/TemporalQuery.js index 1b613016..be3153d9 100644 --- a/src/domain/services/TemporalQuery.js +++ b/src/domain/services/TemporalQuery.js @@ -60,10 +60,11 @@ function unwrapValue(value) { * * @param {import('./JoinReducer.js').WarpStateV5} state - Current state * @param {string} nodeId - Node ID to extract - * @returns {{ id: string, exists: boolean, props: Object }} + * @returns {{ id: string, exists: boolean, props: Record }} */ function extractNodeSnapshot(state, nodeId) { const exists = orsetContains(state.nodeAlive, nodeId); + /** @type {Record} */ const props = {}; if (exists) { @@ -108,7 +109,7 @@ export class TemporalQuery { * @param {string} nodeId - The node ID to evaluate * @param {Function} predicate - Predicate receiving node snapshot * `{ id, exists, props }`. Should return boolean. - * @param {{ since?: number }} [options={}] - Options + * @param {Object} [options={}] - Options * @param {number} [options.since=0] - Minimum Lamport tick (inclusive). * Only patches with lamport >= since are considered. * @returns {Promise} True if predicate held at every tick @@ -161,7 +162,7 @@ export class TemporalQuery { * @param {string} nodeId - The node ID to evaluate * @param {Function} predicate - Predicate receiving node snapshot * `{ id, exists, props }`. Should return boolean. - * @param {{ since?: number }} [options={}] - Options + * @param {Object} [options={}] - Options * @param {number} [options.since=0] - Minimum Lamport tick (inclusive). * Only patches with lamport >= since are considered. * @returns {Promise} True if predicate held at any tick diff --git a/src/domain/services/TranslationCost.js b/src/domain/services/TranslationCost.js index 4be93b68..5021cb9a 100644 --- a/src/domain/services/TranslationCost.js +++ b/src/domain/services/TranslationCost.js @@ -94,8 +94,8 @@ function zeroCost() { /** * Counts how many items in `source` are absent from `targetSet`. * - * @param {Array|Set} source - Source collection - * @param {Set} targetSet - Target set to test against + * @param {Array|Set} source - Source collection + * @param {Set} targetSet - Target set to test against * @returns {number} */ function countMissing(source, targetSet) { @@ -141,7 +141,7 @@ function computeEdgeLoss(state, nodesASet, nodesBSet) { * Counts lost properties for a single node between two observer configs. * * @param {Map} nodeProps - Property keys for the node - * @param {{ configA: Object, configB: Object, nodeInB: boolean }} opts + * @param {{ configA: {expose?: string[], redact?: string[]}, configB: {expose?: string[], redact?: string[]}, nodeInB: boolean }} opts * @returns {{ propsInA: number, lostProps: number }} */ function countNodePropLoss(nodeProps, { configA, configB, nodeInB }) { @@ -157,7 +157,7 @@ function countNodePropLoss(nodeProps, { configA, configB, nodeInB }) { * Computes property loss across all A-visible nodes. * * @param {*} state - WarpStateV5 - * @param {{ nodesA: string[], nodesBSet: Set, configA: Object, configB: Object }} opts + * @param {{ nodesA: string[], nodesBSet: Set, configA: {expose?: string[], redact?: string[]}, configB: {expose?: string[], redact?: string[]} }} opts * @returns {number} propLoss fraction */ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) { diff --git a/src/domain/services/WormholeService.js b/src/domain/services/WormholeService.js index 5c4ed008..69ba8484 100644 --- a/src/domain/services/WormholeService.js +++ b/src/domain/services/WormholeService.js @@ -43,7 +43,7 @@ function validateSha(sha, paramName) { /** * Verifies that a SHA exists in the repository. - * @param {Object} persistence - Git persistence adapter + * @param {{ nodeExists: (sha: string) => Promise }} persistence - Git persistence adapter * @param {string} sha - The SHA to verify * @param {string} paramName - Parameter name for error messages * @throws {WormholeError} If SHA doesn't exist @@ -62,10 +62,11 @@ async function verifyShaExists(persistence, sha, paramName) { /** * Processes a single commit in the wormhole chain. * @param {Object} opts - Options - * @param {Object} opts.persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} opts.persistence - Git persistence adapter * @param {string} opts.sha - The commit SHA * @param {string} opts.graphName - Expected graph name * @param {string|null} opts.expectedWriter - Expected writer ID (null for first commit) + * @param {import('../../ports/CodecPort.js').default} [opts.codec] - Codec for deserialization * @returns {Promise<{patch: Object, sha: string, writerId: string, parentSha: string|null}>} * @throws {WormholeError} On validation errors * @private @@ -100,7 +101,7 @@ async function processCommit({ persistence, sha, graphName, expectedWriter, code } const patchBuffer = await persistence.readBlob(patchMeta.patchOid); - const patch = codec.decode(patchBuffer); + const patch = /** @type {Object} */ (codec.decode(patchBuffer)); return { patch, @@ -135,10 +136,11 @@ async function processCommit({ persistence, sha, graphName, expectedWriter, code * are inclusive in the wormhole. * * @param {Object} options - Wormhole creation options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} options.persistence - Git persistence adapter * @param {string} options.graphName - Name of the graph * @param {string} options.fromSha - SHA of the first (oldest) patch commit * @param {string} options.toSha - SHA of the last (newest) patch commit + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization * @returns {Promise} The created wormhole * @throws {WormholeError} If fromSha or toSha doesn't exist (E_WORMHOLE_SHA_NOT_FOUND) * @throws {WormholeError} If fromSha is not an ancestor of toSha (E_WORMHOLE_INVALID_RANGE) @@ -156,7 +158,7 @@ export async function createWormhole({ persistence, graphName, fromSha, toSha, c // Reverse to get oldest-first order (as required by ProvenancePayload) patches.reverse(); - const writerId = patches.length > 0 ? patches[0].writerId : null; + const writerId = patches.length > 0 ? patches[0].writerId : /** @type {string} */ (''); // Strip writerId to match ProvenancePayload's PatchEntry typedef ({patch, sha}) const payload = new ProvenancePayload(patches.map(({ patch, sha }) => ({ patch, sha }))); @@ -170,10 +172,11 @@ export async function createWormhole({ persistence, graphName, fromSha, toSha, c * validating each commit along the way. * * @param {Object} options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git persistence adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default & import('../../ports/BlobPort.js').default} options.persistence - Git persistence adapter * @param {string} options.graphName - Expected graph name * @param {string} options.fromSha - SHA of the first (oldest) patch commit * @param {string} options.toSha - SHA of the last (newest) patch commit + * @param {import('../../ports/CodecPort.js').default} [options.codec] - Codec for deserialization * @returns {Promise>} Patches in newest-first order * @throws {WormholeError} If fromSha is not an ancestor of toSha or range is empty * @private @@ -230,7 +233,7 @@ async function collectPatchRange({ persistence, graphName, fromSha, toSha, codec * @param {WormholeEdge} first - The earlier (older) wormhole * @param {WormholeEdge} second - The later (newer) wormhole * @param {Object} [options] - Composition options - * @param {import('../../ports/GraphPersistencePort.js').default} [options.persistence] - Git persistence adapter (for validation) + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/CommitPort.js').default} [options.persistence] - Git persistence adapter (for validation) * @returns {Promise} The composed wormhole * @throws {WormholeError} If wormholes are from different writers (E_WORMHOLE_MULTI_WRITER) * @throws {WormholeError} If wormholes are not consecutive (E_WORMHOLE_INVALID_RANGE) @@ -318,9 +321,10 @@ export function deserializeWormhole(json) { }); } + const /** @type {Record} */ typedJson = /** @type {Record} */ (json); const requiredFields = ['fromSha', 'toSha', 'writerId', 'patchCount', 'payload']; for (const field of requiredFields) { - if (json[field] === undefined) { + if (typedJson[field] === undefined) { throw new WormholeError(`Invalid wormhole JSON: missing required field '${field}'`, { code: 'E_INVALID_WORMHOLE_JSON', context: { missingField: field }, @@ -328,19 +332,19 @@ export function deserializeWormhole(json) { } } - if (typeof json.patchCount !== 'number' || json.patchCount < 0) { + if (typeof typedJson.patchCount !== 'number' || typedJson.patchCount < 0) { throw new WormholeError('Invalid wormhole JSON: patchCount must be a non-negative number', { code: 'E_INVALID_WORMHOLE_JSON', - context: { patchCount: json.patchCount }, + context: { patchCount: typedJson.patchCount }, }); } return { - fromSha: json.fromSha, - toSha: json.toSha, - writerId: json.writerId, - patchCount: json.patchCount, - payload: ProvenancePayload.fromJSON(json.payload), + fromSha: typedJson.fromSha, + toSha: typedJson.toSha, + writerId: typedJson.writerId, + patchCount: typedJson.patchCount, + payload: ProvenancePayload.fromJSON(typedJson.payload), }; } diff --git a/src/domain/types/TickReceipt.js b/src/domain/types/TickReceipt.js index 22e008cb..70a75cbe 100644 --- a/src/domain/types/TickReceipt.js +++ b/src/domain/types/TickReceipt.js @@ -67,11 +67,12 @@ function validateOp(op, index) { throw new Error(`ops[${index}] must be an object`); } - validateOpType(op.op, index); - validateOpTarget(op.target, index); - validateOpResult(op.result, index); + const entry = /** @type {Record} */ (op); + validateOpType(entry.op, index); + validateOpTarget(entry.target, index); + validateOpResult(entry.result, index); - if (op.reason !== undefined && typeof op.reason !== 'string') { + if (entry.reason !== undefined && typeof entry.reason !== 'string') { throw new Error(`ops[${index}].reason must be a string or undefined`); } } @@ -208,6 +209,7 @@ export function createTickReceipt({ patchSha, writer, lamport, ops }) { // Build frozen op copies (defensive: don't alias caller's objects) const frozenOps = Object.freeze( ops.map((o) => { + /** @type {{ op: string, target: string, result: 'applied' | 'superseded' | 'redundant', reason?: string }} */ const entry = { op: o.op, target: o.target, result: o.result }; if (o.reason !== undefined) { entry.reason = o.reason; @@ -275,9 +277,11 @@ export function canonicalJson(receipt) { */ function sortedReplacer(_key, value) { if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + /** @type {{ [x: string]: * }} */ const sorted = {}; - for (const k of Object.keys(value).sort()) { - sorted[k] = value[k]; + const obj = /** @type {{ [x: string]: * }} */ (value); + for (const k of Object.keys(obj).sort()) { + sorted[k] = obj[k]; } return sorted; } diff --git a/src/domain/types/WarpTypesV2.js b/src/domain/types/WarpTypesV2.js index 3b396602..84b46504 100644 --- a/src/domain/types/WarpTypesV2.js +++ b/src/domain/types/WarpTypesV2.js @@ -25,9 +25,7 @@ /** * Dot - causal identifier for an add operation - * @typedef {Object} Dot - * @property {string} writer - Writer ID that created this dot - * @property {number} seq - Sequence number for this writer + * @typedef {import('../crdt/Dot.js').Dot} Dot */ /** @@ -182,6 +180,7 @@ export function createPropSetV2(node, key, value) { * @returns {PatchV2} PatchV2 object */ export function createPatchV2({ schema = 2, writer, lamport, context, ops, reads, writes }) { + /** @type {PatchV2} */ const patch = { schema, writer, diff --git a/src/domain/utils/CachedValue.js b/src/domain/utils/CachedValue.js index 2ede99a9..acde7dba 100644 --- a/src/domain/utils/CachedValue.js +++ b/src/domain/utils/CachedValue.js @@ -68,7 +68,7 @@ class CachedValue { */ async get() { if (this._isValid()) { - return this._value; + return /** @type {T} */ (this._value); } const value = await this._compute(); diff --git a/src/domain/utils/LRUCache.js b/src/domain/utils/LRUCache.js index c00c8396..c730ff69 100644 --- a/src/domain/utils/LRUCache.js +++ b/src/domain/utils/LRUCache.js @@ -35,7 +35,7 @@ class LRUCache { return undefined; } // Move to end (most recently used) by deleting and re-inserting - const value = this._cache.get(key); + const value = /** @type {V} */ (this._cache.get(key)); this._cache.delete(key); this._cache.set(key, value); return value; @@ -48,7 +48,7 @@ class LRUCache { * * @param {K} key - The key to set * @param {V} value - The value to cache - * @returns {LRUCache} The cache instance for chaining + * @returns {LRUCache} The cache instance for chaining */ set(key, value) { // If key exists, delete it first so it moves to the end @@ -61,7 +61,7 @@ class LRUCache { // Evict oldest entry if over capacity if (this._cache.size > this.maxSize) { - const oldestKey = this._cache.keys().next().value; + const oldestKey = /** @type {K} */ (this._cache.keys().next().value); this._cache.delete(oldestKey); } diff --git a/src/domain/utils/MinHeap.js b/src/domain/utils/MinHeap.js index ec6962f7..7e86d8e1 100644 --- a/src/domain/utils/MinHeap.js +++ b/src/domain/utils/MinHeap.js @@ -32,10 +32,10 @@ class MinHeap { */ extractMin() { if (this.heap.length === 0) { return undefined; } - if (this.heap.length === 1) { return this.heap.pop().item; } + if (this.heap.length === 1) { return /** @type {{item: *, priority: number}} */ (this.heap.pop()).item; } const min = this.heap[0]; - this.heap[0] = this.heap.pop(); + this.heap[0] = /** @type {{item: *, priority: number}} */ (this.heap.pop()); this._bubbleDown(0); return min.item; } diff --git a/src/domain/utils/RefLayout.js b/src/domain/utils/RefLayout.js index a1b5e01a..1b00bdbf 100644 --- a/src/domain/utils/RefLayout.js +++ b/src/domain/utils/RefLayout.js @@ -291,6 +291,25 @@ export function buildCursorSavedPrefix(graphName) { return `${REF_PREFIX}/${graphName}/cursor/saved/`; } +/** + * 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. + * + * @param {string} graphName - The name of the graph + * @returns {string} The full ref path, e.g. `refs/warp//seek-cache` + * @throws {Error} If graphName is invalid + * + * @example + * buildSeekCacheRef('events'); + * // => 'refs/warp/events/seek-cache' + */ +export function buildSeekCacheRef(graphName) { + validateGraphName(graphName); + return `${REF_PREFIX}/${graphName}/seek-cache`; +} + // ----------------------------------------------------------------------------- // Parsers // ----------------------------------------------------------------------------- diff --git a/src/domain/utils/WriterId.js b/src/domain/utils/WriterId.js index ffa202da..3ad4e707 100644 --- a/src/domain/utils/WriterId.js +++ b/src/domain/utils/WriterId.js @@ -178,7 +178,7 @@ export async function resolveWriterId({ graphName, explicitWriterId, configGet, try { existing = await configGet(key); } catch (e) { - throw new WriterIdError('CONFIG_READ_FAILED', `Failed to read git config key ${key}`, e); + throw new WriterIdError('CONFIG_READ_FAILED', `Failed to read git config key ${key}`, /** @type {Error|undefined} */ (e)); } if (existing) { @@ -198,7 +198,7 @@ export async function resolveWriterId({ graphName, explicitWriterId, configGet, try { await configSet(key, fresh); } catch (e) { - throw new WriterIdError('CONFIG_WRITE_FAILED', `Failed to persist writerId to git config key ${key}`, e); + throw new WriterIdError('CONFIG_WRITE_FAILED', `Failed to persist writerId to git config key ${key}`, /** @type {Error|undefined} */ (e)); } return fresh; diff --git a/src/domain/utils/defaultCodec.js b/src/domain/utils/defaultCodec.js index 5d838c32..5e8cd7f0 100644 --- a/src/domain/utils/defaultCodec.js +++ b/src/domain/utils/defaultCodec.js @@ -18,20 +18,27 @@ const encoder = new Encoder({ mapsAsObjects: true, }); +/** + * Recursively sorts object keys for deterministic CBOR encoding. + * @param {unknown} value - The value to sort keys of + * @returns {unknown} The value with sorted keys + */ function sortKeys(value) { if (value === null || value === undefined) { return value; } if (Array.isArray(value)) { return value.map(sortKeys); } if (value instanceof Map) { + /** @type {Record} */ const sorted = {}; for (const key of Array.from(value.keys()).sort()) { sorted[key] = sortKeys(value.get(key)); } return sorted; } - if (typeof value === 'object' && (value.constructor === Object || value.constructor === undefined)) { + if (typeof value === 'object' && (/** @type {Object} */ (value).constructor === Object || /** @type {Object} */ (value).constructor === undefined)) { + /** @type {Record} */ const sorted = {}; for (const key of Object.keys(value).sort()) { - sorted[key] = sortKeys(value[key]); + sorted[key] = sortKeys(/** @type {Record} */ (value)[key]); } return sorted; } diff --git a/src/domain/utils/defaultCrypto.js b/src/domain/utils/defaultCrypto.js new file mode 100644 index 00000000..d199cc5c --- /dev/null +++ b/src/domain/utils/defaultCrypto.js @@ -0,0 +1,36 @@ +/** + * Default crypto implementation for domain services. + * + * Provides SHA hashing, HMAC, and timing-safe comparison using + * node:crypto directly, avoiding concrete adapter imports from + * the infrastructure layer. This follows the same pattern as + * defaultCodec.js and defaultClock.js. + * + * Since git-warp requires Git (and therefore Node 22+, Deno, or Bun), + * node:crypto is always available. + * + * @module domain/utils/defaultCrypto + */ + +import { + createHash, + createHmac, + timingSafeEqual as nodeTimingSafeEqual, +} from 'node:crypto'; + +/** @type {import('../../ports/CryptoPort.js').default} */ +const defaultCrypto = { + // eslint-disable-next-line @typescript-eslint/require-await -- async matches CryptoPort contract + async hash(algorithm, data) { + return createHash(algorithm).update(data).digest('hex'); + }, + // eslint-disable-next-line @typescript-eslint/require-await -- async matches CryptoPort contract + async hmac(algorithm, key, data) { + return createHmac(algorithm, key).update(data).digest(); + }, + timingSafeEqual(a, b) { + return nodeTimingSafeEqual(a, b); + }, +}; + +export default defaultCrypto; diff --git a/src/domain/utils/roaring.js b/src/domain/utils/roaring.js index b7e1650a..e6bdd65a 100644 --- a/src/domain/utils/roaring.js +++ b/src/domain/utils/roaring.js @@ -32,7 +32,7 @@ const NOT_CHECKED = Symbol('NOT_CHECKED'); /** * Cached reference to the loaded roaring module. - * @type {Object|null} + * @type {any} // TODO(ts-cleanup): type lazy singleton * @private */ let roaringModule = null; @@ -51,7 +51,7 @@ let nativeAvailability = NOT_CHECKED; * Uses a top-level-await-friendly pattern with dynamic import. * The module is cached after first load. * - * @returns {Object} The roaring module exports + * @returns {any} The roaring module exports * @throws {Error} If the roaring package is not installed or fails to load * @private */ @@ -151,7 +151,7 @@ export function getRoaringBitmap32() { */ export function getNativeRoaringAvailable() { if (nativeAvailability !== NOT_CHECKED) { - return nativeAvailability; + return /** @type {boolean|null} */ (nativeAvailability); } try { @@ -161,13 +161,13 @@ export function getNativeRoaringAvailable() { // Try the method-based API first (roaring >= 2.x) if (typeof RoaringBitmap32.isNativelyInstalled === 'function') { nativeAvailability = RoaringBitmap32.isNativelyInstalled(); - return nativeAvailability; + return /** @type {boolean|null} */ (nativeAvailability); } // Fall back to property-based API (roaring 1.x) if (roaring.isNativelyInstalled !== undefined) { nativeAvailability = roaring.isNativelyInstalled; - return nativeAvailability; + return /** @type {boolean|null} */ (nativeAvailability); } // Could not determine - leave as null (indeterminate) diff --git a/src/domain/utils/seekCacheKey.js b/src/domain/utils/seekCacheKey.js new file mode 100644 index 00000000..a4c03385 --- /dev/null +++ b/src/domain/utils/seekCacheKey.js @@ -0,0 +1,32 @@ +/** + * 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 { createHash } from 'node:crypto'; + +const KEY_VERSION = 'v1'; + +/** + * Builds a deterministic, collision-resistant cache key from a ceiling tick + * and writer frontier snapshot. + * + * @param {number} ceiling - Lamport ceiling tick + * @param {Map} frontier - Map of writerId → tip SHA + * @returns {string} Cache key, e.g. `v1:t42-a1b2c3d4...` (32+ hex chars in hash) + */ +export function buildSeekCacheKey(ceiling, frontier) { + 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 = createHash('sha256').update(payload).digest('hex'); + return `${KEY_VERSION}:t${ceiling}-${hash}`; +} diff --git a/src/domain/warp/PatchSession.js b/src/domain/warp/PatchSession.js index f0880d14..fa81248b 100644 --- a/src/domain/warp/PatchSession.js +++ b/src/domain/warp/PatchSession.js @@ -21,7 +21,7 @@ export class PatchSession { * * @param {Object} options * @param {import('../services/PatchBuilderV2.js').PatchBuilderV2} options.builder - Internal builder - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default} options.persistence - Git adapter * @param {string} options.graphName - Graph namespace * @param {string} options.writerId - Writer ID * @param {string|null} options.expectedOldHead - Expected parent SHA for CAS @@ -30,7 +30,7 @@ export class PatchSession { /** @type {import('../services/PatchBuilderV2.js').PatchBuilderV2} */ this._builder = builder; - /** @type {import('../../ports/GraphPersistencePort.js').default} */ + /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default} */ this._persistence = persistence; /** @type {string} */ @@ -176,7 +176,7 @@ export class PatchSession { const sha = await this._builder.commit(); this._committed = true; return sha; - } catch (err) { + } catch (/** @type {any} */ err) { // TODO(ts-cleanup): type error // Check if it's a concurrent commit error from PatchBuilderV2 if (err.message?.includes('Concurrent commit detected') || err.message?.includes('has advanced')) { diff --git a/src/domain/warp/Writer.js b/src/domain/warp/Writer.js index 0b97cba6..3787dabe 100644 --- a/src/domain/warp/Writer.js +++ b/src/domain/warp/Writer.js @@ -36,7 +36,7 @@ export class Writer { * Creates a new Writer instance. * * @param {Object} options - * @param {import('../../ports/GraphPersistencePort.js').default} options.persistence - Git adapter + * @param {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default} options.persistence - Git adapter * @param {string} options.graphName - Graph namespace * @param {string} options.writerId - This writer's ID * @param {import('../crdt/VersionVector.js').VersionVector} options.versionVector - Current version vector @@ -48,7 +48,7 @@ export class Writer { constructor({ persistence, graphName, writerId, versionVector, getCurrentState, onCommitSuccess, onDeleteWithData = 'warn', codec }) { validateWriterId(writerId); - /** @type {import('../../ports/GraphPersistencePort.js').default} */ + /** @type {import('../../ports/GraphPersistencePort.js').default & import('../../ports/RefPort.js').default & import('../../ports/CommitPort.js').default} */ this._persistence = persistence; /** @type {string} */ diff --git a/src/infrastructure/adapters/BunHttpAdapter.js b/src/infrastructure/adapters/BunHttpAdapter.js index 54f6a1a8..90108f3d 100644 --- a/src/infrastructure/adapters/BunHttpAdapter.js +++ b/src/infrastructure/adapters/BunHttpAdapter.js @@ -50,9 +50,10 @@ async function readStreamBody(bodyStream) { * HttpServerPort request handlers. * * @param {Request} request - Bun fetch Request - * @returns {Promise<{ method: string, url: string, headers: Object, body: Buffer|undefined }>} + * @returns {Promise<{ method: string, url: string, headers: Record, body: Uint8Array|undefined }>} */ async function toPortRequest(request) { + /** @type {Record} */ const headers = {}; request.headers.forEach((value, key) => { headers[key] = value; @@ -81,11 +82,11 @@ async function toPortRequest(request) { /** * Converts a plain-object port response into a Bun Response. * - * @param {{ status?: number, headers?: Object, body?: string|Uint8Array }} portResponse + * @param {{ status?: number, headers?: Record, body?: string|Uint8Array|null }} portResponse * @returns {Response} */ function toResponse(portResponse) { - return new Response(portResponse.body ?? null, { + return new Response(/** @type {BodyInit | null} */ (portResponse.body ?? null), { status: portResponse.status || 200, headers: portResponse.headers || {}, }); @@ -105,7 +106,7 @@ function createFetchHandler(requestHandler, logger) { const portReq = await toPortRequest(request); const portRes = await requestHandler(portReq); return toResponse(portRes); - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (err.status === 413) { return new Response(PAYLOAD_TOO_LARGE, { status: 413, @@ -131,11 +132,12 @@ function createFetchHandler(requestHandler, logger) { * Note: Bun.serve() is synchronous, so cb fires on the same tick * (unlike Node's server.listen which defers via the event loop). * - * @param {{ port: number, hostname?: string, fetch: Function }} serveOptions + * @param {*} serveOptions * @param {Function|undefined} cb - Node-style callback - * @returns {Object} The Bun server instance + * @returns {*} The Bun server instance */ function startServer(serveOptions, cb) { + // @ts-expect-error — Bun global is only available in Bun runtime const server = globalThis.Bun.serve(serveOptions); if (cb) { cb(null); @@ -146,7 +148,7 @@ function startServer(serveOptions, cb) { /** * Safely stops a Bun server, forwarding errors to the callback. * - * @param {{ server: Object|null }} state - Shared mutable state + * @param {{ server: * }} state - Shared mutable state * @param {Function} [callback] */ function stopServer(state, callback) { @@ -184,15 +186,25 @@ export default class BunHttpAdapter extends HttpServerPort { this._logger = logger || noopLogger; } - /** @inheritdoc */ + /** + * @param {Function} requestHandler + * @returns {{ listen: Function, close: Function, address: Function }} + */ createServer(requestHandler) { const fetchHandler = createFetchHandler(requestHandler, this._logger); + /** @type {{ server: * }} */ const state = { server: null }; return { + /** + * @param {number} port + * @param {string|Function} [host] + * @param {Function} [callback] + */ listen(port, host, callback) { const cb = typeof host === 'function' ? host : callback; const bindHost = typeof host === 'string' ? host : undefined; + /** @type {*} */ // TODO(ts-cleanup): type Bun.serve options const serveOptions = { port, fetch: fetchHandler }; if (bindHost !== undefined) { @@ -208,6 +220,7 @@ export default class BunHttpAdapter extends HttpServerPort { } }, + /** @param {Function} [callback] */ close: (callback) => stopServer(state, callback), address() { diff --git a/src/infrastructure/adapters/CasSeekCacheAdapter.js b/src/infrastructure/adapters/CasSeekCacheAdapter.js new file mode 100644 index 00000000..47b582d7 --- /dev/null +++ b/src/infrastructure/adapters/CasSeekCacheAdapter.js @@ -0,0 +1,311 @@ +/** + * CAS-backed seek materialization cache adapter. + * + * Implements SeekCachePort using @git-stunts/git-cas for persistent storage + * of serialized WarpStateV5 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 from '../../ports/SeekCachePort.js'; +import { buildSeekCacheRef } from '../../domain/utils/RefLayout.js'; +import { Readable } from 'node:stream'; + +const DEFAULT_MAX_ENTRIES = 200; +const INDEX_SCHEMA_VERSION = 1; +const MAX_CAS_RETRIES = 3; + +/** + * @typedef {Object} IndexEntry + * @property {string} treeOid - Git tree OID of the CAS asset + * @property {string} createdAt - ISO 8601 timestamp + * @property {number} ceiling - Lamport ceiling tick + * @property {string} frontierHash - Hex hash portion of the cache key + * @property {number} sizeBytes - Serialized state size in bytes + * @property {string} codec - Codec identifier (e.g. 'cbor-v1') + * @property {number} schemaVersion - Index entry schema version + * @property {string} [lastAccessedAt] - ISO 8601 timestamp of last read (for LRU eviction) + */ + +/** + * @typedef {Object} CacheIndex + * @property {number} schemaVersion - Index-level schema version + * @property {Record} entries - Map of cacheKey → entry + */ + +export default class CasSeekCacheAdapter extends SeekCachePort { + /** + * @param {{ persistence: *, plumbing: *, graphName: string, maxEntries?: number }} options + */ + constructor({ persistence, plumbing, graphName, maxEntries }) { + super(); + this._persistence = persistence; + this._plumbing = plumbing; + this._graphName = graphName; + this._maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES; + this._ref = buildSeekCacheRef(graphName); + this._casPromise = null; + } + + /** + * Lazily initializes the ContentAddressableStore. + * @private + * @returns {Promise<*>} + */ + async _getCas() { + if (!this._casPromise) { + this._casPromise = this._initCas().catch((err) => { + this._casPromise = null; + throw err; + }); + } + return await this._casPromise; + } + + /** + * @private + * @returns {Promise<*>} + */ + async _initCas() { + const { default: ContentAddressableStore } = await import( + /* webpackIgnore: true */ '@git-stunts/git-cas' + ); + return ContentAddressableStore.createCbor({ plumbing: this._plumbing }); + } + + // --------------------------------------------------------------------------- + // Index management + // --------------------------------------------------------------------------- + + /** + * Reads the current cache index from the ref. + * @private + * @returns {Promise} + */ + async _readIndex() { + const oid = await this._persistence.readRef(this._ref); + if (!oid) { + return { schemaVersion: INDEX_SCHEMA_VERSION, entries: {} }; + } + try { + const buf = await this._persistence.readBlob(oid); + const parsed = JSON.parse(buf.toString('utf8')); + if (parsed.schemaVersion !== INDEX_SCHEMA_VERSION) { + return { schemaVersion: INDEX_SCHEMA_VERSION, entries: {} }; + } + return parsed; + } catch { + return { schemaVersion: INDEX_SCHEMA_VERSION, entries: {} }; + } + } + + /** + * Writes the cache index blob and updates the ref. + * @private + * @param {CacheIndex} index - The index to write + * @returns {Promise} + */ + async _writeIndex(index) { + const json = JSON.stringify(index); + const oid = await this._persistence.writeBlob(Buffer.from(json, 'utf8')); + await this._persistence.updateRef(this._ref, oid); + } + + /** + * Mutates the index with retry on write failure. + * + * Note: this adapter is single-writer — concurrent index mutations from + * separate processes may lose updates. The retry loop handles transient + * I/O errors (e.g. temporary lock contention), not true CAS conflicts. + * + * @private + * @param {function(CacheIndex): CacheIndex} mutate - Mutation function applied to current index + * @returns {Promise} The mutated index + */ + async _mutateIndex(mutate) { + /** @type {*} */ // TODO(ts-cleanup): type CAS retry error + let lastErr; + 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; + // Transient write failure — retry with fresh read + if (attempt === MAX_CAS_RETRIES - 1) { + throw new Error(`CasSeekCacheAdapter: index update failed after retries: ${lastErr.message}`); + } + } + } + /* c8 ignore next - unreachable */ + throw new Error('CasSeekCacheAdapter: index update failed'); + } + + /** + * Evicts oldest entries when index exceeds maxEntries. + * @private + * @param {CacheIndex} index + * @returns {CacheIndex} + */ + _enforceMaxEntries(index) { + const keys = Object.keys(index.entries); + if (keys.length <= this._maxEntries) { + return index; + } + // Sort by last access (or creation) ascending — evict least recently used + const sorted = keys.sort((a, b) => { + const ea = index.entries[a]; + const eb = index.entries[b]; + const ta = ea.lastAccessedAt || ea.createdAt || ''; + const tb = eb.lastAccessedAt || eb.createdAt || ''; + return ta < tb ? -1 : ta > tb ? 1 : 0; + }); + const toEvict = sorted.slice(0, keys.length - this._maxEntries); + for (const k of toEvict) { + delete index.entries[k]; + } + return index; + } + + /** + * Parses ceiling and frontierHash from a versioned cache key. + * @private + * @param {string} key - e.g. 'v1:t42-abcdef...' + * @returns {{ ceiling: number, frontierHash: string }} + */ + _parseKey(key) { + 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 }; + } + + // --------------------------------------------------------------------------- + // SeekCachePort implementation + // --------------------------------------------------------------------------- + + /** + * @override + * @param {string} key + * @returns {Promise} + */ + async get(key) { + const cas = await this._getCas(); + const index = await this._readIndex(); + const entry = index.entries[key]; + if (!entry) { + return null; + } + + try { + const manifest = await cas.readManifest({ treeOid: entry.treeOid }); + const { buffer } = await cas.restore({ manifest }); + // Update lastAccessedAt for LRU eviction ordering + await this._mutateIndex((idx) => { + if (idx.entries[key]) { + idx.entries[key].lastAccessedAt = new Date().toISOString(); + } + return idx; + }); + return buffer; + } catch { + // Blob GC'd or corrupted — self-heal by removing dead entry + await this._mutateIndex((idx) => { + delete idx.entries[key]; + return idx; + }); + return null; + } + } + + /** + * @override + * @param {string} key + * @param {Buffer} buffer + * @returns {Promise} + */ + async set(key, buffer) { + const cas = await this._getCas(); + const { ceiling, frontierHash } = this._parseKey(key); + + // Store buffer as CAS asset + const source = Readable.from([buffer]); + const manifest = await cas.store({ + source, + slug: key, + filename: 'state.cbor', + }); + const treeOid = await cas.createTree({ manifest }); + + // Update index with rich metadata + await this._mutateIndex((index) => { + index.entries[key] = { + treeOid, + createdAt: new Date().toISOString(), + ceiling, + frontierHash, + sizeBytes: buffer.length, + codec: 'cbor-v1', + schemaVersion: INDEX_SCHEMA_VERSION, + }; + return this._enforceMaxEntries(index); + }); + } + + /** + * @override + * @param {string} key + * @returns {Promise} + */ + async has(key) { + const index = await this._readIndex(); + return key in index.entries; + } + + /** @override */ + async keys() { + const index = await this._readIndex(); + return Object.keys(index.entries); + } + + /** + * @override + * @param {string} key + * @returns {Promise} + */ + async delete(key) { + let existed = false; + await this._mutateIndex((index) => { + existed = key in index.entries; + delete index.entries[key]; + return index; + }); + return existed; + } + + /** + * Removes the index ref. CAS tree/blob objects are left as loose Git + * objects and will be pruned by `git gc` (default expiry ~2 weeks). + * @override + */ + async clear() { + try { + await this._persistence.deleteRef(this._ref); + } catch { + // Ref may not exist — that's fine + } + } +} diff --git a/src/infrastructure/adapters/ClockAdapter.js b/src/infrastructure/adapters/ClockAdapter.js index b0f51dd1..4ce70880 100644 --- a/src/infrastructure/adapters/ClockAdapter.js +++ b/src/infrastructure/adapters/ClockAdapter.js @@ -15,7 +15,7 @@ import ClockPort from '../../ports/ClockPort.js'; export default class ClockAdapter extends ClockPort { /** * @param {object} [options] - * @param {Performance} [options.performanceImpl] - Performance API implementation. + * @param {{ now(): number }} [options.performanceImpl] - Performance API implementation. * Defaults to `globalThis.performance`. */ constructor({ performanceImpl } = {}) { @@ -28,7 +28,7 @@ export default class ClockAdapter extends ClockPort { * @returns {ClockAdapter} */ static node() { - return new ClockAdapter({ performanceImpl: nodePerformance }); + return new ClockAdapter({ performanceImpl: /** @type {{ now(): number }} */ (nodePerformance) }); } /** diff --git a/src/infrastructure/adapters/DenoHttpAdapter.js b/src/infrastructure/adapters/DenoHttpAdapter.js index 743ba350..cfc596eb 100644 --- a/src/infrastructure/adapters/DenoHttpAdapter.js +++ b/src/infrastructure/adapters/DenoHttpAdapter.js @@ -46,9 +46,10 @@ async function readStreamBody(bodyStream) { * HttpServerPort request handlers. * * @param {Request} request - Deno Request object - * @returns {Promise<{ method: string, url: string, headers: Object, body: Uint8Array|undefined }>} + * @returns {Promise<{ method: string, url: string, headers: Record, body: Uint8Array|undefined }>} */ async function toPlainRequest(request) { + /** @type {Record} */ const headers = {}; request.headers.forEach((value, key) => { headers[key] = value; @@ -75,11 +76,11 @@ async function toPlainRequest(request) { /** * Converts a plain-object response from the handler into a Deno Response. * - * @param {{ status?: number, headers?: Object, body?: string|Uint8Array }} plain + * @param {{ status?: number, headers?: Record, body?: string|Uint8Array|null }} plain * @returns {Response} */ function toDenoResponse(plain) { - return new Response(plain.body ?? null, { + return new Response(/** @type {BodyInit | null} */ (plain.body ?? null), { status: plain.status || 200, headers: plain.headers || {}, }); @@ -99,7 +100,7 @@ function createHandler(requestHandler, logger) { const plain = await toPlainRequest(request); const response = await requestHandler(plain); return toDenoResponse(response); - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (err.status === 413) { const msg = new TextEncoder().encode('Payload Too Large'); return new Response(msg, { @@ -122,7 +123,7 @@ function createHandler(requestHandler, logger) { /** * Gracefully shuts down the Deno HTTP server. * - * @param {object} state - Shared mutable state `{ server }` + * @param {{ server: *}} state - Shared mutable state `{ server }` * @param {Function} [callback] */ function closeImpl(state, callback) { @@ -139,7 +140,7 @@ function closeImpl(state, callback) { callback(); } }, - (err) => { + /** @param {*} err */ (err) => { state.server = null; if (callback) { callback(err); @@ -151,7 +152,7 @@ function closeImpl(state, callback) { /** * Returns the server's bound address info. * - * @param {object} state - Shared mutable state `{ server }` + * @param {{ server: * }} state - Shared mutable state `{ server }` * @returns {{ address: string, port: number, family: string }|null} */ function addressImpl(state) { @@ -189,17 +190,27 @@ export default class DenoHttpAdapter extends HttpServerPort { this._logger = logger || noopLogger; } - /** @inheritdoc */ + /** + * @param {Function} requestHandler + * @returns {{ listen: Function, close: Function, address: Function }} + */ createServer(requestHandler) { const handler = createHandler(requestHandler, this._logger); + /** @type {{ server: * }} */ const state = { server: null }; return { + /** + * @param {number} port + * @param {string|Function} [host] + * @param {Function} [callback] + */ listen: (port, host, callback) => { const cb = typeof host === 'function' ? host : callback; const hostname = typeof host === 'string' ? host : undefined; try { + /** @type {*} */ // TODO(ts-cleanup): type Deno.serve options const serveOptions = { port, onListen() { @@ -212,8 +223,9 @@ export default class DenoHttpAdapter extends HttpServerPort { serveOptions.hostname = hostname; } + // @ts-expect-error — Deno global is only available in Deno runtime state.server = globalThis.Deno.serve(serveOptions, handler); - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (cb) { cb(err); } else { @@ -221,6 +233,7 @@ export default class DenoHttpAdapter extends HttpServerPort { } } }, + /** @param {Function} [callback] */ close: (callback) => { closeImpl(state, callback); }, diff --git a/src/infrastructure/adapters/GitGraphAdapter.js b/src/infrastructure/adapters/GitGraphAdapter.js index 9702efc6..f418bd46 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.js +++ b/src/infrastructure/adapters/GitGraphAdapter.js @@ -73,9 +73,13 @@ const TRANSIENT_ERROR_PATTERNS = [ 'connection timed out', ]; +/** + * @typedef {Error & { details?: { stderr?: string, code?: number }, exitCode?: number, code?: number }} GitError + */ + /** * Determines if an error is transient and safe to retry. - * @param {Error} error - The error to check + * @param {GitError} error - The error to check * @returns {boolean} True if the error is transient */ function isTransientError(error) { @@ -102,7 +106,7 @@ const DEFAULT_RETRY_OPTIONS = { /** * Extracts the exit code from a Git command error. * Checks multiple possible locations where the exit code may be stored. - * @param {Error} err - The error object + * @param {GitError} err - The error object * @returns {number|undefined} The exit code if found */ function getExitCode(err) { @@ -120,7 +124,7 @@ async function refExists(execute, ref) { try { await execute({ args: ['show-ref', '--verify', '--quiet', ref] }); return true; - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (getExitCode(err) === 1) { return false; } @@ -164,11 +168,6 @@ async function refExists(execute, ref) { * synchronization, and the retry logic handles lock contention gracefully. * * @extends GraphPersistencePort - * @implements {CommitPort} - * @implements {BlobPort} - * @implements {TreePort} - * @implements {RefPort} - * @implements {ConfigPort} * @see {@link GraphPersistencePort} for the abstract interface contract * @see {@link DEFAULT_RETRY_OPTIONS} for retry configuration details * @@ -198,19 +197,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { /** * Creates a new GitGraphAdapter instance. * - * @param {Object} options - Configuration options - * @param {import('@git-stunts/plumbing').default} options.plumbing - The Git plumbing - * instance to use for executing Git commands. Must be initialized with a valid - * repository path. - * @param {import('@git-stunts/alfred').RetryOptions} [options.retryOptions={}] - Custom - * retry options to override the defaults. Useful for tuning retry behavior based - * on deployment environment: - * - `retries` (number): Maximum retry attempts (default: 3) - * - `delay` (number): Initial delay in ms (default: 100) - * - `maxDelay` (number): Maximum delay cap in ms (default: 2000) - * - `backoff` ('exponential'|'linear'|'constant'): Backoff strategy - * - `jitter` ('full'|'decorrelated'|'none'): Jitter strategy - * - `shouldRetry` (function): Custom predicate for retryable errors + * @param {{ plumbing: *, retryOptions?: Object }} options - Configuration options * * @throws {Error} If plumbing is not provided * @@ -447,6 +434,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { */ async readTree(treeOid) { const oids = await this.readTreeOids(treeOid); + /** @type {Record} */ const files = {}; // Process sequentially to avoid spawning thousands of concurrent readBlob calls for (const [path, oid] of Object.entries(oids)) { @@ -468,6 +456,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { args: ['ls-tree', '-r', '-z', treeOid] }); + /** @type {Record} */ const oids = {}; // NUL-separated records: "mode type oid\tpath\0" const records = output.split('\0'); @@ -534,7 +523,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { args: ['rev-parse', ref] }); return oid.trim(); - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (getExitCode(err) === 1) { return null; } @@ -607,7 +596,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { try { await this._executeWithRetry({ args: ['cat-file', '-e', sha] }); return true; - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (getExitCode(err) === 1) { return false; } @@ -683,7 +672,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { args: ['merge-base', '--is-ancestor', potentialAncestor, descendant] }); return true; // Exit code 0 means it IS an ancestor - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (this._getExitCode(err) === 1) { return false; // Exit code 1 means it is NOT an ancestor } @@ -705,7 +694,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { }); // Preserve empty-string values; only drop trailing newline return value.replace(/\n$/, ''); - } catch (err) { + } catch (/** @type {*} */ err) { // TODO(ts-cleanup): type error if (this._isConfigKeyNotFound(err)) { return null; } @@ -757,7 +746,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { /** * Extracts the exit code from a Git command error. * Delegates to the standalone getExitCode helper. - * @param {Error} err - The error object + * @param {GitError} err - The error object * @returns {number|undefined} The exit code if found * @private */ @@ -768,7 +757,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { /** * Checks if an error indicates a config key was not found. * Exit code 1 from `git config --get` means the key doesn't exist. - * @param {Error} err - The error object + * @param {GitError} err - The error object * @returns {boolean} True if the error indicates key not found * @private */ diff --git a/src/infrastructure/adapters/NodeCryptoAdapter.js b/src/infrastructure/adapters/NodeCryptoAdapter.js index fe13c5cb..9f3e86b1 100644 --- a/src/infrastructure/adapters/NodeCryptoAdapter.js +++ b/src/infrastructure/adapters/NodeCryptoAdapter.js @@ -13,19 +13,32 @@ import { * @extends CryptoPort */ export default class NodeCryptoAdapter extends CryptoPort { - /** @inheritdoc */ + /** + * @param {string} algorithm + * @param {string|Buffer|Uint8Array} data + * @returns {Promise} + */ // eslint-disable-next-line @typescript-eslint/require-await -- async ensures sync throws become rejected promises async hash(algorithm, data) { return createHash(algorithm).update(data).digest('hex'); } - /** @inheritdoc */ + /** + * @param {string} algorithm + * @param {string|Buffer|Uint8Array} key + * @param {string|Buffer|Uint8Array} data + * @returns {Promise} + */ // eslint-disable-next-line @typescript-eslint/require-await -- async ensures sync throws become rejected promises async hmac(algorithm, key, data) { return createHmac(algorithm, key).update(data).digest(); } - /** @inheritdoc */ + /** + * @param {Buffer|Uint8Array} a + * @param {Buffer|Uint8Array} b + * @returns {boolean} + */ timingSafeEqual(a, b) { return nodeTimingSafeEqual(a, b); } diff --git a/src/infrastructure/adapters/NodeHttpAdapter.js b/src/infrastructure/adapters/NodeHttpAdapter.js index 29383214..ff7bd24c 100644 --- a/src/infrastructure/adapters/NodeHttpAdapter.js +++ b/src/infrastructure/adapters/NodeHttpAdapter.js @@ -7,6 +7,9 @@ const MAX_BODY_BYTES = 10 * 1024 * 1024; /** * Collects the request body and dispatches to the handler, returning * a 500 response if the handler throws. + * @param {import('node:http').IncomingMessage} req + * @param {import('node:http').ServerResponse} res + * @param {{ handler: Function, logger: { error: Function } }} options */ async function dispatch(req, res, { handler, logger }) { try { @@ -60,33 +63,52 @@ export default class NodeHttpAdapter extends HttpServerPort { this._logger = logger || noopLogger; } - /** @inheritdoc */ + /** + * @param {Function} requestHandler + * @returns {{ listen: Function, close: Function, address: Function }} + */ createServer(requestHandler) { const logger = this._logger; const server = createServer((req, res) => { - dispatch(req, res, { handler: requestHandler, logger }).catch((err) => { - logger.error('[NodeHttpAdapter] unhandled dispatch error:', err); - }); + dispatch(req, res, { handler: requestHandler, logger }).catch( + /** @param {*} err */ (err) => { + logger.error('[NodeHttpAdapter] unhandled dispatch error:', err); + }); }); return { + /** + * @param {number} port + * @param {string|Function} [host] + * @param {Function} [callback] + */ listen(port, host, callback) { const cb = typeof host === 'function' ? host : callback; const bindHost = typeof host === 'string' ? host : undefined; + /** @param {*} err */ const onError = (err) => { if (cb) { cb(err); } }; server.once('error', onError); - const args = bindHost !== undefined ? [port, bindHost] : [port]; - server.listen(...args, () => { - server.removeListener('error', onError); - if (cb) { - cb(null); - } - }); + if (bindHost !== undefined) { + server.listen(port, bindHost, () => { + server.removeListener('error', onError); + if (cb) { + cb(null); + } + }); + } else { + server.listen(port, () => { + server.removeListener('error', onError); + if (cb) { + cb(null); + } + }); + } }, + /** @param {((err?: Error) => void)} [callback] */ close(callback) { server.close(callback); }, diff --git a/src/infrastructure/adapters/WebCryptoAdapter.js b/src/infrastructure/adapters/WebCryptoAdapter.js index ec5eb69d..8668e024 100644 --- a/src/infrastructure/adapters/WebCryptoAdapter.js +++ b/src/infrastructure/adapters/WebCryptoAdapter.js @@ -2,9 +2,9 @@ import CryptoPort from '../../ports/CryptoPort.js'; /** * Map of common algorithm names to Web Crypto API algorithm identifiers. - * @const {Object} + * @const {Record} */ -const ALGO_MAP = { +const ALGO_MAP = /** @type {Record} */ ({ 'sha-1': 'SHA-1', 'sha1': 'SHA-1', 'sha-256': 'SHA-256', @@ -13,7 +13,7 @@ const ALGO_MAP = { 'sha384': 'SHA-384', 'sha-512': 'SHA-512', 'sha512': 'SHA-512', -}; +}); /** * Converts a common algorithm name to the Web Crypto API identifier. @@ -38,8 +38,9 @@ function toWebCryptoAlgo(algorithm) { function toUint8Array(data) { if (data instanceof Uint8Array) { return data; } if (typeof data === 'string') { return new TextEncoder().encode(data); } - if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) { - return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(/** @type {*} */ (data))) { // TODO(ts-cleanup): narrow port type + const buf = /** @type {Buffer} */ (/** @type {*} */ (data)); // TODO(ts-cleanup): narrow port type + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); } throw new Error('WebCryptoAdapter: data must be string, Buffer, or Uint8Array'); } @@ -59,7 +60,7 @@ function bufToHex(buf) { * Web Crypto API adapter implementing CryptoPort. * * Uses the standard Web Crypto API (globalThis.crypto.subtle) which is - * available in browsers, Deno, Bun, and Node.js 20+. + * available in browsers, Deno, Bun, and Node.js 22+. * * All hash and HMAC operations are async because the Web Crypto API * is inherently promise-based. @@ -77,26 +78,35 @@ export default class WebCryptoAdapter extends CryptoPort { this._subtle = subtle || globalThis.crypto.subtle; } - /** @inheritdoc */ + /** + * @param {string} algorithm + * @param {string|Buffer|Uint8Array} data + * @returns {Promise} + */ async hash(algorithm, data) { const digest = await this._subtle.digest( toWebCryptoAlgo(algorithm), - toUint8Array(data), + /** @type {BufferSource} */ (toUint8Array(data)), ); return bufToHex(digest); } - /** @inheritdoc */ + /** + * @param {string} algorithm + * @param {string|Buffer|Uint8Array} key + * @param {string|Buffer|Uint8Array} data + * @returns {Promise} + */ async hmac(algorithm, key, data) { const keyBytes = toUint8Array(key); const cryptoKey = await this._subtle.importKey( 'raw', - keyBytes, + /** @type {BufferSource} */ (keyBytes), { name: 'HMAC', hash: toWebCryptoAlgo(algorithm) }, false, ['sign'], ); - const signature = await this._subtle.sign('HMAC', cryptoKey, toUint8Array(data)); + const signature = await this._subtle.sign('HMAC', cryptoKey, /** @type {BufferSource} */ (toUint8Array(data))); return new Uint8Array(signature); } diff --git a/src/infrastructure/codecs/CborCodec.js b/src/infrastructure/codecs/CborCodec.js index 256e55cc..13f47216 100644 --- a/src/infrastructure/codecs/CborCodec.js +++ b/src/infrastructure/codecs/CborCodec.js @@ -83,17 +83,18 @@ const encoder = new Encoder({ * @private */ function isPlainObject(value) { - return typeof value === 'object' && (value.constructor === Object || value.constructor === undefined); + return typeof value === 'object' && value !== null && (value.constructor === Object || value.constructor === undefined); } /** * Sorts the keys of a plain object and recursively processes values. * - * @param {Object} obj - The plain object to process - * @returns {Object} A new object with sorted keys + * @param {Record} obj - The plain object to process + * @returns {Record} A new object with sorted keys * @private */ function sortPlainObject(obj) { + /** @type {Record} */ const sorted = {}; const keys = Object.keys(obj).sort(); for (const key of keys) { @@ -106,8 +107,8 @@ function sortPlainObject(obj) { * Converts a Map to a sorted plain object with recursive value processing. * Validates that all Map keys are strings (required for CBOR encoding). * - * @param {Map} map - The Map instance to convert - * @returns {Object} A plain object with sorted keys + * @param {Map} map - The Map instance to convert + * @returns {Record} A plain object with sorted keys * @throws {TypeError} If any Map key is not a string * @private */ @@ -118,6 +119,7 @@ function sortMapToObject(map) { throw new TypeError(`Map keys must be strings for CBOR encoding, got ${typeof key}`); } } + /** @type {Record} */ const sorted = {}; keys.sort(); for (const key of keys) { @@ -198,7 +200,7 @@ function sortKeys(value) { // Plain objects: sort keys and recursively process values if (isPlainObject(value)) { - return sortPlainObject(value); + return sortPlainObject(/** @type {Record} */ (value)); } // Map instances: convert to sorted object @@ -370,12 +372,18 @@ export function decode(buffer) { * @extends CodecPort */ export class CborCodec extends CodecPort { - /** @inheritdoc */ + /** + * @param {unknown} data + * @returns {Buffer|Uint8Array} + */ encode(data) { return encode(data); } - /** @inheritdoc */ + /** + * @param {Buffer|Uint8Array} buffer + * @returns {unknown} + */ decode(buffer) { return decode(buffer); } diff --git a/src/ports/BlobPort.js b/src/ports/BlobPort.js index 8ee20e71..6c3ebe2a 100644 --- a/src/ports/BlobPort.js +++ b/src/ports/BlobPort.js @@ -10,7 +10,7 @@ export default class BlobPort { /** * Writes content as a Git blob and returns its OID. - * @param {Buffer|string} content - The blob content to write + * @param {Buffer|string} _content - The blob content to write * @returns {Promise} The Git OID of the created blob * @throws {Error} If not implemented by a concrete adapter */ @@ -20,7 +20,7 @@ export default class BlobPort { /** * Reads the content of a Git blob. - * @param {string} oid - The blob OID to read + * @param {string} _oid - The blob OID to read * @returns {Promise} The blob content * @throws {Error} If not implemented by a concrete adapter */ diff --git a/src/ports/CodecPort.js b/src/ports/CodecPort.js index 31de85fd..1117f599 100644 --- a/src/ports/CodecPort.js +++ b/src/ports/CodecPort.js @@ -7,7 +7,7 @@ export default class CodecPort { /** * Encodes data to binary format. - * @param {unknown} data - Data to encode + * @param {unknown} _data - Data to encode * @returns {Buffer|Uint8Array} Encoded bytes */ encode(_data) { @@ -16,7 +16,7 @@ export default class CodecPort { /** * Decodes binary data back to a JavaScript value. - * @param {Buffer|Uint8Array} bytes - Encoded bytes to decode + * @param {Buffer|Uint8Array} _bytes - Encoded bytes to decode * @returns {unknown} Decoded value */ decode(_bytes) { diff --git a/src/ports/CommitPort.js b/src/ports/CommitPort.js index f265a00b..27157ff9 100644 --- a/src/ports/CommitPort.js +++ b/src/ports/CommitPort.js @@ -10,10 +10,7 @@ export default class CommitPort { /** * Creates a commit pointing to the empty tree. - * @param {Object} options - * @param {string} options.message - The commit message (typically CBOR-encoded patch data) - * @param {string[]} [options.parents=[]] - Parent commit SHAs for the commit graph - * @param {boolean} [options.sign=false] - Whether to GPG-sign the commit + * @param {{ message: string, parents?: string[], sign?: boolean }} _options * @returns {Promise} The SHA of the created commit * @throws {Error} If not implemented by a concrete adapter */ @@ -23,7 +20,7 @@ export default class CommitPort { /** * Retrieves the raw commit message for a given SHA. - * @param {string} sha - The commit SHA to read + * @param {string} _sha - The commit SHA to read * @returns {Promise} The raw commit message content * @throws {Error} If not implemented by a concrete adapter */ @@ -33,7 +30,7 @@ export default class CommitPort { /** * Gets full commit metadata for a node. - * @param {string} sha - The commit SHA to retrieve + * @param {string} _sha - The commit SHA to retrieve * @returns {Promise<{sha: string, message: string, author: string, date: string, parents: string[]}>} * Full commit metadata including SHA, message, author, date, and parent SHAs * @throws {Error} If not implemented by a concrete adapter @@ -44,10 +41,7 @@ export default class CommitPort { /** * Returns raw git log output for a ref. - * @param {Object} options - * @param {string} options.ref - The Git ref to log from - * @param {number} [options.limit=50] - Maximum number of commits to return - * @param {string} [options.format] - Custom format string for git log + * @param {{ ref: string, limit?: number, format?: string }} _options * @returns {Promise} The raw log output * @throws {Error} If not implemented by a concrete adapter */ @@ -57,10 +51,7 @@ export default class CommitPort { /** * Streams git log output for a ref. - * @param {Object} options - * @param {string} options.ref - The Git ref to log from - * @param {number} [options.limit=1000000] - Maximum number of commits to return - * @param {string} [options.format] - Custom format string for git log + * @param {{ ref: string, limit?: number, format?: string }} _options * @returns {Promise} A readable stream of log output * @throws {Error} If not implemented by a concrete adapter */ @@ -70,7 +61,7 @@ export default class CommitPort { /** * Counts nodes reachable from a ref without loading them into memory. - * @param {string} ref - Git ref to count from (e.g., 'HEAD', 'main', or a SHA) + * @param {string} _ref - Git ref to count from (e.g., 'HEAD', 'main', or a SHA) * @returns {Promise} The count of reachable nodes * @throws {Error} If not implemented by a concrete adapter */ @@ -81,11 +72,7 @@ export default class CommitPort { /** * Creates a commit pointing to a specified tree (not the empty tree). * Used by CheckpointService and PatchBuilderV2 for tree-backed commits. - * @param {Object} options - * @param {string} options.treeOid - The tree OID to commit - * @param {string[]} [options.parents=[]] - Parent commit SHAs - * @param {string} options.message - The commit message - * @param {boolean} [options.sign=false] - Whether to GPG-sign the commit + * @param {{ treeOid: string, parents?: string[], message: string, sign?: boolean }} _options * @returns {Promise} The SHA of the created commit * @throws {Error} If not implemented by a concrete adapter */ @@ -95,7 +82,7 @@ export default class CommitPort { /** * Checks whether a commit exists in the repository. - * @param {string} sha - The commit SHA to check + * @param {string} _sha - The commit SHA to check * @returns {Promise} True if the commit exists * @throws {Error} If not implemented by a concrete adapter */ diff --git a/src/ports/ConfigPort.js b/src/ports/ConfigPort.js index 00eef590..a3d57d5a 100644 --- a/src/ports/ConfigPort.js +++ b/src/ports/ConfigPort.js @@ -10,7 +10,7 @@ export default class ConfigPort { /** * Reads a git config value. - * @param {string} key - The config key to read (e.g., 'warp.writerId.events') + * @param {string} _key - The config key to read (e.g., 'warp.writerId.events') * @returns {Promise} The config value, or null if not set * @throws {Error} If not implemented by a concrete adapter */ @@ -20,8 +20,8 @@ export default class ConfigPort { /** * Sets a git config value. - * @param {string} key - The config key to set (e.g., 'warp.writerId.events') - * @param {string} value - The value to set + * @param {string} _key - The config key to set (e.g., 'warp.writerId.events') + * @param {string} _value - The value to set * @returns {Promise} * @throws {Error} If not implemented by a concrete adapter */ diff --git a/src/ports/CryptoPort.js b/src/ports/CryptoPort.js index 1959f7e2..26cb36e0 100644 --- a/src/ports/CryptoPort.js +++ b/src/ports/CryptoPort.js @@ -7,8 +7,8 @@ export default class CryptoPort { /** * Computes a hash digest of the given data. - * @param {string} algorithm - Hash algorithm (e.g. 'sha1', 'sha256') - * @param {string|Buffer|Uint8Array} data - Data to hash + * @param {string} _algorithm - Hash algorithm (e.g. 'sha1', 'sha256') + * @param {string|Buffer|Uint8Array} _data - Data to hash * @returns {Promise} Hex-encoded digest */ async hash(_algorithm, _data) { @@ -17,9 +17,9 @@ export default class CryptoPort { /** * Computes an HMAC of the given data. - * @param {string} algorithm - Hash algorithm (e.g. 'sha256') - * @param {string|Buffer|Uint8Array} key - HMAC key - * @param {string|Buffer|Uint8Array} data - Data to authenticate + * @param {string} _algorithm - Hash algorithm (e.g. 'sha256') + * @param {string|Buffer|Uint8Array} _key - HMAC key + * @param {string|Buffer|Uint8Array} _data - Data to authenticate * @returns {Promise} Raw HMAC digest */ async hmac(_algorithm, _key, _data) { @@ -28,8 +28,8 @@ export default class CryptoPort { /** * Constant-time comparison of two buffers. - * @param {Buffer|Uint8Array} a - First buffer - * @param {Buffer|Uint8Array} b - Second buffer + * @param {Buffer|Uint8Array} _a - First buffer + * @param {Buffer|Uint8Array} _b - Second buffer * @returns {boolean} True if buffers are equal */ timingSafeEqual(_a, _b) { diff --git a/src/ports/GraphPersistencePort.js b/src/ports/GraphPersistencePort.js index a7c1d169..e75ecba7 100644 --- a/src/ports/GraphPersistencePort.js +++ b/src/ports/GraphPersistencePort.js @@ -1,3 +1,9 @@ +import CommitPort from './CommitPort.js'; +import BlobPort from './BlobPort.js'; +import TreePort from './TreePort.js'; +import RefPort from './RefPort.js'; +import ConfigPort from './ConfigPort.js'; + /** * Abstract port for graph persistence operations. * @@ -20,27 +26,19 @@ * All methods throw by default and must be overridden by implementations. * * @abstract - * @implements {CommitPort} - * @implements {BlobPort} - * @implements {TreePort} - * @implements {RefPort} - * @implements {ConfigPort} */ - -import CommitPort from './CommitPort.js'; -import BlobPort from './BlobPort.js'; -import TreePort from './TreePort.js'; -import RefPort from './RefPort.js'; -import ConfigPort from './ConfigPort.js'; - class GraphPersistencePort {} +/** @type {Array} */ const focusedPorts = [CommitPort, BlobPort, TreePort, RefPort, ConfigPort]; const seen = new Map(); for (const Port of focusedPorts) { - const descriptors = Object.getOwnPropertyDescriptors(Port.prototype); - delete descriptors.constructor; + const allDescriptors = Object.getOwnPropertyDescriptors(Port.prototype); + /** @type {Record} */ + const descriptors = Object.fromEntries( + Object.entries(allDescriptors).filter(([k]) => k !== 'constructor'), + ); for (const [name, descriptor] of Object.entries(descriptors)) { if (seen.has(name)) { diff --git a/src/ports/HttpServerPort.js b/src/ports/HttpServerPort.js index fa3b9325..6e8e8d2b 100644 --- a/src/ports/HttpServerPort.js +++ b/src/ports/HttpServerPort.js @@ -12,11 +12,7 @@ export default class HttpServerPort { * and must return `{ status, headers, body }`. No raw req/res objects * are exposed to the domain. * - * @param {Function} requestHandler - Async function (request) => response - * @param {string} requestHandler.method - HTTP method - * @param {string} requestHandler.url - Request URL - * @param {Object} requestHandler.headers - Request headers (lowercased keys) - * @param {Buffer|undefined} requestHandler.body - Request body (undefined if none) + * @param {(request: { method: string, url: string, headers: Object, body: Buffer|undefined }) => Promise<{ status: number, headers: Object, body: string|Buffer }>} _requestHandler - Async function (request) => response * @returns {{ listen: Function, close: Function, address: Function }} Server with listen(port, [host], cb(err)), close(cb), and address() */ createServer(_requestHandler) { diff --git a/src/ports/IndexStoragePort.js b/src/ports/IndexStoragePort.js index 599ba8fe..e1e10643 100644 --- a/src/ports/IndexStoragePort.js +++ b/src/ports/IndexStoragePort.js @@ -18,6 +18,7 @@ import RefPort from './RefPort.js'; class IndexStoragePort {} +/** @type {Array<[{ prototype: object, name: string }, string[]]>} */ const picks = [ [BlobPort, ['writeBlob', 'readBlob']], [TreePort, ['writeTree', 'readTreeOids']], diff --git a/src/ports/LoggerPort.js b/src/ports/LoggerPort.js index abe42177..13bc58a4 100644 --- a/src/ports/LoggerPort.js +++ b/src/ports/LoggerPort.js @@ -13,8 +13,8 @@ export default class LoggerPort { /** * Log a debug-level message. - * @param {string} message - The log message - * @param {Record} [context] - Structured metadata + * @param {string} _message - The log message + * @param {Record} [_context] - Structured metadata * @returns {void} * @abstract */ @@ -24,8 +24,8 @@ export default class LoggerPort { /** * Log an info-level message. - * @param {string} message - The log message - * @param {Record} [context] - Structured metadata + * @param {string} _message - The log message + * @param {Record} [_context] - Structured metadata * @returns {void} * @abstract */ @@ -35,8 +35,8 @@ export default class LoggerPort { /** * Log a warning-level message. - * @param {string} message - The log message - * @param {Record} [context] - Structured metadata + * @param {string} _message - The log message + * @param {Record} [_context] - Structured metadata * @returns {void} * @abstract */ @@ -46,8 +46,8 @@ export default class LoggerPort { /** * Log an error-level message. - * @param {string} message - The log message - * @param {Record} [context] - Structured metadata + * @param {string} _message - The log message + * @param {Record} [_context] - Structured metadata * @returns {void} * @abstract */ @@ -58,7 +58,7 @@ export default class LoggerPort { /** * Create a child logger with additional base context. * Child loggers inherit parent context and merge with their own. - * @param {Record} context - Base context for the child logger + * @param {Record} _context - Base context for the child logger * @returns {LoggerPort} A new logger instance with merged context * @abstract */ diff --git a/src/ports/RefPort.js b/src/ports/RefPort.js index 9be50ef7..79712015 100644 --- a/src/ports/RefPort.js +++ b/src/ports/RefPort.js @@ -10,8 +10,8 @@ export default class RefPort { /** * Updates a ref to point to an OID. - * @param {string} ref - The ref name (e.g., 'refs/warp/events/writers/alice') - * @param {string} oid - The OID to point to + * @param {string} _ref - The ref name (e.g., 'refs/warp/events/writers/alice') + * @param {string} _oid - The OID to point to * @returns {Promise} * @throws {Error} If not implemented by a concrete adapter */ @@ -21,7 +21,7 @@ export default class RefPort { /** * Reads the OID a ref points to. - * @param {string} ref - The ref name + * @param {string} _ref - The ref name * @returns {Promise} The OID, or null if the ref does not exist * @throws {Error} If not implemented by a concrete adapter */ @@ -31,7 +31,7 @@ export default class RefPort { /** * Deletes a ref. - * @param {string} ref - The ref name to delete + * @param {string} _ref - The ref name to delete * @returns {Promise} * @throws {Error} If not implemented by a concrete adapter */ @@ -41,7 +41,7 @@ export default class RefPort { /** * Lists refs matching a prefix. - * @param {string} prefix - The ref prefix to match (e.g., 'refs/warp/events/writers/') + * @param {string} _prefix - The ref prefix to match (e.g., 'refs/warp/events/writers/') * @returns {Promise} Array of matching ref names * @throws {Error} If not implemented by a concrete adapter */ diff --git a/src/ports/SeekCachePort.js b/src/ports/SeekCachePort.js new file mode 100644 index 00000000..661d57d2 --- /dev/null +++ b/src/ports/SeekCachePort.js @@ -0,0 +1,73 @@ +/** + * Port interface for seek materialization cache operations. + * + * Defines the contract for caching and retrieving serialized WarpStateV5 + * 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.). + * + * @abstract + */ +export default class SeekCachePort { + /** + * Retrieves a cached state buffer by key. + * @param {string} _key - Cache key (e.g., 'v1:t42-') + * @returns {Promise} The cached buffer, or null on miss + * @throws {Error} If not implemented by a concrete adapter + */ + async get(_key) { + throw new Error('SeekCachePort.get() not implemented'); + } + + /** + * Stores a state buffer under the given key. + * @param {string} _key - Cache key + * @param {Buffer} _buffer - Serialized state to cache + * @returns {Promise} + * @throws {Error} If not implemented by a concrete adapter + */ + async set(_key, _buffer) { + throw new Error('SeekCachePort.set() not implemented'); + } + + /** + * Checks whether a key exists in the cache. + * @param {string} _key - Cache key + * @returns {Promise} + * @throws {Error} If not implemented by a concrete adapter + */ + async has(_key) { + throw new Error('SeekCachePort.has() not implemented'); + } + + /** + * Lists all keys currently in the cache index. + * Note: keys may reference GC'd blobs; callers should handle miss on get(). + * @returns {Promise} + * @throws {Error} If not implemented by a concrete adapter + */ + async keys() { + throw new Error('SeekCachePort.keys() not implemented'); + } + + /** + * Removes a single entry from the cache. + * @param {string} _key - Cache key to remove + * @returns {Promise} True if the entry existed and was removed + * @throws {Error} If not implemented by a concrete adapter + */ + async delete(_key) { + throw new Error('SeekCachePort.delete() not implemented'); + } + + /** + * Removes all entries from the cache. + * @returns {Promise} + * @throws {Error} If not implemented by a concrete adapter + */ + async clear() { + throw new Error('SeekCachePort.clear() not implemented'); + } +} diff --git a/src/ports/TreePort.js b/src/ports/TreePort.js index 251c2143..6f61a5d3 100644 --- a/src/ports/TreePort.js +++ b/src/ports/TreePort.js @@ -10,7 +10,7 @@ export default class TreePort { /** * Creates a Git tree from mktree-formatted entries. - * @param {string[]} entries - Lines in git mktree format (e.g., "100644 blob \t") + * @param {string[]} _entries - Lines in git mktree format (e.g., "100644 blob \t") * @returns {Promise} The Git OID of the created tree * @throws {Error} If not implemented by a concrete adapter */ @@ -20,7 +20,7 @@ export default class TreePort { /** * Reads a tree and returns a map of path to content. - * @param {string} treeOid - The tree OID to read + * @param {string} _treeOid - The tree OID to read * @returns {Promise>} Map of file path to blob content * @throws {Error} If not implemented by a concrete adapter */ @@ -31,7 +31,7 @@ export default class TreePort { /** * Reads a tree and returns a map of path to blob OID. * Useful for lazy-loading shards without reading all blob contents. - * @param {string} treeOid - The tree OID to read + * @param {string} _treeOid - The tree OID to read * @returns {Promise>} Map of file path to blob OID * @throws {Error} If not implemented by a concrete adapter */ diff --git a/src/visualization/layouts/converters.js b/src/visualization/layouts/converters.js index 9bbe5901..a36657ad 100644 --- a/src/visualization/layouts/converters.js +++ b/src/visualization/layouts/converters.js @@ -6,13 +6,19 @@ * { nodes: [{ id, label, props? }], edges: [{ from, to, label? }] } */ +/** + * @typedef {{ id: string, label: string, props?: Record }} GraphDataNode + * @typedef {{ from: string, to: string, label?: string }} GraphDataEdge + * @typedef {{ nodes: GraphDataNode[], edges: GraphDataEdge[] }} GraphData + */ + /** * Converts a query result payload + edge array into graph data. * Edges are filtered to only those connecting matched nodes. * - * @param {Object} payload - Query result { nodes: [{id, props}] } - * @param {Array} edges - Edge array from graph.getEdges() - * @returns {{ nodes: Array, edges: Array }} + * @param {{ nodes?: Array<{ id: string, props?: Record }> } | null} payload - Query result + * @param {Array<{ from: string, to: string, label?: string }>} edges - Edge array from graph.getEdges() + * @returns {GraphData} */ export function queryResultToGraphData(payload, edges) { const nodes = (payload?.nodes ?? []).map((n) => ({ @@ -34,8 +40,8 @@ export function queryResultToGraphData(payload, edges) { * Converts a path result payload into graph data. * Builds a linear chain of nodes with labelled edges. * - * @param {Object} payload - Path result { path: string[], edges?: string[] } - * @returns {{ nodes: Array, edges: Array }} + * @param {{ path?: string[], edges?: string[] } | null} payload - Path result + * @returns {GraphData} */ export function pathResultToGraphData(payload) { const pathArr = payload?.path ?? []; @@ -43,6 +49,7 @@ export function pathResultToGraphData(payload) { const nodes = pathArr.map((id) => ({ id, label: id })); + /** @type {GraphDataEdge[]} */ const edges = []; for (let i = 0; i < pathArr.length - 1; i++) { edges.push({ @@ -59,8 +66,8 @@ export function pathResultToGraphData(payload) { * Converts raw getNodes() + getEdges() output into graph data. * * @param {string[]} nodeIds - Array of node IDs - * @param {Array} edges - Edge array from graph.getEdges() - * @returns {{ nodes: Array, edges: Array }} + * @param {Array<{ from: string, to: string, label?: string }>} edges - Edge array from graph.getEdges() + * @returns {GraphData} */ export function rawGraphToGraphData(nodeIds, edges) { const nodes = (nodeIds ?? []).map((id) => ({ id, label: id })); diff --git a/src/visualization/layouts/elkAdapter.js b/src/visualization/layouts/elkAdapter.js index d5abce9b..0173135b 100644 --- a/src/visualization/layouts/elkAdapter.js +++ b/src/visualization/layouts/elkAdapter.js @@ -2,6 +2,16 @@ * ELK adapter: converts normalised graph data into ELK JSON input. */ +/** + * @typedef {{ id: string, label: string, props?: Record }} GraphDataNode + * @typedef {{ from: string, to: string, label?: string }} GraphDataEdge + * @typedef {{ nodes: GraphDataNode[], edges: GraphDataEdge[] }} GraphData + * @typedef {{ text: string }} ElkLabel + * @typedef {{ id: string, sources: string[], targets: string[], labels?: ElkLabel[] }} ElkEdge + * @typedef {{ id: string, width: number, height: number, labels: ElkLabel[] }} ElkChild + * @typedef {{ id: string, layoutOptions: Record, children: ElkChild[], edges: ElkEdge[] }} ElkGraph + */ + const LAYOUT_PRESETS = { query: { 'elk.algorithm': 'layered', @@ -29,7 +39,7 @@ const DEFAULT_PRESET = LAYOUT_PRESETS.query; * Returns ELK layout options for a given visualisation type. * * @param {'query'|'path'|'slice'} type - * @returns {Object} ELK layout options + * @returns {Record} ELK layout options */ export function getDefaultLayoutOptions(type) { return LAYOUT_PRESETS[type] ?? DEFAULT_PRESET; @@ -38,6 +48,8 @@ export function getDefaultLayoutOptions(type) { /** * Estimates pixel width for a node label. * Approximates monospace glyph width at ~9px with 24px padding. + * @param {string | undefined} label + * @returns {number} */ function estimateNodeWidth(label) { const charWidth = 9; @@ -51,9 +63,9 @@ const NODE_HEIGHT = 30; /** * Converts normalised graph data to an ELK graph JSON object. * - * @param {{ nodes: Array, edges: Array }} graphData - * @param {{ type?: string, layoutOptions?: Object }} [options] - * @returns {Object} ELK-format graph + * @param {GraphData} graphData + * @param {{ type?: 'query'|'path'|'slice', layoutOptions?: Record }} [options] + * @returns {ElkGraph} ELK-format graph */ export function toElkGraph(graphData, options = {}) { const { type = 'query', layoutOptions } = options; @@ -66,6 +78,7 @@ export function toElkGraph(graphData, options = {}) { })); const edges = (graphData.edges ?? []).map((e, i) => { + /** @type {ElkEdge} */ const edge = { id: `e${i}`, sources: [e.from], diff --git a/src/visualization/layouts/elkLayout.js b/src/visualization/layouts/elkLayout.js index b884d690..f78037b1 100644 --- a/src/visualization/layouts/elkLayout.js +++ b/src/visualization/layouts/elkLayout.js @@ -5,11 +5,22 @@ * a layout is actually requested, keeping normal CLI startup fast. */ +/** + * @typedef {{ id: string, x?: number, y?: number, width?: number, height?: number, labels?: Array<{ text: string }> }} ElkResultChild + * @typedef {{ id: string, sources?: string[], targets?: string[], labels?: Array<{ text: string }>, sections?: any[] }} ElkResultEdge + * @typedef {{ children?: ElkResultChild[], edges?: ElkResultEdge[], width?: number, height?: number }} ElkResult + * @typedef {{ id: string, x: number, y: number, width: number, height: number, label: string }} PosNode + * @typedef {{ id: string, source: string, target: string, label?: string, sections: any[] }} PosEdge + * @typedef {{ nodes: PosNode[], edges: PosEdge[], width: number, height: number }} PositionedGraph + * @typedef {{ id: string, children?: Array<{ id: string, width?: number, height?: number, labels?: Array<{ text: string }> }>, edges?: Array<{ id: string, sources?: string[], targets?: string[], labels?: Array<{ text: string }> }>, layoutOptions?: Record }} ElkGraphInput + */ + +/** @type {Promise | null} */ let elkPromise = null; /** * Returns (or creates) a singleton ELK instance. - * @returns {Promise} ELK instance + * @returns {Promise} ELK instance */ function getElk() { if (!elkPromise) { @@ -21,10 +32,11 @@ function getElk() { /** * Runs ELK layout on a graph and returns a PositionedGraph. * - * @param {Object} elkGraph - ELK-format graph from toElkGraph() - * @returns {Promise} PositionedGraph + * @param {ElkGraphInput} elkGraph - ELK-format graph from toElkGraph() + * @returns {Promise} PositionedGraph */ export async function runLayout(elkGraph) { + /** @type {ElkResult | undefined} */ let result; try { const elk = await getElk(); @@ -37,9 +49,11 @@ export async function runLayout(elkGraph) { /** * Converts ELK output to a PositionedGraph. + * @param {ElkResult | undefined} result + * @returns {PositionedGraph} */ function toPositionedGraph(result) { - const nodes = (result.children ?? []).map((c) => ({ + const nodes = (result?.children ?? []).map((c) => ({ id: c.id, x: c.x ?? 0, y: c.y ?? 0, @@ -48,7 +62,7 @@ function toPositionedGraph(result) { label: c.labels?.[0]?.text ?? c.id, })); - const edges = (result.edges ?? []).map((e) => ({ + const edges = (result?.edges ?? []).map((e) => ({ id: e.id, source: e.sources?.[0] ?? '', target: e.targets?.[0] ?? '', @@ -59,13 +73,15 @@ function toPositionedGraph(result) { return { nodes, edges, - width: result.width ?? 0, - height: result.height ?? 0, + width: result?.width ?? 0, + height: result?.height ?? 0, }; } /** * Fallback: line nodes up horizontally when ELK fails. + * @param {ElkGraphInput} elkGraph + * @returns {PositionedGraph} */ function fallbackLayout(elkGraph) { let x = 20; diff --git a/src/visualization/layouts/index.js b/src/visualization/layouts/index.js index 2af679fb..8265fddb 100644 --- a/src/visualization/layouts/index.js +++ b/src/visualization/layouts/index.js @@ -19,9 +19,9 @@ import { runLayout } from './elkLayout.js'; /** * Full pipeline: graphData → PositionedGraph. * - * @param {{ nodes: Array, edges: Array }} graphData - Normalised graph data - * @param {{ type?: string, layoutOptions?: Object }} [options] - * @returns {Promise} PositionedGraph + * @param {{ nodes: Array<{ id: string, label: string }>, edges: Array<{ from: string, to: string, label?: string }> }} graphData - Normalised graph data + * @param {{ type?: 'query'|'path'|'slice', layoutOptions?: Record }} [options] + * @returns {Promise<{ nodes: any[], edges: any[], width: number, height: number }>} PositionedGraph */ export async function layoutGraph(graphData, options = {}) { const elkGraph = toElkGraph(graphData, options); diff --git a/src/visualization/renderers/ascii/check.js b/src/visualization/renderers/ascii/check.js index 41f4c9b2..1147a0bf 100644 --- a/src/visualization/renderers/ascii/check.js +++ b/src/visualization/renderers/ascii/check.js @@ -12,6 +12,18 @@ import { colors } from './colors.js'; import { padRight } from '../../utils/unicode.js'; import { formatAge } from './formatters.js'; +/** + * @typedef {{ cachedState?: string, tombstoneRatio?: number, patchesSinceCheckpoint?: number }} CheckStatus + * @typedef {{ writerId?: string, sha?: string }} WriterHead + * @typedef {{ sha?: string, ageSeconds?: number | null }} CheckpointInfo + * @typedef {{ installed?: boolean, foreign?: boolean, current?: boolean, version?: string }} HookInfo + * @typedef {{ sha?: string, missingWriters?: string[] }} CoverageInfo + * @typedef {{ status?: string }} HealthInfo + * @typedef {{ tombstoneRatio?: number }} GCInfo + * @typedef {{ heads?: WriterHead[] }} WritersInfo + * @typedef {{ graph: string, health: HealthInfo, status: CheckStatus, writers: WritersInfo, checkpoint: CheckpointInfo, coverage: CoverageInfo, gc: GCInfo, hook: HookInfo | null }} CheckPayload + */ + // Health thresholds const TOMBSTONE_HEALTHY_MAX = 0.15; // < 15% tombstones = healthy const TOMBSTONE_WARNING_MAX = 0.30; // < 30% tombstones = warning @@ -19,7 +31,7 @@ const CACHE_STALE_PENALTY = 20; // Reduce "freshness" score for stale ca /** * Get cache freshness percentage and state. - * @param {Object} status - The status object from check payload + * @param {CheckStatus | null} status - The status object from check payload * @returns {{ percent: number, label: string }} */ function getCacheFreshness(status) { @@ -78,7 +90,7 @@ function tombstoneBar(percent, width = 20) { /** * Format writer information for display. - * @param {Object[]} heads - Writer heads array + * @param {WriterHead[] | undefined} heads - Writer heads array * @returns {string} */ function formatWriters(heads) { @@ -94,7 +106,7 @@ function formatWriters(heads) { /** * Format checkpoint status line. - * @param {Object} checkpoint - Checkpoint info + * @param {CheckpointInfo | null} checkpoint - Checkpoint info * @returns {string} */ function formatCheckpoint(checkpoint) { @@ -103,13 +115,14 @@ function formatCheckpoint(checkpoint) { } const sha = colors.muted(checkpoint.sha.slice(0, 7)); - const age = formatAge(checkpoint.ageSeconds); + const ageSeconds = checkpoint.ageSeconds ?? null; + const age = formatAge(ageSeconds); // Add checkmark for recent checkpoints (< 5 min), warning for older let status; - if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 300) { + if (ageSeconds !== null && ageSeconds < 300) { status = colors.success('\u2713'); - } else if (checkpoint.ageSeconds !== null && checkpoint.ageSeconds < 3600) { + } else if (ageSeconds !== null && ageSeconds < 3600) { status = colors.warning('\u2713'); } else { status = colors.muted('\u2713'); @@ -120,7 +133,7 @@ function formatCheckpoint(checkpoint) { /** * Format hook status line. - * @param {Object|null} hook - Hook status + * @param {HookInfo|null} hook - Hook status * @returns {string} */ function formatHook(hook) { @@ -145,7 +158,7 @@ function formatHook(hook) { /** * Format coverage status line. - * @param {Object} coverage - Coverage info + * @param {CoverageInfo | null} coverage - Coverage info * @returns {string} */ function formatCoverage(coverage) { @@ -164,7 +177,7 @@ function formatCoverage(coverage) { /** * Get overall health status with color and symbol. - * @param {Object} health - Health object + * @param {HealthInfo | null} health - Health object * @returns {{ text: string, symbol: string, color: Function }} */ function getOverallHealth(health) { @@ -190,8 +203,8 @@ function getOverallHealth(health) { /** * Build the state section lines (cache, tombstones, patches). - * @param {Object} status - Status object - * @param {Object} gc - GC metrics + * @param {CheckStatus | null} status - Status object + * @param {GCInfo | null} gc - GC metrics * @returns {string[]} */ function buildStateLines(status, gc) { @@ -215,10 +228,10 @@ function buildStateLines(status, gc) { /** * Build the metadata section lines (writers, checkpoint, coverage, hooks). * @param {Object} opts - Metadata options - * @param {Object} opts.writers - Writers info - * @param {Object} opts.checkpoint - Checkpoint info - * @param {Object} opts.coverage - Coverage info - * @param {Object} opts.hook - Hook status + * @param {WritersInfo} opts.writers - Writers info + * @param {CheckpointInfo} opts.checkpoint - Checkpoint info + * @param {CoverageInfo} opts.coverage - Coverage info + * @param {HookInfo | null} opts.hook - Hook status * @returns {string[]} */ function buildMetadataLines({ writers, checkpoint, coverage, hook }) { @@ -232,7 +245,7 @@ function buildMetadataLines({ writers, checkpoint, coverage, hook }) { /** * Determine border color based on health status. - * @param {Object} overall - Overall health info + * @param {{ text: string, symbol: string, color: Function }} overall - Overall health info * @returns {string} */ function getBorderColor(overall) { @@ -243,7 +256,7 @@ function getBorderColor(overall) { /** * Render the check view dashboard. - * @param {Object} payload - The check command payload + * @param {CheckPayload} payload - The check command payload * @returns {string} Formatted dashboard string */ export function renderCheckView(payload) { diff --git a/src/visualization/renderers/ascii/graph.js b/src/visualization/renderers/ascii/graph.js index 2a599271..caee6e6b 100644 --- a/src/visualization/renderers/ascii/graph.js +++ b/src/visualization/renderers/ascii/graph.js @@ -11,6 +11,14 @@ import { createBox } from './box.js'; import { colors } from './colors.js'; import { ARROW } from './symbols.js'; +/** + * @typedef {{ x: number, y: number }} Point + * @typedef {{ startPoint?: Point, endPoint?: Point, bendPoints?: Point[] }} Section + * @typedef {{ id: string, x: number, y: number, width: number, height: number, label?: string }} PositionedNode + * @typedef {{ id: string, source: string, target: string, label?: string, sections?: Section[] }} PositionedEdge + * @typedef {{ nodes: PositionedNode[], edges: PositionedEdge[], width: number, height: number }} PositionedGraph + */ + // ── Scaling constants ──────────────────────────────────────────────────────── const CELL_W = 10; @@ -30,23 +38,33 @@ const BOX = { // ── Grid helpers ───────────────────────────────────────────────────────────── +/** @param {number} px */ function toCol(px) { return Math.round(px / CELL_W) + MARGIN; } +/** @param {number} px */ function toRow(px) { return Math.round(px / CELL_H) + MARGIN; } +/** @param {number} px */ function scaleW(px) { return Math.round(px / CELL_W); } +/** @param {number} px */ function scaleH(px) { return Math.round(px / CELL_H); } +/** + * @param {number} rows + * @param {number} cols + * @returns {string[][]} + */ function createGrid(rows, cols) { + /** @type {string[][]} */ const grid = []; for (let r = 0; r < rows; r++) { grid.push(new Array(cols).fill(' ')); @@ -54,12 +72,24 @@ function createGrid(rows, cols) { return grid; } +/** + * @param {string[][]} grid + * @param {number} r + * @param {number} c + * @param {string} ch + */ function writeChar(grid, r, c, ch) { if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) { grid[r][c] = ch; } } +/** + * @param {string[][]} grid + * @param {number} r + * @param {number} c + * @returns {string} + */ function readChar(grid, r, c) { if (r >= 0 && r < grid.length && c >= 0 && c < grid[0].length) { return grid[r][c]; @@ -67,6 +97,12 @@ function readChar(grid, r, c) { return ' '; } +/** + * @param {string[][]} grid + * @param {number} r + * @param {number} c + * @param {string} str + */ function writeString(grid, r, c, str) { for (let i = 0; i < str.length; i++) { writeChar(grid, r, c + i, str[i]); @@ -75,6 +111,10 @@ function writeString(grid, r, c, str) { // ── Node stamping ──────────────────────────────────────────────────────────── +/** + * @param {string[][]} grid + * @param {PositionedNode} node + */ function stampNode(grid, node) { const r = toRow(node.y); const c = toCol(node.x); @@ -112,6 +152,11 @@ function stampNode(grid, node) { // ── Edge tracing ───────────────────────────────────────────────────────────── +/** + * @param {string[][]} grid + * @param {PositionedEdge} edge + * @param {Set} nodeSet + */ function traceEdge(grid, edge, nodeSet) { const { sections } = edge; if (!sections || sections.length === 0) { @@ -136,6 +181,7 @@ function traceEdge(grid, edge, nodeSet) { } } +/** @param {Section} section @returns {Point[]} */ function buildPointList(section) { const points = []; if (section.startPoint) { @@ -150,6 +196,11 @@ function buildPointList(section) { return points; } +/** + * @param {string[][]} grid + * @param {Point[]} points + * @param {Set} nodeSet + */ function drawSegments(grid, points, nodeSet) { for (let i = 0; i < points.length - 1; i++) { const r1 = toRow(points[i].y); @@ -160,6 +211,14 @@ function drawSegments(grid, points, nodeSet) { } } +/** + * @param {string[][]} grid + * @param {number} r1 + * @param {number} c1 + * @param {number} r2 + * @param {number} c2 + * @param {Set} nodeSet + */ function drawLine(grid, r1, c1, r2, c2, nodeSet) { if (r1 === r2) { drawHorizontal(grid, r1, c1, c2, nodeSet); @@ -172,6 +231,13 @@ function drawLine(grid, r1, c1, r2, c2, nodeSet) { } } +/** + * @param {string[][]} grid + * @param {number} row + * @param {number} c1 + * @param {number} c2 + * @param {Set} nodeSet + */ function drawHorizontal(grid, row, c1, c2, nodeSet) { const start = Math.min(c1, c2); const end = Math.max(c1, c2); @@ -187,6 +253,13 @@ function drawHorizontal(grid, row, c1, c2, nodeSet) { } } +/** + * @param {string[][]} grid + * @param {number} col + * @param {number} r1 + * @param {number} r2 + * @param {Set} nodeSet + */ function drawVertical(grid, col, r1, r2, nodeSet) { const start = Math.min(r1, r2); const end = Math.max(r1, r2); @@ -202,6 +275,11 @@ function drawVertical(grid, col, r1, r2, nodeSet) { } } +/** + * @param {string[][]} grid + * @param {Section} section + * @param {Set} nodeSet + */ function drawArrowhead(grid, section, nodeSet) { const ep = section.endPoint; if (!ep) { @@ -250,6 +328,12 @@ function drawArrowhead(grid, section, nodeSet) { } } +/** + * @param {string[][]} grid + * @param {Section[]} sections + * @param {string} label + * @param {Set} nodeSet + */ function placeEdgeLabel(grid, sections, label, nodeSet) { // Find midpoint of the full path const allPoints = []; @@ -292,6 +376,7 @@ function placeEdgeLabel(grid, sections, label, nodeSet) { // ── Node occupancy set ─────────────────────────────────────────────────────── +/** @param {PositionedNode[]} nodes @returns {Set} */ function buildNodeSet(nodes) { const set = new Set(); for (const node of nodes) { @@ -308,6 +393,12 @@ function buildNodeSet(nodes) { return set; } +/** + * @param {Set} nodeSet + * @param {number} r + * @param {number} c + * @returns {boolean} + */ function isNodeCell(nodeSet, r, c) { return nodeSet.has(`${r},${c}`); } @@ -317,7 +408,7 @@ function isNodeCell(nodeSet, r, c) { /** * Renders a PositionedGraph (from ELK) as an ASCII box-drawing string. * - * @param {Object} positionedGraph - PositionedGraph from runLayout() + * @param {PositionedGraph} positionedGraph - PositionedGraph from runLayout() * @param {{ title?: string }} [options] * @returns {string} Rendered ASCII art wrapped in a box */ diff --git a/src/visualization/renderers/ascii/history.js b/src/visualization/renderers/ascii/history.js index acf61ff8..5e4436a4 100644 --- a/src/visualization/renderers/ascii/history.js +++ b/src/visualization/renderers/ascii/history.js @@ -9,13 +9,17 @@ import { padRight, padLeft } from '../../utils/unicode.js'; import { TIMELINE } from './symbols.js'; import { OP_DISPLAY, EMPTY_OP_SUMMARY, summarizeOps, formatOpSummary } from './opSummary.js'; +/** + * @typedef {{ sha?: string, lamport?: number, writerId?: string, opSummary?: Record, ops?: Array<{ type: string }> }} PatchEntry + */ + // Default pagination settings const DEFAULT_PAGE_SIZE = 20; /** * Ensures entry has an opSummary, computing one if needed. - * @param {Object} entry - Patch entry - * @returns {Object} Operation summary + * @param {PatchEntry} entry - Patch entry + * @returns {Record} Operation summary */ function ensureOpSummary(entry) { if (entry.opSummary) { @@ -29,10 +33,10 @@ function ensureOpSummary(entry) { /** * Paginates entries, returning display entries and truncation info. - * @param {Object[]} entries - All entries + * @param {PatchEntry[]} entries - All entries * @param {number} pageSize - Page size * @param {boolean} showAll - Whether to show all - * @returns {{displayEntries: Object[], truncated: boolean, hiddenCount: number}} + * @returns {{displayEntries: PatchEntry[], truncated: boolean, hiddenCount: number}} */ function paginateEntries(entries, pageSize, showAll) { if (showAll || entries.length <= pageSize) { @@ -64,17 +68,22 @@ function renderTruncationIndicator(truncated, hiddenCount) { /** * Renders a single patch entry line. * @param {Object} params - Entry parameters + * @param {PatchEntry} params.entry - Patch entry + * @param {boolean} params.isLast - Whether this is the last entry + * @param {number} params.lamportWidth - Width for lamport timestamp padding + * @param {string} [params.writerStr] - Writer string + * @param {number} [params.maxWriterIdLen] - Max writer ID length for padding * @returns {string} Formatted entry line */ function renderEntryLine({ entry, isLast, lamportWidth, writerStr, maxWriterIdLen }) { const connector = isLast ? TIMELINE.end : TIMELINE.connector; const shortSha = (entry.sha || '').slice(0, 7); - const lamportStr = padLeft(String(entry.lamport), lamportWidth); + const lamportStr = padLeft(String(entry.lamport ?? 0), lamportWidth); const opSummary = ensureOpSummary(entry); const opSummaryStr = formatOpSummary(opSummary, writerStr ? 30 : 40); if (writerStr) { - const paddedWriter = padRight(writerStr, maxWriterIdLen); + const paddedWriter = padRight(writerStr, maxWriterIdLen ?? 6); return ` ${connector}${TIMELINE.dot} ${colors.muted(`L${lamportStr}`)} ${colors.primary(paddedWriter)}:${colors.muted(shortSha)} ${opSummaryStr}`; } return ` ${connector}${TIMELINE.dot} ${colors.muted(`L${lamportStr}`)} ${colors.primary(shortSha)} ${opSummaryStr}`; @@ -101,8 +110,8 @@ function renderSingleWriterFooter(totalCount) { /** * Renders single-writer timeline view. - * @param {Object} payload - History payload - * @param {Object} options - Rendering options + * @param {{ entries: PatchEntry[], writer: string }} payload - History payload + * @param {{ pageSize?: number, showAll?: boolean }} options - Rendering options * @returns {string[]} Lines for the timeline */ function renderSingleWriterTimeline(payload, options) { @@ -121,7 +130,7 @@ function renderSingleWriterTimeline(payload, options) { lines.push(colors.muted(' (no patches)')); return lines; } - const maxLamport = Math.max(...displayEntries.map((e) => e.lamport)); + const maxLamport = Math.max(...displayEntries.map((e) => e.lamport ?? 0)); const lamportWidth = String(maxLamport).length; lines.push(...renderTruncationIndicator(truncated, hiddenCount)); @@ -137,8 +146,8 @@ function renderSingleWriterTimeline(payload, options) { /** * Merges and sorts entries from all writers by lamport timestamp. - * @param {Object} writers - Map of writerId to entries - * @returns {Object[]} Sorted entries with writerId attached + * @param {Record} writers - Map of writerId to entries + * @returns {PatchEntry[]} Sorted entries with writerId attached */ function mergeWriterEntries(writers) { const allEntries = []; @@ -147,7 +156,7 @@ function mergeWriterEntries(writers) { allEntries.push({ ...entry, writerId }); } } - allEntries.sort((a, b) => a.lamport - b.lamport || a.writerId.localeCompare(b.writerId)); + allEntries.sort((a, b) => (a.lamport ?? 0) - (b.lamport ?? 0) || (a.writerId ?? '').localeCompare(b.writerId ?? '')); return allEntries; } @@ -178,8 +187,8 @@ function renderMultiWriterFooter(totalCount, writerCount) { /** * Renders multi-writer timeline view with parallel columns. - * @param {Object} payload - History payload with allWriters data - * @param {Object} options - Rendering options + * @param {{ writers: Record, graph: string }} payload - History payload with allWriters data + * @param {{ pageSize?: number, showAll?: boolean }} options - Rendering options * @returns {string[]} Lines for the timeline */ function renderMultiWriterTimeline(payload, options) { @@ -206,7 +215,7 @@ function renderMultiWriterTimeline(payload, options) { lines.push(colors.muted(' (no patches)')); return lines; } - const maxLamport = Math.max(...displayEntries.map((e) => e.lamport)); + const maxLamport = Math.max(...displayEntries.map((e) => e.lamport ?? 0)); const lamportWidth = String(maxLamport).length; const maxWriterIdLen = Math.max(...writerIds.map((id) => id.length), 6); @@ -230,15 +239,8 @@ function renderMultiWriterTimeline(payload, options) { /** * Renders the history view with ASCII timeline. - * @param {Object} payload - History payload from handleHistory - * @param {string} payload.graph - Graph name - * @param {string} [payload.writer] - Writer ID (single writer mode) - * @param {string|null} [payload.nodeFilter] - Node filter if applied - * @param {Object[]} [payload.entries] - Array of patch entries (single writer mode) - * @param {Object} [payload.writers] - Map of writerId to entries (multi-writer mode) - * @param {Object} [options] - Rendering options - * @param {number} [options.pageSize=20] - Number of patches to show per page - * @param {boolean} [options.showAll=false] - Show all patches (no pagination) + * @param {{ graph: string, writer?: string, nodeFilter?: string | null, entries?: PatchEntry[], writers?: Record }} payload - History payload from handleHistory + * @param {{ pageSize?: number, showAll?: boolean }} [options] - Rendering options * @returns {string} Formatted ASCII output */ export function renderHistoryView(payload, options = {}) { @@ -248,8 +250,8 @@ export function renderHistoryView(payload, options = {}) { const isMultiWriter = payload.writers && typeof payload.writers === 'object'; const contentLines = isMultiWriter - ? renderMultiWriterTimeline(payload, options) - : renderSingleWriterTimeline(payload, options); + ? renderMultiWriterTimeline(/** @type {{ writers: Record, graph: string }} */ (payload), options) + : renderSingleWriterTimeline(/** @type {{ entries: PatchEntry[], writer: string }} */ (payload), options); // Add node filter indicator if present if (payload.nodeFilter) { diff --git a/src/visualization/renderers/ascii/info.js b/src/visualization/renderers/ascii/info.js index 2b015d2a..104d278c 100644 --- a/src/visualization/renderers/ascii/info.js +++ b/src/visualization/renderers/ascii/info.js @@ -10,6 +10,10 @@ import { padRight } from '../../utils/unicode.js'; import { timeAgo } from '../../utils/time.js'; import { TIMELINE } from './symbols.js'; +/** + * @typedef {{ name: string, writers?: { count?: number, ids?: string[] }, checkpoint?: { sha?: string, date?: string | Date }, coverage?: { sha?: string }, writerPatches?: Record }} GraphInfo + */ + // Box drawing characters (info.js uses verbose key names for card rendering) const BOX = { topLeft: '\u250C', // ┌ @@ -77,7 +81,7 @@ function formatWriterNames(writerIds) { /** * Renders the header lines for a graph card. - * @param {Object} graph - Graph info object + * @param {GraphInfo} graph - Graph info object * @param {number} contentWidth - Available content width * @returns {string[]} Header lines */ @@ -102,7 +106,7 @@ function renderCardHeader(graph, contentWidth) { /** * Renders writer timeline lines for a graph card. - * @param {Object} writerPatches - Map of writerId to patch count + * @param {Record | undefined} writerPatches - Map of writerId to patch count * @param {number} contentWidth - Available content width * @returns {string[]} Timeline lines */ @@ -133,7 +137,7 @@ function renderWriterTimelines(writerPatches, contentWidth) { /** * Renders checkpoint and coverage lines for a graph card. - * @param {Object} graph - Graph info object + * @param {GraphInfo} graph - Graph info object * @param {number} contentWidth - Available content width * @returns {string[]} Status lines */ @@ -169,7 +173,7 @@ function renderCardStatus(graph, contentWidth) { /** * Renders a single graph card. - * @param {Object} graph - Graph info object + * @param {GraphInfo} graph - Graph info object * @param {number} innerWidth - Available width inside the card * @returns {string[]} Array of lines for this graph card */ @@ -186,9 +190,7 @@ function renderGraphCard(graph, innerWidth) { /** * Renders the info view with ASCII box art. - * @param {Object} data - Info payload from handleInfo - * @param {string} data.repo - Repository path - * @param {Object[]} data.graphs - Array of graph info objects + * @param {{ repo?: string, graphs: GraphInfo[] }} data - Info payload from handleInfo * @returns {string} Formatted ASCII output */ export function renderInfoView(data) { diff --git a/src/visualization/renderers/ascii/materialize.js b/src/visualization/renderers/ascii/materialize.js index e79ce9a8..69ba1482 100644 --- a/src/visualization/renderers/ascii/materialize.js +++ b/src/visualization/renderers/ascii/materialize.js @@ -12,6 +12,11 @@ import { padRight } from '../../utils/unicode.js'; import { truncate } from '../../utils/truncate.js'; import { formatNumber, formatSha } from './formatters.js'; +/** + * @typedef {{ graph: string, error?: string, noOp?: boolean, patchCount?: number, nodes?: number, edges?: number, properties?: number, writers?: Record, checkpoint?: string | null }} GraphResult + * @typedef {{ maxNodes: number, maxEdges: number, maxProps: number }} MaxValues + */ + // Bar chart settings const BAR_WIDTH = 20; const STAT_LABEL_WIDTH = 12; @@ -55,15 +60,15 @@ function renderErrorState(errorMessage) { /** * Render no-op state (already materialized). - * @param {Object} graph - Graph data + * @param {GraphResult} graph - Graph data * @returns {string[]} No-op state lines */ function renderNoOpState(graph) { const lines = [ ` ${colors.success('\u2713')} Already materialized (no new patches)`, '', - ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${formatNumber(graph.nodes)}`, - ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${formatNumber(graph.edges)}`, + ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${formatNumber(graph.nodes || 0)}`, + ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${formatNumber(graph.edges || 0)}`, ]; if (typeof graph.properties === 'number') { lines.push(` ${padRight('Properties:', STAT_LABEL_WIDTH)} ${formatNumber(graph.properties)}`); @@ -73,7 +78,7 @@ function renderNoOpState(graph) { /** * Render empty graph state (0 patches). - * @param {Object} graph - Graph data + * @param {GraphResult} graph - Graph data * @returns {string[]} Empty state lines */ function renderEmptyState(graph) { @@ -86,7 +91,7 @@ function renderEmptyState(graph) { /** * Render writer progress section. - * @param {Object} writers - Writer patch counts + * @param {Record | undefined} writers - Writer patch counts * @returns {string[]} Writer lines */ function renderWriterSection(writers) { @@ -108,15 +113,15 @@ function renderWriterSection(writers) { /** * Render statistics section with bar charts. - * @param {Object} graph - Graph data - * @param {Object} maxValues - Max values for scaling + * @param {GraphResult} graph - Graph data + * @param {MaxValues} maxValues - Max values for scaling * @returns {string[]} Statistics lines */ function renderStatsSection(graph, { maxNodes, maxEdges, maxProps }) { const lines = [ ` ${colors.dim('Statistics:')}`, - ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${statBar(graph.nodes, maxNodes)} ${formatNumber(graph.nodes)}`, - ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${statBar(graph.edges, maxEdges)} ${formatNumber(graph.edges)}`, + ` ${padRight('Nodes:', STAT_LABEL_WIDTH)} ${statBar(graph.nodes || 0, maxNodes)} ${formatNumber(graph.nodes || 0)}`, + ` ${padRight('Edges:', STAT_LABEL_WIDTH)} ${statBar(graph.edges || 0, maxEdges)} ${formatNumber(graph.edges || 0)}`, ]; if (typeof graph.properties === 'number') { lines.push(` ${padRight('Properties:', STAT_LABEL_WIDTH)} ${statBar(graph.properties, maxProps)} ${formatNumber(graph.properties)}`); @@ -139,8 +144,8 @@ function renderCheckpointInfo(checkpoint) { /** * Render a single graph's materialization result. - * @param {Object} graph - Graph result from materialize - * @param {Object} maxValues - Max values for scaling bars + * @param {GraphResult} graph - Graph result from materialize + * @param {MaxValues} maxValues - Max values for scaling bars * @returns {string[]} Array of lines for this graph */ function renderGraphResult(graph, maxValues) { @@ -158,14 +163,14 @@ function renderGraphResult(graph, maxValues) { lines.push(...renderWriterSection(graph.writers)); lines.push(...renderStatsSection(graph, maxValues)); - lines.push(...renderCheckpointInfo(graph.checkpoint)); + lines.push(...renderCheckpointInfo(graph.checkpoint ?? null)); return lines; } /** * Calculate max values for scaling bar charts. - * @param {Object[]} graphs - Array of graph results - * @returns {Object} Max values object + * @param {GraphResult[]} graphs - Array of graph results + * @returns {MaxValues} Max values object */ function calculateMaxValues(graphs) { const successfulGraphs = graphs.filter((g) => !g.error); @@ -212,8 +217,7 @@ function getBorderColor(successCount, errorCount) { /** * Render the materialize view dashboard. - * @param {Object} payload - The materialize command payload - * @param {Object[]} payload.graphs - Array of graph results + * @param {{ graphs: GraphResult[] }} payload - The materialize command payload * @returns {string} Formatted dashboard string */ export function renderMaterializeView(payload) { diff --git a/src/visualization/renderers/ascii/opSummary.js b/src/visualization/renderers/ascii/opSummary.js index 60b0c6b6..f7c6741a 100644 --- a/src/visualization/renderers/ascii/opSummary.js +++ b/src/visualization/renderers/ascii/opSummary.js @@ -8,6 +8,11 @@ import { colors } from './colors.js'; import { truncate } from '../../utils/truncate.js'; +/** + * @typedef {'NodeAdd' | 'EdgeAdd' | 'PropSet' | 'NodeTombstone' | 'EdgeTombstone' | 'BlobValue'} OpType + * @typedef {Record} OpSummary + */ + // Operation type to display info mapping export const OP_DISPLAY = Object.freeze({ NodeAdd: { symbol: '+', label: 'node', color: colors.success }, @@ -30,14 +35,16 @@ export const EMPTY_OP_SUMMARY = Object.freeze({ /** * Summarizes operations in a patch. - * @param {Object[]} ops - Array of patch operations - * @returns {Object} Summary with counts by operation type + * @param {Array<{ type: string }>} ops - Array of patch operations + * @returns {OpSummary} Summary with counts by operation type */ export function summarizeOps(ops) { + /** @type {OpSummary} */ const summary = { ...EMPTY_OP_SUMMARY }; for (const op of ops) { - if (op.type && summary[op.type] !== undefined) { - summary[op.type]++; + const t = /** @type {OpType} */ (op.type); + if (t && summary[t] !== undefined) { + summary[t]++; } } return summary; @@ -45,17 +52,18 @@ export function summarizeOps(ops) { /** * Formats operation summary as a colored string. - * @param {Object} summary - Operation counts by type + * @param {OpSummary | Record} summary - Operation counts by type * @param {number} maxWidth - Maximum width for the summary string * @returns {string} Formatted summary string */ export function formatOpSummary(summary, maxWidth = 40) { + /** @type {OpType[]} */ const order = ['NodeAdd', 'EdgeAdd', 'PropSet', 'NodeTombstone', 'EdgeTombstone', 'BlobValue']; const parts = order - .filter((opType) => summary[opType] > 0) + .filter((opType) => (/** @type {Record} */ (summary))[opType] > 0) .map((opType) => { const display = OP_DISPLAY[opType]; - return { text: `${display.symbol}${summary[opType]}${display.label}`, color: display.color }; + return { text: `${display.symbol}${(/** @type {Record} */ (summary))[opType]}${display.label}`, color: display.color }; }); if (parts.length === 0) { diff --git a/src/visualization/renderers/ascii/path.js b/src/visualization/renderers/ascii/path.js index 95535f1a..b5064784 100644 --- a/src/visualization/renderers/ascii/path.js +++ b/src/visualization/renderers/ascii/path.js @@ -75,7 +75,7 @@ function createPathSegment({ nodeId, index, pathLength, edges }) { * Builds path segments that fit within the terminal width. * Wraps long paths to multiple lines. * @param {string[]} path - Array of node IDs - * @param {string[]} [edges] - Optional array of edge labels (one fewer than nodes) + * @param {string[] | undefined} edges - Optional array of edge labels (one fewer than nodes) * @param {number} maxWidth - Maximum line width * @returns {string[]} Array of line strings */ diff --git a/src/visualization/renderers/ascii/seek.js b/src/visualization/renderers/ascii/seek.js index a1fda984..e84f336a 100644 --- a/src/visualization/renderers/ascii/seek.js +++ b/src/visualization/renderers/ascii/seek.js @@ -15,6 +15,11 @@ import { formatSha, formatWriterName } from './formatters.js'; import { TIMELINE } from './symbols.js'; import { formatOpSummary } from './opSummary.js'; +/** + * @typedef {{ ticks: number[], tipSha?: string, tickShas?: Record }} WriterInfo + * @typedef {{ graph: string, tick: number, maxTick: number, ticks: number[], nodes: number, edges: number, patchCount: number, perWriter: Map | Record, diff?: { nodes?: number, edges?: number }, tickReceipt?: Record }} SeekPayload + */ + /** Maximum number of tick columns shown in the windowed view. */ const MAX_COLS = 9; @@ -30,6 +35,7 @@ const DOT_MID = '\u00B7'; // · /** Open circle used for excluded-zone patch markers. */ const CIRCLE_OPEN = '\u25CB'; // ○ +/** @param {number} n @returns {string} */ function formatDelta(n) { if (typeof n !== 'number' || !Number.isFinite(n) || n === 0) { return ''; @@ -38,10 +44,17 @@ function formatDelta(n) { return ` (${sign}${n})`; } +/** + * @param {number} n + * @param {string} singular + * @param {string} plural + * @returns {string} + */ function pluralize(n, singular, plural) { return n === 1 ? singular : plural; } +/** @param {Record | undefined} tickReceipt @returns {string[]} */ function buildReceiptLines(tickReceipt) { if (!tickReceipt || typeof tickReceipt !== 'object') { return []; @@ -200,7 +213,7 @@ function buildLane(patchSet, points, currentTick) { * * @param {Object} opts * @param {string} opts.writerId - * @param {Object} opts.writerInfo - `{ ticks, tipSha, tickShas }` + * @param {WriterInfo} opts.writerInfo - `{ ticks, tipSha, tickShas }` * @param {{ points: number[] }} opts.win - Computed window * @param {number} opts.currentTick - Active seek cursor tick * @returns {string} Formatted, indented swimlane line @@ -255,7 +268,7 @@ function buildTickPoints(ticks, tick) { /** * Builds the body lines for the seek dashboard. * - * @param {Object} payload - Seek payload from the CLI handler + * @param {SeekPayload} payload - Seek payload from the CLI handler * @returns {string[]} Lines for the box body */ function buildSeekBodyLines(payload) { @@ -277,6 +290,7 @@ function buildSeekBodyLines(payload) { lines.push(buildHeaderRow(win)); // Per-writer swimlanes + /** @type {Array<[string, WriterInfo]>} */ const writerEntries = perWriter instanceof Map ? [...perWriter.entries()] : Object.entries(perWriter).map(([k, v]) => [k, v]); @@ -291,8 +305,8 @@ function buildSeekBodyLines(payload) { const nodeLabel = pluralize(nodes, 'node', 'nodes'); const patchLabel = pluralize(patchCount, 'patch', 'patches'); - const nodesStr = `${nodes} ${nodeLabel}${formatDelta(diff?.nodes)}`; - const edgesStr = `${edges} ${edgeLabel}${formatDelta(diff?.edges)}`; + const nodesStr = `${nodes} ${nodeLabel}${formatDelta(diff?.nodes ?? 0)}`; + const edgesStr = `${edges} ${edgeLabel}${formatDelta(diff?.edges ?? 0)}`; lines.push(` ${colors.bold('State:')} ${nodesStr}, ${edgesStr}, ${patchCount} ${patchLabel}`); const receiptLines = buildReceiptLines(tickReceipt); @@ -313,7 +327,7 @@ function buildSeekBodyLines(payload) { /** * Renders the seek view dashboard inside a double-bordered box. * - * @param {Object} payload - Seek payload from the CLI handler + * @param {SeekPayload} payload - Seek payload from the CLI handler * @returns {string} Boxen-wrapped ASCII dashboard with trailing newline */ export function renderSeekView(payload) { diff --git a/src/visualization/renderers/ascii/table.js b/src/visualization/renderers/ascii/table.js index 3d9a49dc..d3189953 100644 --- a/src/visualization/renderers/ascii/table.js +++ b/src/visualization/renderers/ascii/table.js @@ -6,7 +6,7 @@ import Table from 'cli-table3'; * @param {Object} [options] - Options forwarded to cli-table3 constructor * @param {string[]} [options.head] - Column headers * @param {Object} [options.style] - Style overrides (defaults: head=cyan, border=gray) - * @returns {import('cli-table3')} A cli-table3 instance + * @returns {InstanceType} A cli-table3 instance */ export function createTable(options = {}) { const defaultStyle = { head: ['cyan'], border: ['gray'] }; diff --git a/src/visualization/renderers/svg/index.js b/src/visualization/renderers/svg/index.js index 1724a4e4..fc47791e 100644 --- a/src/visualization/renderers/svg/index.js +++ b/src/visualization/renderers/svg/index.js @@ -16,6 +16,7 @@ const PALETTE = { arrowFill: '#a6adc8', }; +/** @param {string} str @returns {string} */ function escapeXml(str) { return String(str) .replace(/&/g, '&') @@ -46,6 +47,7 @@ function renderStyle() { ].join('\n'); } +/** @param {{ id: string, x: number, y: number, width: number, height: number, label?: string }} node @returns {string} */ function renderNode(node) { const { x, y, width, height } = node; const label = escapeXml(node.label ?? node.id); @@ -59,6 +61,7 @@ function renderNode(node) { ].join('\n'); } +/** @param {{ startPoint?: { x: number, y: number }, bendPoints?: Array<{ x: number, y: number }>, endPoint?: { x: number, y: number } }} section @returns {Array<{ x: number, y: number }>} */ function sectionToPoints(section) { const pts = []; if (section.startPoint) { @@ -73,6 +76,7 @@ function sectionToPoints(section) { return pts; } +/** @param {{ sections?: Array<{ startPoint?: { x: number, y: number }, bendPoints?: Array<{ x: number, y: number }>, endPoint?: { x: number, y: number } }>, label?: string }} edge @returns {string} */ function renderEdge(edge) { const { sections } = edge; if (!sections || sections.length === 0) { @@ -115,7 +119,7 @@ function renderEdge(edge) { /** * Renders a PositionedGraph as an SVG string. * - * @param {Object} positionedGraph - PositionedGraph from runLayout() + * @param {{ nodes?: Array<{ id: string, x: number, y: number, width: number, height: number, label?: string }>, edges?: Array<{ sections?: Array, label?: string }>, width?: number, height?: number }} positionedGraph - PositionedGraph from runLayout() * @param {{ title?: string }} [options] * @returns {string} Complete SVG markup */ diff --git a/test/bats/helpers/seed-setup.js b/test/bats/helpers/seed-setup.js index ef84aa62..7079ffe0 100644 --- a/test/bats/helpers/seed-setup.js +++ b/test/bats/helpers/seed-setup.js @@ -6,6 +6,7 @@ */ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +// @ts-expect-error - no declaration file for @git-stunts/plumbing import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing'; const projectRoot = process.env.PROJECT_ROOT || resolve(import.meta.dirname, '../../..'); diff --git a/test/bats/helpers/setup.bash b/test/bats/helpers/setup.bash index 17ecd735..1f6739e3 100644 --- a/test/bats/helpers/setup.bash +++ b/test/bats/helpers/setup.bash @@ -4,6 +4,8 @@ # Sets up a fresh temporary git repo and PROJECT_ROOT. # Usage: call setup_test_repo in your setup() function. setup_test_repo() { + _BATS_T0=$(date +%s) + echo "STARTING TEST: ${BATS_TEST_DESCRIPTION}" >&3 PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" export PROJECT_ROOT export TEST_REPO @@ -23,6 +25,8 @@ setup_test_repo() { # Usage: call teardown_test_repo in your teardown() function. teardown_test_repo() { rm -rf "${TEST_REPO}" + local elapsed=$(( $(date +%s) - _BATS_T0 )) + echo "ENDED TEST: ${BATS_TEST_DESCRIPTION} took ${elapsed}s" >&3 } # Assert that the last command succeeded (exit code 0). diff --git a/test/bats/warp-graph-cli.bats b/test/bats/warp-graph-cli.bats index 4915fab3..8cc4a603 100644 --- a/test/bats/warp-graph-cli.bats +++ b/test/bats/warp-graph-cli.bats @@ -1,6 +1,8 @@ #!/usr/bin/env bats setup() { + _BATS_T0=$(date +%s) + echo "STARTING TEST: ${BATS_TEST_DESCRIPTION}" >&3 PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" export PROJECT_ROOT export TEST_REPO @@ -58,6 +60,8 @@ EOF teardown() { rm -rf "${TEST_REPO}" + local elapsed=$(( $(date +%s) - _BATS_T0 )) + echo "ENDED TEST: ${BATS_TEST_DESCRIPTION} took ${elapsed}s" >&3 } assert_success() { diff --git a/test/benchmark/Compaction.benchmark.js b/test/benchmark/Compaction.benchmark.js index 0c5f5012..dcb5ee62 100644 --- a/test/benchmark/Compaction.benchmark.js +++ b/test/benchmark/Compaction.benchmark.js @@ -48,7 +48,7 @@ const SOFT_TARGETS = { * * @param {number} entryCount - Number of elements to add * @param {number} tombstoneRatio - Ratio of entries to tombstone (0-1) - * @returns {{set: ORSet, vv: VersionVector, tombstoneCount: number, liveCount: number}} + * @returns {{set: any, vv: any, tombstoneCount: number, liveCount: number}} */ function createPopulatedORSet(entryCount, tombstoneRatio = TOMBSTONE_RATIO) { const set = createORSet(); @@ -99,6 +99,7 @@ function createPopulatedORSet(entryCount, tombstoneRatio = TOMBSTONE_RATIO) { /** * Deep clones an ORSet for isolated benchmarking */ +/** @param {any} set */ function cloneORSet(set) { const clone = createORSet(); @@ -116,6 +117,7 @@ function cloneORSet(set) { /** * Calculates approximate memory size of an ORSet */ +/** @param {any} set */ function estimateORSetMemory(set) { let size = 0; @@ -157,6 +159,7 @@ describe('ORSet Compaction Benchmarks', () => { const memBefore = estimateORSetMemory(templateSet); // Run benchmark - clone set each time since compaction mutates + /** @type {any} */ let compactedSet; const stats = await runBenchmark(() => { compactedSet = cloneORSet(templateSet); diff --git a/test/benchmark/ReducerV5.benchmark.js b/test/benchmark/ReducerV5.benchmark.js index 2ae6770a..7a9d0984 100644 --- a/test/benchmark/ReducerV5.benchmark.js +++ b/test/benchmark/ReducerV5.benchmark.js @@ -9,13 +9,20 @@ */ import { describe, it, expect } from 'vitest'; -import { reduceV5, createEmptyStateV5 } from '../../src/domain/services/JoinReducer.js'; +import { reduceV5 as _reduceV5, createEmptyStateV5 } from '../../src/domain/services/JoinReducer.js'; import { - createPatchV2, - createNodeAddV2, - createEdgeAddV2, + createPatchV2 as _createPatchV2, + createNodeAddV2 as _createNodeAddV2, + createEdgeAddV2 as _createEdgeAddV2, createPropSetV2, } from '../../src/domain/types/WarpTypesV2.js'; + +/** @type {any} */ const createPatchV2 = _createPatchV2; +/** @type {any} */ const createNodeAddV2 = _createNodeAddV2; +/** @type {any} */ const createEdgeAddV2 = _createEdgeAddV2; + +/** @type {any} */ +const reduceV5 = _reduceV5; import { createInlineValue } from '../../src/domain/types/WarpTypes.js'; import { createDot, encodeDot } from '../../src/domain/crdt/Dot.js'; import { createVersionVector, vvIncrement } from '../../src/domain/crdt/VersionVector.js'; @@ -52,8 +59,8 @@ const SOFT_TARGETS = { * Uses multiple writers, proper dot tracking, and varied operations. * * @param {number} patchCount - Number of patches to generate - * @param {Object} options - Generation options - * @returns {Array<{patch: Object, sha: string}>} + * @param {any} [options] - Generation options + * @returns {any[]} */ function generateV5Patches(patchCount, options = {}) { const { @@ -188,6 +195,7 @@ describe('WARP V5 Reducer Performance Benchmarks', () => { const memBefore = process.memoryUsage().heapUsed; // Run benchmark (real clock for informational logging only) + /** @type {any} */ let state; const stats = await runBenchmark(() => { state = reduceV5(patches); @@ -225,23 +233,27 @@ describe('WARP V5 Reducer Performance Benchmarks', () => { // Test clock: advances by patch count so assertions are deterministic const clock = new TestClock(); + /** @param {any} patches @param {any} [base] */ function timedReduce(patches, base) { clock.advance(patches.length); return reduceV5(patches, base); } // Full reduce + /** @type {any} */ let stateFull; const fullStats = await runBenchmark(() => { stateFull = timedReduce(allPatches); }, WARMUP_RUNS, MEASURED_RUNS, { clock }); // Incremental: build checkpoint, then apply new patches + /** @type {any} */ let checkpointState; await runBenchmark(() => { checkpointState = timedReduce(checkpointPatches); }, WARMUP_RUNS, MEASURED_RUNS, { clock }); + /** @type {any} */ let incrementalState; const incrementalStats = await runBenchmark(() => { incrementalState = timedReduce(newPatches, checkpointState); diff --git a/test/benchmark/benchmarkUtils.js b/test/benchmark/benchmarkUtils.js index 4a220d50..c6817e45 100644 --- a/test/benchmark/benchmarkUtils.js +++ b/test/benchmark/benchmarkUtils.js @@ -24,7 +24,7 @@ export class TestClock { now() { return this._time; } - advance(ms) { + advance(/** @type {number} */ ms) { this._time += ms; } } @@ -99,7 +99,7 @@ export function randomHex(length = 8) { * @param {number} [measuredRuns=5] - Number of measured runs * @param {Object} [options] * @param {{now(): number}} [options.clock] - Injectable clock (defaults to performance) - * @returns {{median: number, min: number, max: number, times: number[]}} + * @returns {Promise<{median: number, min: number, max: number, times: number[]}>} */ export async function runBenchmark(fn, warmupRuns = 2, measuredRuns = 5, { clock } = {}) { const clk = clock || performance; diff --git a/test/helpers/warpGraphTestUtils.js b/test/helpers/warpGraphTestUtils.js index de489fd6..33b18738 100644 --- a/test/helpers/warpGraphTestUtils.js +++ b/test/helpers/warpGraphTestUtils.js @@ -9,6 +9,7 @@ import { vi } from 'vitest'; import { mkdtemp, rm } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; +// @ts-expect-error - no declaration file for @git-stunts/plumbing import Plumbing from '@git-stunts/plumbing'; import GitGraphAdapter from '../../src/infrastructure/adapters/GitGraphAdapter.js'; import { encode } from '../../src/infrastructure/codecs/CborCodec.js'; @@ -99,7 +100,7 @@ export function generateOidFromNumber(n) { * Creates a basic mock persistence adapter with all methods stubbed. * Use this when you need fine-grained control over mock behavior. * - * @returns {Object} Mock persistence adapter with vi.fn() methods + * @returns {any} Mock persistence adapter with vi.fn() methods * * @example * const persistence = createMockPersistence(); @@ -130,9 +131,9 @@ export function createMockPersistence() { * Creates a mock persistence adapter pre-populated with commits. * Useful for WormholeService and other tests that need a commit chain. * - * @param {Array<{index: number, patch: Object, parentIndex: number|null, writerId: string, lamport: number}>} commits - Commits to populate + * @param {Array<{index: number, patch: any, parentIndex: number|null, writerId: string, lamport: number}>} commits - Commits to populate * @param {string} [graphName='test-graph'] - The graph name for validation - * @returns {{persistence: Object, getSha: (index: number) => string}} Mock persistence adapter and SHA lookup + * @returns {{persistence: any, getSha: (index: number) => string}} Mock persistence adapter and SHA lookup * * @example * const commits = [ @@ -205,7 +206,7 @@ export function createPopulatedMockPersistence(commits, graphName = 'test-graph' configSet: vi.fn().mockResolvedValue(undefined), }; - const getSha = (index) => shaMap.get(index); + const getSha = (/** @type {any} */ index) => shaMap.get(index); return { persistence, getSha }; } @@ -223,12 +224,12 @@ export function createPopulatedMockPersistence(commits, graphName = 'test-graph' * @param {string} options.graphName - Graph name * @param {string} options.writerId - Writer ID * @param {number} options.lamport - Lamport timestamp - * @param {Array} options.ops - Patch operations - * @param {Array} [options.reads] - Read declarations - * @param {Array} [options.writes] - Write declarations + * @param {any[]} options.ops - Patch operations + * @param {any[]} [options.reads] - Read declarations + * @param {any[]} [options.writes] - Write declarations * @param {string|null} [options.parentSha] - Parent commit SHA * @param {function} oidGenerator - OID generator function (e.g., from createOidGenerator().next) - * @returns {Object} Mock patch with sha, patchOid, patchBuffer, message, patch, nodeInfo + * @returns {any} Mock patch with sha, patchOid, patchBuffer, message, patch, nodeInfo * * @example * const oidGen = createOidGenerator(); @@ -248,6 +249,7 @@ export function createMockPatchWithIO( ) { const patchOid = oidGenerator(); const context = { [writerId]: lamport }; + /** @type {any} */ const patch = { schema: 2, writer: writerId, @@ -296,9 +298,9 @@ export function createMockPatchWithIO( * @param {string} options.graphName - Graph name * @param {string} options.writerId - Writer ID * @param {number} options.lamport - Lamport timestamp - * @param {Array} [options.ops=[]] - Patch operations + * @param {any[]} [options.ops=[]] - Patch operations * @param {string|null} [options.parentSha=null] - Parent commit SHA - * @returns {Object} Mock patch with sha, patchOid, patchBuffer, message, patch, nodeInfo + * @returns {any} Mock patch with sha, patchOid, patchBuffer, message, patch, nodeInfo * * @example * const patch = createMockPatch({ @@ -359,8 +361,8 @@ export function createMockPatch({ * Creates a NodeAdd operation for V2 patches. * * @param {string} node - Node ID - * @param {Object} dot - Dot from createDot() - * @returns {Object} NodeAdd operation + * @param {any} dot - Dot from createDot() + * @returns {any} NodeAdd operation * * @example * createNodeAddV2('user:alice', createDot('alice', 1)) @@ -372,8 +374,8 @@ export function createNodeAddV2(node, dot) { /** * Creates a NodeRemove operation for V2 patches. * - * @param {Array} observedDots - Array of observed dots to remove - * @returns {Object} NodeRemove operation + * @param {any[]} observedDots - Array of observed dots to remove + * @returns {any} NodeRemove operation * * @example * createNodeRemoveV2([createDot('alice', 1)]) @@ -386,8 +388,8 @@ export function createNodeRemoveV2(observedDots) { * Creates a NodeTombstone operation for V2 patches. * * @param {string} node - Node ID - * @param {Array} observedDots - Array of observed dots - * @returns {Object} NodeTombstone operation + * @param {any[]} observedDots - Array of observed dots + * @returns {any} NodeTombstone operation * * @example * createNodeTombstoneV2('user:alice', [createDot('alice', 1)]) @@ -402,8 +404,8 @@ export function createNodeTombstoneV2(node, observedDots) { * @param {string} from - Source node ID * @param {string} to - Target node ID * @param {string} label - Edge label - * @param {Object} dot - Dot from createDot() - * @returns {Object} EdgeAdd operation + * @param {any} dot - Dot from createDot() + * @returns {any} EdgeAdd operation * * @example * createEdgeAddV2('user:alice', 'user:bob', 'follows', createDot('alice', 1)) @@ -418,8 +420,8 @@ export function createEdgeAddV2(from, to, label, dot) { * @param {string} from - Source node ID * @param {string} to - Target node ID * @param {string} label - Edge label - * @param {Array} observedDots - Array of observed dots - * @returns {Object} EdgeTombstone operation + * @param {any[]} observedDots - Array of observed dots + * @returns {any} EdgeTombstone operation * * @example * createEdgeTombstoneV2('user:alice', 'user:bob', 'follows', [createDot('alice', 1)]) @@ -434,7 +436,7 @@ export function createEdgeTombstoneV2(from, to, label, observedDots) { * @param {string} node - Node ID * @param {string} key - Property key * @param {*} value - Property value (use createInlineValue for typed values) - * @returns {Object} PropSet operation + * @returns {any} PropSet operation * * @example * createPropSetV2('user:alice', 'name', createInlineValue('Alice')) @@ -453,9 +455,9 @@ export function createPropSetV2(node, key, value) { * @param {Object} options - Patch options * @param {string} options.writer - Writer ID * @param {number} options.lamport - Lamport timestamp - * @param {Array} options.ops - Array of operations + * @param {any[]} options.ops - Array of operations * @param {Object} [options.context] - Version vector context (defaults to empty) - * @returns {Object} Complete V2 patch object + * @returns {any} Complete V2 patch object * * @example * createPatchV2({ @@ -478,7 +480,7 @@ export function createPatchV2({ writer, lamport, ops, context }) { * Creates a standard set of sample patches for testing. * Includes three patches: patchA (node-a), patchB (node-b), patchC (edge + property). * - * @returns {Object} Object with patchA, patchB, patchC properties + * @returns {any} Object with patchA, patchB, patchC properties * * @example * const { patchA, patchB, patchC } = createSamplePatches(); @@ -523,7 +525,7 @@ export function createSamplePatches() { /** * Creates a mock logger with all methods stubbed. * - * @returns {Object} Mock logger with vi.fn() methods + * @returns {any} Mock logger with vi.fn() methods * * @example * const logger = createMockLogger(); @@ -548,7 +550,7 @@ export function createMockLogger() { * Each call to now() advances by `step` milliseconds. * * @param {number} [step=42] - Milliseconds to advance per now() call - * @returns {Object} Mock clock with now() and timestamp() methods + * @returns {any} Mock clock with now() and timestamp() methods * * @example * const clock = createMockClock(10); @@ -580,7 +582,7 @@ export function createMockClock(step = 42) { * Call cleanup() when done to remove the temp directory. * * @param {string} [label='test'] - Label for the temp directory prefix - * @returns {Promise<{tempDir: string, plumbing: Object, persistence: Object, cleanup: () => Promise}>} + * @returns {Promise<{tempDir: string, plumbing: any, persistence: any, cleanup: () => Promise}>} * * @example * let repo; @@ -617,7 +619,7 @@ export async function createGitRepo(label = 'test') { /** * Adds a node to a V5 state's alive set. * - * @param {Object} state - WarpStateV5 + * @param {any} state - WarpStateV5 * @param {string} nodeId - Node ID * @param {number} counter - Dot counter * @param {string} [writerId='w1'] - Writer ID for the dot @@ -629,7 +631,7 @@ export function addNodeToState(state, nodeId, counter, writerId = 'w1') { /** * Adds an edge to a V5 state's alive set. * - * @param {Object} state - WarpStateV5 + * @param {any} state - WarpStateV5 * @param {string} from - Source node ID * @param {string} to - Target node ID * @param {string} label - Edge label @@ -645,9 +647,9 @@ export function addEdgeToState(state, from, to, label, counter, writerId = 'w1') * Seeds a graph instance with a fresh empty state and applies a seed function. * Patches the graph's internal state and materialize method for testing. * - * @param {Object} graph - WarpGraph instance + * @param {any} graph - WarpGraph instance * @param {Function} seedFn - Function that receives the state and populates it - * @returns {Object} The seeded WarpStateV5 + * @returns {any} The seeded WarpStateV5 * * @example * setupGraphState(graph, (state) => { diff --git a/test/integration/WarpGraph.integration.test.js b/test/integration/WarpGraph.integration.test.js index dbede1bb..d897f9a1 100644 --- a/test/integration/WarpGraph.integration.test.js +++ b/test/integration/WarpGraph.integration.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm } from 'fs/promises'; import { join } from 'path'; import { tmpdir } from 'os'; +// @ts-expect-error - no declaration file for @git-stunts/plumbing import Plumbing from '@git-stunts/plumbing'; import GitGraphAdapter from '../../src/infrastructure/adapters/GitGraphAdapter.js'; import WarpGraph from '../../src/domain/WarpGraph.js'; @@ -10,8 +11,11 @@ import { encodeEdgeKey } from '../../src/domain/services/JoinReducer.js'; import NodeCryptoAdapter from '../../src/infrastructure/adapters/NodeCryptoAdapter.js'; describe('WarpGraph Integration', () => { + /** @type {any} */ let tempDir; + /** @type {any} */ let plumbing; + /** @type {any} */ let persistence; beforeEach(async () => { @@ -51,6 +55,7 @@ describe('WarpGraph Integration', () => { .commit(); // Materialize and verify + /** @type {any} */ const state = await graph.materialize(); expect(nodeVisibleV5(state, 'user:alice')).toBe(true); @@ -79,8 +84,9 @@ describe('WarpGraph Integration', () => { .removeNode('temp') .commit(); - const state = await graph.materialize(); - expect(nodeVisibleV5(state, 'temp')).toBe(false); + /** @type {any} */ + const state2 = await graph.materialize(); + expect(nodeVisibleV5(state2, 'temp')).toBe(false); }); }); @@ -109,6 +115,7 @@ describe('WarpGraph Integration', () => { .commit(); // Either writer can materialize the combined state + /** @type {any} */ const state = await alice.materialize(); expect(nodeVisibleV5(state, 'node:a')).toBe(true); @@ -156,6 +163,7 @@ describe('WarpGraph Integration', () => { await (await graph.createPatch()).addNode('n3').commit(); // Materialize from checkpoint should include all nodes + /** @type {any} */ const state = await graph.materializeAt(checkpointSha); expect(nodeVisibleV5(state, 'n1')).toBe(true); expect(nodeVisibleV5(state, 'n2')).toBe(true); @@ -177,6 +185,7 @@ describe('WarpGraph Integration', () => { .commit(); const crypto = new NodeCryptoAdapter(); + /** @type {any} */ const state1 = await graph1.materialize(); const hash1 = await computeStateHashV5(state1, { crypto }); @@ -192,6 +201,7 @@ describe('WarpGraph Integration', () => { .setProperty('x', 'v', 42) .commit(); + /** @type {any} */ const state2 = await graph2.materialize(); const hash2 = await computeStateHashV5(state2, { crypto }); diff --git a/test/integration/api/checkpoint.test.js b/test/integration/api/checkpoint.test.js index 264754c8..b874c71c 100644 --- a/test/integration/api/checkpoint.test.js +++ b/test/integration/api/checkpoint.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Checkpoint', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/edge-cases.test.js b/test/integration/api/edge-cases.test.js index 692f1efb..6ed74702 100644 --- a/test/integration/api/edge-cases.test.js +++ b/test/integration/api/edge-cases.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Edge Cases', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/fork.test.js b/test/integration/api/fork.test.js index 6d4f28ed..50518c2e 100644 --- a/test/integration/api/fork.test.js +++ b/test/integration/api/fork.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Fork', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/helpers/setup.js b/test/integration/api/helpers/setup.js index ec206cde..32607869 100644 --- a/test/integration/api/helpers/setup.js +++ b/test/integration/api/helpers/setup.js @@ -7,6 +7,7 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +// @ts-expect-error - no declaration file for @git-stunts/plumbing import Plumbing from '@git-stunts/plumbing'; import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAdapter.js'; import WarpGraph from '../../../../src/domain/WarpGraph.js'; diff --git a/test/integration/api/lifecycle.test.js b/test/integration/api/lifecycle.test.js index 56efd376..c492233c 100644 --- a/test/integration/api/lifecycle.test.js +++ b/test/integration/api/lifecycle.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Lifecycle', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/multiwriter.test.js b/test/integration/api/multiwriter.test.js index 67346432..58a7e0c9 100644 --- a/test/integration/api/multiwriter.test.js +++ b/test/integration/api/multiwriter.test.js @@ -3,6 +3,7 @@ import { createTestRepo } from './helpers/setup.js'; import { computeStateHashV5 } from '../../../src/domain/services/StateSerializerV5.js'; describe('API: Multi-Writer', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/querybuilder.test.js b/test/integration/api/querybuilder.test.js index bd5ffcd5..52336c7a 100644 --- a/test/integration/api/querybuilder.test.js +++ b/test/integration/api/querybuilder.test.js @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: QueryBuilder', () => { + /** @type {any} */ let repo; + /** @type {any} */ let graph; beforeEach(async () => { @@ -39,7 +41,7 @@ describe('API: QueryBuilder', () => { it('match glob returns matching nodes', async () => { const result = await graph.query().match('user:*').select(['id']).run(); - const ids = result.nodes.map((n) => n.id); + const ids = result.nodes.map((/** @type {any} */ n) => n.id); expect(ids).toContain('user:alice'); expect(ids).toContain('user:bob'); expect(ids).toContain('user:carol'); @@ -53,7 +55,7 @@ describe('API: QueryBuilder', () => { .where({ role: 'engineering' }) .select(['id']) .run(); - const ids = result.nodes.map((n) => n.id); + const ids = result.nodes.map((/** @type {any} */ n) => n.id); expect(ids).toContain('user:alice'); expect(ids).toContain('user:bob'); expect(ids).not.toContain('user:carol'); @@ -66,7 +68,7 @@ describe('API: QueryBuilder', () => { .outgoing('manages') .select(['id']) .run(); - const ids = result.nodes.map((n) => n.id); + const ids = result.nodes.map((/** @type {any} */ n) => n.id); expect(ids).toEqual(['user:bob']); }); @@ -77,7 +79,7 @@ describe('API: QueryBuilder', () => { .incoming('manages') .select(['id']) .run(); - const ids = result.nodes.map((n) => n.id); + const ids = result.nodes.map((/** @type {any} */ n) => n.id); expect(ids).toEqual(['user:alice']); }); @@ -89,7 +91,7 @@ describe('API: QueryBuilder', () => { .outgoing('knows') .select(['id']) .run(); - const ids = result.nodes.map((n) => n.id); + const ids = result.nodes.map((/** @type {any} */ n) => n.id); expect(ids).toEqual(['user:carol']); }); diff --git a/test/integration/api/sync.test.js b/test/integration/api/sync.test.js index 56bf85f5..2587251e 100644 --- a/test/integration/api/sync.test.js +++ b/test/integration/api/sync.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Sync', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/tombstone-gc.test.js b/test/integration/api/tombstone-gc.test.js index 8eb40e58..483355ce 100644 --- a/test/integration/api/tombstone-gc.test.js +++ b/test/integration/api/tombstone-gc.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Tombstone & GC', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/integration/api/traversal.test.js b/test/integration/api/traversal.test.js index e4212fe3..96c98bc8 100644 --- a/test/integration/api/traversal.test.js +++ b/test/integration/api/traversal.test.js @@ -2,7 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Traversal', () => { + /** @type {any} */ let repo; + /** @type {any} */ let graph; beforeEach(async () => { diff --git a/test/integration/api/writer-discovery.test.js b/test/integration/api/writer-discovery.test.js index d0767015..b71ac393 100644 --- a/test/integration/api/writer-discovery.test.js +++ b/test/integration/api/writer-discovery.test.js @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { createTestRepo } from './helpers/setup.js'; describe('API: Writer Discovery', () => { + /** @type {any} */ let repo; beforeEach(async () => { diff --git a/test/unit/domain/WarpGraph.adjacencyCache.test.js b/test/unit/domain/WarpGraph.adjacencyCache.test.js index 3b391159..1731cd7c 100644 --- a/test/unit/domain/WarpGraph.adjacencyCache.test.js +++ b/test/unit/domain/WarpGraph.adjacencyCache.test.js @@ -7,11 +7,11 @@ import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAd const crypto = new NodeCryptoAdapter(); -function addNode(state, nodeId, counter) { +function addNode(/** @type {any} */ state, /** @type {any} */ nodeId, /** @type {any} */ counter) { orsetAdd(state.nodeAlive, nodeId, createDot('w1', counter)); } -function addEdge(state, from, to, label, counter) { +function addEdge(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ counter) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); } @@ -25,16 +25,18 @@ function createSeededState() { } describe('WarpGraph adjacency cache', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -53,7 +55,7 @@ describe('WarpGraph adjacency cache', () => { await graph._materializeGraph(); expect(buildSpy).toHaveBeenCalledTimes(1); - expect(graph._adjacencyCache.size).toBe(1); + expect(/** @type {any} */ (graph)._adjacencyCache.size).toBe(1); }); it('evicts adjacency entries when over cache cap', async () => { @@ -84,6 +86,6 @@ describe('WarpGraph adjacency cache', () => { await graph._materializeGraph(); expect(buildSpy).toHaveBeenCalledTimes(3); - expect(graph._adjacencyCache.size).toBe(1); + expect(/** @type {any} */ (graph)._adjacencyCache.size).toBe(1); }); }); diff --git a/test/unit/domain/WarpGraph.autoCheckpoint.test.js b/test/unit/domain/WarpGraph.autoCheckpoint.test.js index 9d1ef0a2..2542d32b 100644 --- a/test/unit/domain/WarpGraph.autoCheckpoint.test.js +++ b/test/unit/domain/WarpGraph.autoCheckpoint.test.js @@ -8,7 +8,7 @@ import { createMockPersistence } from '../../helpers/warpGraphTestUtils.js'; /** * Creates a minimal schema:2 patch object. */ -function createPatch(writer, lamport, nodeId) { +function createPatch(/** @type {any} */ writer, /** @type {any} */ lamport, /** @type {any} */ nodeId) { return { schema: 2, writer, @@ -21,7 +21,7 @@ function createPatch(writer, lamport, nodeId) { /** * A fake 40-char hex SHA for use in tests. */ -function fakeSha(label) { +function fakeSha(/** @type {any} */ label) { const hex = Buffer.from(String(label)).toString('hex'); return hex.padEnd(40, 'a').slice(0, 40); } @@ -32,14 +32,15 @@ function fakeSha(label) { * * Returns the tip SHA so it can be wired to readRef. */ -function buildPatchChain(persistence, writer, count) { +function buildPatchChain(/** @type {any} */ persistence, /** @type {any} */ writer, /** @type {any} */ count) { + /** @type {any[]} */ const shas = []; for (let i = 1; i <= count; i++) { shas.push(fakeSha(`${writer}${i}`)); } // getNodeInfo returns commit info (message + parents) - persistence.getNodeInfo.mockImplementation((querySha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ querySha) => { for (let j = 0; j < count; j++) { if (querySha === shas[j]) { const l = j + 1; @@ -59,7 +60,7 @@ function buildPatchChain(persistence, writer, count) { }); // readBlob returns CBOR for the patch - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { for (let j = 0; j < count; j++) { const l = j + 1; const po = fakeSha(`blob-${writer}-${l}`); @@ -79,8 +80,8 @@ function buildPatchChain(persistence, writer, count) { * Helper: wire persistence mocks so materialize() discovers the given * writer and walks its chain. No checkpoint is present. */ -function wirePersistenceForWriter(persistence, writer, tipSha) { - persistence.readRef.mockImplementation((ref) => { +function wirePersistenceForWriter(/** @type {any} */ persistence, /** @type {any} */ writer, /** @type {any} */ tipSha) { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/checkpoints/head') { return Promise.resolve(null); } @@ -95,6 +96,7 @@ function wirePersistenceForWriter(persistence, writer, tipSha) { } describe('AP/CKPT/3: auto-checkpoint in materialize() path', () => { + /** @type {any} */ let persistence; beforeEach(() => { @@ -186,7 +188,7 @@ describe('AP/CKPT/3: auto-checkpoint in materialize() path', () => { new Error('disk full') ); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // materialize returns a valid state despite checkpoint failure expect(state).toBeDefined(); @@ -208,7 +210,7 @@ describe('AP/CKPT/3: auto-checkpoint in materialize() path', () => { new Error('transient failure') ); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // All 3 nodes should be alive in the materialized state const nodeIds = [...state.nodeAlive.entries.keys()]; @@ -381,12 +383,12 @@ describe('AP/CKPT/3: auto-checkpoint in materialize() path', () => { }); } - vi.spyOn(graph, '_loadLatestCheckpoint').mockResolvedValue({ + vi.spyOn(graph, /** @type {any} */ ('_loadLatestCheckpoint')).mockResolvedValue({ schema: 2, state: checkpointState, frontier: {}, }); - vi.spyOn(graph, '_loadPatchesSince').mockResolvedValue(patches); + vi.spyOn(graph, /** @type {any} */ ('_loadPatchesSince')).mockResolvedValue(patches); const spy = vi .spyOn(graph, 'createCheckpoint') @@ -416,12 +418,12 @@ describe('AP/CKPT/3: auto-checkpoint in materialize() path', () => { }); } - vi.spyOn(graph, '_loadLatestCheckpoint').mockResolvedValue({ + vi.spyOn(graph, /** @type {any} */ ('_loadLatestCheckpoint')).mockResolvedValue({ schema: 2, state: checkpointState, frontier: {}, }); - vi.spyOn(graph, '_loadPatchesSince').mockResolvedValue(patches); + vi.spyOn(graph, /** @type {any} */ ('_loadPatchesSince')).mockResolvedValue(patches); const spy = vi .spyOn(graph, 'createCheckpoint') @@ -449,7 +451,7 @@ describe('AP/CKPT/3: auto-checkpoint in materialize() path', () => { vi.spyOn(graph, 'createCheckpoint').mockResolvedValue(fakeSha('ckpt')); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // Should return a WarpStateV5, not a SHA string expect(typeof state).toBe('object'); diff --git a/test/unit/domain/WarpGraph.autoGC.test.js b/test/unit/domain/WarpGraph.autoGC.test.js index 92230ae3..b25e1f72 100644 --- a/test/unit/domain/WarpGraph.autoGC.test.js +++ b/test/unit/domain/WarpGraph.autoGC.test.js @@ -23,7 +23,7 @@ function createHighTombstoneState() { // Add many nodes then tombstone them to create high tombstone ratio for (let i = 0; i < 100; i++) { const dot = `writer-1:${i + 1}`; - orsetAdd(state.nodeAlive, `node-${i}`, dot); + orsetAdd(state.nodeAlive, `node-${i}`, /** @type {any} */ (dot)); } // Remove them all (add tombstones for each dot) for (let i = 0; i < 100; i++) { @@ -36,6 +36,7 @@ function createHighTombstoneState() { } describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { + /** @type {any} */ let persistence; beforeEach(() => { @@ -62,8 +63,8 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { await graph.materialize(); // Now inject a high-tombstone state and re-materialize - graph._cachedState = createHighTombstoneState(); - graph._stateDirty = false; + /** @type {any} */ (graph)._cachedState = createHighTombstoneState(); + /** @type {any} */ (graph)._stateDirty = false; // Clear logger.info after materialize (which now logs timing) logger.info.mockClear(); @@ -71,7 +72,7 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { // Call materialize — since no writers exist, it'll reduce to empty state // but _maybeRunGC runs on the fresh state. Let's trigger it directly. // Better approach: test _maybeRunGC directly with injected state - graph._maybeRunGC(createHighTombstoneState()); + /** @type {any} */ (graph)._maybeRunGC(createHighTombstoneState()); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining('auto-GC is disabled'), @@ -97,7 +98,7 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { }); await graph.materialize(); - graph._maybeRunGC(createHighTombstoneState()); + /** @type {any} */ (graph)._maybeRunGC(createHighTombstoneState()); expect(logger.info).toHaveBeenCalledWith( 'Auto-GC completed', @@ -119,15 +120,15 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { }); // Set recent GC time so time-since-compaction doesn't trigger - graph._lastGCTime = Date.now(); - graph._patchesSinceGC = 0; + /** @type {any} */ (graph)._lastGCTime = Date.now(); + /** @type {any} */ (graph)._patchesSinceGC = 0; await graph.materialize(); logger.warn.mockClear(); logger.info.mockClear(); // Empty state → no tombstones → no GC needed - graph._maybeRunGC(createEmptyStateV5()); + /** @type {any} */ (graph)._maybeRunGC(createEmptyStateV5()); expect(logger.warn).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); @@ -155,7 +156,7 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { const badState = { nodeAlive: null, edgeAlive: null }; // Should not throw despite internal error - expect(() => graph._maybeRunGC(badState)).not.toThrow(); + expect(() => /** @type {any} */ (graph)._maybeRunGC(badState)).not.toThrow(); }); it('_lastGCTime and _patchesSinceGC reset after GC', async () => { @@ -173,14 +174,14 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { }, }); - graph._patchesSinceGC = 999; - graph._lastGCTime = 0; + /** @type {any} */ (graph)._patchesSinceGC = 999; + /** @type {any} */ (graph)._lastGCTime = 0; await graph.materialize(); - graph._maybeRunGC(createHighTombstoneState()); + /** @type {any} */ (graph)._maybeRunGC(createHighTombstoneState()); - expect(graph._patchesSinceGC).toBe(0); - expect(graph._lastGCTime).toBeGreaterThan(0); + expect(/** @type {any} */ (graph)._patchesSinceGC).toBe(0); + expect(/** @type {any} */ (graph)._lastGCTime).toBeGreaterThan(0); }); it('no logger provided → no crash', async () => { @@ -199,6 +200,6 @@ describe('WarpGraph auto-GC after materialize (GK/GC/1)', () => { await graph.materialize(); // No logger → should still work without crashing - expect(() => graph._maybeRunGC(createHighTombstoneState())).not.toThrow(); + expect(() => /** @type {any} */ (graph)._maybeRunGC(createHighTombstoneState())).not.toThrow(); }); }); diff --git a/test/unit/domain/WarpGraph.autoMaterialize.test.js b/test/unit/domain/WarpGraph.autoMaterialize.test.js index 333c9683..a179adf0 100644 --- a/test/unit/domain/WarpGraph.autoMaterialize.test.js +++ b/test/unit/domain/WarpGraph.autoMaterialize.test.js @@ -11,7 +11,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { autoMaterialize: true, }); - expect(graph._autoMaterialize).toBe(true); + expect(/** @type {any} */ (graph)._autoMaterialize).toBe(true); }); it('stores flag when opened with autoMaterialize: false', async () => { @@ -22,7 +22,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { autoMaterialize: false, }); - expect(graph._autoMaterialize).toBe(false); + expect(/** @type {any} */ (graph)._autoMaterialize).toBe(false); }); it('defaults to false when autoMaterialize is not provided', async () => { @@ -32,7 +32,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { writerId: 'writer-1', }); - expect(graph._autoMaterialize).toBe(false); + expect(/** @type {any} */ (graph)._autoMaterialize).toBe(false); }); it('defaults to false when autoMaterialize is explicitly undefined', async () => { @@ -43,7 +43,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { autoMaterialize: undefined, }); - expect(graph._autoMaterialize).toBe(false); + expect(/** @type {any} */ (graph)._autoMaterialize).toBe(false); }); it('rejects autoMaterialize: "yes" (string)', async () => { @@ -52,7 +52,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - autoMaterialize: 'yes', + autoMaterialize: /** @type {any} */ ('yes'), }), ).rejects.toThrow('autoMaterialize must be a boolean'); }); @@ -63,7 +63,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - autoMaterialize: 1, + autoMaterialize: /** @type {any} */ (1), }), ).rejects.toThrow('autoMaterialize must be a boolean'); }); @@ -74,7 +74,7 @@ describe('WarpGraph autoMaterialize option (AP/LAZY/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - autoMaterialize: null, + autoMaterialize: /** @type {any} */ (null), }), ).rejects.toThrow('autoMaterialize must be a boolean'); }); diff --git a/test/unit/domain/WarpGraph.cascadeDelete.test.js b/test/unit/domain/WarpGraph.cascadeDelete.test.js index d0aa59d8..c4212292 100644 --- a/test/unit/domain/WarpGraph.cascadeDelete.test.js +++ b/test/unit/domain/WarpGraph.cascadeDelete.test.js @@ -75,7 +75,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { // Cascade delete node A await (await graph.createPatch()).removeNode('A').commit(); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // Node A should be gone expect(await graph.hasNode('A')).toBe(false); @@ -117,7 +117,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { const ops = builder.ops; expect(ops).toHaveLength(1); expect(ops[0].type).toBe('NodeRemove'); - expect(ops[0].node).toBe('lonely'); + expect(/** @type {any} */ (ops[0]).node).toBe('lonely'); } finally { await repo.cleanup(); } diff --git a/test/unit/domain/WarpGraph.checkpointPolicy.test.js b/test/unit/domain/WarpGraph.checkpointPolicy.test.js index 8c376050..594bcf07 100644 --- a/test/unit/domain/WarpGraph.checkpointPolicy.test.js +++ b/test/unit/domain/WarpGraph.checkpointPolicy.test.js @@ -11,7 +11,7 @@ describe('WarpGraph checkpointPolicy (AP/CKPT/1)', () => { checkpointPolicy: { every: 500 }, }); - expect(graph._checkpointPolicy).toEqual({ every: 500 }); + expect(/** @type {any} */ (graph)._checkpointPolicy).toEqual({ every: 500 }); }); it('accepts minimum valid value { every: 1 }', async () => { @@ -22,7 +22,7 @@ describe('WarpGraph checkpointPolicy (AP/CKPT/1)', () => { checkpointPolicy: { every: 1 }, }); - expect(graph._checkpointPolicy).toEqual({ every: 1 }); + expect(/** @type {any} */ (graph)._checkpointPolicy).toEqual({ every: 1 }); }); it('defaults _checkpointPolicy to null when not provided', async () => { @@ -32,7 +32,7 @@ describe('WarpGraph checkpointPolicy (AP/CKPT/1)', () => { writerId: 'writer-1', }); - expect(graph._checkpointPolicy).toBeNull(); + expect(/** @type {any} */ (graph)._checkpointPolicy).toBeNull(); }); it('rejects every: 0', async () => { @@ -63,7 +63,7 @@ describe('WarpGraph checkpointPolicy (AP/CKPT/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - checkpointPolicy: { every: 'foo' }, + checkpointPolicy: { every: /** @type {any} */ ('foo') }, }) ).rejects.toThrow('checkpointPolicy.every must be a positive integer'); }); @@ -85,19 +85,19 @@ describe('WarpGraph checkpointPolicy (AP/CKPT/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - checkpointPolicy: 'auto', + checkpointPolicy: /** @type {any} */ ('auto'), }) ).rejects.toThrow('checkpointPolicy must be an object with { every: number }'); }); - it('treats checkpointPolicy: null as no policy', async () => { + it('treats checkpointPolicy: /** @type {any} */ (null) as no policy', async () => { const graph = await WarpGraph.open({ persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - checkpointPolicy: null, + checkpointPolicy: /** @type {any} */ (null), }); - expect(graph._checkpointPolicy).toBeNull(); + expect(/** @type {any} */ (graph)._checkpointPolicy).toBeNull(); }); }); diff --git a/test/unit/domain/WarpGraph.coverageGaps.test.js b/test/unit/domain/WarpGraph.coverageGaps.test.js new file mode 100644 index 00000000..3919556d --- /dev/null +++ b/test/unit/domain/WarpGraph.coverageGaps.test.js @@ -0,0 +1,732 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { encode } from '../../../src/infrastructure/codecs/CborCodec.js'; +import { encodePatchMessage } from '../../../src/domain/services/WarpMessageCodec.js'; +import { createEmptyStateV5 } from '../../../src/domain/services/JoinReducer.js'; +import { createORSet, orsetAdd } from '../../../src/domain/crdt/ORSet.js'; +import { createDot } from '../../../src/domain/crdt/Dot.js'; +import { createVersionVector } from '../../../src/domain/crdt/VersionVector.js'; +import NodeCryptoAdapter from '../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; + +const crypto = new NodeCryptoAdapter(); + +function createMockPersistence() { + return { + readRef: vi.fn(), + showNode: vi.fn(), + writeBlob: vi.fn(), + writeTree: vi.fn(), + readBlob: vi.fn(), + readTreeOids: vi.fn(), + commitNode: vi.fn(), + commitNodeWithTree: vi.fn(), + updateRef: vi.fn(), + listRefs: vi.fn().mockResolvedValue([]), + getNodeInfo: vi.fn(), + ping: vi.fn().mockResolvedValue({ ok: true, latencyMs: 1 }), + configGet: vi.fn().mockResolvedValue(null), + configSet: vi.fn().mockResolvedValue(undefined), + deleteRef: vi.fn(), + nodeExists: vi.fn().mockResolvedValue(true), + }; +} + +/** + * Helper: creates a mock patch commit for testing. + * @param {any} options + * @returns {any} + */ +function createMockPatch({ sha, graphName, writerId, lamport, patchOid, ops, parentSha = null, context = null }) { + const patch = { + schema: 2, + writer: writerId, + lamport, + context: context || { [writerId]: lamport }, + ops, + }; + const patchBuffer = encode(patch); + const message = encodePatchMessage({ + graph: graphName, + writer: writerId, + lamport, + patchOid, + schema: 2, + }); + + return { + sha, + patchOid, + patchBuffer, + message, + parentSha, + nodeInfo: { + sha, + message, + author: 'Test ', + date: new Date().toISOString(), + parents: parentSha ? [parentSha] : [], + }, + }; +} + +describe('WarpGraph coverage gaps', () => { + /** @type {any} */ + let persistence; + + beforeEach(() => { + persistence = createMockPersistence(); + }); + + // -------------------------------------------------------------------------- + // 1. seekCache getter + // -------------------------------------------------------------------------- + describe('get seekCache', () => { + it('returns null when no seek cache is set', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + expect(graph.seekCache).toBeNull(); + }); + + it('returns the seek cache passed at construction', async () => { + /** @type {any} */ + const mockCache = { get: vi.fn(), set: vi.fn(), delete: vi.fn() }; + const graph = await WarpGraph.open({ + 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 WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + expect(graph.seekCache).toBeNull(); + + /** @type {any} */ + const mockCache = { get: vi.fn(), set: vi.fn(), delete: vi.fn() }; + graph.setSeekCache(mockCache); + + expect(graph.seekCache).toBe(mockCache); + }); + + it('replaces an existing seek cache', async () => { + /** @type {any} */ + const cache1 = { get: vi.fn(), set: vi.fn(), delete: vi.fn() }; + /** @type {any} */ + const cache2 = { get: vi.fn(), set: vi.fn(), delete: vi.fn() }; + + const graph = await WarpGraph.open({ + 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 WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const otherState = createEmptyStateV5(); + + expect(() => graph.join(otherState)).toThrow('No cached state'); + }); + + it('throws when otherState is null', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + + expect(() => graph.join(/** @type {any} */ (null))).toThrow('Invalid state'); + }); + + it('throws when otherState is missing nodeAlive', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + + expect(() => graph.join(/** @type {any} */ ({ edgeAlive: createORSet() }))).toThrow('Invalid state'); + }); + + it('throws when otherState is missing edgeAlive', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + + expect(() => graph.join(/** @type {any} */ ({ nodeAlive: createORSet() }))).toThrow('Invalid state'); + }); + + it('merges two empty states and returns zero-change receipt', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + const otherState = createEmptyStateV5(); + + const { state, receipt } = graph.join(otherState); + + expect(state).toBeDefined(); + expect(receipt.nodesAdded).toBe(0); + expect(receipt.nodesRemoved).toBe(0); + expect(receipt.edgesAdded).toBe(0); + expect(receipt.edgesRemoved).toBe(0); + expect(receipt.propsChanged).toBe(0); + expect(receipt.frontierMerged).toBe(false); + }); + + it('merges a state with nodes into empty and reports additions', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + + const otherState = createEmptyStateV5(); + const dot = createDot('writer-2', 1); + orsetAdd(otherState.nodeAlive, 'user:alice', dot); + + const { receipt } = graph.join(otherState); + + expect(receipt.nodesAdded).toBe(1); + expect(receipt.nodesRemoved).toBe(0); + }); + }); + + // -------------------------------------------------------------------------- + // 4. maybeRunGC() + // -------------------------------------------------------------------------- + describe('maybeRunGC', () => { + it('returns ran: false when no cached state exists', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const result = graph.maybeRunGC(); + + expect(result).toEqual({ ran: false, result: null, reasons: [] }); + }); + + it('returns ran: false when GC policy thresholds are not met', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + // Set up a minimal cached state — empty state has no tombstones + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + + const result = graph.maybeRunGC(); + + expect(result.ran).toBe(false); + expect(result.result).toBeNull(); + expect(result.reasons).toEqual([]); + }); + + it('runs GC when tombstone ratio threshold is exceeded', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + gcPolicy: { + tombstoneRatioThreshold: 0.0, + entryCountThreshold: 0, + minPatchesSinceCompaction: 0, + maxTimeSinceCompaction: 0, + }, + }); + + // Set up state with a node that has a dot (so metrics show entries) + const state = createEmptyStateV5(); + const dot = createDot('writer-1', 1); + orsetAdd(state.nodeAlive, 'user:alice', dot); + /** @type {any} */ (graph)._cachedState = state; + + // Force high patchesSinceGC and time since GC to trigger thresholds + /** @type {any} */ (graph)._patchesSinceGC = 10000; + /** @type {any} */ (graph)._lastGCTime = 0; + + const result = graph.maybeRunGC(); + + expect(result.ran).toBe(true); + expect(result.result).toBeDefined(); + expect(result.reasons.length).toBeGreaterThan(0); + }); + }); + + // -------------------------------------------------------------------------- + // 5. getGCMetrics() + // -------------------------------------------------------------------------- + describe('getGCMetrics', () => { + it('returns null when no cached state exists', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + expect(graph.getGCMetrics()).toBeNull(); + }); + + it('returns metrics when cached state exists', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const state = createEmptyStateV5(); + const dot = createDot('writer-1', 1); + orsetAdd(state.nodeAlive, 'user:alice', dot); + /** @type {any} */ (graph)._cachedState = state; + + /** @type {any} */ + const metrics = graph.getGCMetrics(); + + expect(metrics).not.toBeNull(); + expect(metrics.nodeEntries).toBe(1); + expect(metrics.edgeEntries).toBe(0); + expect(metrics.totalEntries).toBe(1); + expect(metrics.tombstoneRatio).toBe(0); + }); + + it('includes patchesSinceCompaction and lastCompactionTime', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + /** @type {any} */ (graph)._patchesSinceGC = 42; + /** @type {any} */ (graph)._lastGCTime = 1234567890; + + /** @type {any} */ + const metrics = graph.getGCMetrics(); + + expect(metrics.patchesSinceCompaction).toBe(42); + expect(metrics.lastCompactionTime).toBe(1234567890); + }); + }); + + // -------------------------------------------------------------------------- + // 6. gcPolicy getter + // -------------------------------------------------------------------------- + describe('get gcPolicy', () => { + it('returns default GC policy when none provided', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ + const policy = graph.gcPolicy; + + expect(policy.enabled).toBe(false); + expect(policy.tombstoneRatioThreshold).toBe(0.3); + expect(policy.entryCountThreshold).toBe(50000); + expect(policy.minPatchesSinceCompaction).toBe(1000); + expect(policy.maxTimeSinceCompaction).toBe(86400000); + expect(policy.compactOnCheckpoint).toBe(true); + }); + + it('returns custom GC policy when provided', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + gcPolicy: { + enabled: true, + tombstoneRatioThreshold: 0.5, + }, + }); + + /** @type {any} */ + const policy = graph.gcPolicy; + + expect(policy.enabled).toBe(true); + expect(policy.tombstoneRatioThreshold).toBe(0.5); + // Other defaults remain + expect(policy.entryCountThreshold).toBe(50000); + }); + + it('returns a defensive copy (mutations do not affect the graph)', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ + const policy1 = graph.gcPolicy; + policy1.enabled = true; + policy1.tombstoneRatioThreshold = 0.99; + + /** @type {any} */ + const policy2 = graph.gcPolicy; + + expect(policy2.enabled).toBe(false); + expect(policy2.tombstoneRatioThreshold).toBe(0.3); + }); + }); + + // -------------------------------------------------------------------------- + // 7. syncNeeded() + // -------------------------------------------------------------------------- + describe('syncNeeded', () => { + it('returns false when local and remote frontiers match', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const writerSha = 'a'.repeat(40); + + // discoverWriters returns one writer + persistence.listRefs.mockResolvedValue(['refs/warp/test-graph/writers/writer-1']); + persistence.readRef.mockResolvedValue(writerSha); + + const remoteFrontier = new Map([['writer-1', writerSha]]); + const needed = await graph.syncNeeded(remoteFrontier); + + expect(needed).toBe(false); + }); + + it('returns true when remote has a writer not in local', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + // Local has no writers + persistence.listRefs.mockResolvedValue([]); + + const remoteFrontier = new Map([['writer-2', 'b'.repeat(40)]]); + const needed = await graph.syncNeeded(remoteFrontier); + + expect(needed).toBe(true); + }); + + it('returns true when local has a writer not in remote', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const writerSha = 'a'.repeat(40); + persistence.listRefs.mockResolvedValue(['refs/warp/test-graph/writers/writer-1']); + persistence.readRef.mockResolvedValue(writerSha); + + const remoteFrontier = new Map(); + const needed = await graph.syncNeeded(remoteFrontier); + + expect(needed).toBe(true); + }); + + it('returns true when same writer has different SHAs', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const localSha = 'a'.repeat(40); + const remoteSha = 'b'.repeat(40); + + persistence.listRefs.mockResolvedValue(['refs/warp/test-graph/writers/writer-1']); + persistence.readRef.mockResolvedValue(localSha); + + const remoteFrontier = new Map([['writer-1', remoteSha]]); + const needed = await graph.syncNeeded(remoteFrontier); + + expect(needed).toBe(true); + }); + }); + + // -------------------------------------------------------------------------- + // 8. getPropertyCount() + // -------------------------------------------------------------------------- + describe('getPropertyCount', () => { + it('throws E_NO_STATE when no cached state exists', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + await expect(graph.getPropertyCount()).rejects.toThrow('No cached state'); + }); + + it('returns 0 for empty state', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); + + const count = await graph.getPropertyCount(); + + expect(count).toBe(0); + }); + + it('returns correct count when properties exist', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const state = createEmptyStateV5(); + state.prop.set('user:alice\0name', { value: 'Alice', eventId: /** @type {any} */ ('writer-1:1') }); + state.prop.set('user:alice\0age', { value: 30, eventId: /** @type {any} */ ('writer-1:2') }); + state.prop.set('user:bob\0name', { value: 'Bob', eventId: /** @type {any} */ ('writer-1:3') }); + /** @type {any} */ (graph)._cachedState = state; + + const count = await graph.getPropertyCount(); + + expect(count).toBe(3); + }); + }); + + // -------------------------------------------------------------------------- + // 9. createWormhole() + // -------------------------------------------------------------------------- + describe('createWormhole', () => { + it('delegates to WormholeService with correct arguments', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const fromSha = 'a'.repeat(40); + const toSha = 'b'.repeat(40); + const patchOid = 'c'.repeat(40); + + // createWormhole walks the commit chain from toSha back to fromSha. + // We need to mock the commit chain for the WormholeService. + // The service calls persistence.getNodeInfo for each commit. + const mockPatch = createMockPatch({ + sha: toSha, + graphName: 'test-graph', + writerId: 'writer-1', + lamport: 2, + patchOid, + ops: [{ type: 'NodeAdd', node: 'user:alice', dot: 'writer-1:2' }], + parentSha: fromSha, + }); + + const rootPatch = createMockPatch({ + sha: fromSha, + graphName: 'test-graph', + writerId: 'writer-1', + lamport: 1, + patchOid: 'd'.repeat(40), + ops: [], + parentSha: null, + }); + + persistence.getNodeInfo + .mockResolvedValueOnce(mockPatch.nodeInfo) // toSha + .mockResolvedValueOnce(rootPatch.nodeInfo); // fromSha + + persistence.readBlob + .mockResolvedValueOnce(mockPatch.patchBuffer) + .mockResolvedValueOnce(rootPatch.patchBuffer); + + const wormhole = await graph.createWormhole(fromSha, toSha); + + expect(wormhole).toBeDefined(); + expect(wormhole.patchCount).toBeGreaterThanOrEqual(0); + }); + }); + + // -------------------------------------------------------------------------- + // 10. loadPatchBySha() + // -------------------------------------------------------------------------- + describe('loadPatchBySha', () => { + it('loads and decodes a patch by SHA', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const sha = 'a'.repeat(40); + const patchOid = 'b'.repeat(40); + + const mockPatch = createMockPatch({ + sha, + graphName: 'test-graph', + writerId: 'writer-1', + lamport: 1, + patchOid, + ops: [{ type: 'NodeAdd', node: 'user:alice', dot: 'writer-1:1' }], + }); + + persistence.getNodeInfo.mockResolvedValue(mockPatch.nodeInfo); + persistence.readBlob.mockResolvedValue(mockPatch.patchBuffer); + + const patch = /** @type {any} */ (await graph.loadPatchBySha(sha)); + + expect(patch).toBeDefined(); + expect(patch.schema).toBe(2); + expect(patch.writer).toBe('writer-1'); + expect(patch.lamport).toBe(1); + expect(patch.ops).toHaveLength(1); + expect(patch.ops[0].type).toBe('NodeAdd'); + expect(patch.ops[0].node).toBe('user:alice'); + }); + + it('throws when commit is not a patch', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const sha = 'a'.repeat(40); + + // Return a checkpoint message instead of a patch + persistence.getNodeInfo.mockResolvedValue({ + sha, + message: `warp:checkpoint\n\neg-kind: checkpoint\neg-graph: test-graph\neg-state-hash: ${'c'.repeat(64)}\neg-frontier-oid: ${'d'.repeat(40)}\neg-schema: 2`, + parents: [], + }); + + await expect(graph.loadPatchBySha(sha)).rejects.toThrow(`Commit ${sha} is not a patch`); + }); + + it('throws when getNodeInfo fails', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const sha = 'a'.repeat(40); + persistence.getNodeInfo.mockRejectedValue(new Error('not found')); + + await expect(graph.loadPatchBySha(sha)).rejects.toThrow('not found'); + }); + }); + + // -------------------------------------------------------------------------- + // 11. temporal getter + // -------------------------------------------------------------------------- + describe('get temporal', () => { + it('returns a TemporalQuery instance', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const temporal = graph.temporal; + + expect(temporal).toBeDefined(); + expect(typeof temporal.always).toBe('function'); + expect(typeof temporal.eventually).toBe('function'); + }); + + it('returns the same instance on subsequent accesses (lazy singleton)', async () => { + const graph = await WarpGraph.open({ + persistence, + graphName: 'test-graph', + writerId: 'writer-1', + crypto, + }); + + const temporal1 = graph.temporal; + const temporal2 = graph.temporal; + + expect(temporal1).toBe(temporal2); + }); + }); +}); diff --git a/test/unit/domain/WarpGraph.deleteGuard.test.js b/test/unit/domain/WarpGraph.deleteGuard.test.js index ed3dfc20..c1d0a5d1 100644 --- a/test/unit/domain/WarpGraph.deleteGuard.test.js +++ b/test/unit/domain/WarpGraph.deleteGuard.test.js @@ -52,7 +52,7 @@ describe('WarpGraph onDeleteWithData option', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - onDeleteWithData: 'invalid', + onDeleteWithData: /** @type {any} */ ('invalid'), }), ).rejects.toThrow('onDeleteWithData must be one of: reject, cascade, warn'); }); diff --git a/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js b/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js index 78a56b90..ed3d6542 100644 --- a/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js +++ b/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js @@ -3,6 +3,7 @@ import WarpGraph from '../../../src/domain/WarpGraph.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { + /** @type {any} */ let repo; afterEach(async () => { diff --git a/test/unit/domain/WarpGraph.edgePropVisibility.test.js b/test/unit/domain/WarpGraph.edgePropVisibility.test.js index ad8ce656..d0e50a56 100644 --- a/test/unit/domain/WarpGraph.edgePropVisibility.test.js +++ b/test/unit/domain/WarpGraph.edgePropVisibility.test.js @@ -13,20 +13,20 @@ import { createDot, encodeDot } from '../../../src/domain/crdt/Dot.js'; * Seeds a WarpGraph instance with a fresh empty V5 state and runs seedFn to populate it. * Replaces materialize with a no-op mock so tests exercise query methods directly. */ -function setupGraphState(graph, seedFn) { +function setupGraphState(/** @type {any} */ graph, /** @type {any} */ seedFn) { const state = createEmptyStateV5(); - graph._cachedState = state; + /** @type {any} */ (graph)._cachedState = state; graph.materialize = vi.fn().mockResolvedValue(state); seedFn(state); } /** Adds a node to the ORSet with a dot at the given counter. */ -function addNode(state, nodeId, writerId, counter) { +function addNode(/** @type {any} */ state, /** @type {any} */ nodeId, /** @type {any} */ writerId, /** @type {any} */ counter) { orsetAdd(state.nodeAlive, nodeId, createDot(writerId, counter)); } /** Adds an edge to the ORSet and records its birth event. */ -function addEdge(state, from, to, label, writerId, counter, lamport) { +function addEdge(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ writerId, /** @type {any} */ counter, /** @type {any} */ lamport) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot(writerId, counter)); // Record birth event using full EventId comparison (same as applyOpV2) @@ -38,13 +38,13 @@ function addEdge(state, from, to, label, writerId, counter, lamport) { } /** Removes an edge by tombstoning its observed dots. */ -function removeEdge(state, from, to, label, writerId, counter) { +function removeEdge(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ writerId, /** @type {any} */ counter) { const dot = encodeDot(createDot(writerId, counter)); orsetRemove(state.edgeAlive, new Set([dot])); } /** Sets an edge property with a proper LWW register (eventId + value). */ -function setEdgeProp(state, from, to, label, key, value, lamport, writerId, patchSha, opIndex) { +function setEdgeProp(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ key, /** @type {any} */ value, /** @type {any} */ lamport, /** @type {any} */ writerId, /** @type {any} */ patchSha = undefined, /** @type {any} */ opIndex = undefined) { const propKey = encodeEdgePropKey(from, to, label, key); state.prop.set(propKey, { eventId: { lamport, writerId, patchSha: patchSha || 'aabbccdd', opIndex: opIndex || 0 }, @@ -55,16 +55,18 @@ function setEdgeProp(state, from, to, label, key, value, lamport, writerId, patc // ============================================================================= describe('WarpGraph edge property visibility (WT/VIS/1)', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -79,7 +81,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // =========================================================================== it('add edge with props -> remove edge -> props invisible via getEdges()', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // Edge added at lamport 1, counter 3 @@ -96,7 +98,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { }); it('add edge with props -> remove edge -> getEdgeProps returns null', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); @@ -113,7 +115,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // =========================================================================== it('add edge with props -> remove edge -> re-add edge -> props are empty (clean slate)', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // First incarnation: add at lamport 1 @@ -137,7 +139,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { }); it('add edge with props -> remove -> re-add -> set new props -> new props visible', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // First incarnation at lamport 1 @@ -166,7 +168,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // =========================================================================== it('concurrent add and remove with props (two writers)', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // Writer 1 adds edge at lamport 1 @@ -188,7 +190,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { }); it('concurrent add+props from two writers, one removes, re-adds -> clean slate for old props', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // Writer 1 adds edge at lamport 1 @@ -213,7 +215,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // =========================================================================== it('edge without props -> remove -> re-add -> still no props', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // Add edge without props @@ -237,7 +239,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // =========================================================================== it('stale props remain in the prop map but are not surfaced', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); // Add edge, set prop, remove, re-add @@ -249,7 +251,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // The prop is still in the map (not physically deleted) const propKey = encodeEdgePropKey('a', 'b', 'rel', 'weight'); - expect(graph._cachedState.prop.has(propKey)).toBeTruthy(); + expect(/** @type {any} */ (graph)._cachedState.prop.has(propKey)).toBeTruthy(); // But it is not surfaced via getEdgeProps const props = await graph.getEdgeProps('a', 'b', 'rel'); @@ -261,7 +263,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { // =========================================================================== it('props on a live edge with matching lamport are visible', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); addEdge(state, 'a', 'b', 'rel', 'w1', 3, 5); @@ -277,7 +279,7 @@ describe('WarpGraph edge property visibility (WT/VIS/1)', () => { }); it('props on a live edge with higher lamport are visible', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'a', 'w1', 1); addNode(state, 'b', 'w1', 2); addEdge(state, 'a', 'b', 'rel', 'w1', 3, 1); diff --git a/test/unit/domain/WarpGraph.edgeProps.test.js b/test/unit/domain/WarpGraph.edgeProps.test.js index e7bffbff..c7763b46 100644 --- a/test/unit/domain/WarpGraph.edgeProps.test.js +++ b/test/unit/domain/WarpGraph.edgeProps.test.js @@ -4,39 +4,41 @@ import { createEmptyStateV5, encodeEdgeKey, encodeEdgePropKey } from '../../../s import { orsetAdd } from '../../../src/domain/crdt/ORSet.js'; import { createDot } from '../../../src/domain/crdt/Dot.js'; -function setupGraphState(graph, seedFn) { +function setupGraphState(/** @type {any} */ graph, /** @type {any} */ seedFn) { const state = createEmptyStateV5(); - graph._cachedState = state; + /** @type {any} */ (graph)._cachedState = state; graph.materialize = vi.fn().mockResolvedValue(state); seedFn(state); } -function addNode(state, nodeId, counter) { +function addNode(/** @type {any} */ state, /** @type {any} */ nodeId, /** @type {any} */ counter) { orsetAdd(state.nodeAlive, nodeId, createDot('w1', counter)); } -function addEdge(state, from, to, label, counter) { +function addEdge(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ counter) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); state.edgeBirthEvent.set(edgeKey, { lamport: 1, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }); } -function addEdgeProp(state, from, to, label, key, value) { +function addEdgeProp(/** @type {any} */ state, /** @type {any} */ from, /** @type {any} */ to, /** @type {any} */ label, /** @type {any} */ key, /** @type {any} */ value) { const propKey = encodeEdgePropKey(from, to, label, key); state.prop.set(propKey, { eventId: { lamport: 1, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, value }); } describe('WarpGraph edge properties', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -51,7 +53,7 @@ describe('WarpGraph edge properties', () => { // ============================================================================ it('getEdges returns edge props in props field', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -65,7 +67,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdges returns empty props for edge with no properties', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -78,7 +80,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdges returns multiple props on a single edge', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -92,7 +94,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdges assigns props to correct edges when multiple edges exist', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addNode(state, 'user:carol', 3); @@ -103,8 +105,8 @@ describe('WarpGraph edge properties', () => { }); const edges = await graph.getEdges(); - const followsEdge = edges.find((e) => e.label === 'follows'); - const managesEdge = edges.find((e) => e.label === 'manages'); + const followsEdge = edges.find((/** @type {any} */ e) => e.label === 'follows'); + const managesEdge = edges.find((/** @type {any} */ e) => e.label === 'manages'); expect(followsEdge.props).toEqual({ weight: 0.9 }); expect(managesEdge.props).toEqual({ since: '2024-06-15' }); @@ -115,7 +117,7 @@ describe('WarpGraph edge properties', () => { // ============================================================================ it('getEdgeProps returns correct props for an edge', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -127,7 +129,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdgeProps returns empty object for edge with no props', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -138,7 +140,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdgeProps returns null for non-existent edge', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); }); @@ -148,7 +150,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdgeProps returns multiple properties', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -162,7 +164,7 @@ describe('WarpGraph edge properties', () => { }); it('getEdgeProps does not leak props from other edges', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addNode(state, 'user:carol', 3); @@ -183,7 +185,7 @@ describe('WarpGraph edge properties', () => { try { await graph.getEdgeProps('user:alice', 'user:bob', 'follows'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.code).toBe('E_NO_STATE'); } }); @@ -193,7 +195,7 @@ describe('WarpGraph edge properties', () => { // ============================================================================ it('edge props do not appear in getNodeProps results', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -212,7 +214,7 @@ describe('WarpGraph edge properties', () => { // ============================================================================ it('query outgoing traversal works with edges that have props', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); @@ -224,7 +226,7 @@ describe('WarpGraph edge properties', () => { }); it('query incoming traversal works with edges that have props', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNode(state, 'user:alice', 1); addNode(state, 'user:bob', 2); addEdge(state, 'user:alice', 'user:bob', 'follows', 3); diff --git a/test/unit/domain/WarpGraph.errorCodes.test.js b/test/unit/domain/WarpGraph.errorCodes.test.js index ec7a6906..9315839e 100644 --- a/test/unit/domain/WarpGraph.errorCodes.test.js +++ b/test/unit/domain/WarpGraph.errorCodes.test.js @@ -22,7 +22,7 @@ const FAKE_COMMIT_SHA = 'c'.repeat(40); /** * Configure mock persistence so a first-time writer commit succeeds. */ -function mockFirstCommit(persistence) { +function mockFirstCommit(/** @type {any} */ persistence) { persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); @@ -31,7 +31,9 @@ function mockFirstCommit(persistence) { } describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -50,7 +52,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_NO_STATE'); } @@ -60,7 +62,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.getNodes(); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_NO_STATE'); } @@ -70,7 +72,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.getEdges(); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_NO_STATE'); } @@ -80,7 +82,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.getNodeProps('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_NO_STATE'); } @@ -90,7 +92,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.neighbors('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_NO_STATE'); } @@ -100,7 +102,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('materialize()'); } }); @@ -109,7 +111,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('autoMaterialize'); } }); @@ -118,7 +120,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toBe( 'No cached state. Call materialize() to load initial state, or pass autoMaterialize: true to WarpGraph.open().', ); @@ -140,12 +142,12 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () await (await graph.createPatch()).addNode('test:node').commit(); // Force dirty to simulate the stale-after-write scenario - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:node'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_STALE_STATE'); } @@ -153,12 +155,12 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () it('throws QueryError with code E_STALE_STATE when _stateDirty is manually set', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.getNodes(); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_STALE_STATE'); } @@ -166,36 +168,36 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () it('error message includes recovery hint mentioning materialize()', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('materialize()'); } }); it('error message includes recovery hint mentioning autoMaterialize', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('autoMaterialize'); } }); it('error message matches expected recovery hint text', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toBe( 'Cached state is stale. Call materialize() to refresh, or enable autoMaterialize.', ); @@ -211,7 +213,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); - } catch (e) { + } catch (/** @type {any} */ e) { if (e.code === 'E_NO_STATE') { matched = true; } @@ -222,13 +224,13 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () it('catch (e) { if (e.code === "E_STALE_STATE") } works for stale-state errors', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; let matched = false; try { await graph.getNodes(); - } catch (e) { + } catch (/** @type {any} */ e) { if (e.code === 'E_STALE_STATE') { matched = true; } @@ -243,17 +245,17 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () // Trigger E_NO_STATE try { await graph.hasNode('test:x'); - } catch (e) { + } catch (/** @type {any} */ e) { codes.push(e.code); } // Trigger E_STALE_STATE await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:x'); - } catch (e) { + } catch (/** @type {any} */ e) { codes.push(e.code); } @@ -264,7 +266,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (e) { + } catch (/** @type {any} */ e) { expect(typeof e.code).toBe('string'); expect(e.code).toBeTruthy(); } @@ -274,6 +276,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () // ── autoMaterialize prevents both errors ─────────────────────────────── describe('autoMaterialize: true prevents both E_NO_STATE and E_STALE_STATE', () => { + /** @type {any} */ let autoGraph; beforeEach(async () => { @@ -333,7 +336,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.name).toBe('QueryError'); expect(err.code).toBe('E_NO_STATE'); expect(typeof err.message).toBe('string'); @@ -343,12 +346,12 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () it('E_STALE_STATE error has name, code, message, and context properties', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.name).toBe('QueryError'); expect(err.code).toBe('E_STALE_STATE'); expect(typeof err.message).toBe('string'); @@ -360,7 +363,7 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.stack).toBeDefined(); expect(err.stack.length).toBeGreaterThan(0); } @@ -368,12 +371,12 @@ describe('HS/ERR/2: Error codes and recovery hints for state-related errors', () it('E_STALE_STATE error has a stack trace', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; try { await graph.hasNode('test:x'); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.stack).toBeDefined(); expect(err.stack.length).toBeGreaterThan(0); } diff --git a/test/unit/domain/WarpGraph.fork.test.js b/test/unit/domain/WarpGraph.fork.test.js index eeffdabc..ec5a28ff 100644 --- a/test/unit/domain/WarpGraph.fork.test.js +++ b/test/unit/domain/WarpGraph.fork.test.js @@ -15,7 +15,9 @@ const POID2 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; const POID3 = 'cccccccccccccccccccccccccccccccccccccccc'; describe('WarpGraph.fork', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -29,25 +31,25 @@ describe('WarpGraph.fork', () => { describe('parameter validation', () => { it('throws E_FORK_INVALID_ARGS when from is missing', async () => { - const err = await graph.fork({ at: SHA1 }).catch(e => e); + const err = await graph.fork({ at: SHA1 }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_INVALID_ARGS'); }); it('throws E_FORK_INVALID_ARGS when from is not a string', async () => { - const err = await graph.fork({ from: 123, at: SHA1 }).catch(e => e); + const err = await graph.fork({ from: 123, at: SHA1 }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_INVALID_ARGS'); }); it('throws E_FORK_INVALID_ARGS when at is missing', async () => { - const err = await graph.fork({ from: 'alice' }).catch(e => e); + const err = await graph.fork({ from: 'alice' }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_INVALID_ARGS'); }); it('throws E_FORK_INVALID_ARGS when at is not a string', async () => { - const err = await graph.fork({ from: 'alice', at: 123 }).catch(e => e); + const err = await graph.fork({ from: 'alice', at: 123 }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_INVALID_ARGS'); }); @@ -57,7 +59,7 @@ describe('WarpGraph.fork', () => { it('throws E_FORK_WRITER_NOT_FOUND when writer does not exist', async () => { persistence.listRefs.mockResolvedValue([]); - const err = await graph.fork({ from: 'nonexistent', at: SHA1 }).catch(e => e); + const err = await graph.fork({ from: 'nonexistent', at: SHA1 }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_WRITER_NOT_FOUND'); expect(err.context).toMatchObject({ @@ -83,7 +85,7 @@ describe('WarpGraph.fork', () => { persistence.listRefs.mockResolvedValue(['refs/warp/test-graph/writers/alice']); persistence.nodeExists.mockResolvedValue(false); - const err = await graph.fork({ from: 'alice', at: '4444444444444444444444444444444444444444' }).catch(e => e); + const err = await graph.fork({ from: 'alice', at: '4444444444444444444444444444444444444444' }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_PATCH_NOT_FOUND'); expect(err.context).toMatchObject({ @@ -97,14 +99,14 @@ describe('WarpGraph.fork', () => { persistence.listRefs.mockResolvedValue(['refs/warp/test-graph/writers/alice']); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA2; } return null; }); // Simulate that '5555555555555555555555555555555555555555' is not an ancestor of SHA2 - persistence.getNodeInfo.mockImplementation(async (sha) => { + persistence.getNodeInfo.mockImplementation(async (/** @type {any} */ sha) => { if (sha === SHA2) { return patch2.nodeInfo; } @@ -114,7 +116,7 @@ describe('WarpGraph.fork', () => { return { parents: [] }; }); - const err = await graph.fork({ from: 'alice', at: '5555555555555555555555555555555555555555' }).catch(e => e); + const err = await graph.fork({ from: 'alice', at: '5555555555555555555555555555555555555555' }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_PATCH_NOT_IN_CHAIN'); expect(err.context).toMatchObject({ @@ -128,7 +130,7 @@ describe('WarpGraph.fork', () => { it('throws E_FORK_NAME_INVALID for invalid fork name', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix.includes('test-graph')) { return ['refs/warp/test-graph/writers/alice']; } @@ -138,7 +140,7 @@ describe('WarpGraph.fork', () => { persistence.readRef.mockResolvedValue(SHA1); persistence.getNodeInfo.mockResolvedValue(patch.nodeInfo); - const err = await graph.fork({ from: 'alice', at: SHA1, forkName: '../invalid' }).catch(e => e); + const err = await graph.fork({ from: 'alice', at: SHA1, forkName: '../invalid' }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_NAME_INVALID'); }); @@ -146,7 +148,7 @@ describe('WarpGraph.fork', () => { it('throws E_FORK_ALREADY_EXISTS when fork graph already has refs', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix.includes('test-graph/')) { return ['refs/warp/test-graph/writers/alice']; } @@ -159,7 +161,7 @@ describe('WarpGraph.fork', () => { persistence.readRef.mockResolvedValue(SHA1); persistence.getNodeInfo.mockResolvedValue(patch.nodeInfo); - const err = await graph.fork({ from: 'alice', at: SHA1, forkName: 'existing-fork' }).catch(e => e); + const err = await graph.fork({ from: 'alice', at: SHA1, forkName: 'existing-fork' }).catch((/** @type {any} */ e) => e); expect(err).toBeInstanceOf(ForkError); expect(err.code).toBe('E_FORK_ALREADY_EXISTS'); expect(err.context).toMatchObject({ @@ -172,14 +174,14 @@ describe('WarpGraph.fork', () => { it('creates a fork with auto-generated name and writer ID', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -203,14 +205,14 @@ describe('WarpGraph.fork', () => { it('creates a fork with custom name and writer ID', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -238,14 +240,14 @@ describe('WarpGraph.fork', () => { it('fork shares the same persistence adapter', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -264,14 +266,14 @@ describe('WarpGraph.fork', () => { it('fork gets independent graph name from original', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -294,14 +296,14 @@ describe('WarpGraph.fork', () => { it('validates fork writer ID if explicitly provided', async () => { const patch = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -326,20 +328,20 @@ describe('WarpGraph.fork', () => { const patch1 = createMockPatch({ graphName: 'test-graph', sha: SHA1, writerId: 'alice', lamport: 1, patchOid: POID1 }); const patch2 = createMockPatch({ graphName: 'test-graph', sha: SHA2, writerId: 'alice', lamport: 2, patchOid: POID2, parentSha: SHA1 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA2; // tip } return null; }); - persistence.getNodeInfo.mockImplementation(async (sha) => { + persistence.getNodeInfo.mockImplementation(async (/** @type {any} */ sha) => { if (sha === SHA2) return patch2.nodeInfo; if (sha === SHA1) return patch1.nodeInfo; return { parents: [] }; @@ -359,20 +361,20 @@ describe('WarpGraph.fork', () => { const patch2 = createMockPatch({ graphName: 'test-graph', sha: SHA2, writerId: 'alice', lamport: 2, patchOid: POID2, parentSha: SHA1 }); const patch3 = createMockPatch({ graphName: 'test-graph', sha: SHA3, writerId: 'alice', lamport: 3, patchOid: POID3, parentSha: SHA2 }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA3; // tip } return null; }); - persistence.getNodeInfo.mockImplementation(async (sha) => { + persistence.getNodeInfo.mockImplementation(async (/** @type {any} */ sha) => { if (sha === SHA3) return patch3.nodeInfo; if (sha === SHA2) return patch2.nodeInfo; if (sha === SHA1) return patch1.nodeInfo; diff --git a/test/unit/domain/WarpGraph.forkCryptoCodec.test.js b/test/unit/domain/WarpGraph.forkCryptoCodec.test.js index 4cf3104c..754da653 100644 --- a/test/unit/domain/WarpGraph.forkCryptoCodec.test.js +++ b/test/unit/domain/WarpGraph.forkCryptoCodec.test.js @@ -12,9 +12,13 @@ const SHA1 = '1111111111111111111111111111111111111111'; const POID1 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; describe('WarpGraph.fork crypto/codec propagation', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let mockCrypto; + /** @type {any} */ let mockCodec; + /** @type {any} */ let graph; beforeEach(async () => { @@ -23,8 +27,8 @@ describe('WarpGraph.fork crypto/codec propagation', () => { digest: async () => 'mockhash', }; mockCodec = { - encode: (obj) => Buffer.from(JSON.stringify(obj)), - decode: (buf) => JSON.parse(buf.toString()), + encode: (/** @type {any} */ obj) => Buffer.from(JSON.stringify(obj)), + decode: (/** @type {any} */ buf) => JSON.parse(buf.toString()), }; graph = await WarpGraph.open({ @@ -47,14 +51,14 @@ describe('WarpGraph.fork crypto/codec propagation', () => { patchOid: POID1, }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -82,14 +86,14 @@ describe('WarpGraph.fork crypto/codec propagation', () => { patchOid: POID1, }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/test-graph/writers/') { return ['refs/warp/test-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/test-graph/writers/alice') { return SHA1; } @@ -109,7 +113,7 @@ describe('WarpGraph.fork crypto/codec propagation', () => { }); it('forked graph without crypto/codec uses parent defaults', async () => { - // Create a graph without explicit crypto (should be undefined) + // Create a graph without explicit crypto (uses defaultCrypto) const plainGraph = await WarpGraph.open({ persistence, graphName: 'plain-graph', @@ -124,14 +128,14 @@ describe('WarpGraph.fork crypto/codec propagation', () => { patchOid: POID1, }); - persistence.listRefs.mockImplementation(async (prefix) => { + persistence.listRefs.mockImplementation(async (/** @type {any} */ prefix) => { if (prefix === 'refs/warp/plain-graph/writers/') { return ['refs/warp/plain-graph/writers/alice']; } return []; }); persistence.nodeExists.mockResolvedValue(true); - persistence.readRef.mockImplementation(async (ref) => { + persistence.readRef.mockImplementation(async (/** @type {any} */ ref) => { if (ref === 'refs/warp/plain-graph/writers/alice') { return SHA1; } @@ -149,7 +153,7 @@ describe('WarpGraph.fork crypto/codec propagation', () => { expect(fork).toBeInstanceOf(WarpGraph); // Both should share the same default codec expect(fork._codec).toBe(plainGraph._codec); - // Both should have undefined crypto (no crypto port injected) + // Both should share the same default crypto expect(fork._crypto).toBe(plainGraph._crypto); }); }); diff --git a/test/unit/domain/WarpGraph.frontierChanged.test.js b/test/unit/domain/WarpGraph.frontierChanged.test.js index 6e958eea..f9acfcb5 100644 --- a/test/unit/domain/WarpGraph.frontierChanged.test.js +++ b/test/unit/domain/WarpGraph.frontierChanged.test.js @@ -19,9 +19,9 @@ const FAKE_COMMIT_SHA_2 = 'd'.repeat(40); const EMPTY_PATCH_CBOR = Buffer.from(cborEncode({ schema: 2, ops: [], context: {} })); /** Configure mocks for a single writer with one patch */ -function mockSingleWriter(persistence, { writerRef, commitSha, patchMessage }) { +function mockSingleWriter(/** @type {any} */ persistence, /** @type {any} */ { writerRef, commitSha, patchMessage }) { persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(commitSha); return Promise.resolve(null); }); @@ -35,7 +35,9 @@ function mockSingleWriter(persistence, { writerRef, commitSha, patchMessage }) { } describe('WarpGraph.hasFrontierChanged() (GK/FRONTIER/1)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -81,7 +83,7 @@ describe('WarpGraph.hasFrontierChanged() (GK/FRONTIER/1)', () => { await graph.materialize(); // Writer tip advances - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); }); @@ -102,7 +104,7 @@ describe('WarpGraph.hasFrontierChanged() (GK/FRONTIER/1)', () => { // Second writer appears const writerRef2 = 'refs/warp/test/writers/writer-2'; persistence.listRefs.mockResolvedValue([writerRef1, writerRef2]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef1) return Promise.resolve(FAKE_COMMIT_SHA); if (ref === writerRef2) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); @@ -124,19 +126,19 @@ describe('WarpGraph.hasFrontierChanged() (GK/FRONTIER/1)', () => { }); persistence.listRefs.mockResolvedValue([writerRef1, writerRef2]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef1) return Promise.resolve(FAKE_COMMIT_SHA); if (ref === writerRef2) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === FAKE_COMMIT_SHA) { return Promise.resolve({ sha, message: patchMessage1, parents: [] }); } return Promise.resolve({ sha, message: patchMessage2, parents: [] }); }); persistence.readBlob.mockResolvedValue(EMPTY_PATCH_CBOR); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === FAKE_COMMIT_SHA) return Promise.resolve(patchMessage1); return Promise.resolve(patchMessage2); }); @@ -145,7 +147,7 @@ describe('WarpGraph.hasFrontierChanged() (GK/FRONTIER/1)', () => { // Only writer-1 remains persistence.listRefs.mockResolvedValue([writerRef1]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef1) return Promise.resolve(FAKE_COMMIT_SHA); return Promise.resolve(null); }); @@ -168,17 +170,17 @@ describe('WarpGraph.hasFrontierChanged() (GK/FRONTIER/1)', () => { graph: 'test', writer: 'writer-1', lamport: 2, patchOid: FAKE_BLOB_OID, schema: 2, }); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === FAKE_COMMIT_SHA_2) { return Promise.resolve({ sha, message: patchMessage2, parents: [FAKE_COMMIT_SHA] }); } return Promise.resolve({ sha, message: patchMessage, parents: [] }); }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === FAKE_COMMIT_SHA_2) return Promise.resolve(patchMessage2); return Promise.resolve(patchMessage); }); diff --git a/test/unit/domain/WarpGraph.invalidation.test.js b/test/unit/domain/WarpGraph.invalidation.test.js index 2d77b9ca..ac1d833d 100644 --- a/test/unit/domain/WarpGraph.invalidation.test.js +++ b/test/unit/domain/WarpGraph.invalidation.test.js @@ -22,7 +22,7 @@ const FAKE_COMMIT_SHA_2 = 'd'.repeat(40); * Configure the mock persistence so that a single createPatch().addNode().commit() * succeeds for a first-time writer (no existing ref). */ -function mockFirstCommit(persistence) { +function mockFirstCommit(/** @type {any} */ persistence) { persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); @@ -34,7 +34,7 @@ function mockFirstCommit(persistence) { * After the first commit, the writer ref points to FAKE_COMMIT_SHA. * Configure mocks so a second commit succeeds. */ -function mockSecondCommit(persistence) { +function mockSecondCommit(/** @type {any} */ persistence) { const patchMessage = encodePatchMessage({ graph: 'test', writer: 'writer-1', @@ -52,7 +52,9 @@ function mockSecondCommit(persistence) { } describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -67,25 +69,25 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' // ── AP/INVAL/1: Basic dirty flag ────────────────────────────────── it('_stateDirty is false after construction', () => { - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); it('_stateDirty is false after materialize()', async () => { await graph.materialize(); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); // ── AP/INVAL/2: Eager re-materialize on commit ──────────────────── it('_stateDirty stays false after commit when _cachedState exists (eager re-materialize)', async () => { await graph.materialize(); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); mockFirstCommit(persistence); await (await graph.createPatch()).addNode('test:node').commit(); // Eager re-materialize applied the patch, so state is fresh - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); it('hasNode returns true after commit without explicit re-materialize', async () => { @@ -117,12 +119,12 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' mockFirstCommit(persistence); await (await graph.createPatch()).addNode('test:a').commit(); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:a')).toBe(true); mockSecondCommit(persistence); await (await graph.createPatch()).addNode('test:b').commit(); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:b')).toBe(true); }); @@ -133,17 +135,17 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' await (await graph.createPatch()).addNode('test:node').commit(); // No _cachedState, so can't eagerly apply — dirty - expect(graph._stateDirty).toBe(true); + expect(/** @type {any} */ (graph)._stateDirty).toBe(true); }); it('multiple commits without materialize keep _stateDirty true', async () => { mockFirstCommit(persistence); await (await graph.createPatch()).addNode('test:a').commit(); - expect(graph._stateDirty).toBe(true); + expect(/** @type {any} */ (graph)._stateDirty).toBe(true); mockSecondCommit(persistence); await (await graph.createPatch()).addNode('test:b').commit(); - expect(graph._stateDirty).toBe(true); + expect(/** @type {any} */ (graph)._stateDirty).toBe(true); }); // ── Edge cases: failed commits ───────────────────────────────────── @@ -155,7 +157,7 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' const patch = (await graph.createPatch()).addNode('test:node'); await expect(patch.commit()).rejects.toThrow('disk full'); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); it('_stateDirty remains false if updateRef fails', async () => { @@ -168,7 +170,7 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' const patch = (await graph.createPatch()).addNode('test:node'); await expect(patch.commit()).rejects.toThrow('ref lock failed'); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); it('_stateDirty remains false if race detection rejects', async () => { @@ -179,6 +181,6 @@ describe('WarpGraph dirty flag + eager re-materialize (AP/INVAL/1 + AP/INVAL/2)' const patch = (await graph.createPatch()).addNode('test:node'); await expect(patch.commit()).rejects.toThrow('Commit failed: writer ref was updated by another process'); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); }); diff --git a/test/unit/domain/WarpGraph.lazyMaterialize.test.js b/test/unit/domain/WarpGraph.lazyMaterialize.test.js index 9320228c..19d67d55 100644 --- a/test/unit/domain/WarpGraph.lazyMaterialize.test.js +++ b/test/unit/domain/WarpGraph.lazyMaterialize.test.js @@ -32,7 +32,7 @@ const FAKE_COMMIT_SHA = 'c'.repeat(40); /** * Configure mock persistence so a first-time writer commit succeeds. */ -function mockFirstCommit(persistence) { +function mockFirstCommit(/** @type {any} */ persistence) { persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); @@ -46,7 +46,9 @@ function mockFirstCommit(persistence) { describe('AP/LAZY/2: auto-materialize guards on query methods', () => { describe('1. Fresh open with autoMaterialize: true -> query returns results', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -85,9 +87,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { }); it('_cachedState is populated after first query triggers auto-materialize', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); await graph.getNodes(); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); }); }); @@ -96,7 +98,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('2. Dirty state triggers auto-rematerialization on query', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -118,7 +122,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { await (await graph.createPatch()).addNode('test:node').commit(); // State should still be fresh (eager re-materialize) - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:node')).toBe(true); }); @@ -131,7 +135,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { await (await graph.createPatch()).addNode('test:node').commit(); // Manually mark dirty to simulate external change - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; // Mock listRefs to return the writer ref for rematerialization const patchMessage = encodePatchMessage({ @@ -161,7 +165,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { it('auto-materialize is triggered when _stateDirty is true', async () => { await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; const materializeSpy = vi.spyOn(graph, 'materialize'); @@ -181,8 +185,8 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { it('auto-materialize is NOT triggered when state is clean', async () => { await graph.materialize(); - expect(graph._stateDirty).toBe(false); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); const materializeSpy = vi.spyOn(graph, 'materialize'); @@ -197,7 +201,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('3. autoMaterialize: false -> null state -> throws', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -236,7 +242,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('4. autoMaterialize: false -> explicit materialize -> normal query behavior', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -281,7 +289,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { it('querying state with data works after materialize + manual seed', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; orsetAdd(state.nodeAlive, 'test:alice', createDot('w1', 1)); expect(await graph.hasNode('test:alice')).toBe(true); @@ -294,7 +302,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('5. All query methods respect autoMaterialize: true', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -308,44 +318,44 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { }); it('hasNode auto-materializes and returns result', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const result = await graph.hasNode('test:x'); expect(result).toBe(false); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); }); it('getNodeProps auto-materializes and returns result', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const result = await graph.getNodeProps('test:x'); expect(result).toBe(null); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); }); it('neighbors auto-materializes and returns result', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const result = await graph.neighbors('test:x'); expect(result).toEqual([]); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); }); it('getNodes auto-materializes and returns result', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const result = await graph.getNodes(); expect(result).toEqual([]); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); }); it('getEdges auto-materializes and returns result', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const result = await graph.getEdges(); expect(result).toEqual([]); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); }); it('all methods return consistent data from auto-materialized state', async () => { // First call triggers materialize; seed state for subsequent calls await graph.getNodes(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Seed data orsetAdd(state.nodeAlive, 'test:alice', createDot('w1', 1)); @@ -385,7 +395,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('6. query().run() works with autoMaterialize: true', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -419,7 +431,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { orsetAdd(state.nodeAlive, 'test:bob', createDot('w1', 2)); orsetAdd(state.edgeAlive, encodeEdgeKey('test:alice', 'test:bob', 'follows'), createDot('w1', 3)); - graph._cachedState = state; + /** @type {any} */ (graph)._cachedState = state; graph.materialize = vi.fn().mockResolvedValue(state); const result = await graph.query().match('test:alice').outgoing().run(); @@ -427,9 +439,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { }); it('query().run() auto-materializes when state is null', async () => { - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const result = await graph.query().match('*').run(); - expect(graph._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); expect(result.nodes).toEqual([]); }); }); @@ -439,7 +451,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('7. Concurrent auto-materialize calls', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -484,7 +498,9 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // ──────────────────────────────────────────────────────────────────────── describe('8. traverse methods work with autoMaterialize: true', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -529,7 +545,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { orsetAdd(state.edgeAlive, encodeEdgeKey('test:a', 'test:b', 'x'), createDot('w1', 4)); orsetAdd(state.edgeAlive, encodeEdgeKey('test:b', 'test:c', 'x'), createDot('w1', 5)); - graph._cachedState = state; + /** @type {any} */ (graph)._cachedState = state; graph.materialize = vi.fn().mockResolvedValue(state); const result = await graph.traverse.bfs('test:a', { dir: 'out' }); @@ -542,7 +558,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { orsetAdd(state.nodeAlive, 'test:b', createDot('w1', 2)); orsetAdd(state.edgeAlive, encodeEdgeKey('test:a', 'test:b', 'x'), createDot('w1', 3)); - graph._cachedState = state; + /** @type {any} */ (graph)._cachedState = state; graph.materialize = vi.fn().mockResolvedValue(state); const result = await graph.traverse.shortestPath('test:a', 'test:b', { dir: 'out' }); @@ -555,7 +571,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { try { await graph.traverse.bfs('test:missing'); expect.fail('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('Start node not found'); expect(err.message).not.toContain('No cached state'); } @@ -589,8 +605,8 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { // First call materializes await graph.getNodes(); - expect(graph._cachedState).not.toBe(null); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._cachedState).not.toBe(null); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); // Spy on materialize for subsequent call const spy = vi.spyOn(graph, 'materialize'); @@ -610,7 +626,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { }); await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; const spy = vi.spyOn(graph, 'materialize'); await graph.getNodes(); @@ -627,7 +643,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { autoMaterialize: true, }); - expect(graph._cachedState).toBe(null); + expect(/** @type {any} */ (graph)._cachedState).toBe(null); const spy = vi.spyOn(graph, 'materialize'); await graph.hasNode('test:x'); @@ -644,7 +660,7 @@ describe('AP/LAZY/2: auto-materialize guards on query methods', () => { }); await graph.materialize(); - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; await expect(graph.getNodes()).rejects.toThrow(QueryError); }); diff --git a/test/unit/domain/WarpGraph.materializeSlice.test.js b/test/unit/domain/WarpGraph.materializeSlice.test.js index 854075ff..30157bac 100644 --- a/test/unit/domain/WarpGraph.materializeSlice.test.js +++ b/test/unit/domain/WarpGraph.materializeSlice.test.js @@ -11,7 +11,9 @@ import { } from '../../helpers/warpGraphTestUtils.js'; describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let oidGen; beforeEach(() => { @@ -64,7 +66,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -123,27 +125,27 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha3; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.nodeInfo; if (sha === sha2) return patch2.nodeInfo; if (sha === sha1) return patch1.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.message; if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch3.patchOid) return patch3.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; @@ -214,27 +216,27 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha3; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.nodeInfo; if (sha === sha2) return patch2.nodeInfo; if (sha === sha1) return patch1.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.message; if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch3.patchOid) return patch3.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; @@ -288,26 +290,26 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { 'refs/warp/test/writers/alice', 'refs/warp/test/writers/bob', ]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/writers/bob') return sha2; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha1) return patch1.nodeInfo; if (sha === sha2) return patch2.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha1) return patch1.message; if (sha === sha2) return patch2.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch1.patchOid) return patch1.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; return null; @@ -368,26 +370,26 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { 'refs/warp/test/writers/alice', 'refs/warp/test/writers/bob', ]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/writers/bob') return sha2; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha1) return patch1.nodeInfo; if (sha === sha2) return patch2.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha1) return patch1.message; if (sha === sha2) return patch2.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch1.patchOid) return patch1.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; return null; @@ -451,27 +453,27 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha3; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.nodeInfo; if (sha === sha2) return patch2.nodeInfo; if (sha === sha1) return patch1.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.message; if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch3.patchOid) return patch3.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; @@ -484,7 +486,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { writerId: 'alice', }); - const fullState = await graph.materialize(); + const fullState = /** @type {any} */ (await graph.materialize()); const slice = await graph.materializeSlice('target'); @@ -508,7 +510,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -526,9 +528,9 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { await graph.materialize(); const slice = await graph.materializeSlice('user:alice', { receipts: true }); - expect(slice.receipts).toBeDefined(); - expect(Array.isArray(slice.receipts)).toBe(true); - expect(slice.receipts.length).toBe(1); + expect(/** @type {any} */ (slice.receipts)).toBeDefined(); + expect(Array.isArray(/** @type {any} */ (slice.receipts))).toBe(true); + expect(/** @type {any} */ (/** @type {any} */ (slice.receipts)).length).toBe(1); }); it('does not include receipts when not requested', async () => { @@ -543,7 +545,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -561,7 +563,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { await graph.materialize(); const slice = await graph.materializeSlice('user:alice'); - expect(slice.receipts).toBeUndefined(); + expect(/** @type {any} */ (slice.receipts)).toBeUndefined(); }); }); @@ -609,27 +611,27 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha3; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.nodeInfo; if (sha === sha2) return patch2.nodeInfo; if (sha === sha1) return patch1.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.message; if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch3.patchOid) return patch3.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; @@ -667,7 +669,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -749,14 +751,14 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { 'refs/warp/test/writers/alice', 'refs/warp/test/writers/bob', ]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return shaD; if (ref === 'refs/warp/test/writers/bob') return shaC; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === shaD) return patchD.nodeInfo; if (sha === shaC) return patchC.nodeInfo; if (sha === shaB) return patchB.nodeInfo; @@ -764,7 +766,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === shaD) return patchD.message; if (sha === shaC) return patchC.message; if (sha === shaB) return patchB.message; @@ -772,7 +774,7 @@ describe('WarpGraph.materializeSlice() (HG/SLICE/1)', () => { return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patchD.patchOid) return patchD.patchBuffer; if (oid === patchC.patchOid) return patchC.patchBuffer; if (oid === patchB.patchOid) return patchB.patchBuffer; diff --git a/test/unit/domain/WarpGraph.noCoordination.test.js b/test/unit/domain/WarpGraph.noCoordination.test.js index ac819148..8d7a1f15 100644 --- a/test/unit/domain/WarpGraph.noCoordination.test.js +++ b/test/unit/domain/WarpGraph.noCoordination.test.js @@ -4,7 +4,7 @@ import WarpGraph from '../../../src/domain/WarpGraph.js'; import { buildWriterRef } from '../../../src/domain/utils/RefLayout.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; -async function assertLinearWriterChain(persistence, graphName, writerId) { +async function assertLinearWriterChain(/** @type {any} */ persistence, /** @type {any} */ graphName, /** @type {any} */ writerId) { const writerRef = buildWriterRef(graphName, writerId); let current = await persistence.readRef(writerRef); diff --git a/test/unit/domain/WarpGraph.patchCount.test.js b/test/unit/domain/WarpGraph.patchCount.test.js index 014e401c..1629fa62 100644 --- a/test/unit/domain/WarpGraph.patchCount.test.js +++ b/test/unit/domain/WarpGraph.patchCount.test.js @@ -7,7 +7,7 @@ import { createMockPersistence } from '../../helpers/warpGraphTestUtils.js'; /** * Creates a minimal schema:2 patch object. */ -function createPatch(writer, lamport, nodeId) { +function createPatch(/** @type {any} */ writer, /** @type {any} */ lamport, /** @type {any} */ nodeId) { return { schema: 2, writer, @@ -21,7 +21,7 @@ function createPatch(writer, lamport, nodeId) { * A fake 40-char hex SHA for use in tests. * Converts the input to a hex string padded/truncated to exactly 40 hex chars. */ -function fakeSha(label) { +function fakeSha(/** @type {any} */ label) { const hex = Buffer.from(String(label)).toString('hex'); return hex.padEnd(40, 'a').slice(0, 40); } @@ -32,7 +32,8 @@ function fakeSha(label) { * * Returns the tip SHA so it can be wired to readRef. */ -function buildPatchChain(persistence, writer, count) { +function buildPatchChain(/** @type {any} */ persistence, /** @type {any} */ writer, /** @type {any} */ count) { + /** @type {any[]} */ const shas = []; for (let i = 1; i <= count; i++) { shas.push(fakeSha(`${writer}${i}`)); @@ -56,7 +57,7 @@ function buildPatchChain(persistence, writer, count) { const parents = i < count - 1 ? [shas[i + 1]] : []; // getNodeInfo returns commit info (message + parents) - persistence.getNodeInfo.mockImplementation((querySha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ querySha) => { // Find the matching SHA among all configured commits for (let j = 0; j < count; j++) { if (querySha === shas[j]) { @@ -78,7 +79,7 @@ function buildPatchChain(persistence, writer, count) { }); // readBlob returns CBOR for the patch - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { for (let j = 0; j < count; j++) { const l = j + 1; const po = fakeSha(`blob-${writer}-${l}`); @@ -96,7 +97,9 @@ function buildPatchChain(persistence, writer, count) { } describe('AP/CKPT/2: _patchesSinceCheckpoint tracking', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -127,7 +130,7 @@ describe('AP/CKPT/2: _patchesSinceCheckpoint tracking', () => { const tipSha = buildPatchChain(persistence, 'w1', patchCount); // checkpoint ref returns null (no checkpoint) - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/checkpoints/head') { return Promise.resolve(null); } @@ -203,7 +206,7 @@ describe('AP/CKPT/2: _patchesSinceCheckpoint tracking', () => { const tipSha = buildPatchChain(persistence, 'w1', patchCount); // Phase 1: materialize with 3 patches (no checkpoint) - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/checkpoints/head') { return Promise.resolve(null); } diff --git a/test/unit/domain/WarpGraph.patchesFor.test.js b/test/unit/domain/WarpGraph.patchesFor.test.js index b54b678c..400d4003 100644 --- a/test/unit/domain/WarpGraph.patchesFor.test.js +++ b/test/unit/domain/WarpGraph.patchesFor.test.js @@ -17,9 +17,12 @@ import { } from '../../helpers/warpGraphTestUtils.js'; describe('WarpGraph.patchesFor() (HG/IO/2)', () => { + /** @type {any} */ let persistence; // Parallel-safe generators: each test gets fresh instances via beforeEach + /** @type {any} */ let oidGen; + /** @type {any} */ let hashGen; beforeEach(() => { @@ -66,7 +69,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -125,28 +128,28 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha3; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); // Walk chain: sha3 -> sha2 -> sha1 - need to handle by SHA - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.nodeInfo; if (sha === sha2) return patch2.nodeInfo; if (sha === sha1) return patch1.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.message; if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch3.patchOid) return patch3.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; @@ -197,7 +200,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha2; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -207,13 +210,13 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { .mockResolvedValueOnce(patch2.nodeInfo) .mockResolvedValueOnce(patch1.nodeInfo); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; return null; @@ -250,7 +253,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -309,28 +312,28 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha3; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); // Walk chain: sha3 -> sha2 -> sha1 - need to handle by SHA - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.nodeInfo; if (sha === sha2) return patch2.nodeInfo; if (sha === sha1) return patch1.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha3) return patch3.message; if (sha === sha2) return patch2.message; if (sha === sha1) return patch1.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch3.patchOid) return patch3.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; if (oid === patch1.patchOid) return patch1.patchBuffer; @@ -376,7 +379,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; @@ -394,7 +397,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { await graph.materialize(); expect(graph.provenanceIndex).not.toBeNull(); - expect(graph.provenanceIndex.size).toBeGreaterThan(0); + expect(/** @type {any} */ (graph.provenanceIndex).size).toBeGreaterThan(0); }); }); @@ -426,26 +429,26 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { 'refs/warp/test/writers/alice', 'refs/warp/test/writers/bob', ]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/writers/bob') return sha2; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === sha1) return patch1.nodeInfo; if (sha === sha2) return patch2.nodeInfo; return null; }); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === sha1) return patch1.message; if (sha === sha2) return patch2.message; return ''; }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === patch1.patchOid) return patch1.patchBuffer; if (oid === patch2.patchOid) return patch2.patchBuffer; return null; @@ -495,7 +498,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/checkpoints/head') return checkpointSha; if (ref === 'refs/warp/test/writers/alice') return sha2; return null; @@ -507,7 +510,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { 'appliedVV.cbor': 'applied-oid', 'provenanceIndex.cbor': 'provenance-oid', }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === 'state-oid') return stateBuffer; if (oid === 'frontier-oid') return frontierBuffer; if (oid === 'applied-oid') return appliedVVBuffer; @@ -559,7 +562,7 @@ describe('WarpGraph.patchesFor() (HG/IO/2)', () => { }, oidGen.next); persistence.listRefs.mockResolvedValue(['refs/warp/test/writers/alice']); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/test/writers/alice') return sha1; if (ref === 'refs/warp/test/checkpoints/head') return null; return null; diff --git a/test/unit/domain/WarpGraph.query.test.js b/test/unit/domain/WarpGraph.query.test.js index 0b0f04cf..b69bcb0e 100644 --- a/test/unit/domain/WarpGraph.query.test.js +++ b/test/unit/domain/WarpGraph.query.test.js @@ -17,16 +17,18 @@ import { orsetAdd } from '../../../src/domain/crdt/ORSet.js'; import { createDot } from '../../../src/domain/crdt/Dot.js'; describe('WarpGraph Query API', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -46,7 +48,7 @@ describe('WarpGraph Query API', () => { await graph.materialize(); // Manually add a node to cached state for testing - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); expect(await graph.hasNode('user:alice')).toBe(true); @@ -70,7 +72,7 @@ describe('WarpGraph Query API', () => { it('returns empty map for node with no props', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); const props = await graph.getNodeProps('user:alice'); @@ -80,7 +82,7 @@ describe('WarpGraph Query API', () => { it('returns props for node with properties', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add node orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); @@ -106,7 +108,7 @@ describe('WarpGraph Query API', () => { it('returns empty array for node with no edges', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); expect(await graph.neighbors('user:alice')).toEqual([]); @@ -114,7 +116,7 @@ describe('WarpGraph Query API', () => { it('returns outgoing neighbors', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add nodes orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); @@ -135,7 +137,7 @@ describe('WarpGraph Query API', () => { it('returns incoming neighbors', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add nodes orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); @@ -156,7 +158,7 @@ describe('WarpGraph Query API', () => { it('returns both directions by default', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add nodes orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); @@ -170,13 +172,13 @@ describe('WarpGraph Query API', () => { const neighbors = await graph.neighbors('user:alice'); expect(neighbors).toHaveLength(2); - expect(neighbors.find(n => n.nodeId === 'user:bob' && n.direction === 'outgoing')).toBeDefined(); - expect(neighbors.find(n => n.nodeId === 'user:carol' && n.direction === 'incoming')).toBeDefined(); + expect(neighbors.find((/** @type {any} */ n) => n.nodeId === 'user:bob' && n.direction === 'outgoing')).toBeDefined(); + expect(neighbors.find((/** @type {any} */ n) => n.nodeId === 'user:carol' && n.direction === 'incoming')).toBeDefined(); }); it('filters by edge label', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add nodes orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); @@ -195,7 +197,7 @@ describe('WarpGraph Query API', () => { it('excludes edges with non-visible endpoints', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add only alice (bob is NOT added) orsetAdd(state.nodeAlive, 'user:alice', createDot('w1', 1)); @@ -221,7 +223,7 @@ describe('WarpGraph Query API', () => { it('returns all visible nodes', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; orsetAdd(state.nodeAlive, 'node-a', createDot('w1', 1)); orsetAdd(state.nodeAlive, 'node-b', createDot('w1', 2)); @@ -247,7 +249,7 @@ describe('WarpGraph Query API', () => { it('returns all visible edges', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Add nodes orsetAdd(state.nodeAlive, 'a', createDot('w1', 1)); @@ -260,13 +262,13 @@ describe('WarpGraph Query API', () => { const edges = await graph.getEdges(); expect(edges).toHaveLength(2); - expect(edges.find(e => e.from === 'a' && e.to === 'b' && e.label === 'e1')).toBeDefined(); - expect(edges.find(e => e.from === 'b' && e.to === 'c' && e.label === 'e2')).toBeDefined(); + expect(edges.find((/** @type {any} */ e) => e.from === 'a' && e.to === 'b' && e.label === 'e1')).toBeDefined(); + expect(edges.find((/** @type {any} */ e) => e.from === 'b' && e.to === 'c' && e.label === 'e2')).toBeDefined(); }); it('excludes edges with non-visible endpoints', async () => { await graph.materialize(); - const state = graph._cachedState; + const state = /** @type {any} */ (graph)._cachedState; // Only add 'a' node orsetAdd(state.nodeAlive, 'a', createDot('w1', 1)); diff --git a/test/unit/domain/WarpGraph.queryBuilder.compass.test.js b/test/unit/domain/WarpGraph.queryBuilder.compass.test.js index cf1ad3dd..fe834733 100644 --- a/test/unit/domain/WarpGraph.queryBuilder.compass.test.js +++ b/test/unit/domain/WarpGraph.queryBuilder.compass.test.js @@ -5,14 +5,16 @@ import QueryError from '../../../src/domain/errors/QueryError.js'; import { addNodeToState, addEdgeToState, setupGraphState } from '../../helpers/warpGraphTestUtils.js'; let lamportCounter = 0; -function addProp(state, nodeId, key, value) { +function addProp(/** @type {any} */ state, /** @type {any} */ nodeId, /** @type {any} */ key, /** @type {any} */ value) { lamportCounter++; const propKey = encodePropKey(nodeId, key); state.prop.set(propKey, { value, lamport: lamportCounter, writerId: 'w1' }); } describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -20,9 +22,9 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ persistence: mockPersistence, @@ -32,7 +34,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('where({ role: "admin" }) returns only admin nodes', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:carol', 3); @@ -54,7 +56,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('multiple properties in object = AND semantics', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addProp(state, 'user:alice', 'role', 'admin'); @@ -75,7 +77,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('chained object + function filters', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:carol', 3); @@ -91,7 +93,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { .query() .match('user:*') .where({ role: 'admin' }) - .where(({ props }) => props.age > 18) + .where((/** @type {any} */ { props }) => props.age > 18) .run(); expect(result.nodes).toEqual([ @@ -100,7 +102,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('empty object matches all nodes', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); }); @@ -118,7 +120,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('property value is null — filters correctly', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addProp(state, 'user:alice', 'status', null); @@ -137,7 +139,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('non-existent property in filter excludes node', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addProp(state, 'user:alice', 'role', 'admin'); @@ -161,7 +163,7 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); it('accepts arrays as values (strict equality)', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addProp(state, 'user:alice', 'tags', 'solo'); @@ -174,7 +176,9 @@ describe('COMPASS — CP/WHERE/1: Object shorthand in where()', () => { }); describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -182,9 +186,9 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ persistence: mockPersistence, @@ -194,7 +198,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('depth [1,3] on linear chain A→B→C→D returns B,C,D', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -218,7 +222,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('depth 2 from A returns only hop-2 nodes', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -238,7 +242,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('cycles do not cause infinite loops', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -260,7 +264,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('default depth [1,1] preserves existing single-hop behavior', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -278,7 +282,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('incoming with depth [1,2] works in reverse', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -299,7 +303,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('depth [2,3] excludes hop-1 nodes', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -322,7 +326,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('depth [0,0] returns the start set (self-inclusion)', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addEdgeToState(state, 'node:a', 'node:b', 'next', 3); @@ -348,7 +352,7 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); it('branching graph with depth [1,2] returns all reachable nodes', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:root', 1); addNodeToState(state, 'node:l1a', 2); addNodeToState(state, 'node:l1b', 3); @@ -376,7 +380,9 @@ describe('COMPASS — CP/MULTIHOP/1: Multi-hop traversal', () => { }); describe('COMPASS — CP/AGG/1: Aggregation', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -384,9 +390,9 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ persistence: mockPersistence, @@ -396,7 +402,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('count returns correct node count', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addNodeToState(state, 'order:2', 2); addNodeToState(state, 'order:3', 3); @@ -413,7 +419,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('sum computes correctly over numeric property', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addNodeToState(state, 'order:2', 2); addNodeToState(state, 'order:3', 3); @@ -433,7 +439,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('avg, min, max on numeric props', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addNodeToState(state, 'order:2', 2); addNodeToState(state, 'order:3', 3); @@ -454,7 +460,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('non-numeric values are silently skipped', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addNodeToState(state, 'order:2', 2); addNodeToState(state, 'order:3', 3); @@ -478,7 +484,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { expect(() => q.select(['id'])).toThrow(QueryError); try { q.select(['id']); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.code).toBe('E_QUERY_AGGREGATE_TERMINAL'); } }); @@ -500,7 +506,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('empty match set returns zeroes', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { // no nodes }); @@ -516,7 +522,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('single node aggregate', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addProp(state, 'order:1', 'total', 42); }); @@ -535,7 +541,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('all non-numeric values yield sum=0', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addNodeToState(state, 'order:2', 2); addProp(state, 'order:1', 'total', 'abc'); @@ -552,7 +558,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('stateHash is included in aggregate result', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); }); @@ -566,7 +572,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('where + aggregate composes correctly', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addNodeToState(state, 'order:2', 2); addNodeToState(state, 'order:3', 3); @@ -590,7 +596,7 @@ describe('COMPASS — CP/AGG/1: Aggregation', () => { }); it('property path without props. prefix works', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'order:1', 1); addProp(state, 'order:1', 'total', 50); }); diff --git a/test/unit/domain/WarpGraph.queryBuilder.test.js b/test/unit/domain/WarpGraph.queryBuilder.test.js index 1d99e245..ffbcf68b 100644 --- a/test/unit/domain/WarpGraph.queryBuilder.test.js +++ b/test/unit/domain/WarpGraph.queryBuilder.test.js @@ -4,22 +4,24 @@ import { encodePropKey } from '../../../src/domain/services/JoinReducer.js'; import QueryError from '../../../src/domain/errors/QueryError.js'; import { addNodeToState, addEdgeToState, setupGraphState } from '../../helpers/warpGraphTestUtils.js'; -function addProp(state, nodeId, key, value) { +function addProp(/** @type {any} */ state, /** @type {any} */ nodeId, /** @type {any} */ key, /** @type {any} */ value) { const propKey = encodePropKey(nodeId, key); state.prop.set(propKey, { value, lamport: 1, writerId: 'w1' }); } describe('WarpGraph QueryBuilder', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -33,13 +35,13 @@ describe('WarpGraph QueryBuilder', () => { expect(() => graph.query().match(['user:*'])).toThrow(QueryError); try { graph.query().match(['user:*']); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.code).toBe('E_QUERY_MATCH_TYPE'); } }); it('supports two-hop traversal with ordered results', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:carol', 3); @@ -58,7 +60,7 @@ describe('WarpGraph QueryBuilder', () => { }); it('supports glob match patterns', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'team:eng', 3); @@ -72,7 +74,7 @@ describe('WarpGraph QueryBuilder', () => { }); it('match(*) returns all nodes in canonical order', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'team:eng', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:alice', 3); @@ -87,7 +89,7 @@ describe('WarpGraph QueryBuilder', () => { }); it('produces deterministic JSON across runs', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:carol', 3); @@ -102,7 +104,7 @@ describe('WarpGraph QueryBuilder', () => { }); it('chaining order matters', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:carol', 3); @@ -132,7 +134,7 @@ describe('WarpGraph QueryBuilder', () => { }); it('where snapshots are read-only and mutation does not affect traversal', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addNodeToState(state, 'user:bob', 2); addNodeToState(state, 'user:carol', 3); @@ -143,7 +145,7 @@ describe('WarpGraph QueryBuilder', () => { const result = await graph .query() .match('user:alice') - .where(({ edgesOut, props }) => { + .where((/** @type {any} */ { edgesOut, props }) => { try { edgesOut.push({ label: 'follows', to: 'user:carol' }); } catch { @@ -163,7 +165,7 @@ describe('WarpGraph QueryBuilder', () => { }); it('selects fields and enforces allowed fields', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'user:alice', 1); addProp(state, 'user:alice', 'role', 'admin'); }); @@ -179,7 +181,7 @@ describe('WarpGraph QueryBuilder', () => { try { await graph.query().match('user:alice').select(['id', 'bogus']).run(); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(QueryError); expect(err.code).toBe('E_QUERY_SELECT_FIELD'); } diff --git a/test/unit/domain/WarpGraph.receipts.test.js b/test/unit/domain/WarpGraph.receipts.test.js index d47b63a5..bfa8f774 100644 --- a/test/unit/domain/WarpGraph.receipts.test.js +++ b/test/unit/domain/WarpGraph.receipts.test.js @@ -24,7 +24,7 @@ import { createVersionVector } from '../../../src/domain/crdt/VersionVector.js'; * Creates a mock persistence layer with in-memory patch storage. * Patches are stored in `_patches` map and writer refs in `_refs`. */ -function hexSha(counter) { +function hexSha(/** @type {any} */ counter) { return String(counter).padStart(40, '0'); } @@ -83,7 +83,7 @@ function createMockPersistence() { * * Returns the commit SHA. */ -async function simulatePatchCommit(persistence, { +async function simulatePatchCommit(/** @type {any} */ persistence, /** @type {any} */ { graphName, writerId, lamport, @@ -127,7 +127,9 @@ async function simulatePatchCommit(persistence, { // --------------------------------------------------------------------------- describe('WarpGraph.materialize() with receipts', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; const graphName = 'test'; const writerId = 'writer-1'; @@ -147,7 +149,7 @@ describe('WarpGraph.materialize() with receipts', () => { describe('receipts disabled (default)', () => { it('materialize() returns state directly', async () => { - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); expect(state).toBeDefined(); expect(state.nodeAlive).toBeDefined(); expect(state.edgeAlive).toBeDefined(); @@ -157,13 +159,13 @@ describe('WarpGraph.materialize() with receipts', () => { }); it('materialize({}) returns state directly', async () => { - const state = await graph.materialize({}); + const state = /** @type {any} */ (await graph.materialize({})); expect(state.nodeAlive).toBeDefined(); expect(state.receipts).toBeUndefined(); }); it('materialize({ receipts: false }) returns state directly', async () => { - const state = await graph.materialize({ receipts: false }); + const state = /** @type {any} */ (await graph.materialize({ receipts: false })); expect(state.nodeAlive).toBeDefined(); expect(state.receipts).toBeUndefined(); }); @@ -175,7 +177,7 @@ describe('WarpGraph.materialize() with receipts', () => { describe('receipts enabled', () => { it('materialize({ receipts: true }) returns { state, receipts }', async () => { - const result = await graph.materialize({ receipts: true }); + const result = /** @type {any} */ (await graph.materialize({ receipts: true })); expect(result).toHaveProperty('state'); expect(result).toHaveProperty('receipts'); expect(result.state.nodeAlive).toBeDefined(); @@ -490,14 +492,14 @@ describe('WarpGraph.materialize() with receipts', () => { expect(receipts).toHaveLength(2); // Alice's patch (lamport 5) - const aliceReceipt = receipts.find(r => r.writer === 'alice'); + const aliceReceipt = receipts.find((/** @type {any} */ r) => r.writer === 'alice'); expect(aliceReceipt.ops).toHaveLength(3); expect(aliceReceipt.ops[0]).toMatchObject({ op: 'NodeAdd', result: 'applied' }); expect(aliceReceipt.ops[1]).toMatchObject({ op: 'PropSet', result: 'applied' }); expect(aliceReceipt.ops[2]).toMatchObject({ op: 'EdgeAdd', result: 'applied' }); // Bob's patch (lamport 2): node add is applied (different dot), but prop is superseded - const bobReceipt = receipts.find(r => r.writer === 'bob'); + const bobReceipt = receipts.find((/** @type {any} */ r) => r.writer === 'bob'); expect(bobReceipt.ops).toHaveLength(2); expect(bobReceipt.ops[0]).toMatchObject({ op: 'NodeAdd', result: 'applied' }); expect(bobReceipt.ops[1]).toMatchObject({ op: 'PropSet', result: 'superseded' }); @@ -523,7 +525,7 @@ describe('WarpGraph.materialize() with receipts', () => { }); const { state } = await graph.materialize({ receipts: true }); - expect(graph._cachedState).toBe(state); + expect(/** @type {any} */ (graph)._cachedState).toBe(state); expect(orsetContains(state.nodeAlive, 'n1')).toBe(true); }); diff --git a/test/unit/domain/WarpGraph.seek.test.js b/test/unit/domain/WarpGraph.seek.test.js index c4323d21..541a896b 100644 --- a/test/unit/domain/WarpGraph.seek.test.js +++ b/test/unit/domain/WarpGraph.seek.test.js @@ -7,7 +7,7 @@ import { createMockPersistence } from '../../helpers/warpGraphTestUtils.js'; /** * Creates a minimal schema:2 patch object. */ -function createPatch(writer, lamport, nodeId) { +function createPatch(/** @type {any} */ writer, /** @type {any} */ lamport, /** @type {any} */ nodeId) { return { schema: 2, writer, @@ -20,7 +20,7 @@ function createPatch(writer, lamport, nodeId) { /** * A fake 40-char hex SHA for use in tests. */ -function fakeSha(label) { +function fakeSha(/** @type {any} */ label) { const hex = Buffer.from(String(label)).toString('hex'); return hex.padEnd(40, 'a').slice(0, 40); } @@ -29,10 +29,10 @@ function fakeSha(label) { * Sets up persistence mocks for multiple writers at once. * Each writer gets `count` patches with lamport 1..count. * - * @param {Object} persistence - Mock persistence - * @param {Object} writerSpecs - { writerId: count, ... } + * @param {any} persistence - Mock persistence + * @param {any} writerSpecs - { writerId: count, ... } * @param {string} [graphName='test'] - * @returns {Object} writerTips - { writerId: tipSha, ... } + * @returns {any} writerTips - { writerId: tipSha, ... } */ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test') { const nodeInfoMap = new Map(); @@ -44,7 +44,7 @@ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test for (let i = 1; i <= count; i++) { shas.push(fakeSha(`${writer}${i}`)); } - writerTips[writer] = shas[0]; + /** @type {any} */ (writerTips)[writer] = shas[0]; // shas[0] = tip (newest, highest lamport) // shas[count-1] = oldest (lamport=1) @@ -70,7 +70,7 @@ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test (w) => `refs/warp/${graphName}/writers/${w}` ); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { const info = nodeInfoMap.get(sha); if (info) { return Promise.resolve(info); @@ -78,7 +78,7 @@ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test return Promise.resolve({ message: '', parents: [] }); }); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { const buf = blobMap.get(oid); if (buf) { return Promise.resolve(buf); @@ -86,7 +86,7 @@ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test return Promise.resolve(Buffer.alloc(0)); }); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === `refs/warp/${graphName}/checkpoints/head`) { return Promise.resolve(null); } @@ -98,7 +98,7 @@ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test return Promise.resolve(null); }); - persistence.listRefs.mockImplementation((prefix) => { + persistence.listRefs.mockImplementation((/** @type {any} */ prefix) => { if (prefix.startsWith(`refs/warp/${graphName}/writers`)) { return Promise.resolve(writerRefs); } @@ -109,6 +109,7 @@ function setupMultiWriterPersistence(persistence, writerSpecs, graphName = 'test } describe('WarpGraph.seek (time-travel)', () => { + /** @type {any} */ let persistence; beforeEach(() => { @@ -163,9 +164,9 @@ describe('WarpGraph.seek (time-travel)', () => { const result = await graph.discoverTicks(); - expect(result.perWriter.get('alice').ticks).toEqual([1, 2]); - expect(result.perWriter.get('bob').ticks).toEqual([1, 2, 3]); - expect(result.perWriter.get('alice').tipSha).toBe(tips.alice); + expect(/** @type {any} */ (result).perWriter.get('alice').ticks).toEqual([1, 2]); + expect(/** @type {any} */ (result).perWriter.get('bob').ticks).toEqual([1, 2, 3]); + expect(/** @type {any} */ (result).perWriter.get('alice').tipSha).toBe(tips.alice); }); }); @@ -183,7 +184,7 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - const state = await graph.materialize({ ceiling: 2 }); + const state = /** @type {any} */ (await graph.materialize({ ceiling: 2 })); const nodeIds = [...state.nodeAlive.entries.keys()]; expect(nodeIds).toHaveLength(2); @@ -201,7 +202,7 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - const state = await graph.materialize({ ceiling: 0 }); + const state = /** @type {any} */ (await graph.materialize({ ceiling: 0 })); expect(state.nodeAlive.entries.size).toBe(0); }); @@ -215,13 +216,13 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - const fullState = await graph.materialize(); + const fullState = /** @type {any} */ (await graph.materialize()); const fullNodes = [...fullState.nodeAlive.entries.keys()].sort(); // Force cache invalidation for second call - graph._stateDirty = true; - graph._cachedCeiling = null; - const ceilingState = await graph.materialize({ ceiling: 999 }); + /** @type {any} */ (graph)._stateDirty = true; + /** @type {any} */ (graph)._cachedCeiling = null; + const ceilingState = /** @type {any} */ (await graph.materialize({ ceiling: 999 })); const ceilingNodes = [...ceilingState.nodeAlive.entries.keys()].sort(); expect(ceilingNodes).toEqual(fullNodes); @@ -236,7 +237,7 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 2, bob: 3 }); - const state = await graph.materialize({ ceiling: 2 }); + const state = /** @type {any} */ (await graph.materialize({ ceiling: 2 })); const nodeIds = [...state.nodeAlive.entries.keys()].sort(); // alice:1, alice:2, bob:1, bob:2 = 4 nodes @@ -257,10 +258,10 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - const stateA = await graph.materialize({ ceiling: 1 }); + const stateA = /** @type {any} */ (await graph.materialize({ ceiling: 1 })); const nodesA = stateA.nodeAlive.entries.size; - const stateB = await graph.materialize({ ceiling: 3 }); + const stateB = /** @type {any} */ (await graph.materialize({ ceiling: 3 })); const nodesB = stateB.nodeAlive.entries.size; expect(nodesA).toBe(1); @@ -295,8 +296,8 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - graph._seekCeiling = 1; - const state = await graph.materialize(); + /** @type {any} */ (graph)._seekCeiling = 1; + const state = /** @type {any} */ (await graph.materialize()); expect(state.nodeAlive.entries.size).toBe(1); }); @@ -310,8 +311,8 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - graph._seekCeiling = 1; - const state = await graph.materialize({ ceiling: 3 }); + /** @type {any} */ (graph)._seekCeiling = 1; + const state = /** @type {any} */ (await graph.materialize({ ceiling: 3 })); expect(state.nodeAlive.entries.size).toBe(3); }); @@ -347,7 +348,7 @@ describe('WarpGraph.seek (time-travel)', () => { const callCountAfterFirst = persistence.getNodeInfo.mock.calls.length; // Second call: same ceiling but with receipts — must NOT use cache - const result = await graph.materialize({ ceiling: 2, receipts: true }); + const result = /** @type {any} */ (await graph.materialize({ ceiling: 2, receipts: true })); expect(result.state).toBeDefined(); expect(Array.isArray(result.receipts)).toBe(true); @@ -366,13 +367,13 @@ describe('WarpGraph.seek (time-travel)', () => { // Start with one writer setupMultiWriterPersistence(persistence, { alice: 3 }); - const stateA = await graph.materialize({ ceiling: 2 }); + const stateA = /** @type {any} */ (await graph.materialize({ ceiling: 2 })); expect(stateA.nodeAlive.entries.size).toBe(2); // alice:1, alice:2 // A new writer appears — frontier changes setupMultiWriterPersistence(persistence, { alice: 3, bob: 3 }); - const stateB = await graph.materialize({ ceiling: 2 }); + const stateB = /** @type {any} */ (await graph.materialize({ ceiling: 2 })); // Must see 4 nodes (alice:1, alice:2, bob:1, bob:2), not stale 2 expect(stateB.nodeAlive.entries.size).toBe(4); }); @@ -386,9 +387,9 @@ describe('WarpGraph.seek (time-travel)', () => { setupMultiWriterPersistence(persistence, { alice: 3 }); - graph._seekCeiling = 1; + /** @type {any} */ (graph)._seekCeiling = 1; // Passing ceiling: null should clear the ceiling, giving us all 3 nodes - const state = await graph.materialize({ ceiling: null }); + const state = /** @type {any} */ (await graph.materialize({ ceiling: null })); expect(state.nodeAlive.entries.size).toBe(3); }); diff --git a/test/unit/domain/WarpGraph.serve.test.js b/test/unit/domain/WarpGraph.serve.test.js index 189fc42c..a513cb54 100644 --- a/test/unit/domain/WarpGraph.serve.test.js +++ b/test/unit/domain/WarpGraph.serve.test.js @@ -2,34 +2,36 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import WarpGraph from '../../../src/domain/WarpGraph.js'; import NodeHttpAdapter from '../../../src/infrastructure/adapters/NodeHttpAdapter.js'; -function canonicalizeJson(value) { +/** @returns {any} */ +function canonicalizeJson(/** @type {any} */ value) { if (Array.isArray(value)) { return value.map(canonicalizeJson); } if (value && typeof value === 'object') { const sorted = {}; for (const key of Object.keys(value).sort()) { - sorted[key] = canonicalizeJson(value[key]); + /** @type {any} */ (sorted)[key] = canonicalizeJson(value[key]); } return sorted; } return value; } -function canonicalStringify(value) { +function canonicalStringify(/** @type {any} */ value) { return JSON.stringify(canonicalizeJson(value)); } describe('WarpGraph serve', () => { + /** @type {any} */ let graph; beforeEach(async () => { const mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ diff --git a/test/unit/domain/WarpGraph.status.test.js b/test/unit/domain/WarpGraph.status.test.js index d9ec4513..fec26609 100644 --- a/test/unit/domain/WarpGraph.status.test.js +++ b/test/unit/domain/WarpGraph.status.test.js @@ -19,9 +19,9 @@ const FAKE_COMMIT_SHA_3 = 'e'.repeat(40); const EMPTY_PATCH_CBOR = Buffer.from(cborEncode({ schema: 2, ops: [], context: {} })); /** Configure mocks for a single writer with one patch */ -function mockSingleWriter(persistence, { writerRef, commitSha, patchMessage }) { +function mockSingleWriter(/** @type {any} */ persistence, /** @type {any} */ { writerRef, commitSha, patchMessage }) { persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(commitSha); return Promise.resolve(null); }); @@ -35,7 +35,9 @@ function mockSingleWriter(persistence, { writerRef, commitSha, patchMessage }) { } describe('WarpGraph.status() (LH/STATUS/1)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -82,7 +84,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { await graph.materialize(); // Manually mark state dirty (simulates a commit without eager re-materialize) - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; const status = await graph.status(); expect(status.cachedState).toBe('stale'); @@ -99,7 +101,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { await graph.materialize(); // Writer tip advances externally - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); }); @@ -143,19 +145,19 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { }); persistence.listRefs.mockResolvedValue([writerRef1, writerRef2]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef1) return Promise.resolve(FAKE_COMMIT_SHA); if (ref === writerRef2) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); }); - persistence.getNodeInfo.mockImplementation((sha) => { + persistence.getNodeInfo.mockImplementation((/** @type {any} */ sha) => { if (sha === FAKE_COMMIT_SHA) { return Promise.resolve({ sha, message: patchMessage1, parents: [] }); } return Promise.resolve({ sha, message: patchMessage2, parents: [] }); }); persistence.readBlob.mockResolvedValue(EMPTY_PATCH_CBOR); - persistence.showNode.mockImplementation((sha) => { + persistence.showNode.mockImplementation((/** @type {any} */ sha) => { if (sha === FAKE_COMMIT_SHA) return Promise.resolve(patchMessage1); return Promise.resolve(patchMessage2); }); @@ -211,7 +213,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { it('reports writers = 1 for single-writer graph', async () => { const writerRef = 'refs/warp/test/writers/writer-1'; persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA); return Promise.resolve(null); }); @@ -226,7 +228,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { const writerRef3 = 'refs/warp/test/writers/writer-3'; persistence.listRefs.mockResolvedValue([writerRef1, writerRef2, writerRef3]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef1) return Promise.resolve(FAKE_COMMIT_SHA); if (ref === writerRef2) return Promise.resolve(FAKE_COMMIT_SHA_2); if (ref === writerRef3) return Promise.resolve(FAKE_COMMIT_SHA_3); @@ -250,7 +252,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { it('returns plain object frontier (not a Map)', async () => { const writerRef = 'refs/warp/test/writers/writer-1'; persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA); return Promise.resolve(null); }); @@ -266,7 +268,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { const writerRef2 = 'refs/warp/test/writers/writer-2'; persistence.listRefs.mockResolvedValue([writerRef1, writerRef2]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef1) return Promise.resolve(FAKE_COMMIT_SHA); if (ref === writerRef2) return Promise.resolve(FAKE_COMMIT_SHA_2); return Promise.resolve(null); @@ -286,7 +288,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { it('does NOT trigger materialization', async () => { const writerRef = 'refs/warp/test/writers/writer-1'; persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA); return Promise.resolve(null); }); @@ -299,7 +301,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { // getNodeInfo should not be called — that would mean materialization occurred expect(getNodeInfoSpy).not.toHaveBeenCalled(); // The internal cached state should remain null - expect(graph._cachedState).toBeNull(); + expect(/** @type {any} */ (graph)._cachedState).toBeNull(); }); it('does NOT trigger materialization even when autoMaterialize is true', async () => { @@ -363,7 +365,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { // 4. After commit, update listRefs/readRef to reflect the new ref persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA); return Promise.resolve(null); }); @@ -391,7 +393,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { // 4. After commit, update listRefs/readRef to reflect the new ref persistence.listRefs.mockResolvedValue([writerRef]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef) return Promise.resolve(FAKE_COMMIT_SHA); return Promise.resolve(null); }); @@ -429,7 +431,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { // 4. After sync, listRefs/readRef reflect the remote writer const writerRef2 = 'refs/warp/test/writers/writer-2'; persistence.listRefs.mockResolvedValue([writerRef2]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef2) return Promise.resolve(remoteSha); return Promise.resolve(null); }); @@ -464,7 +466,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { // 4. After sync, readRef reflects the remote writer const writerRef2 = 'refs/warp/test/writers/writer-2'; persistence.listRefs.mockResolvedValue([writerRef2]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === writerRef2) return Promise.resolve(remoteSha); return Promise.resolve(null); }); @@ -501,7 +503,7 @@ describe('WarpGraph.status() (LH/STATUS/1)', () => { expect(statusAfter.frontier).toEqual({ 'writer-1': FAKE_COMMIT_SHA }); // After marking dirty - graph._stateDirty = true; + /** @type {any} */ (graph)._stateDirty = true; const statusDirty = await graph.status(); expect(statusDirty.cachedState).toBe('stale'); expect(statusDirty.patchesSinceCheckpoint).toBe(1); diff --git a/test/unit/domain/WarpGraph.subscribe.test.js b/test/unit/domain/WarpGraph.subscribe.test.js index 6535ae9b..9adc932a 100644 --- a/test/unit/domain/WarpGraph.subscribe.test.js +++ b/test/unit/domain/WarpGraph.subscribe.test.js @@ -4,7 +4,9 @@ import { isEmptyDiff } from '../../../src/domain/services/StateDiff.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; describe('WarpGraph.subscribe() (PL/SUB/1)', () => { + /** @type {any} */ let repo; + /** @type {any} */ let graph; beforeEach(async () => { @@ -88,7 +90,7 @@ describe('WarpGraph.subscribe() (PL/SUB/1)', () => { to: 'user:bob', label: 'follows', }); - expect(diff.props.set.some(p => p.nodeId === 'user:alice' && p.propKey === 'name')).toBe(true); + expect(diff.props.set.some((/** @type {any} */ p) => p.nodeId === 'user:alice' && p.propKey === 'name')).toBe(true); }); }); @@ -256,6 +258,7 @@ describe('WarpGraph.subscribe() (PL/SUB/1)', () => { }); it('handles unsubscribe called during handler execution', async () => { + /** @type {any} */ let sub; const onChange = vi.fn(() => { sub.unsubscribe(); @@ -275,7 +278,9 @@ describe('WarpGraph.subscribe() (PL/SUB/1)', () => { }); describe('WarpGraph.subscribe() with replay option (PL/SUB/2)', () => { + /** @type {any} */ let repo; + /** @type {any} */ let graph; beforeEach(async () => { @@ -331,7 +336,7 @@ describe('WarpGraph.subscribe() with replay option (PL/SUB/2)', () => { to: 'user:bob', label: 'follows', }); - expect(diff.props.set.some(p => p.nodeId === 'user:alice' && p.propKey === 'name')).toBe(true); + expect(diff.props.set.some((/** @type {any} */ p) => p.nodeId === 'user:alice' && p.propKey === 'name')).toBe(true); }); it('does not fire if state is empty', async () => { diff --git a/test/unit/domain/WarpGraph.syncMaterialize.test.js b/test/unit/domain/WarpGraph.syncMaterialize.test.js index 220e2aa0..e3cf1bfa 100644 --- a/test/unit/domain/WarpGraph.syncMaterialize.test.js +++ b/test/unit/domain/WarpGraph.syncMaterialize.test.js @@ -30,8 +30,9 @@ describe('syncWith({ materialize }) option', () => { expect(result.state).toBeDefined(); // The materialized state should contain bob's node - expect(result.state.nodeAlive).toBeDefined(); - const aliveNodes = [...result.state.nodeAlive.entries.keys()]; + const state1 = /** @type {any} */ (result.state); + expect(state1.nodeAlive).toBeDefined(); + const aliveNodes = [...state1.nodeAlive.entries.keys()]; expect(aliveNodes).toContain('node:bob-1'); } finally { await repoA.cleanup(); @@ -94,8 +95,9 @@ describe('syncWith({ materialize }) option', () => { expect(result.state).toBeDefined(); // State should be a valid empty materialized state - expect(result.state.nodeAlive).toBeDefined(); - expect([...result.state.nodeAlive.entries.keys()]).toHaveLength(0); + const state2 = /** @type {any} */ (result.state); + expect(state2.nodeAlive).toBeDefined(); + expect([...state2.nodeAlive.entries.keys()]).toHaveLength(0); } finally { await repoA.cleanup(); await repoB.cleanup(); diff --git a/test/unit/domain/WarpGraph.syncWith.test.js b/test/unit/domain/WarpGraph.syncWith.test.js index 72628851..5cf01d66 100644 --- a/test/unit/domain/WarpGraph.syncWith.test.js +++ b/test/unit/domain/WarpGraph.syncWith.test.js @@ -6,9 +6,9 @@ async function createGraph() { const mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; return WarpGraph.open({ @@ -19,11 +19,12 @@ async function createGraph() { } describe('WarpGraph syncWith', () => { + /** @type {any} */ let graph; beforeEach(async () => { graph = await createGraph(); - graph._cachedState = {}; + /** @type {any} */ (graph)._cachedState = {}; graph.applySyncResponse = vi.fn().mockReturnValue({ applied: 0 }); graph.createSyncRequest = vi.fn().mockResolvedValue({ type: 'sync-request', frontier: {} }); }); @@ -39,8 +40,8 @@ describe('WarpGraph syncWith', () => { }); }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const port = server.address().port; + await new Promise((resolve) => server.listen(0, '127.0.0.1', /** @type {any} */ (resolve))); + const port = /** @type {any} */ (server).address().port; try { const result = await graph.syncWith(`http://127.0.0.1:${port}`); @@ -65,8 +66,8 @@ describe('WarpGraph syncWith', () => { res.end(JSON.stringify(responsePayload)); }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const port = server.address().port; + await new Promise((resolve) => server.listen(0, '127.0.0.1', /** @type {any} */ (resolve))); + const port = /** @type {any} */ (server).address().port; const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0); try { @@ -87,10 +88,11 @@ describe('WarpGraph syncWith', () => { it('syncs directly with a peer graph instance', async () => { const responsePayload = { type: 'sync-response', frontier: {}, patches: [] }; const peer = { processSyncRequest: vi.fn().mockResolvedValue(responsePayload) }; + /** @type {any[]} */ const events = []; await graph.syncWith(peer, { - onStatus: (evt) => events.push(evt.type), + onStatus: (/** @type {any} */ evt) => events.push(evt.type), }); expect(peer.processSyncRequest).toHaveBeenCalled(); diff --git a/test/unit/domain/WarpGraph.test.js b/test/unit/domain/WarpGraph.test.js index 4b7fa394..738df664 100644 --- a/test/unit/domain/WarpGraph.test.js +++ b/test/unit/domain/WarpGraph.test.js @@ -16,7 +16,7 @@ const crypto = new NodeCryptoAdapter(); /** * Creates a mock persistence adapter for testing. - * @returns {Object} Mock persistence adapter + * @returns {any} Mock persistence adapter */ function createMockPersistence() { return { @@ -39,16 +39,16 @@ function createMockPersistence() { /** * Creates a mock patch commit structure for testing. - * @param {Object} options + * @param {object} options * @param {string} options.sha - The commit SHA * @param {string} options.graphName - The graph name * @param {string} options.writerId - The writer ID * @param {number} options.lamport - The lamport timestamp * @param {string} options.patchOid - The patch blob OID - * @param {Array} options.ops - The operations in the patch (schema:2 format with dots) + * @param {any[]} options.ops - The operations in the patch (schema:2 format with dots) * @param {string|null} [options.parentSha] - The parent commit SHA - * @param {Object} [options.context] - The context VV for schema:2 patches - * @returns {Object} Mock patch data for testing + * @param {any} [options.context] - The context VV for schema:2 patches + * @returns {any} Mock patch data for testing */ function createMockPatch({ sha, graphName, writerId, lamport, patchOid, ops, parentSha = null, context = null }) { const patch = { @@ -150,11 +150,11 @@ describe('WarpGraph', () => { it('rejects missing persistence', async () => { await expect( - WarpGraph.open({ + WarpGraph.open(/** @type {any} */ ({ persistence: null, graphName: 'events', writerId: 'node-1', - }) + })) ).rejects.toThrow('persistence is required'); }); @@ -203,12 +203,12 @@ describe('WarpGraph', () => { it('creates a PatchBuilderV2 with correct configuration', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'my-events', writerId: 'writer-42', schema: 2, - }); + })); // Set up mock responses for commit persistence.readRef.mockResolvedValue(null); @@ -233,12 +233,12 @@ describe('WarpGraph', () => { // No existing ref - first commit persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', schema: 2, - }); + })); const patchBuilder = await graph.createPatch(); @@ -253,7 +253,7 @@ describe('WarpGraph', () => { // During open(): checkpoint check returns null // During createPatch(): _nextLamport calls readRef(writerRef) which returns existingSha - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref.includes('checkpoints')) return Promise.resolve(null); if (ref.includes('writers')) return Promise.resolve(existingSha); return Promise.resolve(null); @@ -265,12 +265,12 @@ describe('WarpGraph', () => { `warp:patch\n\neg-kind: patch\neg-graph: test-graph\neg-writer: writer1\neg-lamport: 7\neg-patch-oid: ${existingPatchOid}\neg-schema: 2` ); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', schema: 2, - }); + })); const patchBuilder = await graph.createPatch(); @@ -284,7 +284,7 @@ describe('WarpGraph', () => { // During open(): checkpoint check returns null, listRefs returns [] // During createPatch(): _nextLamport calls readRef(writerRef) - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref.includes('checkpoints')) return Promise.resolve(null); if (ref.includes('writers')) return Promise.resolve(existingSha); return Promise.resolve(null); @@ -297,12 +297,12 @@ describe('WarpGraph', () => { 'warp:patch\n\neg-kind: patch\neg-graph: test-graph\neg-writer: writer1\neg-lamport: not-a-number\neg-patch-oid: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\neg-schema: 2' ); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', schema: 2, - }); + })); await expect(graph.createPatch()).rejects.toThrow(/Failed to parse lamport/); }); @@ -365,16 +365,16 @@ describe('WarpGraph', () => { describe('materialize', () => { it('returns empty state when no writers exist', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); persistence.listRefs.mockResolvedValue([]); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); expect(state.nodeAlive).toBeDefined(); expect(state.edgeAlive).toBeDefined(); @@ -383,12 +383,12 @@ describe('WarpGraph', () => { it('materializes state from single writer', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const patchOid = 'a'.repeat(40); const commitSha = 'b'.repeat(40); @@ -409,7 +409,7 @@ describe('WarpGraph', () => { persistence.getNodeInfo.mockResolvedValue(mockPatch.nodeInfo); persistence.readBlob.mockResolvedValue(mockPatch.patchBuffer); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // V5 state uses ORSet - check using ORSet API expect(state.nodeAlive.entries.has('user:alice')).toBe(true); @@ -417,12 +417,12 @@ describe('WarpGraph', () => { it('materializes state from multiple writers', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const patchOid1 = 'a'.repeat(40); const commitSha1 = 'b'.repeat(40); @@ -469,7 +469,7 @@ describe('WarpGraph', () => { .mockResolvedValueOnce(mockPatch1.patchBuffer) .mockResolvedValueOnce(mockPatch2.patchBuffer); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // V5 state uses ORSet expect(state.nodeAlive.entries.has('user:alice')).toBe(true); @@ -478,12 +478,12 @@ describe('WarpGraph', () => { it('materializes chain of patches from single writer', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const patchOid1 = 'a'.repeat(40); const commitSha1 = 'b'.repeat(40); @@ -524,7 +524,7 @@ describe('WarpGraph', () => { .mockResolvedValueOnce(mockPatch2.patchBuffer) .mockResolvedValueOnce(mockPatch1.patchBuffer); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // V5 state uses ORSet expect(state.nodeAlive.entries.has('user:alice')).toBe(true); @@ -533,17 +533,17 @@ describe('WarpGraph', () => { it('returns empty state when writer ref returns null', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); persistence.listRefs.mockResolvedValue(['refs/warp/events/writers/writer-1']); persistence.readRef.mockResolvedValue(null); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); // V5 state uses ORSet expect(state.nodeAlive.entries.size).toBe(0); @@ -599,7 +599,7 @@ eg-schema: 2`; const appliedVV = computeAppliedVV(v5State); const appliedVVBuffer = serializeAppliedVV(appliedVV); - persistence.readBlob.mockImplementation((oid) => { + persistence.readBlob.mockImplementation((/** @type {any} */ oid) => { if (oid === frontierBlobOid) return Promise.resolve(frontierBuffer); if (oid === stateBlobOid) return Promise.resolve(stateBuffer); if (oid === appliedVVBlobOid) return Promise.resolve(appliedVVBuffer); @@ -964,12 +964,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const patchBuilder = await graph.createPatch(); @@ -994,7 +994,7 @@ eg-schema: 2`; }); persistence.listRefs.mockResolvedValue([]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref === 'refs/warp/events/checkpoints/head') { return Promise.resolve(checkpointSha); } @@ -1014,12 +1014,12 @@ eg-schema: 2`; .mockResolvedValueOnce(encode({})) // frontier .mockResolvedValueOnce(encode({ nodes: [], edges: [], props: [] })); // state - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); expect(graph).toBeInstanceOf(WarpGraph); }); @@ -1031,12 +1031,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); expect(graph).toBeInstanceOf(WarpGraph); }); @@ -1050,15 +1050,15 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const sha = 'a'.repeat(40); - const result = await graph._isAncestor(sha, sha); + const result = await /** @type {any} */ (graph)._isAncestor(sha, sha); expect(result).toBe(true); }); @@ -1068,12 +1068,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const ancestorSha = 'a'.repeat(40); const descendantSha = 'b'.repeat(40); @@ -1083,7 +1083,7 @@ eg-schema: 2`; parents: [ancestorSha], }); - const result = await graph._isAncestor(ancestorSha, descendantSha); + const result = await /** @type {any} */ (graph)._isAncestor(ancestorSha, descendantSha); expect(result).toBe(true); }); @@ -1093,12 +1093,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const ancestorSha = 'a'.repeat(40); const middleSha = 'b'.repeat(40); @@ -1108,7 +1108,7 @@ eg-schema: 2`; .mockResolvedValueOnce({ sha: descendantSha, parents: [middleSha] }) .mockResolvedValueOnce({ sha: middleSha, parents: [ancestorSha] }); - const result = await graph._isAncestor(ancestorSha, descendantSha); + const result = await /** @type {any} */ (graph)._isAncestor(ancestorSha, descendantSha); expect(result).toBe(true); }); @@ -1118,12 +1118,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const sha1 = 'a'.repeat(40); const sha2 = 'b'.repeat(40); @@ -1134,7 +1134,7 @@ eg-schema: 2`; parents: [], }); - const result = await graph._isAncestor(sha1, sha2); + const result = await /** @type {any} */ (graph)._isAncestor(sha1, sha2); expect(result).toBe(false); }); @@ -1144,16 +1144,16 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); - expect(await graph._isAncestor(null, 'a'.repeat(40))).toBe(false); - expect(await graph._isAncestor('a'.repeat(40), null)).toBe(false); - expect(await graph._isAncestor(null, null)).toBe(false); + expect(await /** @type {any} */ (graph)._isAncestor(null, 'a'.repeat(40))).toBe(false); + expect(await /** @type {any} */ (graph)._isAncestor('a'.repeat(40), null)).toBe(false); + expect(await /** @type {any} */ (graph)._isAncestor(null, null)).toBe(false); }); }); @@ -1163,15 +1163,15 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const sha = 'a'.repeat(40); - const result = await graph._relationToCheckpointHead(sha, sha); + const result = await /** @type {any} */ (graph)._relationToCheckpointHead(sha, sha); expect(result).toBe('same'); }); @@ -1181,12 +1181,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const ckHead = 'a'.repeat(40); const incomingSha = 'b'.repeat(40); @@ -1197,7 +1197,7 @@ eg-schema: 2`; parents: [ckHead], }); - const result = await graph._relationToCheckpointHead(ckHead, incomingSha); + const result = await /** @type {any} */ (graph)._relationToCheckpointHead(ckHead, incomingSha); expect(result).toBe('ahead'); }); @@ -1207,12 +1207,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const incomingSha = 'a'.repeat(40); const ckHead = 'b'.repeat(40); @@ -1228,7 +1228,7 @@ eg-schema: 2`; parents: [incomingSha], }); - const result = await graph._relationToCheckpointHead(ckHead, incomingSha); + const result = await /** @type {any} */ (graph)._relationToCheckpointHead(ckHead, incomingSha); expect(result).toBe('behind'); }); @@ -1238,12 +1238,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const ckHead = 'a'.repeat(40); const incomingSha = 'b'.repeat(40); @@ -1268,7 +1268,7 @@ eg-schema: 2`; parents: [], }); - const result = await graph._relationToCheckpointHead(ckHead, incomingSha); + const result = await /** @type {any} */ (graph)._relationToCheckpointHead(ckHead, incomingSha); expect(result).toBe('diverged'); }); @@ -1280,18 +1280,18 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const checkpoint = { schema: 1, frontier: new Map() }; // Should not throw await expect( - graph._validatePatchAgainstCheckpoint('writer-1', 'a'.repeat(40), checkpoint) + /** @type {any} */ (graph)._validatePatchAgainstCheckpoint('writer-1', 'a'.repeat(40), checkpoint) ).resolves.toBeUndefined(); }); @@ -1300,12 +1300,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const checkpoint = { schema: 2, @@ -1314,7 +1314,7 @@ eg-schema: 2`; // writer-1 not in checkpoint - should succeed await expect( - graph._validatePatchAgainstCheckpoint('writer-1', 'a'.repeat(40), checkpoint) + /** @type {any} */ (graph)._validatePatchAgainstCheckpoint('writer-1', 'a'.repeat(40), checkpoint) ).resolves.toBeUndefined(); }); @@ -1323,12 +1323,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const ckHead = 'a'.repeat(40); const incomingSha = 'b'.repeat(40); @@ -1345,7 +1345,7 @@ eg-schema: 2`; }); await expect( - graph._validatePatchAgainstCheckpoint('writer-1', incomingSha, checkpoint) + /** @type {any} */ (graph)._validatePatchAgainstCheckpoint('writer-1', incomingSha, checkpoint) ).resolves.toBeUndefined(); }); @@ -1354,12 +1354,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const sha = 'a'.repeat(40); @@ -1369,7 +1369,7 @@ eg-schema: 2`; }; await expect( - graph._validatePatchAgainstCheckpoint('writer-1', sha, checkpoint) + /** @type {any} */ (graph)._validatePatchAgainstCheckpoint('writer-1', sha, checkpoint) ).rejects.toThrow('Backfill rejected for writer writer-1: incoming patch is same checkpoint frontier'); }); @@ -1378,12 +1378,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const incomingSha = 'a'.repeat(40); const ckHead = 'b'.repeat(40); @@ -1405,7 +1405,7 @@ eg-schema: 2`; }); await expect( - graph._validatePatchAgainstCheckpoint('writer-1', incomingSha, checkpoint) + /** @type {any} */ (graph)._validatePatchAgainstCheckpoint('writer-1', incomingSha, checkpoint) ).rejects.toThrow('Backfill rejected for writer writer-1: incoming patch is behind checkpoint frontier'); }); @@ -1414,12 +1414,12 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); const ckHead = 'a'.repeat(40); const incomingSha = 'b'.repeat(40); @@ -1450,7 +1450,7 @@ eg-schema: 2`; }); await expect( - graph._validatePatchAgainstCheckpoint('writer-1', incomingSha, checkpoint) + /** @type {any} */ (graph)._validatePatchAgainstCheckpoint('writer-1', incomingSha, checkpoint) ).rejects.toThrow('Writer fork detected for writer-1: incoming patch does not extend checkpoint head'); }); }); @@ -1460,15 +1460,15 @@ eg-schema: 2`; describe('VV updates after materialize', () => { it('updates _versionVector to match state.observedFrontier', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); // Before materialize, VV should be empty - expect(graph._versionVector.size).toBe(0); + expect(/** @type {any} */ (graph)._versionVector.size).toBe(0); // Create patches with context VVs that will merge into observedFrontier const patchOidA = 'a'.repeat(40); @@ -1539,25 +1539,25 @@ eg-schema: 2`; await graph.materialize(); // After materialize, VV should reflect merged observedFrontier: {writer-a: 3, writer-b: 2} - expect(graph._versionVector.get('writer-a')).toBe(3); - expect(graph._versionVector.get('writer-b')).toBe(2); + expect(/** @type {any} */ (graph)._versionVector.get('writer-a')).toBe(3); + expect(/** @type {any} */ (graph)._versionVector.get('writer-b')).toBe(2); }); it('VV is empty for empty graph', async () => { const persistence = createMockPersistence(); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'node-1', schema: 2, - }); + })); persistence.listRefs.mockResolvedValue([]); persistence.readRef.mockResolvedValue(null); await graph.materialize(); - expect(graph._versionVector.size).toBe(0); + expect(/** @type {any} */ (graph)._versionVector.size).toBe(0); }); }); @@ -1566,15 +1566,15 @@ eg-schema: 2`; const persistence = createMockPersistence(); persistence.readRef.mockResolvedValue(null); // No existing commits - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'writer-1', schema: 2, - }); + })); // VV starts empty - expect(graph._versionVector.get('writer-1')).toBeUndefined(); + expect(/** @type {any} */ (graph)._versionVector.get('writer-1')).toBeUndefined(); // Setup mocks for commit persistence.writeBlob.mockResolvedValue('a'.repeat(40)); @@ -1587,7 +1587,7 @@ eg-schema: 2`; await builder.commit(); // After commit, VV should have writer-1: 1 - expect(graph._versionVector.get('writer-1')).toBe(1); + expect(/** @type {any} */ (graph)._versionVector.get('writer-1')).toBe(1); }); it('increments only local writer counter, not others', async () => { @@ -1616,7 +1616,7 @@ eg-schema: 2`; persistence.listRefs.mockResolvedValue([ 'refs/warp/events/writers/writer-other', ]); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref.includes('checkpoints')) return Promise.resolve(null); if (ref.includes('writer-other')) return Promise.resolve(commitSha); if (ref.includes('writer-1')) return Promise.resolve(null); @@ -1629,18 +1629,18 @@ eg-schema: 2`; }); persistence.readBlob.mockResolvedValue(patchBuffer); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'writer-1', schema: 2, - }); + })); await graph.materialize(); // VV should have writer-other: 5 - expect(graph._versionVector.get('writer-other')).toBe(5); - expect(graph._versionVector.get('writer-1')).toBeUndefined(); + expect(/** @type {any} */ (graph)._versionVector.get('writer-other')).toBe(5); + expect(/** @type {any} */ (graph)._versionVector.get('writer-1')).toBeUndefined(); // Setup mocks for commit persistence.writeBlob.mockResolvedValue('c'.repeat(40)); @@ -1653,8 +1653,8 @@ eg-schema: 2`; await builder.commit(); // After commit: writer-1 should be 1, writer-other should still be 5 - expect(graph._versionVector.get('writer-1')).toBe(1); - expect(graph._versionVector.get('writer-other')).toBe(5); + expect(/** @type {any} */ (graph)._versionVector.get('writer-1')).toBe(1); + expect(/** @type {any} */ (graph)._versionVector.get('writer-other')).toBe(5); }); }); @@ -1666,12 +1666,12 @@ eg-schema: 2`; persistence.readRef.mockResolvedValueOnce(null); // During open() checkpoint check persistence.listRefs.mockResolvedValue([]); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'writer-1', schema: 2, - }); + })); // createPatch reads ref (returns null - first commit) persistence.readRef.mockResolvedValueOnce(null); @@ -1695,12 +1695,12 @@ eg-schema: 2`; persistence.readRef.mockResolvedValueOnce(null); // During open() checkpoint check persistence.listRefs.mockResolvedValue([]); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'writer-1', schema: 2, - }); + })); // Both builders read ref at creation time (both see null) persistence.readRef.mockResolvedValueOnce(null); // builder1 creation @@ -1736,7 +1736,7 @@ eg-schema: 2`; const existingSha = 'd'.repeat(40); const existingPatchOid = 'e'.repeat(40); - persistence.readRef.mockImplementation((ref) => { + persistence.readRef.mockImplementation((/** @type {any} */ ref) => { if (ref.includes('checkpoints')) return Promise.resolve(null); if (ref.includes('writers')) return Promise.resolve(existingSha); return Promise.resolve(null); @@ -1746,12 +1746,12 @@ eg-schema: 2`; `warp:patch\n\neg-kind: patch\neg-graph: events\neg-writer: writer-1\neg-lamport: 5\neg-patch-oid: ${existingPatchOid}\neg-schema: 2` ); - const graph = await WarpGraph.open({ + const graph = await WarpGraph.open(/** @type {any} */ ({ persistence, graphName: 'events', writerId: 'writer-1', schema: 2, - }); + })); const builder = await graph.createPatch(); builder.addNode('user:alice'); diff --git a/test/unit/domain/WarpGraph.timing.test.js b/test/unit/domain/WarpGraph.timing.test.js index b08a9ef1..663c0679 100644 --- a/test/unit/domain/WarpGraph.timing.test.js +++ b/test/unit/domain/WarpGraph.timing.test.js @@ -15,8 +15,11 @@ const crypto = new NodeCryptoAdapter(); */ describe('WarpGraph operation timing (LH/TIMING/1)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let logger; + /** @type {any} */ let clock; beforeEach(() => { @@ -44,7 +47,7 @@ describe('WarpGraph operation timing (LH/TIMING/1)', () => { clock, }); - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); expect(state).toBeDefined(); expect(logger.info).toHaveBeenCalledWith( @@ -97,7 +100,7 @@ describe('WarpGraph operation timing (LH/TIMING/1)', () => { }); // Should not throw even without logger - const state = await graph.materialize(); + const state = /** @type {any} */ (await graph.materialize()); expect(state).toBeDefined(); }); }); @@ -245,14 +248,14 @@ describe('WarpGraph operation timing (LH/TIMING/1)', () => { }); // Pre-cache state so sync doesn't need to materialize - graph._cachedState = createEmptyStateV5(); + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); graph.applySyncResponse = vi.fn().mockReturnValue({ applied: 5 }); graph.createSyncRequest = vi.fn().mockResolvedValue({ type: 'sync-request', frontier: {} }); const responsePayload = { type: 'sync-response', frontier: {}, patches: [] }; const peer = { processSyncRequest: vi.fn().mockResolvedValue(responsePayload) }; - const result = await graph.syncWith(peer); + const result = await graph.syncWith(/** @type {any} */ (peer)); expect(result.applied).toBe(5); expect(logger.info).toHaveBeenCalledWith( @@ -269,14 +272,14 @@ describe('WarpGraph operation timing (LH/TIMING/1)', () => { clock, }); - graph._cachedState = createEmptyStateV5(); + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); graph.applySyncResponse = vi.fn().mockReturnValue({ applied: 0 }); graph.createSyncRequest = vi.fn().mockResolvedValue({ type: 'sync-request', frontier: {} }); const responsePayload = { type: 'sync-response', frontier: {}, patches: [] }; const peer = { processSyncRequest: vi.fn().mockResolvedValue(responsePayload) }; - await graph.syncWith(peer); + await graph.syncWith(/** @type {any} */ (peer)); expect(clock.now).toHaveBeenCalled(); expect(clock.now.mock.calls.length).toBeGreaterThanOrEqual(2); @@ -291,14 +294,14 @@ describe('WarpGraph operation timing (LH/TIMING/1)', () => { clock, }); - graph._cachedState = createEmptyStateV5(); + /** @type {any} */ (graph)._cachedState = createEmptyStateV5(); graph.createSyncRequest = vi.fn().mockResolvedValue({ type: 'sync-request', frontier: {} }); const peer = { processSyncRequest: vi.fn().mockRejectedValue(new Error('peer unreachable')), }; - await expect(graph.syncWith(peer)).rejects.toThrow(); + await expect(graph.syncWith(/** @type {any} */ (peer))).rejects.toThrow(); expect(logger.info).toHaveBeenCalledWith( expect.stringMatching(/^\[warp\] syncWith failed in \d+ms$/), @@ -350,7 +353,7 @@ describe('WarpGraph operation timing (LH/TIMING/1)', () => { // The elapsed time should be a multiple of the step (150ms per call) const infoCall = logger.info.mock.calls.find( - (args) => typeof args[0] === 'string' && args[0].includes('materialize completed'), + (/** @type {any} */ args) => typeof args[0] === 'string' && args[0].includes('materialize completed'), ); expect(infoCall).toBeDefined(); expect(infoCall[0]).toMatch(/completed in \d+ms/); diff --git a/test/unit/domain/WarpGraph.traverse.test.js b/test/unit/domain/WarpGraph.traverse.test.js index baa82521..deea73cf 100644 --- a/test/unit/domain/WarpGraph.traverse.test.js +++ b/test/unit/domain/WarpGraph.traverse.test.js @@ -3,7 +3,9 @@ import WarpGraph from '../../../src/domain/WarpGraph.js'; import { addNodeToState, addEdgeToState, setupGraphState, createMockPersistence } from '../../helpers/warpGraphTestUtils.js'; describe('WarpGraph logical traversal', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -17,7 +19,7 @@ describe('WarpGraph logical traversal', () => { }); it('bfs visits neighbors in canonical order', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -30,7 +32,7 @@ describe('WarpGraph logical traversal', () => { }); it('dfs follows canonical neighbor order', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -43,7 +45,7 @@ describe('WarpGraph logical traversal', () => { }); it('shortestPath uses canonical tie-breaks', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -59,7 +61,7 @@ describe('WarpGraph logical traversal', () => { }); it('labelFilter supports string and array (OR semantics)', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); @@ -75,7 +77,7 @@ describe('WarpGraph logical traversal', () => { }); it('labelFilter empty array returns only the start node', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addEdgeToState(state, 'node:a', 'node:b', 'follows', 3); @@ -86,7 +88,7 @@ describe('WarpGraph logical traversal', () => { }); it('connectedComponent uses both directions', async () => { - setupGraphState(graph, (state) => { + setupGraphState(graph, (/** @type {any} */ state) => { addNodeToState(state, 'node:a', 1); addNodeToState(state, 'node:b', 2); addNodeToState(state, 'node:c', 3); diff --git a/test/unit/domain/WarpGraph.watch.test.js b/test/unit/domain/WarpGraph.watch.test.js index 3517db0b..9fb7d1aa 100644 --- a/test/unit/domain/WarpGraph.watch.test.js +++ b/test/unit/domain/WarpGraph.watch.test.js @@ -3,7 +3,9 @@ import WarpGraph from '../../../src/domain/WarpGraph.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; describe('WarpGraph.watch() (PL/WATCH/1)', () => { + /** @type {any} */ let repo; + /** @type {any} */ let graph; beforeEach(async () => { @@ -183,7 +185,7 @@ describe('WarpGraph.watch() (PL/WATCH/1)', () => { expect(onChange).toHaveBeenCalledTimes(1); const diff = onChange.mock.calls[0][0]; - expect(diff.props.set.some(p => p.nodeId === 'user:alice' && p.propKey === 'name')).toBe(true); + expect(diff.props.set.some((/** @type {any} */ p) => p.nodeId === 'user:alice' && p.propKey === 'name')).toBe(true); }); it('excludes props for non-matching nodes', async () => { @@ -436,7 +438,9 @@ describe('WarpGraph.watch() (PL/WATCH/1)', () => { }); describe('WarpGraph.watch() polling (PL/WATCH/2)', () => { + /** @type {any} */ let repo; + /** @type {any} */ let graph; beforeAll(() => { @@ -512,7 +516,7 @@ describe('WarpGraph.watch() polling (PL/WATCH/2)', () => { it('calls materialize when frontier has changed', async () => { const onChange = vi.fn(); const hasFrontierChangedSpy = vi.spyOn(graph, 'hasFrontierChanged').mockResolvedValue(true); - const materializeSpy = vi.spyOn(graph, 'materialize').mockResolvedValue(); + const materializeSpy = vi.spyOn(graph, 'materialize').mockResolvedValue(undefined); const { unsubscribe } = graph.watch('user:*', { onChange, poll: 1000 }); @@ -529,7 +533,7 @@ describe('WarpGraph.watch() polling (PL/WATCH/2)', () => { it('does not call materialize when frontier has not changed', async () => { const onChange = vi.fn(); const hasFrontierChangedSpy = vi.spyOn(graph, 'hasFrontierChanged').mockResolvedValue(false); - const materializeSpy = vi.spyOn(graph, 'materialize').mockResolvedValue(); + const materializeSpy = vi.spyOn(graph, 'materialize').mockResolvedValue(undefined); const { unsubscribe } = graph.watch('user:*', { onChange, poll: 1000 }); diff --git a/test/unit/domain/WarpGraph.writerApi.test.js b/test/unit/domain/WarpGraph.writerApi.test.js index 98380a85..056852cd 100644 --- a/test/unit/domain/WarpGraph.writerApi.test.js +++ b/test/unit/domain/WarpGraph.writerApi.test.js @@ -2,16 +2,18 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import WarpGraph from '../../../src/domain/WarpGraph.js'; describe('WarpGraph writer API', () => { + /** @type {any} */ let mockPersistence; + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -30,7 +32,7 @@ describe('WarpGraph writer API', () => { const w1 = await graph.writer(); // configSet should have been called to persist the generated ID const persistedId = mockPersistence.configSet.mock.calls.find( - ([key]) => key === 'warp.writerId.test', + (/** @type {any} */ [key]) => key === 'warp.writerId.test', )?.[1]; expect(persistedId).toBeTruthy(); @@ -63,6 +65,7 @@ describe('WarpGraph writer API', () => { }); it('createWriter() logs via logger when present', async () => { + /** @type {any} */ const mockLogger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const graphWithLogger = await WarpGraph.open({ persistence: mockPersistence, diff --git a/test/unit/domain/WarpGraph.writerInvalidation.test.js b/test/unit/domain/WarpGraph.writerInvalidation.test.js index d2f978e7..e141c244 100644 --- a/test/unit/domain/WarpGraph.writerInvalidation.test.js +++ b/test/unit/domain/WarpGraph.writerInvalidation.test.js @@ -27,7 +27,7 @@ const FAKE_COMMIT_SHA_2 = 'd'.repeat(40); * 3. PatchBuilderV2.commit() reads ref for its own CAS check * All return null for a first commit. */ -function mockWriterFirstCommit(persistence) { +function mockWriterFirstCommit(/** @type {any} */ persistence) { persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); persistence.writeTree.mockResolvedValue(FAKE_TREE_OID); @@ -42,7 +42,7 @@ function mockWriterFirstCommit(persistence) { * readRef returns FAKE_COMMIT_SHA (3 times), and showNode returns a valid * patch message so lamport can be extracted. */ -function mockWriterSecondCommit(persistence) { +function mockWriterSecondCommit(/** @type {any} */ persistence) { const patchMessage = encodePatchMessage({ graph: 'test', writer: 'writer-1', @@ -60,7 +60,9 @@ function mockWriterSecondCommit(persistence) { } describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let graph; beforeEach(async () => { @@ -79,23 +81,23 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((p) => p.addNode('test:node')); + await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); // Query reflects the commit immediately — no explicit materialize needed expect(await graph.hasNode('test:node')).toBe(true); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); it('writer.commitPatch() keeps _stateDirty false when _cachedState exists', async () => { await graph.materialize(); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((p) => p.addNode('test:node')); + await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); // Eager re-materialize applied the patch, so state is fresh - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); // ── writer.beginPatch() / patch.commit() two-step API ──────────── @@ -110,7 +112,7 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { await patch.commit(); expect(await graph.hasNode('test:node')).toBe(true); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); it('beginPatch() + setProperty reflected in getNodeProps() after commit', async () => { @@ -135,14 +137,14 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((p) => p.addNode('test:a')); - expect(graph._stateDirty).toBe(false); + await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:a')); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:a')).toBe(true); mockWriterSecondCommit(persistence); const writer2 = await graph.writer('writer-1'); - await writer2.commitPatch((p) => p.addNode('test:b')); - expect(graph._stateDirty).toBe(false); + await writer2.commitPatch((/** @type {any} */ p) => p.addNode('test:b')); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); expect(await graph.hasNode('test:b')).toBe(true); // Both nodes should be present @@ -155,10 +157,10 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { // No materialize() call — _cachedState is null mockWriterFirstCommit(persistence); const writer = await graph.writer('writer-1'); - await writer.commitPatch((p) => p.addNode('test:node')); + await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); // No _cachedState, so can't eagerly apply — dirty - expect(graph._stateDirty).toBe(true); + expect(/** @type {any} */ (graph)._stateDirty).toBe(true); }); // ── createWriter() path ────────────────────────────────────────── @@ -169,32 +171,32 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { mockWriterFirstCommit(persistence); const writer = await graph.createWriter(); - await writer.commitPatch((p) => p.addNode('test:node')); + await writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node')); expect(await graph.hasNode('test:node')).toBe(true); - expect(graph._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); }); // ── Failure cases ──────────────────────────────────────────────── it('writer commit failure (writeBlob rejects) does not corrupt state', async () => { await graph.materialize(); - const stateBeforeAttempt = graph._cachedState; + const stateBeforeAttempt = /** @type {any} */ (graph)._cachedState; persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockRejectedValue(new Error('disk full')); const writer = await graph.writer('writer-1'); - await expect(writer.commitPatch((p) => p.addNode('test:node'))).rejects.toThrow('disk full'); + await expect(writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node'))).rejects.toThrow('disk full'); // State should be unchanged - expect(graph._stateDirty).toBe(false); - expect(graph._cachedState).toBe(stateBeforeAttempt); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._cachedState).toBe(stateBeforeAttempt); }); it('writer commit failure (updateRef rejects) does not corrupt state', async () => { await graph.materialize(); - const stateBeforeAttempt = graph._cachedState; + const stateBeforeAttempt = /** @type {any} */ (graph)._cachedState; persistence.readRef.mockResolvedValue(null); persistence.writeBlob.mockResolvedValue(FAKE_BLOB_OID); @@ -203,15 +205,15 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { persistence.updateRef.mockRejectedValue(new Error('ref lock failed')); const writer = await graph.writer('writer-1'); - await expect(writer.commitPatch((p) => p.addNode('test:node'))).rejects.toThrow('ref lock failed'); + await expect(writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node'))).rejects.toThrow('ref lock failed'); - expect(graph._stateDirty).toBe(false); - expect(graph._cachedState).toBe(stateBeforeAttempt); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._cachedState).toBe(stateBeforeAttempt); }); it('writer commit failure (CAS race in PatchSession) does not corrupt state', async () => { await graph.materialize(); - const stateBeforeAttempt = graph._cachedState; + const stateBeforeAttempt = /** @type {any} */ (graph)._cachedState; // beginPatch() sees null, but by the time PatchSession.commit() checks, ref has advanced persistence.readRef @@ -219,9 +221,9 @@ describe('WarpGraph Writer invalidation (AP/INVAL/3)', () => { .mockResolvedValueOnce(FAKE_COMMIT_SHA); // PatchSession.commit() — CAS pre-check const writer = await graph.writer('writer-1'); - await expect(writer.commitPatch((p) => p.addNode('test:node'))).rejects.toThrow(); + await expect(writer.commitPatch((/** @type {any} */ p) => p.addNode('test:node'))).rejects.toThrow(); - expect(graph._stateDirty).toBe(false); - expect(graph._cachedState).toBe(stateBeforeAttempt); + expect(/** @type {any} */ (graph)._stateDirty).toBe(false); + expect(/** @type {any} */ (graph)._cachedState).toBe(stateBeforeAttempt); }); }); diff --git a/test/unit/domain/crdt/Dot.test.js b/test/unit/domain/crdt/Dot.test.js index 464f90f4..6a6e91e7 100644 --- a/test/unit/domain/crdt/Dot.test.js +++ b/test/unit/domain/crdt/Dot.test.js @@ -1,12 +1,15 @@ import { describe, it, expect } from 'vitest'; import { - createDot, + createDot as _createDot, dotsEqual, encodeDot, decodeDot, compareDots, } from '../../../../src/domain/crdt/Dot.js'; +/** @type {any} */ +const createDot = _createDot; + describe('Dot', () => { describe('createDot', () => { it('creates a dot with writerId and counter', () => { diff --git a/test/unit/domain/crdt/LWW.test.js b/test/unit/domain/crdt/LWW.test.js index bda1d4b1..85562cfa 100644 --- a/test/unit/domain/crdt/LWW.test.js +++ b/test/unit/domain/crdt/LWW.test.js @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { lwwSet, lwwMax, lwwValue } from '../../../../src/domain/crdt/LWW.js'; +import { lwwSet, lwwMax as _lwwMax, lwwValue } from '../../../../src/domain/crdt/LWW.js'; import { createEventId } from '../../../../src/domain/utils/EventId.js'; +/** @type {any} */ +const lwwMax = _lwwMax; + describe('LWW Register', () => { describe('lwwSet', () => { it('creates register with eventId and value', () => { diff --git a/test/unit/domain/crdt/ORSet.test.js b/test/unit/domain/crdt/ORSet.test.js index a90c4faa..ddc94eaf 100644 --- a/test/unit/domain/crdt/ORSet.test.js +++ b/test/unit/domain/crdt/ORSet.test.js @@ -14,6 +14,9 @@ import { import { createDot, encodeDot } from '../../../../src/domain/crdt/Dot.js'; import { createVersionVector } from '../../../../src/domain/crdt/VersionVector.js'; +/** @param {Map} map @param {any} key @returns {any} */ +const getEntry = (map, key) => map.get(key); + describe('ORSet', () => { describe('createORSet', () => { it('creates empty ORSet', () => { @@ -34,7 +37,7 @@ describe('ORSet', () => { orsetAdd(set, 'element1', dot); expect(set.entries.has('element1')).toBe(true); - expect(set.entries.get('element1').has(encodeDot(dot))).toBe(true); + expect(getEntry(set.entries,'element1').has(encodeDot(dot))).toBe(true); }); it('adds multiple dots to same element', () => { @@ -45,9 +48,9 @@ describe('ORSet', () => { orsetAdd(set, 'element1', dot1); orsetAdd(set, 'element1', dot2); - expect(set.entries.get('element1').size).toBe(2); - expect(set.entries.get('element1').has(encodeDot(dot1))).toBe(true); - expect(set.entries.get('element1').has(encodeDot(dot2))).toBe(true); + expect(getEntry(set.entries,'element1').size).toBe(2); + expect(getEntry(set.entries,'element1').has(encodeDot(dot1))).toBe(true); + expect(getEntry(set.entries,'element1').has(encodeDot(dot2))).toBe(true); }); it('adds same dot twice (idempotent)', () => { @@ -57,7 +60,7 @@ describe('ORSet', () => { orsetAdd(set, 'element1', dot); orsetAdd(set, 'element1', dot); - expect(set.entries.get('element1').size).toBe(1); + expect(getEntry(set.entries,'element1').size).toBe(1); }); it('adds different elements', () => { @@ -245,7 +248,7 @@ describe('ORSet', () => { expect(ab.entries.size).toBe(ba.entries.size); for (const [element, dots] of ab.entries) { expect(ba.entries.has(element)).toBe(true); - expect([...dots].sort()).toEqual([...ba.entries.get(element)].sort()); + expect([...dots].sort()).toEqual([...getEntry(ba.entries, element)].sort()); } }); @@ -275,7 +278,7 @@ describe('ORSet', () => { expect(left.entries.size).toBe(right.entries.size); for (const [element, dots] of left.entries) { expect(right.entries.has(element)).toBe(true); - expect([...dots].sort()).toEqual([...right.entries.get(element)].sort()); + expect([...dots].sort()).toEqual([...getEntry(right.entries, element)].sort()); } }); @@ -300,7 +303,7 @@ describe('ORSet', () => { expect(result.entries.size).toBe(a.entries.size); for (const [element, dots] of result.entries) { expect(a.entries.has(element)).toBe(true); - expect([...dots].sort()).toEqual([...a.entries.get(element)].sort()); + expect([...dots].sort()).toEqual([...getEntry(a.entries, element)].sort()); } }); }); @@ -332,7 +335,7 @@ describe('ORSet', () => { const result = orsetJoin(a, b); - expect(result.entries.get('element1').size).toBe(2); + expect(getEntry(result.entries, 'element1').size).toBe(2); }); it('unions tombstones', () => { @@ -481,7 +484,7 @@ describe('ORSet', () => { // Dot should still be there (CRITICAL: never remove live dots) expect(set.entries.has('element1')).toBe(true); - expect(set.entries.get('element1').has(encodeDot(dot))).toBe(true); + expect(getEntry(set.entries,'element1').has(encodeDot(dot))).toBe(true); }); it('does NOT remove tombstoned dots that are > includedVV', () => { @@ -534,8 +537,8 @@ describe('ORSet', () => { // dot1 compacted, dot2 still there expect(set.entries.has('element1')).toBe(true); - expect(set.entries.get('element1').has(encodeDot(dot1))).toBe(false); - expect(set.entries.get('element1').has(encodeDot(dot2))).toBe(true); + expect(getEntry(set.entries,'element1').has(encodeDot(dot1))).toBe(false); + expect(getEntry(set.entries,'element1').has(encodeDot(dot2))).toBe(true); expect(set.tombstones.has(encodeDot(dot1))).toBe(false); expect(set.tombstones.has(encodeDot(dot2))).toBe(true); }); diff --git a/test/unit/domain/crdt/VersionVector.test.js b/test/unit/domain/crdt/VersionVector.test.js index 7199e90d..cb3a184a 100644 --- a/test/unit/domain/crdt/VersionVector.test.js +++ b/test/unit/domain/crdt/VersionVector.test.js @@ -6,12 +6,15 @@ import { vvDescends, vvContains, vvSerialize, - vvDeserialize, + vvDeserialize as _vvDeserialize, vvClone, vvEqual, } from '../../../../src/domain/crdt/VersionVector.js'; import { createDot } from '../../../../src/domain/crdt/Dot.js'; +/** @type {any} */ +const vvDeserialize = _vvDeserialize; + describe('VersionVector', () => { describe('createVersionVector', () => { it('creates an empty version vector', () => { diff --git a/test/unit/domain/entities/GraphNode.test.js b/test/unit/domain/entities/GraphNode.test.js index 1c689cf7..77e2f448 100644 --- a/test/unit/domain/entities/GraphNode.test.js +++ b/test/unit/domain/entities/GraphNode.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import GraphNode from '../../../../src/domain/entities/GraphNode.js'; +import GraphNode_ from '../../../../src/domain/entities/GraphNode.js'; + +/** @type {any} */ +const GraphNode = GraphNode_; describe('GraphNode', () => { describe('construction with valid data', () => { diff --git a/test/unit/domain/errors/ForkError.test.js b/test/unit/domain/errors/ForkError.test.js index cc8d9546..6ef07c29 100644 --- a/test/unit/domain/errors/ForkError.test.js +++ b/test/unit/domain/errors/ForkError.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import ForkError from '../../../../src/domain/errors/ForkError.js'; +import ForkError_ from '../../../../src/domain/errors/ForkError.js'; + +/** @type {any} */ +const ForkError = ForkError_; describe('ForkError', () => { it('constructs with default options', () => { diff --git a/test/unit/domain/errors/WarpError.test.js b/test/unit/domain/errors/WarpError.test.js index da083615..48ef8cbb 100644 --- a/test/unit/domain/errors/WarpError.test.js +++ b/test/unit/domain/errors/WarpError.test.js @@ -64,7 +64,9 @@ describe('All domain errors extend WarpError', () => { { Class: SchemaUnsupportedError, args: ['schema fail'], expectedCode: 'E_SCHEMA_UNSUPPORTED', expectedName: 'SchemaUnsupportedError' }, ]; - for (const { Class, args, expectedCode, expectedName } of errorCases) { + for (const { Class: _Class, args, expectedCode, expectedName } of errorCases) { + /** @type {any} */ + const Class = _Class; it(`${expectedName} instanceof WarpError`, () => { const err = new Class(...args); expect(err).toBeInstanceOf(WarpError); diff --git a/test/unit/domain/index.exports.test.js b/test/unit/domain/index.exports.test.js index 906f5a8c..6e6529c9 100644 --- a/test/unit/domain/index.exports.test.js +++ b/test/unit/domain/index.exports.test.js @@ -50,9 +50,6 @@ import WarpGraphDefault, { checkAborted, createTimeoutSignal, - // Multi-writer graph support (WARP) - WarpGraph, - // WARP type creators createNodeAdd, createNodeTombstone, @@ -64,6 +61,10 @@ import WarpGraphDefault, { createEventId, } from '../../../index.js'; +// WarpGraph is both default and named export; index.d.ts only declares +// the default, so we pull the named export via dynamic import to avoid TS2614. +const { WarpGraph } = /** @type {any} */ (await import('../../../index.js')); + describe('index.js exports', () => { describe('default export', () => { it('exports WarpGraph as default', () => { diff --git a/test/unit/domain/parseCursorBlob.test.js b/test/unit/domain/parseCursorBlob.test.js index 41690712..02d81dbe 100644 --- a/test/unit/domain/parseCursorBlob.test.js +++ b/test/unit/domain/parseCursorBlob.test.js @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { parseCursorBlob } from '../../../src/domain/utils/parseCursorBlob.js'; describe('parseCursorBlob', () => { + /** @param {string} str */ function buf(str) { return Buffer.from(str, 'utf8'); } diff --git a/test/unit/domain/properties/Join.property.test.js b/test/unit/domain/properties/Join.property.test.js index 393b18f8..a78c1bf1 100644 --- a/test/unit/domain/properties/Join.property.test.js +++ b/test/unit/domain/properties/Join.property.test.js @@ -2,13 +2,20 @@ import { describe, it, expect } from 'vitest'; import fc from 'fast-check'; import { createEmptyStateV5, - joinStates, + joinStates as _joinStates, join, - reduceV5, + reduceV5 as _reduceV5, } from '../../../../src/domain/services/JoinReducer.js'; -import { computeStateHashV5 } from '../../../../src/domain/services/StateSerializerV5.js'; +import { computeStateHashV5 as _computeStateHashV5 } from '../../../../src/domain/services/StateSerializerV5.js'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.js'; +/** @type {any} */ +const joinStates = _joinStates; +/** @type {any} */ +const reduceV5 = _reduceV5; +/** @type {any} */ +const computeStateHashV5 = _computeStateHashV5; + const crypto = new NodeCryptoAdapter(); import { createORSet, orsetAdd, orsetRemove, orsetSerialize } from '../../../../src/domain/crdt/ORSet.js'; import { createVersionVector, vvSerialize } from '../../../../src/domain/crdt/VersionVector.js'; @@ -74,6 +81,7 @@ const eventIdArb = fc.record({ /** * Generates a random ORSet with elements and tombstones */ +/** @param {any} elements @param {any} dotArbitrary */ function generateORSet(elements, dotArbitrary) { return fc.array( fc.record({ @@ -143,6 +151,7 @@ const stateArb = fc.record({ /** * Checks if two states are structurally equal */ +/** @param {any} a @param {any} b */ function statesEqual(a, b) { // Compare nodeAlive ORSets if (JSON.stringify(orsetSerialize(a.nodeAlive)) !== JSON.stringify(orsetSerialize(b.nodeAlive))) { diff --git a/test/unit/domain/properties/ORSet.property.test.js b/test/unit/domain/properties/ORSet.property.test.js index 32762872..ab893ef1 100644 --- a/test/unit/domain/properties/ORSet.property.test.js +++ b/test/unit/domain/properties/ORSet.property.test.js @@ -68,6 +68,7 @@ const orsetArb = fc.array(operationArb, { minLength: 0, maxLength: 10 }).map((op /** * Checks if two ORSets are structurally equal using serialization */ +/** @param {any} a @param {any} b */ function orsetEqual(a, b) { return JSON.stringify(orsetSerialize(a)) === JSON.stringify(orsetSerialize(b)); } @@ -134,7 +135,7 @@ describe('ORSet property tests', () => { return false; } for (const dot of dots) { - if (!joined.entries.get(element).has(dot)) { + if (!(/** @type {any} */ (joined.entries.get(element))).has(dot)) { return false; } } diff --git a/test/unit/domain/seekCache.test.js b/test/unit/domain/seekCache.test.js new file mode 100644 index 00000000..90d30395 --- /dev/null +++ b/test/unit/domain/seekCache.test.js @@ -0,0 +1,405 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import WarpGraph from '../../../src/domain/WarpGraph.js'; +import { buildSeekCacheKey } from '../../../src/domain/utils/seekCacheKey.js'; +import { encode } from '../../../src/infrastructure/codecs/CborCodec.js'; +import { encodePatchMessage } from '../../../src/domain/services/WarpMessageCodec.js'; +import { createMockPersistence } from '../../helpers/warpGraphTestUtils.js'; + +// --------------------------------------------------------------------------- +// 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: { 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(); + /** @type {Record} */ + const writerTips = {}; + + for (const [writer, count] of Object.entries(writerSpecs)) { + const shas = []; + for (let i = 1; i <= count; i++) { + shas.push(fakeSha(`${writer}${i}`)); + } + writerTips[writer] = shas[0]; + + for (let j = 0; j < count; j++) { + const lamport = count - j; + const patchOid = fakeSha(`blob-${writer}-${lamport}`); + const message = encodePatchMessage({ + graph: graphName, + writer, + lamport, + patchOid, + schema: 2, + }); + const parents = j < count - 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. + */ +function createMockSeekCache() { + const store = new Map(); + return { + get: vi.fn(async (key) => store.get(key) ?? null), + set: vi.fn(async (key, buf) => { store.set(key, buf); }), + has: vi.fn(async (key) => store.has(key)), + keys: vi.fn(async () => [...store.keys()]), + delete: vi.fn(async (key) => store.delete(key)), + clear: vi.fn(async () => { store.clear(); }), + _store: store, + }; +} + +// =========================================================================== +// seekCacheKey utility +// =========================================================================== + +describe('buildSeekCacheKey', () => { + it('produces deterministic keys for identical inputs', () => { + const frontier = new Map([['alice', 'aaa'], ['bob', 'bbb']]); + const k1 = buildSeekCacheKey(5, frontier); + const k2 = buildSeekCacheKey(5, frontier); + expect(k1).toBe(k2); + }); + + it('starts with version prefix', () => { + const key = buildSeekCacheKey(10, new Map([['w1', 'sha1']])); + expect(key).toMatch(/^v1:t10-/); + }); + + it('uses full 64-char SHA-256 hex digest', () => { + const key = buildSeekCacheKey(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', () => { + const f = new Map([['w', 'sha']]); + expect(buildSeekCacheKey(1, f)).not.toBe(buildSeekCacheKey(2, f)); + }); + + it('differs when frontier changes', () => { + const f1 = new Map([['w', 'sha1']]); + const f2 = new Map([['w', 'sha2']]); + expect(buildSeekCacheKey(1, f1)).not.toBe(buildSeekCacheKey(1, f2)); + }); + + it('is order-independent for frontier entries', () => { + const f1 = new Map([['alice', 'a'], ['bob', 'b']]); + const f2 = new Map([['bob', 'b'], ['alice', 'a']]); + expect(buildSeekCacheKey(1, f1)).toBe(buildSeekCacheKey(1, f2)); + }); +}); + +// =========================================================================== +// WarpGraph seek cache integration (mock cache) +// =========================================================================== + +describe('WarpGraph seek cache integration', () => { + /** @type {any} */ + let persistence; + /** @type {any} */ + let seekCache; + + beforeEach(() => { + persistence = createMockPersistence(); + seekCache = createMockSeekCache(); + }); + + it('stores state to cache on first ceiling materialize', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + await graph.materialize({ ceiling: 2 }); + + expect(seekCache.set).toHaveBeenCalledTimes(1); + const [key, buf] = seekCache.set.mock.calls[0]; + expect(key).toMatch(/^v1:t2-/); + expect(Buffer.isBuffer(buf)).toBe(true); + }); + + it('restores state from cache on second visit to same tick', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + // First visit — full materialize, stores to cache + await graph.materialize({ ceiling: 2 }); + const getCallsBefore = seekCache.get.mock.calls.length; + + // Clear in-memory cache to force persistent cache path + graph._cachedState = null; + graph._cachedCeiling = null; + graph._cachedFrontier = null; + + // Second visit — should hit persistent cache + await graph.materialize({ ceiling: 2 }); + + // get() called at least once more + expect(seekCache.get.mock.calls.length).toBeGreaterThan(getCallsBefore); + // No additional set() call (already cached) + expect(seekCache.set).toHaveBeenCalledTimes(1); + }); + + it('skips cache when collectReceipts is true', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + await graph.materialize({ ceiling: 2, receipts: true }); + + expect(seekCache.get).not.toHaveBeenCalled(); + expect(seekCache.set).not.toHaveBeenCalled(); + }); + + it('does not store when no patches match ceiling', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + await graph.materialize({ ceiling: 0 }); + + expect(seekCache.set).not.toHaveBeenCalled(); + }); + + it('sets _provenanceDegraded on cache hit', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + // First materialize — populates cache + await graph.materialize({ ceiling: 2 }); + expect(graph._provenanceDegraded).toBe(false); + + // Force persistent cache path + graph._cachedState = null; + graph._cachedCeiling = null; + graph._cachedFrontier = null; + + // Second materialize — hits cache + await graph.materialize({ ceiling: 2 }); + expect(graph._provenanceDegraded).toBe(true); + }); + + it('throws E_PROVENANCE_DEGRADED on patchesFor after cache hit', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + await graph.materialize({ ceiling: 2 }); + + // Force cache hit + graph._cachedState = null; + graph._cachedCeiling = null; + graph._cachedFrontier = null; + await graph.materialize({ ceiling: 2 }); + + await expect(graph.patchesFor('n:w1:1')).rejects.toThrow(/Provenance unavailable/); + }); + + it('clears _provenanceDegraded on full materialize', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + await graph.materialize({ ceiling: 2 }); + graph._cachedState = null; + graph._cachedCeiling = null; + graph._cachedFrontier = null; + await graph.materialize({ ceiling: 2 }); + expect(graph._provenanceDegraded).toBe(true); + + // Full materialize without ceiling clears degraded flag + await graph.materialize(); + expect(graph._provenanceDegraded).toBe(false); + }); + + it('gracefully handles cache get() failure', async () => { + setupPersistence(persistence, { w1: 3 }); + seekCache.get.mockRejectedValue(new Error('storage error')); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + // Should not throw — falls through to full materialize + const state = /** @type {any} */ (await graph.materialize({ ceiling: 2 })); + expect(state).toBeDefined(); + expect(state.nodeAlive).toBeDefined(); + }); + + it('gracefully handles cache set() failure', async () => { + setupPersistence(persistence, { w1: 3 }); + seekCache.set.mockRejectedValue(new Error('storage error')); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + // Should not throw — cache write failure is non-fatal + const state = await graph.materialize({ ceiling: 2 }); + expect(state).toBeDefined(); + }); + + it('works without seekCache (null)', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + }); + + const state = await graph.materialize({ ceiling: 2 }); + expect(state).toBeDefined(); + expect(graph._seekCache).toBeNull(); + }); + + it('setSeekCache(null) detaches the cache', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + expect(graph.seekCache).toBe(seekCache); + graph.setSeekCache(/** @type {any} */ (null)); + expect(graph.seekCache).toBeNull(); + + // Materialize should still work without cache + const state = await graph.materialize({ ceiling: 2 }); + expect(state).toBeDefined(); + expect(seekCache.get).not.toHaveBeenCalled(); + expect(seekCache.set).not.toHaveBeenCalled(); + }); + + it('deletes corrupted cache entry on deserialize failure', async () => { + setupPersistence(persistence, { w1: 3 }); + const graph = await WarpGraph.open({ + persistence, + graphName: 'test', + writerId: 'w1', + seekCache, + }); + + // First materialize populates cache + await graph.materialize({ ceiling: 2 }); + expect(seekCache.set).toHaveBeenCalledTimes(1); + const [cacheKey] = seekCache.set.mock.calls[0]; + + // Corrupt the cached data + seekCache._store.set(cacheKey, Buffer.from('corrupted-data')); + + // Clear in-memory cache + graph._cachedState = null; + graph._cachedCeiling = null; + graph._cachedFrontier = null; + + // Second materialize should self-heal: delete bad entry and re-materialize + const state = /** @type {any} */ (await graph.materialize({ ceiling: 2 })); + expect(state).toBeDefined(); + expect(state.nodeAlive).toBeDefined(); + expect(seekCache.delete).toHaveBeenCalledWith(cacheKey); + }); +}); diff --git a/test/unit/domain/services/BitmapIndexBuilder.frontier.test.js b/test/unit/domain/services/BitmapIndexBuilder.frontier.test.js index f0745657..124fdb26 100644 --- a/test/unit/domain/services/BitmapIndexBuilder.frontier.test.js +++ b/test/unit/domain/services/BitmapIndexBuilder.frontier.test.js @@ -53,7 +53,7 @@ describe('BitmapIndexBuilder frontier metadata (GK/IDX/1)', () => { ]); const tree = await builder.serialize({ frontier }); - const envelope = cborDecode(tree['frontier.cbor']); + const envelope = /** @type {any} */ (cborDecode(tree['frontier.cbor'])); expect(envelope.version).toBe(1); expect(envelope.writerCount).toBe(2); @@ -97,7 +97,7 @@ describe('BitmapIndexBuilder frontier metadata (GK/IDX/1)', () => { const frontier = new Map(); const tree = await builder.serialize({ frontier }); - const envelope = cborDecode(tree['frontier.cbor']); + const envelope = /** @type {any} */ (cborDecode(tree['frontier.cbor'])); expect(envelope.version).toBe(1); expect(envelope.writerCount).toBe(0); diff --git a/test/unit/domain/services/BitmapIndexReader.test.js b/test/unit/domain/services/BitmapIndexReader.test.js index a3ab9eb5..cfb6ea29 100644 --- a/test/unit/domain/services/BitmapIndexReader.test.js +++ b/test/unit/domain/services/BitmapIndexReader.test.js @@ -10,7 +10,7 @@ const crypto = new NodeCryptoAdapter(); /** * Creates a v1 shard envelope using JSON.stringify for checksum (legacy format). */ -const createV1Shard = (data) => ({ +const createV1Shard = (/** @type {any} */ data) => ({ version: 1, checksum: createHash('sha256').update(JSON.stringify(data)).digest('hex'), data, @@ -20,6 +20,7 @@ const createV1Shard = (data) => ({ * Produces a canonical JSON string with deterministic key ordering. * Mirrors the canonicalStringify function used in BitmapIndexBuilder. */ +/** @type {(obj: any) => string} */ const canonicalStringify = (obj) => { if (obj === null || typeof obj !== 'object') { return JSON.stringify(obj); @@ -34,26 +35,30 @@ const canonicalStringify = (obj) => { /** * Creates a v2 shard envelope using canonicalStringify for checksum (current format). */ -const createV2Shard = (data) => ({ +const createV2Shard = (/** @type {any} */ data) => ({ version: 2, checksum: createHash('sha256').update(canonicalStringify(data)).digest('hex'), data, }); describe('BitmapIndexReader', () => { + /** @type {any} */ + /** @type {any} */ let mockStorage; + /** @type {any} */ + /** @type {any} */ let reader; beforeEach(() => { mockStorage = { readBlob: vi.fn(), }; - reader = new BitmapIndexReader({ storage: mockStorage }); + reader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage })); }); describe('constructor validation', () => { it('throws when storage is not provided', () => { - expect(() => new BitmapIndexReader({})).toThrow('BitmapIndexReader requires a storage adapter'); + expect(() => new BitmapIndexReader(/** @type {any} */ ({}))).toThrow('BitmapIndexReader requires a storage adapter'); }); it('throws when called with no arguments', () => { @@ -61,12 +66,12 @@ describe('BitmapIndexReader', () => { }); it('uses default maxCachedShards of 100', () => { - const readerWithDefaults = new BitmapIndexReader({ storage: mockStorage }); + const readerWithDefaults = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage })); expect(readerWithDefaults.maxCachedShards).toBe(100); }); it('accepts custom maxCachedShards', () => { - const readerWithCustom = new BitmapIndexReader({ storage: mockStorage, maxCachedShards: 50 }); + const readerWithCustom = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, maxCachedShards: 50 })); expect(readerWithCustom.maxCachedShards).toBe(50); }); }); @@ -102,7 +107,7 @@ describe('BitmapIndexReader', () => { const tree = await builder.serialize(); // Mock storage to return serialized data - mockStorage.readBlob.mockImplementation(async (oid) => { + mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { if (oid === 'meta-oid') return tree['meta_aa.json'] || tree['meta_ee.json']; if (oid === 'rev-oid') return tree['shards_rev_ee.json']; return Buffer.from('{}'); @@ -170,7 +175,7 @@ describe('BitmapIndexReader', () => { const tree = await builder.serialize(); let callCount = 0; - mockStorage.readBlob.mockImplementation(async (oid) => { + mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { callCount++; // First call fails, subsequent calls succeed if (callCount === 1) { @@ -198,7 +203,7 @@ describe('BitmapIndexReader', () => { }); it('in strict mode throws ShardValidationError on version mismatch', async () => { - const strictReader = new BitmapIndexReader({ storage: mockStorage, strict: true }); + const strictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: true })); mockStorage.readBlob.mockResolvedValue(Buffer.from(JSON.stringify({ version: 999, // Wrong version checksum: 'abc', @@ -213,7 +218,7 @@ describe('BitmapIndexReader', () => { }); it('in strict mode throws ShardCorruptionError on invalid format', async () => { - const strictReader = new BitmapIndexReader({ storage: mockStorage, strict: true }); + const strictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: true })); mockStorage.readBlob.mockResolvedValue(Buffer.from('not valid json {{{')); strictReader.setup({ @@ -224,7 +229,7 @@ describe('BitmapIndexReader', () => { }); it('in strict mode throws ShardValidationError on checksum mismatch', async () => { - const strictReader = new BitmapIndexReader({ storage: mockStorage, strict: true, crypto }); + const strictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: true, crypto })); mockStorage.readBlob.mockResolvedValue(Buffer.from(JSON.stringify({ version: 1, checksum: 'wrong-checksum-value', @@ -239,7 +244,7 @@ describe('BitmapIndexReader', () => { }); it('error objects contain useful context for debugging', async () => { - const strictReader = new BitmapIndexReader({ storage: mockStorage, strict: true }); + const strictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: true })); mockStorage.readBlob.mockResolvedValue(Buffer.from(JSON.stringify({ version: 999, checksum: 'abc', @@ -253,7 +258,7 @@ describe('BitmapIndexReader', () => { try { await strictReader.getChildren('cdcd1234'); expect.fail('Should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(ShardValidationError); expect(err.code).toBe('SHARD_VALIDATION_ERROR'); expect(err.field).toBe('version'); @@ -274,7 +279,7 @@ describe('BitmapIndexReader', () => { try { await reader.lookupId('efgh5678'); expect.fail('Should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(ShardLoadError); expect(err.code).toBe('SHARD_LOAD_ERROR'); expect(err.shardPath).toBe('meta_ef.json'); @@ -287,7 +292,7 @@ describe('BitmapIndexReader', () => { const corruptData = Buffer.from('{"not": "a valid shard format"}'); // Non-strict reader (default) - const nonStrictReader = new BitmapIndexReader({ storage: mockStorage, strict: false }); + const nonStrictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: false })); mockStorage.readBlob.mockResolvedValue(corruptData); nonStrictReader.setup({ 'shards_rev_ab.json': 'corrupt-oid' }); @@ -295,7 +300,7 @@ describe('BitmapIndexReader', () => { expect(nonStrictResult).toEqual([]); // Graceful degradation // Strict reader - const strictReader = new BitmapIndexReader({ storage: mockStorage, strict: true }); + const strictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: true })); strictReader.setup({ 'shards_rev_ab.json': 'corrupt-oid' }); await expect(strictReader.getParents('abcd1234')).rejects.toThrow(ShardCorruptionError); @@ -308,11 +313,11 @@ describe('BitmapIndexReader', () => { warn: vi.fn(), error: vi.fn(), }; - const nonStrictReader = new BitmapIndexReader({ + const nonStrictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: false, logger: mockLogger, - }); + })); // Return data with wrong version (validation failure) mockStorage.readBlob.mockResolvedValue(Buffer.from(JSON.stringify({ @@ -347,11 +352,11 @@ describe('BitmapIndexReader', () => { warn: vi.fn(), error: vi.fn(), }; - const nonStrictReader = new BitmapIndexReader({ + const nonStrictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: false, logger: mockLogger, - }); + })); // Return invalid JSON (parse error) mockStorage.readBlob.mockResolvedValue(Buffer.from('not valid json {{{')); @@ -407,7 +412,7 @@ describe('BitmapIndexReader', () => { }); it('v2 checksum mismatch throws ShardValidationError in strict mode', async () => { - const strictReader = new BitmapIndexReader({ storage: mockStorage, strict: true, crypto }); + const strictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: true, crypto })); // Create v2 shard with intentionally wrong checksum const v2ShardWithBadChecksum = { @@ -427,7 +432,7 @@ describe('BitmapIndexReader', () => { // Verify the error contains the expected context try { await strictReader.lookupId('abcd1234'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.field).toBe('checksum'); expect(err.shardPath).toBe('meta_ab.json'); } @@ -440,12 +445,12 @@ describe('BitmapIndexReader', () => { warn: vi.fn(), error: vi.fn(), }; - const nonStrictReader = new BitmapIndexReader({ + const nonStrictReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: false, logger: mockLogger, crypto, - }); + })); // Create v2 shard with intentionally wrong checksum const v2ShardWithBadChecksum = { @@ -533,19 +538,19 @@ describe('BitmapIndexReader', () => { describe('LRU cache eviction', () => { it('evicts least recently used shards when exceeding maxCachedShards', async () => { // Create reader with small cache size - const smallCacheReader = new BitmapIndexReader({ + const smallCacheReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, maxCachedShards: 2 - }); + })); // Create valid shard data - const createValidShard = (id) => Buffer.from(JSON.stringify({ + const createValidShard = (/** @type {any} */ id) => Buffer.from(JSON.stringify({ version: 1, checksum: createHash('sha256').update(JSON.stringify({ id })).digest('hex'), data: { id } })); - mockStorage.readBlob.mockImplementation(async (oid) => { + mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { return createValidShard(oid); }); @@ -575,18 +580,18 @@ describe('BitmapIndexReader', () => { }); it('marks accessed shards as recently used', async () => { - const smallCacheReader = new BitmapIndexReader({ + const smallCacheReader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, maxCachedShards: 2 - }); + })); - const createValidShard = (id) => Buffer.from(JSON.stringify({ + const createValidShard = (/** @type {any} */ id) => Buffer.from(JSON.stringify({ version: 1, checksum: createHash('sha256').update(JSON.stringify({ id })).digest('hex'), data: { id } })); - mockStorage.readBlob.mockImplementation(async (oid) => { + mockStorage.readBlob.mockImplementation(async (/** @type {any} */ oid) => { return createValidShard(oid); }); diff --git a/test/unit/domain/services/BoundaryTransitionRecord.test.js b/test/unit/domain/services/BoundaryTransitionRecord.test.js index 6f4e4910..085e8d2a 100644 --- a/test/unit/domain/services/BoundaryTransitionRecord.test.js +++ b/test/unit/domain/services/BoundaryTransitionRecord.test.js @@ -13,10 +13,12 @@ import { import ProvenancePayload from '../../../../src/domain/services/ProvenancePayload.js'; import { createEmptyStateV5, - reduceV5, + reduceV5 as _reduceV5, encodeEdgeKey, encodePropKey, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { computeStateHashV5 } from '../../../../src/domain/services/StateSerializerV5.js'; import { orsetContains } from '../../../../src/domain/crdt/ORSet.js'; import { lwwValue } from '../../../../src/domain/crdt/LWW.js'; @@ -119,9 +121,9 @@ describe('BoundaryTransitionRecord', () => { it('throws TypeError for non-ProvenancePayload', async () => { const initialState = createEmptyStateV5(); - await expect(createBTR(initialState, [], { key: testKey, crypto })).rejects.toThrow(TypeError); - await expect(createBTR(initialState, {}, { key: testKey, crypto })).rejects.toThrow(TypeError); - await expect(createBTR(initialState, null, { key: testKey, crypto })).rejects.toThrow(TypeError); + await expect(createBTR(initialState, /** @type {any} */ ([]), { key: testKey, crypto })).rejects.toThrow(TypeError); + await expect(createBTR(initialState, /** @type {any} */ ({}), { key: testKey, crypto })).rejects.toThrow(TypeError); + await expect(createBTR(initialState, /** @type {any} */ (null), { key: testKey, crypto })).rejects.toThrow(TypeError); }); it('produces different kappa for different keys', async () => { @@ -238,14 +240,14 @@ describe('BoundaryTransitionRecord', () => { }); it('rejects null BTR', async () => { - const result = await verifyBTR(null, testKey); + const result = await verifyBTR(/** @type {any} */ (null), testKey); expect(result.valid).toBe(false); expect(result.reason).toBe('BTR must be an object'); }); it('rejects BTR missing required fields', async () => { - const partialBTR = { version: 1, h_in: 'abc' }; + const partialBTR = /** @type {any} */ ({ version: 1, h_in: 'abc' }); const result = await verifyBTR(partialBTR, testKey); expect(result.valid).toBe(false); diff --git a/test/unit/domain/services/CheckpointSerializerV5.test.js b/test/unit/domain/services/CheckpointSerializerV5.test.js index 1a0f8ff3..27cc223c 100644 --- a/test/unit/domain/services/CheckpointSerializerV5.test.js +++ b/test/unit/domain/services/CheckpointSerializerV5.test.js @@ -28,7 +28,7 @@ function mockEventId(lamport = 1, writerId = 'test', patchSha = 'abcd1234', opIn /** * Helper to build a V5 state with specific nodes, edges, and props. */ -function buildStateV5({ nodes = [], edges = [], props = [], tombstoneDots = [] }) { +function buildStateV5({ nodes = /** @type {any[]} */ ([]), edges = /** @type {any[]} */ ([]), props = /** @type {any[]} */ ([]), tombstoneDots = /** @type {any[]} */ ([]) }) { const state = createEmptyStateV5(); // Add nodes with their dots @@ -61,7 +61,7 @@ function buildStateV5({ nodes = [], edges = [], props = [], tombstoneDots = [] } describe('CheckpointSerializerV5', () => { describe('serializeFullStateV5 / deserializeFullStateV5', () => { it('returns empty state when buffer is null', () => { - const restored = deserializeFullStateV5(null); + const restored = deserializeFullStateV5(/** @type {any} */ (null)); expect(restored.nodeAlive.entries.size).toBe(0); expect(restored.edgeAlive.entries.size).toBe(0); @@ -71,7 +71,7 @@ describe('CheckpointSerializerV5', () => { }); it('returns empty state when buffer is undefined', () => { - const restored = deserializeFullStateV5(undefined); + const restored = deserializeFullStateV5(/** @type {any} */ (undefined)); expect(restored.nodeAlive.entries.size).toBe(0); expect(restored.edgeAlive.entries.size).toBe(0); @@ -128,10 +128,10 @@ describe('CheckpointSerializerV5', () => { expect(restored.nodeAlive.entries.has('b')).toBe(true); // Check the dots are preserved - const aDots = restored.nodeAlive.entries.get('a'); + const aDots = /** @type {any} */ (restored.nodeAlive.entries.get('a')); expect(aDots.has('alice:1')).toBe(true); - const bDots = restored.nodeAlive.entries.get('b'); + const bDots = /** @type {any} */ (restored.nodeAlive.entries.get('b')); expect(bDots.has('bob:2')).toBe(true); }); @@ -152,7 +152,7 @@ describe('CheckpointSerializerV5', () => { const edgeKey = encodeEdgeKey('a', 'b', 'knows'); expect(restored.edgeAlive.entries.has(edgeKey)).toBe(true); - const edgeDots = restored.edgeAlive.entries.get(edgeKey); + const edgeDots = /** @type {any} */ (restored.edgeAlive.entries.get(edgeKey)); expect(edgeDots.has('alice:3')).toBe(true); }); @@ -170,7 +170,7 @@ describe('CheckpointSerializerV5', () => { const propKey = encodePropKey('a', 'name'); expect(restored.prop.has(propKey)).toBe(true); - const register = restored.prop.get(propKey); + const register = /** @type {any} */ (restored.prop.get(propKey)); expect(register.value).toBe('Alice'); expect(register.eventId.lamport).toBe(5); expect(register.eventId.writerId).toBe('alice'); @@ -222,7 +222,7 @@ describe('CheckpointSerializerV5', () => { const restored = deserializeFullStateV5(buffer); expect(restored.edgeBirthEvent.size).toBe(1); - const restoredEvent = restored.edgeBirthEvent.get(edgeKey); + const restoredEvent = /** @type {any} */ (restored.edgeBirthEvent.get(edgeKey)); expect(restoredEvent.lamport).toBe(3); expect(restoredEvent.writerId).toBe('alice'); expect(restoredEvent.patchSha).toBe('deadbeef'); @@ -244,7 +244,7 @@ describe('CheckpointSerializerV5', () => { const restored = deserializeFullStateV5(buffer); expect(restored.edgeBirthEvent.size).toBe(1); - const event = restored.edgeBirthEvent.get(edgeKey); + const event = /** @type {any} */ (restored.edgeBirthEvent.get(edgeKey)); expect(event.lamport).toBe(42); // Legacy sentinel values expect(event.writerId).toBe(''); @@ -277,17 +277,17 @@ describe('CheckpointSerializerV5', () => { // Verify nodes expect(restored.nodeAlive.entries.size).toBe(3); - expect(restored.nodeAlive.entries.get('n1').has('alice:1')).toBe(true); - expect(restored.nodeAlive.entries.get('n2').has('bob:1')).toBe(true); - expect(restored.nodeAlive.entries.get('n3').has('alice:2')).toBe(true); + expect(/** @type {any} */ (restored.nodeAlive.entries.get('n1')).has('alice:1')).toBe(true); + expect(/** @type {any} */ (restored.nodeAlive.entries.get('n2')).has('bob:1')).toBe(true); + expect(/** @type {any} */ (restored.nodeAlive.entries.get('n3')).has('alice:2')).toBe(true); // Verify edges expect(restored.edgeAlive.entries.size).toBe(2); // Verify props expect(restored.prop.size).toBe(2); - expect(restored.prop.get(encodePropKey('n1', 'name')).value).toBe('Node One'); - expect(restored.prop.get(encodePropKey('n2', 'count')).value).toBe(42); + expect(/** @type {any} */ (restored.prop.get(encodePropKey('n1', 'name'))).value).toBe('Node One'); + expect(/** @type {any} */ (restored.prop.get(encodePropKey('n2', 'count'))).value).toBe(42); // Verify tombstones expect(restored.nodeAlive.tombstones.size).toBe(2); @@ -325,7 +325,7 @@ describe('CheckpointSerializerV5', () => { const buffer1 = serializeFullStateV5(state1); const buffer2 = serializeFullStateV5(state2); - expect(buffer1.equals(buffer2)).toBe(true); + expect(/** @type {Buffer} */ (buffer1).equals(/** @type {Buffer} */ (buffer2))).toBe(true); }); }); @@ -458,7 +458,7 @@ describe('CheckpointSerializerV5', () => { const buffer1 = serializeAppliedVV(vv1); const buffer2 = serializeAppliedVV(vv2); - expect(buffer1.equals(buffer2)).toBe(true); + expect(/** @type {Buffer} */ (buffer1).equals(/** @type {Buffer} */ (buffer2))).toBe(true); }); }); diff --git a/test/unit/domain/services/CheckpointService.test.js b/test/unit/domain/services/CheckpointService.test.js index 683397c8..7f2fd564 100644 --- a/test/unit/domain/services/CheckpointService.test.js +++ b/test/unit/domain/services/CheckpointService.test.js @@ -18,12 +18,14 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt const crypto = new NodeCryptoAdapter(); // Helper to create valid 40-char hex OIDs for testing -const makeOid = (prefix) => { +const makeOid = (/** @type {string} */ prefix) => { const base = prefix.replace(/[^0-9a-f]/gi, '0').toLowerCase(); return (base + '0'.repeat(40)).slice(0, 40); }; describe('CheckpointService', () => { + /** @type {any} */ + /** @type {any} */ let mockPersistence; beforeEach(() => { @@ -206,7 +208,7 @@ describe('CheckpointService', () => { 'state.cbor': stateBlobOid, 'appliedVV.cbor': appliedVVBlobOid, }); - mockPersistence.readBlob.mockImplementation((oid) => { + mockPersistence.readBlob.mockImplementation((/** @type {string} */ oid) => { if (oid === frontierBlobOid) return Promise.resolve(frontierBuffer); if (oid === stateBlobOid) return Promise.resolve(stateBuffer); if (oid === appliedVVBlobOid) return Promise.resolve(appliedVVBuffer); @@ -305,8 +307,9 @@ describe('CheckpointService', () => { updateFrontier(frontier, 'writer1', makeOid('sha1')); // Track written blobs (V5 writes 4 blobs: state, visible, frontier, appliedVV) + /** @type {any[]} */ const writtenBlobs = []; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { writtenBlobs.push(buffer); return Promise.resolve(makeOid(`blob${writtenBlobs.length}`)); }); @@ -330,6 +333,7 @@ describe('CheckpointService', () => { expect(writtenBlobs).toHaveLength(4); // First blob is full state - should deserialize with deserializeFullStateV5 + /** @type {any} */ const deserializedFullState = deserializeFullStateV5(writtenBlobs[0]); expect(deserializedFullState.nodeAlive.entries.has('node1')).toBe(true); expect(deserializedFullState.nodeAlive.entries.get('node1').has('writer1:1')).toBe(true); @@ -380,13 +384,14 @@ describe('CheckpointService', () => { 'state.cbor': stateBlobOid, 'appliedVV.cbor': appliedVVBlobOid, }); - mockPersistence.readBlob.mockImplementation((oid) => { + mockPersistence.readBlob.mockImplementation((/** @type {string} */ oid) => { if (oid === frontierBlobOid) return Promise.resolve(frontierBuffer); if (oid === stateBlobOid) return Promise.resolve(stateBuffer); if (oid === appliedVVBlobOid) return Promise.resolve(appliedVVBuffer); throw new Error(`Unknown oid: ${oid}`); }); + /** @type {any} */ const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); expect(result.schema).toBe(2); @@ -417,16 +422,18 @@ describe('CheckpointService', () => { updateFrontier(frontier, 'writer1', makeOid('p')); // V5 writes 4 blobs: state, visible, frontier, appliedVV + /** @type {any[]} */ const writtenBlobs = []; + /** @type {any} */ let writtenMessage; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { writtenBlobs.push(buffer); const names = ['state', 'visible', 'frontier', 'appliedvv']; return Promise.resolve(makeOid(names[writtenBlobs.length - 1])); }); mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); - mockPersistence.commitNodeWithTree.mockImplementation(({ message }) => { + mockPersistence.commitNodeWithTree.mockImplementation((/** @type {any} */ { message }) => { writtenMessage = message; return Promise.resolve(makeOid('checkpoint')); }); @@ -448,7 +455,7 @@ describe('CheckpointService', () => { 'frontier.cbor': makeOid('frontier'), 'appliedVV.cbor': makeOid('appliedvv'), }); - mockPersistence.readBlob.mockImplementation((oid) => { + mockPersistence.readBlob.mockImplementation((/** @type {string} */ oid) => { if (oid === makeOid('state')) return Promise.resolve(writtenBlobs[0]); if (oid === makeOid('visible')) return Promise.resolve(writtenBlobs[1]); if (oid === makeOid('frontier')) return Promise.resolve(writtenBlobs[2]); @@ -456,6 +463,7 @@ describe('CheckpointService', () => { throw new Error(`Unknown oid: ${oid}`); }); + /** @type {any} */ const loaded = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); expect(loaded.schema).toBe(2); @@ -511,8 +519,8 @@ describe('CheckpointService', () => { const prop2Key = encodePropKeyV5('n2', 'y'); expect(state.prop.has(prop1Key)).toBe(true); expect(state.prop.has(prop2Key)).toBe(true); - expect(state.prop.get(prop1Key).value).toEqual({ type: 'inline', value: 1 }); - expect(state.prop.get(prop2Key).value).toEqual({ type: 'inline', value: 2 }); + expect(/** @type {any} */ (state.prop.get(prop1Key)).value).toEqual({ type: 'inline', value: 1 }); + expect(/** @type {any} */ (state.prop.get(prop2Key)).value).toEqual({ type: 'inline', value: 2 }); // Verify observedFrontier exists expect(state.observedFrontier).toBeDefined(); @@ -535,6 +543,9 @@ describe('CheckpointService', () => { }); describe('V5 checkpoint with full ORSet state', () => { + /** @type {any} */ + /** @type {any} */ + /** @type {any} */ let mockPersistence; beforeEach(() => { @@ -567,8 +578,9 @@ describe('CheckpointService', () => { updateFrontier(frontier, 'alice', makeOid('sha1')); // Track written blobs + /** @type {any[]} */ const writtenBlobs = []; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { writtenBlobs.push(buffer); return Promise.resolve(makeOid(`blob${writtenBlobs.length}`)); }); @@ -589,10 +601,10 @@ describe('CheckpointService', () => { // Verify tree has all 4 entries const treeEntries = mockPersistence.writeTree.mock.calls[0][0]; expect(treeEntries).toHaveLength(4); - expect(treeEntries.some((e) => e.includes('state.cbor'))).toBe(true); - expect(treeEntries.some((e) => e.includes('visible.cbor'))).toBe(true); - expect(treeEntries.some((e) => e.includes('frontier.cbor'))).toBe(true); - expect(treeEntries.some((e) => e.includes('appliedVV.cbor'))).toBe(true); + expect(treeEntries.some((/** @type {string} */ e) => e.includes('state.cbor'))).toBe(true); + expect(treeEntries.some((/** @type {string} */ e) => e.includes('visible.cbor'))).toBe(true); + expect(treeEntries.some((/** @type {string} */ e) => e.includes('frontier.cbor'))).toBe(true); + expect(treeEntries.some((/** @type {string} */ e) => e.includes('appliedVV.cbor'))).toBe(true); // Verify schema 2 in message const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; @@ -609,8 +621,10 @@ describe('CheckpointService', () => { const frontier = createFrontier(); updateFrontier(frontier, 'alice', makeOid('sha1')); + /** @type {any} */ + /** @type {any} */ let capturedStateBuffer; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { if (!capturedStateBuffer) { capturedStateBuffer = buffer; } @@ -644,8 +658,10 @@ describe('CheckpointService', () => { const frontier = createFrontier(); updateFrontier(frontier, 'alice', makeOid('sha1')); + /** @type {any} */ + /** @type {any} */ let capturedStateBuffer; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { if (!capturedStateBuffer) { capturedStateBuffer = buffer; } @@ -717,7 +733,7 @@ describe('CheckpointService', () => { 'frontier.cbor': frontierBlobOid, 'appliedVV.cbor': appliedVVBlobOid, }); - mockPersistence.readBlob.mockImplementation((oid) => { + mockPersistence.readBlob.mockImplementation((/** @type {string} */ oid) => { if (oid === stateBlobOid) return Promise.resolve(stateBuffer); if (oid === visibleBlobOid) return Promise.resolve(visibleBuffer); if (oid === frontierBlobOid) return Promise.resolve(frontierBuffer); @@ -725,6 +741,7 @@ describe('CheckpointService', () => { throw new Error(`Unknown oid: ${oid}`); }); + /** @type {any} */ const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); // Verify schema @@ -777,12 +794,13 @@ describe('CheckpointService', () => { 'frontier.cbor': frontierBlobOid, // No appliedVV.cbor }); - mockPersistence.readBlob.mockImplementation((oid) => { + mockPersistence.readBlob.mockImplementation((/** @type {string} */ oid) => { if (oid === stateBlobOid) return Promise.resolve(stateBuffer); if (oid === frontierBlobOid) return Promise.resolve(frontierBuffer); throw new Error(`Unknown oid: ${oid}`); }); + /** @type {any} */ const result = await loadCheckpoint(mockPersistence, makeOid('checkpoint')); expect(result.schema).toBe(2); @@ -815,14 +833,15 @@ describe('CheckpointService', () => { updateFrontier(frontier, 'bob', makeOid('sha2')); // Capture written data during create - let writtenStateBlob; - let writtenVisibleBlob; - let writtenFrontierBlob; - let writtenAppliedVVBlob; + /** @type {any} */ let writtenStateBlob; + /** @type {any} */ let writtenVisibleBlob; + /** @type {any} */ let writtenFrontierBlob; + /** @type {any} */ let writtenAppliedVVBlob; + /** @type {any} */ let writtenMessage; let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { switch (blobIndex++) { case 0: writtenStateBlob = buffer; @@ -841,13 +860,13 @@ describe('CheckpointService', () => { } }); mockPersistence.writeTree.mockResolvedValue(makeOid('treeOid')); - mockPersistence.commitNodeWithTree.mockImplementation(({ message }) => { + mockPersistence.commitNodeWithTree.mockImplementation((/** @type {any} */ { message }) => { writtenMessage = message; return Promise.resolve(makeOid('checkpointSha')); }); // Create checkpoint - await create({ + await create(/** @type {any} */ ({ persistence: mockPersistence, graphName: 'roundtrip-v5', state, @@ -855,7 +874,7 @@ describe('CheckpointService', () => { schema: 2, compact: false, // Don't compact to preserve all state crypto, - }); + })); // Setup mocks for loading mockPersistence.showNode.mockResolvedValue(writtenMessage); @@ -866,7 +885,7 @@ describe('CheckpointService', () => { 'frontier.cbor': makeOid('frontierOid'), 'appliedVV.cbor': makeOid('appliedVVOid'), }); - mockPersistence.readBlob.mockImplementation((oid) => { + mockPersistence.readBlob.mockImplementation((/** @type {string} */ oid) => { if (oid === makeOid('stateOid')) return Promise.resolve(writtenStateBlob); if (oid === makeOid('visibleOid')) return Promise.resolve(writtenVisibleBlob); if (oid === makeOid('frontierOid')) return Promise.resolve(writtenFrontierBlob); @@ -875,6 +894,7 @@ describe('CheckpointService', () => { }); // Load checkpoint + /** @type {any} */ const loaded = await loadCheckpoint(mockPersistence, makeOid('checkpointSha')); // Verify schema @@ -923,9 +943,11 @@ describe('CheckpointService', () => { const hashBeforeCompact = await computeStateHashV5(state, { crypto }); // Create checkpoint with compaction + /** @type {any} */ + /** @type {any} */ let writtenVisibleBlob; let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { if (blobIndex === 1) { writtenVisibleBlob = buffer; } @@ -935,7 +957,7 @@ describe('CheckpointService', () => { mockPersistence.writeTree.mockResolvedValue(makeOid('tree')); mockPersistence.commitNodeWithTree.mockResolvedValue(makeOid('checkpoint')); - await create({ + await create(/** @type {any} */ ({ persistence: mockPersistence, graphName: 'test', state, @@ -943,7 +965,7 @@ describe('CheckpointService', () => { schema: 2, compact: true, crypto, - }); + })); // Verify the state hash in checkpoint message matches visible projection const messageArg = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; @@ -969,9 +991,10 @@ describe('CheckpointService', () => { updateFrontier(frontier, 'alice', makeOid('sha1')); updateFrontier(frontier, 'bob', makeOid('sha2')); + /** @type {any} */ let capturedAppliedVVBlob; let blobIndex = 0; - mockPersistence.writeBlob.mockImplementation((buffer) => { + mockPersistence.writeBlob.mockImplementation((/** @type {any} */ buffer) => { if (blobIndex === 3) { capturedAppliedVVBlob = buffer; } diff --git a/test/unit/domain/services/CommitDagTraversalService.test.js b/test/unit/domain/services/CommitDagTraversalService.test.js index 963654e2..80f0f237 100644 --- a/test/unit/domain/services/CommitDagTraversalService.test.js +++ b/test/unit/domain/services/CommitDagTraversalService.test.js @@ -17,6 +17,8 @@ import TraversalError from '../../../../src/domain/errors/TraversalError.js'; * Reverse edges: B->A, C->A, D->B, D->C, E->D */ function createMockIndexReader() { + /** @type {Record} */ + /** @type {Record} */ const forwardEdges = { A: ['B', 'C'], B: ['D'], @@ -25,6 +27,8 @@ function createMockIndexReader() { E: [], }; + /** @type {Record} */ + /** @type {Record} */ const reverseEdges = { A: [], B: ['A'], @@ -34,12 +38,12 @@ function createMockIndexReader() { }; return { - getChildren: vi.fn(async (sha) => forwardEdges[sha] || []), - getParents: vi.fn(async (sha) => reverseEdges[sha] || []), + getChildren: vi.fn(async (/** @type {string} */ sha) => forwardEdges[sha] || []), + getParents: vi.fn(async (/** @type {string} */ sha) => reverseEdges[sha] || []), }; } -async function collectAll(generator) { +async function collectAll(/** @type {AsyncIterable} */ generator) { const results = []; for await (const item of generator) { results.push(item); @@ -48,17 +52,19 @@ async function collectAll(generator) { } describe('CommitDagTraversalService', () => { + /** @type {any} */ let service; + /** @type {any} */ let mockIndexReader; beforeEach(() => { mockIndexReader = createMockIndexReader(); - service = new CommitDagTraversalService({ indexReader: mockIndexReader }); + service = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: mockIndexReader })); }); describe('constructor validation', () => { it('throws when indexReader is not provided', () => { - expect(() => new CommitDagTraversalService({})) + expect(() => new CommitDagTraversalService(/** @type {any} */ ({}))) .toThrow('CommitDagTraversalService requires an indexReader'); }); @@ -295,16 +301,20 @@ describe('CommitDagTraversalService', () => { it('detects cycles and yields partial results', async () => { // Create a cycle: A -> B -> C -> A const cyclicReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B'], B: ['C'], C: ['A'] }; return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { B: ['A'], C: ['B'], A: ['C'] }; return edges[sha] || []; }), }; - const cyclicService = new CommitDagTraversalService({ indexReader: cyclicReader }); + const cyclicService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: cyclicReader })); const nodes = await collectAll(cyclicService.topologicalSort({ start: 'A' })); @@ -315,7 +325,9 @@ describe('CommitDagTraversalService', () => { it('logs warning when cycle is detected', async () => { const cyclicReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B'], B: ['C'], C: ['A'] }; return edges[sha] || []; }), @@ -326,11 +338,12 @@ describe('CommitDagTraversalService', () => { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + child: vi.fn(), }; - const cyclicService = new CommitDagTraversalService({ + const cyclicService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: cyclicReader, logger: mockLogger, - }); + })); await collectAll(cyclicService.topologicalSort({ start: 'A' })); @@ -346,13 +359,15 @@ describe('CommitDagTraversalService', () => { it('throws TraversalError when throwOnCycle is true and cycle detected', async () => { const cyclicReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B'], B: ['C'], C: ['A'] }; return edges[sha] || []; }), getParents: vi.fn(async () => []), }; - const cyclicService = new CommitDagTraversalService({ indexReader: cyclicReader }); + const cyclicService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: cyclicReader })); await expect( collectAll(cyclicService.topologicalSort({ start: 'A', throwOnCycle: true })) @@ -361,7 +376,7 @@ describe('CommitDagTraversalService', () => { // Verify the error has the expected properties try { await collectAll(cyclicService.topologicalSort({ start: 'A', throwOnCycle: true })); - } catch (error) { + } catch (/** @type {any} */ error) { expect(error.code).toBe('CYCLE_DETECTED'); expect(error.context).toMatchObject({ start: 'A', @@ -381,16 +396,20 @@ describe('CommitDagTraversalService', () => { it('detects self-loop cycle (node is its own parent)', async () => { // Create a self-loop: A -> A (node A points to itself) const selfLoopReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['A'] }; // A is its own child return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['A'] }; // A is its own parent return edges[sha] || []; }), }; - const selfLoopService = new CommitDagTraversalService({ indexReader: selfLoopReader }); + const selfLoopService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: selfLoopReader })); // With throwOnCycle: true, it should throw TraversalError await expect( @@ -400,7 +419,7 @@ describe('CommitDagTraversalService', () => { // Verify the error details try { await collectAll(selfLoopService.topologicalSort({ start: 'A', throwOnCycle: true })); - } catch (error) { + } catch (/** @type {any} */ error) { expect(error.code).toBe('CYCLE_DETECTED'); expect(error.context).toMatchObject({ start: 'A', @@ -413,13 +432,15 @@ describe('CommitDagTraversalService', () => { it('handles self-loop gracefully without throwOnCycle (yields no nodes)', async () => { // Create a self-loop: A -> A const selfLoopReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['A'] }; return edges[sha] || []; }), getParents: vi.fn(async () => []), }; - const selfLoopService = new CommitDagTraversalService({ indexReader: selfLoopReader }); + const selfLoopService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: selfLoopReader })); // Without throwOnCycle, it should complete without hanging // and yield partial results (the node cannot be yielded because its in-degree is never 0) @@ -434,7 +455,9 @@ describe('CommitDagTraversalService', () => { it('logs warning for self-loop cycle', async () => { const selfLoopReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['A'] }; return edges[sha] || []; }), @@ -445,11 +468,12 @@ describe('CommitDagTraversalService', () => { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + child: vi.fn(), }; - const selfLoopService = new CommitDagTraversalService({ + const selfLoopService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: selfLoopReader, logger: mockLogger, - }); + })); await collectAll(selfLoopService.topologicalSort({ start: 'A' })); @@ -467,7 +491,9 @@ describe('CommitDagTraversalService', () => { // Island 1: A -> B -> C (connected) // Island 2: X -> Y -> Z (disconnected from Island 1) const disconnectedReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B'], B: ['C'], @@ -478,7 +504,9 @@ describe('CommitDagTraversalService', () => { }; return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: [], B: ['A'], @@ -490,7 +518,7 @@ describe('CommitDagTraversalService', () => { return edges[sha] || []; }), }; - const disconnectedService = new CommitDagTraversalService({ indexReader: disconnectedReader }); + const disconnectedService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: disconnectedReader })); // Start traversal from node A - should only visit Island 1 const nodes = await collectAll(disconnectedService.topologicalSort({ start: 'A' })); @@ -540,7 +568,9 @@ describe('CommitDagTraversalService', () => { // Shortest hop: A->B (2 hops via B->D) // Cheapest: A->C->D (cost 2) vs A->B->D (cost 11) const weightedReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C'], B: ['D'], @@ -549,7 +579,9 @@ describe('CommitDagTraversalService', () => { }; return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: [], B: ['A'], @@ -560,10 +592,10 @@ describe('CommitDagTraversalService', () => { }), }; - const weightedService = new CommitDagTraversalService({ indexReader: weightedReader }); + const weightedService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: weightedReader })); // Weight provider: A->B is expensive (10), everything else is cheap (1) - const weightProvider = (from, to) => { + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => { if (from === 'A' && to === 'B') return 10; return 1; }; @@ -591,7 +623,7 @@ describe('CommitDagTraversalService', () => { // Should have been called for each edge explored expect(weightProvider).toHaveBeenCalled(); // Verify it was called with (fromSha, toSha) arguments - const calls = weightProvider.mock.calls; + const calls = /** @type {any[][]} */ (weightProvider.mock.calls); for (const [fromSha, toSha] of calls) { expect(typeof fromSha).toBe('string'); expect(typeof toSha).toBe('string'); @@ -621,7 +653,7 @@ describe('CommitDagTraversalService', () => { try { await service.weightedShortestPath({ from: 'E', to: 'A', direction: 'children' }); - } catch (error) { + } catch (/** @type {any} */ error) { expect(error.code).toBe('NO_PATH'); expect(error.context).toMatchObject({ from: 'E', @@ -634,7 +666,9 @@ describe('CommitDagTraversalService', () => { it('handles disconnected nodes', async () => { // Create a graph with disconnected components const disconnectedReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B'], B: [], @@ -643,7 +677,9 @@ describe('CommitDagTraversalService', () => { }; return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: [], B: ['A'], @@ -653,7 +689,7 @@ describe('CommitDagTraversalService', () => { return edges[sha] || []; }), }; - const disconnectedService = new CommitDagTraversalService({ indexReader: disconnectedReader }); + const disconnectedService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: disconnectedReader })); // Try to find path between disconnected components await expect( @@ -662,7 +698,7 @@ describe('CommitDagTraversalService', () => { try { await disconnectedService.weightedShortestPath({ from: 'A', to: 'X' }); - } catch (error) { + } catch (/** @type {any} */ error) { expect(error.code).toBe('NO_PATH'); } }); @@ -694,7 +730,9 @@ describe('CommitDagTraversalService', () => { // // Shortest path A->E: A->B->E (cost 2) or A->D->E (cost 3) or A->C->E (cost 6) const complexReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C', 'D'], B: ['E'], @@ -706,7 +744,9 @@ describe('CommitDagTraversalService', () => { }), getParents: vi.fn(async () => []), }; - const complexService = new CommitDagTraversalService({ indexReader: complexReader }); + const complexService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: complexReader })); + + /** @type {Record} */ const weights = { 'A-B': 1, @@ -716,7 +756,7 @@ describe('CommitDagTraversalService', () => { 'C-E': 1, 'D-E': 1, }; - const weightProvider = (from, to) => weights[`${from}-${to}`] || 1; + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => weights[`${from}-${to}`] || 1; const result = await complexService.weightedShortestPath({ from: 'A', @@ -730,7 +770,9 @@ describe('CommitDagTraversalService', () => { it('handles zero-weight edges', async () => { const zeroWeightReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C'], B: ['D'], @@ -741,10 +783,10 @@ describe('CommitDagTraversalService', () => { }), getParents: vi.fn(async () => []), }; - const zeroWeightService = new CommitDagTraversalService({ indexReader: zeroWeightReader }); + const zeroWeightService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: zeroWeightReader })); // A->C has zero weight, A->B has weight 1 - const weightProvider = (from, to) => { + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => { if (from === 'A' && to === 'C') return 0; return 1; }; @@ -766,11 +808,12 @@ describe('CommitDagTraversalService', () => { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + child: vi.fn(), }; - const loggingService = new CommitDagTraversalService({ + const loggingService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: mockIndexReader, logger: mockLogger, - }); + })); await loggingService.weightedShortestPath({ from: 'A', to: 'D' }); @@ -784,11 +827,12 @@ describe('CommitDagTraversalService', () => { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + child: vi.fn(), }; - const loggingService = new CommitDagTraversalService({ + const loggingService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: mockIndexReader, logger: mockLogger, - }); + })); try { await loggingService.weightedShortestPath({ from: 'E', to: 'A', direction: 'children' }); @@ -825,7 +869,9 @@ describe('CommitDagTraversalService', () => { // Path A->F can go A->B->C->F (cost 3) or A->D->E->F (cost 3) // With a good heuristic, A* should explore fewer nodes const gridReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'D'], B: ['C'], @@ -838,7 +884,7 @@ describe('CommitDagTraversalService', () => { }), getParents: vi.fn(async () => []), }; - const gridService = new CommitDagTraversalService({ indexReader: gridReader }); + const gridService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: gridReader })); // Heuristic: estimate based on "distance" to F // A=2, B=2, C=1, D=2, E=1, F=0 (admissible - never overestimates) @@ -854,7 +900,7 @@ describe('CommitDagTraversalService', () => { const result = await gridService.aStarSearch({ from: 'A', to: 'F', - heuristicProvider: (sha) => heuristic[sha] || 0, + heuristicProvider: (/** @type {string} */ sha) => /** @type {any} */ (heuristic)[sha] || 0, }); // Should find optimal path with cost 3 @@ -877,7 +923,9 @@ describe('CommitDagTraversalService', () => { // Goal is J. With no heuristic, Dijkstra explores many nodes. // With heuristic pointing toward C->G->J path, fewer nodes explored. const wideReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C', 'D'], B: ['E', 'F'], @@ -894,7 +942,7 @@ describe('CommitDagTraversalService', () => { }), getParents: vi.fn(async () => []), }; - const wideService = new CommitDagTraversalService({ indexReader: wideReader }); + const wideService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: wideReader })); // Run Dijkstra (zero heuristic) const dijkstraResult = await wideService.aStarSearch({ @@ -920,7 +968,7 @@ describe('CommitDagTraversalService', () => { const aStarResult = await wideService.aStarSearch({ from: 'A', to: 'J', - heuristicProvider: (sha) => heuristic[sha] || 0, + heuristicProvider: (/** @type {string} */ sha) => /** @type {any} */ (heuristic)[sha] || 0, }); // Both should find path with same cost @@ -943,7 +991,7 @@ describe('CommitDagTraversalService', () => { expect(heuristicProvider).toHaveBeenCalled(); // Verify it was called with (sha, targetSha) arguments - const calls = heuristicProvider.mock.calls; + const calls = /** @type {any[][]} */ (heuristicProvider.mock.calls); for (const [sha, targetSha] of calls) { expect(typeof sha).toBe('string'); expect(targetSha).toBe('D'); // Target should always be 'D' @@ -973,7 +1021,7 @@ describe('CommitDagTraversalService', () => { try { await service.aStarSearch({ from: 'E', to: 'A', direction: 'children' }); - } catch (error) { + } catch (/** @type {any} */ error) { expect(error.code).toBe('NO_PATH'); expect(error.context).toMatchObject({ from: 'E', @@ -994,7 +1042,9 @@ describe('CommitDagTraversalService', () => { it('with zero heuristic behaves like Dijkstra', async () => { // Create a weighted graph const weightedReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C'], B: ['D'], @@ -1005,10 +1055,10 @@ describe('CommitDagTraversalService', () => { }), getParents: vi.fn(async () => []), }; - const weightedService = new CommitDagTraversalService({ indexReader: weightedReader }); + const weightedService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: weightedReader })); // A->B is expensive (10), A->C is cheap (1), both ->D is 1 - const weightProvider = (from, to) => { + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => { if (from === 'A' && to === 'B') return 10; return 1; }; @@ -1056,7 +1106,9 @@ describe('CommitDagTraversalService', () => { // With tie-breaking favoring higher g, B should be explored first // because it has made more "actual progress" (g=2 > g=1) const tieBreakReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { START: ['A', 'B'], A: ['END'], @@ -1067,10 +1119,10 @@ describe('CommitDagTraversalService', () => { }), getParents: vi.fn(async () => []), }; - const _tieBreakService = new CommitDagTraversalService({ indexReader: tieBreakReader }); + const _tieBreakService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: tieBreakReader })); // Weight provider: START->A is 1, START->B is 2, A->END is 2, B->END is 1 - const weightProvider = (from, to) => { + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => { if (from === 'START' && to === 'A') return 1; if (from === 'START' && to === 'B') return 2; if (from === 'A' && to === 'END') return 2; @@ -1080,7 +1132,8 @@ describe('CommitDagTraversalService', () => { // Heuristic: A has h=2 (far from goal), B has h=1 (close to goal) // This makes f(A) = 1 + 2 = 3 and f(B) = 2 + 1 = 3 (equal f values!) - const heuristicProvider = (sha) => { + const heuristicProvider = (/** @type {string} */ sha) => { + /** @type {Record} */ const heuristics = { START: 3, A: 2, @@ -1091,15 +1144,16 @@ describe('CommitDagTraversalService', () => { }; // Track exploration order + /** @type {string[]} */ const explorationOrder = []; const trackingReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { explorationOrder.push(sha); return tieBreakReader.getChildren(sha); }), getParents: vi.fn(async () => []), }; - const trackingService = new CommitDagTraversalService({ indexReader: trackingReader }); + const trackingService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: trackingReader })); const result = await trackingService.aStarSearch({ from: 'START', @@ -1133,9 +1187,9 @@ describe('CommitDagTraversalService', () => { * Creates a mock index reader for a long chain graph: * N0 -> N1 -> N2 -> ... -> N(length-1) */ - function createChainReader(length) { + function createChainReader(/** @type {number} */ length) { return { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { const match = sha.match(/^N(\d+)$/); if (!match) return []; const idx = parseInt(match[1], 10); @@ -1144,7 +1198,7 @@ describe('CommitDagTraversalService', () => { } return []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { const match = sha.match(/^N(\d+)$/); if (!match) return []; const idx = parseInt(match[1], 10); @@ -1170,7 +1224,9 @@ describe('CommitDagTraversalService', () => { it('returns same optimal path as unidirectional A*', async () => { // Create a graph with weighted edges where path choice matters const weightedReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C'], B: ['D'], @@ -1180,7 +1236,9 @@ describe('CommitDagTraversalService', () => { }; return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: [], B: ['A'], @@ -1191,10 +1249,10 @@ describe('CommitDagTraversalService', () => { return edges[sha] || []; }), }; - const weightedService = new CommitDagTraversalService({ indexReader: weightedReader }); + const weightedService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: weightedReader })); // A->B is expensive (10), everything else is cheap (1) - const weightProvider = (from, to) => { + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => { if (from === 'A' && to === 'B') return 10; return 1; }; @@ -1230,7 +1288,7 @@ describe('CommitDagTraversalService', () => { // Create a long chain where bidirectional search should meet in the middle const chainLength = 20; const chainReader = createChainReader(chainLength); - const chainService = new CommitDagTraversalService({ indexReader: chainReader }); + const chainService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: chainReader })); // Run unidirectional A* from start to end const uniResult = await chainService.aStarSearch({ @@ -1270,7 +1328,7 @@ describe('CommitDagTraversalService', () => { // Forward heuristic should be called for nodes in forward search expect(forwardHeuristic).toHaveBeenCalled(); // Verify forward heuristic was called with (sha, targetSha='E') - const forwardCalls = forwardHeuristic.mock.calls; + const forwardCalls = /** @type {any[][]} */ (forwardHeuristic.mock.calls); for (const [sha, target] of forwardCalls) { expect(typeof sha).toBe('string'); expect(target).toBe('E'); @@ -1279,7 +1337,7 @@ describe('CommitDagTraversalService', () => { // Backward heuristic should be called for nodes in backward search expect(backwardHeuristic).toHaveBeenCalled(); // Verify backward heuristic was called with (sha, targetSha='A') - const backwardCalls = backwardHeuristic.mock.calls; + const backwardCalls = /** @type {any[][]} */ (backwardHeuristic.mock.calls); for (const [sha, target] of backwardCalls) { expect(typeof sha).toBe('string'); expect(target).toBe('A'); @@ -1294,7 +1352,7 @@ describe('CommitDagTraversalService', () => { try { await service.bidirectionalAStar({ from: 'E', to: 'A' }); - } catch (error) { + } catch (/** @type {any} */ error) { expect(error.code).toBe('NO_PATH'); expect(error.context).toMatchObject({ from: 'E', @@ -1314,7 +1372,9 @@ describe('CommitDagTraversalService', () => { it('works with weighted edges', async () => { // Create a graph with different edge weights const weightedReader = { - getChildren: vi.fn(async (sha) => { + getChildren: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: ['B', 'C'], B: ['D'], @@ -1323,7 +1383,9 @@ describe('CommitDagTraversalService', () => { }; return edges[sha] || []; }), - getParents: vi.fn(async (sha) => { + getParents: vi.fn(async (/** @type {string} */ sha) => { + /** @type {Record} */ + /** @type {Record} */ const edges = { A: [], B: ['A'], @@ -1333,12 +1395,12 @@ describe('CommitDagTraversalService', () => { return edges[sha] || []; }), }; - const weightedService = new CommitDagTraversalService({ indexReader: weightedReader }); + const weightedService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: weightedReader })); // Make A->C->D path cheaper than A->B->D // A->B: 5, B->D: 5 (total 10) // A->C: 1, C->D: 1 (total 2) - const weightProvider = (from, to) => { + const weightProvider = (/** @type {string} */ from, /** @type {string} */ to) => { if (from === 'A' && to === 'B') return 5; if (from === 'B' && to === 'D') return 5; if (from === 'A' && to === 'C') return 1; @@ -1361,7 +1423,7 @@ describe('CommitDagTraversalService', () => { // Create a chain: N0 -> N1 -> N2 -> N3 -> N4 -> N5 -> N6 -> N7 -> N8 -> N9 const chainLength = 10; const chainReader = createChainReader(chainLength); - const chainService = new CommitDagTraversalService({ indexReader: chainReader }); + const chainService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: chainReader })); const result = await chainService.bidirectionalAStar({ from: 'N0', @@ -1393,11 +1455,12 @@ describe('CommitDagTraversalService', () => { info: vi.fn(), warn: vi.fn(), error: vi.fn(), + child: vi.fn(), }; - const loggingService = new CommitDagTraversalService({ + const loggingService = new CommitDagTraversalService(/** @type {any} */ ({ indexReader: mockIndexReader, logger: mockLogger, - }); + })); await collectAll(loggingService.bfs({ start: 'A', maxNodes: 2 })); diff --git a/test/unit/domain/services/EdgePropKey.test.js b/test/unit/domain/services/EdgePropKey.test.js index a6eee1e5..d04647db 100644 --- a/test/unit/domain/services/EdgePropKey.test.js +++ b/test/unit/domain/services/EdgePropKey.test.js @@ -193,6 +193,7 @@ describe('EdgePropKey', () => { }); describe('fuzz: 10,000 random tuples round-trip', () => { + /** @param {number} maxLen */ function randomString(maxLen) { const len = Math.floor(Math.random() * maxLen) + 1; const chars = []; diff --git a/test/unit/domain/services/Frontier.test.js b/test/unit/domain/services/Frontier.test.js index e97d5683..ca496e5b 100644 --- a/test/unit/domain/services/Frontier.test.js +++ b/test/unit/domain/services/Frontier.test.js @@ -159,7 +159,7 @@ describe('Frontier', () => { updateFrontier(original, 'writer1', 'sha123'); updateFrontier(original, 'writer2', 'sha456'); - const bytes = serializeFrontier(original); + const bytes = /** @type {Buffer} */ (serializeFrontier(original)); const restored = deserializeFrontier(bytes); expect(restored).toBeInstanceOf(Map); @@ -171,7 +171,7 @@ describe('Frontier', () => { it('reconstructs empty frontier', () => { const original = createFrontier(); - const bytes = serializeFrontier(original); + const bytes = /** @type {Buffer} */ (serializeFrontier(original)); const restored = deserializeFrontier(bytes); expect(restored).toBeInstanceOf(Map); @@ -186,7 +186,7 @@ describe('Frontier', () => { updateFrontier(original, 'writer2', '789xyz'); updateFrontier(original, 'writer3', 'sha-with-special_chars.ok'); - const bytes = serializeFrontier(original); + const bytes = /** @type {Buffer} */ (serializeFrontier(original)); const restored = deserializeFrontier(bytes); expect(restored.size).toBe(original.size); @@ -200,9 +200,9 @@ describe('Frontier', () => { updateFrontier(original, 'a', 'sha1'); updateFrontier(original, 'b', 'sha2'); - const bytes1 = serializeFrontier(original); + const bytes1 = /** @type {Buffer} */ (serializeFrontier(original)); const restored1 = deserializeFrontier(bytes1); - const bytes2 = serializeFrontier(restored1); + const bytes2 = /** @type {Buffer} */ (serializeFrontier(restored1)); const restored2 = deserializeFrontier(bytes2); expect(Buffer.from(bytes1).equals(Buffer.from(bytes2))).toBe(true); diff --git a/test/unit/domain/services/GCPolicy.test.js b/test/unit/domain/services/GCPolicy.test.js index 6e9bff39..d943f870 100644 --- a/test/unit/domain/services/GCPolicy.test.js +++ b/test/unit/domain/services/GCPolicy.test.js @@ -27,6 +27,7 @@ describe('GCPolicy', () => { }); describe('shouldRunGC', () => { + /** @type {any} */ const policy = { tombstoneRatioThreshold: 0.3, entryCountThreshold: 1000, diff --git a/test/unit/domain/services/GitGraphAdapter.test.js b/test/unit/domain/services/GitGraphAdapter.test.js index 6ca56b92..b077a3e4 100644 --- a/test/unit/domain/services/GitGraphAdapter.test.js +++ b/test/unit/domain/services/GitGraphAdapter.test.js @@ -3,7 +3,9 @@ import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAda describe('GitGraphAdapter', () => { describe('getNodeInfo()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { @@ -103,8 +105,11 @@ describe('GitGraphAdapter', () => { }); describe('logNodesStream NUL byte stripping', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; + /** @type {any} */ let capturedArgs; beforeEach(() => { @@ -128,7 +133,7 @@ describe('GitGraphAdapter', () => { await adapter.logNodesStream({ ref: 'HEAD', format: formatWithTrailingNul }); expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBe('--format=%H%n%B'); expect(formatArg).not.toContain('\x00'); }); @@ -139,7 +144,7 @@ describe('GitGraphAdapter', () => { await adapter.logNodesStream({ ref: 'HEAD', format: formatWithEmbeddedNul }); expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBe('--format=%H%n%B'); expect(formatArg).not.toContain('\x00'); }); @@ -150,7 +155,7 @@ describe('GitGraphAdapter', () => { await adapter.logNodesStream({ ref: 'HEAD', format: formatWithMultipleNuls }); expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBe('--format=%H%n%B'); expect(formatArg).not.toContain('\x00'); }); @@ -161,7 +166,7 @@ describe('GitGraphAdapter', () => { await adapter.logNodesStream({ ref: 'HEAD', format: cleanFormat }); expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBe('--format=%H%n%P%n%s%n%b'); }); @@ -170,7 +175,7 @@ describe('GitGraphAdapter', () => { expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); // Empty format should not add --format argument - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBeUndefined(); }); @@ -182,7 +187,7 @@ describe('GitGraphAdapter', () => { expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); // After stripping NULs, format becomes empty string but --format= is still added // since the original format was truthy. The key is no NUL bytes in args. - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBe('--format='); expect(formatArg).not.toContain('\x00'); }); @@ -191,7 +196,7 @@ describe('GitGraphAdapter', () => { await adapter.logNodesStream({ ref: 'HEAD' }); expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(1); - const formatArg = capturedArgs.find(arg => arg.startsWith('--format=')); + const formatArg = capturedArgs.find((/** @type {any} */ arg) => arg.startsWith('--format=')); expect(formatArg).toBeUndefined(); // Verify other args are correct expect(capturedArgs).toContain('log'); @@ -217,7 +222,9 @@ describe('GitGraphAdapter', () => { }); describe('nodeExists()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { @@ -241,7 +248,7 @@ describe('GitGraphAdapter', () => { }); it('returns false when node does not exist', async () => { - const err = new Error('fatal: Not a valid object name'); + const err = /** @type {any} */ (new Error('fatal: Not a valid object name')); err.details = { code: 1 }; mockPlumbing.execute.mockRejectedValue(err); @@ -301,7 +308,9 @@ describe('GitGraphAdapter', () => { }); describe('countNodes()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { @@ -386,7 +395,9 @@ describe('GitGraphAdapter', () => { }); describe('configGet()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { @@ -410,7 +421,7 @@ describe('GitGraphAdapter', () => { }); it('returns null when config key not found', async () => { - const err = new Error('exit code 1'); + const err = /** @type {any} */ (new Error('exit code 1')); err.exitCode = 1; mockPlumbing.execute.mockRejectedValue(err); @@ -467,7 +478,9 @@ describe('GitGraphAdapter', () => { }); describe('configSet()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { @@ -502,10 +515,10 @@ describe('GitGraphAdapter', () => { }); it('rejects non-string value', async () => { - await expect(adapter.configSet('some.key', 123)) + await expect(adapter.configSet('some.key', /** @type {any} */ (123))) .rejects.toThrow(/Config value must be a string/); - await expect(adapter.configSet('some.key', null)) + await expect(adapter.configSet('some.key', /** @type {any} */ (null))) .rejects.toThrow(/Config value must be a string/); }); diff --git a/test/unit/domain/services/GitLogParser.test.js b/test/unit/domain/services/GitLogParser.test.js index 6441a995..9f3bd491 100644 --- a/test/unit/domain/services/GitLogParser.test.js +++ b/test/unit/domain/services/GitLogParser.test.js @@ -3,6 +3,8 @@ import GitLogParser, { RECORD_SEPARATOR } from '../../../../src/domain/services/ import GraphNode from '../../../../src/domain/entities/GraphNode.js'; describe('GitLogParser', () => { + /** @type {any} */ + /** @type {any} */ let parser; beforeEach(() => { diff --git a/test/unit/domain/services/HealthCheckService.test.js b/test/unit/domain/services/HealthCheckService.test.js index 29bcbeca..13895479 100644 --- a/test/unit/domain/services/HealthCheckService.test.js +++ b/test/unit/domain/services/HealthCheckService.test.js @@ -2,11 +2,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import HealthCheckService, { HealthStatus } from '../../../../src/domain/services/HealthCheckService.js'; describe('HealthCheckService', () => { + /** @type {any} */ + /** @type {any} */ let service; + /** @type {any} */ + /** @type {any} */ let mockPersistence; + /** @type {any} */ + /** @type {any} */ let mockClock; + /** @type {any} */ + /** @type {any} */ let mockIndexReader; + /** @type {any} */ + /** @type {any} */ let mockLogger; + /** @type {any} */ + /** @type {any} */ let currentTime; beforeEach(() => { @@ -37,22 +49,22 @@ describe('HealthCheckService', () => { child: vi.fn().mockReturnThis(), }; - service = new HealthCheckService({ + service = new HealthCheckService(/** @type {any} */ ({ persistence: mockPersistence, clock: mockClock, cacheTtlMs: 5000, logger: mockLogger, - }); + })); }); describe('constructor', () => { it('accepts persistence, clock, and optional parameters', () => { - const s = new HealthCheckService({ persistence: mockPersistence, clock: mockClock }); + const s = new HealthCheckService(/** @type {any} */ ({ persistence: mockPersistence, clock: mockClock })); expect(s).toBeDefined(); }); it('uses default cache TTL of 5000ms', async () => { - const s = new HealthCheckService({ persistence: mockPersistence, clock: mockClock }); + const s = new HealthCheckService(/** @type {any} */ ({ persistence: mockPersistence, clock: mockClock })); await s.getHealth(); // Call again immediately - should be cached @@ -67,11 +79,11 @@ describe('HealthCheckService', () => { }); it('allows custom cache TTL', async () => { - const s = new HealthCheckService({ + const s = new HealthCheckService(/** @type {any} */ ({ persistence: mockPersistence, clock: mockClock, cacheTtlMs: 1000, - }); + })); await s.getHealth(); // Advance 1.5 seconds - should expire diff --git a/test/unit/domain/services/HookInstaller.test.js b/test/unit/domain/services/HookInstaller.test.js index 79d47097..8e56f29c 100644 --- a/test/unit/domain/services/HookInstaller.test.js +++ b/test/unit/domain/services/HookInstaller.test.js @@ -19,7 +19,7 @@ function makeFs(files = {}) { }; } -function makeExecGitConfig(overrides = {}) { +function makeExecGitConfig(overrides = /** @type {any} */ ({})) { return vi.fn((repoPath, key) => { if (key === '--git-dir') return overrides.gitDir || '.git'; if (key === 'core.hooksPath') return overrides.hooksPath || null; @@ -204,7 +204,7 @@ describe('HookInstaller.install', () => { expect(result.action).toBe('replaced'); expect(result.backupPath).toContain('.backup'); - const backup = fs._store.get(result.backupPath); + const backup = fs._store.get(/** @type {string} */ (result.backupPath)); expect(backup).toBe(foreign); const written = fs._store.get(result.hookPath); @@ -226,7 +226,7 @@ describe('HookInstaller.install', () => { '/tmpl/post-merge.sh': TEMPLATE, }); - expect(() => installer.install('/repo', { strategy: 'bogus' })) + expect(() => installer.install('/repo', { strategy: /** @type {any} */ ('bogus') })) .toThrow('Unknown install strategy: bogus'); }); }); @@ -310,6 +310,7 @@ describe('hooks directory resolution', () => { // ── Template integrity ────────────────────────────────────────────────────── describe('template integrity', () => { + /** @type {any} */ let templateContent; beforeEach(async () => { diff --git a/test/unit/domain/services/HttpSyncServer.test.js b/test/unit/domain/services/HttpSyncServer.test.js index 4eb283a1..e7df9336 100644 --- a/test/unit/domain/services/HttpSyncServer.test.js +++ b/test/unit/domain/services/HttpSyncServer.test.js @@ -1,11 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import HttpSyncServer from '../../../../src/domain/services/HttpSyncServer.js'; +/** @param {any} value @returns {any} */ function canonicalizeJson(value) { if (Array.isArray(value)) { return value.map(canonicalizeJson); } if (value && typeof value === 'object') { + /** @type {Record} */ const sorted = {}; for (const key of Object.keys(value).sort()) { sorted[key] = canonicalizeJson(value[key]); @@ -15,6 +17,7 @@ function canonicalizeJson(value) { return value; } +/** @param {any} value */ function canonicalStringify(value) { return JSON.stringify(canonicalizeJson(value)); } @@ -23,7 +26,10 @@ function canonicalStringify(value) { * Creates a mock HttpServerPort that captures the request handler * and lets tests invoke it directly without network I/O. */ +/** @returns {any} */ function createMockPort() { + /** @type {any} */ + /** @type {any} */ let handler; let listenCallback; let closeCallback; @@ -31,17 +37,17 @@ function createMockPort() { return { port: { - createServer(requestHandler) { + createServer(/** @type {any} */ requestHandler) { handler = requestHandler; return { - listen(_port, _host, cb) { + listen(/** @type {any} */ _port, /** @type {any} */ _host, /** @type {any} */ cb) { if (typeof _host === 'function') { cb = _host; } listenCallback = cb; if (cb) cb(null); }, - close(cb) { + close(/** @type {any} */ cb) { closeCallback = cb; if (cb) cb(null); }, @@ -54,14 +60,18 @@ function createMockPort() { getHandler() { return handler; }, - setAddress(addr) { + setAddress(/** @type {any} */ addr) { addressValue.port = addr.port; }, }; } describe('HttpSyncServer', () => { + /** @type {any} */ + /** @type {any} */ let mockPort; + /** @type {any} */ + /** @type {any} */ let graph; beforeEach(() => { @@ -76,20 +86,20 @@ describe('HttpSyncServer', () => { }); it('throws if port is not a number', async () => { - const server = new HttpSyncServer({ + const server = new HttpSyncServer(/** @type {any} */ ({ httpPort: mockPort.port, graph, - }); - await expect(server.listen('abc')).rejects.toThrow('listen() requires a numeric port'); + })); + await expect(server.listen(/** @type {any} */ ('abc'))).rejects.toThrow('listen() requires a numeric port'); }); it('returns url and close handle on listen', async () => { - const server = new HttpSyncServer({ + const server = new HttpSyncServer(/** @type {any} */ ({ httpPort: mockPort.port, graph, host: '127.0.0.1', path: '/sync', - }); + })); const handle = await server.listen(9999); expect(handle.url).toBe('http://127.0.0.1:9999/sync'); @@ -98,11 +108,11 @@ describe('HttpSyncServer', () => { }); it('normalizes path without leading slash', async () => { - const server = new HttpSyncServer({ + const server = new HttpSyncServer(/** @type {any} */ ({ httpPort: mockPort.port, graph, path: 'custom', - }); + })); const handle = await server.listen(9999); expect(handle.url).toBe('http://127.0.0.1:9999/custom'); @@ -110,15 +120,17 @@ describe('HttpSyncServer', () => { }); describe('request handling', () => { + /** @type {any} */ + /** @type {any} */ let handler; beforeEach(async () => { - const server = new HttpSyncServer({ + const server = new HttpSyncServer(/** @type {any} */ ({ httpPort: mockPort.port, graph, host: '127.0.0.1', path: '/sync', - }); + })); await server.listen(9999); handler = mockPort.getHandler(); }); @@ -168,11 +180,11 @@ describe('HttpSyncServer', () => { }); it('returns 413 for oversized request', async () => { - const server = new HttpSyncServer({ + const server = new HttpSyncServer(/** @type {any} */ ({ httpPort: mockPort.port, graph, maxRequestBytes: 10, - }); + })); await server.listen(9999); const h = mockPort.getHandler(); diff --git a/test/unit/domain/services/IndexRebuildService.deep.test.js b/test/unit/domain/services/IndexRebuildService.deep.test.js index b1008e94..ac20366c 100644 --- a/test/unit/domain/services/IndexRebuildService.deep.test.js +++ b/test/unit/domain/services/IndexRebuildService.deep.test.js @@ -7,6 +7,7 @@ describe('IndexRebuildService Deep DAG Test', () => { const CHAIN_LENGTH = 10_000; // Generate a linear chain: node0 <- node1 <- node2 <- ... <- node9999 + /** @type {GraphNode[]} */ const chain = []; for (let i = 0; i < CHAIN_LENGTH; i++) { chain.push(new GraphNode({ @@ -19,7 +20,7 @@ describe('IndexRebuildService Deep DAG Test', () => { } const mockGraphService = { - async *iterateNodes({ ref: _ref, limit: _limit }) { + async *iterateNodes(/** @type {any} */ { ref: _ref, limit: _limit }) { for (const node of chain) { yield node; } @@ -36,10 +37,10 @@ describe('IndexRebuildService Deep DAG Test', () => { writeTree: vi.fn().mockResolvedValue('tree-oid-deep') }; - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage - }); + })); // This should complete without stack overflow const treeOid = await service.rebuild('HEAD'); @@ -56,7 +57,7 @@ describe('IndexRebuildService Deep DAG Test', () => { expect(treeEntries.length).toBeGreaterThan(0); // All entries should be valid tree format - treeEntries.forEach(entry => { + treeEntries.forEach(/** @param {any} entry */ entry => { expect(entry).toMatch(/^100644 blob blob\d+\t(meta|shards)_.+\.json$/); }); }, 30000); // 30 second timeout for large test @@ -65,6 +66,7 @@ describe('IndexRebuildService Deep DAG Test', () => { const PARENT_COUNT = 1000; // Create 1000 parent nodes and 1 child with all of them as parents + /** @type {GraphNode[]} */ const nodes = []; const parentShas = []; @@ -102,10 +104,10 @@ describe('IndexRebuildService Deep DAG Test', () => { writeTree: vi.fn().mockResolvedValue('tree-oid-wide') }; - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage - }); + })); const treeOid = await service.rebuild('HEAD'); diff --git a/test/unit/domain/services/IndexRebuildService.streaming.test.js b/test/unit/domain/services/IndexRebuildService.streaming.test.js index 4712988a..46919fd5 100644 --- a/test/unit/domain/services/IndexRebuildService.streaming.test.js +++ b/test/unit/domain/services/IndexRebuildService.streaming.test.js @@ -3,9 +3,17 @@ import IndexRebuildService from '../../../../src/domain/services/IndexRebuildSer import GraphNode from '../../../../src/domain/entities/GraphNode.js'; describe('IndexRebuildService streaming mode', () => { + /** @type {any} */ + /** @type {any} */ let service; + /** @type {any} */ + /** @type {any} */ let mockStorage; + /** @type {any} */ + /** @type {any} */ let mockGraphService; + /** @type {any} */ + /** @type {any} */ let writtenBlobs; beforeEach(() => { @@ -35,7 +43,7 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); const treeOid = await service.rebuild('main', { maxMemoryBytes: 50 * 1024 * 1024 }); @@ -55,12 +63,13 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); + /** @type {any[]} */ const flushCalls = []; await service.rebuild('main', { maxMemoryBytes: 1000, // Low threshold to trigger flushes - onFlush: (data) => flushCalls.push(data), + onFlush: (/** @type {any} */ data) => flushCalls.push(data), }); expect(flushCalls.length).toBeGreaterThan(0); @@ -79,12 +88,13 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); + /** @type {any[]} */ const progressCalls = []; await service.rebuild('main', { maxMemoryBytes: 50 * 1024 * 1024, - onProgress: (data) => progressCalls.push(data), + onProgress: (/** @type {any} */ data) => progressCalls.push(data), }); // Should have received progress callbacks @@ -104,18 +114,19 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); 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(e => e.includes('meta_'))).toBe(true); - expect(treeEntries.some(e => e.includes('shards_'))).toBe(true); + expect(treeEntries.some((/** @type {any} */ e) => e.includes('meta_'))).toBe(true); + expect(treeEntries.some((/** @type {any} */ e) => e.includes('shards_'))).toBe(true); // Should be able to load the index (mock the tree OIDs) + /** @type {Record} */ const shardOids = {}; - treeEntries.forEach(entry => { + treeEntries.forEach((/** @type {any} */ entry) => { const match = entry.match(/100644 blob (\S+)\t(\S+)/); if (match) { shardOids[match[2]] = match[1]; @@ -138,7 +149,7 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); // Without maxMemoryBytes, should use in-memory builder (original behavior) const treeOid = await service.rebuild('main'); @@ -162,11 +173,12 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); + /** @type {any[]} */ const progressCalls = []; await service.rebuild('main', { - onProgress: (data) => progressCalls.push(data), + onProgress: (/** @type {any} */ data) => progressCalls.push(data), }); expect(progressCalls.length).toBeGreaterThanOrEqual(1); // Progress called at 10000-node intervals @@ -203,13 +215,13 @@ describe('IndexRebuildService streaming mode', () => { } }; - service = new IndexRebuildService({ storage: mockStorage, graphService: mockGraphService }); + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService })); let flushCount = 0; await service.rebuild('main', { maxMemoryBytes: memoryThreshold, onFlush: () => flushCount++, - onProgress: ({ currentMemoryBytes }) => { + onProgress: (/** @type {any} */ { currentMemoryBytes }) => { if (currentMemoryBytes !== null) { maxMemorySeen = Math.max(maxMemorySeen, currentMemoryBytes); } diff --git a/test/unit/domain/services/IndexRebuildService.test.js b/test/unit/domain/services/IndexRebuildService.test.js index 48d36f3d..bfda9e1d 100644 --- a/test/unit/domain/services/IndexRebuildService.test.js +++ b/test/unit/domain/services/IndexRebuildService.test.js @@ -6,8 +6,14 @@ import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCrypt const crypto = new NodeCryptoAdapter(); describe('IndexRebuildService', () => { + /** @type {any} */ + /** @type {any} */ let service; + /** @type {any} */ + /** @type {any} */ let mockStorage; + /** @type {any} */ + /** @type {any} */ let mockGraphService; beforeEach(() => { @@ -20,32 +26,32 @@ describe('IndexRebuildService', () => { // Mock iterateNodes as an async generator mockGraphService = { - async *iterateNodes({ ref: _ref, limit: _limit }) { + 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({ + service = new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage, graphService: mockGraphService, crypto, - }); + })); }); describe('constructor validation', () => { it('throws when graphService is not provided', () => { - expect(() => new IndexRebuildService({ storage: mockStorage })) + expect(() => new IndexRebuildService(/** @type {any} */ ({ storage: mockStorage }))) .toThrow('IndexRebuildService requires a graphService'); }); it('throws when storage is not provided', () => { - expect(() => new IndexRebuildService({ graphService: mockGraphService })) + expect(() => new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService }))) .toThrow('IndexRebuildService requires a storage adapter'); }); it('throws when called with empty options', () => { - expect(() => new IndexRebuildService({})) + expect(() => new IndexRebuildService(/** @type {any} */ ({}))) .toThrow('IndexRebuildService requires a graphService'); }); }); @@ -89,7 +95,7 @@ describe('IndexRebuildService', () => { expect(mockStorage.writeTree).toHaveBeenCalledTimes(1); const treeEntries = mockStorage.writeTree.mock.calls[0][0]; expect(treeEntries.length).toBeGreaterThanOrEqual(2); - expect(treeEntries.every(e => e.startsWith('100644 blob'))).toBe(true); + expect(treeEntries.every((/** @type {any} */ e) => e.startsWith('100644 blob'))).toBe(true); expect(treeOid).toBe('tree-oid'); }); diff --git a/test/unit/domain/services/IndexStalenessChecker.test.js b/test/unit/domain/services/IndexStalenessChecker.test.js index 1adb93f9..54420631 100644 --- a/test/unit/domain/services/IndexStalenessChecker.test.js +++ b/test/unit/domain/services/IndexStalenessChecker.test.js @@ -14,12 +14,12 @@ describe('loadIndexFrontier', () => { const storage = { readBlob: vi.fn().mockResolvedValue(cborBuffer) }; const shardOids = { 'frontier.cbor': 'cbor-oid' }; - const result = await loadIndexFrontier(shardOids, storage); + const result = await loadIndexFrontier(shardOids, /** @type {any} */ (storage)); expect(result).toBeInstanceOf(Map); - expect(result.get('alice')).toBe('sha-a'); - expect(result.get('bob')).toBe('sha-b'); - expect(result.size).toBe(2); + expect(/** @type {any} */ (result).get('alice')).toBe('sha-a'); + expect(/** @type {any} */ (result).get('bob')).toBe('sha-b'); + expect(/** @type {any} */ (result).size).toBe(2); }); it('with JSON fallback → correct Map', async () => { @@ -28,15 +28,15 @@ describe('loadIndexFrontier', () => { const storage = { readBlob: vi.fn().mockResolvedValue(jsonBuffer) }; const shardOids = { 'frontier.json': 'json-oid' }; - const result = await loadIndexFrontier(shardOids, storage); + const result = await loadIndexFrontier(shardOids, /** @type {any} */ (storage)); expect(result).toBeInstanceOf(Map); - expect(result.get('alice')).toBe('sha-a'); + expect(/** @type {any} */ (result).get('alice')).toBe('sha-a'); }); it('with neither → null', async () => { const storage = { readBlob: vi.fn() }; - const result = await loadIndexFrontier({}, storage); + const result = await loadIndexFrontier({}, /** @type {any} */ (storage)); expect(result).toBeNull(); }); }); @@ -108,8 +108,14 @@ describe('checkStaleness', () => { }); describe('IndexRebuildService.load() staleness integration', () => { + /** @type {any} */ + /** @type {any} */ let storage; + /** @type {any} */ + /** @type {any} */ let logger; + /** @type {any} */ + /** @type {any} */ let graphService; beforeEach(() => { @@ -141,7 +147,7 @@ describe('IndexRebuildService.load() staleness integration', () => { }); storage.readBlob.mockResolvedValue(cborBuffer); - const service = new IndexRebuildService({ graphService, storage, logger }); + const service = new IndexRebuildService(/** @type {any} */ ({ graphService, storage, logger })); const currentFrontier = new Map([['alice', 'sha-new']]); await service.load('tree-oid', { currentFrontier }); @@ -162,7 +168,7 @@ describe('IndexRebuildService.load() staleness integration', () => { }); storage.readBlob.mockResolvedValue(cborBuffer); - const service = new IndexRebuildService({ graphService, storage, logger }); + const service = new IndexRebuildService(/** @type {any} */ ({ graphService, storage, logger })); const currentFrontier = new Map([['alice', 'sha-a']]); await service.load('tree-oid', { currentFrontier }); @@ -175,7 +181,7 @@ describe('IndexRebuildService.load() staleness integration', () => { 'meta_aa.json': 'meta-oid', }); - const service = new IndexRebuildService({ graphService, storage, logger }); + const service = new IndexRebuildService(/** @type {any} */ ({ graphService, storage, logger })); const currentFrontier = new Map([['alice', 'sha-a']]); await service.load('tree-oid', { currentFrontier }); @@ -209,7 +215,7 @@ describe('IndexRebuildService.load() staleness integration', () => { // Mock graphService.iterateNodes to yield nothing (empty graph) graphService.iterateNodes = function* () { /* empty */ }; - const service = new IndexRebuildService({ graphService, storage, logger }); + const service = new IndexRebuildService(/** @type {any} */ ({ graphService, storage, logger })); const reader = await service.load('tree-oid', { currentFrontier, diff --git a/test/unit/domain/services/JoinReducer.edgeProps.test.js b/test/unit/domain/services/JoinReducer.edgeProps.test.js index 31425524..71bc03b5 100644 --- a/test/unit/domain/services/JoinReducer.edgeProps.test.js +++ b/test/unit/domain/services/JoinReducer.edgeProps.test.js @@ -10,8 +10,10 @@ import { applyOpV2, join, joinStates, - reduceV5, + reduceV5 as _reduceV5, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { createEventId } from '../../../../src/domain/utils/EventId.js'; import { createDot } from '../../../../src/domain/crdt/Dot.js'; import { orsetContains } from '../../../../src/domain/crdt/ORSet.js'; @@ -23,14 +25,17 @@ import { createInlineValue } from '../../../../src/domain/types/WarpTypes.js'; // Helpers — mirror the patterns in JoinReducer.test.js // --------------------------------------------------------------------------- +/** @param {string} node @param {any} dot */ function createNodeAddV2(node, dot) { return { type: 'NodeAdd', node, dot }; } +/** @param {string} from @param {string} to @param {string} label @param {any} dot */ function createEdgeAddV2(from, to, label, dot) { return { type: 'EdgeAdd', from, to, label, dot }; } +/** @param {string} node @param {string} key @param {any} value */ function createPropSetV2(node, key, value) { return { type: 'PropSet', node, key, value }; } @@ -40,11 +45,13 @@ function createPropSetV2(node, key, value) { * PatchBuilderV2.setEdgeProperty does: op.node = '\x01from\0to\0label', * op.key = propKey. */ +/** @param {string} from @param {string} to @param {string} label @param {string} propKey @param {any} value */ function createEdgePropSetV2(from, to, label, propKey, value) { const edgeNode = `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`; return createPropSetV2(edgeNode, propKey, value); } +/** @param {any} params */ function createPatchV2({ writer, lamport, ops, context }) { return { schema: 2, @@ -60,6 +67,7 @@ function createPatchV2({ writer, lamport, ops, context }) { * encoding path: encodePropKey(op.node, op.key) which equals * encodeEdgePropKey(from, to, label, propKey). */ +/** @param {any} state @param {string} from @param {string} to @param {string} label @param {string} propKey */ function getEdgeProp(state, from, to, label, propKey) { const key = encodeEdgePropKey(from, to, label, propKey); return lwwValue(state.prop.get(key)); @@ -68,6 +76,7 @@ function getEdgeProp(state, from, to, label, propKey) { /** * Reads a node property from materialized state. */ +/** @param {any} state @param {string} nodeId @param {string} propKey */ function getNodeProp(state, nodeId, propKey) { const key = encodePropKey(nodeId, propKey); return lwwValue(state.prop.get(key)); @@ -326,6 +335,7 @@ describe('JoinReducer — edge property LWW', () => { const expected = createInlineValue(400); // Generate all 24 permutations of 4 elements + /** @param {any[]} arr @returns {any[][]} */ function permutations(arr) { if (arr.length <= 1) return [arr]; const result = []; @@ -364,6 +374,7 @@ describe('JoinReducer — edge property LWW', () => { const expected = createInlineValue('foxtrot'); // Fisher-Yates shuffle with a seeded PRNG (simple LCG) + /** @param {any[]} arr @param {number} seed */ function shuffle(arr, seed) { const a = [...arr]; let s = seed; diff --git a/test/unit/domain/services/JoinReducer.integration.test.js b/test/unit/domain/services/JoinReducer.integration.test.js index bbeb1636..bd0759d5 100644 --- a/test/unit/domain/services/JoinReducer.integration.test.js +++ b/test/unit/domain/services/JoinReducer.integration.test.js @@ -14,7 +14,7 @@ import { describe, it, expect } from 'vitest'; // Core v5 reducer import { - reduceV5, + reduceV5 as _reduceV5, createEmptyStateV5, encodeEdgeKey, encodePropKey, @@ -22,6 +22,14 @@ import { joinStates, } from '../../../../src/domain/services/JoinReducer.js'; +/** + * Typed wrapper for reduceV5 that returns WarpStateV5 (no receipts in these tests). + * @param {any[]} patches + * @param {any} [initialState] + * @returns {any} + */ +const reduceV5 = (patches, initialState) => _reduceV5(patches, initialState); + // v4 reducer helpers (local test helpers for migration tests) import { compareEventIds, createEventId } from '../../../../src/domain/utils/EventId.js'; import { lwwSet, lwwMax } from '../../../../src/domain/crdt/LWW.js'; @@ -29,7 +37,7 @@ import { lwwSet, lwwMax } from '../../../../src/domain/crdt/LWW.js'; /** * Creates an empty v4 state for migration testing. * NOTE: Test-only helper. Schema:1 is deprecated. - * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} + * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} */ function createEmptyState() { return { @@ -42,8 +50,8 @@ function createEmptyState() { /** * v4 reducer for migration testing. * NOTE: Test-only helper. Schema:1 is deprecated. - * @param {Array<{patch: Object, sha: string}>} patches - * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} + * @param {Array<{patch: any, sha: string}>} patches + * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} */ function reduce(patches) { const state = createEmptyState(); @@ -144,11 +152,12 @@ import { * @param {Object} options - Patch options * @param {string} options.writer - Writer ID * @param {number} options.lamport - Lamport timestamp - * @param {Array} options.ops - Array of operations + * @param {Array} options.ops - Array of operations * @param {string} [options.baseCheckpoint] - Optional base checkpoint OID - * @returns {Object} PatchV1 object + * @returns {any} PatchV1 object */ function createPatch({ writer, lamport, ops, baseCheckpoint }) { + /** @type {any} */ const patch = { schema: 1, writer, @@ -173,6 +182,7 @@ import { lwwValue } from '../../../../src/domain/crdt/LWW.js'; /** * Fisher-Yates shuffle - returns a new shuffled array + * @param {any[]} array */ function shuffle(array) { const result = [...array]; @@ -197,6 +207,8 @@ function randomHex(length = 8) { /** * Generates N random v2 patches with varied operations + * @param {number} n + * @param {{ writers?: string[], maxOpsPerPatch?: number }} options */ function generatePatches(n, options = {}) { const { writers = ['writerA', 'writerB', 'writerC', 'writerD'], maxOpsPerPatch = 3 } = options; @@ -247,8 +259,8 @@ function generatePatches(n, options = {}) { const patch = createPatchV2({ writer, lamport, - context: createVersionVector(), - ops, + context: /** @type {any} */ (createVersionVector()), + ops: /** @type {any[]} */ (ops), }); patches.push({ patch, sha }); @@ -260,6 +272,7 @@ function generatePatches(n, options = {}) { /** * Generates v2 patches specifically for testing (deterministic) * Note: SHA must be 4-64 hex chars, so we use 'aaaa' prefix + number in hex + * @param {number} n */ function generateV2Patches(n) { const patches = []; @@ -271,6 +284,7 @@ function generateV2Patches(n) { const sha = `aaaa${i.toString(16).padStart(4, '0')}`; const dot = createDot(writer, i + 1); + /** @type {any[]} */ const ops = [ createNodeAddV2(`node:${i}`, dot), ]; @@ -290,7 +304,7 @@ function generateV2Patches(n) { patch: createPatchV2({ writer, lamport, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops, }), sha, @@ -303,6 +317,7 @@ function generateV2Patches(n) { /** * Computes the included version vector from a set of patches * (max counter per writer across all patches) + * @param {any[]} patches */ function computeIncludedVV(patches) { const vv = createVersionVector(); @@ -323,6 +338,7 @@ function computeIncludedVV(patches) { /** * Gets visible nodes from a v4 state + * @param {any} v4State */ function getVisibleNodes(v4State) { const visible = []; @@ -362,7 +378,7 @@ describe('KILLER TEST 1: Permutation Invariance', () => { patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('x', createDot('A', 1))], }), sha: 'aaaa1111', @@ -372,7 +388,7 @@ describe('KILLER TEST 1: Permutation Invariance', () => { patch: createPatchV2({ writer: 'B', lamport: 2, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('y', createDot('B', 1))], }), sha: 'bbbb2222', @@ -382,7 +398,7 @@ describe('KILLER TEST 1: Permutation Invariance', () => { patch: createPatchV2({ writer: 'C', lamport: 3, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createEdgeAddV2('x', 'y', 'link', createDot('C', 1))], }), sha: 'cccc3333', @@ -486,7 +502,7 @@ describe('KILLER TEST 2: Migration Boundary Test', () => { patch: createPatchV2({ writer: 'charlie', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('user:charlie', createDot('charlie', 1)), createPropSetV2('user:charlie', 'name', createInlineValue('Charlie')), @@ -498,7 +514,7 @@ describe('KILLER TEST 2: Migration Boundary Test', () => { patch: createPatchV2({ writer: 'charlie', lamport: 11, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createEdgeAddV2('user:charlie', 'user:alice', 'follows', createDot('charlie', 2)), ], @@ -605,7 +621,7 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [{ type: 'NodeAdd', node: 'X', dot: createDot('A', 1) }], }), sha: 'aaaa1234', @@ -616,8 +632,8 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'B', lamport: 1, - context: createVersionVector(), - ops: [{ type: 'NodeRemove', observedDots: new Set() }], + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'NodeRemove', observedDots: new Set() })], }), sha: 'bbbb1234', }; @@ -640,7 +656,7 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('X', createDot('A', 1))], }), sha: 'aaaa1111', @@ -651,7 +667,7 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'B', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('X', createDot('B', 1))], }), sha: 'bbbb1111', @@ -662,8 +678,8 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'C', lamport: 2, - context: createVersionVector(), - ops: [{ type: 'NodeRemove', observedDots: new Set(['A:1']) }], + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'NodeRemove', observedDots: new Set(['A:1']) })], }), sha: 'cccc1111', }; @@ -695,7 +711,7 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('X', createDot('A', 1))], }), sha: 'aaaa1111', @@ -706,8 +722,8 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'B', lamport: 2, - context: createVersionVector(), - ops: [{ type: 'NodeRemove', observedDots: new Set(['A:1']) }], + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'NodeRemove', observedDots: new Set(['A:1']) })], }), sha: 'bbbb2222', }; @@ -725,7 +741,7 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'setup', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('from', createDot('setup', 1)), createNodeAddV2('to', createDot('setup', 2)), @@ -740,7 +756,7 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'A', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createEdgeAddV2('from', 'to', 'link', createDot('A', 1))], }), sha: 'edaa0011', @@ -751,8 +767,8 @@ describe('KILLER TEST 3: Concurrent Add/Remove Resurrection (semantic change)', patch: createPatchV2({ writer: 'B', lamport: 10, - context: createVersionVector(), - ops: [{ type: 'EdgeRemove', observedDots: new Set() }], + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'EdgeRemove', observedDots: new Set() })], }), sha: 'edbb0011', }; @@ -796,7 +812,7 @@ describe('KILLER TEST 4: Compaction Safety Test (GC warranty)', () => { patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('x', createDot('A', 1))], }), sha: 'aaaa1111', @@ -806,8 +822,8 @@ describe('KILLER TEST 4: Compaction Safety Test (GC warranty)', () => { patch: createPatchV2({ writer: 'A', lamport: 2, - context: createVersionVector(), - ops: [{ type: 'NodeRemove', observedDots: new Set(['A:1']) }], + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'NodeRemove', observedDots: new Set(['A:1']) })], }), sha: 'bbbb2222', }; @@ -835,7 +851,7 @@ describe('KILLER TEST 4: Compaction Safety Test (GC warranty)', () => { patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('live-node', createDot('A', 1))], }), sha: 'aaaa1111', @@ -896,7 +912,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'base', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('root', createDot('base', 1)), createNodeAddV2('shared', createDot('base', 2)), @@ -919,7 +935,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'alice', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('alice-node', createDot('alice', 1)), createEdgeAddV2('root', 'alice-node', 'owns', createDot('alice', 2)), @@ -934,7 +950,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'bob', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('bob-node', createDot('bob', 1)), createEdgeAddV2('root', 'bob-node', 'owns', createDot('bob', 2)), @@ -981,7 +997,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'base', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('target', createDot('base', 1)), createPropSetV2('target', 'value', createInlineValue('initial')), @@ -1000,7 +1016,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'alice', lamport: 5, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createPropSetV2('target', 'value', createInlineValue('alice-value'))], }), sha: 'aaaa2222', @@ -1010,7 +1026,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'bob', lamport: 7, // Higher lamport - Bob wins - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createPropSetV2('target', 'value', createInlineValue('bob-value'))], }), sha: 'bbbb3333', @@ -1038,7 +1054,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'base', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('contested', createDot('base', 1))], }), sha: 'baaa4444', @@ -1054,8 +1070,8 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'alice', lamport: 10, - context: createVersionVector(), - ops: [{ type: 'NodeRemove', observedDots: new Set(['base:1']) }], + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'NodeRemove', observedDots: new Set(['base:1']) })], }), sha: 'aaaa5555', }; @@ -1065,7 +1081,7 @@ describe('KILLER TEST 5: Diamond Test - True Lattice Confluence', () => { patch: createPatchV2({ writer: 'bob', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('contested', createDot('bob', 1))], }), sha: 'bbbb6666', @@ -1157,6 +1173,7 @@ describe('KILLER TEST 6: Chaos Test - 100 Patches, 5 Permutations', () => { const newCounter = currentCounter + 1; writerCounters.set(writer, newCounter); + /** @type {any[]} */ const ops = []; const nodeId = `chaos-node-${i % 20}`; @@ -1180,7 +1197,7 @@ describe('KILLER TEST 6: Chaos Test - 100 Patches, 5 Permutations', () => { patch: createPatchV2({ writer, lamport, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops, }), sha, @@ -1235,7 +1252,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'A', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('x', createDot('A', 1)), createPropSetV2('x', 'color', createInlineValue('red')), @@ -1248,7 +1265,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'B', lamport: 2, // Higher lamport wins - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createPropSetV2('x', 'color', createInlineValue('blue'))], }), sha: 'bbbb2222', @@ -1272,7 +1289,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'A', lamport: 5, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('x', createDot('A', 1)), createPropSetV2('x', 'val', createInlineValue('A-value')), @@ -1285,7 +1302,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'B', lamport: 5, // Same lamport - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createPropSetV2('x', 'val', createInlineValue('B-value'))], }), sha: 'bbbb2222', @@ -1306,7 +1323,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'W', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('a', createDot('W', 1)), createNodeAddV2('b', createDot('W', 2)), @@ -1319,8 +1336,8 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'W', lamport: 2, - context: createVersionVector(), - ops: [{ type: 'NodeRemove', observedDots: new Set(['W:1']) }], // Remove 'a' + context: /** @type {any} */ (createVersionVector()), + ops: [/** @type {any} */ ({ type: 'NodeRemove', observedDots: new Set(['W:1']) })], // Remove 'a' }), sha: 'aaa22222', }, @@ -1356,7 +1373,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'A', lamport: 1, - context: ctx1, + context: /** @type {any} */ (ctx1), ops: [createNodeAddV2('n1', createDot('A', 1))], }), sha: 'aaaa1111', @@ -1365,7 +1382,7 @@ describe('Additional WARP v5 Integration Tests', () => { patch: createPatchV2({ writer: 'B', lamport: 2, - context: ctx2, + context: /** @type {any} */ (ctx2), ops: [createNodeAddV2('n2', createDot('B', 1))], }), sha: 'bbbb2222', diff --git a/test/unit/domain/services/JoinReducer.receipts.test.js b/test/unit/domain/services/JoinReducer.receipts.test.js index eec0000c..e146f0c1 100644 --- a/test/unit/domain/services/JoinReducer.receipts.test.js +++ b/test/unit/domain/services/JoinReducer.receipts.test.js @@ -4,8 +4,10 @@ import { encodeEdgeKey, encodePropKey, join, - reduceV5, + reduceV5 as _reduceV5, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { createDot, encodeDot } from '../../../../src/domain/crdt/Dot.js'; import { orsetAdd, orsetRemove, orsetContains } from '../../../../src/domain/crdt/ORSet.js'; import { createVersionVector } from '../../../../src/domain/crdt/VersionVector.js'; @@ -16,7 +18,7 @@ import { lwwSet } from '../../../../src/domain/crdt/LWW.js'; // Helpers // --------------------------------------------------------------------------- -function makePatch({ writer = 'w1', lamport = 1, ops = [], context }) { +function makePatch({ writer = 'w1', lamport = 1, ops = /** @type {any[]} */ ([]), context = /** @type {any} */ (undefined) }) { return { schema: 2, writer, @@ -26,22 +28,27 @@ function makePatch({ writer = 'w1', lamport = 1, ops = [], context }) { }; } +/** @param {any} node @param {any} dot */ function nodeAdd(node, dot) { return { type: 'NodeAdd', node, dot }; } +/** @param {any} node @param {any} observedDots */ function nodeRemove(node, observedDots) { return { type: 'NodeRemove', node, observedDots }; } +/** @param {any} from @param {any} to @param {any} label @param {any} dot */ function edgeAdd(from, to, label, dot) { return { type: 'EdgeAdd', from, to, label, dot }; } +/** @param {any} from @param {any} to @param {any} label @param {any} observedDots */ function edgeRemove(from, to, label, observedDots) { return { type: 'EdgeRemove', from, to, label, observedDots }; } +/** @param {any} node @param {any} key @param {any} value */ function propSet(node, key, value) { return { type: 'PropSet', node, key, value }; } @@ -61,7 +68,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeAdd('n1', createDot('w1', 1))], }); - const result = join(state, patch, 'abcd1234'); + const result = /** @type {any} */ (join(state, patch, 'abcd1234')); // Returns the state object directly (not wrapped) expect(result).toBe(state); expect(result.nodeAlive).toBeDefined(); @@ -70,7 +77,7 @@ describe('JoinReducer receipts', () => { it('returns state directly when collectReceipts is undefined', () => { const state = createEmptyStateV5(); const patch = makePatch({ ops: [] }); - const result = join(state, patch, 'abcd1234', undefined); + const result = /** @type {any} */ (join(state, patch, 'abcd1234', undefined)); expect(result).toBe(state); }); }); @@ -85,7 +92,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeAdd('n1', createDot('w1', 1))], }); - const result = join(state, patch, 'abcd1234', true); + const result = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(result).toHaveProperty('state'); expect(result).toHaveProperty('receipt'); expect(result.state).toBe(state); @@ -98,7 +105,7 @@ describe('JoinReducer receipts', () => { lamport: 42, ops: [nodeAdd('n1', createDot('alice', 1))], }); - const { receipt } = join(state, patch, 'deadbeef', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'deadbeef', true)); expect(receipt.patchSha).toBe('deadbeef'); expect(receipt.writer).toBe('alice'); expect(receipt.lamport).toBe(42); @@ -109,7 +116,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeAdd('n1', createDot('w1', 1))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(Object.isFrozen(receipt)).toBe(true); expect(Object.isFrozen(receipt.ops)).toBe(true); }); @@ -117,7 +124,7 @@ describe('JoinReducer receipts', () => { it('empty patch yields receipt with empty ops', () => { const state = createEmptyStateV5(); const patch = makePatch({ ops: [] }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops).toHaveLength(0); }); }); @@ -132,7 +139,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeAdd('n1', createDot('w1', 1))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0]).toEqual({ op: 'NodeAdd', target: 'n1', @@ -149,7 +156,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeAdd('n1', dot)], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('redundant'); }); @@ -161,7 +168,7 @@ describe('JoinReducer receipts', () => { writer: 'w2', ops: [nodeAdd('n1', createDot('w2', 1))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('applied'); }); }); @@ -180,7 +187,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeRemove('n1', new Set([encoded]))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0]).toMatchObject({ op: 'NodeTombstone', result: 'applied', @@ -198,7 +205,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeRemove('n1', new Set([encoded]))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('redundant'); }); @@ -209,7 +216,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [nodeRemove('n1', new Set([encoded]))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('redundant'); }); }); @@ -224,7 +231,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [edgeAdd('a', 'b', 'rel', createDot('w1', 1))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0]).toEqual({ op: 'EdgeAdd', target: encodeEdgeKey('a', 'b', 'rel'), @@ -241,7 +248,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [edgeAdd('a', 'b', 'rel', dot)], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('redundant'); }); }); @@ -261,7 +268,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [edgeRemove('a', 'b', 'rel', new Set([encoded]))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0]).toMatchObject({ op: 'EdgeTombstone', result: 'applied', @@ -279,7 +286,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [edgeRemove('a', 'b', 'rel', new Set([encoded]))], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('redundant'); }); }); @@ -294,7 +301,7 @@ describe('JoinReducer receipts', () => { const patch = makePatch({ ops: [propSet('n1', 'name', 'Alice')], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0]).toEqual({ op: 'PropSet', target: encodePropKey('n1', 'name'), @@ -314,7 +321,7 @@ describe('JoinReducer receipts', () => { lamport: 2, ops: [propSet('n1', 'name', 'NewName')], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('applied'); }); @@ -330,7 +337,7 @@ describe('JoinReducer receipts', () => { lamport: 1, // Lower lamport ops: [propSet('n1', 'name', 'Loser')], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('superseded'); expect(receipt.ops[0].reason).toContain('LWW'); expect(receipt.ops[0].reason).toContain('w1'); @@ -349,7 +356,7 @@ describe('JoinReducer receipts', () => { lamport: 1, ops: [propSet('n1', 'name', 'Value')], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops[0].result).toBe('redundant'); }); }); @@ -480,7 +487,7 @@ describe('JoinReducer receipts', () => { propSet('n1', 'name', 'Alice'), ], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops).toHaveLength(3); expect(receipt.ops[0].op).toBe('NodeAdd'); expect(receipt.ops[1].op).toBe('NodeAdd'); @@ -507,7 +514,7 @@ describe('JoinReducer receipts', () => { }); // Must not throw despite the unknown op type - const { state: resultState, receipt } = join(state, patch, 'abcd1234', true); + const { state: resultState, receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); // The unknown op is silently skipped in the receipt expect(receipt.ops).toHaveLength(2); @@ -528,7 +535,7 @@ describe('JoinReducer receipts', () => { ], }); - const { receipt } = join(state, patch, 'abcd1234', true); + const { receipt } = /** @type {any} */ (join(state, patch, 'abcd1234', true)); expect(receipt.ops).toHaveLength(0); }); }); diff --git a/test/unit/domain/services/JoinReducer.test.js b/test/unit/domain/services/JoinReducer.test.js index 1966686f..3d7d7abb 100644 --- a/test/unit/domain/services/JoinReducer.test.js +++ b/test/unit/domain/services/JoinReducer.test.js @@ -8,9 +8,11 @@ import { applyOpV2, join, joinStates, - reduceV5, + reduceV5 as _reduceV5, cloneStateV5, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { createEventId } from '../../../../src/domain/utils/EventId.js'; import { createDot } from '../../../../src/domain/crdt/Dot.js'; import { orsetContains, orsetGetDots } from '../../../../src/domain/crdt/ORSet.js'; @@ -19,26 +21,32 @@ import { createVersionVector, vvMerge } from '../../../../src/domain/crdt/Versio import { createInlineValue } from '../../../../src/domain/types/WarpTypes.js'; // Helper functions to create V2 operations +/** @param {string} node @param {any} dot */ function createNodeAddV2(node, dot) { return { type: 'NodeAdd', node, dot }; } +/** @param {any} observedDots */ function createNodeRemoveV2(observedDots) { return { type: 'NodeRemove', observedDots }; } +/** @param {string} from @param {string} to @param {string} label @param {any} dot */ function createEdgeAddV2(from, to, label, dot) { return { type: 'EdgeAdd', from, to, label, dot }; } +/** @param {any} observedDots */ function createEdgeRemoveV2(observedDots) { return { type: 'EdgeRemove', observedDots }; } +/** @param {string} node @param {string} key @param {any} value */ function createPropSetV2(node, key, value) { return { type: 'PropSet', node, key, value }; } +/** @param {any} params */ function createPatchV2({ writer, lamport, ops, context }) { return { schema: 2, @@ -49,6 +57,7 @@ function createPatchV2({ writer, lamport, ops, context }) { }; } + describe('JoinReducer', () => { describe('createEmptyStateV5', () => { it('returns state with empty ORSets and Maps', () => { @@ -70,7 +79,7 @@ describe('JoinReducer', () => { const state1 = createEmptyStateV5(); const state2 = createEmptyStateV5(); - state1.prop.set('key', { eventId: {}, value: 'test' }); + state1.prop.set('key', { eventId: /** @type {any} */ ({}), value: 'test' }); expect(state2.prop.size).toBe(0); }); diff --git a/test/unit/domain/services/MigrationService.test.js b/test/unit/domain/services/MigrationService.test.js index 3cb578f6..144862c6 100644 --- a/test/unit/domain/services/MigrationService.test.js +++ b/test/unit/domain/services/MigrationService.test.js @@ -1,10 +1,12 @@ import { describe, it, expect } from 'vitest'; import { migrateV4toV5 } from '../../../../src/domain/services/MigrationService.js'; import { - reduceV5, + reduceV5 as _reduceV5, encodeEdgeKey as encodeEdgeKeyV5, encodePropKey as encodePropKeyV5, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { compareEventIds, createEventId } from '../../../../src/domain/utils/EventId.js'; import { lwwSet as lwwSetImported, lwwMax as lwwMaxImported } from '../../../../src/domain/crdt/LWW.js'; @@ -19,7 +21,7 @@ const lwwMaxLocal = lwwMaxImported; /** * Creates an empty v4 state for migration testing. * NOTE: Test-only helper. Schema:1 is deprecated. - * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} + * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} */ function createEmptyState() { return { @@ -55,8 +57,8 @@ function encodePropKey(nodeId, propKey) { /** * v4 reducer for migration testing. * NOTE: Test-only helper. Schema:1 is deprecated. - * @param {Array<{patch: Object, sha: string}>} patches - * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} + * @param {Array<{patch: any, sha: string}>} patches + * @returns {{nodeAlive: Map, edgeAlive: Map, prop: Map}} */ function reduce(patches) { const state = createEmptyState(); @@ -150,11 +152,12 @@ const crypto = new NodeCryptoAdapter(); * @param {Object} options - Patch options * @param {string} options.writer - Writer ID * @param {number} options.lamport - Lamport timestamp - * @param {Array} options.ops - Array of operations + * @param {any[]} options.ops - Array of operations * @param {string} [options.baseCheckpoint] - Optional base checkpoint OID - * @returns {Object} PatchV1 object + * @returns {any} PatchV1 object */ function createPatch({ writer, lamport, ops, baseCheckpoint }) { + /** @type {any} */ const patch = { schema: 1, writer, @@ -170,7 +173,7 @@ function createPatch({ writer, lamport, ops, baseCheckpoint }) { /** * Helper to create a v4 state with nodes, edges, and props directly */ -function createV4State({ nodes = [], edges = [], props = [] } = {}) { +function createV4State({ nodes = /** @type {any[]} */ ([]), edges = /** @type {any[]} */ ([]), props = /** @type {any[]} */ ([]) } = {}) { const state = createEmptyState(); let counter = 0; @@ -680,7 +683,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'charlie', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('user:charlie', createDot('charlie', 1)), createPropSetV2('user:charlie', 'name', createInlineValue('Charlie')), @@ -692,7 +695,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'charlie', lamport: 11, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createEdgeAddV2('user:charlie', 'user:alice', 'follows', createDot('charlie', 2)), createEdgeAddV2('user:charlie', 'user:bob', 'follows', createDot('charlie', 3)), @@ -747,7 +750,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'A', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('node-a', createDot('A', 1))], }), sha: 'aaaa2222', @@ -757,7 +760,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'B', lamport: 11, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('node-b', createDot('B', 1))], }), sha: 'bbbb3333', @@ -767,7 +770,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'C', lamport: 12, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createEdgeAddV2('node-a', 'node-b', 'link', createDot('C', 1))], }), sha: 'cccc4444', @@ -827,7 +830,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'bob', lamport: 2, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('node-from-v2', createDot('bob', 1))], }), sha: 'b2bb2222', @@ -886,7 +889,7 @@ describe('MigrationService', () => { patch: createPatchV2({ writer: 'V5-writer', lamport: 10, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [ createNodeAddV2('n3', createDot('V5-writer', 1)), createEdgeAddV2('n1', 'n3', 'link', createDot('V5-writer', 2)), @@ -933,7 +936,7 @@ describe('MigrationService', () => { const v2Patch = createPatchV2({ writer: 'W', lamport: 1, - context: createVersionVector(), + context: /** @type {any} */ (createVersionVector()), ops: [createNodeAddV2('test-node', createDot('W', 1))], }); @@ -942,9 +945,9 @@ describe('MigrationService', () => { expect(v1Patch.ops[0].dot).toBeUndefined(); // v1 has no dot expect(v2Patch.ops[0].type).toBe('NodeAdd'); - expect(v2Patch.ops[0].dot).toBeDefined(); // v2 has a dot - expect(v2Patch.ops[0].dot.writerId).toBe('W'); - expect(v2Patch.ops[0].dot.counter).toBe(1); + expect(/** @type {any} */ (v2Patch.ops[0]).dot).toBeDefined(); // v2 has a dot + expect(/** @type {any} */ (v2Patch.ops[0]).dot.writerId).toBe('W'); + expect(/** @type {any} */ (v2Patch.ops[0]).dot.counter).toBe(1); }); }); }); diff --git a/test/unit/domain/services/ObserverView.test.js b/test/unit/domain/services/ObserverView.test.js index e154dcdc..0eb80f6e 100644 --- a/test/unit/domain/services/ObserverView.test.js +++ b/test/unit/domain/services/ObserverView.test.js @@ -4,6 +4,7 @@ import { createEmptyStateV5, encodeEdgeKey, encodePropKey } from '../../../../sr import { orsetAdd } from '../../../../src/domain/crdt/ORSet.js'; import { createDot } from '../../../../src/domain/crdt/Dot.js'; +/** @param {any} graph @param {(state: any) => void} seedFn */ function setupGraphState(graph, seedFn) { const state = createEmptyStateV5(); graph._cachedState = state; @@ -11,31 +12,38 @@ function setupGraphState(graph, seedFn) { seedFn(state); } +/** @param {any} state @param {any} nodeId @param {any} counter */ function addNode(state, nodeId, counter) { orsetAdd(state.nodeAlive, nodeId, createDot('w1', counter)); } +/** @param {any} state @param {any} from @param {any} to @param {any} label @param {any} counter */ function addEdge(state, from, to, label, counter) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); } +/** @param {any} state @param {any} nodeId @param {any} key @param {any} value */ function addProp(state, nodeId, key, value) { const propKey = encodePropKey(nodeId, key); state.prop.set(propKey, { value, lamport: 1, writerId: 'w1' }); } describe('ObserverView', () => { + /** @type {any} */ + /** @type {any} */ let mockPersistence; + /** @type {any} */ + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ @@ -271,7 +279,7 @@ describe('ObserverView', () => { const view = await graph.observer('userView', { match: 'user:*' }); const result = await view.query().match('user:*').run(); - expect(result.nodes.map((n) => n.id)).toEqual(['user:alice', 'user:bob']); + expect(result.nodes.map((/** @type {any} */ n) => n.id)).toEqual(['user:alice', 'user:bob']); }); it('query with where filter works through observer', async () => { @@ -288,7 +296,7 @@ describe('ObserverView', () => { const result = await view.query().match('*').where({ role: 'admin' }).run(); // Only user:alice should show, not team:eng (filtered by observer) - expect(result.nodes.map((n) => n.id)).toEqual(['user:alice']); + expect(result.nodes.map((/** @type {any} */ n) => n.id)).toEqual(['user:alice']); }); it('query respects property redaction in results', async () => { @@ -323,7 +331,7 @@ describe('ObserverView', () => { const result = await view.query().match('user:alice').outgoing().run(); // Should only see user:bob, not team:eng - expect(result.nodes.map((n) => n.id)).toEqual(['user:bob']); + expect(result.nodes.map((/** @type {any} */ n) => n.id)).toEqual(['user:bob']); }); }); diff --git a/test/unit/domain/services/PatchBuilderV2.cas.test.js b/test/unit/domain/services/PatchBuilderV2.cas.test.js index b42430fe..466a8f84 100644 --- a/test/unit/domain/services/PatchBuilderV2.cas.test.js +++ b/test/unit/domain/services/PatchBuilderV2.cas.test.js @@ -9,6 +9,7 @@ import { createVersionVector } from '../../../../src/domain/crdt/VersionVector.j * @param {Object} [overrides] - Method overrides * @returns {Object} Mock persistence adapter */ +/** @returns {any} */ function createMockPersistence(overrides = {}) { return { readRef: vi.fn().mockResolvedValue(null), @@ -87,7 +88,7 @@ describe('PatchBuilderV2 CAS conflict detection', () => { try { await builder.commit(); expect.unreachable('commit() should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(WriterError); expect(err.expectedSha).toBe(expectedParent); expect(err.actualSha).toBe(advancedSha); @@ -141,7 +142,7 @@ describe('PatchBuilderV2 CAS conflict detection', () => { try { await builder.commit(); expect.unreachable('commit() should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(WriterError); expect(err.code).toBe('WRITER_CAS_CONFLICT'); expect(err.expectedSha).toBeNull(); @@ -172,7 +173,7 @@ describe('PatchBuilderV2 CAS conflict detection', () => { try { await builder.commit(); expect.unreachable('commit() should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(WriterError); expect(err.code).toBe('WRITER_CAS_CONFLICT'); expect(err.expectedSha).toBe(expectedParent); diff --git a/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js b/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js index fa27d461..3d3f03e4 100644 --- a/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js +++ b/test/unit/domain/services/PatchBuilderV2.edgeProps.test.js @@ -10,13 +10,13 @@ import { /** * Helper — creates a minimal PatchBuilderV2 for unit tests (no persistence needed). */ -function makeBuilder(opts = {}) { - return new PatchBuilderV2({ +function makeBuilder(opts = /** @type {any} */ ({})) { + return new PatchBuilderV2(/** @type {any} */ ({ writerId: opts.writerId ?? 'w1', lamport: opts.lamport ?? 1, versionVector: opts.versionVector ?? createVersionVector(), getCurrentState: opts.getCurrentState ?? (() => null), - }); + })); } describe('PatchBuilderV2.setEdgeProperty', () => { @@ -34,7 +34,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const patch = builder.build(); expect(patch.ops).toHaveLength(2); - const propOp = patch.ops[1]; + const propOp = /** @type {any} */ (patch.ops[1]); expect(propOp.type).toBe('PropSet'); expect(propOp.key).toBe('since'); expect(propOp.value).toBe('2025-01-01'); @@ -47,7 +47,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'weight', 42); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); const mapKey = encodePropKey(op.node, op.key); const expected = encodeEdgePropKey('a', 'b', 'rel', 'weight'); expect(mapKey).toBe(expected); @@ -66,7 +66,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { .setProperty('a', 'weight', 10) .setEdgeProperty('a', 'b', 'rel', 'weight', 99); - const [, nodeOp, edgeOp] = builder.ops; + const [, nodeOp, edgeOp] = /** @type {any[]} */ (builder.ops); // Both are PropSet but with different node fields expect(nodeOp.type).toBe('PropSet'); @@ -96,7 +96,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const patch = builder.build(); expect(patch.ops).toHaveLength(4); expect(patch.ops[3].type).toBe('PropSet'); - expect(patch.ops[3].value).toBe('red'); + expect(/** @type {any} */ (patch.ops[3]).value).toBe('red'); }); }); @@ -117,7 +117,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { // 1 EdgeAdd + 3 PropSet expect(patch.ops).toHaveLength(4); - const propOps = patch.ops.filter((o) => o.type === 'PropSet'); + const propOps = /** @type {any[]} */ (patch.ops.filter((o) => o.type === 'PropSet')); expect(propOps).toHaveLength(3); // All share the same node field (edge identity) @@ -142,7 +142,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'note', ''); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); expect(op.value).toBe(''); }); @@ -150,7 +150,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'weight', 3.14); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); expect(op.value).toBe(3.14); }); @@ -159,7 +159,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const obj = { nested: true, count: 7 }; builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'meta', obj); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); expect(op.value).toEqual({ nested: true, count: 7 }); }); @@ -167,7 +167,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'deleted', null); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); expect(op.value).toBeNull(); }); @@ -175,7 +175,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'active', false); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); expect(op.value).toBe(false); }); @@ -183,7 +183,7 @@ describe('PatchBuilderV2.setEdgeProperty', () => { const builder = makeBuilder(); builder.addEdge('a', 'b', 'rel').setEdgeProperty('a', 'b', 'rel', 'tags', ['x', 'y']); - const op = builder.ops[1]; + const op = /** @type {any} */ (builder.ops[1]); expect(op.value).toEqual(['x', 'y']); }); }); @@ -238,14 +238,14 @@ describe('PatchBuilderV2.setEdgeProperty', () => { expect(types).toEqual(['NodeAdd', 'EdgeAdd', 'PropSet', 'PropSet', 'PropSet']); // Verify which PropSet is which by checking the key - expect(builder.ops[2].key).toBe('name'); - expect(builder.ops[3].key).toBe('weight'); - expect(builder.ops[4].key).toBe('age'); + expect(/** @type {any} */ (builder.ops[2]).key).toBe('name'); + expect(/** @type {any} */ (builder.ops[3]).key).toBe('weight'); + expect(/** @type {any} */ (builder.ops[4]).key).toBe('age'); // Only the middle PropSet should have edge-prop-prefix node - expect(builder.ops[2].node).toBe('n1'); - expect(builder.ops[3].node.startsWith(EDGE_PROP_PREFIX)).toBe(true); - expect(builder.ops[4].node).toBe('n1'); + expect(/** @type {any} */ (builder.ops[2]).node).toBe('n1'); + expect(/** @type {any} */ (builder.ops[3]).node.startsWith(EDGE_PROP_PREFIX)).toBe(true); + expect(/** @type {any} */ (builder.ops[4]).node).toBe('n1'); }); }); }); diff --git a/test/unit/domain/services/PatchBuilderV2.test.js b/test/unit/domain/services/PatchBuilderV2.test.js index 7fa93027..fbeea701 100644 --- a/test/unit/domain/services/PatchBuilderV2.test.js +++ b/test/unit/domain/services/PatchBuilderV2.test.js @@ -9,7 +9,7 @@ import { decode } from '../../../../src/infrastructure/codecs/CborCodec.js'; /** * Creates a mock V5 state for testing. - * @returns {Object} Mock state with nodeAlive and edgeAlive ORSets + * @returns {any} Mock state with nodeAlive and edgeAlive ORSets */ function createMockState() { return { @@ -22,7 +22,7 @@ function createMockState() { /** * Creates a mock persistence adapter for testing commit(). - * @returns {Object} Mock persistence with standard methods stubbed + * @returns {any} Mock persistence with standard methods stubbed */ function createMockPersistence() { return { @@ -39,12 +39,12 @@ describe('PatchBuilderV2', () => { describe('building patch with node add', () => { it('creates NodeAdd operation with dot', () => { const vv = createVersionVector(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.addNode('x'); @@ -54,17 +54,17 @@ describe('PatchBuilderV2', () => { expect(patch.lamport).toBe(1); expect(patch.ops).toHaveLength(1); expect(patch.ops[0].type).toBe('NodeAdd'); - expect(patch.ops[0].node).toBe('x'); - expect(patch.ops[0].dot).toEqual({ writerId: 'writer1', counter: 1 }); + expect(/** @type {any} */ (patch.ops[0]).node).toBe('x'); + expect(/** @type {any} */ (patch.ops[0]).dot).toEqual({ writerId: 'writer1', counter: 1 }); }); it('returns this for chaining', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); const result = builder.addNode('x'); expect(result).toBe(builder); @@ -78,21 +78,21 @@ describe('PatchBuilderV2', () => { const existingDot = createDot('otherWriter', 5); orsetAdd(state.nodeAlive, 'x', existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeNode('x'); const patch = builder.build(); expect(patch.ops).toHaveLength(1); expect(patch.ops[0].type).toBe('NodeRemove'); - expect(patch.ops[0].node).toBe('x'); + expect(/** @type {any} */ (patch.ops[0]).node).toBe('x'); // orsetGetDots returns already-encoded dots (strings like "writerId:counter") - expect(patch.ops[0].observedDots).toEqual(['otherWriter:5']); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toEqual(['otherWriter:5']); }); it('includes multiple observed dots when node has multiple adds', () => { @@ -102,29 +102,29 @@ describe('PatchBuilderV2', () => { orsetAdd(state.nodeAlive, 'x', dot1); orsetAdd(state.nodeAlive, 'x', dot2); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 3, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeNode('x'); const patch = builder.build(); - expect(patch.ops[0].observedDots).toHaveLength(2); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toHaveLength(2); // orsetGetDots returns already-encoded dots (strings like "writerId:counter") - expect(patch.ops[0].observedDots).toContain('writerA:1'); - expect(patch.ops[0].observedDots).toContain('writerB:2'); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toContain('writerA:1'); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toContain('writerB:2'); }); it('returns this for chaining', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); const result = builder.removeNode('x'); expect(result).toBe(builder); @@ -133,22 +133,22 @@ describe('PatchBuilderV2', () => { describe('building patch with edge add/remove', () => { it('creates EdgeAdd operation with dot', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addEdge('a', 'b', 'follows'); const patch = builder.build(); expect(patch.ops).toHaveLength(1); expect(patch.ops[0].type).toBe('EdgeAdd'); - expect(patch.ops[0].from).toBe('a'); - expect(patch.ops[0].to).toBe('b'); - expect(patch.ops[0].label).toBe('follows'); - expect(patch.ops[0].dot).toEqual({ writerId: 'writer1', counter: 1 }); + expect(/** @type {any} */ (patch.ops[0]).from).toBe('a'); + expect(/** @type {any} */ (patch.ops[0]).to).toBe('b'); + expect(/** @type {any} */ (patch.ops[0]).label).toBe('follows'); + expect(/** @type {any} */ (patch.ops[0]).dot).toEqual({ writerId: 'writer1', counter: 1 }); }); it('creates EdgeRemove operation with observedDots from state', () => { @@ -157,43 +157,43 @@ describe('PatchBuilderV2', () => { const edgeKey = encodeEdgeKey('a', 'b', 'follows'); orsetAdd(state.edgeAlive, edgeKey, existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeEdge('a', 'b', 'follows'); const patch = builder.build(); expect(patch.ops).toHaveLength(1); expect(patch.ops[0].type).toBe('EdgeRemove'); - expect(patch.ops[0].from).toBe('a'); - expect(patch.ops[0].to).toBe('b'); - expect(patch.ops[0].label).toBe('follows'); + expect(/** @type {any} */ (patch.ops[0]).from).toBe('a'); + expect(/** @type {any} */ (patch.ops[0]).to).toBe('b'); + expect(/** @type {any} */ (patch.ops[0]).label).toBe('follows'); // orsetGetDots returns already-encoded dots (strings like "writerId:counter") - expect(patch.ops[0].observedDots).toEqual(['otherWriter:3']); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toEqual(['otherWriter:3']); }); it('addEdge returns this for chaining', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); expect(builder.addEdge('a', 'b', 'rel')).toBe(builder); }); it('removeEdge returns this for chaining', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); expect(builder.removeEdge('a', 'b', 'rel')).toBe(builder); }); @@ -201,33 +201,33 @@ describe('PatchBuilderV2', () => { describe('building patch with property set', () => { it('creates PropSet operation without dot', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.setProperty('x', 'name', 'Alice'); const patch = builder.build(); expect(patch.ops).toHaveLength(1); expect(patch.ops[0].type).toBe('PropSet'); - expect(patch.ops[0].node).toBe('x'); - expect(patch.ops[0].key).toBe('name'); - expect(patch.ops[0].value).toBe('Alice'); + expect(/** @type {any} */ (patch.ops[0]).node).toBe('x'); + expect(/** @type {any} */ (patch.ops[0]).key).toBe('name'); + expect(/** @type {any} */ (patch.ops[0]).value).toBe('Alice'); // PropSet should NOT have a dot field - expect(patch.ops[0].dot).toBeUndefined(); + expect(/** @type {any} */ (patch.ops[0]).dot).toBeUndefined(); }); it('does not increment version vector for props', () => { const vv = createVersionVector(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.setProperty('x', 'name', 'Alice'); builder.setProperty('x', 'age', 30); @@ -237,23 +237,23 @@ describe('PatchBuilderV2', () => { }); it('returns this for chaining', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); expect(builder.setProperty('x', 'name', 'Alice')).toBe(builder); }); it('handles various value types', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder .setProperty('node', 'string', 'hello') @@ -265,24 +265,24 @@ describe('PatchBuilderV2', () => { const patch = builder.build(); expect(patch.ops).toHaveLength(6); - expect(patch.ops[0].value).toBe('hello'); - expect(patch.ops[1].value).toBe(42); - expect(patch.ops[2].value).toBe(true); - expect(patch.ops[3].value).toBe(null); - expect(patch.ops[4].value).toEqual([1, 2, 3]); - expect(patch.ops[5].value).toEqual({ key: 'value' }); + expect(/** @type {any} */ (patch.ops[0]).value).toBe('hello'); + expect(/** @type {any} */ (patch.ops[1]).value).toBe(42); + expect(/** @type {any} */ (patch.ops[2]).value).toBe(true); + expect(/** @type {any} */ (patch.ops[3]).value).toBe(null); + expect(/** @type {any} */ (patch.ops[4]).value).toEqual([1, 2, 3]); + expect(/** @type {any} */ (patch.ops[5]).value).toEqual({ key: 'value' }); }); }); describe('multiple operations increment the VersionVector', () => { it('increments version vector for each add operation', () => { const vv = createVersionVector(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.addNode('a'); expect(builder.versionVector.get('writer1')).toBe(1); @@ -295,31 +295,31 @@ describe('PatchBuilderV2', () => { }); it('assigns sequential dots to operations', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('a').addNode('b').addEdge('a', 'b', 'link'); const patch = builder.build(); - expect(patch.ops[0].dot).toEqual({ writerId: 'writer1', counter: 1 }); - expect(patch.ops[1].dot).toEqual({ writerId: 'writer1', counter: 2 }); - expect(patch.ops[2].dot).toEqual({ writerId: 'writer1', counter: 3 }); + expect(/** @type {any} */ (patch.ops[0]).dot).toEqual({ writerId: 'writer1', counter: 1 }); + expect(/** @type {any} */ (patch.ops[1]).dot).toEqual({ writerId: 'writer1', counter: 2 }); + expect(/** @type {any} */ (patch.ops[2]).dot).toEqual({ writerId: 'writer1', counter: 3 }); }); it('preserves existing version vector entries', () => { const vv = createVersionVector(); vv.set('otherWriter', 10); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.addNode('x'); @@ -332,29 +332,29 @@ describe('PatchBuilderV2', () => { const vv = createVersionVector(); vv.set('writer1', 5); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.addNode('x'); expect(builder.versionVector.get('writer1')).toBe(6); - expect(builder.ops[0].dot).toEqual({ writerId: 'writer1', counter: 6 }); + expect(/** @type {any} */ (builder.ops[0]).dot).toEqual({ writerId: 'writer1', counter: 6 }); }); it('does not mutate original version vector', () => { const originalVv = createVersionVector(); originalVv.set('writer1', 3); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: originalVv, getCurrentState: () => null, - }); + })); builder.addNode('x'); @@ -367,63 +367,63 @@ describe('PatchBuilderV2', () => { describe('empty state produces empty observedDots', () => { it('removeNode with null state returns empty observedDots', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.removeNode('x'); const patch = builder.build(); - expect(patch.ops[0].observedDots).toEqual([]); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toEqual([]); }); it('removeNode with empty state returns empty observedDots', () => { const state = createMockState(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeNode('x'); // Node doesn't exist in state const patch = builder.build(); - expect(patch.ops[0].observedDots).toEqual([]); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toEqual([]); }); it('removeEdge with null state returns empty observedDots', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.removeEdge('a', 'b', 'rel'); const patch = builder.build(); - expect(patch.ops[0].observedDots).toEqual([]); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toEqual([]); }); it('removeEdge with empty state returns empty observedDots', () => { const state = createMockState(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeEdge('a', 'b', 'rel'); // Edge doesn't exist in state const patch = builder.build(); - expect(patch.ops[0].observedDots).toEqual([]); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toEqual([]); }); }); @@ -432,45 +432,45 @@ describe('PatchBuilderV2', () => { const vv = createVersionVector(); vv.set('otherWriter', 5); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.addNode('x'); const patch = builder.build(); expect(patch.context).toBeDefined(); - expect(patch.context.get('writer1')).toBe(1); - expect(patch.context.get('otherWriter')).toBe(5); + expect(/** @type {any} */ (patch.context).get('writer1')).toBe(1); + expect(/** @type {any} */ (patch.context).get('otherWriter')).toBe(5); }); it('context is the updated version vector with increments', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('a').addNode('b').addEdge('a', 'b', 'link'); const patch = builder.build(); // Context should reflect all 3 increments - expect(patch.context.get('writer1')).toBe(3); + expect(/** @type {any} */ (patch.context).get('writer1')).toBe(3); }); }); describe('ops getter', () => { it('returns the operations array', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); @@ -479,12 +479,12 @@ describe('PatchBuilderV2', () => { }); it('returns empty array when no operations', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); expect(builder.ops).toEqual([]); }); @@ -500,12 +500,12 @@ describe('PatchBuilderV2', () => { const vv = createVersionVector(); vv.set('writer1', 1); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: vv, getCurrentState: () => state, - }); + })); builder .addNode('a') @@ -524,12 +524,12 @@ describe('PatchBuilderV2', () => { }); it('supports method chaining for all operations', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); const result = builder .addNode('a') @@ -547,14 +547,14 @@ describe('PatchBuilderV2', () => { describe('commit()', () => { it('commits a patch and returns the commit SHA', async () => { const persistence = createMockPersistence(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); const sha = await builder.commit(); @@ -571,28 +571,28 @@ describe('PatchBuilderV2', () => { it('throws error for empty patch', async () => { const persistence = createMockPersistence(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); await expect(builder.commit()).rejects.toThrow('Cannot commit empty patch'); }); it('creates commit with schema:2 in trailers', async () => { const persistence = createMockPersistence(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); await builder.commit(); @@ -617,7 +617,7 @@ describe('PatchBuilderV2', () => { `warp:patch\n\neg-kind: patch\neg-graph: test-graph\neg-writer: writer1\neg-lamport: 5\neg-patch-oid: ${existingPatchOid}\neg-schema: 2` ); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', @@ -625,7 +625,7 @@ describe('PatchBuilderV2', () => { versionVector: createVersionVector(), getCurrentState: () => null, expectedParentSha: existingSha, // Race detection: expected parent matches current ref - }); + })); builder.addNode('x'); await builder.commit(); @@ -641,14 +641,14 @@ describe('PatchBuilderV2', () => { it('creates tree with patch.cbor blob', async () => { const persistence = createMockPersistence(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); await builder.commit(); @@ -664,20 +664,21 @@ describe('PatchBuilderV2', () => { const vv = createVersionVector(); vv.set('otherWriter', 3); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: vv, getCurrentState: () => null, - }); + })); builder.addNode('x').setProperty('x', 'name', 'X'); await builder.commit(); // Decode the blob that was written const blobData = persistence.writeBlob.mock.calls[0][0]; + /** @type {any} */ const patch = decode(blobData); expect(patch.schema).toBe(2); @@ -695,14 +696,14 @@ describe('PatchBuilderV2', () => { // No existing ref persistence.readRef.mockResolvedValue(null); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); await builder.commit(); @@ -715,12 +716,12 @@ describe('PatchBuilderV2', () => { describe('reads/writes provenance tracking (HG/IO/1)', () => { describe('NodeAdd', () => { it('tracks nodeId as write', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('user:alice'); @@ -729,12 +730,12 @@ describe('PatchBuilderV2', () => { }); it('includes writes in built patch', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('user:alice').addNode('user:bob'); @@ -750,12 +751,12 @@ describe('PatchBuilderV2', () => { const existingDot = createDot('otherWriter', 5); orsetAdd(state.nodeAlive, 'user:alice', existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeNode('user:alice'); @@ -768,12 +769,12 @@ describe('PatchBuilderV2', () => { const existingDot = createDot('otherWriter', 5); orsetAdd(state.nodeAlive, 'user:alice', existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeNode('user:alice'); @@ -785,12 +786,12 @@ describe('PatchBuilderV2', () => { describe('EdgeAdd', () => { it('tracks endpoint nodes as reads and edge key as write', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addEdge('user:alice', 'user:bob', 'follows'); @@ -804,12 +805,12 @@ describe('PatchBuilderV2', () => { }); it('includes reads and writes in built patch', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addEdge('user:alice', 'user:bob', 'follows'); @@ -828,12 +829,12 @@ describe('PatchBuilderV2', () => { const edgeKey = encodeEdgeKey('user:alice', 'user:bob', 'follows'); orsetAdd(state.edgeAlive, edgeKey, existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeEdge('user:alice', 'user:bob', 'follows'); @@ -847,12 +848,12 @@ describe('PatchBuilderV2', () => { const edgeKey = encodeEdgeKey('user:alice', 'user:bob', 'follows'); orsetAdd(state.edgeAlive, edgeKey, existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeEdge('user:alice', 'user:bob', 'follows'); @@ -864,12 +865,12 @@ describe('PatchBuilderV2', () => { describe('PropSet on node', () => { it('tracks nodeId as both read and write', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.setProperty('user:alice', 'name', 'Alice'); @@ -878,12 +879,12 @@ describe('PatchBuilderV2', () => { }); it('includes in both reads and writes arrays', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.setProperty('user:alice', 'name', 'Alice'); @@ -895,12 +896,12 @@ describe('PatchBuilderV2', () => { describe('setEdgeProperty', () => { it('tracks edge key as both read and write', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); // First add the edge, then set property builder.addEdge('user:alice', 'user:bob', 'follows'); @@ -912,12 +913,12 @@ describe('PatchBuilderV2', () => { }); it('includes edge key in built patch reads and writes', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addEdge('user:alice', 'user:bob', 'follows'); builder.setEdgeProperty('user:alice', 'user:bob', 'follows', 'since', '2025-01-01'); @@ -937,12 +938,12 @@ describe('PatchBuilderV2', () => { describe('complex patches', () => { it('deduplicates reads and writes', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); // Add node twice via different operations should only appear once builder.addNode('user:alice'); @@ -957,12 +958,12 @@ describe('PatchBuilderV2', () => { }); it('sorts reads and writes deterministically', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); // Add nodes in non-alphabetical order builder.addNode('user:zebra'); @@ -984,12 +985,12 @@ describe('PatchBuilderV2', () => { const existingEdgeKey = encodeEdgeKey('user:existing', 'user:target', 'knows'); orsetAdd(state.edgeAlive, existingEdgeKey, edgeDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder .addNode('user:new') // writes: new @@ -1019,12 +1020,12 @@ describe('PatchBuilderV2', () => { describe('backward compatibility', () => { it('omits empty reads array from patch', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); // Only writes, no reads @@ -1038,12 +1039,12 @@ describe('PatchBuilderV2', () => { const existingDot = createDot('otherWriter', 5); orsetAdd(state.nodeAlive, 'x', existingDot); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 2, versionVector: createVersionVector(), getCurrentState: () => state, - }); + })); builder.removeNode('x'); // Only reads, no writes @@ -1053,12 +1054,12 @@ describe('PatchBuilderV2', () => { }); it('handles patch with no ops gracefully (builds but cannot commit)', () => { - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); // Build empty patch (no ops) const patch = builder.build(); @@ -1071,20 +1072,21 @@ describe('PatchBuilderV2', () => { describe('commit() includes reads/writes', () => { it('committed patch includes reads/writes arrays', async () => { const persistence = createMockPersistence(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('user:alice').setProperty('user:alice', 'name', 'Alice'); await builder.commit(); // Decode the CBOR blob that was written const blobData = persistence.writeBlob.mock.calls[0][0]; + /** @type {any} */ const patch = decode(blobData); expect(patch.reads).toEqual(['user:alice']); @@ -1093,20 +1095,21 @@ describe('PatchBuilderV2', () => { it('committed patch omits empty reads array', async () => { const persistence = createMockPersistence(); - const builder = new PatchBuilderV2({ + const builder = new PatchBuilderV2(/** @type {any} */ ({ persistence, graphName: 'test-graph', writerId: 'writer1', lamport: 1, versionVector: createVersionVector(), getCurrentState: () => null, - }); + })); builder.addNode('x'); // Only writes, no reads await builder.commit(); const blobData = persistence.writeBlob.mock.calls[0][0]; + /** @type {any} */ const patch = decode(blobData); expect(patch.writes).toEqual(['x']); diff --git a/test/unit/domain/services/ProvenanceIndex.test.js b/test/unit/domain/services/ProvenanceIndex.test.js index bb0a4a4a..0f391fbb 100644 --- a/test/unit/domain/services/ProvenanceIndex.test.js +++ b/test/unit/domain/services/ProvenanceIndex.test.js @@ -213,7 +213,7 @@ describe('ProvenanceIndex', () => { describe('serialize/deserialize', () => { it('roundtrips empty index', () => { const index = new ProvenanceIndex(); - const buffer = index.serialize(); + const buffer = /** @type {Buffer} */ (index.serialize()); const restored = ProvenanceIndex.deserialize(buffer); expect(restored.size).toBe(0); @@ -225,7 +225,7 @@ describe('ProvenanceIndex', () => { index.addPatch('sha2', ['a'], ['c']); index.addPatch('sha3', [], ['a']); - const buffer = index.serialize(); + const buffer = /** @type {Buffer} */ (index.serialize()); const restored = ProvenanceIndex.deserialize(buffer); expect(restored.patchesFor('a')).toEqual(['sha1', 'sha2', 'sha3']); @@ -241,8 +241,8 @@ describe('ProvenanceIndex', () => { const index2 = new ProvenanceIndex(); index2.addPatch('sha1', ['a', 'b'], []); - const buffer1 = index1.serialize(); - const buffer2 = index2.serialize(); + const buffer1 = /** @type {Buffer} */ (index1.serialize()); + const buffer2 = /** @type {Buffer} */ (index2.serialize()); expect(buffer1.equals(buffer2)).toBe(true); }); @@ -285,7 +285,7 @@ describe('ProvenanceIndex', () => { const index = new ProvenanceIndex(); index.addPatch('sha1', ['z', 'a'], []); - const json = index.toJSON(); + const json = /** @type {any} */ (index.toJSON()); expect(json.version).toBe(1); expect(json.entries[0][0]).toBe('a'); @@ -301,7 +301,7 @@ describe('ProvenanceIndex', () => { }); it('throws on null entries in fromJSON', () => { - expect(() => ProvenanceIndex.fromJSON({ version: 1, entries: null })).toThrow('Missing or invalid ProvenanceIndex entries'); + expect(() => ProvenanceIndex.fromJSON(/** @type {any} */ ({ version: 1, entries: null }))).toThrow('Missing or invalid ProvenanceIndex entries'); }); it('handles empty entries array in fromJSON', () => { @@ -343,7 +343,7 @@ describe('ProvenanceIndex', () => { index.addPatch('aaa', ['a'], []); const entries = [...index]; - const aEntry = entries.find(e => e[0] === 'a'); + const aEntry = /** @type {[string, string[]]} */ (entries.find(e => e[0] === 'a')); expect(aEntry[1]).toEqual(['aaa', 'zzz']); }); @@ -361,7 +361,7 @@ describe('ProvenanceIndex', () => { index.addPatch('sha1', ['c', 'a', 'b'], []); const iteratedIds = [...index].map(([id]) => id); - const jsonIds = index.toJSON().entries.map(([id]) => id); + const jsonIds = /** @type {any} */ (index.toJSON()).entries.map((/** @type {any[]} */ [id]) => id); expect(iteratedIds).toEqual(jsonIds); }); diff --git a/test/unit/domain/services/ProvenancePayload.test.js b/test/unit/domain/services/ProvenancePayload.test.js index 16597148..599da5e0 100644 --- a/test/unit/domain/services/ProvenancePayload.test.js +++ b/test/unit/domain/services/ProvenancePayload.test.js @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; import ProvenancePayload from '../../../../src/domain/services/ProvenancePayload.js'; -import { reduceV5, encodeEdgeKey, encodePropKey } from '../../../../src/domain/services/JoinReducer.js'; +import { reduceV5 as _reduceV5, encodeEdgeKey, encodePropKey } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { orsetContains, orsetGetDots } from '../../../../src/domain/crdt/ORSet.js'; import { lwwValue } from '../../../../src/domain/crdt/LWW.js'; import { @@ -33,9 +35,9 @@ describe('ProvenancePayload', () => { }); it('throws TypeError for non-array input', () => { - expect(() => new ProvenancePayload('not-an-array')).toThrow(TypeError); - expect(() => new ProvenancePayload({})).toThrow(TypeError); - expect(() => new ProvenancePayload(42)).toThrow(TypeError); + expect(() => new ProvenancePayload(/** @type {any} */ ('not-an-array'))).toThrow(TypeError); + expect(() => new ProvenancePayload(/** @type {any} */ ({}))).toThrow(TypeError); + expect(() => new ProvenancePayload(/** @type {any} */ (42))).toThrow(TypeError); }); it('is immutable (frozen)', () => { @@ -46,7 +48,7 @@ describe('ProvenancePayload', () => { // Attempting to add properties should fail in strict mode expect(() => { - payload.newProp = 'value'; + /** @type {any} */ (payload).newProp = 'value'; }).toThrow(); }); }); @@ -80,9 +82,9 @@ describe('ProvenancePayload', () => { describe('concat', () => { it('throws TypeError for non-ProvenancePayload argument', () => { const payload = new ProvenancePayload(); - expect(() => payload.concat([])).toThrow(TypeError); - expect(() => payload.concat({})).toThrow(TypeError); - expect(() => payload.concat(null)).toThrow(TypeError); + expect(() => payload.concat(/** @type {any} */ ([]))).toThrow(TypeError); + expect(() => payload.concat(/** @type {any} */ ({}))).toThrow(TypeError); + expect(() => payload.concat(/** @type {any} */ (null))).toThrow(TypeError); }); it('concatenates two payloads', () => { @@ -608,7 +610,7 @@ describe('ProvenancePayload', () => { expect(leftAssoc.length).toBe(patches.length); for (let i = 0; i < patches.length; i++) { - expect(leftAssoc.at(i).sha).toBe(rightAssoc.at(i).sha); + expect(/** @type {any} */ (leftAssoc.at(i)).sha).toBe(/** @type {any} */ (rightAssoc.at(i)).sha); } } }); @@ -755,7 +757,7 @@ describe('ProvenancePayload', () => { patch: createPatchV2({ writer: 'W', lamport: 2, - ops: [createNodeRemoveV2(new Set(['W:1']))], + ops: [createNodeRemoveV2(/** @type {any} */ (new Set(['W:1'])))], }), sha: 'abcd0002', }, diff --git a/test/unit/domain/services/SchemaCompat.test.js b/test/unit/domain/services/SchemaCompat.test.js index 757c9240..3375da2a 100644 --- a/test/unit/domain/services/SchemaCompat.test.js +++ b/test/unit/domain/services/SchemaCompat.test.js @@ -12,18 +12,22 @@ import { EDGE_PROP_PREFIX } from '../../../../src/domain/services/JoinReducer.js // Helpers — minimal op factories // --------------------------------------------------------------------------- +/** @param {string} nodeId */ function nodeAddOp(nodeId) { return { type: 'NodeAdd', node: nodeId, dot: { writer: 'w1', counter: 1 } }; } +/** @param {string} from @param {string} to @param {string} label */ function edgeAddOp(from, to, label) { return { type: 'EdgeAdd', from, to, label, dot: { writer: 'w1', counter: 1 } }; } +/** @param {string} nodeId @param {string} key @param {any} value */ function nodePropSetOp(nodeId, key, value) { return { type: 'PropSet', node: nodeId, key, value }; } +/** @param {string} from @param {string} to @param {string} label @param {string} key @param {any} value */ function edgePropSetOp(from, to, label, key, value) { // Edge prop ops use the \x01 prefix namespace in the node field return { type: 'PropSet', node: `${EDGE_PROP_PREFIX}${from}\0${to}\0${label}`, key, value }; @@ -65,8 +69,8 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { }); it('accepts non-array ops (defensive)', () => { - expect(() => assertOpsCompatible(null, SCHEMA_V2)).not.toThrow(); - expect(() => assertOpsCompatible(undefined, SCHEMA_V2)).not.toThrow(); + expect(() => assertOpsCompatible(/** @type {any} */ (null), SCHEMA_V2)).not.toThrow(); + expect(() => assertOpsCompatible(/** @type {any} */ (undefined), SCHEMA_V2)).not.toThrow(); }); it('throws E_SCHEMA_UNSUPPORTED for edge property ops', () => { @@ -84,7 +88,7 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { try { assertOpsCompatible(ops, SCHEMA_V2); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.code).toBe('E_SCHEMA_UNSUPPORTED'); } }); @@ -95,7 +99,7 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { try { assertOpsCompatible(ops, SCHEMA_V2); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('>=7.3.0'); expect(err.message).toContain('WEIGHTED'); expect(err.message).toContain('edge properties'); @@ -108,7 +112,7 @@ describe('Schema Compatibility (WT/SCHEMA/2)', () => { try { assertOpsCompatible(ops, SCHEMA_V2); expect.unreachable('should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.context.requiredSchema).toBe(SCHEMA_V3); expect(err.context.maxSupportedSchema).toBe(SCHEMA_V2); } diff --git a/test/unit/domain/services/StateDiff.test.js b/test/unit/domain/services/StateDiff.test.js index ba39b94a..d15aef94 100644 --- a/test/unit/domain/services/StateDiff.test.js +++ b/test/unit/domain/services/StateDiff.test.js @@ -14,6 +14,7 @@ import { createEventId } from '../../../../src/domain/utils/EventId.js'; import { lwwSet } from '../../../../src/domain/crdt/LWW.js'; // Helper to create a node add operation +/** @param {string} nodeId @param {string} writerId @param {number} counter */ function createNodeAddOp(nodeId, writerId, counter) { return { type: 'NodeAdd', @@ -23,6 +24,7 @@ function createNodeAddOp(nodeId, writerId, counter) { } // Helper to create an edge add operation +/** @param {string} from @param {string} to @param {string} label @param {string} writerId @param {number} counter */ function createEdgeAddOp(from, to, label, writerId, counter) { return { type: 'EdgeAdd', @@ -34,6 +36,7 @@ function createEdgeAddOp(from, to, label, writerId, counter) { } // Helper to apply operations to state with auto-incrementing lamport +/** @param {any} state @param {any[]} ops @param {string} writerId */ function applyOps(state, ops, writerId) { let lamport = 1; for (const op of ops) { @@ -43,6 +46,7 @@ function applyOps(state, ops, writerId) { } // Helper to create an EventId for property tests +/** @param {number} lamport @param {string} [writerId] */ function makeEventId(lamport, writerId = 'w1') { return createEventId(lamport, writerId, 'abcd1234', 0); } diff --git a/test/unit/domain/services/StateSerializerV5.test.js b/test/unit/domain/services/StateSerializerV5.test.js index 236b3421..8d4db607 100644 --- a/test/unit/domain/services/StateSerializerV5.test.js +++ b/test/unit/domain/services/StateSerializerV5.test.js @@ -40,7 +40,7 @@ function mockDot(writerId = 'test', seq = 1) { * Helper to build a V5 state with specific nodes, edges, and props. * Uses ORSet for nodes and edges (V5 style). */ -function buildStateV5({ nodes = [], edges = [], props = [] }) { +function buildStateV5({ nodes = /** @type {any[]} */ ([]), edges = /** @type {any[]} */ ([]), props = /** @type {any[]} */ ([]) }) { const state = createEmptyStateV5(); let dotSeq = 1; @@ -51,7 +51,7 @@ function buildStateV5({ nodes = [], edges = [], props = [] }) { orsetAdd(state.nodeAlive, nodeId, nodeDot); if (!alive) { // Remove by adding observed dots to tombstones - orsetRemove(state.nodeAlive, state.nodeAlive.entries.get(nodeId)); + orsetRemove(state.nodeAlive, /** @type {any} */ (state.nodeAlive.entries.get(nodeId))); } } @@ -62,7 +62,7 @@ function buildStateV5({ nodes = [], edges = [], props = [] }) { const edgeDot = dot ?? mockDot('test', dotSeq++); orsetAdd(state.edgeAlive, key, edgeDot); if (!alive) { - orsetRemove(state.edgeAlive, state.edgeAlive.entries.get(key)); + orsetRemove(state.edgeAlive, /** @type {any} */ (state.edgeAlive.entries.get(key))); } } @@ -235,7 +235,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.nodes).toEqual(['a', 'c']); }); @@ -246,7 +246,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.nodes).toEqual(['apple', 'mango', 'zebra']); }); @@ -263,7 +263,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.edges).toEqual([ { from: 'a', to: 'b', label: 'a' }, @@ -285,7 +285,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.props).toEqual([ { node: 'a', key: 'age', value: createInlineValue(25) }, @@ -313,7 +313,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.nodes).toEqual(['a', 'c']); expect(result.edges).toEqual([{ from: 'a', to: 'c', label: 'knows' }]); @@ -329,7 +329,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.edges).toEqual([]); }); @@ -338,7 +338,7 @@ describe('StateSerializerV5', () => { const state = createEmptyStateV5(); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result).toEqual({ nodes: [], edges: [], props: [] }); }); @@ -404,7 +404,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.nodes).toEqual(['a', 'b']); expect(result.edges).toEqual([{ from: 'a', to: 'b', label: 'knows' }]); @@ -421,7 +421,7 @@ describe('StateSerializerV5', () => { }); const bytes = serializeStateV5(state); - const result = deserializeStateV5(bytes); + const result = deserializeStateV5(/** @type {Buffer} */ (bytes)); expect(result.props[0].value).toEqual(complexValue); }); diff --git a/test/unit/domain/services/StreamingBitmapIndexBuilder.test.js b/test/unit/domain/services/StreamingBitmapIndexBuilder.test.js index f770291f..82dbbc56 100644 --- a/test/unit/domain/services/StreamingBitmapIndexBuilder.test.js +++ b/test/unit/domain/services/StreamingBitmapIndexBuilder.test.js @@ -6,6 +6,7 @@ import StreamingBitmapIndexBuilder, { SHARD_VERSION } from '../../../../src/doma * Helper to create a valid shard envelope with checksum. * Uses SHA-256 to match production validation in StreamingBitmapIndexBuilder. */ +/** @param {any} data @returns {any} */ function createMockEnvelope(data) { const checksum = createHash('sha256') .update(JSON.stringify(data)) @@ -18,7 +19,11 @@ function createMockEnvelope(data) { } describe('StreamingBitmapIndexBuilder', () => { + /** @type {any} */ + /** @type {any} */ let mockStorage; + /** @type {any} */ + /** @type {any} */ let writtenBlobs; beforeEach(() => { @@ -33,7 +38,7 @@ describe('StreamingBitmapIndexBuilder', () => { }), writeTree: vi.fn().mockResolvedValue('tree-oid'), readBlob: vi.fn().mockImplementation(async (oid) => { - const blob = writtenBlobs.find(b => b.oid === oid); + const blob = writtenBlobs.find((/** @type {any} */ b) => b.oid === oid); return Buffer.from(blob ? blob.content : '{}'); }), }; @@ -41,40 +46,40 @@ describe('StreamingBitmapIndexBuilder', () => { describe('constructor', () => { it('requires storage adapter', () => { - expect(() => new StreamingBitmapIndexBuilder({})).toThrow('requires a storage adapter'); + expect(() => new StreamingBitmapIndexBuilder(/** @type {any} */ ({}))).toThrow('requires a storage adapter'); }); it('throws when maxMemoryBytes is zero', () => { - expect(() => new StreamingBitmapIndexBuilder({ + expect(() => new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 0, - })).toThrow('maxMemoryBytes must be a positive number'); + }))).toThrow('maxMemoryBytes must be a positive number'); }); it('throws when maxMemoryBytes is negative', () => { - expect(() => new StreamingBitmapIndexBuilder({ + expect(() => new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: -100, - })).toThrow('maxMemoryBytes must be a positive number'); + }))).toThrow('maxMemoryBytes must be a positive number'); }); it('accepts custom maxMemoryBytes', () => { - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 1024, - }); + })); expect(builder.maxMemoryBytes).toBe(1024); }); it('uses default maxMemoryBytes of 50MB', () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); expect(builder.maxMemoryBytes).toBe(50 * 1024 * 1024); }); }); describe('registerNode', () => { it('assigns sequential IDs to nodes', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); const id1 = await builder.registerNode('abc123'); const id2 = await builder.registerNode('def456'); @@ -88,7 +93,7 @@ describe('StreamingBitmapIndexBuilder', () => { describe('addEdge', () => { it('registers both nodes and creates bitmaps', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); await builder.addEdge('parent1', 'child1'); @@ -99,7 +104,7 @@ describe('StreamingBitmapIndexBuilder', () => { describe('flush', () => { it('writes bitmap shards to storage', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); await builder.addEdge('aa1111', 'bb2222'); await builder.flush(); @@ -111,10 +116,10 @@ describe('StreamingBitmapIndexBuilder', () => { it('invokes onFlush callback', async () => { const onFlush = vi.fn(); - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, onFlush, - }); + })); await builder.addEdge('aa1111', 'bb2222'); await builder.flush(); @@ -127,7 +132,7 @@ describe('StreamingBitmapIndexBuilder', () => { }); it('does nothing when bitmaps are empty', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); await builder.flush(); @@ -135,7 +140,7 @@ describe('StreamingBitmapIndexBuilder', () => { }); it('preserves SHA→ID mappings after flush', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); await builder.addEdge('aa1111', 'bb2222'); const idBefore = builder.shaToId.get('aa1111'); @@ -148,7 +153,7 @@ describe('StreamingBitmapIndexBuilder', () => { describe('finalize', () => { it('creates tree with all shards', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); await builder.addEdge('aa1111', 'bb2222'); const treeOid = await builder.finalize(); @@ -157,18 +162,18 @@ describe('StreamingBitmapIndexBuilder', () => { expect(mockStorage.writeTree).toHaveBeenCalled(); const treeEntries = mockStorage.writeTree.mock.calls[0][0]; - 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); + expect(treeEntries.some((/** @type {any} */ e) => e.includes('meta_'))).toBe(true); + expect(treeEntries.some((/** @type {any} */ e) => e.includes('shards_fwd_'))).toBe(true); + expect(treeEntries.some((/** @type {any} */ e) => e.includes('shards_rev_'))).toBe(true); }); }); describe('getMemoryStats', () => { it('returns current memory statistics', async () => { - const builder = new StreamingBitmapIndexBuilder({ storage: mockStorage }); + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage })); await builder.addEdge('aa1111', 'bb2222'); - const stats = builder.getMemoryStats(); + const stats = /** @type {any} */ (builder.getMemoryStats()); expect(stats.nodeCount).toBe(2); expect(stats.bitmapCount).toBe(2); @@ -180,11 +185,11 @@ describe('StreamingBitmapIndexBuilder', () => { describe('automatic flush on memory threshold', () => { it('flushes when memory exceeds threshold', async () => { const onFlush = vi.fn(); - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 200, // Very low threshold to trigger flush onFlush, - }); + })); // Add enough edges to exceed threshold for (let i = 0; i < 10; i++) { @@ -198,10 +203,10 @@ describe('StreamingBitmapIndexBuilder', () => { describe('chunk merging', () => { it('merges multiple chunks for same shard', async () => { - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 100, // Force multiple flushes - }); + })); // First batch of edges await builder.addEdge('aa1111', 'aa2222'); @@ -221,10 +226,10 @@ describe('StreamingBitmapIndexBuilder', () => { it('correctly merges bitmap data from multiple chunks', async () => { // Create builder with very low threshold - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 1, // Force immediate flush after each edge - }); + })); // Add edges that will be in same shard (same prefix) await builder.addEdge('aa0001', 'bb0001'); @@ -234,7 +239,7 @@ describe('StreamingBitmapIndexBuilder', () => { expect(treeOid).toBe('tree-oid'); // Verify all nodes are in the meta shards - const metaBlobs = writtenBlobs.filter(b => b.oid.includes('blob-')); + const metaBlobs = writtenBlobs.filter((/** @type {any} */ b) => b.oid.includes('blob-')); expect(metaBlobs.length).toBeGreaterThan(0); }); }); @@ -265,13 +270,13 @@ describe('StreamingBitmapIndexBuilder memory guard', () => { }), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: memoryThreshold, - onFlush: ({ flushedBytes }) => { + onFlush: (/** @type {any} */ { flushedBytes }) => { memoryReadings.push({ event: 'flush', flushedBytes }); }, - }); + })); // Simulate large input (500 nodes with edges) for (let i = 0; i < 500; i++) { @@ -288,7 +293,7 @@ describe('StreamingBitmapIndexBuilder memory guard', () => { // Track memory after each node const stats = builder.getMemoryStats(); - maxMemorySeen = Math.max(maxMemorySeen, stats.estimatedBitmapBytes); + maxMemorySeen = Math.max(maxMemorySeen, /** @type {any} */ (stats).estimatedBitmapBytes); } await builder.finalize(); @@ -321,10 +326,10 @@ describe('StreamingBitmapIndexBuilder memory guard', () => { }), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 500, // Force frequent flushes - }); + })); // Build a small graph const nodes = ['aa0001', 'aa0002', 'aa0003', 'bb0001', 'bb0002']; @@ -346,7 +351,7 @@ describe('StreamingBitmapIndexBuilder memory guard', () => { // Verify all nodes are in meta shards const treeEntries = mockStorage.writeTree.mock.calls[0][0]; - const metaEntries = treeEntries.filter(e => e.includes('meta_')); + const metaEntries = treeEntries.filter((/** @type {any} */ e) => e.includes('meta_')); expect(metaEntries.length).toBeGreaterThan(0); // Verify all nodes got IDs @@ -374,11 +379,11 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { }), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 512, // Extremely small - forces flush on almost every edge onFlush: () => { flushCount++; }, - }); + })); // Create 1000 nodes with edges forming a chain const nodeCount = 1000; @@ -409,12 +414,12 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { const treeEntries = mockStorage.writeTree.mock.calls[0][0]; // Should have meta shards for SHA→ID mappings - const metaEntries = treeEntries.filter(e => e.includes('meta_')); + const metaEntries = treeEntries.filter((/** @type {any} */ e) => e.includes('meta_')); expect(metaEntries.length).toBeGreaterThan(0); // Should have both fwd and rev bitmap shards - const fwdEntries = treeEntries.filter(e => e.includes('shards_fwd_')); - const revEntries = treeEntries.filter(e => e.includes('shards_rev_')); + const fwdEntries = treeEntries.filter((/** @type {any} */ e) => e.includes('shards_fwd_')); + const revEntries = treeEntries.filter((/** @type {any} */ e) => e.includes('shards_rev_')); expect(fwdEntries.length).toBeGreaterThan(0); expect(revEntries.length).toBeGreaterThan(0); }); @@ -436,10 +441,10 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { readBlob: vi.fn().mockResolvedValue(Buffer.from('{}')), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 50000, // Large enough to not auto-flush during addEdge - }); + })); // Add edges across multiple prefixes to ensure multiple writeBlob calls per flush await builder.addEdge('aa0001', 'bb0001'); @@ -453,7 +458,7 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { writeCallCount = 2; // Reset to trigger on next call try { await builder.flush(); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('Storage write failed'); } }); @@ -472,10 +477,10 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { readBlob: vi.fn().mockResolvedValue(Buffer.from('{}')), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 50000, // Large enough to not auto-flush - }); + })); // Add some edges await builder.addEdge('aa0001', 'bb0001'); @@ -519,10 +524,10 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { }), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 1, // Force flush after every edge - }); + })); // Create 10 edges from aa0001 to different targets, each in separate flush const sourceNode = 'aa0001'; @@ -541,7 +546,7 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { // The final tree should reference a merged shard expect(mockStorage.writeTree).toHaveBeenCalled(); const treeEntries = mockStorage.writeTree.mock.calls[0][0]; - const fwdAaShard = treeEntries.find(e => e.includes('shards_fwd_aa')); + const fwdAaShard = treeEntries.find((/** @type {any} */ e) => e.includes('shards_fwd_aa')); expect(fwdAaShard).toBeDefined(); // Find the merged content for aa prefix (now wrapped in envelope) @@ -595,10 +600,10 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { }), }; - const builder = new StreamingBitmapIndexBuilder({ + const builder = new StreamingBitmapIndexBuilder(/** @type {any} */ ({ storage: mockStorage, maxMemoryBytes: 1, // Force flush after every edge - }); + })); // Create a more complex scenario: // Multiple sources with 'aa' prefix, each with multiple targets @@ -618,7 +623,7 @@ describe('StreamingBitmapIndexBuilder extreme stress tests', () => { // Extract the merged 'aa' shard (now wrapped in envelope) const treeEntries = mockStorage.writeTree.mock.calls[0][0]; - const fwdAaShard = treeEntries.find(e => e.includes('shards_fwd_aa')); + const fwdAaShard = treeEntries.find((/** @type {any} */ e) => e.includes('shards_fwd_aa')); expect(fwdAaShard).toBeDefined(); const oidMatch = fwdAaShard.match(/blob ([^\s]+)/); diff --git a/test/unit/domain/services/SyncProtocol.test.js b/test/unit/domain/services/SyncProtocol.test.js index ad5fde2c..52de7315 100644 --- a/test/unit/domain/services/SyncProtocol.test.js +++ b/test/unit/domain/services/SyncProtocol.test.js @@ -10,9 +10,11 @@ import { } from '../../../../src/domain/services/SyncProtocol.js'; import { createEmptyStateV5, - reduceV5, + reduceV5 as _reduceV5, encodeEdgeKey, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { createFrontier, updateFrontier } from '../../../../src/domain/services/Frontier.js'; import { createDot } from '../../../../src/domain/crdt/Dot.js'; import { orsetContains } from '../../../../src/domain/crdt/ORSet.js'; @@ -43,7 +45,8 @@ const OID_F = '6'.repeat(40); /** * Creates a test patch with the given operations. */ -function createTestPatch({ writer, lamport, ops, context }) { +/** @returns {any} */ +function createTestPatch({ writer = /** @type {any} */ (undefined), lamport = /** @type {any} */ (undefined), ops = /** @type {any} */ (undefined), context = /** @type {any} */ (undefined) }) { return { schema: 2, writer, @@ -56,6 +59,7 @@ function createTestPatch({ writer, lamport, ops, context }) { /** * Creates a NodeAdd operation. */ +/** @param {any} node @param {any} dot */ function createNodeAddOp(node, dot) { return { type: 'NodeAdd', node, dot }; } @@ -63,7 +67,8 @@ function createNodeAddOp(node, dot) { /** * Creates a mock persistence layer for testing. */ -function createMockPersistence(commits = {}, blobs = {}) { +/** @returns {any} */ +function createMockPersistence(commits = /** @type {any} */ ({}), blobs = /** @type {any} */ ({})) { return { showNode: vi.fn(async sha => { if (commits[sha]?.message) { @@ -97,6 +102,7 @@ function createMockPersistence(commits = {}, blobs = {}) { /** * Creates a commit message and blob for a test patch. */ +/** @param {any} commits @param {any} blobs @param {any} sha @param {any} patch @param {any} patchOid @param {any[]} parents */ function setupCommit(commits, blobs, sha, patch, patchOid, parents = []) { const message = encodePatchMessage({ graph: 'events', @@ -213,9 +219,9 @@ describe('SyncProtocol', () => { expect(patches).toHaveLength(2); expect(patches[0].sha).toBe(SHA_B); - expect(patches[0].patch.lamport).toBe(2); + expect(/** @type {any} */ (patches[0].patch).lamport).toBe(2); expect(patches[1].sha).toBe(SHA_C); - expect(patches[1].patch.lamport).toBe(3); + expect(/** @type {any} */ (patches[1].patch).lamport).toBe(3); }); it('returns all patches when fromSha is null', async () => { @@ -326,7 +332,7 @@ describe('SyncProtocol', () => { const request = { type: 'sync-request', frontier: { w1: SHA_A } }; const localFrontier = new Map([['w1', SHA_B]]); - const response = await processSyncRequest(request, localFrontier, persistence, 'events'); + const response = await processSyncRequest(/** @type {any} */ (request), localFrontier, persistence, 'events'); expect(response.type).toBe('sync-response'); expect(response.patches).toHaveLength(1); @@ -354,7 +360,7 @@ describe('SyncProtocol', () => { ['w2', SHA_B], ]); - const response = await processSyncRequest(request, localFrontier, persistence, 'events'); + const response = await processSyncRequest(/** @type {any} */ (request), localFrontier, persistence, 'events'); // Response should include complete local frontier expect(response.frontier).toEqual({ @@ -372,7 +378,7 @@ describe('SyncProtocol', () => { const request = { type: 'sync-request', frontier: { w1: SHA_A } }; const localFrontier = new Map([['w1', SHA_A]]); - const response = await processSyncRequest(request, localFrontier, persistence, 'events'); + const response = await processSyncRequest(/** @type {any} */ (request), localFrontier, persistence, 'events'); expect(response.patches).toHaveLength(0); }); @@ -395,7 +401,7 @@ describe('SyncProtocol', () => { patches: [{ writerId: 'w1', sha: SHA_A, patch: patch1 }], }; - const result = applySyncResponse(response, state, frontier); + const result = /** @type {any} */ (applySyncResponse(/** @type {any} */ (response), state, frontier)); expect(result.applied).toBe(1); expect(orsetContains(result.state.nodeAlive, 'x')).toBe(true); @@ -427,7 +433,7 @@ describe('SyncProtocol', () => { ], }; - const result = applySyncResponse(response, state, frontier); + const result = /** @type {any} */ (applySyncResponse(/** @type {any} */ (response), state, frontier)); expect(result.applied).toBe(2); expect(orsetContains(result.state.nodeAlive, 'x')).toBe(true); @@ -451,7 +457,7 @@ describe('SyncProtocol', () => { patches: [{ writerId: 'w1', sha: SHA_A, patch }], }; - applySyncResponse(response, state, frontier); + applySyncResponse(/** @type {any} */ (response), state, frontier); // Original state should be unchanged expect(orsetContains(state.nodeAlive, 'x')).toBe(false); @@ -483,7 +489,7 @@ describe('SyncProtocol', () => { ], }; - const result = applySyncResponse(response, state, frontier); + const result = /** @type {any} */ (applySyncResponse(/** @type {any} */ (response), state, frontier)); expect(result.applied).toBe(2); expect(orsetContains(result.state.nodeAlive, 'x')).toBe(true); @@ -502,7 +508,7 @@ describe('SyncProtocol', () => { patches: [], }; - const result = applySyncResponse(response, state, frontier); + const result = /** @type {any} */ (applySyncResponse(/** @type {any} */ (response), state, frontier)); expect(result.applied).toBe(0); }); @@ -606,7 +612,7 @@ describe('SyncProtocol', () => { const responseA = await processSyncRequest(requestB, frontierA, persistence, 'events'); // B applies response from A - const resultB = applySyncResponse(responseA, stateB, frontierB); + const resultB = /** @type {any} */ (applySyncResponse(responseA, stateB, frontierB)); stateB = resultB.state; frontierB = resultB.frontier; @@ -615,7 +621,7 @@ describe('SyncProtocol', () => { const responseB = await processSyncRequest(requestA, frontierB, persistence, 'events'); // A applies response from B - const resultA = applySyncResponse(responseB, stateA, frontierA); + const resultA = /** @type {any} */ (applySyncResponse(responseB, stateA, frontierA)); stateA = resultA.state; frontierA = resultA.frontier; @@ -661,7 +667,7 @@ describe('SyncProtocol', () => { persistence, 'events' ); - const result1 = applySyncResponse(response1, state, frontier); + const result1 = /** @type {any} */ (applySyncResponse(response1, state, frontier)); // Second sync with same data const response2 = { @@ -669,7 +675,7 @@ describe('SyncProtocol', () => { frontier: { w1: SHA_A }, patches: [{ writerId: 'w1', sha: SHA_A, patch }], }; - const result2 = applySyncResponse(response2, result1.state, result1.frontier); + const result2 = /** @type {any} */ (applySyncResponse(/** @type {any} */ (response2), result1.state, result1.frontier)); // State should be the same (idempotent) // Note: Due to OR-Set semantics, applying the same add twice adds a new dot diff --git a/test/unit/domain/services/TemporalQuery.test.js b/test/unit/domain/services/TemporalQuery.test.js index 516a069a..4deda590 100644 --- a/test/unit/domain/services/TemporalQuery.test.js +++ b/test/unit/domain/services/TemporalQuery.test.js @@ -15,9 +15,9 @@ import { * @returns {TemporalQuery} */ function createTemporalWithPatches(patches) { - return new TemporalQuery({ + return new TemporalQuery(/** @type {any} */ ({ loadAllPatches: async () => patches, - }); + })); } /** @@ -56,7 +56,7 @@ function createNodeWithPropPatch({ /** * Creates a property-only patch (no NodeAdd). */ -function createPropOnlyPatch({ nodeId, writer, lamport, propKey, propValue, sha }) { +function createPropOnlyPatch({ nodeId = /** @type {any} */ (undefined), writer = /** @type {any} */ (undefined), lamport = /** @type {any} */ (undefined), propKey = /** @type {any} */ (undefined), propValue = /** @type {any} */ (undefined), sha = /** @type {any} */ (undefined) }) { return { patch: createPatchV2({ writer, @@ -93,7 +93,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -124,7 +124,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -148,7 +148,7 @@ describe('TemporalQuery', () => { // Query for a node that was never created const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -193,7 +193,7 @@ describe('TemporalQuery', () => { // where it appears, and the predicate holds at those ticks. const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -233,7 +233,7 @@ describe('TemporalQuery', () => { // Since 0: draft at tick 1 fails the predicate const resultAll = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); expect(resultAll).toBe(false); @@ -241,7 +241,7 @@ describe('TemporalQuery', () => { // Since 2: only ticks 2 and 3 are checked, both active const resultSince2 = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 2 } ); expect(resultSince2).toBe(true); @@ -263,7 +263,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -275,7 +275,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -297,7 +297,7 @@ describe('TemporalQuery', () => { const tq = createTemporalWithPatches(patches); // Call without options - const result = await tq.always('X', (n) => n.props.status === 'active'); + const result = await tq.always('X', (/** @type {any} */ n) => n.props.status === 'active'); expect(result).toBe(true); }); @@ -315,9 +315,10 @@ describe('TemporalQuery', () => { ]; const tq = createTemporalWithPatches(patches); + /** @type {any[]} */ const snapshots = []; - await tq.always('X', (n) => { + await tq.always('X', (/** @type {any} */ n) => { snapshots.push({ id: n.id, exists: n.exists, @@ -366,7 +367,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(true); @@ -396,7 +397,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(false); @@ -418,7 +419,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'active' + (/** @type {any} */ n) => n.props.status === 'active' ); expect(result).toBe(false); @@ -429,7 +430,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(false); @@ -467,7 +468,7 @@ describe('TemporalQuery', () => { const tq = createTemporalWithPatches(patches); - const result = await tq.eventually('X', (n) => { + const result = await tq.eventually('X', (/** @type {any} */ n) => { callCount++; return n.props.status === 'target'; }); @@ -502,7 +503,7 @@ describe('TemporalQuery', () => { // Since 2: only tick 2 is checked, which has 'other' not 'target' const result = await tq.eventually( 'X', - (n) => n.props.status === 'target', + (/** @type {any} */ n) => n.props.status === 'target', { since: 2 } ); @@ -525,7 +526,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(true); @@ -547,7 +548,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(false); @@ -569,7 +570,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(true); @@ -609,7 +610,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -640,7 +641,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -679,7 +680,7 @@ describe('TemporalQuery', () => { const result = await tq.eventually( 'X', - (n) => n.props.status === 'merged' + (/** @type {any} */ n) => n.props.status === 'merged' ); expect(result).toBe(true); @@ -719,7 +720,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); @@ -751,7 +752,7 @@ describe('TemporalQuery', () => { const result = await tq.always( 'X', - (n) => n.props.status === 'active', + (/** @type {any} */ n) => n.props.status === 'active', { since: 0 } ); diff --git a/test/unit/domain/services/TranslationCost.test.js b/test/unit/domain/services/TranslationCost.test.js index 4d9de492..6a24288b 100644 --- a/test/unit/domain/services/TranslationCost.test.js +++ b/test/unit/domain/services/TranslationCost.test.js @@ -5,6 +5,7 @@ import { orsetAdd } from '../../../../src/domain/crdt/ORSet.js'; import { createDot } from '../../../../src/domain/crdt/Dot.js'; import { computeTranslationCost } from '../../../../src/domain/services/TranslationCost.js'; +/** @param {any} graph @param {(state: any) => void} seedFn */ function setupGraphState(graph, seedFn) { const state = createEmptyStateV5(); graph._cachedState = state; @@ -13,31 +14,38 @@ function setupGraphState(graph, seedFn) { return state; } +/** @param {any} state @param {any} nodeId @param {any} counter */ function addNode(state, nodeId, counter) { orsetAdd(state.nodeAlive, nodeId, createDot('w1', counter)); } +/** @param {any} state @param {any} from @param {any} to @param {any} label @param {any} counter */ function addEdge(state, from, to, label, counter) { const edgeKey = encodeEdgeKey(from, to, label); orsetAdd(state.edgeAlive, edgeKey, createDot('w1', counter)); } +/** @param {any} state @param {any} nodeId @param {any} key @param {any} value */ function addProp(state, nodeId, key, value) { const propKey = encodePropKey(nodeId, key); state.prop.set(propKey, { value, lamport: 1, writerId: 'w1' }); } describe('TranslationCost', () => { + /** @type {any} */ + /** @type {any} */ let mockPersistence; + /** @type {any} */ + /** @type {any} */ let graph; beforeEach(async () => { mockPersistence = { readRef: vi.fn().mockResolvedValue(null), listRefs: vi.fn().mockResolvedValue([]), - updateRef: vi.fn().mockResolvedValue(), + updateRef: vi.fn().mockResolvedValue(undefined), configGet: vi.fn().mockResolvedValue(null), - configSet: vi.fn().mockResolvedValue(), + configSet: vi.fn().mockResolvedValue(undefined), }; graph = await WarpGraph.open({ diff --git a/test/unit/domain/services/TraversalService.test.js b/test/unit/domain/services/TraversalService.test.js new file mode 100644 index 00000000..e4a9eba2 --- /dev/null +++ b/test/unit/domain/services/TraversalService.test.js @@ -0,0 +1,9 @@ +import { describe, it, expect } from 'vitest'; +import TraversalService from '../../../../src/domain/services/TraversalService.js'; +import CommitDagTraversalService from '../../../../src/domain/services/CommitDagTraversalService.js'; + +describe('TraversalService (deprecation alias)', () => { + it('default export is CommitDagTraversalService', () => { + expect(TraversalService).toBe(CommitDagTraversalService); + }); +}); diff --git a/test/unit/domain/services/WarpMessageCodec.test.js b/test/unit/domain/services/WarpMessageCodec.test.js index 9ebaabdd..17e52e80 100644 --- a/test/unit/domain/services/WarpMessageCodec.test.js +++ b/test/unit/domain/services/WarpMessageCodec.test.js @@ -562,10 +562,10 @@ eg-schema: 1`; }); it('returns null for non-string input', () => { - expect(detectMessageKind(null)).toBeNull(); - expect(detectMessageKind(undefined)).toBeNull(); - expect(detectMessageKind(123)).toBeNull(); - expect(detectMessageKind({})).toBeNull(); + expect(detectMessageKind(/** @type {any} */ (null))).toBeNull(); + expect(detectMessageKind(/** @type {any} */ (undefined))).toBeNull(); + expect(detectMessageKind(/** @type {any} */ (123))).toBeNull(); + expect(detectMessageKind(/** @type {any} */ ({}))).toBeNull(); }); it('returns null for empty string', () => { diff --git a/test/unit/domain/services/WarpMessageCodec.v3.test.js b/test/unit/domain/services/WarpMessageCodec.v3.test.js index 82036ff4..be28ec97 100644 --- a/test/unit/domain/services/WarpMessageCodec.v3.test.js +++ b/test/unit/domain/services/WarpMessageCodec.v3.test.js @@ -64,9 +64,9 @@ describe('WarpMessageCodec schema v3', () => { }); it('returns schema 2 for non-array input', () => { - expect(detectSchemaVersion(null)).toBe(2); - expect(detectSchemaVersion(undefined)).toBe(2); - expect(detectSchemaVersion('not-an-array')).toBe(2); + expect(detectSchemaVersion(/** @type {any} */ (null))).toBe(2); + expect(detectSchemaVersion(/** @type {any} */ (undefined))).toBe(2); + expect(detectSchemaVersion(/** @type {any} */ ('not-an-array'))).toBe(2); }); it('returns schema 2 when no PropSet ops exist', () => { diff --git a/test/unit/domain/services/WarpStateIndexBuilder.test.js b/test/unit/domain/services/WarpStateIndexBuilder.test.js index b9da6cc5..0b5e7262 100644 --- a/test/unit/domain/services/WarpStateIndexBuilder.test.js +++ b/test/unit/domain/services/WarpStateIndexBuilder.test.js @@ -15,8 +15,8 @@ describe('WarpStateIndexBuilder', () => { describe('buildFromState()', () => { it('throws on invalid state', () => { const builder = new WarpStateIndexBuilder(); - expect(() => builder.buildFromState(null)).toThrow('Invalid state'); - expect(() => builder.buildFromState({})).toThrow('Invalid state'); + expect(() => builder.buildFromState(/** @type {any} */ (null))).toThrow('Invalid state'); + expect(() => builder.buildFromState(/** @type {any} */ ({}))).toThrow('Invalid state'); }); it('returns empty index for empty state', () => { diff --git a/test/unit/domain/services/WormholeService.test.js b/test/unit/domain/services/WormholeService.test.js index d8ec59ed..7ac0aa70 100644 --- a/test/unit/domain/services/WormholeService.test.js +++ b/test/unit/domain/services/WormholeService.test.js @@ -9,10 +9,12 @@ import { import ProvenancePayload from '../../../../src/domain/services/ProvenancePayload.js'; import WormholeError from '../../../../src/domain/errors/WormholeError.js'; import { - reduceV5, + reduceV5 as _reduceV5, encodeEdgeKey, encodePropKey, } from '../../../../src/domain/services/JoinReducer.js'; +/** @type {(...args: any[]) => any} */ +const reduceV5 = _reduceV5; import { orsetContains } from '../../../../src/domain/crdt/ORSet.js'; import { lwwValue } from '../../../../src/domain/crdt/LWW.js'; import { @@ -212,7 +214,7 @@ describe('WormholeService', () => { await expect(createWormhole({ persistence, graphName: 'test-graph', - fromSha: null, + fromSha: /** @type {any} */ (null), toSha: 'something', })).rejects.toThrow(WormholeError); @@ -220,7 +222,7 @@ describe('WormholeService', () => { persistence, graphName: 'test-graph', fromSha: 'something', - toSha: undefined, + toSha: /** @type {any} */ (undefined), })).rejects.toThrow(WormholeError); }); }); @@ -517,9 +519,9 @@ describe('WormholeService', () => { }); it('throws on null/undefined input', () => { - expect(() => deserializeWormhole(null)).toThrow(WormholeError); - expect(() => deserializeWormhole(null)).toThrow('expected object'); - expect(() => deserializeWormhole(undefined)).toThrow(WormholeError); + expect(() => deserializeWormhole(/** @type {any} */ (null))).toThrow(WormholeError); + expect(() => deserializeWormhole(/** @type {any} */ (null))).toThrow('expected object'); + expect(() => deserializeWormhole(/** @type {any} */ (undefined))).toThrow(WormholeError); }); it('throws on missing required fields', () => { @@ -533,6 +535,7 @@ describe('WormholeService', () => { // Test each required field for (const field of ['fromSha', 'toSha', 'writerId', 'patchCount', 'payload']) { + /** @type {any} */ const incomplete = { ...validBase }; delete incomplete[field]; expect(() => deserializeWormhole(incomplete)).toThrow(`missing required field '${field}'`); diff --git a/test/unit/domain/services/logging.integration.test.js b/test/unit/domain/services/logging.integration.test.js index e7392b94..93a6ca32 100644 --- a/test/unit/domain/services/logging.integration.test.js +++ b/test/unit/domain/services/logging.integration.test.js @@ -9,7 +9,9 @@ const crypto = new NodeCryptoAdapter(); /** * Creates a mock logger that tracks all calls. */ +/** @returns {any} */ function createMockLogger() { + /** @type {Record} */ const calls = { debug: [], info: [], @@ -35,8 +37,14 @@ function createMockLogger() { describe('Service Logging Integration', () => { describe('IndexRebuildService', () => { + /** @type {any} */ + /** @type {any} */ let mockGraphService; + /** @type {any} */ + /** @type {any} */ let mockStorage; + /** @type {any} */ + /** @type {any} */ let mockLogger; beforeEach(() => { @@ -56,11 +64,11 @@ describe('Service Logging Integration', () => { describe('rebuild', () => { it('logs info at start and completion', async () => { - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage, logger: mockLogger - }); + })); await service.rebuild('HEAD'); @@ -84,11 +92,11 @@ describe('Service Logging Integration', () => { throw new Error('Graph error'); }); - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage, logger: mockLogger - }); + })); await expect(service.rebuild('HEAD')).rejects.toThrow('Graph error'); @@ -99,11 +107,11 @@ describe('Service Logging Integration', () => { }); it('indicates streaming mode in logs', async () => { - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage, logger: mockLogger - }); + })); await service.rebuild('HEAD', { maxMemoryBytes: 1024 * 1024 }); @@ -115,11 +123,11 @@ describe('Service Logging Integration', () => { describe('load', () => { it('logs debug on load', async () => { - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage, logger: mockLogger - }); + })); await service.load('tree-oid'); @@ -136,11 +144,11 @@ describe('Service Logging Integration', () => { }); it('creates child logger for BitmapIndexReader', async () => { - const service = new IndexRebuildService({ + const service = new IndexRebuildService(/** @type {any} */ ({ graphService: mockGraphService, storage: mockStorage, logger: mockLogger - }); + })); await service.load('tree-oid'); @@ -151,7 +159,11 @@ describe('Service Logging Integration', () => { }); describe('BitmapIndexReader', () => { + /** @type {any} */ + /** @type {any} */ let mockStorage; + /** @type {any} */ + /** @type {any} */ let mockLogger; beforeEach(() => { @@ -172,12 +184,12 @@ describe('Service Logging Integration', () => { })) ); - const reader = new BitmapIndexReader({ + const reader = new BitmapIndexReader(/** @type {any} */ ({ storage: mockStorage, strict: false, logger: mockLogger, crypto, - }); + })); reader.setup({ 'meta_sh.json': 'blob-oid' }); const id = await reader.lookupId('sha123'); diff --git a/test/unit/domain/services/v3-compatibility.test.js b/test/unit/domain/services/v3-compatibility.test.js index 3bfb6975..da1ff108 100644 --- a/test/unit/domain/services/v3-compatibility.test.js +++ b/test/unit/domain/services/v3-compatibility.test.js @@ -37,7 +37,7 @@ describe('v3 Backward Compatibility', () => { it('handles malformed JSON gracefully', () => { expect(isLegacyAnchor('{invalid')).toBe(false); expect(isLegacyAnchor('')).toBe(false); - expect(isLegacyAnchor(null)).toBe(false); + expect(isLegacyAnchor(/** @type {any} */ (null))).toBe(false); }); }); @@ -137,35 +137,35 @@ describe('v3 Backward Compatibility', () => { describe('Type Safety', () => { it('isLegacyAnchor handles undefined', () => { - expect(isLegacyAnchor(undefined)).toBe(false); + expect(isLegacyAnchor(/** @type {any} */ (undefined))).toBe(false); }); it('isLegacyAnchor handles numbers', () => { - expect(isLegacyAnchor(123)).toBe(false); + expect(isLegacyAnchor(/** @type {any} */ (123))).toBe(false); }); it('isLegacyAnchor handles objects', () => { - expect(isLegacyAnchor({ _type: 'anchor' })).toBe(false); + expect(isLegacyAnchor(/** @type {any} */ ({ _type: 'anchor' }))).toBe(false); }); it('isLegacyAnchor handles arrays', () => { - expect(isLegacyAnchor(['{"_type":"anchor"}'])).toBe(false); + expect(isLegacyAnchor(/** @type {any} */ (['{"_type":"anchor"}']))).toBe(false); }); it('isAnyAnchor handles undefined', () => { - expect(isAnyAnchor(undefined)).toBe(false); + expect(isAnyAnchor(/** @type {any} */ (undefined))).toBe(false); }); it('isAnyAnchor handles numbers', () => { - expect(isAnyAnchor(123)).toBe(false); + expect(isAnyAnchor(/** @type {any} */ (123))).toBe(false); }); it('isAnyAnchor handles objects', () => { - expect(isAnyAnchor({ message: 'test' })).toBe(false); + expect(isAnyAnchor(/** @type {any} */ ({ message: 'test' }))).toBe(false); }); it('isAnyAnchor handles arrays', () => { - expect(isAnyAnchor(['test'])).toBe(false); + expect(isAnyAnchor(/** @type {any} */ (['test']))).toBe(false); }); }); @@ -189,6 +189,7 @@ describe('v3 Backward Compatibility', () => { }); it('handles empty commit list', () => { + /** @type {{sha: string, message: string}[]} */ const commits = []; const filtered = commits.filter(c => !isAnyAnchor(c.message)); expect(filtered).toHaveLength(0); diff --git a/test/unit/domain/types/TickReceipt.test.js b/test/unit/domain/types/TickReceipt.test.js index 6e8e7828..c9d3f2b6 100644 --- a/test/unit/domain/types/TickReceipt.test.js +++ b/test/unit/domain/types/TickReceipt.test.js @@ -1,11 +1,14 @@ import { describe, it, expect } from 'vitest'; import { - createTickReceipt, + createTickReceipt as _createTickReceipt, canonicalJson, OP_TYPES, RESULT_TYPES, } from '../../../../src/domain/types/TickReceipt.js'; +/** @type {any} */ +const createTickReceipt = _createTickReceipt; + describe('TickReceipt', () => { // ----------------------------------------------------------------------- // Valid construction diff --git a/test/unit/domain/types/WarpTypesV2.test.js b/test/unit/domain/types/WarpTypesV2.test.js index f054241a..fbac6239 100644 --- a/test/unit/domain/types/WarpTypesV2.test.js +++ b/test/unit/domain/types/WarpTypesV2.test.js @@ -1,13 +1,22 @@ import { describe, it, expect } from 'vitest'; import { - createNodeAddV2, - createNodeRemoveV2, - createEdgeAddV2, - createEdgeRemoveV2, + createNodeAddV2 as _createNodeAddV2, + createNodeRemoveV2 as _createNodeRemoveV2, + createEdgeAddV2 as _createEdgeAddV2, + createEdgeRemoveV2 as _createEdgeRemoveV2, createPropSetV2, createPatchV2, } from '../../../../src/domain/types/WarpTypesV2.js'; +/** @type {any} */ +const createNodeAddV2 = _createNodeAddV2; +/** @type {any} */ +const createNodeRemoveV2 = _createNodeRemoveV2; +/** @type {any} */ +const createEdgeAddV2 = _createEdgeAddV2; +/** @type {any} */ +const createEdgeRemoveV2 = _createEdgeRemoveV2; + describe('WarpTypesV2', () => { describe('Operation Factory Functions', () => { describe('createNodeAddV2', () => { @@ -374,7 +383,7 @@ describe('WarpTypesV2', () => { dot: { writer: 'app-server-1', seq: 1 }, }); expect(patch.ops[1].type).toBe('PropSet'); - expect(patch.ops[1].value).toBe('alice@example.com'); + expect(/** @type {any} */ (patch.ops[1]).value).toBe('alice@example.com'); }); it('creates a social graph patch', () => { @@ -398,8 +407,8 @@ describe('WarpTypesV2', () => { const edges = patch.ops.filter((op) => op.type === 'EdgeAdd'); expect(edges).toHaveLength(2); expect(edges[0].label).toBe('follows'); - expect(edges[0].dot.seq).toBe(3); - expect(edges[1].dot.seq).toBe(4); + expect(/** @type {any} */ (edges[0]).dot.seq).toBe(3); + expect(/** @type {any} */ (edges[1]).dot.seq).toBe(4); }); it('creates a deletion patch with observed dots', () => { @@ -425,9 +434,9 @@ describe('WarpTypesV2', () => { expect(patch.ops).toHaveLength(2); expect(patch.ops[0].type).toBe('EdgeRemove'); - expect(patch.ops[0].observedDots).toHaveLength(1); + expect(/** @type {any} */ (patch.ops[0]).observedDots).toHaveLength(1); expect(patch.ops[1].type).toBe('NodeRemove'); - expect(patch.ops[1].observedDots).toHaveLength(2); + expect(/** @type {any} */ (patch.ops[1]).observedDots).toHaveLength(2); }); it('creates a merge-scenario patch observing multiple writers', () => { diff --git a/test/unit/domain/utils/CachedValue.test.js b/test/unit/domain/utils/CachedValue.test.js index afe51155..6bb04592 100644 --- a/test/unit/domain/utils/CachedValue.test.js +++ b/test/unit/domain/utils/CachedValue.test.js @@ -1,19 +1,22 @@ import { describe, it, expect, vi } from 'vitest'; -import CachedValue from '../../../../src/domain/utils/CachedValue.js'; +import CachedValue_ from '../../../../src/domain/utils/CachedValue.js'; + +/** @type {any} */ +const CachedValue = CachedValue_; /** * Creates a mock clock for testing. - * @returns {Object} Mock clock with controllable time + * @returns {any} Mock clock with controllable time */ function createMockClock() { let currentTime = 0; return { now: () => currentTime, timestamp: () => new Date(currentTime).toISOString(), - advance: (ms) => { + advance: (/** @type {number} */ ms) => { currentTime += ms; }, - setTime: (ms) => { + setTime: (/** @type {number} */ ms) => { currentTime = ms; }, }; diff --git a/test/unit/domain/utils/EventId.test.js b/test/unit/domain/utils/EventId.test.js index 46d18e92..d2a2a911 100644 --- a/test/unit/domain/utils/EventId.test.js +++ b/test/unit/domain/utils/EventId.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { createEventId, compareEventIds, isGreater } from '../../../../src/domain/utils/EventId.js'; +import { createEventId as _createEventId, compareEventIds, isGreater } from '../../../../src/domain/utils/EventId.js'; + +/** @type {any} */ +const createEventId = _createEventId; describe('EventId', () => { describe('createEventId', () => { @@ -299,6 +302,7 @@ describe('EventId', () => { }); it('handles empty array', () => { + /** @type {any[]} */ const events = []; const sorted = [...events].sort(compareEventIds); diff --git a/test/unit/domain/utils/LRUCache.test.js b/test/unit/domain/utils/LRUCache.test.js index 99380e6d..f3a548f2 100644 --- a/test/unit/domain/utils/LRUCache.test.js +++ b/test/unit/domain/utils/LRUCache.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import LRUCache from '../../../../src/domain/utils/LRUCache.js'; +import LRUCache_ from '../../../../src/domain/utils/LRUCache.js'; + +/** @type {any} */ +const LRUCache = LRUCache_; describe('LRUCache', () => { describe('constructor', () => { diff --git a/test/unit/domain/utils/RefLayout.test.js b/test/unit/domain/utils/RefLayout.test.js index d63b20eb..9a911986 100644 --- a/test/unit/domain/utils/RefLayout.test.js +++ b/test/unit/domain/utils/RefLayout.test.js @@ -6,11 +6,22 @@ import { buildCheckpointRef, buildCoverageRef, buildWritersPrefix, - parseWriterIdFromRef, - validateGraphName, - validateWriterId, + buildSeekCacheRef, + buildCursorActiveRef, + buildCursorSavedRef, + buildCursorSavedPrefix, + parseWriterIdFromRef as _parseWriterIdFromRef, + validateGraphName as _validateGraphName, + validateWriterId as _validateWriterId, } from '../../../../src/domain/utils/RefLayout.js'; +/** @type {any} */ +const parseWriterIdFromRef = _parseWriterIdFromRef; +/** @type {any} */ +const validateGraphName = _validateGraphName; +/** @type {any} */ +const validateWriterId = _validateWriterId; + describe('RefLayout', () => { describe('constants', () => { it('REF_PREFIX is refs/warp', () => { @@ -368,4 +379,96 @@ describe('RefLayout', () => { expect(buildWriterRef('123', '456')).toBe('refs/warp/123/writers/456'); }); }); + + 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'); + }); + + it('builds cursor active ref for nested graph names', () => { + expect(buildCursorActiveRef('team/events')).toBe( + 'refs/warp/team/events/cursor/active' + ); + }); + + it('throws for invalid graph name', () => { + expect(() => buildCursorActiveRef('')).toThrow('cannot be empty'); + expect(() => buildCursorActiveRef('../etc')).toThrow( + "contains path traversal sequence '..'" + ); + expect(() => buildCursorActiveRef('my graph')).toThrow('contains space'); + }); + }); + + describe('buildCursorSavedRef', () => { + it('builds correct cursor saved ref path', () => { + expect(buildCursorSavedRef('events', 'before-tui')).toBe( + 'refs/warp/events/cursor/saved/before-tui' + ); + }); + + it('builds saved ref for various valid inputs', () => { + expect(buildCursorSavedRef('my-graph', 'snap_01')).toBe( + 'refs/warp/my-graph/cursor/saved/snap_01' + ); + expect(buildCursorSavedRef('team/events', 'checkpoint.v2')).toBe( + 'refs/warp/team/events/cursor/saved/checkpoint.v2' + ); + }); + + it('throws for invalid graph name', () => { + expect(() => buildCursorSavedRef('../etc', 'name')).toThrow( + "contains path traversal sequence '..'" + ); + expect(() => buildCursorSavedRef('', 'name')).toThrow('cannot be empty'); + }); + + it('throws for invalid cursor name', () => { + expect(() => buildCursorSavedRef('events', '')).toThrow('cannot be empty'); + expect(() => buildCursorSavedRef('events', 'a/b')).toThrow('contains forward slash'); + expect(() => buildCursorSavedRef('events', 'x'.repeat(65))).toThrow( + 'exceeds maximum length' + ); + expect(() => buildCursorSavedRef('events', 'has space')).toThrow( + 'contains whitespace' + ); + }); + }); + + describe('buildCursorSavedPrefix', () => { + it('builds correct cursor saved prefix path', () => { + expect(buildCursorSavedPrefix('events')).toBe('refs/warp/events/cursor/saved/'); + }); + + it('builds prefix with trailing slash', () => { + const prefix = buildCursorSavedPrefix('my-graph'); + expect(prefix).toBe('refs/warp/my-graph/cursor/saved/'); + expect(prefix.endsWith('/')).toBe(true); + }); + + it('builds prefix for nested graph names', () => { + expect(buildCursorSavedPrefix('team/events')).toBe( + 'refs/warp/team/events/cursor/saved/' + ); + }); + + it('throws for invalid graph name', () => { + expect(() => buildCursorSavedPrefix('')).toThrow('cannot be empty'); + expect(() => buildCursorSavedPrefix('../etc')).toThrow( + "contains path traversal sequence '..'" + ); + expect(() => buildCursorSavedPrefix('my graph')).toThrow('contains space'); + }); + }); }); diff --git a/test/unit/domain/utils/WriterId.test.js b/test/unit/domain/utils/WriterId.test.js index aabfbb2f..5709c6c0 100644 --- a/test/unit/domain/utils/WriterId.test.js +++ b/test/unit/domain/utils/WriterId.test.js @@ -6,11 +6,16 @@ import { describe, it, expect } from 'vitest'; import { - generateWriterId, - validateWriterIdCanonical, + generateWriterId as _generateWriterId, + validateWriterIdCanonical as _validateWriterIdCanonical, resolveWriterId, WriterIdError, } from '../../../../src/domain/utils/WriterId.js'; + +/** @type {any} */ +const generateWriterId = _generateWriterId; +/** @type {any} */ +const validateWriterIdCanonical = _validateWriterIdCanonical; import { validateWriterId } from '../../../../src/domain/utils/RefLayout.js'; /** @@ -127,7 +132,7 @@ describe('WriterId SPEC', () => { try { validateWriterIdCanonical('bad-id'); expect.fail('Should have thrown'); - } catch (e) { + } catch (/** @type {any} */ e) { expect(e.message).toContain('bad-id'); expect(e.code).toBe('INVALID_CANONICAL'); } @@ -244,7 +249,7 @@ describe('WriterId SPEC', () => { configSet: async () => {}, }); expect.fail('Should have thrown'); - } catch (e) { + } catch (/** @type {any} */ e) { expect(e.cause).toBe(cause); } }); @@ -268,7 +273,7 @@ describe('WriterId SPEC', () => { configSet: async () => { throw cause; }, }); expect.fail('Should have thrown'); - } catch (e) { + } catch (/** @type {any} */ e) { expect(e.cause).toBe(cause); } }); diff --git a/test/unit/domain/utils/cancellation.test.js b/test/unit/domain/utils/cancellation.test.js index d0940544..94f0df54 100644 --- a/test/unit/domain/utils/cancellation.test.js +++ b/test/unit/domain/utils/cancellation.test.js @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest'; import OperationAbortedError from '../../../../src/domain/errors/OperationAbortedError.js'; -import { checkAborted, createTimeoutSignal } from '../../../../src/domain/utils/cancellation.js'; +import { checkAborted as _checkAborted, createTimeoutSignal } from '../../../../src/domain/utils/cancellation.js'; + +/** @type {any} */ +const checkAborted = _checkAborted; describe('Cancellation', () => { describe('OperationAbortedError', () => { @@ -82,7 +85,7 @@ describe('Cancellation', () => { try { checkAborted(controller.signal, 'rebuild'); expect.fail('Should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.context.operation).toBe('rebuild'); } }); @@ -109,7 +112,7 @@ describe('Cancellation', () => { try { checkAborted(controller.signal); expect.fail('Should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain('Operation was aborted'); } }); @@ -186,7 +189,7 @@ describe('Cancellation', () => { const controller = new AbortController(); // Simulate an async generator that checks abort signal - async function* mockIterateNodes(options) { + async function* mockIterateNodes(/** @type {any} */ options) { const nodes = [ { sha: 'sha1', parents: [] }, { sha: 'sha2', parents: ['sha1'] }, @@ -221,7 +224,7 @@ describe('Cancellation', () => { it('iteration completes normally when signal is not aborted', async () => { const controller = new AbortController(); - async function* mockIterateNodes(options) { + async function* mockIterateNodes(/** @type {any} */ options) { const nodes = [ { sha: 'sha1', parents: [] }, { sha: 'sha2', parents: ['sha1'] }, @@ -242,7 +245,7 @@ describe('Cancellation', () => { }); it('iteration works without signal', async () => { - async function* mockIterateNodes(options) { + async function* mockIterateNodes(/** @type {any} */ options) { const nodes = [{ sha: 'sha1', parents: [] }]; for (const node of nodes) { @@ -266,7 +269,7 @@ describe('Cancellation', () => { let processedCount = 0; // Simulate rebuild loop that checks abort signal - async function mockRebuild(options) { + async function mockRebuild(/** @type {any} */ options) { const nodes = [ { sha: 'sha1', parents: [] }, { sha: 'sha2', parents: ['sha1'] }, @@ -290,7 +293,7 @@ describe('Cancellation', () => { try { await mockRebuild({ signal: controller.signal }); // Might complete if abort happens after all nodes - } catch (err) { + } catch (/** @type {any} */ err) { expect(err).toBeInstanceOf(OperationAbortedError); expect(err.message).toContain('rebuild'); // Should have processed at least one node but not all @@ -301,7 +304,7 @@ describe('Cancellation', () => { it('rebuild completes when signal is not aborted', async () => { const controller = new AbortController(); - async function mockRebuild(options) { + async function mockRebuild(/** @type {any} */ options) { const nodes = [ { sha: 'sha1', parents: [] }, { sha: 'sha2', parents: ['sha1'] }, @@ -325,7 +328,7 @@ describe('Cancellation', () => { let processedCount = 0; let abortError = null; - async function mockLongRebuild(options) { + async function mockLongRebuild(/** @type {any} */ options) { const manyNodes = Array.from({ length: 100 }, (_, i) => ({ sha: `sha${i}`, parents: i > 0 ? [`sha${i - 1}`] : [], diff --git a/test/unit/domain/utils/canonicalStringify.test.js b/test/unit/domain/utils/canonicalStringify.test.js new file mode 100644 index 00000000..4da6a1ba --- /dev/null +++ b/test/unit/domain/utils/canonicalStringify.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { canonicalStringify } from '../../../../src/domain/utils/canonicalStringify.js'; + +describe('canonicalStringify', () => { + describe('primitives', () => { + it('returns "null" for undefined', () => { + expect(canonicalStringify(undefined)).toBe('null'); + }); + + it('returns "null" for null', () => { + expect(canonicalStringify(null)).toBe('null'); + }); + + it('stringifies strings with JSON quoting', () => { + expect(canonicalStringify('hello')).toBe('"hello"'); + expect(canonicalStringify('')).toBe('""'); + expect(canonicalStringify('with "quotes"')).toBe('"with \\"quotes\\""'); + }); + + it('stringifies numbers', () => { + expect(canonicalStringify(42)).toBe('42'); + expect(canonicalStringify(0)).toBe('0'); + expect(canonicalStringify(-3.14)).toBe('-3.14'); + }); + + it('stringifies booleans', () => { + expect(canonicalStringify(true)).toBe('true'); + expect(canonicalStringify(false)).toBe('false'); + }); + }); + + describe('objects', () => { + it('sorts keys alphabetically', () => { + const result = canonicalStringify({ z: 1, a: 2, m: 3 }); + expect(result).toBe('{"a":2,"m":3,"z":1}'); + }); + + it('returns "{}" for empty object', () => { + expect(canonicalStringify({})).toBe('{}'); + }); + + it('omits keys with undefined values', () => { + const result = canonicalStringify({ a: 1, b: undefined, c: 3 }); + expect(result).toBe('{"a":1,"c":3}'); + }); + + it('omits keys with function values', () => { + const result = canonicalStringify({ a: 1, b: () => {}, c: 3 }); + expect(result).toBe('{"a":1,"c":3}'); + }); + + it('omits keys with symbol values', () => { + const result = canonicalStringify({ a: 1, b: Symbol('test'), c: 3 }); + expect(result).toBe('{"a":1,"c":3}'); + }); + + it('returns "{}" when all values are filtered out', () => { + const result = canonicalStringify({ + a: undefined, + b: () => {}, + c: Symbol('x'), + }); + expect(result).toBe('{}'); + }); + }); + + describe('arrays', () => { + it('stringifies basic arrays', () => { + expect(canonicalStringify([1, 2, 3])).toBe('[1,2,3]'); + expect(canonicalStringify(['a', 'b'])).toBe('["a","b"]'); + }); + + it('returns "[]" for empty array', () => { + expect(canonicalStringify([])).toBe('[]'); + }); + + it('replaces undefined elements with "null"', () => { + const result = canonicalStringify([1, undefined, 3]); + expect(result).toBe('[1,null,3]'); + }); + + it('replaces function elements with "null"', () => { + const result = canonicalStringify([1, () => {}, 3]); + expect(result).toBe('[1,null,3]'); + }); + + it('replaces symbol elements with "null"', () => { + const result = canonicalStringify([1, Symbol('x'), 3]); + expect(result).toBe('[1,null,3]'); + }); + }); + + describe('nested structures', () => { + it('handles nested objects with sorted keys', () => { + const result = canonicalStringify({ b: { z: 1, a: 2 }, a: 'first' }); + expect(result).toBe('{"a":"first","b":{"a":2,"z":1}}'); + }); + + it('handles nested arrays', () => { + const result = canonicalStringify([[1, 2], [3, 4]]); + expect(result).toBe('[[1,2],[3,4]]'); + }); + + it('handles mixed nesting', () => { + const result = canonicalStringify({ arr: [1, { b: 2, a: 1 }], key: 'val' }); + expect(result).toBe('{"arr":[1,{"a":1,"b":2}],"key":"val"}'); + }); + + it('handles deeply nested structures', () => { + const result = canonicalStringify({ a: { b: { c: { d: 42 } } } }); + expect(result).toBe('{"a":{"b":{"c":{"d":42}}}}'); + }); + }); + + describe('determinism', () => { + it('produces identical output regardless of insertion order', () => { + const obj1 = { z: 1, a: 2, m: 3 }; + const obj2 = { a: 2, z: 1, m: 3 }; + const obj3 = { m: 3, z: 1, a: 2 }; + + const result1 = canonicalStringify(obj1); + const result2 = canonicalStringify(obj2); + const result3 = canonicalStringify(obj3); + + expect(result1).toBe(result2); + expect(result2).toBe(result3); + }); + }); +}); diff --git a/test/unit/domain/utils/defaultClock.test.js b/test/unit/domain/utils/defaultClock.test.js new file mode 100644 index 00000000..4d6a0e46 --- /dev/null +++ b/test/unit/domain/utils/defaultClock.test.js @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import defaultClock from '../../../../src/domain/utils/defaultClock.js'; + +describe('defaultClock', () => { + describe('now', () => { + it('returns a number', () => { + expect(typeof defaultClock.now()).toBe('number'); + }); + + it('returns monotonically non-decreasing values', () => { + const a = defaultClock.now(); + const b = defaultClock.now(); + expect(b).toBeGreaterThanOrEqual(a); + }); + }); + + describe('timestamp', () => { + it('returns an ISO 8601 string', () => { + const ts = defaultClock.timestamp(); + // ISO 8601 format: YYYY-MM-DDTHH:mm:ss.sssZ + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); + + it('is parseable by new Date()', () => { + const ts = defaultClock.timestamp(); + const parsed = new Date(ts); + expect(parsed.getTime()).not.toBeNaN(); + }); + + it('returns a recent timestamp', () => { + const before = Date.now(); + const ts = defaultClock.timestamp(); + const after = Date.now(); + const parsed = new Date(ts).getTime(); + expect(parsed).toBeGreaterThanOrEqual(before); + expect(parsed).toBeLessThanOrEqual(after); + }); + }); +}); diff --git a/test/unit/domain/utils/defaultCrypto.test.js b/test/unit/domain/utils/defaultCrypto.test.js new file mode 100644 index 00000000..eb24cd37 --- /dev/null +++ b/test/unit/domain/utils/defaultCrypto.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import defaultCrypto from '../../../../src/domain/utils/defaultCrypto.js'; + +describe('defaultCrypto', () => { + describe('hash', () => { + it('returns expected sha256 hex digest', async () => { + const result = await defaultCrypto.hash('sha256', 'hello'); + expect(result).toBe( + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' + ); + }); + + it('returns a string', async () => { + const result = await defaultCrypto.hash('sha256', 'test-data'); + expect(typeof result).toBe('string'); + }); + + it('produces different hashes for different inputs', async () => { + const a = await defaultCrypto.hash('sha256', 'alpha'); + const b = await defaultCrypto.hash('sha256', 'beta'); + expect(a).not.toBe(b); + }); + + it('returns consistent results for the same input', async () => { + const first = await defaultCrypto.hash('sha256', 'deterministic'); + const second = await defaultCrypto.hash('sha256', 'deterministic'); + expect(first).toBe(second); + }); + }); + + describe('hmac', () => { + it('returns a Buffer', async () => { + const result = await defaultCrypto.hmac('sha256', 'secret-key', 'data'); + expect(Buffer.isBuffer(result)).toBe(true); + }); + + it('produces different results for different keys', async () => { + const a = /** @type {any} */ (await defaultCrypto.hmac('sha256', 'key-1', 'same-data')); + const b = /** @type {any} */ (await defaultCrypto.hmac('sha256', 'key-2', 'same-data')); + expect(a.equals(b)).toBe(false); + }); + + it('produces consistent results', async () => { + const first = /** @type {any} */ (await defaultCrypto.hmac('sha256', 'key', 'data')); + const second = /** @type {any} */ (await defaultCrypto.hmac('sha256', 'key', 'data')); + expect(first.equals(second)).toBe(true); + }); + }); + + describe('timingSafeEqual', () => { + it('returns true for equal buffers', () => { + const a = Buffer.from('identical'); + const b = Buffer.from('identical'); + expect(defaultCrypto.timingSafeEqual(a, b)).toBe(true); + }); + + it('returns false for unequal buffers of same length', () => { + const a = Buffer.from('aaaabbbb'); + const b = Buffer.from('ccccdddd'); + expect(defaultCrypto.timingSafeEqual(a, b)).toBe(false); + }); + + it('throws for buffers of different lengths', () => { + const a = Buffer.from('short'); + const b = Buffer.from('much longer'); + expect(() => defaultCrypto.timingSafeEqual(a, b)).toThrow(); + }); + }); +}); diff --git a/test/unit/domain/utils/nullLogger.test.js b/test/unit/domain/utils/nullLogger.test.js new file mode 100644 index 00000000..0da4e4d1 --- /dev/null +++ b/test/unit/domain/utils/nullLogger.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import nullLogger from '../../../../src/domain/utils/nullLogger.js'; + +describe('nullLogger', () => { + it('debug() does not throw', () => { + expect(nullLogger.debug('test message')).toBeUndefined(); + }); + + it('info() does not throw', () => { + expect(nullLogger.info('test message')).toBeUndefined(); + }); + + it('warn() does not throw', () => { + expect(nullLogger.warn('test message')).toBeUndefined(); + }); + + it('error() does not throw', () => { + expect(nullLogger.error('test message')).toBeUndefined(); + }); + + it('child() returns nullLogger itself', () => { + const child = nullLogger.child({ component: 'test' }); + expect(child).toBe(nullLogger); + }); + + it('chained child calls return nullLogger', () => { + const grandchild = nullLogger.child({ a: 1 }).child({ b: 2 }); + expect(grandchild).toBe(nullLogger); + }); +}); diff --git a/test/unit/domain/warp/Writer.test.js b/test/unit/domain/warp/Writer.test.js index 57fb5dab..8a88b530 100644 --- a/test/unit/domain/warp/Writer.test.js +++ b/test/unit/domain/warp/Writer.test.js @@ -42,8 +42,11 @@ function createPatchMessage(lamport = 1) { } describe('Writer (WARP schema:2)', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let versionVector; + /** @type {any} */ let getCurrentState; beforeEach(() => { @@ -470,7 +473,7 @@ describe('Writer (WARP schema:2)', () => { try { await patch.commit(); expect.fail('Should have thrown'); - } catch (err) { + } catch (/** @type {any} */ err) { expect(err.message).toContain(oldHead); expect(err.message).toContain(movedHead); expect(err.message).toContain('beginPatch()'); @@ -549,8 +552,11 @@ describe('Writer (WARP schema:2)', () => { }); describe('PatchSession operations', () => { + /** @type {any} */ let persistence; + /** @type {any} */ let versionVector; + /** @type {any} */ let getCurrentState; beforeEach(() => { @@ -572,7 +578,7 @@ describe('PatchSession operations', () => { const patch = await writer.beginPatch(); patch.addNode('user:alice'); - const built = patch.build(); + const built = /** @type {any} */ (patch.build()); expect(built.ops).toHaveLength(1); expect(built.ops[0].type).toBe('NodeAdd'); expect(built.ops[0].node).toBe('user:alice'); @@ -590,7 +596,7 @@ describe('PatchSession operations', () => { const patch = await writer.beginPatch(); patch.removeNode('user:alice'); - const built = patch.build(); + const built = /** @type {any} */ (patch.build()); expect(built.ops).toHaveLength(1); expect(built.ops[0].type).toBe('NodeRemove'); expect(built.ops[0].node).toBe('user:alice'); @@ -608,7 +614,7 @@ describe('PatchSession operations', () => { const patch = await writer.beginPatch(); patch.addEdge('n1', 'n2', 'links'); - const built = patch.build(); + const built = /** @type {any} */ (patch.build()); expect(built.ops).toHaveLength(1); expect(built.ops[0].type).toBe('EdgeAdd'); expect(built.ops[0].from).toBe('n1'); @@ -628,7 +634,7 @@ describe('PatchSession operations', () => { const patch = await writer.beginPatch(); patch.removeEdge('n1', 'n2', 'links'); - const built = patch.build(); + const built = /** @type {any} */ (patch.build()); expect(built.ops).toHaveLength(1); expect(built.ops[0].type).toBe('EdgeRemove'); }); @@ -645,7 +651,7 @@ describe('PatchSession operations', () => { const patch = await writer.beginPatch(); patch.setProperty('user:alice', 'name', 'Alice'); - const built = patch.build(); + const built = /** @type {any} */ (patch.build()); expect(built.ops).toHaveLength(1); expect(built.ops[0].type).toBe('PropSet'); expect(built.ops[0].node).toBe('user:alice'); @@ -669,7 +675,7 @@ describe('PatchSession operations', () => { patch.setProperty('n', 'arr', [1, 2, 3]); patch.setProperty('n', 'obj', { x: 1 }); - const built = patch.build(); + const built = /** @type {any} */ (patch.build()); expect(built.ops).toHaveLength(5); expect(built.ops[0].value).toBe('hello'); expect(built.ops[1].value).toBe(42); diff --git a/test/unit/infrastructure/adapters/BunHttpAdapter.test.js b/test/unit/infrastructure/adapters/BunHttpAdapter.test.js index bff65e2f..e44964ba 100644 --- a/test/unit/infrastructure/adapters/BunHttpAdapter.test.js +++ b/test/unit/infrastructure/adapters/BunHttpAdapter.test.js @@ -6,15 +6,18 @@ import HttpServerPort from '../../../../src/ports/HttpServerPort.js'; /** * Creates a mock Bun.serve() that captures its options and returns * a fake server object. + * + * @returns {{ serve: any, mockServer: any }} */ function createMockBunServe() { + /** @type {any} */ const mockServer = { port: 0, hostname: '0.0.0.0', stop: vi.fn(), }; - const serve = vi.fn((options) => { + const serve = vi.fn((/** @type {any} */ options) => { mockServer.port = options.port || 0; mockServer.hostname = options.hostname || '0.0.0.0'; // Stash fetch so tests can invoke it directly @@ -28,8 +31,8 @@ function createMockBunServe() { /** * Creates a minimal mock Request for testing the fetch bridge. * - * @param {{ method?: string, url?: string, headers?: Object, body?: string }} opts - * @returns {Request-like object} + * @param {{ method?: string, url?: string, headers?: Record, body?: string }} opts + * @returns {any} */ function createMockRequest(opts = {}) { const method = opts.method || 'GET'; @@ -62,6 +65,7 @@ function createMockRequest(opts = {}) { method, url, headers: { + /** @param {Function} fn */ forEach(fn) { headerMap.forEach((value, key) => { fn(value, key); @@ -76,17 +80,18 @@ function createMockRequest(opts = {}) { } describe('BunHttpAdapter', () => { + /** @type {any} */ let savedBun; beforeEach(() => { - savedBun = globalThis.Bun; + savedBun = /** @type {any} */ (globalThis).Bun; }); afterEach(() => { if (savedBun === undefined) { - delete globalThis.Bun; + delete /** @type {any} */ (globalThis).Bun; } else { - globalThis.Bun = savedBun; + /** @type {any} */ (globalThis).Bun = savedBun; } }); @@ -129,7 +134,7 @@ describe('BunHttpAdapter', () => { describe('listen', () => { it('calls Bun.serve with correct port', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -144,7 +149,7 @@ describe('BunHttpAdapter', () => { it('calls Bun.serve with hostname when host is a string', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -160,7 +165,7 @@ describe('BunHttpAdapter', () => { it('does not set hostname when host is a function (callback)', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -174,7 +179,7 @@ describe('BunHttpAdapter', () => { it('passes error to callback when Bun.serve throws', () => { const err = new Error('bind EADDRINUSE'); - globalThis.Bun = { + /** @type {any} */ (globalThis).Bun = { serve: vi.fn(() => { throw err; }), @@ -191,7 +196,7 @@ describe('BunHttpAdapter', () => { it('works without callback', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -204,7 +209,7 @@ describe('BunHttpAdapter', () => { describe('address', () => { it('returns address info after listen', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -222,7 +227,7 @@ describe('BunHttpAdapter', () => { describe('close', () => { it('calls server.stop()', () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -237,7 +242,7 @@ describe('BunHttpAdapter', () => { it('address returns null after close', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -249,7 +254,7 @@ describe('BunHttpAdapter', () => { it('works without callback', () => { const { serve } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const adapter = new BunHttpAdapter(); const server = adapter.createServer(vi.fn()); @@ -262,7 +267,7 @@ describe('BunHttpAdapter', () => { describe('request/response bridging', () => { it('converts GET Request to port request and back', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const handler = vi.fn(async () => ({ status: 200, @@ -283,7 +288,7 @@ describe('BunHttpAdapter', () => { const response = await mockServer._fetch(mockReq); expect(handler).toHaveBeenCalledOnce(); - const portReq = handler.mock.calls[0][0]; + const portReq = /** @type {any} */ (handler).mock.calls[0][0]; expect(portReq.method).toBe('GET'); expect(portReq.url).toBe('/api/nodes?limit=10'); expect(portReq.headers.accept).toBe('application/json'); @@ -296,9 +301,9 @@ describe('BunHttpAdapter', () => { it('converts POST Request with body', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; - const handler = vi.fn(async (req) => ({ + const handler = vi.fn(async (/** @type {any} */ req) => ({ status: 201, headers: {}, body: `received ${req.body.length} bytes`, @@ -317,7 +322,7 @@ describe('BunHttpAdapter', () => { const response = await mockServer._fetch(mockReq); - const portReq = handler.mock.calls[0][0]; + const portReq = /** @type {any} */ (handler.mock.calls[0][0]); expect(portReq.method).toBe('POST'); expect(portReq.body).toBeInstanceOf(Uint8Array); expect(portReq.body.length).toBe(15); @@ -327,7 +332,7 @@ describe('BunHttpAdapter', () => { it('body is undefined for GET even if arrayBuffer returns data', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const handler = vi.fn(async () => ({ status: 200, @@ -346,13 +351,13 @@ describe('BunHttpAdapter', () => { await mockServer._fetch(mockReq); - const portReq = handler.mock.calls[0][0]; + const portReq = /** @type {any} */ (handler).mock.calls[0][0]; expect(portReq.body).toBeUndefined(); }); it('defaults status to 200 and headers to empty', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const handler = vi.fn(async () => ({ body: 'hello', @@ -373,7 +378,7 @@ describe('BunHttpAdapter', () => { describe('body size enforcement', () => { it('rejects request with Content-Length exceeding MAX_BODY_BYTES', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const handler = vi.fn(async () => ({ status: 200 })); const adapter = new BunHttpAdapter(); @@ -395,7 +400,7 @@ describe('BunHttpAdapter', () => { it('uses streaming to enforce body limit without calling arrayBuffer', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const handler = vi.fn(async () => ({ status: 200 })); const adapter = new BunHttpAdapter(); @@ -417,6 +422,7 @@ describe('BunHttpAdapter', () => { }); const arrayBufferSpy = vi.fn(); + /** @type {any} */ const mockReq = { method: 'POST', url: 'http://localhost:8001/stream', @@ -441,7 +447,7 @@ describe('BunHttpAdapter', () => { describe('error handling', () => { it('returns 500 when handler throws', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const logger = { error: vi.fn() }; const handler = vi.fn(async () => { @@ -466,7 +472,7 @@ describe('BunHttpAdapter', () => { it('returns 500 with default noop logger (no throw)', async () => { const { serve, mockServer } = createMockBunServe(); - globalThis.Bun = { serve }; + /** @type {any} */ (globalThis).Bun = { serve }; const handler = vi.fn(async () => { throw new Error('boom'); diff --git a/test/unit/infrastructure/adapters/CasSeekCacheAdapter.test.js b/test/unit/infrastructure/adapters/CasSeekCacheAdapter.test.js new file mode 100644 index 00000000..d6e3a499 --- /dev/null +++ b/test/unit/infrastructure/adapters/CasSeekCacheAdapter.test.js @@ -0,0 +1,732 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock @git-stunts/git-cas (dynamic import used by _initCas) +const mockReadManifest = vi.fn(); +const mockRestore = vi.fn(); +const mockStore = vi.fn(); +const mockCreateTree = vi.fn(); +const mockCreateCbor = vi.fn(() => ({ + readManifest: mockReadManifest, + restore: mockRestore, + store: mockStore, + createTree: mockCreateTree, +})); + +vi.mock('@git-stunts/git-cas', () => ({ + default: { + createCbor: mockCreateCbor, + }, +})); + +// Import after mock setup +const { default: CasSeekCacheAdapter } = await import( + '../../../../src/infrastructure/adapters/CasSeekCacheAdapter.js' +); +const { default: SeekCachePort } = await import( + '../../../../src/ports/SeekCachePort.js' +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Builds a minimal mock persistence port with vi.fn() stubs. */ +function makePersistence() { + return { + readRef: vi.fn().mockResolvedValue(null), + readBlob: vi.fn().mockResolvedValue(Buffer.from('{}', 'utf8')), + writeBlob: vi.fn().mockResolvedValue('blob-oid-1'), + updateRef: vi.fn().mockResolvedValue(undefined), + deleteRef: vi.fn().mockResolvedValue(undefined), + }; +} + +function makePlumbing() { + return {}; +} + +/** Builds a JSON index buffer for readBlob to return. */ +function indexBuffer(entries = {}) { + return Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), 'utf8'); +} + +const GRAPH_NAME = 'test-graph'; +const EXPECTED_REF = `refs/warp/${GRAPH_NAME}/seek-cache`; +const SAMPLE_KEY = 'v1:t42-abcdef0123456789'; +const SAMPLE_BUFFER = Buffer.from('serialized-state-data'); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('CasSeekCacheAdapter', () => { + /** @type {any} */ + let persistence; + /** @type {any} */ + let plumbing; + /** @type {any} */ + let adapter; + + beforeEach(() => { + vi.clearAllMocks(); + persistence = makePersistence(); + plumbing = makePlumbing(); + adapter = new CasSeekCacheAdapter({ + persistence, + plumbing, + graphName: GRAPH_NAME, + }); + }); + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + describe('constructor', () => { + it('extends SeekCachePort', () => { + expect(adapter).toBeInstanceOf(SeekCachePort); + }); + + it('defaults maxEntries to 200', () => { + expect(adapter._maxEntries).toBe(200); + }); + + it('respects custom maxEntries', () => { + const custom = new CasSeekCacheAdapter({ + persistence, + plumbing, + graphName: GRAPH_NAME, + maxEntries: 50, + }); + expect(custom._maxEntries).toBe(50); + }); + + it('builds the correct ref path', () => { + expect(adapter._ref).toBe(EXPECTED_REF); + }); + + it('initialises _casPromise to null', () => { + expect(adapter._casPromise).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // _getCas — lazy CAS initialization + // ------------------------------------------------------------------------- + + describe('_getCas()', () => { + it('creates CAS instance on first call', async () => { + await adapter._getCas(); + expect(mockCreateCbor).toHaveBeenCalledWith({ plumbing }); + }); + + it('caches the CAS promise across multiple calls', async () => { + await adapter._getCas(); + await adapter._getCas(); + expect(mockCreateCbor).toHaveBeenCalledTimes(1); + }); + + it('resets cached promise on init error so next call retries', async () => { + mockCreateCbor.mockImplementationOnce(() => { + throw new Error('init failure'); + }); + + await expect(adapter._getCas()).rejects.toThrow('init failure'); + expect(adapter._casPromise).toBeNull(); + + // Second call should retry and succeed + mockCreateCbor.mockReturnValueOnce({ + readManifest: mockReadManifest, + restore: mockRestore, + store: mockStore, + createTree: mockCreateTree, + }); + await expect(adapter._getCas()).resolves.toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // _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 = Buffer.from('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).toBe(stateBuffer); + expect(mockReadManifest).toHaveBeenCalledWith({ treeOid }); + expect(mockRestore).toHaveBeenCalledWith({ manifest }); + }); + + it('updates lastAccessedAt on successful cache hit', async () => { + const treeOid = 'tree-oid-abc'; + const manifest = { chunks: ['c1'] }; + const stateBuffer = Buffer.from('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( + persistence.writeBlob.mock.calls[0][0].toString('utf8') + ); + 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(); + }); + }); + + // ------------------------------------------------------------------------- + // 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( + persistence.writeBlob.mock.calls[0][0].toString('utf8') + ); + 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('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( + persistence.writeBlob.mock.calls[0][0].toString('utf8') + ); + expect(writtenJson.entries[existingKey]).toEqual(existingEntry); + expect(writtenJson.entries[SAMPLE_KEY]).toBeDefined(); + }); + }); + + // ------------------------------------------------------------------------- + // 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); + }); + }); + + // ------------------------------------------------------------------------- + // 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( + persistence.writeBlob.mock.calls[0][0].toString('utf8') + ); + 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( + persistence.writeBlob.mock.calls[0][0].toString('utf8') + ); + expect(writtenJson.entries[otherKey]).toBeDefined(); + expect(writtenJson.entries[SAMPLE_KEY]).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // 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(); + }); + }); + + // ------------------------------------------------------------------------- + // LRU eviction + // ------------------------------------------------------------------------- + + describe('LRU eviction (_enforceMaxEntries)', () => { + it('does not evict when under maxEntries', () => { + const smallAdapter = new CasSeekCacheAdapter({ + persistence, + plumbing, + 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 = /** @type {any} */ (smallAdapter)._enforceMaxEntries(index); + expect(Object.keys(result.entries)).toHaveLength(2); + }); + + it('evicts oldest entries when exceeding maxEntries', () => { + const smallAdapter = new CasSeekCacheAdapter({ + persistence, + plumbing, + 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 = /** @type {any} */ (smallAdapter)._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, + plumbing, + 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 = /** @type {any} */ (smallAdapter)._enforceMaxEntries(index); + expect(Object.keys(result.entries)).toHaveLength(3); + }); + + it('prefers lastAccessedAt over createdAt for LRU ordering', () => { + const smallAdapter = new CasSeekCacheAdapter({ + persistence, + plumbing, + graphName: GRAPH_NAME, + maxEntries: 2, + }); + + const index = { + schemaVersion: 1, + entries: { + // Oldest by creation but recently accessed + 'v1:t1-old-but-used': { + createdAt: '2025-01-01T00:00:00Z', + lastAccessedAt: '2025-01-10T00:00:00Z', + }, + // Newer by creation but never accessed since + 'v1:t2-new-unused': { + createdAt: '2025-01-05T00:00:00Z', + }, + // Newest by creation, not accessed + 'v1:t3-newest': { + createdAt: '2025-01-06T00:00:00Z', + }, + }, + }; + + const result = /** @type {any} */ (smallAdapter)._enforceMaxEntries(index); + const remaining = Object.keys(result.entries); + expect(remaining).toHaveLength(2); + // The old-but-recently-used entry should survive (LRU) + 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, + plumbing, + graphName: GRAPH_NAME, + maxEntries: 1, + }); + + const index = { + schemaVersion: 1, + entries: { + 'v1:t1-nodate': {}, + 'v1:t2-hasdate': { createdAt: '2025-06-01T00:00:00Z' }, + }, + }; + + const result = /** @type {any} */ (smallAdapter)._enforceMaxEntries(index); + expect(Object.keys(result.entries)).toHaveLength(1); + }); + + it('evicts via set() when maxEntries exceeded', async () => { + const tinyAdapter = new CasSeekCacheAdapter({ + persistence, + plumbing, + 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', Buffer.from('new')); + + const writtenJson = JSON.parse( + persistence.writeBlob.mock.calls[0][0].toString('utf8') + ); + expect(Object.keys(writtenJson.entries)).toHaveLength(1); + expect(writtenJson.entries['v1:t99-newhash']).toBeDefined(); + expect(writtenJson.entries['v1:t1-old']).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(Buffer.from('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( + Buffer.from(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('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(buf.toString('utf8'))).toEqual(index); + expect(persistence.updateRef).toHaveBeenCalledWith(EXPECTED_REF, 'written-oid'); + }); + }); +}); diff --git a/test/unit/infrastructure/adapters/ClockAdapter.test.js b/test/unit/infrastructure/adapters/ClockAdapter.test.js new file mode 100644 index 00000000..ed415c23 --- /dev/null +++ b/test/unit/infrastructure/adapters/ClockAdapter.test.js @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import ClockAdapter from '../../../../src/infrastructure/adapters/ClockAdapter.js'; +import ClockPort from '../../../../src/ports/ClockPort.js'; + +describe('ClockAdapter', () => { + describe('constructor', () => { + it('creates an instance', () => { + const clock = new ClockAdapter(); + expect(clock).toBeInstanceOf(ClockAdapter); + }); + + it('extends ClockPort', () => { + const clock = new ClockAdapter(); + expect(clock).toBeInstanceOf(ClockPort); + }); + + it('defaults to globalThis.performance', () => { + const clock = new ClockAdapter(); + const value = clock.now(); + expect(typeof value).toBe('number'); + expect(value).toBeGreaterThan(0); + }); + + it('accepts a custom performanceImpl', () => { + let called = false; + const fake = { now: () => { called = true; return 42; } }; + const clock = new ClockAdapter({ performanceImpl: fake }); + const value = clock.now(); + expect(called).toBe(true); + expect(value).toBe(42); + }); + }); + + describe('static factories', () => { + it('node() returns a ClockAdapter using perf_hooks', () => { + const clock = ClockAdapter.node(); + expect(clock).toBeInstanceOf(ClockAdapter); + const value = clock.now(); + expect(typeof value).toBe('number'); + expect(value).toBeGreaterThan(0); + }); + + it('global() returns a ClockAdapter using globalThis.performance', () => { + const clock = ClockAdapter.global(); + expect(clock).toBeInstanceOf(ClockAdapter); + const value = clock.now(); + expect(typeof value).toBe('number'); + expect(value).toBeGreaterThan(0); + }); + }); + + describe('now()', () => { + it('returns a number', () => { + const clock = new ClockAdapter(); + expect(typeof clock.now()).toBe('number'); + }); + + it('returns increasing values on successive calls', () => { + const clock = new ClockAdapter(); + const a = clock.now(); + const b = clock.now(); + expect(b).toBeGreaterThanOrEqual(a); + }); + + it('delegates to the injected performanceImpl', () => { + const values = [100, 200, 300]; + let idx = 0; + const fake = { now: () => values[idx++] }; + const clock = new ClockAdapter({ performanceImpl: fake }); + + expect(clock.now()).toBe(100); + expect(clock.now()).toBe(200); + expect(clock.now()).toBe(300); + }); + }); + + describe('timestamp()', () => { + it('returns a string', () => { + const clock = new ClockAdapter(); + expect(typeof clock.timestamp()).toBe('string'); + }); + + it('returns a valid ISO 8601 string', () => { + const clock = new ClockAdapter(); + const ts = clock.timestamp(); + // ISO strings end with 'Z' and can be parsed back to a valid Date + expect(ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + const parsed = new Date(ts); + expect(parsed.toISOString()).toBe(ts); + }); + + it('returns a timestamp close to the current time', () => { + const clock = new ClockAdapter(); + const before = Date.now(); + const ts = clock.timestamp(); + const after = Date.now(); + const parsed = new Date(ts).getTime(); + expect(parsed).toBeGreaterThanOrEqual(before); + expect(parsed).toBeLessThanOrEqual(after); + }); + }); +}); diff --git a/test/unit/infrastructure/adapters/ConsoleLogger.test.js b/test/unit/infrastructure/adapters/ConsoleLogger.test.js index 090bb551..69103066 100644 --- a/test/unit/infrastructure/adapters/ConsoleLogger.test.js +++ b/test/unit/infrastructure/adapters/ConsoleLogger.test.js @@ -3,8 +3,11 @@ import ConsoleLogger, { LogLevel } from '../../../../src/infrastructure/adapters import LoggerPort from '../../../../src/ports/LoggerPort.js'; describe('ConsoleLogger', () => { + /** @type {any} */ let consoleLogSpy; + /** @type {any} */ let consoleWarnSpy; + /** @type {any} */ let consoleErrorSpy; beforeEach(() => { @@ -49,7 +52,7 @@ describe('ConsoleLogger', () => { }); it('accepts string log level', () => { - const logger = new ConsoleLogger({ level: 'debug' }); + const logger = new ConsoleLogger({ level: /** @type {any} */ ('debug') }); logger.debug('test'); expect(consoleLogSpy).toHaveBeenCalled(); }); diff --git a/test/unit/infrastructure/adapters/DenoHttpAdapter.test.js b/test/unit/infrastructure/adapters/DenoHttpAdapter.test.js index f3ca59bc..b7cd9883 100644 --- a/test/unit/infrastructure/adapters/DenoHttpAdapter.test.js +++ b/test/unit/infrastructure/adapters/DenoHttpAdapter.test.js @@ -7,7 +7,9 @@ import HttpServerPort from '../../../../src/ports/HttpServerPort.js'; * and returns a controllable mock server object. */ function createMockDenoServe() { + /** @type {any} */ let capturedHandler = null; + /** @type {any} */ let capturedOptions = null; const mockServer = { @@ -29,15 +31,21 @@ function createMockDenoServe() { } describe('DenoHttpAdapter', () => { + /** @type {any} */ let originalDeno; + /** @type {any} */ let mockServe; + /** @type {any} */ let mockServer; + /** @type {any} */ let getCapturedHandler; + /** @type {any} */ let getCapturedOptions; + /** @type {any} */ let DenoHttpAdapter; beforeEach(async () => { - originalDeno = globalThis.Deno; + originalDeno = /** @type {any} */ (globalThis).Deno; const mock = createMockDenoServe(); mockServe = mock.serve; @@ -45,7 +53,7 @@ describe('DenoHttpAdapter', () => { getCapturedHandler = mock.getCapturedHandler; getCapturedOptions = mock.getCapturedOptions; - globalThis.Deno = { serve: mockServe }; + /** @type {any} */ (globalThis).Deno = { serve: mockServe }; // Dynamic import to pick up the globalThis.Deno we just set const mod = await import('../../../../src/infrastructure/adapters/DenoHttpAdapter.js'); @@ -54,9 +62,9 @@ describe('DenoHttpAdapter', () => { afterEach(() => { if (originalDeno === undefined) { - delete globalThis.Deno; + delete /** @type {any} */ (globalThis).Deno; } else { - globalThis.Deno = originalDeno; + /** @type {any} */ (globalThis).Deno = originalDeno; } }); @@ -132,7 +140,7 @@ describe('DenoHttpAdapter', () => { it('passes error to callback when Deno.serve throws', () => { const error = new Error('bind failed'); - globalThis.Deno.serve = vi.fn(() => { + /** @type {any} */ (globalThis).Deno.serve = vi.fn(() => { throw error; }); @@ -154,7 +162,7 @@ describe('DenoHttpAdapter', () => { it('throws when Deno.serve fails without callback', () => { const error = new Error('bind failed'); - globalThis.Deno.serve = vi.fn(() => { + /** @type {any} */ (globalThis).Deno.serve = vi.fn(() => { throw error; }); @@ -186,7 +194,7 @@ describe('DenoHttpAdapter', () => { const response = await denoHandler(request); expect(handler).toHaveBeenCalledTimes(1); - const arg = handler.mock.calls[0][0]; + const arg = /** @type {any} */ (handler).mock.calls[0][0]; expect(arg.method).toBe('POST'); expect(arg.url).toBe('/api/test?q=1'); expect(arg.headers['content-type']).toBe('text/plain'); @@ -214,7 +222,7 @@ describe('DenoHttpAdapter', () => { await denoHandler(request); - const arg = handler.mock.calls[0][0]; + const arg = /** @type {any} */ (handler).mock.calls[0][0]; expect(arg.body).toBeUndefined(); }); diff --git a/test/unit/infrastructure/adapters/GitGraphAdapter.commitNodeWithTree.test.js b/test/unit/infrastructure/adapters/GitGraphAdapter.commitNodeWithTree.test.js index 9f391a93..f6260743 100644 --- a/test/unit/infrastructure/adapters/GitGraphAdapter.commitNodeWithTree.test.js +++ b/test/unit/infrastructure/adapters/GitGraphAdapter.commitNodeWithTree.test.js @@ -3,7 +3,9 @@ import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAda describe('GitGraphAdapter', () => { describe('commitNodeWithTree()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { diff --git a/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.js b/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.js new file mode 100644 index 00000000..5762114c --- /dev/null +++ b/test/unit/infrastructure/adapters/GitGraphAdapter.coverage.test.js @@ -0,0 +1,400 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAdapter.js'; + +/** @type {any} */ +let mockPlumbing; +/** @type {any} */ +let adapter; + +beforeEach(() => { + mockPlumbing = { + emptyTree: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + execute: vi.fn(), + executeStream: vi.fn(), + }; + adapter = new GitGraphAdapter({ plumbing: mockPlumbing }); +}); + +describe('GitGraphAdapter coverage', () => { + // ── logNodes ──────────────────────────────────────────────────────── + + describe('logNodes()', () => { + it('calls git log with default limit and ref', async () => { + mockPlumbing.execute.mockResolvedValue('commit abc123\n'); + + const result = await adapter.logNodes({ ref: 'HEAD' }); + + expect(result).toBe('commit abc123\n'); + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['log', '-50', 'HEAD'], + }); + }); + + it('passes custom limit to git log', async () => { + mockPlumbing.execute.mockResolvedValue(''); + + await adapter.logNodes({ ref: 'main', limit: 10 }); + + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['log', '-10', 'main'], + }); + }); + + it('appends --format when format is provided', async () => { + mockPlumbing.execute.mockResolvedValue('abc123\n'); + + await adapter.logNodes({ ref: 'HEAD', limit: 5, format: '%H' }); + + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['log', '-5', '--format=%H', 'HEAD'], + }); + }); + + it('omits --format when format is not provided', async () => { + mockPlumbing.execute.mockResolvedValue(''); + + await adapter.logNodes({ ref: 'HEAD' }); + + const args = mockPlumbing.execute.mock.calls[0][0].args; + const hasFormat = /** @type {string[]} */ (args).some((a) => a.startsWith('--format=')); + expect(hasFormat).toBe(false); + }); + + it('validates ref before calling git', async () => { + await expect(adapter.logNodes({ ref: '--malicious' })) + .rejects.toThrow(/Invalid ref/); + + expect(mockPlumbing.execute).not.toHaveBeenCalled(); + }); + + it('rejects empty ref', async () => { + await expect(adapter.logNodes({ ref: '' })) + .rejects.toThrow(/non-empty string/); + }); + + it('validates limit must be a finite number', async () => { + await expect(adapter.logNodes({ ref: 'HEAD', limit: Infinity })) + .rejects.toThrow(/finite number/); + }); + + it('validates limit must be a positive integer', async () => { + await expect(adapter.logNodes({ ref: 'HEAD', limit: 0 })) + .rejects.toThrow(/positive integer/); + }); + + it('validates limit must be an integer', async () => { + await expect(adapter.logNodes({ ref: 'HEAD', limit: 1.5 })) + .rejects.toThrow(/integer/); + }); + + it('validates limit cannot exceed maximum', async () => { + await expect(adapter.logNodes({ ref: 'HEAD', limit: 10_000_001 })) + .rejects.toThrow(/too large/); + }); + + it('accepts limit at maximum boundary', async () => { + mockPlumbing.execute.mockResolvedValue(''); + + await adapter.logNodes({ ref: 'HEAD', limit: 10_000_000 }); + + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['log', '-10000000', 'HEAD'], + }); + }); + }); + + // ── readTree ──────────────────────────────────────────────────────── + + describe('readTree()', () => { + it('reads each blob content for entries in the tree', async () => { + const treeOid = 'aabb' + '0'.repeat(36); + // ls-tree output: NUL-separated records + mockPlumbing.execute.mockResolvedValue( + `100644 blob deadbeef01234567890123456789012345678901\tfile_a.json\0` + + `100644 blob cafebabe01234567890123456789012345678901\tfile_b.json\0` + ); + + const mockStream = { + collect: vi.fn(), + }; + // First call returns content for file_a, second for file_b + let callCount = 0; + mockPlumbing.executeStream.mockImplementation(async () => { + callCount += 1; + return { + collect: vi.fn().mockResolvedValue( + Buffer.from(callCount === 1 ? 'content_a' : 'content_b') + ), + }; + }); + + const result = await adapter.readTree(treeOid); + + expect(result['file_a.json']).toEqual(Buffer.from('content_a')); + expect(result['file_b.json']).toEqual(Buffer.from('content_b')); + expect(mockPlumbing.executeStream).toHaveBeenCalledTimes(2); + }); + + it('returns empty map for empty tree', async () => { + const treeOid = 'aabb' + '0'.repeat(36); + mockPlumbing.execute.mockResolvedValue(''); + + const result = await adapter.readTree(treeOid); + + expect(result).toEqual({}); + expect(mockPlumbing.executeStream).not.toHaveBeenCalled(); + }); + + it('validates tree OID', async () => { + await expect(adapter.readTree('invalid!oid')) + .rejects.toThrow(/Invalid OID format/); + }); + }); + + // ── readTreeOids ──────────────────────────────────────────────────── + + describe('readTreeOids()', () => { + it('parses NUL-separated ls-tree output into path-oid map', async () => { + const treeOid = 'aabb' + '0'.repeat(36); + mockPlumbing.execute.mockResolvedValue( + '100644 blob deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\tindex.json\0' + + '100644 blob cafebabecafebabecafebabecafebabecafebabe\tdata.json\0' + ); + + const result = await adapter.readTreeOids(treeOid); + + expect(result).toEqual({ + 'index.json': 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + 'data.json': 'cafebabecafebabecafebabecafebabecafebabe', + }); + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['ls-tree', '-r', '-z', treeOid], + }); + }); + + it('returns empty map when tree has no entries', async () => { + const treeOid = 'aabb' + '0'.repeat(36); + mockPlumbing.execute.mockResolvedValue(''); + + const result = await adapter.readTreeOids(treeOid); + + expect(result).toEqual({}); + }); + + it('skips records without a tab separator', async () => { + const treeOid = 'aabb' + '0'.repeat(36); + mockPlumbing.execute.mockResolvedValue( + '100644 blob deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\tvalid.json\0' + + 'malformed-no-tab\0' + ); + + const result = await adapter.readTreeOids(treeOid); + + expect(Object.keys(result)).toEqual(['valid.json']); + }); + + it('handles single entry with trailing NUL', async () => { + const treeOid = 'aabb' + '0'.repeat(36); + mockPlumbing.execute.mockResolvedValue( + '100644 blob abcdef1234567890abcdef1234567890abcdef12\tonly.json\0' + ); + + const result = await adapter.readTreeOids(treeOid); + + expect(result).toEqual({ + 'only.json': 'abcdef1234567890abcdef1234567890abcdef12', + }); + }); + + it('validates tree OID', async () => { + await expect(adapter.readTreeOids('bad!')) + .rejects.toThrow(/Invalid OID format/); + + expect(mockPlumbing.execute).not.toHaveBeenCalled(); + }); + + it('rejects empty OID', async () => { + await expect(adapter.readTreeOids('')) + .rejects.toThrow(/non-empty string/); + }); + }); + + // ── deleteRef ─────────────────────────────────────────────────────── + + describe('deleteRef()', () => { + it('calls update-ref -d with the ref', async () => { + mockPlumbing.execute.mockResolvedValue(''); + + await adapter.deleteRef('refs/warp/events/writers/alice'); + + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['update-ref', '-d', 'refs/warp/events/writers/alice'], + }); + }); + + it('validates ref before calling git', async () => { + await expect(adapter.deleteRef('--malicious')) + .rejects.toThrow(/Invalid ref/); + + expect(mockPlumbing.execute).not.toHaveBeenCalled(); + }); + + it('rejects empty ref', async () => { + await expect(adapter.deleteRef('')) + .rejects.toThrow(/non-empty string/); + }); + + it('rejects ref with invalid characters', async () => { + await expect(adapter.deleteRef('refs/warp;rm -rf /')) + .rejects.toThrow(/Invalid ref format/); + }); + + it('propagates git errors', async () => { + mockPlumbing.execute.mockRejectedValue(new Error('permission denied')); + + await expect(adapter.deleteRef('refs/warp/test')) + .rejects.toThrow('permission denied'); + }); + }); + + // ── ping ──────────────────────────────────────────────────────────── + + describe('ping()', () => { + it('returns ok:true with latency on success', async () => { + mockPlumbing.execute.mockResolvedValue('true\n'); + + const result = await adapter.ping(); + + expect(result.ok).toBe(true); + expect(typeof result.latencyMs).toBe('number'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['rev-parse', '--is-inside-work-tree'], + }); + }); + + it('returns ok:false with latency on error', async () => { + mockPlumbing.execute.mockRejectedValue(new Error('not a git repository')); + + const result = await adapter.ping(); + + expect(result.ok).toBe(false); + expect(typeof result.latencyMs).toBe('number'); + expect(result.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it('does not throw on error', async () => { + mockPlumbing.execute.mockRejectedValue(new Error('fatal')); + + await expect(adapter.ping()).resolves.toBeDefined(); + }); + }); + + // ── isAncestor ────────────────────────────────────────────────────── + + describe('isAncestor()', () => { + it('returns true when ancestor relationship exists (exit 0)', async () => { + mockPlumbing.execute.mockResolvedValue(''); + + const ancestorOid = 'aaaa' + '0'.repeat(36); + const descendantOid = 'bbbb' + '0'.repeat(36); + + const result = await adapter.isAncestor(ancestorOid, descendantOid); + + expect(result).toBe(true); + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['merge-base', '--is-ancestor', ancestorOid, descendantOid], + }); + }); + + it('returns false when not an ancestor (exit code 1)', async () => { + /** @type {any} */ + const err = new Error('not ancestor'); + err.details = { code: 1 }; + mockPlumbing.execute.mockRejectedValue(err); + + const ancestorOid = 'aaaa' + '0'.repeat(36); + const descendantOid = 'bbbb' + '0'.repeat(36); + + const result = await adapter.isAncestor(ancestorOid, descendantOid); + + expect(result).toBe(false); + }); + + it('returns false when exit code 1 via exitCode property', async () => { + /** @type {any} */ + const err = new Error('not ancestor'); + err.exitCode = 1; + mockPlumbing.execute.mockRejectedValue(err); + + const ancestorOid = 'aaaa' + '0'.repeat(36); + const descendantOid = 'bbbb' + '0'.repeat(36); + + const result = await adapter.isAncestor(ancestorOid, descendantOid); + + expect(result).toBe(false); + }); + + it('returns false when exit code 1 via code property', async () => { + /** @type {any} */ + const err = new Error('not ancestor'); + err.code = 1; + mockPlumbing.execute.mockRejectedValue(err); + + const ancestorOid = 'aaaa' + '0'.repeat(36); + const descendantOid = 'bbbb' + '0'.repeat(36); + + const result = await adapter.isAncestor(ancestorOid, descendantOid); + + expect(result).toBe(false); + }); + + it('re-throws unexpected errors (non exit-code-1)', async () => { + /** @type {any} */ + const err = new Error('repository corrupt'); + err.details = { code: 128 }; + mockPlumbing.execute.mockRejectedValue(err); + + const ancestorOid = 'aaaa' + '0'.repeat(36); + const descendantOid = 'bbbb' + '0'.repeat(36); + + await expect(adapter.isAncestor(ancestorOid, descendantOid)) + .rejects.toThrow('repository corrupt'); + }); + + it('re-throws errors with no exit code', async () => { + const err = new Error('unexpected failure'); + mockPlumbing.execute.mockRejectedValue(err); + + const ancestorOid = 'aaaa' + '0'.repeat(36); + const descendantOid = 'bbbb' + '0'.repeat(36); + + await expect(adapter.isAncestor(ancestorOid, descendantOid)) + .rejects.toThrow('unexpected failure'); + }); + + it('validates potentialAncestor OID', async () => { + await expect(adapter.isAncestor('bad!', 'aaaa' + '0'.repeat(36))) + .rejects.toThrow(/Invalid OID format/); + + expect(mockPlumbing.execute).not.toHaveBeenCalled(); + }); + + it('validates descendant OID', async () => { + await expect(adapter.isAncestor('aaaa' + '0'.repeat(36), 'bad!')) + .rejects.toThrow(/Invalid OID format/); + + expect(mockPlumbing.execute).not.toHaveBeenCalled(); + }); + + it('rejects empty ancestor OID', async () => { + await expect(adapter.isAncestor('', 'aaaa' + '0'.repeat(36))) + .rejects.toThrow(/non-empty string/); + }); + + it('rejects empty descendant OID', async () => { + await expect(adapter.isAncestor('aaaa' + '0'.repeat(36), '')) + .rejects.toThrow(/non-empty string/); + }); + }); +}); diff --git a/test/unit/infrastructure/adapters/GitGraphAdapter.listRefs.test.js b/test/unit/infrastructure/adapters/GitGraphAdapter.listRefs.test.js index 97e8d306..3401b1fc 100644 --- a/test/unit/infrastructure/adapters/GitGraphAdapter.listRefs.test.js +++ b/test/unit/infrastructure/adapters/GitGraphAdapter.listRefs.test.js @@ -3,7 +3,9 @@ import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAda describe('GitGraphAdapter', () => { describe('listRefs()', () => { + /** @type {any} */ let mockPlumbing; + /** @type {any} */ let adapter; beforeEach(() => { diff --git a/test/unit/infrastructure/adapters/NoOpLogger.test.js b/test/unit/infrastructure/adapters/NoOpLogger.test.js index 4daf8e6b..6a2d2b7a 100644 --- a/test/unit/infrastructure/adapters/NoOpLogger.test.js +++ b/test/unit/infrastructure/adapters/NoOpLogger.test.js @@ -81,6 +81,7 @@ describe('NoOpLogger', () => { describe('zero overhead', () => { it('handles large context objects without performance issues (sanity check)', () => { const logger = new NoOpLogger(); + /** @type {Record} */ const largeContext = {}; for (let i = 0; i < 1000; i++) { largeContext[`key${i}`] = `value${i}`; diff --git a/test/unit/infrastructure/adapters/NodeHttpAdapter.error.test.js b/test/unit/infrastructure/adapters/NodeHttpAdapter.error.test.js new file mode 100644 index 00000000..d30e5f9f --- /dev/null +++ b/test/unit/infrastructure/adapters/NodeHttpAdapter.error.test.js @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import NodeHttpAdapter from '../../../../src/infrastructure/adapters/NodeHttpAdapter.js'; +import HttpServerPort from '../../../../src/ports/HttpServerPort.js'; + +describe('NodeHttpAdapter error paths', () => { + /** @type {any[]} */ + const servers = []; + + afterEach(async () => { + // Close all servers opened during the test + for (const s of servers) { + await new Promise((resolve) => { + s.close(resolve); + }); + } + servers.length = 0; + }); + + /** + * Helper: starts a server with the given handler on a random port + * and returns the base URL. + * + * @param {any} handler + * @param {any} [options] + * @returns {Promise} + */ + async function startServer(handler, options = {}) { + const adapter = new NodeHttpAdapter(options); + const server = adapter.createServer(handler); + servers.push(server); + + await /** @type {Promise} */ (new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', (/** @type {any} */ err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + })); + + const addr = server.address(); + return `http://127.0.0.1:${addr.port}`; + } + + it('extends HttpServerPort', () => { + const adapter = new NodeHttpAdapter(); + expect(adapter).toBeInstanceOf(HttpServerPort); + }); + + it('returns 500 when handler throws', async () => { + const logger = { error: vi.fn() }; + const base = await startServer( + async () => { + throw new Error('handler boom'); + }, + { logger }, + ); + + const res = await fetch(`${base}/sync`, { + method: 'POST', + body: '{}', + }); + + expect(res.status).toBe(500); + const text = await res.text(); + expect(text).toBe('Internal Server Error'); + expect(logger.error).toHaveBeenCalled(); + expect(logger.error.mock.calls[0][0]).toContain('dispatch error'); + }); + + it('returns 500 with default noop logger when handler throws', async () => { + const base = await startServer(async () => { + throw new Error('silent boom'); + }); + + const res = await fetch(`${base}/test`, { method: 'GET' }); + + expect(res.status).toBe(500); + const text = await res.text(); + expect(text).toBe('Internal Server Error'); + }); + + it('returns 413 when request body exceeds 10MB limit', async () => { + const logger = { error: vi.fn() }; + const base = await startServer( + async () => ({ status: 200, body: 'ok' }), + { logger }, + ); + + // Send a body larger than 10MB + const bigBody = Buffer.alloc(11 * 1024 * 1024, 'x'); + try { + const res = await fetch(`${base}/upload`, { + method: 'POST', + body: bigBody, + }); + + // If the response arrives before the socket is torn down, assert 413 + expect(res.status).toBe(413); + const text = await res.text(); + expect(text).toBe('Payload Too Large'); + } catch (/** @type {any} */ err) { + // On some platforms / timing, the server resets the connection + // before fetch can read the response. + expect(err.cause?.code ?? err.code).toBe('ECONNRESET'); + } + }); + + it('handles successful request/response cycle', async () => { + const base = await startServer(async (/** @type {any} */ req) => ({ + status: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: req.method, url: req.url }), + })); + + const res = await fetch(`${base}/info`); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.method).toBe('GET'); + expect(json.url).toBe('/info'); + }); + + it('listen callback receives error when port is in use', async () => { + const adapter1 = new NodeHttpAdapter(); + const server1 = adapter1.createServer(async () => ({ + status: 200, + body: 'ok', + })); + servers.push(server1); + + // Bind to a random port + await /** @type {Promise} */ (new Promise((resolve, reject) => { + server1.listen(0, '127.0.0.1', (/** @type {any} */ err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + })); + + const port = server1.address().port; + + // Try to bind a second server to the same port + const adapter2 = new NodeHttpAdapter(); + const server2 = adapter2.createServer(async () => ({ + status: 200, + body: 'ok', + })); + servers.push(server2); + + const err = await new Promise((resolve) => { + server2.listen(port, '127.0.0.1', (/** @type {any} */ listenErr) => { + resolve(listenErr); + }); + }); + + expect(err).toBeInstanceOf(Error); + expect(/** @type {any} */ (err).code).toBe('EADDRINUSE'); + }); + + it('listen accepts host as callback (2-arg form)', async () => { + const adapter = new NodeHttpAdapter(); + const server = adapter.createServer(async () => ({ + status: 200, + body: 'ok', + })); + servers.push(server); + + // listen(port, callback) — host argument is a function + await /** @type {Promise} */ (new Promise((resolve, reject) => { + server.listen(0, (/** @type {any} */ err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + })); + + const addr = server.address(); + expect(addr.port).toBeGreaterThan(0); + }); +}); diff --git a/test/unit/infrastructure/codecs/CborCodec.test.js b/test/unit/infrastructure/codecs/CborCodec.test.js index a8b74f4a..db88c27a 100644 --- a/test/unit/infrastructure/codecs/CborCodec.test.js +++ b/test/unit/infrastructure/codecs/CborCodec.test.js @@ -13,7 +13,7 @@ describe('CborCodec', () => { const result = encode({ z: 1, a: 2 }); // Decode to verify key order is preserved after encoding - const decoded = decode(result); + const decoded = /** @type {Record} */ (decode(result)); const keys = Object.keys(decoded); // Object.keys on decoded should show sorted order @@ -77,7 +77,7 @@ describe('CborCodec', () => { it('preserves arrays of objects with correct key order', () => { const original = [{ z: 1, a: 2 }, { b: 3, c: 4 }]; - const result = decode(encode(original)); + const result = /** @type {any[]} */ (decode(encode(original))); expect(result).toEqual(original); expect(Object.keys(result[0])).toEqual(['a', 'z']); }); @@ -108,7 +108,7 @@ describe('CborCodec', () => { big: BigInt('9223372036854775807'), negative: BigInt('-9223372036854775808'), }; - const result = decode(encode(original)); + const result = /** @type {any} */ (decode(encode(original))); expect(result.big).toBe(original.big); expect(result.negative).toBe(original.negative); }); @@ -119,7 +119,7 @@ describe('CborCodec', () => { e: 2.71828182845, negative: -123.456, }; - const result = decode(encode(original)); + const result = /** @type {any} */ (decode(encode(original))); expect(result.pi).toBeCloseTo(original.pi, 10); expect(result.e).toBeCloseTo(original.e, 10); expect(result.negative).toBeCloseTo(original.negative, 10); @@ -228,7 +228,7 @@ describe('CborCodec', () => { }, }; - const result = decode(encode(nested)); + const result = /** @type {any} */ (decode(encode(nested))); // Verify top-level keys are sorted expect(Object.keys(result)).toEqual(['a', 'z']); @@ -248,7 +248,7 @@ describe('CborCodec', () => { ], }; - const result = decode(encode(mixed)); + const result = /** @type {any} */ (decode(encode(mixed))); expect(Object.keys(result.items[0])).toEqual(['a', 'z']); expect(Object.keys(result.items[1])).toEqual(['b', 'c']); @@ -273,7 +273,7 @@ describe('CborCodec', () => { }, }; - const result = decode(encode(deep)); + const result = /** @type {any} */ (decode(encode(deep))); const deepKeys = Object.keys(result.l1.l2.l3.l4.l5); expect(deepKeys).toEqual(['a', 'value', 'z']); }); @@ -287,6 +287,7 @@ describe('CborCodec', () => { }); it('handles empty arrays', () => { + /** @type {any[]} */ const original = []; const result = decode(encode(original)); expect(result).toEqual([]); @@ -334,14 +335,14 @@ describe('CborCodec', () => { it('handles objects with unicode keys', () => { const obj = { alpha: 1, beta: 2, gamma: 3 }; - const result = decode(encode(obj)); + const result = /** @type {any} */ (decode(encode(obj))); expect(Object.keys(result)).toEqual(['alpha', 'beta', 'gamma']); }); it('handles Buffer/Uint8Array values', () => { const buffer = Buffer.from([1, 2, 3, 4]); const obj = { data: buffer }; - const result = decode(encode(obj)); + const result = /** @type {any} */ (decode(encode(obj))); expect(result.data).toBeInstanceOf(Uint8Array); expect(Buffer.from(result.data).equals(buffer)).toBe(true); diff --git a/test/unit/ports/ClockPort.test.js b/test/unit/ports/ClockPort.test.js new file mode 100644 index 00000000..99980d1f --- /dev/null +++ b/test/unit/ports/ClockPort.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import ClockPort from '../../../src/ports/ClockPort.js'; + +describe('ClockPort', () => { + describe('abstract methods', () => { + it('now() throws Not implemented', () => { + const port = new ClockPort(); + expect(() => port.now()).toThrow('Not implemented'); + }); + + it('timestamp() throws Not implemented', () => { + const port = new ClockPort(); + expect(() => port.timestamp()).toThrow('Not implemented'); + }); + }); + + describe('contract', () => { + it('can be instantiated', () => { + const port = new ClockPort(); + expect(port).toBeInstanceOf(ClockPort); + }); + }); +}); diff --git a/test/unit/ports/GraphPersistencePort.test.js b/test/unit/ports/GraphPersistencePort.test.js index 489bd5e6..56b0350a 100644 --- a/test/unit/ports/GraphPersistencePort.test.js +++ b/test/unit/ports/GraphPersistencePort.test.js @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest'; -import GraphPersistencePort from '../../../src/ports/GraphPersistencePort.js'; -import IndexStoragePort from '../../../src/ports/IndexStoragePort.js'; +import GraphPersistencePort_ from '../../../src/ports/GraphPersistencePort.js'; +import IndexStoragePort_ from '../../../src/ports/IndexStoragePort.js'; + +/** @type {any} */ const GraphPersistencePort = GraphPersistencePort_; +/** @type {any} */ const IndexStoragePort = IndexStoragePort_; describe('GraphPersistencePort (composite mixin)', () => { const expectedMethods = [ diff --git a/test/unit/ports/HttpServerPort.test.js b/test/unit/ports/HttpServerPort.test.js index 6b5b6fc9..c8ae4dd0 100644 --- a/test/unit/ports/HttpServerPort.test.js +++ b/test/unit/ports/HttpServerPort.test.js @@ -1,7 +1,10 @@ import { describe, it, expect, afterEach } from 'vitest'; -import HttpServerPort from '../../../src/ports/HttpServerPort.js'; +import HttpServerPort_ from '../../../src/ports/HttpServerPort.js'; import NodeHttpAdapter from '../../../src/infrastructure/adapters/NodeHttpAdapter.js'; +/** @type {any} */ +const HttpServerPort = HttpServerPort_; + describe('HttpServerPort', () => { it('throws on direct call to createServer()', () => { const port = new HttpServerPort(); @@ -11,6 +14,7 @@ describe('HttpServerPort', () => { describe('NodeHttpAdapter', () => { const adapter = new NodeHttpAdapter(); + /** @type {any} */ let server; afterEach(() => { @@ -35,7 +39,7 @@ describe('NodeHttpAdapter', () => { }); it('handles a basic request/response cycle', async () => { - server = adapter.createServer(async (req) => ({ + server = adapter.createServer(async (/** @type {any} */ req) => ({ status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ method: req.method, url: req.url }), diff --git a/test/unit/ports/SeekCachePort.test.js b/test/unit/ports/SeekCachePort.test.js new file mode 100644 index 00000000..a5a19bc2 --- /dev/null +++ b/test/unit/ports/SeekCachePort.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import SeekCachePort from '../../../src/ports/SeekCachePort.js'; + +describe('SeekCachePort', () => { + describe('abstract methods', () => { + it('get() throws SeekCachePort.get() not implemented', async () => { + const port = new SeekCachePort(); + await expect(port.get('key')).rejects.toThrow( + 'SeekCachePort.get() not implemented', + ); + }); + + it('set() throws SeekCachePort.set() not implemented', async () => { + const port = new SeekCachePort(); + await expect(port.set('key', Buffer.from('data'))).rejects.toThrow( + 'SeekCachePort.set() not implemented', + ); + }); + + it('has() throws SeekCachePort.has() not implemented', async () => { + const port = new SeekCachePort(); + await expect(port.has('key')).rejects.toThrow( + 'SeekCachePort.has() not implemented', + ); + }); + + it('keys() throws SeekCachePort.keys() not implemented', async () => { + const port = new SeekCachePort(); + await expect(port.keys()).rejects.toThrow( + 'SeekCachePort.keys() not implemented', + ); + }); + + it('delete() throws SeekCachePort.delete() not implemented', async () => { + const port = new SeekCachePort(); + await expect(port.delete('key')).rejects.toThrow( + 'SeekCachePort.delete() not implemented', + ); + }); + + it('clear() throws SeekCachePort.clear() not implemented', async () => { + const port = new SeekCachePort(); + await expect(port.clear()).rejects.toThrow( + 'SeekCachePort.clear() not implemented', + ); + }); + }); + + describe('contract', () => { + it('can be instantiated', () => { + const port = new SeekCachePort(); + expect(port).toBeInstanceOf(SeekCachePort); + }); + }); +}); diff --git a/test/unit/v7-guards.test.js b/test/unit/v7-guards.test.js index 084496b7..2645d3ef 100644 --- a/test/unit/v7-guards.test.js +++ b/test/unit/v7-guards.test.js @@ -87,24 +87,24 @@ describe('V7 Contract Guards', () => { const exportTestFn = SCHEMA1_EXTERMINATION_COMPLETE ? it : it.skip; exportTestFn('should not export PatchBuilder (schema:1)', async () => { - const indexModule = await import('../../index.js'); + const indexModule = /** @type {any} */ (await import('../../index.js')); expect(indexModule.PatchBuilder).toBeUndefined(); }); exportTestFn('should not export Reducer (schema:1)', async () => { - const indexModule = await import('../../index.js'); + const indexModule = /** @type {any} */ (await import('../../index.js')); expect(indexModule.Reducer).toBeUndefined(); }); exportTestFn('should not export createPatch with schema:1 support', async () => { - const indexModule = await import('../../index.js'); + const indexModule = /** @type {any} */ (await import('../../index.js')); // If createPatch exists, it should only support schema:2 // This is tested elsewhere; here we just ensure no explicit schema:1 export expect(indexModule.createPatchV1).toBeUndefined(); }); exportTestFn('should not export StateSerializer (schema:1)', async () => { - const indexModule = await import('../../index.js'); + const indexModule = /** @type {any} */ (await import('../../index.js')); expect(indexModule.StateSerializer).toBeUndefined(); }); }); diff --git a/test/unit/visualization/ascii-graph-edge-label.test.js b/test/unit/visualization/ascii-graph-edge-label.test.js new file mode 100644 index 00000000..4fb56d77 --- /dev/null +++ b/test/unit/visualization/ascii-graph-edge-label.test.js @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { renderGraphView } from '../../../src/visualization/renderers/ascii/graph.js'; +import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; + +describe('ASCII graph renderer edge labels', () => { + it('renders edge label at midpoint of the edge path', () => { + // Two nodes side by side with a horizontal edge that has a label. + // The edge path runs horizontally between them with enough distance + // for the label to be placed at the midpoint. + const positionedGraph = { + nodes: [ + { id: 'a', x: 0, y: 0, width: 80, height: 40, label: 'Left' }, + { id: 'b', x: 300, y: 0, width: 80, height: 40, label: 'Right' }, + ], + edges: [ + { + id: 'e0', + source: 'a', + target: 'b', + label: 'knows', + sections: [ + { + startPoint: { x: 80, y: 20 }, + endPoint: { x: 300, y: 20 }, + }, + ], + }, + ], + width: 420, + height: 80, + }; + + const output = stripAnsi(renderGraphView(positionedGraph)); + + expect(output).toContain('Left'); + expect(output).toContain('Right'); + expect(output).toContain('knows'); + }); + + it('renders edge label on a vertical edge path', () => { + // Two nodes stacked vertically with a vertical edge. + const positionedGraph = { + nodes: [ + { id: 'top', x: 0, y: 0, width: 80, height: 40, label: 'Top' }, + { id: 'bot', x: 0, y: 300, width: 80, height: 40, label: 'Bot' }, + ], + edges: [ + { + id: 'e0', + source: 'top', + target: 'bot', + label: 'link', + sections: [ + { + startPoint: { x: 40, y: 40 }, + endPoint: { x: 40, y: 300 }, + }, + ], + }, + ], + width: 120, + height: 380, + }; + + const output = stripAnsi(renderGraphView(positionedGraph)); + + expect(output).toContain('Top'); + expect(output).toContain('Bot'); + expect(output).toContain('link'); + }); + + it('truncates edge labels longer than 10 characters', () => { + const positionedGraph = { + nodes: [ + { id: 'a', x: 0, y: 0, width: 80, height: 40, label: 'A' }, + { id: 'b', x: 300, y: 0, width: 80, height: 40, label: 'B' }, + ], + edges: [ + { + id: 'e0', + source: 'a', + target: 'b', + label: 'a-very-long-edge-label', + sections: [ + { + startPoint: { x: 80, y: 20 }, + endPoint: { x: 300, y: 20 }, + }, + ], + }, + ], + width: 420, + height: 80, + }; + + const output = stripAnsi(renderGraphView(positionedGraph)); + + // Label should be truncated to 9 chars + ellipsis + expect(output).toContain('a-very-lo\u2026'); + expect(output).not.toContain('a-very-long-edge-label'); + }); + + it('does not render label when edge has no label', () => { + const positionedGraph = { + nodes: [ + { id: 'a', x: 0, y: 0, width: 80, height: 40, label: 'A' }, + { id: 'b', x: 300, y: 0, width: 80, height: 40, label: 'B' }, + ], + edges: [ + { + id: 'e0', + source: 'a', + target: 'b', + label: undefined, + sections: [ + { + startPoint: { x: 80, y: 20 }, + endPoint: { x: 300, y: 20 }, + }, + ], + }, + ], + width: 420, + height: 80, + }; + + const output = stripAnsi(renderGraphView(positionedGraph)); + + // Should still render the nodes but no stray label text + expect(output).toContain('A'); + expect(output).toContain('B'); + }); + + it('renders label on edge with bend points', () => { + const positionedGraph = { + nodes: [ + { id: 'a', x: 0, y: 0, width: 80, height: 40, label: 'Src' }, + { id: 'b', x: 200, y: 200, width: 80, height: 40, label: 'Dst' }, + ], + edges: [ + { + id: 'e0', + source: 'a', + target: 'b', + label: 'path', + sections: [ + { + startPoint: { x: 40, y: 40 }, + bendPoints: [ + { x: 40, y: 120 }, + { x: 240, y: 120 }, + ], + endPoint: { x: 240, y: 200 }, + }, + ], + }, + ], + width: 320, + height: 280, + }; + + const output = stripAnsi(renderGraphView(positionedGraph)); + + expect(output).toContain('Src'); + expect(output).toContain('Dst'); + expect(output).toContain('path'); + }); +}); diff --git a/test/unit/visualization/ascii-op-summary.test.js b/test/unit/visualization/ascii-op-summary.test.js index b7400a85..7fe50522 100644 --- a/test/unit/visualization/ascii-op-summary.test.js +++ b/test/unit/visualization/ascii-op-summary.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { summarizeOps, formatOpSummary, EMPTY_OP_SUMMARY } from '../../../src/visualization/renderers/ascii/opSummary.js'; +import { summarizeOps as _summarizeOps, formatOpSummary, EMPTY_OP_SUMMARY } from '../../../src/visualization/renderers/ascii/opSummary.js'; + +/** @type {any} */ +const summarizeOps = _summarizeOps; import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; describe('opSummary utilities', () => { diff --git a/test/unit/visualization/ascii-renderers.test.js b/test/unit/visualization/ascii-renderers.test.js index 69baa13d..fa5c89d2 100644 --- a/test/unit/visualization/ascii-renderers.test.js +++ b/test/unit/visualization/ascii-renderers.test.js @@ -7,13 +7,19 @@ */ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; -import { renderInfoView } from '../../../src/visualization/renderers/ascii/info.js'; -import { renderCheckView } from '../../../src/visualization/renderers/ascii/check.js'; -import { renderMaterializeView } from '../../../src/visualization/renderers/ascii/materialize.js'; -import { renderHistoryView, summarizeOps } from '../../../src/visualization/renderers/ascii/history.js'; -import { renderPathView } from '../../../src/visualization/renderers/ascii/path.js'; +import { renderInfoView as _renderInfoView } from '../../../src/visualization/renderers/ascii/info.js'; +import { renderCheckView as _renderCheckView } from '../../../src/visualization/renderers/ascii/check.js'; +import { renderMaterializeView as _renderMaterializeView } from '../../../src/visualization/renderers/ascii/materialize.js'; +import { renderHistoryView as _renderHistoryView, summarizeOps } from '../../../src/visualization/renderers/ascii/history.js'; +import { renderPathView as _renderPathView } from '../../../src/visualization/renderers/ascii/path.js'; import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; +/** @type {any} */ const renderInfoView = _renderInfoView; +/** @type {any} */ const renderCheckView = _renderCheckView; +/** @type {any} */ const renderMaterializeView = _renderMaterializeView; +/** @type {any} */ const renderHistoryView = _renderHistoryView; +/** @type {any} */ const renderPathView = _renderPathView; + // Mock Date.now for stable time-based output const FIXED_NOW = new Date('2025-01-15T12:00:00Z').getTime(); diff --git a/test/unit/visualization/ascii-shared.test.js b/test/unit/visualization/ascii-shared.test.js index 91c79920..74bfd48b 100644 --- a/test/unit/visualization/ascii-shared.test.js +++ b/test/unit/visualization/ascii-shared.test.js @@ -6,10 +6,15 @@ */ import { describe, it, expect } from 'vitest'; -import { formatAge, formatNumber, formatSha, formatWriterName } from '../../../src/visualization/renderers/ascii/formatters.js'; +import { formatAge as _formatAge, formatNumber as _formatNumber, formatSha as _formatSha, formatWriterName as _formatWriterName } from '../../../src/visualization/renderers/ascii/formatters.js'; import { TIMELINE, ARROW, TREE } from '../../../src/visualization/renderers/ascii/symbols.js'; import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; +/** @type {any} */ const formatAge = _formatAge; +/** @type {any} */ const formatNumber = _formatNumber; +/** @type {any} */ const formatSha = _formatSha; +/** @type {any} */ const formatWriterName = _formatWriterName; + describe('formatters', () => { describe('formatNumber', () => { it('formats integers with locale separators', () => { diff --git a/test/unit/visualization/ascii-table.test.js b/test/unit/visualization/ascii-table.test.js new file mode 100644 index 00000000..477b908b --- /dev/null +++ b/test/unit/visualization/ascii-table.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { createTable as _createTable } from '../../../src/visualization/renderers/ascii/table.js'; + +/** @type {any} */ +const createTable = _createTable; + +describe('createTable', () => { + it('returns an object with push and toString methods', () => { + const table = createTable(); + + expect(typeof table.push).toBe('function'); + expect(typeof table.toString).toBe('function'); + }); + + it('applies default style (head: cyan, border: gray)', () => { + const table = createTable(); + + expect(table.options.style.head).toEqual(['cyan']); + expect(table.options.style.border).toEqual(['gray']); + }); + + it('custom style overrides defaults', () => { + const table = createTable({ + style: { head: ['green'], border: ['white'] }, + }); + + expect(table.options.style.head).toEqual(['green']); + expect(table.options.style.border).toEqual(['white']); + }); + + it('accepts head option for column headers', () => { + const table = createTable({ head: ['Name', 'Value'] }); + + expect(table.options.head).toEqual(['Name', 'Value']); + }); + + it('can be rendered with toString()', () => { + const table = createTable({ head: ['ID', 'Label'] }); + table.push(['n1', 'Alice']); + table.push(['n2', 'Bob']); + + const output = table.toString(); + + expect(typeof output).toBe('string'); + expect(output).toContain('Alice'); + expect(output).toContain('Bob'); + expect(output).toContain('ID'); + expect(output).toContain('Label'); + }); + + it('renders empty table without errors', () => { + const table = createTable({ head: ['Col'] }); + const output = table.toString(); + + expect(typeof output).toBe('string'); + expect(output).toContain('Col'); + }); + + it('preserves non-style options', () => { + const table = createTable({ colWidths: [20, 30] }); + + expect(table.options.colWidths).toEqual([20, 30]); + }); + + it('partial style override merges with defaults', () => { + const table = createTable({ style: { head: ['red'] } }); + + // head is overridden, border keeps default + expect(table.options.style.head).toEqual(['red']); + expect(table.options.style.border).toEqual(['gray']); + }); +}); diff --git a/test/unit/visualization/browser-placeholder.test.js b/test/unit/visualization/browser-placeholder.test.js new file mode 100644 index 00000000..e5e165e3 --- /dev/null +++ b/test/unit/visualization/browser-placeholder.test.js @@ -0,0 +1,8 @@ +import { describe, it } from 'vitest'; + +describe('browser renderer placeholder', () => { + it('is a placeholder module (M5)', async () => { + // Importing the module is enough to cover the placeholder line. + await import(/** @type {any} */ ('../../../src/visualization/renderers/browser/index.js')); + }); +}); diff --git a/test/unit/visualization/elk-adapter.test.js b/test/unit/visualization/elk-adapter.test.js index 04576ef2..5b30d92c 100644 --- a/test/unit/visualization/elk-adapter.test.js +++ b/test/unit/visualization/elk-adapter.test.js @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { toElkGraph, getDefaultLayoutOptions } from '../../../src/visualization/layouts/elkAdapter.js'; +import { toElkGraph as _toElkGraph, getDefaultLayoutOptions as _getDefaultLayoutOptions } from '../../../src/visualization/layouts/elkAdapter.js'; + +/** @type {any} */ const toElkGraph = _toElkGraph; +/** @type {any} */ const getDefaultLayoutOptions = _getDefaultLayoutOptions; describe('elkAdapter', () => { describe('getDefaultLayoutOptions', () => { diff --git a/test/unit/visualization/elk-layout-fallback.test.js b/test/unit/visualization/elk-layout-fallback.test.js new file mode 100644 index 00000000..cd5c93cd --- /dev/null +++ b/test/unit/visualization/elk-layout-fallback.test.js @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock elkjs to throw on layout(), forcing the fallback path. +vi.mock('elkjs/lib/elk.bundled.js', () => ({ + default: class MockELK { + async layout() { + throw new Error('ELK failed'); + } + }, +})); + +const { runLayout } = await import( + '../../../src/visualization/layouts/elkLayout.js' +); + +describe('elkLayout fallbackLayout', () => { + it('falls back when ELK throws', async () => { + const elkGraph = { + id: 'root', + children: [ + { id: 'a', width: 80, height: 40, labels: [{ text: 'A' }] }, + ], + edges: [], + }; + + const result = await runLayout(elkGraph); + + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('edges'); + expect(result).toHaveProperty('width'); + expect(result).toHaveProperty('height'); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].id).toBe('a'); + }); + + it('positions nodes horizontally starting at x=20', async () => { + const elkGraph = { + id: 'root', + children: [ + { id: 'n1', width: 80, height: 40, labels: [{ text: 'N1' }] }, + { id: 'n2', width: 100, height: 50, labels: [{ text: 'N2' }] }, + ], + edges: [], + }; + + const result = await runLayout(elkGraph); + + expect(result.nodes[0].x).toBe(20); + expect(result.nodes[0].y).toBe(20); + // Second node: x = 20 + (80 + 40) = 140 + expect(result.nodes[1].x).toBe(140); + expect(result.nodes[1].y).toBe(20); + }); + + it('uses default width=80 and height=40 when not specified', async () => { + const elkGraph = { + id: 'root', + children: [{ id: 'x' }], + edges: [], + }; + + const result = await runLayout(elkGraph); + + expect(result.nodes[0].width).toBe(80); + expect(result.nodes[0].height).toBe(40); + }); + + it('uses node id as label when labels are missing', async () => { + const elkGraph = { + id: 'root', + children: [{ id: 'my-node' }], + edges: [], + }; + + const result = await runLayout(elkGraph); + + expect(result.nodes[0].label).toBe('my-node'); + }); + + it('edge sections are always empty arrays', async () => { + const elkGraph = { + id: 'root', + children: [ + { id: 'a', width: 80, height: 40 }, + { id: 'b', width: 80, height: 40 }, + ], + edges: [ + { + id: 'e0', + sources: ['a'], + targets: ['b'], + labels: [{ text: 'rel' }], + }, + ], + }; + + const result = await runLayout(elkGraph); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0].sections).toEqual([]); + expect(result.edges[0].source).toBe('a'); + expect(result.edges[0].target).toBe('b'); + expect(result.edges[0].label).toBe('rel'); + }); + + it('returns total accumulated width and height=80', async () => { + const elkGraph = { + id: 'root', + children: [ + { id: 'a', width: 80, height: 40 }, + { id: 'b', width: 100, height: 40 }, + ], + edges: [], + }; + + const result = await runLayout(elkGraph); + + // totalWidth = 20 (start) + (80 + 40) + (100 + 40) = 280 + expect(result.width).toBe(280); + expect(result.height).toBe(80); + }); + + it('handles empty children and edges', async () => { + const elkGraph = { id: 'root' }; + + const result = await runLayout(elkGraph); + + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + expect(result.width).toBe(20); + expect(result.height).toBe(80); + }); + + it('edge defaults source/target to empty string when missing', async () => { + const elkGraph = { + id: 'root', + children: [], + edges: [{ id: 'e0' }], + }; + + const result = await runLayout(elkGraph); + + expect(result.edges[0].source).toBe(''); + expect(result.edges[0].target).toBe(''); + expect(result.edges[0].label).toBeUndefined(); + }); +}); diff --git a/test/unit/visualization/elk-layout.test.js b/test/unit/visualization/elk-layout.test.js index 54d7f72e..d10e41d0 100644 --- a/test/unit/visualization/elk-layout.test.js +++ b/test/unit/visualization/elk-layout.test.js @@ -7,12 +7,12 @@ vi.mock('elkjs/lib/elk.bundled.js', () => { ...graph, width: 200, height: 100, - children: (graph.children ?? []).map((c, i) => ({ + children: (graph.children ?? []).map((/** @type {any} */ c, /** @type {any} */ i) => ({ ...c, x: i * 100, y: 20, })), - edges: (graph.edges ?? []).map((e) => ({ + edges: (graph.edges ?? []).map((/** @type {any} */ e) => ({ ...e, sections: [{ startPoint: { x: 0, y: 0 }, diff --git a/test/unit/visualization/exports-contract.test.js b/test/unit/visualization/exports-contract.test.js index 9e4441c1..11a494b0 100644 --- a/test/unit/visualization/exports-contract.test.js +++ b/test/unit/visualization/exports-contract.test.js @@ -6,7 +6,10 @@ */ import { describe, it, expect } from 'vitest'; -import * as viz from '../../../src/visualization/index.js'; +import * as _viz from '../../../src/visualization/index.js'; + +/** @type {any} */ +const viz = _viz; describe('visualization subpath export contract', () => { const expectedFunctions = [ diff --git a/test/unit/visualization/layout-converters.test.js b/test/unit/visualization/layout-converters.test.js index 5bc34049..863940c0 100644 --- a/test/unit/visualization/layout-converters.test.js +++ b/test/unit/visualization/layout-converters.test.js @@ -1,10 +1,13 @@ import { describe, it, expect } from 'vitest'; import { - queryResultToGraphData, - pathResultToGraphData, + queryResultToGraphData as _queryResultToGraphData, + pathResultToGraphData as _pathResultToGraphData, rawGraphToGraphData, } from '../../../src/visualization/layouts/converters.js'; +/** @type {any} */ const queryResultToGraphData = _queryResultToGraphData; +/** @type {any} */ const pathResultToGraphData = _pathResultToGraphData; + describe('layout converters', () => { describe('queryResultToGraphData', () => { it('converts payload and filters edges to matched nodes', () => { @@ -122,7 +125,7 @@ describe('layout converters', () => { }); it('handles null inputs', () => { - const result = rawGraphToGraphData(null, null); + const result = rawGraphToGraphData(/** @type {any} */ (null), /** @type {any} */ (null)); expect(result.nodes).toEqual([]); expect(result.edges).toEqual([]); }); diff --git a/test/unit/visualization/layout-graph.test.js b/test/unit/visualization/layout-graph.test.js new file mode 100644 index 00000000..749b08f9 --- /dev/null +++ b/test/unit/visualization/layout-graph.test.js @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock elkjs to avoid loading the real 2.5MB engine. +vi.mock('elkjs/lib/elk.bundled.js', () => { + const mockLayout = vi.fn(async (graph) => ({ + ...graph, + width: 300, + height: 150, + children: (graph.children ?? []).map((/** @type {any} */ c, /** @type {any} */ i) => ({ + ...c, + x: i * 120, + y: 10, + })), + edges: (graph.edges ?? []).map((/** @type {any} */ e) => ({ + ...e, + sections: [ + { + startPoint: { x: 0, y: 0 }, + endPoint: { x: 120, y: 0 }, + }, + ], + })), + })); + + return { + default: class ELK { + layout = mockLayout; + }, + }; +}); + +const { layoutGraph } = await import( + '../../../src/visualization/layouts/index.js' +); + +describe('layoutGraph', () => { + it('runs the full pipeline: graphData -> positionedGraph', async () => { + const graphData = { + nodes: [ + { id: 'user:alice', label: 'Alice' }, + { id: 'user:bob', label: 'Bob' }, + ], + edges: [{ from: 'user:alice', to: 'user:bob', label: 'knows' }], + }; + + const result = await layoutGraph(graphData); + + expect(result).toHaveProperty('nodes'); + expect(result).toHaveProperty('edges'); + expect(result).toHaveProperty('width'); + expect(result).toHaveProperty('height'); + }); + + it('returns a PositionedGraph with correct node count', async () => { + const graphData = { + nodes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ], + edges: [], + }; + + const result = await layoutGraph(graphData); + + expect(result.nodes).toHaveLength(3); + expect(result.nodes[0].id).toBe('a'); + expect(result.nodes[0].label).toBe('A'); + expect(typeof result.nodes[0].x).toBe('number'); + expect(typeof result.nodes[0].y).toBe('number'); + }); + + it('returns edges with sections from layout engine', async () => { + const graphData = { + nodes: [ + { id: 'src', label: 'Source' }, + { id: 'dst', label: 'Dest' }, + ], + edges: [{ from: 'src', to: 'dst', label: 'link' }], + }; + + const result = await layoutGraph(graphData); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0].source).toBe('src'); + expect(result.edges[0].target).toBe('dst'); + expect(result.edges[0].sections).toHaveLength(1); + }); + + it('accepts layout options', async () => { + const graphData = { + nodes: [{ id: 'x', label: 'X' }], + edges: [], + }; + + const result = await layoutGraph(graphData, { type: 'path' }); + + expect(result.nodes).toHaveLength(1); + }); + + it('handles empty graph data', async () => { + const result = await layoutGraph({ nodes: [], edges: [] }); + + expect(result.nodes).toEqual([]); + expect(result.edges).toEqual([]); + }); +}); diff --git a/test/unit/visualization/visualization-utils.test.js b/test/unit/visualization/visualization-utils.test.js index 5e88746c..93af9748 100644 --- a/test/unit/visualization/visualization-utils.test.js +++ b/test/unit/visualization/visualization-utils.test.js @@ -8,9 +8,12 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { truncate } from '../../../src/visualization/utils/truncate.js'; -import { timeAgo, formatDuration } from '../../../src/visualization/utils/time.js'; +import { timeAgo as _timeAgo, formatDuration as _formatDuration } from '../../../src/visualization/utils/time.js'; import { padRight, padLeft, center } from '../../../src/visualization/utils/unicode.js'; import { progressBar } from '../../../src/visualization/renderers/ascii/progress.js'; + +/** @type {any} */ const timeAgo = _timeAgo; +/** @type {any} */ const formatDuration = _formatDuration; import { stripAnsi } from '../../../src/visualization/utils/ansi.js'; // Fixed "now" for deterministic timeAgo tests diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..f5485cdf --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "allowJs": true, + "checkJs": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + } +} diff --git a/tsconfig.json b/tsconfig.json index de46fc42..a68af0f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,13 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "allowJs": true, - "checkJs": true, - "noEmit": true, - "strict": true, - "skipLibCheck": true, - "resolveJsonModule": true - }, + "extends": "./tsconfig.base.json", "include": [ "src/**/*.js", - "bin/**/*.js", + "bin/warp-graph.js", "scripts/**/*.js", - "test/**/*.js" + "test/**/*.js", + "types/**/*.d.ts" + ], + "exclude": [ + "node_modules" ] } diff --git a/tsconfig.src.json b/tsconfig.src.json new file mode 100644 index 00000000..ffc8c3fd --- /dev/null +++ b/tsconfig.src.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.base.json", + "include": [ + "src/**/*.js", + "bin/warp-graph.js", + "scripts/**/*.js", + "types/**/*.d.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..481e1984 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "include": [ + "test/**/*.js" + ], + "exclude": ["node_modules"] +} diff --git a/types/git-cas.d.ts b/types/git-cas.d.ts new file mode 100644 index 00000000..896b8cc2 --- /dev/null +++ b/types/git-cas.d.ts @@ -0,0 +1,20 @@ +/** + * Type stub for @git-stunts/git-cas. + * + * Provides just enough shape for CasSeekCacheAdapter to typecheck. + */ +declare module '@git-stunts/git-cas' { + interface CasStore { + put(key: string, value: Uint8Array): Promise; + get(key: string): Promise; + has(key: string): Promise; + delete(key: string): Promise; + } + + interface ContentAddressableStore { + createCbor(opts: { plumbing: unknown }): CasStore; + } + + const ContentAddressableStore: ContentAddressableStore; + export default ContentAddressableStore; +}