diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf2f004..356bce62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `Timeline.draft(name)`, draft writes, `Timeline.previewJoin(draft)`, and `Timeline.join(draft)` with join receipts for first-use speculative workflows. +- Added the `lint:test-law` gate to reject conditional bare `return;` + statements in test bodies so skipped assertions cannot masquerade as passing + tests. ### Changed @@ -29,10 +32,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 diagnostic compatibility exports out of the package root into explicit `legacy`, `storage`, `advanced`, and `diagnostics` subpaths. The package root now rejects those nouns through the v19 public API boundary audit. +- Locked the package root to the v19 facade allowlist so support ports, + infrastructure adapters, memory helpers, cancellation utilities, sync + internals, and canonical serialization helpers stay behind explicit subpaths. +- Aligned `CheckpointStorePort` with the schema:5 checkpoint envelope tree. The + CBOR adapter now owns the named checkpoint artifact encoding for runtime + checkpoint creation and loading instead of exposing a stale single + `state.cbor` result. - Added visible-state scope helpers to the `diagnostics` subpath so materialized-state inspection has an explicit non-legacy import path. - Deprecated the entire graph-first legacy API. `legacy` remains migration-only and is no longer presented as a valid first-use path. +- Moved receipt canonical JSON and ORSet/full-state wire encoding out of + domain types and storage adapters into infrastructure codec modules; `ORSet` + no longer exposes `serialize()` or `deserialize()`. +- Raised the coverage ratchet from `92.10%` to `92.56%` after adding targeted + coverage for bounded query node paging and memory-budget rejection paths. +- Upgraded `@git-stunts/git-cas` to `^6.1.0` so Git-backed state caches can use + the library's crash-safe `RootSet` retention API. + +### Fixed + +- Git-backed state-cache payload trees are now anchored through a graph-scoped + `git-cas` RootSet before their index record is published, then reconciled + after publication so live cache entries remain reachable across Git garbage + collection without retaining evicted entries forever. +- Existing state-cache entries are adopted into the RootSet on ordinary reads, + and `git warp doctor --repair-state-cache` can rebuild malformed or stale + retention metadata while reporting payloads that are missing or have the + wrong Git object type. ## [18.2.1] - 2026-06-30 diff --git a/bin/cli/commands/doctor/checksStateCache.ts b/bin/cli/commands/doctor/checksStateCache.ts new file mode 100644 index 000000000..e036f1c02 --- /dev/null +++ b/bin/cli/commands/doctor/checksStateCache.ts @@ -0,0 +1,151 @@ +import type WarpStateCacheRetentionReport from '../../../../src/domain/services/state/WarpStateCacheRetentionReport.ts'; +import type WarpStateCacheRepairResult from '../../../../src/domain/services/state/WarpStateCacheRepairResult.ts'; +import type { DoctorFinding, FindingEvidence } from './types.ts'; +import { CODES } from './codes.ts'; + +type StateCacheInspectionContext = { + readonly stateCache: { + inspectRetention(): Promise; + } | null; +}; + +function retentionEvidence(report: WarpStateCacheRetentionReport) { + return { + liveSnapshotIds: [...report.liveSnapshotIds], + anchoredSnapshotIds: [...report.anchoredSnapshotIds], + unanchoredSnapshotIds: [...report.unanchoredSnapshotIds], + missingSnapshotIds: [...report.missingSnapshotIds], + wrongTypeSnapshotIds: [...report.wrongTypeSnapshotIds], + staleRootNames: [...report.staleRootNames], + mismatchedRootNames: [...report.mismatchedRootNames], + rootSetError: report.rootSetError, + }; +} + +export async function checkStateCacheRetention( + ctx: StateCacheInspectionContext, +): Promise { + if (ctx.stateCache === null) { return []; } + const report = await ctx.stateCache.inspectRetention(); + return retentionFindings(report); +} + +function retentionFindings(report: WarpStateCacheRetentionReport): DoctorFinding[] { + const findings: DoctorFinding[] = []; + const evidence = retentionEvidence(report); + const candidates = [ + invalidRootSetFinding(report, evidence), + missingPayloadFinding(report, evidence), + wrongTypeFinding(report, evidence), + unanchoredPayloadFinding(report, evidence), + staleRootFinding(report, evidence), + ]; + for (const candidate of candidates) { + if (candidate !== null) { findings.push(candidate); } + } + if (findings.length === 0) { findings.push(healthyRetentionFinding(report, evidence)); } + return findings; +} + +function invalidRootSetFinding( + report: WarpStateCacheRetentionReport, + evidence: FindingEvidence, +): DoctorFinding | null { + if (report.rootSetError === null) { return null; } + return { + id: 'state-cache-root-set', status: 'fail', code: CODES.STATE_CACHE_ROOT_SET_INVALID, + impact: 'data_integrity', message: `State-cache RootSet is invalid: ${report.rootSetError}`, + fix: 'Run `git warp doctor --repair-state-cache` after confirming the state-cache index is authoritative', evidence, + }; +} + +function missingPayloadFinding( + report: WarpStateCacheRetentionReport, + evidence: FindingEvidence, +): DoctorFinding | null { + if (report.missingSnapshotIds.length === 0) { return null; } + return { + id: 'state-cache-missing-payloads', status: 'fail', code: CODES.STATE_CACHE_PAYLOAD_MISSING, + impact: 'data_integrity', message: `${report.missingSnapshotIds.length} state-cache payload(s) no longer exist in Git`, + fix: 'Run `git warp doctor --repair-state-cache`; missing payload bytes cannot be recovered', evidence, + }; +} + +function wrongTypeFinding( + report: WarpStateCacheRetentionReport, + evidence: FindingEvidence, +): DoctorFinding | null { + if (report.wrongTypeSnapshotIds.length === 0) { return null; } + return { + id: 'state-cache-wrong-type', status: 'fail', code: CODES.STATE_CACHE_PAYLOAD_WRONG_TYPE, + impact: 'data_integrity', message: `${report.wrongTypeSnapshotIds.length} state-cache payload ref(s) do not identify Git trees`, + fix: 'Rebuild the affected state-cache snapshots from authoritative WARP history', evidence, + }; +} + +function unanchoredPayloadFinding( + report: WarpStateCacheRetentionReport, + evidence: FindingEvidence, +): DoctorFinding | null { + if (report.unanchoredSnapshotIds.length === 0) { return null; } + return { + id: 'state-cache-unanchored-payloads', status: 'fail', code: CODES.STATE_CACHE_PAYLOAD_UNANCHORED, + impact: 'data_integrity', message: `${report.unanchoredSnapshotIds.length} live state-cache payload(s) are not protected from Git GC`, + fix: 'Run `git warp doctor --repair-state-cache` before any repository cleanup', evidence, + }; +} + +function staleRootFinding( + report: WarpStateCacheRetentionReport, + evidence: FindingEvidence, +): DoctorFinding | null { + if (report.staleRootNames.length === 0) { return null; } + return { + id: 'state-cache-stale-roots', status: 'warn', code: CODES.STATE_CACHE_STALE_ROOTS, + impact: 'hygiene', message: `${report.staleRootNames.length} stale state-cache RootSet entry or entries retain evicted payloads`, + fix: 'Run `git warp doctor --repair-state-cache` to release stale roots', evidence, + }; +} + +function healthyRetentionFinding( + report: WarpStateCacheRetentionReport, + evidence: FindingEvidence, +): DoctorFinding { + return { + id: 'state-cache-retention', status: 'ok', code: CODES.STATE_CACHE_RETENTION_OK, + impact: 'data_integrity', + message: `All ${report.liveSnapshotIds.length} live state-cache payload(s) are Git-anchored`, + evidence, + }; +} + +export function stateCacheRepairFinding(result: WarpStateCacheRepairResult): DoctorFinding { + const partial = result.unrecoverableSnapshotIds.length > 0; + return { + id: 'state-cache-retention-repair', + status: partial ? 'warn' : 'ok', + code: partial + ? CODES.STATE_CACHE_RETENTION_PARTIAL_REPAIR + : CODES.STATE_CACHE_RETENTION_REPAIRED, + impact: 'data_integrity', + message: partial + ? 'State-cache retention repair anchored every recoverable payload; some payloads were already missing' + : 'State-cache retention repair anchored the live index and released stale roots', + evidence: { + anchoredSnapshotIds: [...result.anchoredSnapshotIds], + unrecoverableSnapshotIds: [...result.unrecoverableSnapshotIds], + removedStaleRootNames: [...result.removedStaleRootNames], + }, + }; +} + +export function stateCacheRepairFailureFinding(error: unknown): DoctorFinding { + return { + id: 'state-cache-retention-repair', + status: 'fail', + code: CODES.CHECK_INTERNAL_ERROR, + impact: 'data_integrity', + message: `State-cache retention repair failed: ${error instanceof Error ? error.message : String(error)}`, + fix: 'Resolve the repository or RootSet error, then rerun `git warp doctor --repair-state-cache`', + }; +} diff --git a/bin/cli/commands/doctor/codes.ts b/bin/cli/commands/doctor/codes.ts index 501dff34d..dc95e99d8 100644 --- a/bin/cli/commands/doctor/codes.ts +++ b/bin/cli/commands/doctor/codes.ts @@ -43,6 +43,16 @@ export const CODES = { // memory-budget MEMORY_BUDGET_REPORT: 'MEMORY_BUDGET_REPORT', + // state-cache-retention + STATE_CACHE_RETENTION_OK: 'STATE_CACHE_RETENTION_OK', + STATE_CACHE_PAYLOAD_UNANCHORED: 'STATE_CACHE_PAYLOAD_UNANCHORED', + STATE_CACHE_PAYLOAD_MISSING: 'STATE_CACHE_PAYLOAD_MISSING', + STATE_CACHE_PAYLOAD_WRONG_TYPE: 'STATE_CACHE_PAYLOAD_WRONG_TYPE', + STATE_CACHE_ROOT_SET_INVALID: 'STATE_CACHE_ROOT_SET_INVALID', + STATE_CACHE_STALE_ROOTS: 'STATE_CACHE_STALE_ROOTS', + STATE_CACHE_RETENTION_REPAIRED: 'STATE_CACHE_RETENTION_REPAIRED', + STATE_CACHE_RETENTION_PARTIAL_REPAIR: 'STATE_CACHE_RETENTION_PARTIAL_REPAIR', + // meta CHECK_SKIPPED_BUDGET_EXHAUSTED: 'CHECK_SKIPPED_BUDGET_EXHAUSTED', CHECK_INTERNAL_ERROR: 'CHECK_INTERNAL_ERROR', diff --git a/bin/cli/commands/doctor/index.ts b/bin/cli/commands/doctor/index.ts index 8f9aa933d..04c504d6b 100644 --- a/bin/cli/commands/doctor/index.ts +++ b/bin/cli/commands/doctor/index.ts @@ -15,12 +15,16 @@ import { doctorSchema } from '../../schemas.ts'; import { createPersistence, resolveGraphName } from '../../shared.ts'; import { ALL_CHECKS } from './checks.ts'; import { CODES } from './codes.ts'; +import { + checkStateCacheRetention, +} from './checksStateCache.ts'; +import { repairStateCache, resolveStateCache } from './stateCacheCapability.ts'; import { DOCTOR_EXIT_CODES, type DoctorFinding, type DoctorPolicy, type DoctorPayload, type DoctorContext } from './types.ts'; import type { CliOptions, Persistence } from '../../types.ts'; -import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts'; const DOCTOR_OPTION_MEMORY_BUDGET = 'memory-budget'; const DOCTOR_OPTION_LARGE_GRAPH = 'large-graph'; +const DOCTOR_OPTION_REPAIR_STATE_CACHE = 'repair-state-cache'; const MEMORY_BUDGET_FINDING_ID = 'memory-budget'; const MEMORY_BUDGET_NOT_SPECIFIED = 'not-specified'; @@ -29,6 +33,7 @@ const DOCTOR_OPTIONS = { strict: { type: 'boolean', default: false }, [DOCTOR_OPTION_MEMORY_BUDGET]: { type: 'string' }, [DOCTOR_OPTION_LARGE_GRAPH]: { type: 'boolean', default: false }, + [DOCTOR_OPTION_REPAIR_STATE_CACHE]: { type: 'boolean', default: false }, }; const DEFAULT_POLICY: DoctorPolicy = { @@ -51,12 +56,14 @@ type DoctorCommandValues = { readonly strict: boolean; readonly [DOCTOR_OPTION_MEMORY_BUDGET]: string | undefined; readonly [DOCTOR_OPTION_LARGE_GRAPH]: boolean; + readonly [DOCTOR_OPTION_REPAIR_STATE_CACHE]: boolean; }; type RawDoctorCommandValues = { readonly strict: boolean; readonly [DOCTOR_OPTION_MEMORY_BUDGET]?: string | undefined; readonly [DOCTOR_OPTION_LARGE_GRAPH]: boolean; + readonly [DOCTOR_OPTION_REPAIR_STATE_CACHE]: boolean; }; /** Handles the `git warp doctor` command: runs structural health checks and returns findings. */ @@ -64,7 +71,6 @@ export default async function handleDoctor({ options, args }: { options: CliOpti const { values } = parseCommandArgs(args, DOCTOR_OPTIONS, doctorSchema); const commandValues = normalizeCommandValues(values); const startMs = Date.now(); - const { persistence } = await createPersistence(options.repo); const graphName = await resolveGraphName(persistence, options.graph); const policy = { ...DEFAULT_POLICY, strict: commandValues.strict }; @@ -74,8 +80,10 @@ export default async function handleDoctor({ options, args }: { options: CliOpti const ctx: DoctorContext = { persistence, stateCache, graphName, writerHeads, policy, repoPath: options.repo }; const memoryFindings = memoryBudgetFindings(commandValues); + const repairFinding = await repairStateCache(commandValues[DOCTOR_OPTION_REPAIR_STATE_CACHE], stateCache); const { findings, checksRun } = await runChecks(ctx, startMs); findings.push(...memoryFindings); + if (repairFinding !== null) { findings.push(repairFinding); } findings.sort(compareFinding); const payload = assemblePayload({ @@ -83,7 +91,7 @@ export default async function handleDoctor({ options, args }: { options: CliOpti graph: graphName, policy, findings, - checksRun: checksRun + memoryFindings.length, + checksRun: checksRun + memoryFindings.length + (repairFinding === null ? 0 : 1), startMs, }); const exitCode = computeExitCode(payload.health, policy.strict); @@ -95,18 +103,10 @@ function normalizeCommandValues(values: RawDoctorCommandValues): DoctorCommandVa strict: values.strict, [DOCTOR_OPTION_MEMORY_BUDGET]: values[DOCTOR_OPTION_MEMORY_BUDGET], [DOCTOR_OPTION_LARGE_GRAPH]: values[DOCTOR_OPTION_LARGE_GRAPH], + [DOCTOR_OPTION_REPAIR_STATE_CACHE]: values[DOCTOR_OPTION_REPAIR_STATE_CACHE], }; } -async function resolveStateCache(persistence: Persistence, graphName: string): Promise { - const castPersistence = persistence as unknown as { createRuntimeStateCache?: (args: unknown) => Promise }; - if (typeof castPersistence.createRuntimeStateCache === 'function') { - const { default: defaultCodec } = await import('../../../../src/infrastructure/codecs/CborCodec.ts'); - return await castPersistence.createRuntimeStateCache({ graphName, codec: defaultCodec }); - } - return null; -} - function memoryBudgetFindings(values: DoctorCommandValues): DoctorFinding[] { if (values[DOCTOR_OPTION_MEMORY_BUDGET] === undefined && !values[DOCTOR_OPTION_LARGE_GRAPH]) { return []; @@ -216,8 +216,11 @@ async function executeCheck(check: { id: string; fn: (ctx: DoctorContext) => Pro async function runChecks(ctx: DoctorContext, startMs: number): Promise<{ findings: DoctorFinding[]; checksRun: number }> { const findings: DoctorFinding[] = []; let checksRun = 0; + const checks = ctx.stateCache === null + ? ALL_CHECKS + : [...ALL_CHECKS, { id: 'state-cache-retention', fn: checkStateCacheRetention }]; - for (const check of ALL_CHECKS) { + for (const check of checks) { const elapsed = Date.now() - startMs; if (elapsed >= ctx.policy.globalDeadlineMs) { findings.push({ diff --git a/bin/cli/commands/doctor/stateCacheCapability.ts b/bin/cli/commands/doctor/stateCacheCapability.ts new file mode 100644 index 000000000..94f5065d6 --- /dev/null +++ b/bin/cli/commands/doctor/stateCacheCapability.ts @@ -0,0 +1,31 @@ +import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts'; +import type WarpStateCacheRetentionPort from '../../../../src/ports/WarpStateCacheRetentionPort.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import type { Persistence } from '../../types.ts'; +import type { DoctorFinding } from './types.ts'; +import { + stateCacheRepairFailureFinding, + stateCacheRepairFinding, +} from './checksStateCache.ts'; + +export type DoctorStateCache = WarpStateCachePort & WarpStateCacheRetentionPort; + +export async function resolveStateCache( + persistence: Persistence, + graphName: string, +): Promise { + if (typeof persistence.createRuntimeStateCache !== 'function') { return null; } + return await persistence.createRuntimeStateCache({ graphName, codec: defaultCodec }); +} + +export async function repairStateCache( + requested: boolean, + stateCache: DoctorStateCache | null, +): Promise { + if (!requested || stateCache === null) { return null; } + try { + return stateCacheRepairFinding(await stateCache.repairRetention()); + } catch (error) { + return stateCacheRepairFailureFinding(error); + } +} diff --git a/bin/cli/commands/doctor/types.ts b/bin/cli/commands/doctor/types.ts index df569b8d2..61492a202 100644 --- a/bin/cli/commands/doctor/types.ts +++ b/bin/cli/commands/doctor/types.ts @@ -6,6 +6,7 @@ import type { Persistence } from '../../types.ts'; import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts'; +import type WarpStateCacheRetentionPort from '../../../../src/ports/WarpStateCacheRetentionPort.ts'; // ── JSON-safe recursive value type ────────────────────────────────────────── @@ -64,7 +65,7 @@ export interface DoctorSummary { export interface DoctorContext { persistence: Persistence; - stateCache: WarpStateCachePort | null; + stateCache: (WarpStateCachePort & WarpStateCacheRetentionPort) | null; graphName: string; writerHeads: Array<{ writerId: string; sha: string | null; ref: string }>; policy: DoctorPolicy; diff --git a/bin/cli/schemas.ts b/bin/cli/schemas.ts index b457b86aa..4f002106d 100644 --- a/bin/cli/schemas.ts +++ b/bin/cli/schemas.ts @@ -128,6 +128,7 @@ export const doctorSchema = z.object({ strict: z.boolean().default(false), 'memory-budget': z.string().min(1).optional(), 'large-graph': z.boolean().default(false), + 'repair-state-cache': z.boolean().default(false), }).strict(); // ============================================================================ diff --git a/docs/topics/cas-first-memoized-materialization.md b/docs/topics/cas-first-memoized-materialization.md index 567cdfc96..1272bec2f 100644 --- a/docs/topics/cas-first-memoized-materialization.md +++ b/docs/topics/cas-first-memoized-materialization.md @@ -4,10 +4,11 @@ Use this page when you need to understand how `git-warp` skips redundant materialization replay by memoizing WARP-owned state snapshots in `@git-stunts/git-cas`. -`git-cas` is only the byte storage substrate. It does not know about WARP -frontiers, optics, checkpoints, graph state, or materialization rules. `git-warp` -owns those semantics through `WarpStateCachePort`; the Git-backed adapter stores -snapshot payloads in `git-cas`. +`git-cas` provides byte storage and generic Git-reachability primitives. It does +not know about WARP frontiers, optics, checkpoints, graph state, or +materialization rules. `git-warp` owns those semantics through +`WarpStateCachePort`; the Git-backed adapter stores snapshot payloads in +`git-cas` and declares the live payload trees through a `RootSet`. ## The Live Materialization Lifecycle @@ -79,6 +80,50 @@ chunk-level reuse where the underlying CAS representation can identify unchanged byte ranges. The WARP cache index remains responsible for determining whether a snapshot is semantically usable for a materialization coordinate. +## Git Retention and Repair + +A payload object ID written as text inside the state-cache index is not a Git +reachability edge. Without a ref-backed edge, Git sees the payload tree and its +blobs as unreachable objects and may eventually prune them even while WARP's +index still names them. + +The Git-backed adapter therefore mirrors its live index membership into this +graph-scoped `git-cas` RootSet: + +```text +refs/cas/rootsets/git-warp//state-cache +``` + +Cache policy and Git retention are separate axes. Both `pinned` and `evictable` +records must remain Git-reachable while they are live in the index; `pinned` +only controls WARP eviction policy. Each cache mutation follows this ordering: + +1. Publish a RootSet generation that anchors a safe superset of the desired + payload trees. +2. Compare-and-swap the WARP state-cache index. +3. Guardedly replace the RootSet with the exact recoverable live membership. + +An interrupted write can therefore leave extra reachable payloads, but it does +not publish an index entry whose payload was never anchored. Ordinary reads +also adopt legacy index entries that predate RootSet retention. + +Inspect retention without changing it: + +```bash +git warp doctor --repo ./team-repo +``` + +Reconcile the RootSet from the authoritative WARP index: + +```bash +git warp doctor --repo ./team-repo --repair-state-cache +``` + +Repair anchors every indexed payload that still exists as a Git tree and +removes stale RootSet membership. It reports missing payloads and wrong-type +objects as unrecoverable; it does not delete logical cache records, recreate +lost payload bytes, or run Git garbage collection. + ## Current Limitations - Exact state-cache hits bypass replay, but full materialization still hydrates @@ -87,6 +132,9 @@ snapshot is semantically usable for a materialization coordinate. sharded basis format should make optic reads avoid full-state hydration. - Cache coordinates must stay schema/version aware. A snapshot is reusable only when WARP semantics say the coordinate is compatible. +- Retention repair cannot restore payload objects that Git has already pruned; + those entries remain visible as doctor findings until normal cache lifecycle + replacement or explicit operator cleanup. ## See also diff --git a/docs/topics/cli.md b/docs/topics/cli.md index 6e65a2c21..80de45781 100644 --- a/docs/topics/cli.md +++ b/docs/topics/cli.md @@ -39,6 +39,11 @@ git warp check --repo ./team-repo git warp doctor --repo ./team-repo --strict ``` +`doctor` also checks whether live state-cache payload trees are anchored against +Git garbage collection. Use `--repair-state-cache` to reconcile the retention +RootSet from the WARP cache index. The repair does not run Git garbage +collection and reports already-missing payloads instead of hiding them. + The old `--view` flag has been removed. Use `warp-ttd` for visualization workflows instead of asking `git warp` to render dashboards. diff --git a/docs/topics/reference.md b/docs/topics/reference.md index acc0f1cba..b7c17a1a2 100644 --- a/docs/topics/reference.md +++ b/docs/topics/reference.md @@ -30,100 +30,31 @@ public API export, CLI command, package entrypoint, or public error class. First-use product API: `openWarp`, `intent`, `reading`, timelines, and receipts. -### Export modules - -| Module | Kind | Source | -| --- | --- | --- | -| `./src/domain/memory/index.ts` | export * | `index.ts#L55` | - ### Value exports -Source: `index.ts`. Count: 73. +Source: `index.ts`. Count: 13. ```text -AlfredOperationPolicyAdapter @ index.ts#L68 -AuditError @ index.ts#L71 -BunHttpAdapter @ index.ts#L102 -canonicalEmissionJson @ index.ts#L118 -canonicalObservationJson @ index.ts#L124 -CasContentEncryptionPolicy @ index.ts#L62 -checkAborted @ index.ts#L104 -ChunkEffectSink @ index.ts#L134 -ConsoleEffectSink @ index.ts#L133 -ConsoleLogger @ index.ts#L96 -ContinuumArtifactAuthorityError @ index.ts#L72 -createDeliveryObservation @ index.ts#L123 -createEffectEmission @ index.ts#L117 -createExternalizationPolicy @ index.ts#L127 -createTickReceipt @ index.ts#L108 -createTimeoutSignal @ index.ts#L104 -CryptoPort @ index.ts#L98 -DELIVERY_MODES @ index.ts#L119 -DELIVERY_OUTCOMES @ index.ts#L120 -DenoHttpAdapter @ index.ts#L103 DraftTimeline @ index.ts#L19 -EffectPipeline @ index.ts#L115 -EffectSinkPort @ index.ts#L113 -EncryptionError @ index.ts#L73 -ForkError @ index.ts#L74 -HealthCheckService @ index.ts#L93 -HealthStatus @ index.ts#L93 -HttpServerPort @ index.ts#L99 -IndexError @ index.ts#L75 -INSPECT_LENS @ index.ts#L130 intent @ index.ts#L22 Intent @ index.ts#L23 JoinReceipt @ index.ts#L24 JoinResult @ index.ts#L25 -LIVE_LENS @ index.ts#L128 -LoggerPort @ index.ts#L94 -LogLevel @ index.ts#L96 -MemoryBudgetError @ index.ts#L76 -MultiplexSink @ index.ts#L114 -NodeCryptoAdapter @ index.ts#L100 -NoOpEffectSink @ index.ts#L132 -NoOpLogger @ index.ts#L95 -NoopOperationPolicyAdapter @ index.ts#L69 openWarp @ index.ts#L18 -OperationAbortedError @ index.ts#L77 -OperationPolicyExhaustedError @ index.ts#L78 -OperationPolicyPort @ index.ts#L57 -OperationPolicyTimeoutError @ index.ts#L79 -PatchError @ index.ts#L80 -QueryError @ index.ts#L81 reading @ index.ts#L26 Reading @ index.ts#L27 ReadingResult @ index.ts#L28 ReadReceipt @ index.ts#L29 -REPLAY_LENS @ index.ts#L129 -SchemaUnsupportedError @ index.ts#L82 -ShardCorruptionError @ index.ts#L83 -ShardLoadError @ index.ts#L84 -ShardValidationError @ index.ts#L85 -StorageError @ index.ts#L86 -StrandError @ index.ts#L87 -SyncError @ index.ts#L88 -SyncSecret @ index.ts#L105 -TICK_RECEIPT_OP_TYPES @ index.ts#L110 -TICK_RECEIPT_RESULT_TYPES @ index.ts#L111 -tickReceiptCanonicalJson @ index.ts#L109 Timeline @ index.ts#L21 -TraversalError @ index.ts#L89 Warp @ index.ts#L20 -WebCryptoAdapter @ index.ts#L101 -WormholeError @ index.ts#L90 WriteReceipt @ index.ts#L30 -WriterError @ index.ts#L97 ``` ### Type exports -Source: `index.ts`. Count: 32. +Source: `index.ts`. Count: 26. ```text -CasContentEncryptionDiagnostics @ index.ts#L64 -CasContentEncryptionScheme @ index.ts#L65 -CasResolvedVaultKeyOptions @ index.ts#L66 EdgeIntentFields @ index.ts#L33 EdgePropertyIntentFields @ index.ts#L34 IntentBuilders @ index.ts#L40 @@ -138,8 +69,6 @@ JoinResultOptions @ index.ts#L42 NodeIntentFields @ index.ts#L37 NodeReadingFields @ index.ts#L45 OpenWarpOptions @ index.ts#L31 -OperationPolicyExecuteOptions @ index.ts#L59 -OperationRetryDecision @ index.ts#L60 PropertyIntentFields @ index.ts#L38 PropertyReadingFields @ index.ts#L46 ReadingBuilders @ index.ts#L50 @@ -150,7 +79,6 @@ ReadingValue @ index.ts#L51 ReadReceiptOptions @ index.ts#L52 ReadReceiptOutcome @ index.ts#L52 ReceiptOutcome @ index.ts#L53 -SyncRateLimitConfig @ index.ts#L106 WarpStorage @ index.ts#L31 WriteReceiptOptions @ index.ts#L53 ``` diff --git a/index.ts b/index.ts index 3f4b89df1..75d937488 100644 --- a/index.ts +++ b/index.ts @@ -51,84 +51,3 @@ export type { ReadingBuilders } from './src/domain/api/ReadingBuilders.ts'; export type { ReadingResultOptions, ReadingValue } from './src/domain/api/ReadingResult.ts'; export type { ReadReceiptOptions, ReadReceiptOutcome } from './src/domain/api/ReadReceipt.ts'; export type { ReceiptOutcome, WriteReceiptOptions } from './src/domain/api/WriteReceipt.ts'; - -export * from './src/domain/memory/index.ts'; - -export { default as OperationPolicyPort } from './src/ports/OperationPolicyPort.ts'; -export type { - OperationPolicyExecuteOptions, - OperationRetryDecision, -} from './src/ports/OperationPolicyPort.ts'; -export { default as CasContentEncryptionPolicy } from './src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; -export type { - CasContentEncryptionDiagnostics, - CasContentEncryptionScheme, - CasResolvedVaultKeyOptions, -} from './src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; -export { default as AlfredOperationPolicyAdapter } from './src/infrastructure/adapters/AlfredOperationPolicyAdapter.ts'; -export { default as NoopOperationPolicyAdapter } from './src/infrastructure/adapters/NoopOperationPolicyAdapter.ts'; -export { - AuditError, - ContinuumArtifactAuthorityError, - EncryptionError, - ForkError, - IndexError, - MemoryBudgetError, - OperationAbortedError, - OperationPolicyExhaustedError, - OperationPolicyTimeoutError, - PatchError, - QueryError, - SchemaUnsupportedError, - ShardCorruptionError, - ShardLoadError, - ShardValidationError, - StorageError, - StrandError, - SyncError, - TraversalError, - WormholeError, -} from './src/domain/errors/index.ts'; - -export { default as HealthCheckService, HealthStatus } from './src/domain/services/HealthCheckService.ts'; -export { default as LoggerPort } from './src/ports/LoggerPort.ts'; -export { default as NoOpLogger } from './src/infrastructure/adapters/NoOpLogger.ts'; -export { default as ConsoleLogger, LogLevel } from './src/infrastructure/adapters/ConsoleLogger.ts'; -export { default as WriterError } from './src/domain/errors/WriterError.ts'; -export { default as CryptoPort } from './src/ports/CryptoPort.ts'; -export { default as HttpServerPort } from './src/ports/HttpServerPort.ts'; -export { default as NodeCryptoAdapter } from './src/infrastructure/adapters/NodeCryptoAdapter.ts'; -export { default as WebCryptoAdapter } from './src/infrastructure/adapters/WebCryptoAdapter.ts'; -export { default as BunHttpAdapter } from './src/infrastructure/adapters/BunHttpAdapter.ts'; -export { default as DenoHttpAdapter } from './src/infrastructure/adapters/DenoHttpAdapter.ts'; -export { checkAborted, createTimeoutSignal } from './src/domain/utils/cancellation.ts'; -export { default as SyncSecret } from './src/domain/services/sync/SyncSecret.ts'; -export type { SyncRateLimitConfig } from './src/domain/services/sync/SyncRateLimiter.ts'; -export { - createTickReceipt, - canonicalJson as tickReceiptCanonicalJson, - OP_TYPES as TICK_RECEIPT_OP_TYPES, - RESULT_TYPES as TICK_RECEIPT_RESULT_TYPES, -} from './src/domain/types/TickReceipt.ts'; -export { default as EffectSinkPort } from './src/ports/EffectSinkPort.ts'; -export { MultiplexSink } from './src/domain/services/MultiplexSink.ts'; -export { EffectPipeline } from './src/domain/services/EffectPipeline.ts'; -export { - createEffectEmission, - canonicalEmissionJson, - DELIVERY_MODES, - DELIVERY_OUTCOMES, -} from './src/domain/types/EffectEmission.ts'; -export { - createDeliveryObservation, - canonicalObservationJson, -} from './src/domain/types/DeliveryObservation.ts'; -export { - createExternalizationPolicy, - LIVE_LENS, - REPLAY_LENS, - INSPECT_LENS, -} from './src/domain/types/ExternalizationPolicy.ts'; -export { NoOpEffectSink } from './src/infrastructure/adapters/NoOpEffectSink.ts'; -export { ConsoleEffectSink } from './src/infrastructure/adapters/ConsoleEffectSink.ts'; -export { ChunkEffectSink } from './src/infrastructure/adapters/ChunkEffectSink.ts'; diff --git a/legacy.ts b/legacy.ts index db8d97d25..fb9524643 100644 --- a/legacy.ts +++ b/legacy.ts @@ -74,10 +74,12 @@ import { } from './src/domain/services/KeyCodec.ts'; import { createTickReceipt, - canonicalJson as tickReceiptCanonicalJson, OP_TYPES as TICK_RECEIPT_OP_TYPES, RESULT_TYPES as TICK_RECEIPT_RESULT_TYPES, } from './src/domain/types/TickReceipt.ts'; +import { tickReceiptCanonicalJson } from './src/infrastructure/codecs/TickReceiptJsonCodec.ts'; +import { canonicalEmissionJson } from './src/infrastructure/codecs/EffectEmissionJsonCodec.ts'; +import { canonicalObservationJson } from './src/infrastructure/codecs/DeliveryObservationJsonCodec.ts'; // Provenance payload (HOLOGRAM) import ProvenancePayload from './src/domain/services/provenance/ProvenancePayload.ts'; @@ -112,13 +114,11 @@ import { MultiplexSink } from './src/domain/services/MultiplexSink.ts'; import { EffectPipeline } from './src/domain/services/EffectPipeline.ts'; import { createEffectEmission, - canonicalEmissionJson, DELIVERY_MODES, DELIVERY_OUTCOMES, } from './src/domain/types/EffectEmission.ts'; import { createDeliveryObservation, - canonicalObservationJson, } from './src/domain/types/DeliveryObservation.ts'; import { createExternalizationPolicy, diff --git a/package-lock.json b/package-lock.json index 598c3d23f..b7ecac9e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ ], "dependencies": { "@git-stunts/alfred": "^0.10.4", - "@git-stunts/git-cas": "^6.0.0", + "@git-stunts/git-cas": "^6.1.0", "@git-stunts/plumbing": "^3.0.3", "@git-stunts/trailer-codec": "^2.1.1", "@noble/hashes": "^2.2.0", @@ -489,9 +489,9 @@ "license": "Apache-2.0" }, "node_modules/@git-stunts/git-cas": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-6.0.0.tgz", - "integrity": "sha512-NyTOaCHq6VBGCbL6HKR0bmX3uarumLAR+s2R8pofMGC3WX3YaS1pNdwTJOOzpvcZGWu3FKWAUVU9U0rdEyRoaw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@git-stunts/git-cas/-/git-cas-6.1.0.tgz", + "integrity": "sha512-q+f9xyGKHvSjDxadKToCY4gz/f5QgOBFAUGvLHITucFzd5uTT5n7RQepuK1/tEtXAOAgkMiOxE6tIYoB30iicQ==", "license": "Apache-2.0", "dependencies": { "@flyingrobots/bijou": "^5.0.0", diff --git a/package.json b/package.json index f929b8c01..2f9f862a5 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ }, "scripts": { "build": "tsc -p tsconfig.publish.json", - "lint": "sh -c 'eslint . \"$@\" && npm run lint:source-size && npm run lint:cas-invariants' --", + "lint": "sh -c 'eslint . \"$@\" && npm run lint:test-law && npm run lint:source-size && npm run lint:cas-invariants' --", "lint:ratchet": "sh scripts/lint-ratchet.sh", "lint:md": "markdownlint \"**/*.md\" --ignore node_modules --ignore \"**/node_modules/**\"", "lint:md:code": "node scripts/lint-markdown-code-samples.ts", @@ -84,6 +84,7 @@ "lint:links": "lychee --config .lychee.toml --include-fragments '**/*.md'", "lint:semgrep": "node scripts/lint-semgrep-with-quarantines.ts", "lint:sludge": "bash scripts/check-anti-sludge.sh", + "lint:test-law": "node scripts/lint-test-vacuous-returns.ts", "lint:cas-invariants": "bash scripts/check-git-cas-invariants.sh", "lint:source-size": "node scripts/source-size-gate.ts", "lint:source-backed-reference": "node scripts/check-source-backed-reference.ts", @@ -130,7 +131,7 @@ }, "dependencies": { "@git-stunts/alfred": "^0.10.4", - "@git-stunts/git-cas": "^6.0.0", + "@git-stunts/git-cas": "^6.1.0", "@git-stunts/plumbing": "^3.0.3", "@git-stunts/trailer-codec": "^2.1.1", "@noble/hashes": "^2.2.0", diff --git a/scripts/lint-markdown-code-samples.ts b/scripts/lint-markdown-code-samples.ts index 1d52fa8cf..c800bd90d 100644 --- a/scripts/lint-markdown-code-samples.ts +++ b/scripts/lint-markdown-code-samples.ts @@ -9,7 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, '..'); const repoTsconfigPath = resolve(root, 'tsconfig.base.json'); -const IGNORED_DIRS = new Set(['.git', 'node_modules', 'coverage']); +const IGNORED_DIRS = new Set(['.git', 'node_modules', 'coverage', 'dist']); const CODE_SAMPLE_LANGUAGES = new Set(['js', 'javascript', 'ts', 'typescript']); const ANY_FENCE_PATTERN = /^ {0,3}([`~]{3,})(.*)$/; const OPENING_FENCE_PATTERN = /^ {0,3}((?:`{3,}|~{3,}))(.*)$/; diff --git a/scripts/lint-test-vacuous-returns.ts b/scripts/lint-test-vacuous-returns.ts new file mode 100644 index 000000000..8d93eaacd --- /dev/null +++ b/scripts/lint-test-vacuous-returns.ts @@ -0,0 +1,121 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import ts from 'typescript'; + +export type BareTestReturn = { + filePath: string; + line: number; + column: number; +}; + +export function collectBareTestReturnsFromSource( + filePath: string, + source: string, +): BareTestReturn[] { + const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const findings: BareTestReturn[] = []; + visitTestCalls(sourceFile, sourceFile, findings); + return findings; +} + +function visitTestCalls( + node: ts.Node, + sourceFile: ts.SourceFile, + findings: BareTestReturn[], +): void { + const callback = testCallback(node); + if (callback !== null && ts.isBlock(callback.body)) { + scanCallbackBody(callback.body, callback.body, sourceFile, findings); + } + ts.forEachChild(node, (child) => visitTestCalls(child, sourceFile, findings)); +} + +function testCallback(node: ts.Node): ts.ArrowFunction | ts.FunctionExpression | null { + if (!ts.isCallExpression(node) || !isItCallee(node.expression)) { + return null; + } + for (let index = node.arguments.length - 1; index >= 0; index -= 1) { + const argument = node.arguments[index]; + if (argument !== undefined && (ts.isArrowFunction(argument) || ts.isFunctionExpression(argument))) { + return argument; + } + } + return null; +} + +function isItCallee(expression: ts.Expression): boolean { + if (ts.isIdentifier(expression)) { + return expression.text === 'it'; + } + if (ts.isPropertyAccessExpression(expression)) { + return isItCallee(expression.expression); + } + if (ts.isCallExpression(expression)) { + return isItCallee(expression.expression); + } + return false; +} + +function scanCallbackBody( + node: ts.Node, + root: ts.Node, + sourceFile: ts.SourceFile, + findings: BareTestReturn[], +): void { + if (node !== root && isNestedExecutableBoundary(node)) { + return; + } + if (ts.isReturnStatement(node) && node.expression === undefined) { + const location = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + findings.push({ + filePath: sourceFile.fileName, + line: location.line + 1, + column: location.character + 1, + }); + } + ts.forEachChild(node, (child) => scanCallbackBody(child, root, sourceFile, findings)); +} + +function isNestedExecutableBoundary(node: ts.Node): boolean { + return ts.isFunctionLike(node) || ts.isClassLike(node); +} + +export function testFilesInDirectory(directory: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...testFilesInDirectory(path)); + } else if (entry.isFile() && (path.endsWith('.ts') || path.endsWith('.js'))) { + files.push(path); + } + } + return files.sort(); +} + +function collectBareTestReturnsFromFiles(filePaths: string[]): BareTestReturn[] { + return filePaths.flatMap((filePath) => ( + collectBareTestReturnsFromSource(filePath, readFileSync(filePath, 'utf8')) + )); +} + +function runCli(args: string[]): number { + const filePaths = args.length > 0 ? args : testFilesInDirectory('test'); + const findings = collectBareTestReturnsFromFiles(filePaths); + if (findings.length === 0) { + return 0; + } + for (const finding of findings) { + console.error( + `${finding.filePath}:${String(finding.line)}:${String(finding.column)} ` + + 'bare return inside it() callback makes the test vacuous', + ); + } + return 1; +} + +const entryPoint = process.argv[1]; +if (entryPoint !== undefined && import.meta.url === pathToFileURL(entryPoint).href) { + process.exitCode = runCli(process.argv.slice(2)); +} diff --git a/src/domain/crdt/ORSet.ts b/src/domain/crdt/ORSet.ts index 2b4e26b3b..fb57ff520 100644 --- a/src/domain/crdt/ORSet.ts +++ b/src/domain/crdt/ORSet.ts @@ -38,18 +38,6 @@ import CrdtError from '../errors/CrdtError.ts'; import type VersionVector from './VersionVector.ts'; -/** Serialized form of an ORSet for CBOR encoding. */ -interface SerializedORSet { - entries: Array<[string, string[]]>; - tombstones: string[]; -} - -/** Input for deserialization — entries and tombstones may be absent. */ -interface DeserializeInput { - entries?: Array<[string, string[]]>; - tombstones?: string[]; -} - /** * Throws if the dot is not a well-formed {writerId: string, counter: integer}. */ @@ -95,14 +83,6 @@ export default class ORSet { return new ORSet(new Map(), new Set()); } - /** Deserializes a plain object back to an ORSet. */ - static deserialize(obj: DeserializeInput): ORSet { - const set = ORSet.empty(); - _deserializeEntriesInto(obj.entries, set.entries); - _deserializeTombstonesInto(obj.tombstones, set.tombstones); - return set; - } - // --------------------------------------------------------------------------- // Mutation operations // --------------------------------------------------------------------------- @@ -343,18 +323,6 @@ export default class ORSet { } return result; } - - /** - * Serializes to a plain object for CBOR encoding. - * Entries are sorted by element; dots within entries are sorted. - * Tombstones are sorted. - */ - serialize(): SerializedORSet { - return { - entries: _serializeEntries(this.entries), - tombstones: _sortEncodedDots(this.tombstones), - }; - } } // ============================================================================= @@ -416,45 +384,3 @@ function _applyCompaction(set: ORSet, toDelete: Array<{ element: string; dot: st set.tombstones.delete(encodedDot); } } - -/** Sorts encoded dots by their decoded (writerId, counter) order. */ -function _sortEncodedDots(encodedDots: Set | Iterable): string[] { - const pairs: Array<{ encoded: string; decoded: Dot }> = []; - for (const encoded of encodedDots) { - pairs.push({ encoded, decoded: Dot.decode(encoded) }); - } - pairs.sort((a, b) => Dot.compare(a.decoded, b.decoded)); - return pairs.map((p) => p.encoded); -} - -/** Serializes OR-Set entries as sorted [element, sortedDots[]] pairs. */ -function _serializeEntries(entries: Map>): Array<[string, string[]]> { - const result: Array<[string, string[]]> = []; - for (const [element, dots] of entries) { - result.push([element, _sortEncodedDots(dots)]); - } - result.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); - return result; -} - -/** Populates an entries map from a serialized entries array. */ -function _deserializeEntriesInto(entries: Array<[string, string[]]> | undefined, target: Map>): void { - if (!Array.isArray(entries)) { - return; - } - for (const [element, dots] of entries) { - if (Array.isArray(dots)) { - target.set(element, new Set(dots)); - } - } -} - -/** Populates a tombstone set from a serialized tombstones array. */ -function _deserializeTombstonesInto(tombstones: string[] | undefined, target: Set): void { - if (!Array.isArray(tombstones)) { - return; - } - for (const dot of tombstones) { - target.add(dot); - } -} diff --git a/src/domain/services/state/CheckpointSerializer.ts b/src/domain/services/state/CheckpointSerializer.ts index 28cb699e3..415eaca8a 100644 --- a/src/domain/services/state/CheckpointSerializer.ts +++ b/src/domain/services/state/CheckpointSerializer.ts @@ -12,7 +12,7 @@ * @see WARP Spec Section 10 (Checkpoints) */ -import ORSet from '../../crdt/ORSet.ts'; +import type ORSet from '../../crdt/ORSet.ts'; import VersionVector from '../../crdt/VersionVector.ts'; import { decodeDot } from '../../crdt/Dot.ts'; import { requireCodec } from '../codec/CodecRequirement.ts'; @@ -22,8 +22,14 @@ import SchemaUnsupportedError from '../../errors/SchemaUnsupportedError.ts'; import WarpError from '../../errors/WarpError.ts'; import type CodecPort from '../../../ports/CodecPort.ts'; import type { LWWRegister } from '../../crdt/LWW.ts'; -import type { EventId } from '../../utils/EventId.ts'; +import { EventId } from '../../utils/EventId.ts'; import type { PropValue } from '../../types/PropValue.ts'; +import { compareStrings } from '../../utils/StringComparison.ts'; +import { + deserializeORSet, + serializeORSet, + type ORSetWire, +} from './ORSetWireBoundary.ts'; interface SerializedLWWRegister { eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; @@ -43,8 +49,8 @@ export function serializeFullState( { codec }: { codec?: CodecPort } = {}, ): Uint8Array { const c = requireCodec(codec, 'serializeFullState'); - const nodeAliveObj = state.nodeAlive.serialize(); - const edgeAliveObj = state.edgeAlive.serialize(); + const nodeAliveObj = serializeORSet(state.nodeAlive); + const edgeAliveObj = serializeORSet(state.edgeAlive); const propArray = serializePropsArray(WarpState.allPropEntriesFromState(state)); const observedFrontierObj = VersionVector.serialize(state.observedFrontier); const edgeBirthArray = serializeEdgeBirthArray(state.edgeBirthEvent); @@ -64,20 +70,18 @@ function serializePropsArray(propEntries: Iterable (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + propArray.sort((left, right) => compareStrings(left[0], right[0])); return propArray; } function serializeEdgeBirthArray( - edgeBirthEvent: Map | undefined, + edgeBirthEvent: Map, ): Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> { const result: Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> = []; - if (edgeBirthEvent !== undefined && edgeBirthEvent !== null) { - for (const [key, eventId] of edgeBirthEvent) { - result.push([key, { lamport: eventId.lamport, writerId: eventId.writerId, patchSha: eventId.patchSha, opIndex: eventId.opIndex }]); - } - result.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + for (const [key, eventId] of edgeBirthEvent) { + result.push([key, { lamport: eventId.lamport, writerId: eventId.writerId, patchSha: eventId.patchSha, opIndex: eventId.opIndex }]); } + result.sort((left, right) => compareStrings(left[0], right[0])); return result; } @@ -109,8 +113,8 @@ export function deserializeFullState( ); } return new WarpState({ - nodeAlive: ORSet.deserialize(obj.nodeAlive ?? {}), - edgeAlive: ORSet.deserialize(obj.edgeAlive ?? {}), + nodeAlive: deserializeORSet(obj.nodeAlive ?? {}), + edgeAlive: deserializeORSet(obj.edgeAlive ?? {}), prop: deserializeProps(obj.prop ?? []), observedFrontier: VersionVector.from(obj.observedFrontier ?? {}), edgeBirthEvent: deserializeEdgeBirthEvent(obj), @@ -119,8 +123,8 @@ export function deserializeFullState( interface DeserializedFullState { version?: string; - nodeAlive?: { [x: string]: string[] }; - edgeAlive?: { [x: string]: string[] }; + nodeAlive?: ORSetWire; + edgeAlive?: ORSetWire; prop?: Array<[string, unknown]>; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B observedFrontier?: { [x: string]: number }; edgeBirthEvent?: Array<[string, unknown]>; // nosemgrep: ts-no-unknown-outside-adapters -- 0025B @@ -141,8 +145,8 @@ export function serializeCheckpointStateEnvelope( ): CheckpointStateEnvelopeBuffers { const c = requireCodec(codec, 'serializeCheckpointStateEnvelope'); return { - nodeAlive: c.encode(state.nodeAlive.serialize()), - edgeAlive: c.encode(state.edgeAlive.serialize()), + nodeAlive: c.encode(serializeORSet(state.nodeAlive)), + edgeAlive: c.encode(serializeORSet(state.edgeAlive)), prop: c.encode(serializePropsArray(WarpState.allPropEntriesFromState(state))), observedFrontier: c.encode(VersionVector.serialize(state.observedFrontier)), edgeBirthEvent: c.encode(serializeEdgeBirthArray(state.edgeBirthEvent)), @@ -152,17 +156,17 @@ export function serializeCheckpointStateEnvelope( export function deserializeCheckpointStateEnvelope( buffers: CheckpointStateEnvelopeBuffers, { codec }: { codec?: CodecPort } = {}, -): WarpStateType { +): WarpState { const c = requireCodec(codec, 'deserializeCheckpointStateEnvelope'); const emptyORSet = { entries: [], tombstones: [] }; return new WarpState({ - nodeAlive: ORSet.deserialize(decodeEnvelopeBlob(c, buffers.nodeAlive, emptyORSet)), - edgeAlive: ORSet.deserialize(decodeEnvelopeBlob(c, buffers.edgeAlive, emptyORSet)), + nodeAlive: deserializeORSet(decodeEnvelopeBlob(c, buffers.nodeAlive, emptyORSet)), + edgeAlive: deserializeORSet(decodeEnvelopeBlob(c, buffers.edgeAlive, emptyORSet)), prop: deserializeProps(decodeEnvelopeBlob(c, buffers.prop, [])), observedFrontier: VersionVector.from(decodeEnvelopeBlob(c, buffers.observedFrontier, {})), - edgeBirthEvent: deserializeEdgeBirthEvent({ - edgeBirthEvent: decodeEnvelopeBlob(c, buffers.edgeBirthEvent, []), - }), + edgeBirthEvent: deserializeCurrentEdgeBirthEvent( + decodeEnvelopeBlob(c, buffers.edgeBirthEvent, []), + ), }); } @@ -232,10 +236,6 @@ export function deserializeAppliedVV( return VersionVector.from(obj); } -// ============================================================================ -// Helper Functions -// ============================================================================ - function deserializeProps(propArray: Array<[string, unknown]>): Map> { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B const prop = new Map>(); if (!Array.isArray(propArray)) { return prop; } @@ -258,6 +258,62 @@ function deserializeEdgeBirthEvent(obj: DeserializedFullState): Map; +const UNIDENTIFIED_EDGE_BIRTH_KEY = ''; + +function deserializeCurrentEdgeBirthEvent(value: CurrentEdgeBirthEventWire): Map { + if (!Array.isArray(value)) { + throw invalidCurrentEdgeBirthEvent(UNIDENTIFIED_EDGE_BIRTH_KEY); + } + const edgeBirthEvent = new Map(); + for (const entry of value) { + if (!Array.isArray(entry) || entry.length !== 2) { + throw invalidCurrentEdgeBirthEvent(UNIDENTIFIED_EDGE_BIRTH_KEY); + } + const [key, payload] = entry; + if (typeof key !== 'string' || !isCurrentEdgeBirthEventPayload(payload)) { + throw invalidCurrentEdgeBirthEvent( + typeof key === 'string' ? key : UNIDENTIFIED_EDGE_BIRTH_KEY, + ); + } + try { + edgeBirthEvent.set( + key, + new EventId(payload.lamport, payload.writerId, payload.patchSha, payload.opIndex), + ); + } catch { + throw invalidCurrentEdgeBirthEvent(key); + } + } + return edgeBirthEvent; +} + +function isCurrentEdgeBirthEventPayload( + value: CurrentEdgeBirthEventPayload | null | undefined, +): value is CurrentEdgeBirthEventPayload { + if (typeof value !== 'object' || value === null) { + return false; + } + return typeof value.lamport === 'number' + && typeof value.writerId === 'string' + && typeof value.patchSha === 'string' + && typeof value.opIndex === 'number'; +} + +function invalidCurrentEdgeBirthEvent(key: string): WarpError { + return new WarpError( + `Checkpoint edgeBirthEvent payload is invalid for ${key}`, + 'E_INVALID_CHECKPOINT_EDGE_BIRTH_EVENT', + ); +} + function deserializeSingleBirthEvent(val: unknown): { lamport: number; writerId: string; patchSha: string; opIndex: number } { // nosemgrep: ts-no-unknown-outside-adapters -- 0025B if (typeof val === 'number') { return { lamport: val, writerId: '', patchSha: '0000', opIndex: 0 }; @@ -266,8 +322,7 @@ function deserializeSingleBirthEvent(val: unknown): { lamport: number; writerId: return { lamport: ev.lamport, writerId: ev.writerId, patchSha: ev.patchSha, opIndex: ev.opIndex }; } -function serializeLWWRegister(register: LWWRegister): SerializedLWWRegister | null { - if (register === null || register === undefined) { return null; } +function serializeLWWRegister(register: LWWRegister): SerializedLWWRegister { return { eventId: { lamport: register.eventId.lamport, diff --git a/src/domain/services/state/ORSetWireBoundary.ts b/src/domain/services/state/ORSetWireBoundary.ts new file mode 100644 index 000000000..30955b944 --- /dev/null +++ b/src/domain/services/state/ORSetWireBoundary.ts @@ -0,0 +1,85 @@ +import ORSet from '../../crdt/ORSet.ts'; +import { Dot } from '../../crdt/Dot.ts'; +import CrdtError from '../../errors/CrdtError.ts'; +import { compareStrings } from '../../utils/StringComparison.ts'; + +export type SerializedORSet = { + entries: Array<[string, string[]]>; + tombstones: string[]; +}; + +export type ORSetWire = { + entries?: Array<[string, string[]]>; + tombstones?: string[]; +}; + +export function serializeORSet(set: ORSet): SerializedORSet { + return { + entries: serializeEntries(set.entriesIter()), + tombstones: sortEncodedDots(set.tombstonesIter()), + }; +} + +export function deserializeORSet(wire: ORSetWire): ORSet { + const set = ORSet.empty(); + deserializeEntriesInto(wire.entries, set.entries); + deserializeTombstonesInto(wire.tombstones, set.tombstones); + return set; +} + +function sortEncodedDots(encodedDots: Iterable): string[] { + const pairs: Array<{ encoded: string; decoded: Dot }> = []; + for (const encoded of encodedDots) { + pairs.push({ encoded, decoded: Dot.decode(encoded) }); + } + pairs.sort((left, right) => Dot.compare(left.decoded, right.decoded)); + return pairs.map((pair) => pair.encoded); +} + +function serializeEntries( + entries: Iterable<[string, ReadonlySet]>, +): Array<[string, string[]]> { + const result: Array<[string, string[]]> = []; + for (const [element, dots] of entries) { + result.push([element, sortEncodedDots(dots)]); + } + result.sort((left, right) => compareStrings(left[0], right[0])); + return result; +} + +function deserializeEntriesInto( + entries: Array<[string, string[]]> | undefined, + target: Map>, +): void { + if (!Array.isArray(entries)) { + return; + } + for (const [element, dots] of entries) { + if (!Array.isArray(dots)) { + throw new CrdtError('ORSet entry dots must be an array'); + } + target.set(element, validatedDots(dots)); + } +} + +function deserializeTombstonesInto( + tombstones: string[] | undefined, + target: Set, +): void { + if (!Array.isArray(tombstones)) { + return; + } + for (const dot of tombstones) { + Dot.decode(dot); + target.add(dot); + } +} + +function validatedDots(dots: readonly string[]): Set { + const validated = new Set(); + for (const dot of dots) { + Dot.decode(dot); + validated.add(dot); + } + return validated; +} diff --git a/src/domain/services/state/WarpStateCacheRepairResult.ts b/src/domain/services/state/WarpStateCacheRepairResult.ts new file mode 100644 index 000000000..db54acfb7 --- /dev/null +++ b/src/domain/services/state/WarpStateCacheRepairResult.ts @@ -0,0 +1,56 @@ +import WarpError from '../../errors/WarpError.ts'; +import WarpStateCacheRetentionReport from './WarpStateCacheRetentionReport.ts'; + +type WarpStateCacheRepairResultOptions = { + readonly before: WarpStateCacheRetentionReport; + readonly after: WarpStateCacheRetentionReport; + readonly anchoredSnapshotIds: readonly string[]; + readonly unrecoverableSnapshotIds: readonly string[]; + readonly removedStaleRootNames: readonly string[]; +}; + +function repairedNames(values: readonly string[], field: string): readonly string[] { + const names = new Set(); + for (const value of values) { + if (value.length === 0) { + throw new WarpError( + `State-cache repair result ${field} cannot contain an empty name`, + 'E_CACHE_RETENTION_REPAIR_INVALID', + ); + } + names.add(value); + } + return Object.freeze([...names].sort()); +} + +export default class WarpStateCacheRepairResult { + readonly before: WarpStateCacheRetentionReport; + readonly after: WarpStateCacheRetentionReport; + readonly anchoredSnapshotIds: readonly string[]; + readonly unrecoverableSnapshotIds: readonly string[]; + readonly removedStaleRootNames: readonly string[]; + + constructor(options: WarpStateCacheRepairResultOptions) { + if (!(options.before instanceof WarpStateCacheRetentionReport)) { + throw new WarpError( + 'State-cache repair result requires a retention report for before', + 'E_CACHE_RETENTION_REPAIR_INVALID', + ); + } + if (!(options.after instanceof WarpStateCacheRetentionReport)) { + throw new WarpError( + 'State-cache repair result requires a retention report for after', + 'E_CACHE_RETENTION_REPAIR_INVALID', + ); + } + this.before = options.before; + this.after = options.after; + this.anchoredSnapshotIds = repairedNames(options.anchoredSnapshotIds, 'anchoredSnapshotIds'); + this.unrecoverableSnapshotIds = repairedNames( + options.unrecoverableSnapshotIds, + 'unrecoverableSnapshotIds', + ); + this.removedStaleRootNames = repairedNames(options.removedStaleRootNames, 'removedStaleRootNames'); + Object.freeze(this); + } +} diff --git a/src/domain/services/state/WarpStateCacheRetentionReport.ts b/src/domain/services/state/WarpStateCacheRetentionReport.ts new file mode 100644 index 000000000..884b27629 --- /dev/null +++ b/src/domain/services/state/WarpStateCacheRetentionReport.ts @@ -0,0 +1,67 @@ +import WarpError from '../../errors/WarpError.ts'; + +const INVALID_RETENTION_REPORT = 'E_CACHE_RETENTION_REPORT_INVALID'; + +type WarpStateCacheRetentionReportOptions = { + readonly liveSnapshotIds: readonly string[]; + readonly anchoredSnapshotIds: readonly string[]; + readonly unanchoredSnapshotIds: readonly string[]; + readonly missingSnapshotIds: readonly string[]; + readonly wrongTypeSnapshotIds: readonly string[]; + readonly staleRootNames: readonly string[]; + readonly mismatchedRootNames: readonly string[]; + readonly rootSetError: string | null; +}; + +function normalizedNames(values: readonly string[], field: string): readonly string[] { + const names = new Set(); + for (const value of values) { + if (value.length === 0) { + throw new WarpError( + `State-cache retention report ${field} cannot contain an empty name`, + INVALID_RETENTION_REPORT, + ); + } + names.add(value); + } + return Object.freeze([...names].sort()); +} + +export default class WarpStateCacheRetentionReport { + readonly liveSnapshotIds: readonly string[]; + readonly anchoredSnapshotIds: readonly string[]; + readonly unanchoredSnapshotIds: readonly string[]; + readonly missingSnapshotIds: readonly string[]; + readonly wrongTypeSnapshotIds: readonly string[]; + readonly staleRootNames: readonly string[]; + readonly mismatchedRootNames: readonly string[]; + readonly rootSetError: string | null; + + constructor(options: WarpStateCacheRetentionReportOptions) { + if (options.rootSetError !== null && options.rootSetError.length === 0) { + throw new WarpError( + 'State-cache retention report rootSetError cannot be empty', + INVALID_RETENTION_REPORT, + ); + } + this.liveSnapshotIds = normalizedNames(options.liveSnapshotIds, 'liveSnapshotIds'); + this.anchoredSnapshotIds = normalizedNames(options.anchoredSnapshotIds, 'anchoredSnapshotIds'); + this.unanchoredSnapshotIds = normalizedNames(options.unanchoredSnapshotIds, 'unanchoredSnapshotIds'); + this.missingSnapshotIds = normalizedNames(options.missingSnapshotIds, 'missingSnapshotIds'); + this.wrongTypeSnapshotIds = normalizedNames(options.wrongTypeSnapshotIds, 'wrongTypeSnapshotIds'); + this.staleRootNames = normalizedNames(options.staleRootNames, 'staleRootNames'); + this.mismatchedRootNames = normalizedNames(options.mismatchedRootNames, 'mismatchedRootNames'); + this.rootSetError = options.rootSetError; + Object.freeze(this); + } + + isHealthy(): boolean { + const issueCount = this.unanchoredSnapshotIds.length + + this.missingSnapshotIds.length + + this.wrongTypeSnapshotIds.length + + this.staleRootNames.length + + this.mismatchedRootNames.length + + Number(this.rootSetError !== null); + return issueCount === 0; + } +} diff --git a/src/domain/services/state/checkpointCreate.ts b/src/domain/services/state/checkpointCreate.ts index 87a278744..991910402 100644 --- a/src/domain/services/state/checkpointCreate.ts +++ b/src/domain/services/state/checkpointCreate.ts @@ -14,6 +14,8 @@ import { serializeCheckpointStateEnvelope, } from './CheckpointSerializer.ts'; import { serializeFrontier } from '../Frontier.ts'; +import { requireCodec } from '../codec/CodecRequirement.ts'; +import { requireCrypto } from '../crypto/CryptoRequirement.ts'; import { requireCommitMessageCodec } from '../codec/CommitMessageCodecRequirement.ts'; import { cloneState } from '../JoinReducer.ts'; import { @@ -31,6 +33,10 @@ import type CodecPort from '../../../ports/CodecPort.ts'; import type CommitMessageCodecPort from '../../../ports/CommitMessageCodecPort.ts'; import type CryptoPort from '../../../ports/CryptoPort.ts'; import type CheckpointStorePort from '../../../ports/CheckpointStorePort.ts'; +import type { + CheckpointRecord, + CheckpointWriteResult, +} from '../../../ports/CheckpointStorePort.ts'; import type StateHashService from './StateHashService.ts'; import type { ProvenanceIndex } from '../provenance/ProvenanceIndex.ts'; @@ -118,41 +124,44 @@ export async function createCheckpointEnvelope({ } // 3–6. Serialize and write current state envelope, frontier, appliedVV. - // The previous CheckpointStorePort path wrote a single state.cbor blob; - // current checkpoints keep the option for API compatibility but publish the - // runtime checkpoint through named envelope artifacts. - // codecOpt is still needed for envelope/provenance serialization. - const codecOpt = codec !== undefined && codec !== null ? { codec } : {}; + // Runtime callers route artifact encoding through CheckpointStorePort so the + // domain path no longer needs to know the concrete checkpoint blob layout. let stateHash: string; - let provenanceIndexBlobOid: string | null = null; // Compute stateHash first via StateHashService (preferred) or direct fallback. if (stateHashService !== undefined && stateHashService !== null) { stateHash = await stateHashService.compute(checkpointState); } else { - stateHash = await computeStateHash(checkpointState, { ...codecOpt, crypto: crypto as CryptoPort }); + stateHash = await computeStateHash(checkpointState, { + codec: requireCodec(codec, 'createCheckpointEnvelope'), + crypto: requireCrypto(crypto, 'createCheckpointEnvelope'), + }); } - void checkpointStore; - - // Current checkpoints publish separate envelope artifacts so the Git tree names - // each read basis member. - const stateEnvelope = serializeCheckpointStateEnvelope(checkpointState, codecOpt); - const nodeAliveOid = await persistence.writeBlob(stateEnvelope.nodeAlive); - const edgeAliveOid = await persistence.writeBlob(stateEnvelope.edgeAlive); - const propOid = await persistence.writeBlob(stateEnvelope.prop); - const observedFrontierOid = await persistence.writeBlob(stateEnvelope.observedFrontier); - const edgeBirthEventOid = await persistence.writeBlob(stateEnvelope.edgeBirthEvent); - - const frontierBuffer = serializeFrontier(frontier, codecOpt); - const appliedVVBuffer = serializeAppliedVV(appliedVV, codecOpt); - const frontierBlobOid = await persistence.writeBlob(frontierBuffer); - const appliedVVBlobOid = await persistence.writeBlob(appliedVVBuffer); - - if (provenanceIndex) { - const provenanceIndexBuffer = provenanceIndex.serialize(codecOpt); - provenanceIndexBlobOid = await persistence.writeBlob(provenanceIndexBuffer); - } + const checkpointRecord: CheckpointRecord = { + state: checkpointState, + frontier, + appliedVV, + stateHash, + ...(provenanceIndex !== undefined ? { provenanceIndex } : {}), + }; + const checkpointWrite = checkpointStore !== undefined && checkpointStore !== null + ? await checkpointStore.writeCheckpoint(checkpointRecord) + : await writeFallbackCheckpointArtifacts( + persistence, + checkpointRecord, + requireCodec(codec, 'createCheckpointEnvelope'), + ); + const { + nodeAliveBlobOid: nodeAliveOid, + edgeAliveBlobOid: edgeAliveOid, + propBlobOid: propOid, + observedFrontierBlobOid: observedFrontierOid, + edgeBirthEventBlobOid: edgeBirthEventOid, + frontierBlobOid, + appliedVVBlobOid, + provenanceIndexBlobOid, + } = checkpointWrite; // 6c. Collect content storage OIDs from state properties for GC anchoring. // If patch commits are ever pruned, content trees remain reachable via @@ -225,3 +234,43 @@ export async function createCheckpointEnvelope({ return checkpointSha; } + +async function writeFallbackCheckpointArtifacts( + persistence: CheckpointPersistence, + record: CheckpointRecord, + codec: CodecPort, +): Promise { + const codecOpt = { codec }; + const envelope = serializeCheckpointStateEnvelope(record.state, codecOpt); + const [ + nodeAliveBlobOid, + edgeAliveBlobOid, + propBlobOid, + observedFrontierBlobOid, + edgeBirthEventBlobOid, + frontierBlobOid, + appliedVVBlobOid, + provenanceIndexBlobOid, + ] = await Promise.all([ + persistence.writeBlob(envelope.nodeAlive), + persistence.writeBlob(envelope.edgeAlive), + persistence.writeBlob(envelope.prop), + persistence.writeBlob(envelope.observedFrontier), + persistence.writeBlob(envelope.edgeBirthEvent), + persistence.writeBlob(serializeFrontier(record.frontier, codecOpt)), + persistence.writeBlob(serializeAppliedVV(record.appliedVV, codecOpt)), + record.provenanceIndex === undefined || record.provenanceIndex === null + ? Promise.resolve(null) + : persistence.writeBlob(record.provenanceIndex.serialize(codecOpt)), + ]); + return { + nodeAliveBlobOid, + edgeAliveBlobOid, + propBlobOid, + observedFrontierBlobOid, + edgeBirthEventBlobOid, + frontierBlobOid, + appliedVVBlobOid, + provenanceIndexBlobOid, + }; +} diff --git a/src/domain/services/state/checkpointLoad.ts b/src/domain/services/state/checkpointLoad.ts index ec7564e50..ae2f75dcf 100644 --- a/src/domain/services/state/checkpointLoad.ts +++ b/src/domain/services/state/checkpointLoad.ts @@ -90,7 +90,6 @@ export async function loadCheckpoint( // Build codec option object once for exactOptionalPropertyTypes compliance const loadCodecOpt = codec !== undefined && codec !== null ? { codec } : {}; - void checkpointStore; // 3. Read tree entries via the indexOid from the message (points to the tree) const rawTreeOids = await persistence.readTreeOids(decoded.indexOid); @@ -104,6 +103,24 @@ export async function loadCheckpoint( partitionedTree.indexShardOids, ); + if (checkpointStore !== undefined && checkpointStore !== null) { + const checkpoint = await checkpointStore.readCheckpoint(treeOids); + const result: LoadedCheckpoint = { + state: checkpoint.state, + frontier: checkpoint.frontier, + stateHash: decoded.stateHash, + schema: decoded.schema, + appliedVV: checkpoint.appliedVV, + indexShardOids: Object.keys(indexShardOids).length > 0 + ? indexShardOids + : checkpoint.indexShardOids, + }; + if (checkpoint.provenanceIndex !== null && checkpoint.provenanceIndex !== undefined) { + result.provenanceIndex = checkpoint.provenanceIndex; + } + return result; + } + // Current path: read each envelope blob individually. const frontierOid = treeOids['frontier.cbor']; if (frontierOid === undefined) { diff --git a/src/domain/types/DeliveryObservation.ts b/src/domain/types/DeliveryObservation.ts index 728e4608f..837d57663 100644 --- a/src/domain/types/DeliveryObservation.ts +++ b/src/domain/types/DeliveryObservation.ts @@ -11,7 +11,6 @@ */ import WarpError from '../errors/WarpError.ts'; -import { sortedReplacer } from '../utils/canonicalStringify.ts'; import { requireNonEmptyString, validateTimestamp } from '../utils/scalarValidation.ts'; import { validateOutcome, DELIVERY_MODES, type ExternalizationPolicy, type DeliveryOutcome } from './ExternalizationPolicy.ts'; @@ -130,14 +129,3 @@ export function createDeliveryObservation({ emissionId, sinkId, outcome, reason, ...(reason !== undefined ? { reason } : {}), }); } - -// ============================================================================ -// Canonical JSON -// ============================================================================ - -/** - * Produces a deterministic JSON string for a DeliveryObservation. - */ -export function canonicalObservationJson(observation: DeliveryObservation): string { - return JSON.stringify(observation, sortedReplacer); // nosemgrep: ts-no-json-stringify-in-core -- 0025B -} diff --git a/src/domain/types/EffectEmission.ts b/src/domain/types/EffectEmission.ts index 6fcd598ba..54241fcfb 100644 --- a/src/domain/types/EffectEmission.ts +++ b/src/domain/types/EffectEmission.ts @@ -13,7 +13,6 @@ */ import WarpError from '../errors/WarpError.ts'; -import { sortedReplacer } from '../utils/canonicalStringify.ts'; import { requireNonEmptyString, validateTimestamp } from '../utils/scalarValidation.ts'; import { DELIVERY_MODES, DELIVERY_OUTCOMES } from './ExternalizationPolicy.ts'; @@ -111,14 +110,3 @@ export function createEffectEmission({ id, kind, payload, timestamp, writer, coo }): Readonly { return new EffectEmission({ id, kind, payload, timestamp, writer, coordinate }); } - -// ============================================================================ -// Canonical JSON -// ============================================================================ - -/** - * Produces a deterministic JSON string for an EffectEmission. - */ -export function canonicalEmissionJson(emission: EffectEmission): string { - return JSON.stringify(emission, sortedReplacer); // nosemgrep: ts-no-json-stringify-in-core -- 0025B -} diff --git a/src/domain/types/TickReceipt.ts b/src/domain/types/TickReceipt.ts index b5ebc3498..41b64899a 100644 --- a/src/domain/types/TickReceipt.ts +++ b/src/domain/types/TickReceipt.ts @@ -12,7 +12,6 @@ */ import WarpError from '../errors/WarpError.ts'; -import { sortedReplacer } from '../utils/canonicalStringify.ts'; import { requireNonEmptyString } from '../utils/scalarValidation.ts'; // ============================================================================ @@ -224,18 +223,3 @@ function freezeOps(ops: OpOutcome[]): ReadonlyArray> { }), ); } - -// ============================================================================ -// Canonical JSON Serialization -// ============================================================================ - -/** - * Produces a deterministic JSON string for a TickReceipt. - * - * Keys are sorted alphabetically at every nesting level, ensuring - * identical receipts always produce identical byte strings regardless - * of property insertion order. - */ -export function canonicalJson(receipt: TickReceipt): string { - return JSON.stringify(receipt, sortedReplacer); // nosemgrep: ts-no-json-stringify-in-core -- 0025B -} diff --git a/src/domain/warp/RuntimeHostBoot.ts b/src/domain/warp/RuntimeHostBoot.ts index 98f0138f7..3aa44420e 100644 --- a/src/domain/warp/RuntimeHostBoot.ts +++ b/src/domain/warp/RuntimeHostBoot.ts @@ -343,7 +343,6 @@ export async function resolveRuntimeHostConstructionOptions( resolvedCheckpointStore = new CborCheckpointStoreAdapter({ codec: resolvedCodec, blobPort, - blobStorage: resolvedBlobStorage, }); } diff --git a/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts index 47ef1630b..cf29db877 100644 --- a/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts +++ b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts @@ -1,20 +1,47 @@ import CheckpointStorePort, { type CheckpointRecord, type CheckpointWriteResult, type CheckpointData } from '../../ports/CheckpointStorePort.ts'; +import type BlobPort from '../../ports/BlobPort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import WarpError from '../../domain/errors/WarpError.ts'; -import ORSet from '../../domain/crdt/ORSet.ts'; import VersionVector from '../../domain/crdt/VersionVector.ts'; -import { createEmptyState } from '../../domain/services/JoinReducer.ts'; -import WarpState from '../../domain/services/state/WarpState.ts'; +import type WarpState from '../../domain/services/state/WarpState.ts'; import { ProvenanceIndex } from '../../domain/services/provenance/ProvenanceIndex.ts'; -import type { LWWRegister } from '../../domain/crdt/LWW.ts'; -import type { PropValue } from '../../domain/types/PropValue.ts'; -import type { EventId } from '../../domain/utils/EventId.ts'; -import type BlobStoragePort from '../../ports/BlobStoragePort.ts'; -import { readPayloadBlob, writePayloadBlob } from './CasPayloadPointer.ts'; - -interface BlobPort { - readBlob(oid: string): Promise; - writeBlob(content: Uint8Array | string): Promise; +import { + deserializeCheckpointStateEnvelope, + serializeCheckpointStateEnvelope, + type CheckpointStateEnvelopeBuffers, +} from '../../domain/services/state/CheckpointSerializer.ts'; + +interface CheckpointWritePromises { + nodeAliveBlobOid: Promise; + edgeAliveBlobOid: Promise; + propBlobOid: Promise; + observedFrontierBlobOid: Promise; + edgeBirthEventBlobOid: Promise; + frontierBlobOid: Promise; + appliedVVBlobOid: Promise; + provenanceIndexBlobOid: Promise; +} + +interface CheckpointReadPromises { + nodeAlive: Promise; + edgeAlive: Promise; + prop: Promise; + observedFrontier: Promise; + edgeBirthEvent: Promise; + frontier: Promise; + appliedVV: Promise; + provenanceIndex: Promise; +} + +interface CheckpointReadBuffers { + nodeAlive: Uint8Array; + edgeAlive: Uint8Array; + prop: Uint8Array; + observedFrontier: Uint8Array; + edgeBirthEvent: Uint8Array; + frontier: Uint8Array; + appliedVV: Uint8Array | null; + provenanceIndex: Uint8Array | null; } /** @@ -27,12 +54,10 @@ interface BlobPort { export class CborCheckpointStoreAdapter extends CheckpointStorePort { private readonly _codec: CodecPort; private readonly _blobPort: BlobPort; - private readonly _blobStorage: BlobStoragePort | null; - constructor({ codec, blobPort, blobStorage }: { + constructor({ codec, blobPort }: { codec: CodecPort; blobPort: BlobPort; - blobStorage?: BlobStoragePort | null; }) { super(); if (codec === null || codec === undefined) { @@ -43,11 +68,10 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { } this._codec = codec; this._blobPort = blobPort; - this._blobStorage = blobStorage ?? null; } override async writeCheckpoint(record: CheckpointRecord): Promise { - const stateBytes = this._encodeFullState(record.state); + const stateEnvelope = this._encodeStateEnvelope(record.state); const frontierBytes = this._encodeFrontier(record.frontier); const appliedVVBytes = this._encodeAppliedVV(record.appliedVV); @@ -56,97 +80,64 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { provenanceBytes = record.provenanceIndex.serialize({ codec: this._codec }); } - const writes: Array> = [ - writePayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - bytes: stateBytes, - options: { - slug: 'checkpoint-state', - mime: 'application/cbor', - size: stateBytes.length, - }, - }), - writePayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - bytes: frontierBytes, - options: { - slug: 'checkpoint-frontier', - mime: 'application/cbor', - size: frontierBytes.length, - }, - }), - writePayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - bytes: appliedVVBytes, - options: { - slug: 'checkpoint-applied-vv', - mime: 'application/cbor', - size: appliedVVBytes.length, - }, - }), - ]; - if (provenanceBytes !== null) { - writes.push(writePayloadBlob({ - blobPort: this._blobPort, - blobStorage: this._blobStorage, - bytes: provenanceBytes, - options: { - slug: 'checkpoint-provenance-index', - mime: 'application/cbor', - size: provenanceBytes.length, - }, - })); - } - - const oids = await Promise.all(writes); - return { - stateBlobOid: oids[0] as string, - frontierBlobOid: oids[1] as string, - appliedVVBlobOid: oids[2] as string, - provenanceIndexBlobOid: oids.length > 3 ? (oids[3] as string) : null, + const writes: CheckpointWritePromises = { + nodeAliveBlobOid: this._writeCheckpointBlob(stateEnvelope.nodeAlive), + edgeAliveBlobOid: this._writeCheckpointBlob(stateEnvelope.edgeAlive), + propBlobOid: this._writeCheckpointBlob(stateEnvelope.prop), + observedFrontierBlobOid: this._writeCheckpointBlob(stateEnvelope.observedFrontier), + edgeBirthEventBlobOid: this._writeCheckpointBlob(stateEnvelope.edgeBirthEvent), + frontierBlobOid: this._writeCheckpointBlob(frontierBytes), + appliedVVBlobOid: this._writeCheckpointBlob(appliedVVBytes), + provenanceIndexBlobOid: provenanceBytes !== null + ? this._writeCheckpointBlob(provenanceBytes) + : Promise.resolve(null), }; + + return await this._resolveCheckpointWrites(writes); } override async readCheckpoint(treeOids: Record): Promise { - const stateOid = treeOids['state.cbor']; const frontierOid = treeOids['frontier.cbor']; const appliedVVOid = treeOids['appliedVV.cbor']; const provenanceOid = treeOids['provenanceIndex.cbor']; - if (stateOid === undefined) { - throw new WarpError('Checkpoint missing state.cbor', 'E_MISSING_ARTIFACT'); - } if (frontierOid === undefined) { throw new WarpError('Checkpoint missing frontier.cbor', 'E_MISSING_ARTIFACT'); } - const reads: Array> = [ - readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: stateOid }), - readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: frontierOid }), - ]; - if (appliedVVOid !== undefined) { - reads.push(readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: appliedVVOid })); - } - if (provenanceOid !== undefined) { - reads.push(readPayloadBlob({ blobPort: this._blobPort, blobStorage: this._blobStorage, oid: provenanceOid })); - } + const reads: CheckpointReadPromises = { + nodeAlive: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/nodeAlive')), + edgeAlive: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/edgeAlive')), + prop: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/prop.cbor')), + observedFrontier: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/observedFrontier.cbor')), + edgeBirthEvent: this._readCheckpointBlob(this._requireTreeOid(treeOids, 'state/edgeBirthEvent.cbor')), + frontier: this._readCheckpointBlob(frontierOid), + appliedVV: appliedVVOid !== undefined + ? this._readCheckpointBlob(appliedVVOid) + : Promise.resolve(null), + provenanceIndex: provenanceOid !== undefined + ? this._readCheckpointBlob(provenanceOid) + : Promise.resolve(null), + }; - const buffers = await Promise.all(reads); - let idx = 0; - const state = this._decodeFullState(buffers[idx++] as Uint8Array); - const frontier = this._decodeFrontier(buffers[idx++] as Uint8Array); + const buffers = await this._resolveCheckpointReads(reads); + const state = this._decodeStateEnvelope({ + nodeAlive: buffers.nodeAlive, + edgeAlive: buffers.edgeAlive, + prop: buffers.prop, + observedFrontier: buffers.observedFrontier, + edgeBirthEvent: buffers.edgeBirthEvent, + }); + const frontier = this._decodeFrontier(buffers.frontier); let appliedVV: VersionVector | null = null; - if (appliedVVOid !== undefined) { - appliedVV = this._decodeAppliedVV(buffers[idx++] as Uint8Array); + if (buffers.appliedVV !== null) { + appliedVV = this._decodeAppliedVV(buffers.appliedVV); } let provenanceIndex: ProvenanceIndex | null = null; - if (provenanceOid !== undefined) { - provenanceIndex = ProvenanceIndex.deserialize(buffers[idx++] as Uint8Array, { codec: this._codec }); + if (buffers.provenanceIndex !== null) { + provenanceIndex = ProvenanceIndex.deserialize(buffers.provenanceIndex, { codec: this._codec }); } let indexShardOids: Record | null = null; @@ -159,8 +150,6 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { state, frontier, appliedVV, - stateHash: '', - schema: 2, ...(provenanceIndex !== null ? { provenanceIndex } : {}), indexShardOids, }; @@ -168,15 +157,8 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { // ── Encode Helpers ────────────────────────────────────────────────── - private _encodeFullState(state: WarpState): Uint8Array { - return this._codec.encode({ - version: 'full-v5', - nodeAlive: state.nodeAlive.serialize(), - edgeAlive: state.edgeAlive.serialize(), - prop: _serializePropsArray(state.allPropEntries()), - observedFrontier: VersionVector.serialize(state.observedFrontier), - edgeBirthEvent: _serializeEdgeBirthArray(state.edgeBirthEvent), - }); + private _encodeStateEnvelope(state: WarpState): CheckpointStateEnvelopeBuffers { + return serializeCheckpointStateEnvelope(state, { codec: this._codec }); } private _encodeFrontier(frontier: Map): Uint8Array { @@ -193,27 +175,8 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { // ── Decode Helpers ────────────────────────────────────────────────── - private _decodeFullState(buffer: Uint8Array): WarpState { - if (buffer === null || buffer === undefined) { - return createEmptyState(); - } - const obj = this._codec.decode(buffer); - if (obj === null || obj === undefined) { - return createEmptyState(); - } - if (obj.version !== undefined && obj.version !== 'full-v5') { - throw new WarpError( - `Unsupported full state version: expected 'full-v5', got '${JSON.stringify(obj.version)}'`, - 'E_UNSUPPORTED_VERSION', - ); - } - return new WarpState({ - nodeAlive: ORSet.deserialize(obj.nodeAlive ?? {}), - edgeAlive: ORSet.deserialize(obj.edgeAlive ?? {}), - prop: _deserializeProps(obj.prop ?? []), - observedFrontier: VersionVector.from(obj.observedFrontier ?? {}), - edgeBirthEvent: _deserializeEdgeBirthEvent(obj), - }); + private _decodeStateEnvelope(envelope: CheckpointStateEnvelopeBuffers): WarpState { + return deserializeCheckpointStateEnvelope(envelope, { codec: this._codec }); } private _decodeFrontier(buffer: Uint8Array): Map { @@ -229,93 +192,86 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { const obj = this._codec.decode>(buffer); return VersionVector.from(obj); } -} -interface DecodedFullState { - version?: string; - nodeAlive?: { [x: string]: string[] }; - edgeAlive?: { [x: string]: string[] }; - prop?: Array<[string, unknown]>; - observedFrontier?: { [x: string]: number }; - edgeBirthEvent?: Array<[string, { writerId?: string; lamport?: number }]>; - edgeBirthLamport?: Array<[string, number]>; -} + private async _resolveCheckpointWrites(writes: CheckpointWritePromises): Promise { + const [ + nodeAliveBlobOid, + edgeAliveBlobOid, + propBlobOid, + observedFrontierBlobOid, + edgeBirthEventBlobOid, + frontierBlobOid, + appliedVVBlobOid, + provenanceIndexBlobOid, + ] = await Promise.all([ + writes.nodeAliveBlobOid, + writes.edgeAliveBlobOid, + writes.propBlobOid, + writes.observedFrontierBlobOid, + writes.edgeBirthEventBlobOid, + writes.frontierBlobOid, + writes.appliedVVBlobOid, + writes.provenanceIndexBlobOid, + ]); + + return { + nodeAliveBlobOid, + edgeAliveBlobOid, + propBlobOid, + observedFrontierBlobOid, + edgeBirthEventBlobOid, + frontierBlobOid, + appliedVVBlobOid, + provenanceIndexBlobOid, + }; + } -// ── Private Helpers ─────────────────────────────────────────────────── + private async _resolveCheckpointReads(reads: CheckpointReadPromises): Promise { + const [ + nodeAlive, + edgeAlive, + prop, + observedFrontier, + edgeBirthEvent, + frontier, + appliedVV, + provenanceIndex, + ] = await Promise.all([ + reads.nodeAlive, + reads.edgeAlive, + reads.prop, + reads.observedFrontier, + reads.edgeBirthEvent, + reads.frontier, + reads.appliedVV, + reads.provenanceIndex, + ]); -function _serializePropsArray(propEntries: Iterable]>): Array<[string, unknown]> { - const arr: Array<[string, unknown]> = []; - for (const [key, register] of propEntries) { - arr.push([key, _serializeLWWRegister(register)]); + return { + nodeAlive, + edgeAlive, + prop, + observedFrontier, + edgeBirthEvent, + frontier, + appliedVV, + provenanceIndex, + }; } - arr.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); - return arr; -} -function _serializeEdgeBirthArray(edgeBirthEvent: Map | undefined): Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> { - const result: Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> = []; - if (edgeBirthEvent !== undefined && edgeBirthEvent !== null) { - for (const [key, eventId] of edgeBirthEvent) { - result.push([key, { - lamport: eventId.lamport, writerId: eventId.writerId, - patchSha: eventId.patchSha, opIndex: eventId.opIndex, - }]); - } - result.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + private _writeCheckpointBlob(bytes: Uint8Array): Promise { + return this._blobPort.writeBlob(bytes); } - return result; -} -function _deserializeProps(propArray: Array<[string, unknown]>): Map> { - const prop = new Map>(); - if (!Array.isArray(propArray)) { return prop; } - for (const [key, registerObj] of propArray) { - const register = _deserializeLWWRegister( - registerObj as { eventId: { lamport: number; writerId: string; patchSha: string; opIndex: number }; value: unknown } | null, - ); - if (register !== null) { prop.set(key, register); } + private _readCheckpointBlob(oid: string): Promise { + return this._blobPort.readBlob(oid); } - return prop; -} -function _deserializeEdgeBirthEvent(obj: DecodedFullState): Map { - const result = new Map(); - const birthData = obj.edgeBirthEvent ?? obj.edgeBirthLamport; - if (!Array.isArray(birthData)) { return result; } - for (const [key, val] of birthData) { - if (typeof val === 'number') { - result.set(key, { lamport: val, writerId: '', patchSha: '0000', opIndex: 0 }); - } else { - const ev = val; - result.set(key, { - lamport: ev.lamport ?? 0, - writerId: ev.writerId ?? '', - patchSha: (ev as { patchSha?: string }).patchSha ?? '0000', - opIndex: (ev as { opIndex?: number }).opIndex ?? 0, - }); + private _requireTreeOid(treeOids: Record, path: string): string { + const oid = treeOids[path]; + if (oid === undefined) { + throw new WarpError(`Checkpoint missing ${path}`, 'E_MISSING_ARTIFACT'); } + return oid; } - return result; -} - -function _serializeLWWRegister(register: LWWRegister): { eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; value: unknown } | null { - if (register === null || register === undefined) { return null; } - return { - eventId: { - lamport: register.eventId.lamport, opIndex: register.eventId.opIndex, - patchSha: register.eventId.patchSha, writerId: register.eventId.writerId, - }, - value: register.value, - }; -} - -function _deserializeLWWRegister(obj: { eventId: { lamport: number; writerId: string; patchSha: string; opIndex: number }; value: unknown } | null): LWWRegister | null { - if (obj === null || obj === undefined) { return null; } - return { - eventId: { - lamport: obj.eventId.lamport, writerId: obj.eventId.writerId, - patchSha: obj.eventId.patchSha, opIndex: obj.eventId.opIndex, - }, - value: obj.value as PropValue, - }; } diff --git a/src/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.ts b/src/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.ts new file mode 100644 index 000000000..004dd8aca --- /dev/null +++ b/src/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.ts @@ -0,0 +1,307 @@ +import type { + RootSetDoctorResult, + RootSetEntry, + RootSetMutationResult, + RootSetState, +} from '@git-stunts/git-cas'; +import WarpStateCacheRetentionReport from '../../domain/services/state/WarpStateCacheRetentionReport.ts'; +import WarpStateCacheRepairResult from '../../domain/services/state/WarpStateCacheRepairResult.ts'; +import { validateGraphName } from '../../domain/utils/RefLayout.ts'; +import type { WarpStateSnapshotRecord } from '../../ports/WarpStateCachePort.ts'; + +const ROOT_SET_PREFIX = 'refs/cas/rootsets/git-warp'; + +interface RootSetClient { + read(): Promise; + mutate( + mutator: ( + entries: ReadonlyArray>, + ) => Iterable | Promise>, + ): Promise; + replace(options: { + entries: Iterable; + expectedHeadOid?: string | null; + }): Promise; + doctor(): Promise; + repair(options: { entries: Iterable }): Promise<{ + repaired: true; + commitOid: string; + treeOid: string; + entries: RootSetEntry[]; + }>; +} + +interface GitObjectProbe { + nodeExists(oid: string): Promise; + readObjectType(oid: string): Promise; +} + +type TargetProbe = { + readonly anchorable: WarpStateSnapshotRecord[]; + readonly missingSnapshotIds: string[]; + readonly wrongTypeSnapshotIds: string[]; +}; + +type RootComparison = { + readonly anchoredSnapshotIds: string[]; + readonly unanchoredSnapshotIds: string[]; + readonly mismatchedRootNames: string[]; +}; + +type GitCasStateCacheRootSetCoordinatorOptions = { + readonly graphName: string; + readonly openRootSet: (ref: string) => Promise; + readonly objectProbe: GitObjectProbe; +}; + +function compareEntryNames(left: RootSetEntry, right: RootSetEntry): number { + if (left.name < right.name) { return -1; } + if (left.name > right.name) { return 1; } + return 0; +} + +function entryForRecord(record: WarpStateSnapshotRecord): RootSetEntry { + return { + name: record.snapshotId, + oid: record.payloadRef, + type: 'tree', + retention: record.retention, + }; +} + +function entriesEqual(left: RootSetEntry, right: RootSetEntry): boolean { + return left.name === right.name + && left.oid === right.oid + && left.type === right.type + && left.retention === right.retention; +} + +function entryListsEqual( + left: ReadonlyArray>, + right: ReadonlyArray>, +): boolean { + return left.length === right.length + && left.every((entry, index) => entriesEqual(entry, right[index]!)); +} + +function mergedEntries( + current: ReadonlyArray>, + desired: readonly RootSetEntry[], +): RootSetEntry[] { + const byName = new Map(); + for (const entry of current) { + byName.set(entry.name, { + name: entry.name, + oid: entry.oid, + type: entry.type, + retention: entry.retention, + }); + } + for (const entry of desired) { + byName.set(entry.name, entry); + } + return [...byName.values()].sort(compareEntryNames); +} + +function rootSetError(doctor: RootSetDoctorResult): string | null { + if (doctor.healthy) { return null; } + return doctor.error?.message ?? 'Root-set doctor reported integrity issues'; +} + +function compareRootEntries( + records: readonly WarpStateSnapshotRecord[], + roots: readonly RootSetEntry[], +): RootComparison { + const rootsByName = new Map(roots.map((entry) => [entry.name, entry])); + const anchoredSnapshotIds: string[] = []; + const unanchoredSnapshotIds: string[] = []; + const mismatchedRootNames: string[] = []; + for (const record of records) { + const root = rootsByName.get(record.snapshotId); + if (root === undefined) { + unanchoredSnapshotIds.push(record.snapshotId); + } else if (entriesEqual(root, entryForRecord(record))) { + anchoredSnapshotIds.push(record.snapshotId); + } else { + unanchoredSnapshotIds.push(record.snapshotId); + mismatchedRootNames.push(record.snapshotId); + } + } + return { anchoredSnapshotIds, unanchoredSnapshotIds, mismatchedRootNames }; +} + +function findStaleRootNames( + records: readonly WarpStateSnapshotRecord[], + roots: readonly RootSetEntry[], +): string[] { + const liveNames = new Set(records.map((record) => record.snapshotId)); + return roots.filter((entry) => !liveNames.has(entry.name)).map((entry) => entry.name); +} + +export default class GitCasStateCacheRootSetCoordinator { + readonly rootSetRef: string; + private readonly _getRootSet: () => Promise; + private readonly _objectProbe: GitObjectProbe; + private readonly _anchoredTreeOids = new Set(); + + constructor(options: GitCasStateCacheRootSetCoordinatorOptions) { + validateGraphName(options.graphName); + this.rootSetRef = `${ROOT_SET_PREFIX}/${options.graphName}/state-cache`; + this._objectProbe = options.objectProbe; + let rootSetPromise: Promise | undefined; + this._getRootSet = async () => { + if (rootSetPromise === undefined) { + const opening = options.openRootSet(this.rootSetRef); + rootSetPromise = opening; + try { + return await opening; + } catch (error) { + if (rootSetPromise === opening) { + rootSetPromise = undefined; + } + throw error; + } + } + return await rootSetPromise; + }; + Object.freeze(this); + } + + async adopt(records: readonly WarpStateSnapshotRecord[]): Promise { + if (records.length === 0) { return; } + const rootSet = await this._getRootSet(); + const state = await rootSet.read(); + const currentByName = new Map(state.entries.map((entry) => [entry.name, entry])); + const candidates = records.filter((record) => { + const current = currentByName.get(record.snapshotId); + return current === undefined || !entriesEqual(current, entryForRecord(record)); + }); + const probe = await this._probeTargets(candidates); + if (probe.anchorable.length === 0) { return; } + const desired = probe.anchorable.map(entryForRecord); + await rootSet.mutate((current) => mergedEntries(current, desired)); + this._rememberAnchored(probe.anchorable); + } + + async publishTransition( + records: readonly WarpStateSnapshotRecord[], + publishIndex: () => Promise, + knownTreeOids: readonly string[] = [], + ): Promise { + const rootSet = await this._getRootSet(); + this._rememberTreeOids(knownTreeOids); + const probe = await this._probeTransitionTargets(records); + const desired = probe.anchorable.map(entryForRecord).sort(compareEntryNames); + const prepared = await rootSet.mutate((current) => mergedEntries(current, desired)); + this._rememberAnchored(probe.anchorable); + + await publishIndex(); + await this._cleanupPreparedSuperset(rootSet, prepared, desired); + } + + private async _cleanupPreparedSuperset( + rootSet: RootSetClient, + prepared: RootSetMutationResult, + desired: readonly RootSetEntry[], + ): Promise { + if (entryListsEqual(prepared.entries, desired)) { return; } + // The index is already committed. Any cleanup failure safely retains a + // superset that doctor/repair can reconcile without risking live payloads. + try { + await rootSet.replace({ entries: desired, expectedHeadOid: prepared.commitOid }); + } catch { + // The prepared superset remains authoritative and safe. + } + } + + async inspect(records: readonly WarpStateSnapshotRecord[]): Promise { + const rootSet = await this._getRootSet(); + const doctor = await rootSet.doctor(); + const roots = doctor.entries ?? []; + const probe = await this._probeTargets(records); + const comparison = compareRootEntries(probe.anchorable, roots); + + return new WarpStateCacheRetentionReport({ + liveSnapshotIds: records.map((record) => record.snapshotId), + anchoredSnapshotIds: comparison.anchoredSnapshotIds, + unanchoredSnapshotIds: comparison.unanchoredSnapshotIds, + missingSnapshotIds: probe.missingSnapshotIds, + wrongTypeSnapshotIds: probe.wrongTypeSnapshotIds, + staleRootNames: findStaleRootNames(records, roots), + mismatchedRootNames: comparison.mismatchedRootNames, + rootSetError: rootSetError(doctor), + }); + } + + async repair(records: readonly WarpStateSnapshotRecord[]): Promise { + const rootSet = await this._getRootSet(); + const before = await this.inspect(records); + const probe = await this._probeTargets(records); + const desired = probe.anchorable.map(entryForRecord).sort(compareEntryNames); + + if (before.rootSetError === null) { + const state = await rootSet.read(); + await rootSet.replace({ entries: desired, expectedHeadOid: state.headOid }); + } else { + await rootSet.repair({ entries: desired }); + } + + const after = await this.inspect(records); + const unrecoverableSnapshotIds = [ + ...probe.missingSnapshotIds, + ...probe.wrongTypeSnapshotIds, + ]; + const removedStaleRootNames = before.staleRootNames.filter( + (name) => !after.staleRootNames.includes(name), + ); + + return new WarpStateCacheRepairResult({ + before, + after, + anchoredSnapshotIds: after.anchoredSnapshotIds, + unrecoverableSnapshotIds, + removedStaleRootNames, + }); + } + + private async _probeTargets(records: readonly WarpStateSnapshotRecord[]): Promise { + const anchorable: WarpStateSnapshotRecord[] = []; + const missingSnapshotIds: string[] = []; + const wrongTypeSnapshotIds: string[] = []; + + for (const record of records) { + if (!(await this._objectProbe.nodeExists(record.payloadRef))) { + missingSnapshotIds.push(record.snapshotId); + } else if (await this._objectProbe.readObjectType(record.payloadRef) !== 'tree') { + wrongTypeSnapshotIds.push(record.snapshotId); + } else { + anchorable.push(record); + } + } + return { anchorable, missingSnapshotIds, wrongTypeSnapshotIds }; + } + + private async _probeTransitionTargets( + records: readonly WarpStateSnapshotRecord[], + ): Promise { + const anchored: WarpStateSnapshotRecord[] = []; + const unchecked: WarpStateSnapshotRecord[] = []; + for (const record of records) { + (this._anchoredTreeOids.has(record.payloadRef) ? anchored : unchecked).push(record); + } + const probe = await this._probeTargets(unchecked); + return { + anchorable: [...anchored, ...probe.anchorable], + missingSnapshotIds: probe.missingSnapshotIds, + wrongTypeSnapshotIds: probe.wrongTypeSnapshotIds, + }; + } + + private _rememberAnchored(records: readonly WarpStateSnapshotRecord[]): void { + this._rememberTreeOids(records.map((record) => record.payloadRef)); + } + + private _rememberTreeOids(oids: readonly string[]): void { + for (const oid of oids) { this._anchoredTreeOids.add(oid); } + } +} diff --git a/src/infrastructure/adapters/GitCasWarpStateCacheAdapter.ts b/src/infrastructure/adapters/GitCasWarpStateCacheAdapter.ts index 50277f28d..2ced720ac 100644 --- a/src/infrastructure/adapters/GitCasWarpStateCacheAdapter.ts +++ b/src/infrastructure/adapters/GitCasWarpStateCacheAdapter.ts @@ -14,15 +14,15 @@ import CasContentEncryptionPolicy, { type CasRestoreEncryptionArguments, type Ca import type LoggerPort from '../../ports/LoggerPort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import { Readable } from 'node:stream'; -import ORSet from '../../domain/crdt/ORSet.ts'; -import VersionVector from '../../domain/crdt/VersionVector.ts'; -import { createEmptyState } from '../../domain/services/JoinReducer.ts'; -import WarpState from '../../domain/services/state/WarpState.ts'; -import type { LWWRegister } from '../../domain/crdt/LWW.ts'; -import type { PropValue } from '../../domain/types/PropValue.ts'; -import type { EventId } from '../../domain/utils/EventId.ts'; +import { decodeWarpFullState, encodeWarpFullState } from '../codecs/WarpStateCborCodec.ts'; +import GitCasStateCacheRootSetCoordinator from './GitCasStateCacheRootSetCoordinator.ts'; +import type WarpStateCacheRetentionReport from '../../domain/services/state/WarpStateCacheRetentionReport.ts'; +import type WarpStateCacheRepairResult from '../../domain/services/state/WarpStateCacheRepairResult.ts'; +import type { RootSet } from '@git-stunts/git-cas'; +import type WarpStateCacheRetentionPort from '../../ports/WarpStateCacheRetentionPort.ts'; interface CasStore { + rootSets: { open(opts: { ref: string }): RootSet }; readManifest(opts: { treeOid: string }): Promise; restore(opts: { manifest: unknown } & CasRestoreEncryptionArguments): Promise<{ buffer: Uint8Array }>; restoreStream?: (opts: { manifest: unknown } & CasRestoreEncryptionArguments) => AsyncIterable; @@ -34,8 +34,9 @@ interface CachePersistence { readRef(ref: string): Promise; readBlob(oid: string): Promise; writeBlob(data: Uint8Array): Promise; - updateRef(ref: string, oid: string): Promise; - deleteRef(ref: string): Promise; + compareAndSwapRef(ref: string, newOid: string, expectedOid: string | null): Promise; + nodeExists(oid: string): Promise; + readObjectType(oid: string): Promise; } interface CacheIndexEntry { @@ -56,6 +57,29 @@ interface CacheIndex { snapshots: Record; } +interface CacheIndexState { + headOid: string | null; + index: CacheIndex; +} + +interface CacheIndexCandidate { + schemaVersion?: unknown; + checkpointHeadId?: unknown; + snapshots?: unknown; +} + +interface RetentionRootIdentity { + payloadRef: string; + retention: WarpStateSnapshotRetention; +} + +interface IndexMutationContext { + current: CacheIndexState; + currentRoots: Map; + mutated: CacheIndex; + knownTreeOids: readonly string[]; +} + const DEFAULT_MAX_ENTRIES = 200; const INDEX_SCHEMA_VERSION = 1; const MAX_CAS_RETRIES = 3; @@ -63,14 +87,56 @@ const MAX_CAS_RETRIES = 3; function _emptyIndex(): CacheIndex { return { schemaVersion: INDEX_SCHEMA_VERSION, snapshots: {} }; } function _validateParsedIndex(parsed: unknown): CacheIndex { - if (typeof parsed !== 'object' || parsed === null) { return _emptyIndex(); } - const candidate = parsed as { schemaVersion?: unknown; checkpointHeadId?: string; snapshots?: unknown }; - if (candidate.schemaVersion !== INDEX_SCHEMA_VERSION) { return _emptyIndex(); } - return { schemaVersion: INDEX_SCHEMA_VERSION, checkpointHeadId: candidate.checkpointHeadId, snapshots: (candidate.snapshots ?? {}) as Record }; + const candidate = cacheIndexCandidate(parsed); + validateIndexSchema(candidate.schemaVersion); + const checkpointHeadId = validateCheckpointHeadId(candidate.checkpointHeadId); + const snapshots = validateSnapshots(candidate.snapshots); + const index: CacheIndex = { schemaVersion: INDEX_SCHEMA_VERSION, snapshots }; + if (checkpointHeadId !== undefined) { + index.checkpointHeadId = checkpointHeadId; + } + return index; +} + +function cacheIndexCandidate(parsed: unknown): CacheIndexCandidate { + if (typeof parsed !== 'object' || parsed === null) { + throw new CacheError('GitCasWarpStateCacheAdapter: state-cache index must be an object'); + } + return parsed as CacheIndexCandidate; +} + +function validateIndexSchema(schemaVersion: unknown): void { + if (schemaVersion !== INDEX_SCHEMA_VERSION) { + throw new CacheError( + `GitCasWarpStateCacheAdapter: unsupported state-cache index schema ${String(schemaVersion)}`, + ); + } +} + +function validateCheckpointHeadId(checkpointHeadId: unknown): string | undefined { + if (checkpointHeadId !== undefined && typeof checkpointHeadId !== 'string') { + throw new CacheError('GitCasWarpStateCacheAdapter: checkpointHeadId must be a string'); + } + return checkpointHeadId; +} + +function validateSnapshots(value: unknown): Record { + const snapshots = value ?? {}; + if (typeof snapshots !== 'object' || snapshots === null || Array.isArray(snapshots)) { + throw new CacheError('GitCasWarpStateCacheAdapter: snapshots must be an object'); + } + return snapshots as Record; } function _parseIndexBlob(buf: Uint8Array): CacheIndex { - try { return _validateParsedIndex(JSON.parse(textDecode(buf))); } catch { return _emptyIndex(); } + try { + return _validateParsedIndex(JSON.parse(textDecode(buf))); + } catch (error) { + if (error instanceof CacheError) { throw error; } + throw new CacheError( + `GitCasWarpStateCacheAdapter: malformed state-cache index: ${error instanceof Error ? error.message : String(error)}`, + ); + } } function recordToEntry(record: WarpStateSnapshotRecord): CacheIndexEntry { @@ -104,8 +170,7 @@ function isCoordinateCompatible(cand: WarpStateCoordinate, tgt: WarpStateCoordin function _buildPrunedSnapshotIndex(snapshots: Record, maxEntries: number): WarpStateSnapshotIndex { const snapIndex = new WarpStateSnapshotIndex({ isCoordinateCompatible }); for (const key of Object.keys(snapshots)) { - const entry = snapshots[key]; - if (entry !== undefined) { snapIndex.upsert(entryToRecord(entry)); } + snapIndex.upsert(entryToRecord(snapshots[key]!)); } snapIndex.pruneEvictable({ maxEntries }); return snapIndex; @@ -115,8 +180,7 @@ function _filterSnapshotsByIndex(snapshots: Record, sna const pruned: Record = {}; for (const key of Object.keys(snapshots)) { if (snapIndex.findById(key) !== null) { - const val = snapshots[key]; - if (val !== undefined) { pruned[key] = val; } + pruned[key] = snapshots[key]!; } } return pruned; @@ -128,7 +192,43 @@ function _pruneSnapshotsIndex(index: CacheIndex, maxEntries: number): CacheIndex return index; } -export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { +function indexRecords(index: CacheIndex): WarpStateSnapshotRecord[] { + return Object.values(index.snapshots).map(entryToRecord); +} + +function retentionRoots(index: CacheIndex): Map { + return new Map(Object.values(index.snapshots).map((entry) => [ + entry.snapshotId, + { payloadRef: entry.payloadRef, retention: entry.retention }, + ])); +} + +function retentionRootsEqual( + left: Map, + right: Map, +): boolean { + if (left.size !== right.size) { return false; } + for (const [snapshotId, target] of left) { + if (!retentionRootIdentityEqual(target, right.get(snapshotId))) { return false; } + } + return true; +} + +function retentionRootIdentityEqual( + left: RetentionRootIdentity, + right: RetentionRootIdentity | undefined, +): boolean { + return right !== undefined + && right.payloadRef === left.payloadRef + && right.retention === left.retention; +} + +function cacheUpdateFailure(lastErr: unknown): CacheError { + const message = lastErr instanceof Error ? lastErr.message : String(lastErr); + return new CacheError(`GitCasWarpStateCacheAdapter: index update failed after retries: ${message}`); +} + +export class GitCasWarpStateCacheAdapter extends WarpStateCachePort implements WarpStateCacheRetentionPort { private readonly _persistence: CachePersistence; private readonly _plumbing: unknown; private readonly _maxEntries: number; @@ -138,6 +238,9 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { private readonly _logger: LoggerPort | undefined; private readonly _codec: CodecPort; private readonly _getCas: () => Promise; + private readonly _retention: GitCasStateCacheRootSetCoordinator; + private _retentionAdoption: Promise | null = null; + private _retentionReady = false; constructor(opts: { persistence: CachePersistence; plumbing: unknown; graphName: string; maxEntries?: number; encryptionKey?: Uint8Array; contentEncryption?: CasContentEncryptionPolicy; logger?: LoggerPort; codec: CodecPort }) { super(); @@ -150,36 +253,91 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { this._logger = opts.logger; this._codec = opts.codec; this._getCas = createLazyCas(() => createCdcCasStore({ plumbing: this._plumbing, logger: this._logger })); + this._retention = new GitCasStateCacheRootSetCoordinator({ + graphName: opts.graphName, + openRootSet: async (ref) => (await this._getCas()).rootSets.open({ ref }), + objectProbe: this._persistence, + }); + } + + private async _readIndexState(): Promise { + const headOid = await this._persistence.readRef(this._ref); + if (typeof headOid !== 'string' || headOid.length === 0) { + return { headOid: null, index: _emptyIndex() }; + } + return { + headOid, + index: _parseIndexBlob(await this._persistence.readBlob(headOid)), + }; } private async _readIndex(): Promise { - const oid = await this._persistence.readRef(this._ref); - if (typeof oid !== 'string' || oid.length === 0) { return _emptyIndex(); } - try { return _parseIndexBlob(await this._persistence.readBlob(oid)); } catch { return _emptyIndex(); } + return (await this._readIndexState()).index; + } + + private async _readRetainedIndex(): Promise { + const index = await this._readIndex(); + await this._adoptLegacyRetention(index); + return index; } - private async _writeIndex(index: CacheIndex): Promise { + private async _adoptLegacyRetention(index: CacheIndex): Promise { + if (this._retentionAdoption === null) { + this._retentionAdoption = this._retention.adopt(indexRecords(index)); + } + const adoption = this._retentionAdoption; + try { + await adoption; + this._retentionReady = true; + } catch (err) { + if (this._retentionAdoption === adoption) { + this._retentionAdoption = null; + } + throw err; + } + } + + private async _writeIndex(index: CacheIndex, expectedHeadOid: string | null): Promise { const oid = await this._persistence.writeBlob(textEncode(JSON.stringify(index))); - await this._persistence.updateRef(this._ref, oid); + await this._persistence.compareAndSwapRef(this._ref, oid, expectedHeadOid); } - private async _mutateIndex(mutate: (index: CacheIndex) => CacheIndex): Promise { + private async _mutateIndex( + mutate: (index: CacheIndex) => CacheIndex, + knownTreeOids: readonly string[] = [], + ): Promise { let lastErr: unknown; for (let attempt = 0; attempt < MAX_CAS_RETRIES; attempt++) { - const index = await this._readIndex(); - const mutated = mutate(index); + const current = await this._readIndexState(); + const currentRoots = retentionRoots(current.index); + const mutated = mutate(current.index); try { - await this._writeIndex(mutated); + await this._commitIndexMutation({ current, currentRoots, mutated, knownTreeOids }); return mutated; } catch (err) { lastErr = err; - if (attempt === MAX_CAS_RETRIES - 1) { throw new CacheError(`GitCasWarpStateCacheAdapter: index update failed after retries: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`); } + if (attempt === MAX_CAS_RETRIES - 1) { throw cacheUpdateFailure(lastErr); } } } /* c8 ignore next - unreachable */ throw new CacheError('GitCasWarpStateCacheAdapter: index update failed'); } + private async _commitIndexMutation(context: IndexMutationContext): Promise { + const { current, currentRoots, mutated, knownTreeOids } = context; + if (this._retentionReady + && retentionRootsEqual(currentRoots, retentionRoots(mutated))) { + await this._writeIndex(mutated, current.headOid); + return; + } + await this._retention.publishTransition( + indexRecords(mutated), + () => this._writeIndex(mutated, current.headOid), + knownTreeOids, + ); + this._retentionReady = true; + } + private async _restoreBuffer(cas: CasStore, restoreOpts: { manifest: unknown } & CasRestoreEncryptionArguments): Promise { if (typeof cas.restoreStream === 'function') { const stream = cas.restoreStream(restoreOpts); @@ -192,11 +350,10 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { } private async _loadSnapshotState(cas: CasStore, record: WarpStateSnapshotRecord): Promise { - if (record.state !== undefined) { return record; } const manifest = await cas.readManifest({ treeOid: record.payloadRef }); const restoreOpts: { manifest: unknown } & CasRestoreEncryptionArguments = { manifest, ...this._contentEncryption.toRestoreOptions() }; const buffer = await this._restoreBuffer(cas, restoreOpts); - const state = _decodeFullState(buffer, this._codec); + const state = decodeWarpFullState(buffer, this._codec); await this._mutateIndex((idx) => { const tracked = idx.snapshots[record.snapshotId]; if (tracked !== null && tracked !== undefined) { tracked.lastAccessedAt = new Date().toISOString(); } @@ -207,7 +364,7 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { override async getExact(coordinate: WarpStateCoordinate): Promise { const cas = await this._getCas(); - const indexData = await this._readIndex(); + const indexData = await this._readRetainedIndex(); const snapIndex = new WarpStateSnapshotIndex({ isCoordinateCompatible }); for (const entry of Object.values(indexData.snapshots)) { snapIndex.upsert(entryToRecord(entry)); } const match = snapIndex.findExact(coordinate); @@ -222,7 +379,7 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { override async getBestCompatiblePredecessor(coordinate: WarpStateCoordinate): Promise { const cas = await this._getCas(); - const indexData = await this._readIndex(); + const indexData = await this._readRetainedIndex(); const snapIndex = new WarpStateSnapshotIndex({ isCoordinateCompatible }); for (const entry of Object.values(indexData.snapshots)) { snapIndex.upsert(entryToRecord(entry)); } const match = snapIndex.findBestCompatiblePredecessor(coordinate); @@ -238,7 +395,7 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { override async put(snapshot: WarpStateSnapshotRecord): Promise { const cas = await this._getCas(); if (snapshot.state === undefined) { throw new WarpError('Cannot cache snapshot without WarpState', 'E_CACHE_MISSING_STATE'); } - const buffer = _encodeFullState(snapshot.state, this._codec); + const buffer = encodeWarpFullState(snapshot.state, this._codec); const source = Readable.from([buffer]); const storeOpts: { source: Readable; slug: string; filename: string; encryptionKey?: Uint8Array; encryption?: CasStoreEncryptionOptions } = { source, slug: snapshot.snapshotId, filename: 'state.cbor', ...this._contentEncryption.toStoreOptions() }; const manifest = await cas.store(storeOpts); @@ -247,7 +404,7 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { await this._mutateIndex((index) => { index.snapshots[updatedRecord.snapshotId] = recordToEntry(updatedRecord); return _pruneSnapshotsIndex(index, this._maxEntries); - }); + }, [treeOid]); return updatedRecord; } @@ -269,7 +426,7 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { override async resolveCheckpointHead(_graphName: string): Promise { const cas = await this._getCas(); - const indexData = await this._readIndex(); + const indexData = await this._readRetainedIndex(); if (indexData.checkpointHeadId === undefined) { return null; } const entry = indexData.snapshots[indexData.checkpointHeadId]; if (entry === undefined) { return null; } @@ -285,105 +442,22 @@ export class GitCasWarpStateCacheAdapter extends WarpStateCachePort { override async pruneEvictable(): Promise { await this._mutateIndex((index) => _pruneSnapshotsIndex(index, this._maxEntries)); } -} - -function resolveContentEncryption(contentEncryption: CasContentEncryptionPolicy | undefined, encryptionKey: Uint8Array | undefined): CasContentEncryptionPolicy { - if (contentEncryption !== undefined) { return contentEncryption; } - if (encryptionKey !== undefined) { return CasContentEncryptionPolicy.fromInternalResolvedKey({ encryptionKey }); } - return CasContentEncryptionPolicy.disabled(); -} -interface DecodedFullState { - version?: string; - nodeAlive?: { [x: string]: string[] }; - edgeAlive?: { [x: string]: string[] }; - prop?: Array<[string, unknown]>; - observedFrontier?: { [x: string]: number }; - edgeBirthEvent?: Array<[string, { writerId?: string; lamport?: number }]>; - edgeBirthLamport?: Array<[string, number]>; -} - -function _serializePropsArray(propEntries: Iterable]>): Array<[string, unknown]> { - const arr: Array<[string, unknown]> = []; - for (const [key, register] of propEntries) { arr.push([key, _serializeLWWRegister(register)]); } - arr.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); - return arr; -} - -function _serializeEdgeBirthArray(edgeBirthEvent: Map | undefined): Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> { - const result: Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> = []; - if (edgeBirthEvent !== undefined && edgeBirthEvent !== null) { - for (const [key, eventId] of edgeBirthEvent) { - result.push([key, { lamport: eventId.lamport, writerId: eventId.writerId, patchSha: eventId.patchSha, opIndex: eventId.opIndex }]); - } - result.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + async inspectRetention(): Promise { + const index = await this._readIndex(); + return await this._retention.inspect(indexRecords(index)); } - return result; -} -function _deserializeProps(propArray: Array<[string, unknown]>): Map> { - const prop = new Map>(); - if (!Array.isArray(propArray)) { return prop; } - for (const [key, registerObj] of propArray) { - const register = _deserializeLWWRegister(registerObj as { eventId: { lamport: number; writerId: string; patchSha: string; opIndex: number }; value: unknown } | null); - if (register !== null) { prop.set(key, register); } + async repairRetention(): Promise { + const index = await this._readIndex(); + const result = await this._retention.repair(indexRecords(index)); + this._retentionReady = true; + return result; } - return prop; -} - -function _extractBirthMetadata(ev: { patchSha?: string; opIndex?: number }): { patchSha: string; opIndex: number } { - return { patchSha: ev.patchSha ?? '0000', opIndex: ev.opIndex ?? 0 }; } -function _extractBirthLamport(ev: { lamport?: number; writerId?: string }): { lamport: number; writerId: string } { - return { lamport: ev.lamport ?? 0, writerId: ev.writerId ?? '' }; -} - -function _parseBirthEventVal(val: unknown): EventId { - if (typeof val === 'number') { return { lamport: val, writerId: '', patchSha: '0000', opIndex: 0 }; } - const ev = val as { lamport?: number; writerId?: string; patchSha?: string; opIndex?: number }; - return { ..._extractBirthLamport(ev), ..._extractBirthMetadata(ev) }; -} - -function _deserializeEdgeBirthEvent(obj: DecodedFullState): Map { - const result = new Map(); - const birthData = obj.edgeBirthEvent ?? obj.edgeBirthLamport; - if (!Array.isArray(birthData)) { return result; } - for (const [key, val] of birthData) { result.set(key, _parseBirthEventVal(val)); } - return result; -} - -function _serializeLWWRegister(register: LWWRegister): { eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; value: unknown } | null { - if (register === null || register === undefined) { return null; } - return { eventId: { lamport: register.eventId.lamport, opIndex: register.eventId.opIndex, patchSha: register.eventId.patchSha, writerId: register.eventId.writerId }, value: register.value }; -} - -function _deserializeLWWRegister(obj: { eventId: { lamport: number; writerId: string; patchSha: string; opIndex: number }; value: unknown } | null): LWWRegister | null { - if (obj === null || obj === undefined) { return null; } - return { eventId: { lamport: obj.eventId.lamport, writerId: obj.eventId.writerId, patchSha: obj.eventId.patchSha, opIndex: obj.eventId.opIndex }, value: obj.value as PropValue }; -} - -function _validateFullStateBuffer(buffer: Uint8Array | null | undefined, codec: CodecPort): DecodedFullState | null { - if (buffer === null || buffer === undefined) { return null; } - const obj = codec.decode(buffer); - return obj === null || obj === undefined ? null : obj; -} - -function _verifyFullStateVersion(version: string | undefined): void { - if (version !== undefined && version !== 'full-v5') { throw new WarpError(`Unsupported full state version: expected 'full-v5', got '${JSON.stringify(version)}'`, 'E_UNSUPPORTED_VERSION'); } -} - -function _hydrateStateStructures(obj: DecodedFullState): { nodeAlive: ORSet; edgeAlive: ORSet; observedFrontier: VersionVector } { - return { nodeAlive: ORSet.deserialize(obj.nodeAlive ?? {}), edgeAlive: ORSet.deserialize(obj.edgeAlive ?? {}), observedFrontier: VersionVector.from(obj.observedFrontier ?? {}) }; -} - -function _encodeFullState(state: WarpState, codec: CodecPort): Uint8Array { - return codec.encode({ version: 'full-v5', nodeAlive: state.nodeAlive.serialize(), edgeAlive: state.edgeAlive.serialize(), prop: _serializePropsArray(state.allPropEntries()), observedFrontier: VersionVector.serialize(state.observedFrontier), edgeBirthEvent: _serializeEdgeBirthArray(state.edgeBirthEvent) }); -} - -function _decodeFullState(buffer: Uint8Array, codec: CodecPort): WarpState { - const obj = _validateFullStateBuffer(buffer, codec); - if (obj === null) { return createEmptyState(); } - _verifyFullStateVersion(obj.version); - return new WarpState({ ..._hydrateStateStructures(obj), prop: _deserializeProps(obj.prop ?? []), edgeBirthEvent: _deserializeEdgeBirthEvent(obj) }); +function resolveContentEncryption(contentEncryption: CasContentEncryptionPolicy | undefined, encryptionKey: Uint8Array | undefined): CasContentEncryptionPolicy { + if (contentEncryption !== undefined) { return contentEncryption; } + if (encryptionKey !== undefined) { return CasContentEncryptionPolicy.fromInternalResolvedKey({ encryptionKey }); } + return CasContentEncryptionPolicy.disabled(); } diff --git a/src/infrastructure/adapters/GitGraphAdapter.ts b/src/infrastructure/adapters/GitGraphAdapter.ts index d92834763..ebb84d4d2 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.ts +++ b/src/infrastructure/adapters/GitGraphAdapter.ts @@ -22,6 +22,7 @@ import CasBlobAdapter from './CasBlobAdapter.ts'; import type CasContentEncryptionPolicy from './CasContentEncryptionPolicy.ts'; import { GitCasWarpStateCacheAdapter } from './GitCasWarpStateCacheAdapter.ts'; import type WarpStateCachePort from '../../ports/WarpStateCachePort.ts'; +import type WarpStateCacheRetentionPort from '../../ports/WarpStateCacheRetentionPort.ts'; import type CodecPort from '../../ports/CodecPort.ts'; import type LoggerPort from '../../ports/LoggerPort.ts'; import GitCasGraphReaderAdapter from './GitCasGraphReaderAdapter.ts'; @@ -162,7 +163,7 @@ export default class GitGraphAdapter extends GraphPersistencePort implements Run get emptyTree(): string { return this.plumbing.emptyTree; } createRuntimeBlobStorage(): Promise { return Promise.resolve(new CasBlobAdapter({ plumbing: this.plumbing, persistence: this, ...(this._casContentEncryption ? { contentEncryption: this._casContentEncryption } : {}) })); } createRuntimeTrieStore(): Promise { return Promise.resolve(new GitTrieStoreAdapter({ plumbing: this.plumbing })); } - createRuntimeStateCache(opts: { graphName: string; codec: CodecPort; logger?: LoggerPort }): Promise { return Promise.resolve(new GitCasWarpStateCacheAdapter({ persistence: this, plumbing: this.plumbing, graphName: opts.graphName, codec: opts.codec, ...(opts.logger !== undefined ? { logger: opts.logger } : {}), ...(this._casContentEncryption ? { contentEncryption: this._casContentEncryption } : {}) })); } + createRuntimeStateCache(opts: { graphName: string; codec: CodecPort; logger?: LoggerPort }): Promise { return Promise.resolve(new GitCasWarpStateCacheAdapter({ persistence: this, plumbing: this.plumbing, graphName: opts.graphName, codec: opts.codec, ...(opts.logger !== undefined ? { logger: opts.logger } : {}), ...(this._casContentEncryption ? { contentEncryption: this._casContentEncryption } : {}) })); } defaultPatchWriteStorage(): PatchStorageRoute { return createGitCasPatchStorage(false); } private async _createCommit(opts: { diff --git a/src/infrastructure/codecs/DeliveryObservationJsonCodec.ts b/src/infrastructure/codecs/DeliveryObservationJsonCodec.ts new file mode 100644 index 000000000..b7cde88a8 --- /dev/null +++ b/src/infrastructure/codecs/DeliveryObservationJsonCodec.ts @@ -0,0 +1,6 @@ +import type { DeliveryObservation } from '../../domain/types/DeliveryObservation.ts'; +import { sortedReplacer } from '../../domain/utils/canonicalStringify.ts'; + +export function canonicalObservationJson(observation: DeliveryObservation): string { + return JSON.stringify(observation, sortedReplacer); +} diff --git a/src/infrastructure/codecs/EffectEmissionJsonCodec.ts b/src/infrastructure/codecs/EffectEmissionJsonCodec.ts new file mode 100644 index 000000000..f9ea490c3 --- /dev/null +++ b/src/infrastructure/codecs/EffectEmissionJsonCodec.ts @@ -0,0 +1,6 @@ +import type { EffectEmission } from '../../domain/types/EffectEmission.ts'; +import { sortedReplacer } from '../../domain/utils/canonicalStringify.ts'; + +export function canonicalEmissionJson(emission: EffectEmission): string { + return JSON.stringify(emission, sortedReplacer); +} diff --git a/src/infrastructure/codecs/TickReceiptJsonCodec.ts b/src/infrastructure/codecs/TickReceiptJsonCodec.ts new file mode 100644 index 000000000..8ada1bc79 --- /dev/null +++ b/src/infrastructure/codecs/TickReceiptJsonCodec.ts @@ -0,0 +1,6 @@ +import type { TickReceipt } from '../../domain/types/TickReceipt.ts'; +import { sortedReplacer } from '../../domain/utils/canonicalStringify.ts'; + +export function tickReceiptCanonicalJson(receipt: TickReceipt): string { + return JSON.stringify(receipt, sortedReplacer); +} diff --git a/src/infrastructure/codecs/WarpStateCborCodec.ts b/src/infrastructure/codecs/WarpStateCborCodec.ts new file mode 100644 index 000000000..0dd16ff37 --- /dev/null +++ b/src/infrastructure/codecs/WarpStateCborCodec.ts @@ -0,0 +1,216 @@ +import type CodecPort from '../../ports/CodecPort.ts'; +import type { LWWRegister } from '../../domain/crdt/LWW.ts'; +import VersionVector from '../../domain/crdt/VersionVector.ts'; +import WarpError from '../../domain/errors/WarpError.ts'; +import { createEmptyState } from '../../domain/services/JoinReducer.ts'; +import WarpState from '../../domain/services/state/WarpState.ts'; +import type { PropValue } from '../../domain/types/PropValue.ts'; +import type { EventId } from '../../domain/utils/EventId.ts'; +import { + deserializeORSet, + serializeORSet, + type ORSetWire, +} from '../../domain/services/state/ORSetWireBoundary.ts'; + +const FULL_STATE_VERSION = 'full-v5'; +const LEGACY_PATCH_SHA_PLACEHOLDER = '0000'; + +type EdgeBirthWire = { + readonly writerId?: string; + readonly lamport?: number; + readonly patchSha?: string; + readonly opIndex?: number; +}; + +interface DecodedFullState { + version?: string; + nodeAlive?: ORSetWire; + edgeAlive?: ORSetWire; + prop?: Array<[string, unknown]>; + observedFrontier?: { [x: string]: number }; + edgeBirthEvent?: Array<[string, EdgeBirthWire]>; + edgeBirthLamport?: Array<[string, number]>; +} + +export function encodeWarpFullState(state: WarpState, codec: CodecPort): Uint8Array { + return codec.encode({ + version: FULL_STATE_VERSION, + nodeAlive: serializeORSet(state.nodeAlive), + edgeAlive: serializeORSet(state.edgeAlive), + prop: serializePropsArray(state.allPropEntries()), + observedFrontier: VersionVector.serialize(state.observedFrontier), + edgeBirthEvent: serializeEdgeBirthArray(state.edgeBirthEvent), + }); +} + +export function decodeWarpFullState(buffer: Uint8Array, codec: CodecPort): WarpState { + const obj = decodeFullStatePayload(buffer, codec); + if (obj === null) { + return createEmptyState(); + } + assertSupportedFullStateVersion(obj.version); + return hydrateWarpState(obj); +} + +function decodeFullStatePayload(buffer: Uint8Array | null | undefined, codec: CodecPort): DecodedFullState | null { + if (buffer === null || buffer === undefined) { + return null; + } + const obj = codec.decode(buffer); + return obj ?? null; +} + +function assertSupportedFullStateVersion(version: string | undefined): void { + if (version === undefined || version === FULL_STATE_VERSION) { + return; + } + throw new WarpError( + `Unsupported full state version: expected '${FULL_STATE_VERSION}', got '${JSON.stringify(version)}'`, + 'E_UNSUPPORTED_VERSION', + ); +} + +function hydrateWarpState(obj: DecodedFullState): WarpState { + return new WarpState({ + nodeAlive: deserializeORSet(obj.nodeAlive ?? {}), + edgeAlive: deserializeORSet(obj.edgeAlive ?? {}), + prop: deserializeProps(obj.prop ?? []), + observedFrontier: VersionVector.from(obj.observedFrontier ?? {}), + edgeBirthEvent: deserializeEdgeBirthEvent(obj), + }); +} + +function serializePropsArray(propEntries: Iterable]>): Array<[string, unknown]> { + const arr: Array<[string, unknown]> = []; + for (const [key, register] of propEntries) { + arr.push([key, serializeLWWRegister(register)]); + } + arr.sort((left, right) => ( + left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0 + )); + return arr; +} + +function serializeEdgeBirthArray( + edgeBirthEvent: Map | undefined, +): Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> { + const result: Array<[string, { lamport: number; writerId: string; patchSha: string; opIndex: number }]> = []; + if (edgeBirthEvent !== undefined && edgeBirthEvent !== null) { + for (const [key, eventId] of edgeBirthEvent) { + result.push([key, { + lamport: eventId.lamport, + writerId: eventId.writerId, + patchSha: eventId.patchSha, + opIndex: eventId.opIndex, + }]); + } + result.sort((left, right) => ( + left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0 + )); + } + return result; +} + +function deserializeProps(propArray: Array<[string, unknown]>): Map> { + const prop = new Map>(); + if (!Array.isArray(propArray)) { + return prop; + } + for (const [key, registerObj] of propArray) { + const register = deserializeLWWRegister(registerObj); + if (register !== null) { + prop.set(key, register); + } + } + return prop; +} + +function deserializeEdgeBirthEvent(obj: DecodedFullState): Map { + const result = new Map(); + const birthData = edgeBirthData(obj); + if (!Array.isArray(birthData)) { + return result; + } + for (const [key, val] of birthData) { + result.set(key, deserializeEdgeBirthValue(val)); + } + return result; +} + +function edgeBirthData(obj: DecodedFullState): Array<[string, EdgeBirthWire | number]> | undefined { + return obj.edgeBirthEvent ?? obj.edgeBirthLamport; +} + +function deserializeEdgeBirthValue(value: EdgeBirthWire | number): EventId { + if (typeof value === 'number') { + return legacyNumericEdgeBirth(value); + } + return edgeBirthWireToEventId(value); +} + +function legacyNumericEdgeBirth(lamport: number): EventId { + return { lamport, writerId: '', patchSha: LEGACY_PATCH_SHA_PLACEHOLDER, opIndex: 0 }; +} + +function edgeBirthWireToEventId(value: EdgeBirthWire): EventId { + return { + lamport: value.lamport ?? 0, + writerId: value.writerId ?? '', + patchSha: value.patchSha ?? LEGACY_PATCH_SHA_PLACEHOLDER, + opIndex: value.opIndex ?? 0, + }; +} + +function serializeLWWRegister( + register: LWWRegister, +): { eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; value: unknown } | null { + if (register === null || register === undefined) { + return null; + } + return { + eventId: { + lamport: register.eventId.lamport, + opIndex: register.eventId.opIndex, + patchSha: register.eventId.patchSha, + writerId: register.eventId.writerId, + }, + value: register.value, + }; +} + +function deserializeLWWRegister(obj: unknown): LWWRegister | null { + if (typeof obj !== 'object' || obj === null) { + return null; + } + const wire = obj as { eventId?: unknown; value?: unknown }; + return { + eventId: eventIdFromUnknown(wire.eventId), + value: wire.value as PropValue, + }; +} + +function eventIdFromUnknown(value: unknown): EventId { + if (typeof value !== 'object' || value === null) { + return legacyNumericEdgeBirth(0); + } + const wire = value as { + lamport?: unknown; + writerId?: unknown; + patchSha?: unknown; + opIndex?: unknown; + }; + return { + lamport: numberOrZero(wire.lamport), + writerId: stringOr(wire.writerId, ''), + patchSha: stringOr(wire.patchSha, LEGACY_PATCH_SHA_PLACEHOLDER), + opIndex: numberOrZero(wire.opIndex), + }; +} + +function numberOrZero(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +function stringOr(value: unknown, fallback: string): string { + return typeof value === 'string' ? value : fallback; +} diff --git a/src/ports/CheckpointStorePort.ts b/src/ports/CheckpointStorePort.ts index 295497ca3..cbf0b6222 100644 --- a/src/ports/CheckpointStorePort.ts +++ b/src/ports/CheckpointStorePort.ts @@ -22,7 +22,11 @@ export interface CheckpointRecord { } export interface CheckpointWriteResult { - stateBlobOid: string; + nodeAliveBlobOid: string; + edgeAliveBlobOid: string; + propBlobOid: string; + observedFrontierBlobOid: string; + edgeBirthEventBlobOid: string; frontierBlobOid: string; appliedVVBlobOid: string; provenanceIndexBlobOid: string | null; @@ -32,8 +36,6 @@ export interface CheckpointData { state: WarpState; frontier: Map; appliedVV: VersionVector | null; - stateHash: string; - schema: number; provenanceIndex?: ProvenanceIndex | null; indexShardOids: Record | null; } diff --git a/src/ports/RuntimeStorageCapabilityPort.ts b/src/ports/RuntimeStorageCapabilityPort.ts index 12adf9cb7..30a05b4fc 100644 --- a/src/ports/RuntimeStorageCapabilityPort.ts +++ b/src/ports/RuntimeStorageCapabilityPort.ts @@ -2,6 +2,7 @@ import type BlobStoragePort from './BlobStoragePort.ts'; import type { PatchStorageRoute } from './CommitMessageCodecPort.ts'; import type TrieStorePort from '../domain/orset/trie/TrieStorePort.ts'; import type WarpStateCachePort from './WarpStateCachePort.ts'; +import type WarpStateCacheRetentionPort from './WarpStateCacheRetentionPort.ts'; import type CodecPort from './CodecPort.ts'; import type LoggerPort from './LoggerPort.ts'; @@ -12,6 +13,6 @@ import type LoggerPort from './LoggerPort.ts'; export default interface RuntimeStorageCapabilityPort { createRuntimeBlobStorage(): Promise; createRuntimeTrieStore(): Promise; - createRuntimeStateCache?(opts: { graphName: string; codec: CodecPort; logger?: LoggerPort }): Promise; + createRuntimeStateCache?(opts: { graphName: string; codec: CodecPort; logger?: LoggerPort }): Promise; defaultPatchWriteStorage(): PatchStorageRoute; } diff --git a/src/ports/WarpStateCacheRetentionPort.ts b/src/ports/WarpStateCacheRetentionPort.ts new file mode 100644 index 000000000..f7be9c396 --- /dev/null +++ b/src/ports/WarpStateCacheRetentionPort.ts @@ -0,0 +1,11 @@ +import type WarpStateCacheRetentionReport from '../domain/services/state/WarpStateCacheRetentionReport.ts'; +import type WarpStateCacheRepairResult from '../domain/services/state/WarpStateCacheRepairResult.ts'; + +/** + * Operational retention capability for state caches backed by reclaimable + * content-addressed storage. + */ +export default interface WarpStateCacheRetentionPort { + inspectRetention(): Promise; + repairRetention(): Promise; +} diff --git a/test/bats/cli-doctor.bats b/test/bats/cli-doctor.bats index 4655ea6ad..8ba0c3566 100644 --- a/test/bats/cli-doctor.bats +++ b/test/bats/cli-doctor.bats @@ -33,11 +33,12 @@ data = json.loads(os.environ["JSON"]) assert data["doctorVersion"] == 1 assert data["graph"] == "demo" assert data["health"] == "ok" -assert data["summary"]["checksRun"] == 7 +assert data["summary"]["checksRun"] == 8 assert data["summary"]["fail"] == 0 assert data["summary"]["ok"] >= 1 assert isinstance(data["findings"], list) -assert len(data["findings"]) >= 7 +assert len(data["findings"]) >= 8 +assert "state-cache-retention" in [finding["id"] for finding in data["findings"]] assert isinstance(data["policy"], dict) assert data["policy"]["clockSkewMs"] == 300000 PY @@ -54,6 +55,7 @@ PY echo "$output" | grep -q "refs-consistent" echo "$output" | grep -q "checkpoint-fresh" echo "$output" | grep -q "hooks-installed" + echo "$output" | grep -q "state-cache-retention" } @test "doctor --json broken writer ref yields refs-consistent fail" { diff --git a/test/conformance/readonlyBytePropValueSnapshot.test.ts b/test/conformance/readonlyBytePropValueSnapshot.test.ts index 04c5fcc6e..709c7fca9 100644 --- a/test/conformance/readonlyBytePropValueSnapshot.test.ts +++ b/test/conformance/readonlyBytePropValueSnapshot.test.ts @@ -23,7 +23,7 @@ describe('readonly byte PropValue snapshot contract', () => { expect(snapshotValue).toBeInstanceOf(ImmutableBytes); if (!(snapshotValue instanceof ImmutableBytes)) { - return; + throw new Error('snapshot value must be ImmutableBytes'); } expect([...snapshotValue]).toEqual([1, 2, 3]); diff --git a/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts b/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts new file mode 100644 index 000000000..aa62353e0 --- /dev/null +++ b/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts @@ -0,0 +1,143 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Plumbing from '@git-stunts/plumbing'; +import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAdapter.ts'; +import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts'; +import type WarpStateCacheRetentionPort from '../../../../src/ports/WarpStateCacheRetentionPort.ts'; + +interface PlumbingRuntime { + execute(options: { args: string[]; input?: string | Buffer }): Promise; +} + +interface Harness { + readonly tempDir: string; + readonly plumbing: PlumbingRuntime; + readonly cache: WarpStateCachePort & WarpStateCacheRetentionPort; + cleanup(): Promise; +} + +const PAYLOAD_ENTRY = 'state.cbor'; +const SNAPSHOT_ID = 'legacy-snapshot'; +const WRITER_TIP = 'c'.repeat(40); +const OID_WHITESPACE_PATTERN = /\s+/; +const execFileAsync = promisify(execFile); + +async function writeBlob(plumbing: PlumbingRuntime, contents: string): Promise { + return (await plumbing.execute({ + args: ['hash-object', '-w', '--stdin'], + input: contents, + })).trim(); +} + +async function writeTree(plumbing: PlumbingRuntime, blobOid: string): Promise { + return (await plumbing.execute({ + args: ['mktree'], + input: `100644 blob ${blobOid}\t${PAYLOAD_ENTRY}\n`, + })).trim(); +} + +async function writeLegacyIndex( + plumbing: PlumbingRuntime, + treeOid: string, +): Promise { + const index = JSON.stringify({ + schemaVersion: 1, + snapshots: { + [SNAPSHOT_ID]: { + snapshotId: SNAPSHOT_ID, + coordinate: { frontier: { 'writer-1': WRITER_TIP }, ceiling: 3 }, + retention: 'evictable', + provenancePosture: 'full', + stateHash: 'legacy-state-hash', + payloadRef: treeOid, + createdAt: '2026-07-11T20:00:00.000Z', + }, + }, + }); + const indexOid = await writeBlob(plumbing, index); + await plumbing.execute({ + args: ['update-ref', 'refs/warp/demo/state-cache', indexOid], + }); +} + +async function prunableOids(repoPath: string): Promise> { + const { stdout: output } = await execFileAsync( + 'git', + ['-C', repoPath, 'prune', '-n', '--expire=now'], + ); + return new Set( + output + .split('\n') + .map((line) => line.trim().split(OID_WHITESPACE_PATTERN)[0]) + .filter((oid): oid is string => oid !== undefined && oid.length > 0), + ); +} + +async function createHarness(): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'warp-state-cache-retention-')); + try { + const plumbing = await Plumbing.createDefault({ cwd: tempDir }); + await plumbing.execute({ args: ['init', '-q'] }); + await plumbing.execute({ args: ['config', 'user.email', 'test@test.com'] }); + await plumbing.execute({ args: ['config', 'user.name', 'Test'] }); + const persistence = new GitGraphAdapter({ plumbing }); + const cache = await persistence.createRuntimeStateCache({ + graphName: 'demo', + codec: new CborCodec(), + }); + return { + tempDir, + plumbing, + cache, + async cleanup(): Promise { + await rm(tempDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + throw error; + } +} + +describe('GitCasWarpStateCacheAdapter retention integration', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await harness.cleanup(); + }); + + it('adopts a legacy payload before use and releases it when the index removes it', async () => { + const payloadBlob = await writeBlob(harness.plumbing, 'legacy state payload'); + const payloadTree = await writeTree(harness.plumbing, payloadBlob); + await writeLegacyIndex(harness.plumbing, payloadTree); + + expect(await prunableOids(harness.tempDir)).toContain(payloadTree); + + const miss = await harness.cache.getExact({ + frontier: new Map([['other-writer', '9'.repeat(40)]]), + ceiling: 3, + }); + expect(miss).toBeNull(); + expect(await prunableOids(harness.tempDir)).not.toContain(payloadTree); + expect((await harness.cache.inspectRetention()).anchoredSnapshotIds).toEqual([ + SNAPSHOT_ID, + ]); + + const invalidPayload = await harness.cache.getExact({ + frontier: new Map([['writer-1', WRITER_TIP]]), + ceiling: 3, + }); + expect(invalidPayload).toBeNull(); + expect(await prunableOids(harness.tempDir)).toContain(payloadTree); + expect((await harness.cache.inspectRetention()).liveSnapshotIds).toEqual([]); + }); +}); diff --git a/test/runtime/deno/deno.json b/test/runtime/deno/deno.json index ed9808d87..2020efb75 100644 --- a/test/runtime/deno/deno.json +++ b/test/runtime/deno/deno.json @@ -1,7 +1,7 @@ { "imports": { "@git-stunts/alfred": "npm:@git-stunts/alfred@^0.10.3", - "@git-stunts/git-cas": "npm:@git-stunts/git-cas@^6.0.0", + "@git-stunts/git-cas": "npm:@git-stunts/git-cas@^6.1.0", "@git-stunts/plumbing": "npm:@git-stunts/plumbing@^3.0.3", "@git-stunts/trailer-codec": "npm:@git-stunts/trailer-codec@^2.1.1", "@noble/hashes/blake3.js": "npm:@noble/hashes@^2.2.0/blake3.js", diff --git a/test/unit/benchmark/TrieGeometryProfile.profile.test.ts b/test/unit/benchmark/TrieGeometryProfile.profile.test.ts index e9979f337..b9a1ca6c2 100644 --- a/test/unit/benchmark/TrieGeometryProfile.profile.test.ts +++ b/test/unit/benchmark/TrieGeometryProfile.profile.test.ts @@ -9,12 +9,7 @@ import { } from "../../benchmark/trieGeometryProfile.fixture.ts"; describe("Trie geometry profile harness", () => { - it("runs the default matrix when GIT_WARP_PROFILE=1", async () => { - if (process.env["GIT_WARP_PROFILE"] !== "1") { - expect(process.env["GIT_WARP_PROFILE"]).not.toBe("1"); - return; - } - + it.skipIf(process.env["GIT_WARP_PROFILE"] !== "1")("runs the default matrix when GIT_WARP_PROFILE=1", async () => { const rows: TrieGeometryProfileRow[] = []; const onlyLabel = process.env["GIT_WARP_PROFILE_ONLY_LABEL"] ?? null; const scenarios = createTrieGeometryProfilePlan() @@ -34,12 +29,7 @@ describe("Trie geometry profile harness", () => { expect(rows).toHaveLength(scenarios.length); }, 300_000); - it("runs the 1M-entry stress scale when GIT_WARP_PROFILE_STRESS=1", async () => { - if (process.env["GIT_WARP_PROFILE_STRESS"] !== "1") { - expect(process.env["GIT_WARP_PROFILE_STRESS"]).not.toBe("1"); - return; - } - + it.skipIf(process.env["GIT_WARP_PROFILE_STRESS"] !== "1")("runs the 1M-entry stress scale when GIT_WARP_PROFILE_STRESS=1", async () => { const rows: TrieGeometryProfileRow[] = []; const onlyLabel = process.env["GIT_WARP_PROFILE_ONLY_LABEL"] ?? null; const scenarios = createTrieGeometryProfilePlan({ includeStress: true }) diff --git a/test/unit/cli/doctor-state-cache-retention.test.ts b/test/unit/cli/doctor-state-cache-retention.test.ts new file mode 100644 index 000000000..ab2c0b81f --- /dev/null +++ b/test/unit/cli/doctor-state-cache-retention.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { + checkStateCacheRetention, + stateCacheRepairFailureFinding, + stateCacheRepairFinding, +} from '../../../bin/cli/commands/doctor/checksStateCache.ts'; +import { CODES } from '../../../bin/cli/commands/doctor/codes.ts'; +import WarpStateCacheRetentionReport from '../../../src/domain/services/state/WarpStateCacheRetentionReport.ts'; +import WarpStateCacheRepairResult from '../../../src/domain/services/state/WarpStateCacheRepairResult.ts'; + +function report(options: { + anchored?: readonly string[]; + unanchored?: readonly string[]; + missing?: readonly string[]; + wrongType?: readonly string[]; + stale?: readonly string[]; + mismatched?: readonly string[]; + rootSetError?: string | null; +} = {}): WarpStateCacheRetentionReport { + const liveSnapshotIds = [ + ...(options.anchored ?? []), + ...(options.unanchored ?? []), + ...(options.missing ?? []), + ...(options.wrongType ?? []), + ]; + return new WarpStateCacheRetentionReport({ + liveSnapshotIds, + anchoredSnapshotIds: options.anchored ?? [], + unanchoredSnapshotIds: options.unanchored ?? [], + missingSnapshotIds: options.missing ?? [], + wrongTypeSnapshotIds: options.wrongType ?? [], + staleRootNames: options.stale ?? [], + mismatchedRootNames: options.mismatched ?? [], + rootSetError: options.rootSetError ?? null, + }); +} + +function contextWithReport(retentionReport: WarpStateCacheRetentionReport) { + return { + stateCache: { + inspectRetention: async () => retentionReport, + }, + }; +} + +describe('state-cache retention doctor check', () => { + it('reports a healthy anchored cache', async () => { + const findings = await checkStateCacheRetention( + contextWithReport(report({ anchored: ['snapshot-a'] })), + ); + + expect(findings).toEqual([ + expect.objectContaining({ status: 'ok', code: CODES.STATE_CACHE_RETENTION_OK }), + ]); + }); + + it('separates unanchored, missing, wrong-type, stale, and root-set failures', async () => { + const findings = await checkStateCacheRetention(contextWithReport(report({ + unanchored: ['snapshot-a'], + missing: ['snapshot-b'], + wrongType: ['snapshot-c'], + stale: ['stale-root'], + mismatched: ['snapshot-a'], + rootSetError: 'malformed metadata', + }))); + const codes = findings.map((finding) => finding.code); + + expect(codes).toEqual([ + CODES.STATE_CACHE_ROOT_SET_INVALID, + CODES.STATE_CACHE_PAYLOAD_MISSING, + CODES.STATE_CACHE_PAYLOAD_WRONG_TYPE, + CODES.STATE_CACHE_PAYLOAD_UNANCHORED, + CODES.STATE_CACHE_STALE_ROOTS, + ]); + }); + + it('reports partial repair when already-pruned snapshots are unrecoverable', () => { + const before = report({ unanchored: ['snapshot-a'], missing: ['snapshot-b'] }); + const after = report({ anchored: ['snapshot-a'], missing: ['snapshot-b'] }); + const result = new WarpStateCacheRepairResult({ + before, + after, + anchoredSnapshotIds: ['snapshot-a'], + unrecoverableSnapshotIds: ['snapshot-b'], + removedStaleRootNames: [], + }); + + expect(stateCacheRepairFinding(result)).toEqual(expect.objectContaining({ + status: 'warn', + code: CODES.STATE_CACHE_RETENTION_PARTIAL_REPAIR, + evidence: expect.objectContaining({ unrecoverableSnapshotIds: ['snapshot-b'] }), + })); + }); + + it('settles repair failures as doctor findings', () => { + expect(stateCacheRepairFailureFinding(new Error('root set unavailable'))).toEqual( + expect.objectContaining({ + id: 'state-cache-retention-repair', + status: 'fail', + code: CODES.CHECK_INTERNAL_ERROR, + message: 'State-cache retention repair failed: root set unavailable', + }), + ); + }); +}); diff --git a/test/unit/cli/doctor.test.ts b/test/unit/cli/doctor.test.ts index eae11b061..6687738df 100644 --- a/test/unit/cli/doctor.test.ts +++ b/test/unit/cli/doctor.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CODES } from '../../../bin/cli/commands/doctor/codes.ts'; import { DOCTOR_EXIT_CODES } from '../../../bin/cli/commands/doctor/types.ts'; import { checkCoverageComplete, checkClockSkew, checkRefsConsistent } from '../../../bin/cli/commands/doctor/checks.ts'; +import WarpStateCacheRetentionReport from '../../../src/domain/services/state/WarpStateCacheRetentionReport.ts'; +import WarpStateCacheRepairResult from '../../../src/domain/services/state/WarpStateCacheRepairResult.ts'; // Mock shared.js to avoid real git operations vi.mock('../../../bin/cli/shared.ts', () => ({ @@ -219,6 +221,83 @@ describe('doctor command', () => { }); }); + it('repairs state-cache roots before checking their resulting health', async () => { + const before = new WarpStateCacheRetentionReport({ + liveSnapshotIds: ['snapshot-a'], + anchoredSnapshotIds: [], + unanchoredSnapshotIds: ['snapshot-a'], + missingSnapshotIds: [], + wrongTypeSnapshotIds: [], + staleRootNames: [], + mismatchedRootNames: [], + rootSetError: null, + }); + const after = new WarpStateCacheRetentionReport({ + liveSnapshotIds: ['snapshot-a'], + anchoredSnapshotIds: ['snapshot-a'], + unanchoredSnapshotIds: [], + missingSnapshotIds: [], + wrongTypeSnapshotIds: [], + staleRootNames: [], + mismatchedRootNames: [], + rootSetError: null, + }); + const repairResult = new WarpStateCacheRepairResult({ + before, + after, + anchoredSnapshotIds: ['snapshot-a'], + unrecoverableSnapshotIds: [], + removedStaleRootNames: [], + }); + const repairRetention = vi.fn().mockResolvedValue(repairResult); + const inspectRetention = vi.fn().mockResolvedValue(after); + mockPersistence.createRuntimeStateCache = vi.fn().mockResolvedValue({ + repairRetention, + inspectRetention, + resolveCheckpointHead: vi.fn().mockResolvedValue(null), + }); + + const result = await handleDoctor({ + options: CLI_OPTIONS, + args: ['--repair-state-cache'], + }); + + expect(repairRetention).toHaveBeenCalledOnce(); + expect(inspectRetention).toHaveBeenCalledOnce(); + expect(result.payload.findings.map((finding) => finding.code)).toContain( + CODES.STATE_CACHE_RETENTION_REPAIRED, + ); + }); + + it('reports a failed state-cache repair without aborting doctor', async () => { + const healthy = new WarpStateCacheRetentionReport({ + liveSnapshotIds: [], + anchoredSnapshotIds: [], + unanchoredSnapshotIds: [], + missingSnapshotIds: [], + wrongTypeSnapshotIds: [], + staleRootNames: [], + mismatchedRootNames: [], + rootSetError: null, + }); + mockPersistence.createRuntimeStateCache = vi.fn().mockResolvedValue({ + repairRetention: vi.fn().mockRejectedValue(new Error('root set unavailable')), + inspectRetention: vi.fn().mockResolvedValue(healthy), + resolveCheckpointHead: vi.fn().mockResolvedValue(null), + }); + + const result = await handleDoctor({ + options: CLI_OPTIONS, + args: ['--repair-state-cache'], + }); + + expect(result.payload.findings).toContainEqual(expect.objectContaining({ + id: 'state-cache-retention-repair', + status: 'fail', + code: CODES.CHECK_INTERNAL_ERROR, + })); + }); + it('sorts findings by status > impact > id', async () => { // Targeted mock: only break nodeExists for writer refs so that // checkRefsConsistent emits a fail, without accidentally affecting diff --git a/test/unit/cli/schemas.test.ts b/test/unit/cli/schemas.test.ts index 2fb166b55..51a3bcebf 100644 --- a/test/unit/cli/schemas.test.ts +++ b/test/unit/cli/schemas.test.ts @@ -63,6 +63,11 @@ describe('doctorSchema', () => { expect(result['large-graph']).toBe(true); }); + it('accepts explicit state-cache retention repair', () => { + const result = doctorSchema.parse({ 'repair-state-cache': true }); + expect(result['repair-state-cache']).toBe(true); + }); + it('rejects unknown keys', () => { expect(() => doctorSchema.parse({ unknown: true })).toThrow(); }); diff --git a/test/unit/domain/WarpFacade.test.ts b/test/unit/domain/WarpFacade.test.ts index 4b33c87b8..83072cc89 100644 --- a/test/unit/domain/WarpFacade.test.ts +++ b/test/unit/domain/WarpFacade.test.ts @@ -60,6 +60,48 @@ const FORBIDDEN_BROWSER_V19_EXPORTS = Object.freeze([ 'WarpStorage', ]); +const ALLOWED_ROOT_EXPORTS = Object.freeze([ + 'DraftTimeline', + 'EdgeIntentFields', + 'EdgePropertyIntentFields', + 'Intent', + 'IntentBuilders', + 'IntentDescriptor', + 'IntentKind', + 'JoinMode', + 'JoinOptions', + 'JoinPolicy', + 'JoinReceipt', + 'JoinReceiptOptions', + 'JoinReceiptOutcome', + 'JoinResult', + 'JoinResultOptions', + 'NodeIntentFields', + 'NodeReadingFields', + 'OpenWarpOptions', + 'PropertyIntentFields', + 'PropertyReadingFields', + 'ReadReceipt', + 'ReadReceiptOptions', + 'ReadReceiptOutcome', + 'Reading', + 'ReadingBuilders', + 'ReadingDescriptor', + 'ReadingKind', + 'ReadingResult', + 'ReadingResultOptions', + 'ReadingValue', + 'ReceiptOutcome', + 'Timeline', + 'Warp', + 'WarpStorage', + 'WriteReceipt', + 'WriteReceiptOptions', + 'intent', + 'openWarp', + 'reading', +]); + function exportedNamesFor(path: string): ReadonlySet { const sourceFile = sourceFileFor(path); const exportedNames = new Set(); @@ -191,6 +233,12 @@ describe('v19 Warp facade', () => { } }); + it('keeps the root export surface to the v19 facade allowlist', () => { + const rootExports = exportedNamesFor('index.ts'); + + expect([...rootExports].toSorted()).toEqual(ALLOWED_ROOT_EXPORTS.toSorted()); + }); + it('keeps internal history vocabulary off the public facade objects', async () => { const warp = await openWarp({ storage: new MemoryStorageAdapter(), @@ -300,7 +348,7 @@ describe('v19 Warp facade', () => { expect('nodes' in result).toBe(true); if (!('nodes' in result)) { - return; + throw new Error('query result must include nodes'); } expect(result.nodes).toEqual([ { id: 'user:alice', props: { role: 'admin' } }, diff --git a/test/unit/domain/WarpGraph.audit.test.ts b/test/unit/domain/WarpGraph.audit.test.ts index 33c37e0b7..8b3c08c83 100644 --- a/test/unit/domain/WarpGraph.audit.test.ts +++ b/test/unit/domain/WarpGraph.audit.test.ts @@ -148,16 +148,9 @@ describe('WarpCore — audit mode', () => { (c) => c[1]?.code === 'AUDIT_SKIPPED_DIRTY_STATE', ); - // If graph was able to eager-apply (cachedState was not null), - // then audit should have succeeded. Either way is valid behavior. const auditRef = await persistence.readRef('refs/warp/events/audit/alice'); - if (auditRef) { - // Eager path worked — audit commit was created - expect(typeof auditRef).toBe('string'); - } else { - // Dirty state — skip was logged - expect(skipLog).toBeTruthy(); - } + expect(auditRef).toBeNull(); + expect(skipLog).toBeTruthy(); }); it('audit commit tree contains receipt.cbor with correct receipt data', async () => { @@ -178,8 +171,7 @@ describe('WarpCore — audit mode', () => { const auditSha = await persistence.readRef('refs/warp/events/audit/alice'); if (!auditSha) { - // Skip if eager path wasn't available - return; + throw new Error('audit ref must exist after audited commit'); } const commit = (persistence as any)._commits.get(auditSha); diff --git a/test/unit/domain/WarpGraph.seekDiff.test.ts b/test/unit/domain/WarpGraph.seekDiff.test.ts index 6e184df4e..cc3c499aa 100644 --- a/test/unit/domain/WarpGraph.seekDiff.test.ts +++ b/test/unit/domain/WarpGraph.seekDiff.test.ts @@ -286,7 +286,7 @@ describe('Structural seek diff (diffStates integration)', () => { const state = graph._cachedState; expect(state).not.toBeNull(); if (state === null) { - return; + throw new Error('cached state must exist after materialize'); } const diff = diffStates(state, state); diff --git a/test/unit/domain/crdt/ORSet.test.ts b/test/unit/domain/crdt/ORSet.test.ts index 76e703f3a..2f57f5673 100644 --- a/test/unit/domain/crdt/ORSet.test.ts +++ b/test/unit/domain/crdt/ORSet.test.ts @@ -551,131 +551,6 @@ describe('ORSet', () => { }); }); - describe('serialize / deserialize', () => { - it('serializes empty set', () => { - const set = ORSet.empty(); - const serialized = set.serialize(); - - expect(serialized).toEqual({ - entries: [], - tombstones: [], - }); - }); - - it('serializes set with entries', () => { - const set = ORSet.empty(); - const dot = Dot.create('writer1', 1); - - set.add('element1', dot); - const serialized = set.serialize(); - - expect(serialized.entries).toEqual([['element1', ['writer1:1']]]); - expect(serialized.tombstones).toEqual([]); - }); - - it('serializes set with tombstones', () => { - const set = ORSet.empty(); - const dot = Dot.create('writer1', 1); - - set.add('element1', dot); - set.remove(new Set([encodeDot(dot)])); - const serialized = set.serialize(); - - expect(serialized.tombstones).toEqual(['writer1:1']); - }); - - it('sorts entries by element', () => { - const set = ORSet.empty(); - const dot1 = Dot.create('writer1', 1); - const dot2 = Dot.create('writer1', 2); - const dot3 = Dot.create('writer1', 3); - - set.add('c', dot1); - set.add('a', dot2); - set.add('b', dot3); - - const serialized = set.serialize(); - - const e0 = serialized.entries[0]; if (e0 === undefined) { throw new Error('missing'); } - const e1 = serialized.entries[1]; if (e1 === undefined) { throw new Error('missing'); } - const e2 = serialized.entries[2]; if (e2 === undefined) { throw new Error('missing'); } - expect(e0[0]).toBe('a'); - expect(e1[0]).toBe('b'); - expect(e2[0]).toBe('c'); - }); - - it('sorts dots within entries', () => { - const set = ORSet.empty(); - const dot1 = Dot.create('writer2', 1); - const dot2 = Dot.create('writer1', 1); - - set.add('element1', dot1); - set.add('element1', dot2); - - const serialized = set.serialize(); - - // writer1:1 < writer2:1 (lexicographic by writerId) - const entry0 = serialized.entries[0]; if (entry0 === undefined) { throw new Error('missing'); } - expect(entry0[1]).toEqual(['writer1:1', 'writer2:1']); - }); - - it('sorts tombstones', () => { - const set = ORSet.empty(); - const dot1 = Dot.create('writer2', 1); - const dot2 = Dot.create('writer1', 1); - - set.remove(new Set([encodeDot(dot1), encodeDot(dot2)])); - - const serialized = set.serialize(); - - expect(serialized.tombstones).toEqual(['writer1:1', 'writer2:1']); - }); - - it('deserializes back to equivalent set', () => { - const original = ORSet.empty(); - const dot1 = Dot.create('writer1', 1); - const dot2 = Dot.create('writer2', 1); - - original.add('element1', dot1); - original.add('element2', dot2); - original.remove(new Set([encodeDot(dot1)])); - - const serialized = original.serialize(); - const deserialized = ORSet.deserialize(serialized); - - // Check equivalence - expect(deserialized.contains('element1')).toBe(false); - expect(deserialized.contains('element2')).toBe(true); - expect(deserialized.tombstones.has(encodeDot(dot1))).toBe(true); - }); - - it('deserializes empty object gracefully', () => { - const deserialized = ORSet.deserialize({}); - - expect(deserialized.entries.size).toBe(0); - expect(deserialized.tombstones.size).toBe(0); - }); - - it('round-trip serialization preserves structure', () => { - const original = ORSet.empty(); - const dot1 = Dot.create('alice', 1); - const dot2 = Dot.create('alice', 2); - const dot3 = Dot.create('bob', 1); - - original.add('x', dot1); - original.add('x', dot2); - original.add('y', dot3); - original.remove(new Set([encodeDot(dot1)])); - - const serialized = original.serialize(); - const deserialized = ORSet.deserialize(serialized); - const reserialized = deserialized.serialize(); - - // Serialized forms should be identical - expect(reserialized).toEqual(serialized); - }); - }); - describe('edge cases', () => { it('works with numeric elements', () => { const set = ORSet.empty(); diff --git a/test/unit/domain/orset/session/StateSession.semilattice.property.test.ts b/test/unit/domain/orset/session/StateSession.semilattice.property.test.ts index 33c791855..91b278f91 100644 --- a/test/unit/domain/orset/session/StateSession.semilattice.property.test.ts +++ b/test/unit/domain/orset/session/StateSession.semilattice.property.test.ts @@ -9,6 +9,7 @@ import StateSession from "../../../../../src/domain/orset/session/StateSession.t import PageCache from "../../../../../src/domain/orset/trie/PageCache.ts"; import TrieGeometry from "../../../../../src/domain/orset/trie/TrieGeometry.ts"; import cborCodec from "../../../../../src/infrastructure/codecs/CborCodec.ts"; +import { serializeORSet } from "../../../../../src/domain/services/state/ORSetWireBoundary.ts"; import { InMemoryTrieStore } from "../../../../helpers/trieHelpers.ts"; const PROPERTY_TEST_SEED = 20260422; @@ -131,8 +132,8 @@ async function joinSessionSets(left: ORSet, right: ORSet): Promise { } function orsetsEqual(left: ORSet, right: ORSet): boolean { - const leftSerialized = left.serialize(); - const rightSerialized = right.serialize(); + const leftSerialized = serializeORSet(left); + const rightSerialized = serializeORSet(right); return JSON.stringify(leftSerialized) === JSON.stringify(rightSerialized); } @@ -258,7 +259,7 @@ describe("StateSession semilattice proof", () => { expect(baselineClose.nodeAliveRootOid).not.toBeNull(); if (baselineClose.nodeAliveRootOid === null) { - return; + throw new Error('nodeAliveRootOid must exist after close'); } const reopened = await openSession({ diff --git a/test/unit/domain/orset/shadow/ShadowTrieORSet.compaction.test.ts b/test/unit/domain/orset/shadow/ShadowTrieORSet.compaction.test.ts index d70fe3e87..ee917665f 100644 --- a/test/unit/domain/orset/shadow/ShadowTrieORSet.compaction.test.ts +++ b/test/unit/domain/orset/shadow/ShadowTrieORSet.compaction.test.ts @@ -259,7 +259,7 @@ describe("ShadowTrieORSet.compact", () => { const { rootOid } = await flushAndReopen({ engine, store }); expect(rootOid).not.toBeNull(); if (rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const page = await rootChildPage({ @@ -270,7 +270,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(page.kind).toBe("leaf"); if (page.kind !== "leaf") { - return; + throw new Error("expected root child page to be a leaf"); } expect(page.leaf.entries()).toHaveLength(1); const [entry] = page.leaf.entries(); @@ -302,7 +302,7 @@ describe("ShadowTrieORSet.compact", () => { ); expect(rootOid).not.toBeNull(); if (rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const page = await rootChildPage({ @@ -313,7 +313,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(page.kind).toBe("leaf"); if (page.kind !== "leaf") { - return; + throw new Error("expected root child page to be a leaf"); } expect(new Set(leafElements(page.leaf))).toEqual( new Set(["node:278", "node:20", "node:234"]), @@ -346,7 +346,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(rootOid).not.toBeNull(); if (rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const page = await rootChildPage({ @@ -357,7 +357,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(page.kind).toBe("branch"); if (page.kind !== "branch") { - return; + throw new Error("expected root child page to be a branch"); } expect(page.branch.childCount()).toBe(2); }); @@ -387,7 +387,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(rootOid).not.toBeNull(); if (rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const page = await rootChildPage({ @@ -398,7 +398,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(page.kind).toBe("leaf"); if (page.kind !== "leaf") { - return; + throw new Error("expected root child page to be a leaf"); } expect(new Set(leafElements(page.leaf))).toEqual( new Set(["node:20", "node:234"]), @@ -427,7 +427,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(rootOid).not.toBeNull(); if (rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const page = await rootChildPage({ @@ -438,7 +438,7 @@ describe("ShadowTrieORSet.compact", () => { }); expect(page.kind).toBe("leaf"); if (page.kind !== "leaf") { - return; + throw new Error("expected root child page to be a leaf"); } assertStrictSuffixOrder(page.leaf); }); @@ -452,7 +452,7 @@ describe("ShadowTrieORSet.compact", () => { const flushed = await seeded.engine.flush(); expect(flushed.rootOid).not.toBeNull(); if (flushed.rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } store.failReads(); @@ -474,7 +474,7 @@ describe("ShadowTrieORSet.compact", () => { const before = await seeded.engine.flush(); expect(before.rootOid).not.toBeNull(); if (before.rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const reopened = makeEngine({ diff --git a/test/unit/domain/orset/shadow/ShadowTrieORSet.test.ts b/test/unit/domain/orset/shadow/ShadowTrieORSet.test.ts index 5ffcb9bab..7bafd47f4 100644 --- a/test/unit/domain/orset/shadow/ShadowTrieORSet.test.ts +++ b/test/unit/domain/orset/shadow/ShadowTrieORSet.test.ts @@ -127,7 +127,7 @@ describe("ShadowTrieORSet", () => { const result = await engine.flush(); expect(result.rootOid).not.toBeNull(); if (result.rootOid === null) { - return; + throw new Error("rootOid must exist after flush"); } const reopened = makeEngine({ rootOid: result.rootOid, store }).engine; diff --git a/test/unit/domain/properties/Join.property.test.ts b/test/unit/domain/properties/Join.property.test.ts index 932c6ced9..77883ed2b 100644 --- a/test/unit/domain/properties/Join.property.test.ts +++ b/test/unit/domain/properties/Join.property.test.ts @@ -16,6 +16,7 @@ import ORSet from '../../../../src/domain/crdt/ORSet.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; import { Dot, encodeDot } from '../../../../src/domain/crdt/Dot.ts'; +import { serializeORSet } from '../../../../src/domain/services/state/ORSetWireBoundary.ts'; import { lwwSet } from '../../../../src/domain/crdt/LWW.ts'; import { EventId } from '../../../../src/domain/utils/EventId.ts'; @@ -150,12 +151,12 @@ const stateArb = fc.record({ /** @param {any} a @param {any} b */ function statesEqual(a, b) { // Compare nodeAlive ORSets - if (JSON.stringify(a.nodeAlive.serialize()) !== JSON.stringify(b.nodeAlive.serialize())) { + if (JSON.stringify(serializeORSet(a.nodeAlive)) !== JSON.stringify(serializeORSet(b.nodeAlive))) { return false; } // Compare edgeAlive ORSets - if (JSON.stringify(a.edgeAlive.serialize()) !== JSON.stringify(b.edgeAlive.serialize())) { + if (JSON.stringify(serializeORSet(a.edgeAlive)) !== JSON.stringify(serializeORSet(b.edgeAlive))) { return false; } diff --git a/test/unit/domain/properties/ORSet.property.test.ts b/test/unit/domain/properties/ORSet.property.test.ts index 263782434..c42f3e3da 100644 --- a/test/unit/domain/properties/ORSet.property.test.ts +++ b/test/unit/domain/properties/ORSet.property.test.ts @@ -2,6 +2,7 @@ import { describe, it } from 'vitest'; import fc from 'fast-check'; import ORSet from '../../../../src/domain/crdt/ORSet.ts'; import { Dot, encodeDot } from '../../../../src/domain/crdt/Dot.ts'; +import { serializeORSet } from '../../../../src/domain/services/state/ORSetWireBoundary.ts'; // ============================================================================ // Arbitraries for generating random ORSets @@ -64,7 +65,7 @@ const orsetArb = fc.array(operationArb, { minLength: 0, maxLength: 10 }).map((op */ /** @param {any} a @param {any} b */ function orsetEqual(a, b) { - return JSON.stringify(a.serialize()) === JSON.stringify(b.serialize()); + return JSON.stringify(serializeORSet(a)) === JSON.stringify(serializeORSet(b)); } // ============================================================================ @@ -153,8 +154,8 @@ describe('ORSet property tests', () => { it('serialization is deterministic', () => { fc.assert( fc.property(orsetArb, (set) => { - const s1 = JSON.stringify(set.serialize()); - const s2 = JSON.stringify(set.serialize()); + const s1 = JSON.stringify(serializeORSet(set)); + const s2 = JSON.stringify(serializeORSet(set)); return s1 === s2; }), { numRuns: 100 } @@ -168,8 +169,8 @@ describe('ORSet property tests', () => { const ab = a.join(b); const ba = b.join(a); - const sAB = JSON.stringify(ab.serialize()); - const sBA = JSON.stringify(ba.serialize()); + const sAB = JSON.stringify(serializeORSet(ab)); + const sBA = JSON.stringify(serializeORSet(ba)); return sAB === sBA; }), { numRuns: 100 } diff --git a/test/unit/domain/publicReadingSurface.behavior.test.ts b/test/unit/domain/publicReadingSurface.behavior.test.ts index dcc2b2ddb..d4f6b890e 100644 --- a/test/unit/domain/publicReadingSurface.behavior.test.ts +++ b/test/unit/domain/publicReadingSurface.behavior.test.ts @@ -61,7 +61,7 @@ describe('public reading surfaces', () => { const result = await worldline.live().query().match('user:*').select(['id', 'props']).run(); expect('nodes' in result).toBe(true); if (!('nodes' in result)) { - return; + throw new Error('query result must include nodes'); } expect(result.nodes).toEqual([ { id: 'user:alice', props: { role: 'admin' } }, diff --git a/test/unit/domain/publicStrandAndRuntimeSeams.test.ts b/test/unit/domain/publicStrandAndRuntimeSeams.test.ts index e698006c7..1108b25f2 100644 --- a/test/unit/domain/publicStrandAndRuntimeSeams.test.ts +++ b/test/unit/domain/publicStrandAndRuntimeSeams.test.ts @@ -246,7 +246,11 @@ class RecordingPatchJournalPort extends PatchJournalPort { class RecordingCheckpointStorePort extends CheckpointStorePort { async writeCheckpoint(_record: CheckpointRecord): Promise { return { - stateBlobOid: 'state-oid', + nodeAliveBlobOid: 'node-alive-oid', + edgeAliveBlobOid: 'edge-alive-oid', + propBlobOid: 'prop-oid', + observedFrontierBlobOid: 'observed-frontier-oid', + edgeBirthEventBlobOid: 'edge-birth-event-oid', frontierBlobOid: 'frontier-oid', appliedVVBlobOid: 'applied-vv-oid', provenanceIndexBlobOid: null, diff --git a/test/unit/domain/services/CheckpointService.edgeCases.test.ts b/test/unit/domain/services/CheckpointService.edgeCases.test.ts index 6278c5d9c..dffbc6af3 100644 --- a/test/unit/domain/services/CheckpointService.edgeCases.test.ts +++ b/test/unit/domain/services/CheckpointService.edgeCases.test.ts @@ -845,7 +845,11 @@ describe('CheckpointService edge cases', () => { const frontier = createFrontier(); const checkpointStore = { writeCheckpoint: vi.fn(async () => ({ - stateBlobOid: makeOid('state'), + nodeAliveBlobOid: makeOid('nodeAlive'), + edgeAliveBlobOid: makeOid('edgeAlive'), + propBlobOid: makeOid('prop'), + observedFrontierBlobOid: makeOid('observedFrontier'), + edgeBirthEventBlobOid: makeOid('edgeBirthEvent'), frontierBlobOid: makeOid('frontier'), appliedVVBlobOid: makeOid('appliedvv'), provenanceIndexBlobOid: null, @@ -868,8 +872,8 @@ describe('CheckpointService edge cases', () => { crypto, }); - expect(checkpointStore.writeCheckpoint).not.toHaveBeenCalled(); - expect(mockPersistence.writeBlob).toHaveBeenCalledTimes(7); + expect(checkpointStore.writeCheckpoint).toHaveBeenCalledOnce(); + expect(mockPersistence.writeBlob).not.toHaveBeenCalled(); const message = mockPersistence.commitNodeWithTree.mock.calls[0][0].message; expect(decodeCheckpointMessage(message).schema).toBe(5); }); diff --git a/test/unit/domain/services/query/BoundedQueryNodePageReader.test.ts b/test/unit/domain/services/query/BoundedQueryNodePageReader.test.ts new file mode 100644 index 000000000..f86b4ea88 --- /dev/null +++ b/test/unit/domain/services/query/BoundedQueryNodePageReader.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import MemoryBudgetError from '../../../../../src/domain/errors/MemoryBudgetError.ts'; +import MemoryBudget from '../../../../../src/domain/memory/MemoryBudget.ts'; +import WarpMemoryPool from '../../../../../src/domain/memory/WarpMemoryPool.ts'; +import BoundedQueryNodePageReader from '../../../../../src/domain/services/query/BoundedQueryNodePageReader.ts'; +import type { + QueryNeighborEntry, + QueryNeighborOptions, + QueryNodeStreamRequest, + QueryPropertyBag, + QueryReadModel, +} from '../../../../../src/domain/services/query/QueryReadModelProvider.ts'; +import type { QueryNodeSnapshot } from '../../../../../src/domain/services/query/QueryPlan.ts'; + +describe('BoundedQueryNodePageReader', () => { + it('reads cursorized node pages while retaining only returned nodes', async () => { + const readModel = new FixtureReadModel([ + nodeSnapshot('node:1'), + nodeSnapshot('node:2'), + nodeSnapshot('node:3'), + nodeSnapshot('node:4'), + nodeSnapshot('node:5'), + ]); + const pool = nodePagePool(2); + const reader = new BoundedQueryNodePageReader({ readModel, pool }); + + const first = await reader.readPage({ pattern: 'User', select: ['name'], limit: 2 }); + const second = await reader.readPage({ pattern: 'User', select: ['name'], limit: 2, cursor: first.cursor }); + const third = await reader.readPage({ pattern: 'User', select: ['name'], limit: 2, cursor: second.cursor }); + + expect(first.nodes.map((node) => node.id)).toEqual(['node:1', 'node:2']); + expect(first.cursor).toBe('2'); + expect(second.nodes.map((node) => node.id)).toEqual(['node:3', 'node:4']); + expect(second.cursor).toBe('4'); + expect(third.nodes.map((node) => node.id)).toEqual(['node:5']); + expect(third.cursor).toBeNull(); + expect(readModel.requests).toEqual([ + { pattern: 'User', select: ['name'] }, + { pattern: 'User', select: ['name'] }, + { pattern: 'User', select: ['name'] }, + ]); + expect(pool.snapshot()).toMatchObject({ leased: 0, peak: 2, rejected: 0 }); + }); + + it('releases retained leases when the page exceeds the memory pool', async () => { + const readModel = new FixtureReadModel([ + nodeSnapshot('node:1'), + nodeSnapshot('node:2'), + ]); + const pool = nodePagePool(1); + const reader = new BoundedQueryNodePageReader({ readModel, pool }); + + await expect(reader.readPage({ pattern: 'User', select: null, limit: 2 })) + .rejects.toBeInstanceOf(MemoryBudgetError); + + expect(pool.snapshot()).toMatchObject({ leased: 0, peak: 1, rejected: 1 }); + }); + + it('rejects malformed fields and requests before streaming', async () => { + const pool = nodePagePool(1); + const readModel = new FixtureReadModel([nodeSnapshot('node:1')]); + + expect(() => new BoundedQueryNodePageReader({ + // @ts-expect-error deliberate malformed read model fixture + readModel: null, + pool, + })).toThrow(MemoryBudgetError); + expect(() => new BoundedQueryNodePageReader({ + readModel, + // @ts-expect-error deliberate malformed memory pool fixture + pool: null, + })).toThrow(MemoryBudgetError); + + const reader = new BoundedQueryNodePageReader({ readModel, pool }); + await expect(reader.readPage({ + pattern: 'User', + select: null, + limit: 0, + })).rejects.toBeInstanceOf(MemoryBudgetError); + await expect(reader.readPage({ + pattern: 'User', + select: null, + limit: 1, + cursor: '01', + })).rejects.toBeInstanceOf(MemoryBudgetError); + await expect(reader.readPage(null as never)).rejects.toBeInstanceOf(MemoryBudgetError); + }); +}); + +class FixtureReadModel implements QueryReadModel { + readonly stateHash = 'fixture-state'; + readonly requests: QueryNodeStreamRequest[] = []; + private readonly _nodes: readonly QueryNodeSnapshot[]; + + constructor(nodes: readonly QueryNodeSnapshot[]) { + this._nodes = nodes; + } + + async *nodes(request: QueryNodeStreamRequest): AsyncIterable { + this.requests.push(request); + for (const node of this._nodes) { + yield node; + } + } + + async *neighbors( + _nodeId: string, + _options: QueryNeighborOptions, + ): AsyncIterable { + } + + async nodeProps(_nodeId: string): Promise { + return null; + } +} + +function nodePagePool(limit: number): WarpMemoryPool { + return new WarpMemoryPool({ + name: 'bounded-query-node-page-reader', + budget: MemoryBudget.results(limit), + }); +} + +function nodeSnapshot(id: string): QueryNodeSnapshot { + return { + id, + props: Object.freeze({ name: id }), + edgesOut: Object.freeze([]), + edgesIn: Object.freeze([]), + }; +} diff --git a/test/unit/domain/services/state/CheckpointSerializerBoundary.test.ts b/test/unit/domain/services/state/CheckpointSerializerBoundary.test.ts new file mode 100644 index 000000000..d8e615a16 --- /dev/null +++ b/test/unit/domain/services/state/CheckpointSerializerBoundary.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { deserializeFullState } from '../../../../../src/domain/services/state/CheckpointSerializer.ts'; +import { CborCodec } from '../../../../../src/infrastructure/codecs/CborCodec.ts'; + +const codec = new CborCodec(); + +describe('CheckpointSerializer boundary defaults', () => { + it('defaults omitted property and observed-frontier artifacts', () => { + const bytes = codec.encode({ + version: 'full-v5', + nodeAlive: { entries: [], tombstones: [] }, + edgeAlive: { entries: [], tombstones: [] }, + }); + + const restored = deserializeFullState(bytes, { codec }); + + expect(restored.propSize()).toBe(0); + expect(restored.observedFrontier.size).toBe(0); + }); + + it('treats a non-array property artifact as empty', () => { + const bytes = codec.encode({ + version: 'full-v5', + nodeAlive: { entries: [], tombstones: [] }, + edgeAlive: { entries: [], tombstones: [] }, + prop: {}, + observedFrontier: {}, + }); + + expect(deserializeFullState(bytes, { codec }).propSize()).toBe(0); + }); + + it('skips null property registers from persisted data', () => { + const bytes = codec.encode({ + version: 'full-v5', + nodeAlive: { entries: [], tombstones: [] }, + edgeAlive: { entries: [], tombstones: [] }, + prop: [['node\u0000name', null]], + observedFrontier: {}, + }); + + expect(deserializeFullState(bytes, { codec }).propSize()).toBe(0); + }); +}); diff --git a/test/unit/domain/services/state/ORSetWireBoundary.test.ts b/test/unit/domain/services/state/ORSetWireBoundary.test.ts new file mode 100644 index 000000000..96740c5f5 --- /dev/null +++ b/test/unit/domain/services/state/ORSetWireBoundary.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import ORSet from '../../../../../src/domain/crdt/ORSet.ts'; +import { Dot, encodeDot } from '../../../../../src/domain/crdt/Dot.ts'; +import { + deserializeORSet, + serializeORSet, +} from '../../../../../src/domain/services/state/ORSetWireBoundary.ts'; + +const MALFORMED_DOT = /Invalid encoded dot format/; + +describe('ORSetWireBoundary', () => { + it('serializes empty set', () => { + const set = ORSet.empty(); + const serialized = serializeORSet(set); + + expect(serialized).toEqual({ + entries: [], + tombstones: [], + }); + }); + + it('serializes set with entries', () => { + const set = ORSet.empty(); + const dot = Dot.create('writer1', 1); + + set.add('element1', dot); + const serialized = serializeORSet(set); + + expect(serialized.entries).toEqual([['element1', ['writer1:1']]]); + expect(serialized.tombstones).toEqual([]); + }); + + it('serializes set with tombstones', () => { + const set = ORSet.empty(); + const dot = Dot.create('writer1', 1); + + set.add('element1', dot); + set.remove(new Set([encodeDot(dot)])); + const serialized = serializeORSet(set); + + expect(serialized.tombstones).toEqual(['writer1:1']); + }); + + it('sorts entries by element', () => { + const set = ORSet.empty(); + const dot1 = Dot.create('writer1', 1); + const dot2 = Dot.create('writer1', 2); + const dot3 = Dot.create('writer1', 3); + + set.add('c', dot1); + set.add('a', dot2); + set.add('b', dot3); + + const serialized = serializeORSet(set); + + const e0 = serialized.entries[0]; + const e1 = serialized.entries[1]; + const e2 = serialized.entries[2]; + if (e0 === undefined || e1 === undefined || e2 === undefined) { + throw new Error('missing serialized entries'); + } + expect(e0[0]).toBe('a'); + expect(e1[0]).toBe('b'); + expect(e2[0]).toBe('c'); + }); + + it('sorts dots within entries', () => { + const set = ORSet.empty(); + const dot1 = Dot.create('writer2', 1); + const dot2 = Dot.create('writer1', 1); + + set.add('element1', dot1); + set.add('element1', dot2); + + const serialized = serializeORSet(set); + + const entry0 = serialized.entries[0]; + if (entry0 === undefined) { + throw new Error('missing serialized entry'); + } + expect(entry0[1]).toEqual(['writer1:1', 'writer2:1']); + }); + + it('sorts tombstones', () => { + const set = ORSet.empty(); + const dot1 = Dot.create('writer2', 1); + const dot2 = Dot.create('writer1', 1); + + set.remove(new Set([encodeDot(dot1), encodeDot(dot2)])); + + const serialized = serializeORSet(set); + + expect(serialized.tombstones).toEqual(['writer1:1', 'writer2:1']); + }); + + it('deserializes back to equivalent set', () => { + const original = ORSet.empty(); + const dot1 = Dot.create('writer1', 1); + const dot2 = Dot.create('writer2', 1); + + original.add('element1', dot1); + original.add('element2', dot2); + original.remove(new Set([encodeDot(dot1)])); + + const serialized = serializeORSet(original); + const deserialized = deserializeORSet(serialized); + + expect(deserialized.contains('element1')).toBe(false); + expect(deserialized.contains('element2')).toBe(true); + expect(deserialized.tombstones.has(encodeDot(dot1))).toBe(true); + }); + + it('deserializes empty object gracefully', () => { + const deserialized = deserializeORSet({}); + + expect(deserialized.entries.size).toBe(0); + expect(deserialized.tombstones.size).toBe(0); + }); + + it('rejects malformed entry and tombstone dots at the codec boundary', () => { + expect(() => deserializeORSet({ + entries: [['element', ['not-a-dot']]], + })).toThrow(MALFORMED_DOT); + expect(() => deserializeORSet({ + tombstones: ['also-not-a-dot'], + })).toThrow(MALFORMED_DOT); + }); + + it('rejects a non-array entry dot collection', () => { + expect(() => deserializeORSet({ + // @ts-expect-error Persisted input can violate the transport declaration. + entries: [['element', null]], + })).toThrow('ORSet entry dots must be an array'); + }); + + it('round-trips without changing serialized structure', () => { + const original = ORSet.empty(); + const dot1 = Dot.create('alice', 1); + const dot2 = Dot.create('alice', 2); + const dot3 = Dot.create('bob', 1); + + original.add('x', dot1); + original.add('x', dot2); + original.add('y', dot3); + original.remove(new Set([encodeDot(dot1)])); + + const serialized = serializeORSet(original); + const deserialized = deserializeORSet(serialized); + const reserialized = serializeORSet(deserialized); + + expect(reserialized).toEqual(serialized); + }); +}); diff --git a/test/unit/domain/services/state/WarpStateCacheRetentionResults.test.ts b/test/unit/domain/services/state/WarpStateCacheRetentionResults.test.ts new file mode 100644 index 000000000..3475e1f9c --- /dev/null +++ b/test/unit/domain/services/state/WarpStateCacheRetentionResults.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import WarpStateCacheRepairResult + from '../../../../../src/domain/services/state/WarpStateCacheRepairResult.ts'; +import WarpStateCacheRetentionReport + from '../../../../../src/domain/services/state/WarpStateCacheRetentionReport.ts'; + +const EMPTY_NAME_ERROR = /cannot contain an empty name/; +const EMPTY_ROOT_SET_ERROR = /rootSetError cannot be empty/; +const INVALID_AFTER_REPORT = /requires a retention report for after/; +const INVALID_BEFORE_REPORT = /requires a retention report for before/; + +function report(options: { + readonly rootSetError?: string | null; + readonly unanchoredSnapshotIds?: readonly string[]; +} = {}): WarpStateCacheRetentionReport { + return new WarpStateCacheRetentionReport({ + liveSnapshotIds: ['snapshot-b', 'snapshot-a', 'snapshot-a'], + anchoredSnapshotIds: ['snapshot-a'], + unanchoredSnapshotIds: options.unanchoredSnapshotIds ?? [], + missingSnapshotIds: [], + wrongTypeSnapshotIds: [], + staleRootNames: [], + mismatchedRootNames: [], + rootSetError: options.rootSetError ?? null, + }); +} + +describe('state-cache retention result values', () => { + it('normalizes, freezes, and evaluates retention reports', () => { + const value = report({ unanchoredSnapshotIds: ['snapshot-b'] }); + + expect(value.liveSnapshotIds).toEqual(['snapshot-a', 'snapshot-b']); + expect(Object.isFrozen(value.liveSnapshotIds)).toBe(true); + expect(Object.isFrozen(value)).toBe(true); + expect(value.isHealthy()).toBe(false); + expect(report().isHealthy()).toBe(true); + }); + + it('rejects empty report names and errors', () => { + expect(() => new WarpStateCacheRetentionReport({ + liveSnapshotIds: [''], + anchoredSnapshotIds: [], + unanchoredSnapshotIds: [], + missingSnapshotIds: [], + wrongTypeSnapshotIds: [], + staleRootNames: [], + mismatchedRootNames: [], + rootSetError: null, + })).toThrow(EMPTY_NAME_ERROR); + expect(() => report({ rootSetError: '' })).toThrow(EMPTY_ROOT_SET_ERROR); + }); + + it('normalizes and freezes repair results', () => { + const before = report({ unanchoredSnapshotIds: ['snapshot-b'] }); + const after = report(); + const value = new WarpStateCacheRepairResult({ + before, + after, + anchoredSnapshotIds: ['snapshot-b', 'snapshot-a', 'snapshot-a'], + unrecoverableSnapshotIds: [], + removedStaleRootNames: ['stale-b', 'stale-a'], + }); + + expect(value.anchoredSnapshotIds).toEqual(['snapshot-a', 'snapshot-b']); + expect(value.removedStaleRootNames).toEqual(['stale-a', 'stale-b']); + expect(Object.isFrozen(value.anchoredSnapshotIds)).toBe(true); + expect(Object.isFrozen(value)).toBe(true); + }); + + it('rejects invalid reports and empty repaired names', () => { + const valid = report(); + const base = { + before: valid, + after: valid, + anchoredSnapshotIds: [], + unrecoverableSnapshotIds: [], + removedStaleRootNames: [], + }; + + expect(() => Reflect.construct(WarpStateCacheRepairResult, [ + { ...base, before: {} }, + ])).toThrow(INVALID_BEFORE_REPORT); + expect(() => Reflect.construct(WarpStateCacheRepairResult, [ + { ...base, after: {} }, + ])).toThrow(INVALID_AFTER_REPORT); + expect(() => new WarpStateCacheRepairResult({ + ...base, + anchoredSnapshotIds: [''], + })).toThrow(EMPTY_NAME_ERROR); + }); +}); diff --git a/test/unit/domain/types/DeliveryObservation.test.ts b/test/unit/domain/types/DeliveryObservation.test.ts index 475063b20..86d3fc53d 100644 --- a/test/unit/domain/types/DeliveryObservation.test.ts +++ b/test/unit/domain/types/DeliveryObservation.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import { createDeliveryObservation, - canonicalObservationJson, } from '../../../../src/domain/types/DeliveryObservation.ts'; +import { canonicalObservationJson } from '../../../../src/infrastructure/codecs/DeliveryObservationJsonCodec.ts'; const create = (createDeliveryObservation) as any; diff --git a/test/unit/domain/types/EffectEmission.test.ts b/test/unit/domain/types/EffectEmission.test.ts index 12deae43d..71ffde15a 100644 --- a/test/unit/domain/types/EffectEmission.test.ts +++ b/test/unit/domain/types/EffectEmission.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { createEffectEmission, - canonicalEmissionJson, DELIVERY_MODES, DELIVERY_OUTCOMES, } from '../../../../src/domain/types/EffectEmission.ts'; +import { canonicalEmissionJson } from '../../../../src/infrastructure/codecs/EffectEmissionJsonCodec.ts'; const create = (createEffectEmission) as any; diff --git a/test/unit/domain/types/TickReceipt.test.ts b/test/unit/domain/types/TickReceipt.test.ts index 4e317181f..e3972dfba 100644 --- a/test/unit/domain/types/TickReceipt.test.ts +++ b/test/unit/domain/types/TickReceipt.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { createTickReceipt as _createTickReceipt, - canonicalJson, OP_TYPES, RESULT_TYPES, } from '../../../../src/domain/types/TickReceipt.ts'; +import { tickReceiptCanonicalJson as canonicalJson } from '../../../../src/infrastructure/codecs/TickReceiptJsonCodec.ts'; const createTickReceipt = (_createTickReceipt) as any; diff --git a/test/unit/domain/utils/toBytes.test.ts b/test/unit/domain/utils/toBytes.test.ts index 9ce30460e..8ddbf5b46 100644 --- a/test/unit/domain/utils/toBytes.test.ts +++ b/test/unit/domain/utils/toBytes.test.ts @@ -15,7 +15,10 @@ describe('toBytes', () => { }); it('converts Buffer to Uint8Array', () => { - if (typeof Buffer === 'undefined') { return; } + expect(typeof Buffer).not.toBe('undefined'); + if (typeof Buffer === 'undefined') { + throw new Error('Buffer must exist in this test runtime'); + } const buf = Buffer.from([4, 5, 6]); const result = toBytes(buf); expect(result).toBeInstanceOf(Uint8Array); diff --git a/test/unit/domain/warp/RuntimeStateStore.test.ts b/test/unit/domain/warp/RuntimeStateStore.test.ts index b051477c0..dbef27b8d 100644 --- a/test/unit/domain/warp/RuntimeStateStore.test.ts +++ b/test/unit/domain/warp/RuntimeStateStore.test.ts @@ -104,7 +104,7 @@ describe('RuntimeStateStore', () => { const snapshot = store.get(); expect(snapshot).not.toBeNull(); if (snapshot === null) { - return; + throw new Error('runtime state snapshot must exist'); } host._materializedGraph?.adjacency.outgoing.get('alpha')?.push(edge('gamma', 'late')); diff --git a/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts b/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts index 09ac9eff0..322646489 100644 --- a/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts +++ b/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts @@ -1,16 +1,28 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { CborCheckpointStoreAdapter } from '../../../../src/infrastructure/adapters/CborCheckpointStoreAdapter.ts'; -import { decodeCasPayloadPointer } from '../../../../src/infrastructure/adapters/CasPayloadPointer.ts'; import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; -import CheckpointStorePort from '../../../../src/ports/CheckpointStorePort.ts'; -import BlobStoragePort from '../../../../src/ports/BlobStoragePort.ts'; +import CheckpointStorePort, { type CheckpointWriteResult } from '../../../../src/ports/CheckpointStorePort.ts'; import ORSet from '../../../../src/domain/crdt/ORSet.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import { EventId } from '../../../../src/domain/utils/EventId.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import type { LWWRegister } from '../../../../src/domain/crdt/LWW.ts'; +import type { PropValue } from '../../../../src/domain/types/PropValue.ts'; import MockBlobPort from '../../../helpers/MockBlobPort.ts'; +type EncodedRegister = { + eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; + value: PropValue; +}; + +type EncodedEdgeBirth = { + lamport: number; + writerId: string; + patchSha: string; + opIndex: number; +}; + /** * Builds a small but representative checkpoint state. * @returns {WarpState} @@ -23,9 +35,9 @@ function createGoldenState() { const edgeAlive = ORSet.empty(); edgeAlive.add('user:alice\x00user:bob\x00knows', Dot.create('w1', 3)); - const prop = (new Map()) as any; + const prop = new Map>(); prop.set('user:alice\x00name', { - eventId: { lamport: 1, writerId: 'w1', patchSha: 'a'.repeat(40), opIndex: 0 }, + eventId: new EventId(1, 'w1', 'a'.repeat(40), 0), value: 'Alice', }); @@ -43,54 +55,16 @@ function createMemoryBlobPort() { return new MockBlobPort(); } -class MemoryBlobStorage extends BlobStoragePort { - private readonly _store: Map; - private _counter: number; - - constructor() { - super(); - this._store = new Map(); - this._counter = 0; - } - - override store = vi.fn(async (content: Uint8Array | string) => { - const bytes = typeof content === 'string' ? new TextEncoder().encode(content) : content; - const oid = `storage_${String(this._counter++).padStart(4, '0')}`; - this._store.set(oid, bytes); - return oid; - }); - - override retrieve = vi.fn(async (oid: string) => { - const bytes = this._store.get(oid); - if (bytes === undefined) { - throw new Error(`Storage OID not found: ${oid}`); - } - return bytes; - }); - - override async storeStream(source: AsyncIterable): Promise { - const chunks: Uint8Array[] = []; - for await (const chunk of source) { - chunks.push(chunk); - } - const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const merged = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - merged.set(chunk, offset); - offset += chunk.length; - } - return await this.store(merged); - } - - override retrieveStream(oid: string): AsyncIterable { - const self = this; - return { - async *[Symbol.asyncIterator]() { - yield await self.retrieve(oid); - }, - }; - } +function checkpointTreeOids(result: CheckpointWriteResult): Record { + return { + 'state/nodeAlive': result.nodeAliveBlobOid, + 'state/edgeAlive': result.edgeAliveBlobOid, + 'state/prop.cbor': result.propBlobOid, + 'state/observedFrontier.cbor': result.observedFrontierBlobOid, + 'state/edgeBirthEvent.cbor': result.edgeBirthEventBlobOid, + 'frontier.cbor': result.frontierBlobOid, + 'appliedVV.cbor': result.appliedVVBlobOid, + }; } describe('CborCheckpointStoreAdapter (collapsed)', () => { @@ -104,7 +78,8 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { it('requires codec and blobPort dependencies', () => { expect(() => new CborCheckpointStoreAdapter({ - codec: (null as any), + // @ts-expect-error Runtime dependency validation rejects null. + codec: null, blobPort: createMemoryBlobPort(), }) ).toThrow('requires a codec'); @@ -112,13 +87,14 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { expect(() => new CborCheckpointStoreAdapter({ codec: new CborCodec(), - blobPort: (null as any), + // @ts-expect-error Runtime dependency validation rejects null. + blobPort: null, }) ).toThrow('requires a blobPort'); }); describe('writeCheckpoint', () => { - it('returns OIDs for state, frontier, appliedVV', async () => { + it('returns OIDs for state envelope, frontier, appliedVV', async () => { const blobPort = createMemoryBlobPort(); const adapter = new CborCheckpointStoreAdapter({ codec: new CborCodec(), blobPort, @@ -134,14 +110,18 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { stateHash: 'deadbeef', }); - expect(typeof result.stateBlobOid).toBe('string'); + expect(typeof result.nodeAliveBlobOid).toBe('string'); + expect(typeof result.edgeAliveBlobOid).toBe('string'); + expect(typeof result.propBlobOid).toBe('string'); + expect(typeof result.observedFrontierBlobOid).toBe('string'); + expect(typeof result.edgeBirthEventBlobOid).toBe('string'); expect(typeof result.frontierBlobOid).toBe('string'); expect(typeof result.appliedVVBlobOid).toBe('string'); expect(result.provenanceIndexBlobOid).toBeNull(); - expect(blobPort.writeBlob).toHaveBeenCalledTimes(3); + expect(blobPort.writeBlob).toHaveBeenCalledTimes(7); }); - it('writes 4 blobs when provenanceIndex is provided', async () => { + it('writes 8 blobs when provenanceIndex is provided', async () => { const blobPort = createMemoryBlobPort(); const adapter = new CborCheckpointStoreAdapter({ codec: new CborCodec(), blobPort, @@ -161,16 +141,15 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { }); expect(result.provenanceIndexBlobOid).not.toBeNull(); - expect(blobPort.writeBlob).toHaveBeenCalledTimes(4); + expect(blobPort.writeBlob).toHaveBeenCalledTimes(8); }); - it('stores checkpoint payloads behind CAS pointer blobs when blobStorage is configured', async () => { + it('writes checkpoint tree blobs directly', async () => { const blobPort = createMemoryBlobPort(); - const blobStorage = new MemoryBlobStorage(); + const codec = new CborCodec(); const adapter = new CborCheckpointStoreAdapter({ - codec: new CborCodec(), + codec, blobPort, - blobStorage, }); const vv = VersionVector.empty(); @@ -183,15 +162,13 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { stateHash: 'deadbeef', }); - expect(blobStorage.store).toHaveBeenCalledTimes(3); - - const statePointer = await blobPort.readBlob(result.stateBlobOid); - const frontierPointer = await blobPort.readBlob(result.frontierBlobOid); - const appliedVVPointer = await blobPort.readBlob(result.appliedVVBlobOid); + const nodeAliveBytes = await blobPort.readBlob(result.nodeAliveBlobOid); + const frontierBytes = await blobPort.readBlob(result.frontierBlobOid); + const appliedVVBytes = await blobPort.readBlob(result.appliedVVBlobOid); - expect(decodeCasPayloadPointer(statePointer)).toBe('storage_0000'); - expect(decodeCasPayloadPointer(frontierPointer)).toBe('storage_0001'); - expect(decodeCasPayloadPointer(appliedVVPointer)).toBe('storage_0002'); + expect(nodeAliveBytes.byteLength).toBeGreaterThan(0); + expect(codec.decode(frontierBytes)).toEqual({ w1: 'abc123' }); + expect(codec.decode(appliedVVBytes)).toEqual({ w1: 3 }); }); }); @@ -211,26 +188,23 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { stateHash: 'deadbeef', }); - const treeOids = { - 'state.cbor': writeResult.stateBlobOid, - 'frontier.cbor': writeResult.frontierBlobOid, - 'appliedVV.cbor': writeResult.appliedVVBlobOid, - }; - - const data = await adapter.readCheckpoint(treeOids); + const data = await adapter.readCheckpoint(checkpointTreeOids(writeResult)); expect(data.state).toBeDefined(); expect(data.state.nodeAlive).toBeDefined(); expect(data.frontier.get('w1')).toBe('abc123'); expect(data.appliedVV).not.toBeNull(); - expect((data.appliedVV as NonNullable).get('w1')).toBe(3); + expect(data.appliedVV?.get('w1')).toBe(3); + expect(data).not.toHaveProperty('stateHash'); + expect(data).not.toHaveProperty('schema'); }); - it('throws on missing state.cbor', async () => { + it('throws on missing schema:5 state envelope artifacts', async () => { const adapter = new CborCheckpointStoreAdapter({ codec: new CborCodec(), blobPort: createMemoryBlobPort(), }); - await expect(adapter.readCheckpoint({})).rejects.toThrow('missing state.cbor'); + await expect(adapter.readCheckpoint({ 'frontier.cbor': 'frontier' })) + .rejects.toThrow('missing state/nodeAlive'); }); it('throws on missing frontier.cbor', async () => { @@ -239,9 +213,10 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { codec: new CborCodec(), blobPort, }); - const stateOid = await blobPort.writeBlob(new Uint8Array([1, 2, 3])); + const nodeAliveOid = await blobPort.writeBlob(new Uint8Array([1, 2, 3])); - await expect(adapter.readCheckpoint({ 'state.cbor': stateOid })).rejects.toThrow('missing frontier.cbor'); + await expect(adapter.readCheckpoint({ 'state/nodeAlive': nodeAliveOid })) + .rejects.toThrow('missing frontier.cbor'); }); it('returns stripped index shard oids when index artifacts are present', async () => { @@ -258,9 +233,7 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { }); const data = await adapter.readCheckpoint({ - 'state.cbor': writeResult.stateBlobOid, - 'frontier.cbor': writeResult.frontierBlobOid, - 'appliedVV.cbor': writeResult.appliedVVBlobOid, + ...checkpointTreeOids(writeResult), 'index/meta_aa.cbor': 'oid-meta', 'index/props_aa.cbor': 'oid-props', }); @@ -271,11 +244,10 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { }); }); - it('round-trips CAS-backed pointer blobs via blobStorage', async () => { + it('round-trips direct checkpoint blobs', async () => { const blobPort = createMemoryBlobPort(); - const blobStorage = new MemoryBlobStorage(); const codec = new CborCodec(); - const adapter = new CborCheckpointStoreAdapter({ codec, blobPort, blobStorage }); + const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); const vv = VersionVector.empty(); vv.set('w1', 3); @@ -287,70 +259,23 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { stateHash: 'deadbeef', }); - const data = await adapter.readCheckpoint({ - 'state.cbor': writeResult.stateBlobOid, - 'frontier.cbor': writeResult.frontierBlobOid, - 'appliedVV.cbor': writeResult.appliedVVBlobOid, - }); + const data = await adapter.readCheckpoint(checkpointTreeOids(writeResult)); expect(data.frontier.get('w1')).toBe('abc123'); expect(data.appliedVV?.get('w1')).toBe(3); - expect(blobStorage.retrieve).toHaveBeenCalledTimes(3); }); }); - describe('state encoding helpers', () => { - it('returns empty state when the full-state buffer or payload is absent', () => { - const adapter = ((new CborCheckpointStoreAdapter({ - codec: new CborCodec(), - blobPort: createMemoryBlobPort(), - })) as any); - - const emptyFromNullBuffer = adapter._decodeFullState(null); - expect(emptyFromNullBuffer).toBeInstanceOf(WarpState); - expect(emptyFromNullBuffer.nodeAlive.entries.size).toBe(0); - - const nullDecodingAdapter = ((new CborCheckpointStoreAdapter({ - codec: { - encode(value): Uint8Array { - return (value as any); - }, - decode(_bytes: Uint8Array) { - return null as any; - }, - } as any, - blobPort: createMemoryBlobPort(), - })) as any); - - const emptyFromNullPayload = nullDecodingAdapter._decodeFullState(new Uint8Array([1])); - expect(emptyFromNullPayload).toBeInstanceOf(WarpState); - expect(emptyFromNullPayload.edgeAlive.entries.size).toBe(0); - }); - - it('rejects unsupported full-state versions', () => { - const adapter = ((new CborCheckpointStoreAdapter({ - codec: { - encode(value): Uint8Array { - return (value as any); - }, - decode(_bytes: Uint8Array) { - return { version: 'full-v4' } as any; - }, - } as any, - blobPort: createMemoryBlobPort(), - })) as any); - - expect(() => adapter._decodeFullState(new Uint8Array([1]))).toThrow('Unsupported full state version'); - }); - - it('sorts props and edge birth events, skips null registers, and round-trips birth metadata', () => { + describe('state envelope helpers', () => { + it('sorts props and edge birth events and round-trips birth metadata', async () => { const codec = new CborCodec(); - const adapter = ((new CborCheckpointStoreAdapter({ + const blobPort = createMemoryBlobPort(); + const adapter = new CborCheckpointStoreAdapter({ codec, - blobPort: createMemoryBlobPort(), - })) as any); + blobPort, + }); - const prop = new Map([ + const prop = new Map>([ ['user:z\x00name', { eventId: new EventId(3, 'w3', 'c'.repeat(40), 2), value: 'Zed', @@ -359,8 +284,7 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { eventId: new EventId(1, 'w1', 'a'.repeat(40), 0), value: 'Ada', }], - ['user:skip\x00name', (null)], - ] as any); + ]); const edgeBirthEvent = new Map([ ['user:z\x00user:y\x00likes', new EventId(9, 'w9', 'f'.repeat(40), 2)], @@ -370,64 +294,146 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { const state = new WarpState({ nodeAlive: ORSet.empty(), edgeAlive: ORSet.empty(), - prop: (prop as any), + prop, observedFrontier: VersionVector.empty(), edgeBirthEvent, }); - const bytes = adapter._encodeFullState(state); - const raw = /** @type {{ - prop: Array<[string, unknown]>, - edgeBirthEvent: Array<[string, unknown]>, - }} */ (codec.decode(bytes)); - - expect((raw as any).prop.map(([key]) => key)).toEqual([ + const writeResult = await adapter.writeCheckpoint({ + state, + frontier: new Map(), + appliedVV: VersionVector.empty(), + stateHash: 'deadbeef', + }); + const rawProp = codec.decode>( + await blobPort.readBlob(writeResult.propBlobOid), + ); + const rawEdgeBirthEvent = codec.decode>( + await blobPort.readBlob(writeResult.edgeBirthEventBlobOid), + ); + + expect(rawProp.map(([key]) => key)).toEqual([ 'user:a\x00name', - 'user:skip\x00name', 'user:z\x00name', ]); - expect((raw as any).edgeBirthEvent.map(([key]) => key)).toEqual([ + expect(rawEdgeBirthEvent.map(([key]) => key)).toEqual([ 'user:a\x00user:b\x00knows', 'user:z\x00user:y\x00likes', ]); - const decoded = adapter._decodeFullState(bytes); - expect(decoded.prop.has('user:skip\x00name')).toBe(false); - expect(decoded.prop.get('user:a\x00name')?.value).toBe('Ada'); - expect(decoded.edgeBirthEvent.get('user:a\x00user:b\x00knows')).toEqual({ - lamport: 1, - writerId: 'w1', - patchSha: 'e'.repeat(40), - opIndex: 0, - }); + const decoded = (await adapter.readCheckpoint(checkpointTreeOids(writeResult))).state; + expect(decoded.getEncodedProp('user:a\x00name')?.value).toBe('Ada'); + const decodedBirthEvent = decoded.edgeBirthEvent.get('user:a\x00user:b\x00knows'); + expect(decodedBirthEvent).toBeInstanceOf(EventId); + expect(decodedBirthEvent).toEqual(new EventId(1, 'w1', 'e'.repeat(40), 0)); }); - it('accepts legacy numeric edge birth data when decoding full state', () => { - const adapter = ((new CborCheckpointStoreAdapter({ - codec: { - encode(value): Uint8Array { - return (value as any); - }, - decode(_bytes: Uint8Array) { - return { - nodeAlive: {}, - edgeAlive: {}, - prop: [], - observedFrontier: {}, - edgeBirthLamport: [['user:a\x00user:b\x00knows', 7]], - } as any; - }, - } as any, - blobPort: createMemoryBlobPort(), - })) as any); - - const decoded = adapter._decodeFullState(new Uint8Array([1])); - expect(decoded.edgeBirthEvent.get('user:a\x00user:b\x00knows')).toEqual({ - lamport: 7, - writerId: '', - patchSha: '0000', - opIndex: 0, - }); + it('rejects malformed edge birth payloads', async () => { + const blobPort = createMemoryBlobPort(); + const codec = new CborCodec(); + const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); + const treeOids = { + 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), + 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), + 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ + ['user:a\x00user:b\x00knows', { + lamport: 0, + writerId: '', + patchSha: '0000', + opIndex: 0, + }], + ])), + 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), + }; + + await expect(adapter.readCheckpoint(treeOids)) + .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); + }); + + it('rejects non-array edge birth payloads', async () => { + const blobPort = createMemoryBlobPort(); + const codec = new CborCodec(); + const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); + const treeOids = { + 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), + 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), + 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode({ not: 'an-array' })), + 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), + }; + + await expect(adapter.readCheckpoint(treeOids)) + .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); + }); + + it('rejects malformed edge birth tuples', async () => { + const blobPort = createMemoryBlobPort(); + const codec = new CborCodec(); + const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); + const treeOids = { + 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), + 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), + 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ + ['user:a\x00user:b\x00knows'], + ])), + 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), + }; + + await expect(adapter.readCheckpoint(treeOids)) + .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); + }); + + it('rejects non-string edge birth keys', async () => { + const blobPort = createMemoryBlobPort(); + const codec = new CborCodec(); + const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); + const treeOids = { + 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), + 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), + 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ + [42, { + lamport: 1, + writerId: 'writer', + patchSha: 'e'.repeat(40), + opIndex: 0, + }], + ])), + 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), + }; + + await expect(adapter.readCheckpoint(treeOids)) + .rejects.toThrow('Checkpoint edgeBirthEvent payload is invalid'); + }); + + it('identifies the string key for a malformed edge birth value', async () => { + const blobPort = createMemoryBlobPort(); + const codec = new CborCodec(); + const adapter = new CborCheckpointStoreAdapter({ codec, blobPort }); + const treeOids = { + 'state/nodeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/edgeAlive': await blobPort.writeBlob(codec.encode({})), + 'state/prop.cbor': await blobPort.writeBlob(codec.encode([])), + 'state/observedFrontier.cbor': await blobPort.writeBlob(codec.encode({})), + 'state/edgeBirthEvent.cbor': await blobPort.writeBlob(codec.encode([ + ['user:a\x00user:b\x00knows', { + lamport: 'not-a-number', + writerId: 'writer', + patchSha: 'e'.repeat(40), + opIndex: 0, + }], + ])), + 'frontier.cbor': await blobPort.writeBlob(codec.encode({})), + }; + + await expect(adapter.readCheckpoint(treeOids)) + .rejects.toThrow('invalid for user:a'); }); }); }); diff --git a/test/unit/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.test.ts b/test/unit/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.test.ts new file mode 100644 index 000000000..a83246755 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { + RootSetDoctorResult, + RootSetEntry, + RootSetMutationResult, + RootSetState, +} from '@git-stunts/git-cas'; +import type { WarpStateSnapshotRecord } from '../../../../src/ports/WarpStateCachePort.ts'; +import WarpStateCacheRetentionReport from '../../../../src/domain/services/state/WarpStateCacheRetentionReport.ts'; +import WarpStateCacheRepairResult from '../../../../src/domain/services/state/WarpStateCacheRepairResult.ts'; +import GitCasStateCacheRootSetCoordinator from '../../../../src/infrastructure/adapters/GitCasStateCacheRootSetCoordinator.ts'; + +const TREE_A = 'a'.repeat(40); +const TREE_B = 'b'.repeat(40); +const TREE_C = 'c'.repeat(40); +const INDEX_CAS_FAILURE = /index compare-and-swap failed/; + +function snapshot( + snapshotId: string, + payloadRef: string, + retention: 'evictable' | 'pinned' = 'evictable', +): WarpStateSnapshotRecord { + return { + snapshotId, + coordinate: { frontier: new Map([['writer-1', TREE_A]]), ceiling: 4 }, + retention, + provenancePosture: 'full', + stateHash: `hash-${snapshotId}`, + payloadRef, + createdAt: '2026-07-11T20:00:00.000Z', + }; +} + +class RootSetConflictError extends Error { + readonly code = 'ROOT_SET_CONFLICT'; +} + +class MockRootSet { + readonly events: string[]; + entries: RootSetEntry[]; + headOid: string | null; + cleanupConflict = false; + cleanupError: unknown = null; + unhealthyMessage: string | null = null; + unhealthyWithoutDetails = false; + readonly replaceCalls: Array<{ + entries: RootSetEntry[]; + expectedHeadOid: string | null | undefined; + }> = []; + readonly repairCalls: RootSetEntry[][] = []; + + constructor(events: string[], entries: RootSetEntry[] = []) { + this.events = events; + this.entries = entries; + this.headOid = entries.length === 0 ? null : TREE_C; + } + + async read(): Promise { + return { + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + headOid: this.headOid, + treeOid: this.headOid, + entries: [...this.entries], + }; + } + + async mutate( + mutator: ( + entries: ReadonlyArray>, + ) => Iterable | Promise>, + ): Promise { + this.events.push('root:prepare'); + this.entries = [...await mutator(this.entries)]; + this.headOid = TREE_B; + return { + changed: true, + commitOid: this.headOid, + treeOid: this.headOid, + entries: [...this.entries], + }; + } + + async replace(options: { + entries: Iterable; + expectedHeadOid?: string | null; + }): Promise { + this.events.push('root:cleanup'); + const entries = [...options.entries]; + this.replaceCalls.push({ entries, expectedHeadOid: options.expectedHeadOid }); + if (this.cleanupError !== null) { + throw this.cleanupError; + } + if (this.cleanupConflict) { + throw new RootSetConflictError('concurrent root-set writer'); + } + this.entries = entries; + this.headOid = TREE_C; + return { + changed: true, + commitOid: this.headOid, + treeOid: this.headOid, + entries: [...this.entries], + }; + } + + async doctor(): Promise { + if (this.unhealthyWithoutDetails) { + return { + healthy: false, + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + }; + } + if (this.unhealthyMessage !== null) { + return { + healthy: false, + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + entries: [...this.entries], + error: { code: 'ROOT_SET_METADATA_INVALID', message: this.unhealthyMessage }, + }; + } + return { + healthy: true, + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + headOid: this.headOid, + entries: [...this.entries], + }; + } + + async repair(options: { entries: Iterable }): Promise<{ + repaired: true; + commitOid: string; + treeOid: string; + entries: RootSetEntry[]; + }> { + const entries = [...options.entries]; + this.events.push('root:repair'); + this.repairCalls.push(entries); + this.entries = entries; + this.headOid = TREE_C; + this.unhealthyMessage = null; + return { + repaired: true, + commitOid: TREE_C, + treeOid: TREE_C, + entries: [...entries], + }; + } +} + +class MockObjectProbe { + readonly objectTypes = new Map(); + + async nodeExists(oid: string): Promise { + return this.objectTypes.has(oid); + } + + async readObjectType(oid: string): Promise { + const objectType = this.objectTypes.get(oid); + if (objectType === undefined) { + throw new Error(`missing object ${oid}`); + } + return objectType; + } +} + +function coordinatorFixture(rootEntries: RootSetEntry[] = []) { + const events: string[] = []; + const rootSet = new MockRootSet(events, rootEntries); + const objectProbe = new MockObjectProbe(); + const openedRefs: string[] = []; + const coordinator = new GitCasStateCacheRootSetCoordinator({ + graphName: 'demo', + openRootSet: async (ref) => { + openedRefs.push(ref); + return rootSet; + }, + objectProbe, + }); + return { coordinator, events, objectProbe, openedRefs, rootSet }; +} + +describe('GitCasStateCacheRootSetCoordinator', () => { + it('does not open a root set when there are no legacy records to adopt', async () => { + const { coordinator, openedRefs } = coordinatorFixture(); + + await coordinator.adopt([]); + + expect(openedRefs).toEqual([]); + }); + + it('retries opening the root set after a transient failure', async () => { + const rootSet = new MockRootSet([]); + const openRootSet = vi.fn() + .mockRejectedValueOnce(new Error('root set unavailable')) + .mockResolvedValue(rootSet); + const coordinator = new GitCasStateCacheRootSetCoordinator({ + graphName: 'demo', + openRootSet, + objectProbe: new MockObjectProbe(), + }); + + await expect(coordinator.inspect([])).rejects.toThrow(/root set unavailable/); + await expect(coordinator.inspect([])).resolves.toBeInstanceOf( + WarpStateCacheRetentionReport, + ); + + expect(openRootSet).toHaveBeenCalledTimes(2); + }); + + it('adopts a legacy live payload into the graph root set', async () => { + const { coordinator, objectProbe, openedRefs, rootSet } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'tree'); + + await coordinator.adopt([snapshot('snapshot-a', TREE_A, 'pinned')]); + + expect(openedRefs).toEqual(['refs/cas/rootsets/git-warp/demo/state-cache']); + expect(rootSet.entries).toEqual([ + { name: 'snapshot-a', oid: TREE_A, type: 'tree', retention: 'pinned' }, + ]); + }); + + it('does not rewrite an already exact adopted root', async () => { + const exact: RootSetEntry = { + name: 'snapshot-a', + oid: TREE_A, + type: 'tree', + retention: 'evictable', + }; + const { coordinator, events, rootSet } = coordinatorFixture([exact]); + + await coordinator.adopt([snapshot('snapshot-a', TREE_A)]); + + expect(events).toEqual([]); + expect(rootSet.entries).toEqual([exact]); + }); + + it('publishes the index only after a protective superset and cleans with the prepared head', async () => { + const { coordinator, events, objectProbe, rootSet } = coordinatorFixture([ + { name: 'stale', oid: TREE_C, type: 'tree', retention: 'evictable' }, + ]); + objectProbe.objectTypes.set(TREE_A, 'tree'); + + await coordinator.publishTransition([snapshot('snapshot-a', TREE_A)], async () => { + events.push('index:publish'); + }); + + expect(events).toEqual(['root:prepare', 'index:publish', 'root:cleanup']); + expect(rootSet.replaceCalls[0]?.expectedHeadOid).toBe(TREE_B); + expect(rootSet.replaceCalls[0]?.entries).toEqual([ + { name: 'snapshot-a', oid: TREE_A, type: 'tree', retention: 'evictable' }, + ]); + }); + + it('skips cleanup when the prepared root set is already exact', async () => { + const { coordinator, events, objectProbe, rootSet } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'tree'); + + await coordinator.publishTransition([snapshot('snapshot-a', TREE_A)], async () => { + events.push('index:publish'); + }); + + expect(events).toEqual(['root:prepare', 'index:publish']); + expect(rootSet.replaceCalls).toEqual([]); + }); + + it('orders duplicate desired names deterministically', async () => { + const { coordinator, objectProbe, rootSet } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'tree'); + objectProbe.objectTypes.set(TREE_B, 'tree'); + + await coordinator.publishTransition([ + snapshot('snapshot-a', TREE_A), + snapshot('snapshot-a', TREE_B), + ], async () => undefined); + + expect(rootSet.entries.map((entry) => entry.name)).toEqual([ + 'snapshot-a', + 'snapshot-a', + ]); + }); + + it('accepts a newly created CAS tree without probing it again', async () => { + const { coordinator, events, rootSet } = coordinatorFixture(); + + await coordinator.publishTransition( + [snapshot('snapshot-a', TREE_A)], + async () => { events.push('index:publish'); }, + [TREE_A], + ); + + expect(events).toEqual(['root:prepare', 'index:publish']); + expect(rootSet.entries).toEqual([ + { name: 'snapshot-a', oid: TREE_A, type: 'tree', retention: 'evictable' }, + ]); + }); + + it('leaves the pre-anchored superset in place when index publication fails', async () => { + const { coordinator, events, objectProbe, rootSet } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'tree'); + + await expect( + coordinator.publishTransition([snapshot('snapshot-a', TREE_A)], async () => { + events.push('index:publish'); + throw new Error('index compare-and-swap failed'); + }), + ).rejects.toThrow(INDEX_CAS_FAILURE); + + expect(events).toEqual(['root:prepare', 'index:publish']); + expect(rootSet.entries).toEqual([ + { name: 'snapshot-a', oid: TREE_A, type: 'tree', retention: 'evictable' }, + ]); + }); + + it('keeps harmless extra roots when guarded cleanup loses a race', async () => { + const { coordinator, events, objectProbe, rootSet } = coordinatorFixture([ + { name: 'stale', oid: TREE_C, type: 'tree', retention: 'evictable' }, + ]); + objectProbe.objectTypes.set(TREE_A, 'tree'); + rootSet.cleanupConflict = true; + + await expect( + coordinator.publishTransition([snapshot('snapshot-a', TREE_A)], async () => { + events.push('index:publish'); + }), + ).resolves.toBeUndefined(); + + expect(events).toEqual(['root:prepare', 'index:publish', 'root:cleanup']); + expect(rootSet.entries.map((entry) => entry.name).sort()).toEqual(['snapshot-a', 'stale']); + }); + + it('retains a harmless superset when post-commit cleanup fails', async () => { + const { coordinator, objectProbe, rootSet } = coordinatorFixture([ + { name: 'stale', oid: TREE_C, type: 'tree', retention: 'evictable' }, + ]); + objectProbe.objectTypes.set(TREE_A, 'tree'); + rootSet.cleanupError = new Error('cleanup failed'); + + await expect(coordinator.publishTransition( + [snapshot('snapshot-a', TREE_A)], + async () => undefined, + )).resolves.toBeUndefined(); + + rootSet.cleanupError = { code: 7 }; + await expect(coordinator.publishTransition( + [snapshot('snapshot-a', TREE_A)], + async () => undefined, + )).resolves.toBeUndefined(); + + expect(rootSet.entries.map((entry) => entry.name).sort()).toEqual([ + 'snapshot-a', + 'stale', + ]); + }); + + it('reports mismatched roots and malformed doctor output', async () => { + const { coordinator, objectProbe, rootSet } = coordinatorFixture([ + { name: 'snapshot-a', oid: TREE_C, type: 'tree', retention: 'evictable' }, + ]); + objectProbe.objectTypes.set(TREE_A, 'tree'); + + const mismatch = await coordinator.inspect([snapshot('snapshot-a', TREE_A)]); + expect(mismatch.unanchoredSnapshotIds).toEqual(['snapshot-a']); + expect(mismatch.mismatchedRootNames).toEqual(['snapshot-a']); + + rootSet.unhealthyWithoutDetails = true; + const malformed = await coordinator.inspect([]); + expect(malformed.rootSetError).toBe('Root-set doctor reported integrity issues'); + expect(malformed.staleRootNames).toEqual([]); + }); + + it('reports missing and stale entries, then repairs every payload that still exists', async () => { + const { coordinator, objectProbe, rootSet } = coordinatorFixture([ + { name: 'stale', oid: TREE_C, type: 'tree', retention: 'evictable' }, + ]); + objectProbe.objectTypes.set(TREE_A, 'tree'); + const records = [snapshot('snapshot-a', TREE_A), snapshot('snapshot-b', TREE_B)]; + + const before = await coordinator.inspect(records); + expect(before).toBeInstanceOf(WarpStateCacheRetentionReport); + expect(before.unanchoredSnapshotIds).toEqual(['snapshot-a']); + expect(before.missingSnapshotIds).toEqual(['snapshot-b']); + expect(before.staleRootNames).toEqual(['stale']); + + const repair = await coordinator.repair(records); + expect(repair).toBeInstanceOf(WarpStateCacheRepairResult); + expect(repair.anchoredSnapshotIds).toEqual(['snapshot-a']); + expect(repair.unrecoverableSnapshotIds).toEqual(['snapshot-b']); + expect(repair.removedStaleRootNames).toEqual(['stale']); + expect(rootSet.replaceCalls[0]?.entries).toEqual([ + { name: 'snapshot-a', oid: TREE_A, type: 'tree', retention: 'evictable' }, + ]); + expect(repair.after.missingSnapshotIds).toEqual(['snapshot-b']); + }); + + it('uses root-set repair when metadata is malformed', async () => { + const { coordinator, objectProbe, rootSet } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'tree'); + rootSet.unhealthyMessage = 'malformed root metadata'; + + const repair = await coordinator.repair([snapshot('snapshot-a', TREE_A)]); + + expect(rootSet.repairCalls).toHaveLength(1); + expect(repair.before.rootSetError).toBe('malformed root metadata'); + expect(repair.after.rootSetError).toBeNull(); + }); + + it('does not classify a blob payload as an anchorable state tree', async () => { + const { coordinator, objectProbe } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'blob'); + + const report = await coordinator.inspect([snapshot('snapshot-a', TREE_A)]); + + expect(report.wrongTypeSnapshotIds).toEqual(['snapshot-a']); + expect(report.unanchoredSnapshotIds).toEqual([]); + expect(report.isHealthy()).toBe(false); + }); + + it('opens the root set lazily once', async () => { + const { coordinator, objectProbe, openedRefs } = coordinatorFixture(); + objectProbe.objectTypes.set(TREE_A, 'tree'); + + await coordinator.inspect([snapshot('snapshot-a', TREE_A)]); + await coordinator.inspect([snapshot('snapshot-a', TREE_A)]); + + expect(openedRefs).toHaveLength(1); + }); +}); diff --git a/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.test.ts b/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.test.ts new file mode 100644 index 000000000..17b950c12 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + RootSetEntry, + RootSetMutationResult, + RootSetState, +} from '@git-stunts/git-cas'; +import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; + +const INDEX_HEAD = 'd'.repeat(40); +const ROOT_HEAD = 'e'.repeat(40); +const NEXT_ROOT_HEAD = 'f'.repeat(40); +const PAYLOAD_TREE = 'a'.repeat(40); +const INDEX_BLOB = 'b'.repeat(40); +const OLDER_ADOPTION_FAILURE = /older adoption failed/; +const ROOT_READ_FAILURE = /root read failed/; +const events: string[] = []; +let rootEntries: RootSetEntry[] = []; + +const mockRootRead = vi.fn(async (): Promise => ({ + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + headOid: rootEntries.length === 0 ? null : ROOT_HEAD, + treeOid: rootEntries.length === 0 ? null : ROOT_HEAD, + entries: [...rootEntries], +})); +const mockRootMutate = vi.fn(async ( + mutator: ( + entries: ReadonlyArray>, + ) => Iterable | Promise>, +): Promise => { + events.push('root:prepare'); + rootEntries = [...await mutator(rootEntries)]; + return { + changed: true, + commitOid: NEXT_ROOT_HEAD, + treeOid: NEXT_ROOT_HEAD, + entries: [...rootEntries], + }; +}); +const mockRootReplace = vi.fn(async (options: { + entries: Iterable; + expectedHeadOid?: string | null; +}): Promise => { + events.push('root:cleanup'); + rootEntries = [...options.entries]; + return { + changed: true, + commitOid: ROOT_HEAD, + treeOid: ROOT_HEAD, + entries: [...rootEntries], + }; +}); +const mockRootDoctor = vi.fn(async () => ({ + healthy: true, + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + headOid: rootEntries.length === 0 ? null : ROOT_HEAD, + entries: [...rootEntries], +})); +const mockRootRepair = vi.fn(async (options: { entries: Iterable }) => { + rootEntries = [...options.entries]; + return { + repaired: true as const, + commitOid: ROOT_HEAD, + treeOid: ROOT_HEAD, + entries: [...rootEntries], + }; +}); +const mockOpenRootSet = vi.fn(() => ({ + read: mockRootRead, + mutate: mockRootMutate, + replace: mockRootReplace, + doctor: mockRootDoctor, + repair: mockRootRepair, +})); + +class MockContentAddressableStore { + readonly rootSets = { open: mockOpenRootSet }; +} + +class MockCborCodec {} + +vi.mock('@git-stunts/git-cas', () => ({ + default: MockContentAddressableStore, + CborCodec: MockCborCodec, +})); + +const { GitCasWarpStateCacheAdapter } = await import( + '../../../../src/infrastructure/adapters/GitCasWarpStateCacheAdapter.ts' +); + +function encodedIndex(): Uint8Array { + const payload = { + schemaVersion: 1, + snapshots: { + 'snapshot-a': { + snapshotId: 'snapshot-a', + coordinate: { frontier: { 'writer-1': 'c'.repeat(40) }, ceiling: 3 }, + retention: 'evictable', + provenancePosture: 'full', + stateHash: 'state-hash-a', + payloadRef: PAYLOAD_TREE, + createdAt: '2026-07-11T20:00:00.000Z', + }, + }, + }; + return new TextEncoder().encode(JSON.stringify(payload)); +} + +function persistenceFixture() { + return { + readRef: vi.fn().mockResolvedValue(INDEX_HEAD), + readBlob: vi.fn().mockResolvedValue(encodedIndex()), + writeBlob: vi.fn(async () => { + events.push('index:write'); + return INDEX_BLOB; + }), + compareAndSwapRef: vi.fn(async () => { + events.push('index:publish'); + }), + nodeExists: vi.fn().mockResolvedValue(true), + readObjectType: vi.fn().mockResolvedValue('tree'), + }; +} + +describe('GitCasWarpStateCacheAdapter root-set retention', () => { + beforeEach(() => { + vi.clearAllMocks(); + events.length = 0; + rootEntries = []; + }); + + it('adopts legacy JSON-only payload refs before serving a cache read', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + + const result = await adapter.getExact({ + frontier: new Map([['other-writer', '9'.repeat(40)]]), + ceiling: 3, + }); + + expect(result).toBeNull(); + expect(mockOpenRootSet).toHaveBeenCalledWith({ + ref: 'refs/cas/rootsets/git-warp/demo/state-cache', + }); + expect(rootEntries).toEqual([ + { name: 'snapshot-a', oid: PAYLOAD_TREE, type: 'tree', retention: 'evictable' }, + ]); + }); + + it('checks legacy retention only once per adapter instance', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + const coordinate = { + frontier: new Map([['other-writer', '9'.repeat(40)]]), + ceiling: 3, + }; + + await adapter.getExact(coordinate); + await adapter.getExact(coordinate); + + expect(mockRootRead).toHaveBeenCalledTimes(1); + expect(mockRootMutate).toHaveBeenCalledTimes(1); + }); + + it('retries legacy adoption after a root-set read failure', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + const coordinate = { + frontier: new Map([['other-writer', '9'.repeat(40)]]), + ceiling: 3, + }; + mockRootRead.mockRejectedValueOnce(new Error('root read failed')); + + await expect(adapter.getExact(coordinate)).rejects.toThrow(ROOT_READ_FAILURE); + await expect(adapter.getExact(coordinate)).resolves.toBeNull(); + + expect(mockRootRead).toHaveBeenCalledTimes(2); + }); + + it('does not clear a newer adoption after an older attempt fails', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + let rejectRead: (reason?: unknown) => void = () => { + throw new Error('root read rejection was not captured'); + }; + mockRootRead.mockImplementationOnce(async () => await new Promise( + (_resolve, reject) => { rejectRead = reject; }, + )); + + const pending = adapter.getExact({ + frontier: new Map([['other-writer', '9'.repeat(40)]]), + ceiling: 3, + }); + await vi.waitFor(() => expect(mockRootRead).toHaveBeenCalledOnce()); + const newerAdoption = Promise.resolve(); + Reflect.set(adapter, '_retentionAdoption', newerAdoption); + rejectRead(new Error('older adoption failed')); + + await expect(pending).rejects.toThrow(OLDER_ADOPTION_FAILURE); + expect(Reflect.get(adapter, '_retentionAdoption')).toBe(newerAdoption); + }); + + it('publishes metadata-only index changes without another root-set generation', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + await adapter.getExact({ + frontier: new Map([['other-writer', '9'.repeat(40)]]), + ceiling: 3, + }); + + await adapter.publishCheckpointHead('demo', 'snapshot-a'); + + expect(mockRootMutate).toHaveBeenCalledTimes(1); + expect(persistence.compareAndSwapRef).toHaveBeenCalledWith( + 'refs/warp/demo/state-cache', + INDEX_BLOB, + INDEX_HEAD, + ); + }); + + it('republishes roots when retention metadata changes', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + await adapter.getBestCompatiblePredecessor({ + frontier: new Map([['writer-1', '9'.repeat(40)]]), + ceiling: null, + }); + + const pinned = await adapter.pin('snapshot-a'); + + expect(pinned.retention).toBe('pinned'); + expect(mockRootMutate).toHaveBeenCalledTimes(2); + }); + + it('pre-anchors, compare-and-swaps the index head, then performs guarded cleanup', async () => { + rootEntries = [ + { name: 'stale', oid: ROOT_HEAD, type: 'tree', retention: 'evictable' }, + ]; + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + + await adapter.pruneEvictable(); + + expect(events).toEqual([ + 'root:prepare', + 'index:write', + 'index:publish', + 'root:cleanup', + ]); + expect(persistence.compareAndSwapRef).toHaveBeenCalledWith( + 'refs/warp/demo/state-cache', + INDEX_BLOB, + INDEX_HEAD, + ); + expect(mockRootReplace).toHaveBeenCalledWith({ + entries: [ + { name: 'snapshot-a', oid: PAYLOAD_TREE, type: 'tree', retention: 'evictable' }, + ], + expectedHeadOid: NEXT_ROOT_HEAD, + }); + }); + + it('does not retry an index mutation after post-commit cleanup fails', async () => { + rootEntries = [ + { name: 'stale', oid: ROOT_HEAD, type: 'tree', retention: 'evictable' }, + ]; + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + mockRootReplace.mockRejectedValueOnce(new Error('cleanup failed')); + + await expect(adapter.pruneEvictable()).resolves.toBeUndefined(); + + expect(persistence.compareAndSwapRef).toHaveBeenCalledOnce(); + expect(mockRootMutate).toHaveBeenCalledOnce(); + expect(rootEntries.map((entry) => entry.name).sort()).toEqual([ + 'snapshot-a', + 'stale', + ]); + }); + + it('exposes retention inspection and repair through the cache port', async () => { + const persistence = persistenceFixture(); + const adapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing: {}, + graphName: 'demo', + codec: new CborCodec(), + }); + + const inspection = await adapter.inspectRetention(); + const repair = await adapter.repairRetention(); + + expect(inspection.unanchoredSnapshotIds).toEqual(['snapshot-a']); + expect(repair.anchoredSnapshotIds).toEqual(['snapshot-a']); + expect(repair.after.isHealthy()).toBe(true); + }); +}); diff --git a/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.test.ts index b571b8d21..844127d2e 100644 --- a/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GitCasWarpStateCacheAdapter.test.ts @@ -1,10 +1,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; + +const ERROR_OBJECT_UPDATE_FAILURE = /index update failed after retries: error object failure/; +const SIMULATED_UPDATE_FAILURE = /index update failed after retries: simulated write failure/; +const MALFORMED_INDEX_ERROR = /malformed state-cache index/; +const UNSUPPORTED_SCHEMA_ERROR = /unsupported state-cache index schema/; +const INDEX_NOT_OBJECT_ERROR = /state-cache index must be an object/; +const BLOB_READ_FAILED_ERROR = /blob read failed/; +const SNAPSHOTS_NOT_OBJECT_ERROR = /snapshots must be an object/; import CasContentEncryptionPolicy from '../../../../src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; import ORSet from '../../../../src/domain/crdt/ORSet.ts'; import VersionVector from '../../../../src/domain/crdt/VersionVector.ts'; import { Dot } from '../../../../src/domain/crdt/Dot.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; +import type { + RootSetEntry, + RootSetMutationResult, + RootSetState, +} from '@git-stunts/git-cas'; // Mock @git-stunts/git-cas (dynamic import used by _initCas) const mockReadManifest = vi.fn(); @@ -12,6 +25,59 @@ const mockRestore = vi.fn(); const mockRestoreStream = vi.fn(); const mockStore = vi.fn(); const mockCreateTree = vi.fn(); +let mockRootEntries: RootSetEntry[] = []; +const mockRootRead = vi.fn(async (): Promise => ({ + ref: 'refs/cas/rootsets/git-warp/test-graph/state-cache', + headOid: mockRootEntries.length === 0 ? null : 'a'.repeat(40), + treeOid: mockRootEntries.length === 0 ? null : 'a'.repeat(40), + entries: [...mockRootEntries], +})); +const mockRootMutate = vi.fn(async ( + mutator: ( + entries: ReadonlyArray>, + ) => Iterable | Promise>, +): Promise => { + mockRootEntries = [...await mutator(mockRootEntries)]; + return { + changed: true, + commitOid: 'b'.repeat(40), + treeOid: 'b'.repeat(40), + entries: [...mockRootEntries], + }; +}); +const mockRootReplace = vi.fn(async (options: { + entries: Iterable; + expectedHeadOid?: string | null; +}): Promise => { + mockRootEntries = [...options.entries]; + return { + changed: true, + commitOid: 'c'.repeat(40), + treeOid: 'c'.repeat(40), + entries: [...mockRootEntries], + }; +}); +const mockRootDoctor = vi.fn(async () => ({ + healthy: true, + ref: 'refs/cas/rootsets/git-warp/test-graph/state-cache', + entries: [...mockRootEntries], +})); +const mockRootRepair = vi.fn(async (options: { entries: Iterable }) => { + mockRootEntries = [...options.entries]; + return { + repaired: true as const, + commitOid: 'c'.repeat(40), + treeOid: 'c'.repeat(40), + entries: [...mockRootEntries], + }; +}); +const mockOpenRootSet = vi.fn(() => ({ + read: mockRootRead, + mutate: mockRootMutate, + replace: mockRootReplace, + doctor: mockRootDoctor, + repair: mockRootRepair, +})); /** When true, the mock CAS exposes restoreStream. */ let exposeRestoreStream = false; @@ -20,6 +86,7 @@ let exposeRestoreStream = false; let lastConstructorArgs = {}; class MockContentAddressableStore { + rootSets = { open: mockOpenRootSet }; readManifest: any; restore: any; store: any; @@ -63,6 +130,9 @@ function makePersistence() { writeBlob: vi.fn().mockResolvedValue('blob-oid-1'), updateRef: vi.fn().mockResolvedValue(undefined), deleteRef: vi.fn().mockResolvedValue(undefined), + compareAndSwapRef: vi.fn().mockResolvedValue(undefined), + nodeExists: vi.fn().mockResolvedValue(true), + readObjectType: vi.fn().mockResolvedValue('tree'), }; } @@ -119,6 +189,7 @@ describe('GitCasWarpStateCacheAdapter', () => { beforeEach(() => { vi.clearAllMocks(); exposeRestoreStream = false; + mockRootEntries = []; persistence = makePersistence(); plumbing = makePlumbing(); codec = new CborCodec(); @@ -195,27 +266,59 @@ describe('GitCasWarpStateCacheAdapter', () => { expect(idx2).toEqual({ schemaVersion: 1, snapshots: {} }); }); - it('returns empty index on invalid blob JSON or invalid schemaVersion', async () => { + it('fails closed on unreadable or invalid persisted indexes', async () => { persistence.readRef.mockResolvedValue('blob-1'); persistence.readBlob.mockResolvedValueOnce(new TextEncoder().encode('invalid-json')); - expect(await adapter._readIndex()).toEqual({ schemaVersion: 1, snapshots: {} }); + await expect(adapter._readIndex()).rejects.toThrow(MALFORMED_INDEX_ERROR); persistence.readBlob.mockResolvedValueOnce(indexBuffer({}, undefined, 999)); - expect(await adapter._readIndex()).toEqual({ schemaVersion: 1, snapshots: {} }); + await expect(adapter._readIndex()).rejects.toThrow(UNSUPPORTED_SCHEMA_ERROR); persistence.readBlob.mockResolvedValueOnce(new TextEncoder().encode('null')); + await expect(adapter._readIndex()).rejects.toThrow(INDEX_NOT_OBJECT_ERROR); + + persistence.readBlob.mockRejectedValueOnce(new Error('blob read failed')); + await expect(adapter._readIndex()).rejects.toThrow(BLOB_READ_FAILED_ERROR); + + persistence.readBlob.mockResolvedValueOnce(new TextEncoder().encode(JSON.stringify({ + schemaVersion: 1, + }))); expect(await adapter._readIndex()).toEqual({ schemaVersion: 1, snapshots: {} }); + + persistence.readBlob.mockResolvedValueOnce(new TextEncoder().encode(JSON.stringify({ + schemaVersion: 1, + snapshots: 'invalid', + }))); + await expect(adapter._readIndex()).rejects.toThrow(SNAPSHOTS_NOT_OBJECT_ERROR); + }); + + it('does not publish after a persisted index read fails', async () => { + persistence.readRef.mockResolvedValue('blob-1'); + persistence.readBlob.mockRejectedValue(new Error('blob read failed')); + + await expect(adapter._mutateIndex((idx: any) => idx)).rejects.toThrow( + BLOB_READ_FAILED_ERROR, + ); + + expect(persistence.writeBlob).not.toHaveBeenCalled(); + expect(persistence.compareAndSwapRef).not.toHaveBeenCalled(); }); it('retries index update on failure and throws on max retries', async () => { persistence.readRef.mockResolvedValue('blob-1'); persistence.readBlob.mockResolvedValue(indexBuffer({})); - persistence.writeBlob.mockRejectedValue(new Error('simulated write failure')); + persistence.writeBlob.mockRejectedValue('simulated write failure'); await expect(adapter._mutateIndex((idx: any) => idx)).rejects.toThrow( - /index update failed after retries: simulated write failure/ + SIMULATED_UPDATE_FAILURE, ); expect(persistence.writeBlob).toHaveBeenCalledTimes(3); + + persistence.writeBlob.mockClear(); + persistence.writeBlob.mockRejectedValue(new Error('error object failure')); + await expect(adapter._mutateIndex((idx: any) => idx)).rejects.toThrow( + ERROR_OBJECT_UPDATE_FAILURE, + ); }); }); @@ -311,7 +414,7 @@ describe('GitCasWarpStateCacheAdapter', () => { expect(await adapter.resolveCheckpointHead(GRAPH_NAME)).toBeNull(); }); - it('resolves checkpoint head successfully via restoreStream', async () => { + it.each([false, true])('resolves checkpoint head via restoreStream with split=%s', async (splitStream) => { exposeRestoreStream = true; const entry = { snapshotId: SAMPLE_SNAPSHOT_ID, @@ -323,7 +426,9 @@ describe('GitCasWarpStateCacheAdapter', () => { createdAt: new Date().toISOString(), }; persistence.readRef.mockResolvedValue('blob-1'); - persistence.readBlob.mockResolvedValue(indexBuffer({ [SAMPLE_SNAPSHOT_ID]: entry }, SAMPLE_SNAPSHOT_ID)); + persistence.readBlob + .mockResolvedValueOnce(indexBuffer({ [SAMPLE_SNAPSHOT_ID]: entry }, SAMPLE_SNAPSHOT_ID)) + .mockResolvedValue(indexBuffer({}, SAMPLE_SNAPSHOT_ID)); const encodedState = codec.encode({ version: 'full-v5', @@ -336,7 +441,13 @@ describe('GitCasWarpStateCacheAdapter', () => { mockReadManifest.mockResolvedValueOnce({ some: 'manifest' }); mockRestoreStream.mockImplementationOnce(async function* () { - yield encodedState; + if (splitStream) { + const midpoint = Math.floor(encodedState.byteLength / 2); + yield encodedState.slice(0, midpoint); + yield encodedState.slice(midpoint); + } else { + yield encodedState; + } }); const resolved = await adapter.resolveCheckpointHead(GRAPH_NAME); @@ -541,9 +652,27 @@ describe('GitCasWarpStateCacheAdapter', () => { describe('pruneEvictable', () => { it('prunes evictable snapshots successfully', async () => { + const prunableAdapter = new GitCasWarpStateCacheAdapter({ + persistence, + plumbing, + graphName: GRAPH_NAME, + codec, + maxEntries: 0, + }); + const entry = { + snapshotId: SAMPLE_SNAPSHOT_ID, + coordinate: { frontier: { w1: 'a'.repeat(40) }, ceiling: 10 }, + retention: 'evictable', + provenancePosture: 'complete', + stateHash: 'hash-1', + payloadRef: 'tree-1', + createdAt: new Date().toISOString(), + }; persistence.readRef.mockResolvedValue('blob-1'); - persistence.readBlob.mockResolvedValue(indexBuffer({})); - await adapter.pruneEvictable(); + persistence.readBlob.mockResolvedValue(indexBuffer({ [SAMPLE_SNAPSHOT_ID]: entry })); + + await prunableAdapter.pruneEvictable(); + expect(persistence.writeBlob).toHaveBeenCalled(); }); }); diff --git a/test/unit/infrastructure/adapters/NodeHttpAdapter.boundary.test.ts b/test/unit/infrastructure/adapters/NodeHttpAdapter.boundary.test.ts index cbf21b0ee..3cfc72b93 100644 --- a/test/unit/infrastructure/adapters/NodeHttpAdapter.boundary.test.ts +++ b/test/unit/infrastructure/adapters/NodeHttpAdapter.boundary.test.ts @@ -117,7 +117,7 @@ describe('NodeHttpAdapter boundary validation', () => { const capturedHandler = httpMock.capturedHandler(); expect(capturedHandler).toBeDefined(); if (capturedHandler === undefined) { - return; + throw new Error('captured HTTP handler must exist'); } capturedHandler(new EmptyRequest({}), res.response); await res.done; diff --git a/test/unit/ports/CheckpointStorePort.test.ts b/test/unit/ports/CheckpointStorePort.test.ts index 237f729e9..366f18301 100644 --- a/test/unit/ports/CheckpointStorePort.test.ts +++ b/test/unit/ports/CheckpointStorePort.test.ts @@ -3,6 +3,8 @@ import CheckpointStorePort, { type CheckpointRecord, type CheckpointData, } from '../../../src/ports/CheckpointStorePort.ts'; +import VersionVector from '../../../src/domain/crdt/VersionVector.ts'; +import WarpState from '../../../src/domain/services/state/WarpState.ts'; describe('CheckpointStorePort', () => { it('abstract methods are not callable on base prototype', () => { @@ -13,15 +15,34 @@ describe('CheckpointStorePort', () => { it('concrete subclass satisfies the contract', async () => { class TestStore extends CheckpointStorePort { async writeCheckpoint(_record: CheckpointRecord) { - return { stateBlobOid: 'a', frontierBlobOid: 'b', appliedVVBlobOid: 'c', provenanceIndexBlobOid: null }; + return { + nodeAliveBlobOid: 'nodeAlive', + edgeAliveBlobOid: 'edgeAlive', + propBlobOid: 'prop', + observedFrontierBlobOid: 'observedFrontier', + edgeBirthEventBlobOid: 'edgeBirthEvent', + frontierBlobOid: 'frontier', + appliedVVBlobOid: 'appliedVV', + provenanceIndexBlobOid: null, + }; } - async readCheckpoint(_treeOids: Record) { - return { state: {}, frontier: new Map(), appliedVV: null, stateHash: '', schema: 5, indexShardOids: null } as unknown as CheckpointData; + async readCheckpoint(_treeOids: Record): Promise { + return { + state: WarpState.empty(), + frontier: new Map(), + appliedVV: null, + indexShardOids: null, + }; } } const store = new TestStore(); expect(store).toBeInstanceOf(CheckpointStorePort); - const result = await store.writeCheckpoint({} as unknown as CheckpointRecord); - expect(result.stateBlobOid).toBe('a'); + const result = await store.writeCheckpoint({ + state: WarpState.empty(), + frontier: new Map(), + appliedVV: VersionVector.empty(), + stateHash: 'state-hash', + }); + expect(result.nodeAliveBlobOid).toBe('nodeAlive'); }); }); diff --git a/test/unit/scripts/lint-markdown-code-samples.test.ts b/test/unit/scripts/lint-markdown-code-samples.test.ts index 577d72f1d..67a1fb9a3 100644 --- a/test/unit/scripts/lint-markdown-code-samples.test.ts +++ b/test/unit/scripts/lint-markdown-code-samples.test.ts @@ -182,12 +182,14 @@ describe('lintMarkdownCodeSample', () => { }); describe('collectMarkdownFiles', () => { - it('walks Markdown files and ignores hidden directories', () => { + it('walks Markdown files and ignores hidden and generated directories', () => { const root = createTempDir(); mkdirSync(join(root, 'docs')); + mkdirSync(join(root, 'dist')); mkdirSync(join(root, '.hidden')); writeFileSync(join(root, 'README.md'), '# root\n'); writeFileSync(join(root, 'docs', 'GUIDE.md'), '# guide\n'); + writeFileSync(join(root, 'dist', 'GENERATED.md'), '# generated\n'); writeFileSync(join(root, '.hidden', 'SECRET.md'), '# secret\n'); expect(collectMarkdownFiles(root)).toEqual([ diff --git a/test/unit/scripts/lint-test-vacuous-returns.test.ts b/test/unit/scripts/lint-test-vacuous-returns.test.ts new file mode 100644 index 000000000..a36c59021 --- /dev/null +++ b/test/unit/scripts/lint-test-vacuous-returns.test.ts @@ -0,0 +1,64 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + collectBareTestReturnsFromSource, + testFilesInDirectory, +} from '../../../scripts/lint-test-vacuous-returns.ts'; + +describe('lint-test-vacuous-returns', () => { + it('reports bare returns directly inside it callbacks', () => { + const findings = collectBareTestReturnsFromSource('sample.test.ts', [ + "import { it } from 'vitest';", + "it('can silently pass', () => {", + ' if (!ready) {', + ' return;', + ' }', + ' expect(ready).toBe(true);', + '});', + ].join('\n')); + + expect(findings).toEqual([ + { filePath: 'sample.test.ts', line: 4, column: 5 }, + ]); + }); + + it('ignores returns inside nested helper functions and classes', () => { + const findings = collectBareTestReturnsFromSource('sample.test.ts', [ + "import { it } from 'vitest';", + "it('uses local helpers', () => {", + ' function helper(): void {', + ' return;', + ' }', + ' class Stub {', + ' method(): void {', + ' return;', + ' }', + ' }', + ' helper();', + ' expect(new Stub()).toBeInstanceOf(Stub);', + '});', + ].join('\n')); + + expect(findings).toEqual([]); + }); + + it('discovers nested TypeScript and JavaScript tests without external tools', () => { + const root = mkdtempSync(join(tmpdir(), 'warp-test-law-')); + const nested = join(root, 'nested'); + mkdirSync(nested); + writeFileSync(join(root, 'root.test.ts'), ''); + writeFileSync(join(nested, 'nested.test.js'), ''); + writeFileSync(join(nested, 'ignored.json'), ''); + + try { + expect(testFilesInDirectory(root)).toEqual([ + join(nested, 'nested.test.js'), + join(root, 'root.test.ts'), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts index 9c01d45c0..9720bf801 100644 --- a/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts +++ b/test/unit/scripts/v18-v17-public-read-legacy-reading-builder.test.ts @@ -47,7 +47,7 @@ describe('v18 v17 public-read legacy reading builder', () => { 'alice', ]); expect(reading.facts.find((fact) => fact.factKey === 'node:alpha:_content')?.value) - .toBe('24c25f5d050d4abd1186ab83700fae29144f1f7b'); + .toBe('07e686a83675d4bd189f6e3a2b2580299b77780b'); }); it('fails closed when a restored v17 writer ref drifts after restore', async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 11acde6a3..e2c9bed62 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ include: ['src/**/*.ts'], exclude: ['src/ports/**/*.ts', 'src/**/*.d.ts'], thresholds: { - lines: 92.1, + lines: 92.61, autoUpdate: shouldAutoUpdateCoverageRatchet(), }, },