diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcf2f004..12ec7d613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,13 @@ 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 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/src/domain/services/state/checkpointCreate.ts b/src/domain/services/state/checkpointCreate.ts index 87a278744..ab6a9660c 100644 --- a/src/domain/services/state/checkpointCreate.ts +++ b/src/domain/services/state/checkpointCreate.ts @@ -14,6 +14,7 @@ import { serializeCheckpointStateEnvelope, } from './CheckpointSerializer.ts'; import { serializeFrontier } from '../Frontier.ts'; +import { requireCodec } from '../codec/CodecRequirement.ts'; import { requireCommitMessageCodec } from '../codec/CommitMessageCodecRequirement.ts'; import { cloneState } from '../JoinReducer.ts'; import { @@ -118,11 +119,17 @@ 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. + // Runtime callers route artifact encoding through CheckpointStorePort so the + // domain path no longer needs to know the concrete checkpoint blob layout. // codecOpt is still needed for envelope/provenance serialization. const codecOpt = codec !== undefined && codec !== null ? { codec } : {}; + let nodeAliveOid: string; + let edgeAliveOid: string; + let propOid: string; + let observedFrontierOid: string; + let edgeBirthEventOid: string; + let frontierBlobOid: string; + let appliedVVBlobOid: string; let stateHash: string; let provenanceIndexBlobOid: string | null = null; @@ -130,28 +137,47 @@ export async function createCheckpointEnvelope({ 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: crypto as CryptoPort, + }); } - 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); + if (checkpointStore !== undefined && checkpointStore !== null) { + const checkpointWrite = await checkpointStore.writeCheckpoint({ + state: checkpointState, + frontier, + appliedVV, + stateHash, + ...(provenanceIndex !== undefined ? { provenanceIndex } : {}), + }); + nodeAliveOid = checkpointWrite.nodeAliveBlobOid; + edgeAliveOid = checkpointWrite.edgeAliveBlobOid; + propOid = checkpointWrite.propBlobOid; + observedFrontierOid = checkpointWrite.observedFrontierBlobOid; + edgeBirthEventOid = checkpointWrite.edgeBirthEventBlobOid; + frontierBlobOid = checkpointWrite.frontierBlobOid; + appliedVVBlobOid = checkpointWrite.appliedVVBlobOid; + provenanceIndexBlobOid = checkpointWrite.provenanceIndexBlobOid; + } else { + // Current checkpoints publish separate envelope artifacts so the Git tree names + // each read basis member. + const stateEnvelope = serializeCheckpointStateEnvelope(checkpointState, codecOpt); + nodeAliveOid = await persistence.writeBlob(stateEnvelope.nodeAlive); + edgeAliveOid = await persistence.writeBlob(stateEnvelope.edgeAlive); + propOid = await persistence.writeBlob(stateEnvelope.prop); + observedFrontierOid = await persistence.writeBlob(stateEnvelope.observedFrontier); + edgeBirthEventOid = await persistence.writeBlob(stateEnvelope.edgeBirthEvent); + + const frontierBuffer = serializeFrontier(frontier, codecOpt); + const appliedVVBuffer = serializeAppliedVV(appliedVV, codecOpt); + frontierBlobOid = await persistence.writeBlob(frontierBuffer); + appliedVVBlobOid = await persistence.writeBlob(appliedVVBuffer); + + if (provenanceIndex) { + const provenanceIndexBuffer = provenanceIndex.serialize(codecOpt); + provenanceIndexBlobOid = await persistence.writeBlob(provenanceIndexBuffer); + } } // 6c. Collect content storage OIDs from state properties for GC anchoring. 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/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..0fd6145c9 100644 --- a/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts +++ b/src/infrastructure/adapters/CborCheckpointStoreAdapter.ts @@ -3,20 +3,58 @@ 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 { 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'; +import { EventId } from '../../domain/utils/EventId.ts'; interface BlobPort { readBlob(oid: string): Promise; writeBlob(content: Uint8Array | string): Promise; } +interface CheckpointStateEnvelope { + nodeAlive: Uint8Array; + edgeAlive: Uint8Array; + prop: Uint8Array; + observedFrontier: Uint8Array; + edgeBirthEvent: Uint8Array; +} + +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; +} + /** * CBOR-backed implementation of CheckpointStorePort. * @@ -27,12 +65,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 +79,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 +91,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; @@ -160,7 +162,7 @@ export class CborCheckpointStoreAdapter extends CheckpointStorePort { frontier, appliedVV, stateHash: '', - schema: 2, + schema: 5, ...(provenanceIndex !== null ? { provenanceIndex } : {}), indexShardOids, }; @@ -168,15 +170,14 @@ 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): CheckpointStateEnvelope { + return { + nodeAlive: this._codec.encode(state.nodeAlive.serialize()), + edgeAlive: this._codec.encode(state.edgeAlive.serialize()), + prop: this._codec.encode(_serializePropsArray(state.allPropEntries())), + observedFrontier: this._codec.encode(VersionVector.serialize(state.observedFrontier)), + edgeBirthEvent: this._codec.encode(_serializeEdgeBirthArray(state.edgeBirthEvent)), + }; } private _encodeFrontier(frontier: Map): Uint8Array { @@ -193,26 +194,16 @@ 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', - ); - } + private _decodeStateEnvelope(envelope: CheckpointStateEnvelope): WarpState { + const edgeBirthEvent = this._codec.decode>( + envelope.edgeBirthEvent, + ); 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), + nodeAlive: ORSet.deserialize(this._codec.decode<{ [x: string]: string[] }>(envelope.nodeAlive)), + edgeAlive: ORSet.deserialize(this._codec.decode<{ [x: string]: string[] }>(envelope.edgeAlive)), + prop: _deserializeProps(this._codec.decode>(envelope.prop)), + observedFrontier: VersionVector.from(this._codec.decode<{ [x: string]: number }>(envelope.observedFrontier)), + edgeBirthEvent: _deserializeEdgeBirthEvent({ edgeBirthEvent }), }); } @@ -229,16 +220,88 @@ 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 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, + ]); + + return { + nodeAlive, + edgeAlive, + prop, + observedFrontier, + edgeBirthEvent, + frontier, + appliedVV, + provenanceIndex, + }; + } + + private _writeCheckpointBlob(bytes: Uint8Array): Promise { + return this._blobPort.writeBlob(bytes); + } + + private _readCheckpointBlob(oid: string): Promise { + return this._blobPort.readBlob(oid); + } + + 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; + } } // ── Private Helpers ─────────────────────────────────────────────────── @@ -278,26 +341,51 @@ function _deserializeProps(propArray: Array<[string, unknown]>): Map { +interface EdgeBirthEventPayload { + lamport: number; + writerId: string; + patchSha: string; + opIndex: number; +} + +function _deserializeEdgeBirthEvent(obj: { edgeBirthEvent?: Array<[string, EdgeBirthEventPayload]> }): 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, - }); + const birthData = obj.edgeBirthEvent; + if (birthData === undefined) { return result; } + if (!Array.isArray(birthData)) { throw invalidEdgeBirthEventPayload('unknown'); } + for (const entry of birthData) { + if (!Array.isArray(entry) || entry.length !== 2) { + throw invalidEdgeBirthEventPayload('unknown'); + } + const [key, value] = entry; + if (typeof key !== 'string' || !isEdgeBirthEventPayload(value)) { + throw invalidEdgeBirthEventPayload(typeof key === 'string' ? key : 'unknown'); + } + try { + result.set(key, new EventId(value.lamport, value.writerId, value.patchSha, value.opIndex)); + } catch { + throw invalidEdgeBirthEventPayload(key); } } return result; } +function isEdgeBirthEventPayload(value: EdgeBirthEventPayload | null | undefined): value is EdgeBirthEventPayload { + return value !== null + && value !== undefined + && typeof value.lamport === 'number' + && typeof value.writerId === 'string' + && typeof value.patchSha === 'string' + && typeof value.opIndex === 'number'; +} + +function invalidEdgeBirthEventPayload(key: string): WarpError { + return new WarpError( + `Checkpoint edgeBirthEvent payload is invalid for ${key}`, + 'E_INVALID_CHECKPOINT_EDGE_BIRTH_EVENT', + ); +} + function _serializeLWWRegister(register: LWWRegister): { eventId: { lamport: number; opIndex: number; patchSha: string; writerId: string }; value: unknown } | null { if (register === null || register === undefined) { return null; } return { diff --git a/src/ports/CheckpointStorePort.ts b/src/ports/CheckpointStorePort.ts index 295497ca3..c3662e8ba 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; diff --git a/test/unit/domain/WarpFacade.test.ts b/test/unit/domain/WarpFacade.test.ts index 4b33c87b8..4f200ee98 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(), 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/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts b/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts index 09ac9eff0..4dd1fce03 100644 --- a/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts +++ b/test/unit/infrastructure/adapters/CborCheckpointStoreAdapter.test.ts @@ -1,9 +1,7 @@ -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'; @@ -43,54 +41,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)', () => { @@ -118,7 +78,7 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { }); 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 +94,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 +125,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 +146,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,13 +172,7 @@ 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(); @@ -226,11 +181,12 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { expect((data.appliedVV as NonNullable).get('w1')).toBe(3); }); - 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 +195,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 +215,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 +226,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,62 +241,14 @@ 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'); - }); - + describe('state envelope helpers', () => { it('sorts props and edge birth events, skips null registers, and round-trips birth metadata', () => { const codec = new CborCodec(); const adapter = ((new CborCheckpointStoreAdapter({ @@ -375,59 +281,68 @@ describe('CborCheckpointStoreAdapter (collapsed)', () => { edgeBirthEvent, }); - const bytes = adapter._encodeFullState(state); - const raw = /** @type {{ - prop: Array<[string, unknown]>, - edgeBirthEvent: Array<[string, unknown]>, - }} */ (codec.decode(bytes)); + const envelope = adapter._encodeStateEnvelope(state); + const rawProp = /** @type {Array<[string, unknown]>} */ (codec.decode(envelope.prop)); + const rawEdgeBirthEvent = + /** @type {Array<[string, unknown]>} */ (codec.decode(envelope.edgeBirthEvent)); - expect((raw as any).prop.map(([key]) => key)).toEqual([ + expect((rawProp as any).map(([key]) => key)).toEqual([ 'user:a\x00name', 'user:skip\x00name', 'user:z\x00name', ]); - expect((raw as any).edgeBirthEvent.map(([key]) => key)).toEqual([ + expect((rawEdgeBirthEvent as any).map(([key]) => key)).toEqual([ 'user:a\x00user:b\x00knows', 'user:z\x00user:y\x00likes', ]); - const decoded = adapter._decodeFullState(bytes); + const decoded = adapter._decodeStateEnvelope(envelope); 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 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); + 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({})), + }; - const decoded = adapter._decodeFullState(new Uint8Array([1])); - expect(decoded.edgeBirthEvent.get('user:a\x00user:b\x00knows')).toEqual({ - lamport: 7, - writerId: '', - patchSha: '0000', - opIndex: 0, - }); + 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'); }); }); }); diff --git a/test/unit/ports/CheckpointStorePort.test.ts b/test/unit/ports/CheckpointStorePort.test.ts index 237f729e9..04d32e13e 100644 --- a/test/unit/ports/CheckpointStorePort.test.ts +++ b/test/unit/ports/CheckpointStorePort.test.ts @@ -13,7 +13,16 @@ 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; @@ -22,6 +31,6 @@ describe('CheckpointStorePort', () => { const store = new TestStore(); expect(store).toBeInstanceOf(CheckpointStorePort); const result = await store.writeCheckpoint({} as unknown as CheckpointRecord); - expect(result.stateBlobOid).toBe('a'); + expect(result.nodeAliveBlobOid).toBe('nodeAlive'); }); });