diff --git a/bin/cli/commands/doctor/stateCacheCapability.ts b/bin/cli/commands/doctor/stateCacheCapability.ts index 13c45e4b..6fd3a9e0 100644 --- a/bin/cli/commands/doctor/stateCacheCapability.ts +++ b/bin/cli/commands/doctor/stateCacheCapability.ts @@ -2,6 +2,7 @@ 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 { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCrypto from '../../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import type RuntimeStorageProviderPort from '../../../../src/ports/RuntimeStorageProviderPort.ts'; import type { DoctorFinding } from './types.ts'; import { @@ -18,6 +19,7 @@ export async function resolveStateCache( const services = await runtimeStorage.createRuntimeStorageServices({ timelineName: graphName, codec: defaultCodec, + crypto: defaultCrypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); return services.stateSnapshots ?? null; diff --git a/bin/cli/commands/trust.ts b/bin/cli/commands/trust.ts index 17b0a21f..bf1bf9e5 100644 --- a/bin/cli/commands/trust.ts +++ b/bin/cli/commands/trust.ts @@ -55,12 +55,14 @@ export default async function handleTrust({ options, args }: { options: CliOptio const { mode, trustPin } = parseTrustArgs(args); const { persistence, runtimeStorage, createTrustChain } = await createPersistence(options.repo); const graphName = await resolveGraphName(persistence, options.graph); + const crypto = new WebCryptoAdapter(); const storage = await runtimeStorage.createRuntimeStorageServices({ timelineName: graphName, codec: defaultCodec, + crypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); - const trustChain = createTrustChain(new WebCryptoAdapter()); + const trustChain = createTrustChain(crypto); const verifier = new AuditVerifierService({ auditLog: storage.auditLog, codec: defaultCodec, diff --git a/bin/cli/commands/verify-audit.ts b/bin/cli/commands/verify-audit.ts index 316effc2..faede96e 100644 --- a/bin/cli/commands/verify-audit.ts +++ b/bin/cli/commands/verify-audit.ts @@ -1,6 +1,7 @@ import AuditVerifierService from '../../../src/domain/services/audit/AuditVerifierService.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import { EXIT_CODES, parseCommandArgs, getEnvVar } from '../infrastructure.ts'; import { verifyAuditSchema } from '../schemas.ts'; import { createPersistence, resolveGraphName } from '../shared.ts'; @@ -52,6 +53,7 @@ export default async function handleVerifyAudit({ options, args }: { options: Cl const storage = await runtimeStorage.createRuntimeStorageServices({ timelineName: graphName, codec: defaultCodec, + crypto: defaultCrypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); const verifier = new AuditVerifierService({ diff --git a/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts b/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts index a731fdd1..df61e9f0 100644 --- a/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts +++ b/scripts/migrations/v17.0.0/openCheckpointMigrationStore.ts @@ -1,5 +1,6 @@ import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import type AssetStoragePort from '../../../src/ports/AssetStoragePort.ts'; import type CheckpointStorePort from '../../../src/ports/CheckpointStorePort.ts'; import type RuntimeStorageProviderPort from '../../../src/ports/RuntimeStorageProviderPort.ts'; @@ -17,6 +18,7 @@ export async function openCheckpointMigrationStore( const services = await runtimeStorage.createRuntimeStorageServices({ timelineName: graphName, codec: defaultCodec, + crypto: defaultCrypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); return Object.freeze({ diff --git a/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts b/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts index 905fd6d3..3df60cd4 100644 --- a/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts +++ b/scripts/v18.0.0/migrations/graph-model/V17RestoredPublicReadLegacyReadingBuilder.ts @@ -17,6 +17,7 @@ import { CONTENT_PROPERTY_KEY } import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCrypto from '../../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import type AssetStoragePort from '../../../../src/ports/AssetStoragePort.ts'; import { runMigrationGit } from './GitMigrationCommandRunner.ts'; @@ -147,6 +148,7 @@ class RuntimeContentOidResolver { const services = await runtimeStorage.createRuntimeStorageServices({ timelineName: 'migration-content', codec: defaultCodec, + crypto: defaultCrypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); return new RuntimeContentOidResolver( diff --git a/src/domain/materialization/MaterializationCoordinate.ts b/src/domain/materialization/MaterializationCoordinate.ts new file mode 100644 index 00000000..57f8c6eb --- /dev/null +++ b/src/domain/materialization/MaterializationCoordinate.ts @@ -0,0 +1,83 @@ +import WarpError from '../errors/WarpError.ts'; +import { compareStrings } from '../utils/StringComparison.ts'; + +export type MaterializationFrontierEntry = Readonly<{ + writerId: string; + patchSha: string; +}>; + +/** Immutable causal coordinate identifying one exact materialization. */ +export default class MaterializationCoordinate { + readonly frontierEntries: readonly MaterializationFrontierEntry[]; + readonly ceiling: number | null; + + constructor(options: { + readonly frontier: Map; + readonly ceiling: number | null; + }) { + requireOptions(options); + this.frontierEntries = freezeFrontier(options.frontier); + this.ceiling = requireCeiling(options.ceiling); + Object.freeze(this); + } + + frontier(): Map { + return new Map( + this.frontierEntries.map((entry) => [entry.writerId, entry.patchSha]), + ); + } + + equals(other: MaterializationCoordinate | null | undefined): boolean { + if (!(other instanceof MaterializationCoordinate) || this.ceiling !== other.ceiling) { + return false; + } + if (this.frontierEntries.length !== other.frontierEntries.length) { + return false; + } + return this.frontierEntries.every((entry, index) => { + const candidate = other.frontierEntries[index]; + return candidate?.writerId === entry.writerId && candidate.patchSha === entry.patchSha; + }); + } +} + +function freezeFrontier(frontier: Map): readonly MaterializationFrontierEntry[] { + if (!(frontier instanceof Map)) { + throw coordinateError('frontier must be a Map'); + } + return Object.freeze( + [...frontier.entries()] + .sort(([left], [right]) => compareStrings(left, right)) + .map(([writerId, patchSha]) => Object.freeze({ + writerId: requireNonEmpty(writerId, 'frontier writerId'), + patchSha: requireNonEmpty(patchSha, 'frontier patchSha'), + })), + ); +} + +function requireCeiling(ceiling: number | null): number | null { + if (ceiling === null) { + return null; + } + if (!Number.isSafeInteger(ceiling) || ceiling < 0) { + throw coordinateError('ceiling must be a non-negative safe integer or null'); + } + return ceiling; +} + +function requireNonEmpty(value: string, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw coordinateError(`${field} must be a non-empty string`); + } + return value; +} + +function requireOptions(options: object): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw coordinateError('options must be an object'); + } +} + +function coordinateError(message: string): WarpError { + return new WarpError(`Materialization coordinate ${message}`, 'E_MATERIALIZATION_COORDINATE'); +} diff --git a/src/domain/materialization/MaterializationHandle.ts b/src/domain/materialization/MaterializationHandle.ts new file mode 100644 index 00000000..22ba6ddf --- /dev/null +++ b/src/domain/materialization/MaterializationHandle.ts @@ -0,0 +1,70 @@ +import WarpError from '../errors/WarpError.ts'; +import BundleHandle from '../storage/BundleHandle.ts'; +import StorageRetentionWitness from '../storage/StorageRetentionWitness.ts'; +import MaterializationCoordinate from './MaterializationCoordinate.ts'; +import MaterializationRoots from './MaterializationRoots.ts'; + +/** Retained immutable locator and causal identity for one materialization. */ +export default class MaterializationHandle { + readonly laneName: string; + readonly bundle: BundleHandle; + readonly coordinate: MaterializationCoordinate; + readonly roots: MaterializationRoots; + readonly stateHash: string; + readonly retention: StorageRetentionWitness; + + constructor(options: { + readonly laneName: string; + readonly bundle: BundleHandle; + readonly coordinate: MaterializationCoordinate; + readonly roots: MaterializationRoots; + readonly stateHash: string; + readonly retention: StorageRetentionWitness; + }) { + requireOptions(options); + this.laneName = requireNonEmpty(options.laneName, 'laneName'); + this.bundle = requireInstance(options.bundle, BundleHandle, 'bundle'); + this.coordinate = requireInstance( + options.coordinate, + MaterializationCoordinate, + 'coordinate', + ); + this.roots = requireInstance(options.roots, MaterializationRoots, 'roots'); + this.stateHash = requireNonEmpty(options.stateHash, 'stateHash'); + this.retention = requireInstance( + options.retention, + StorageRetentionWitness, + 'retention', + ); + if (!this.retention.handle.equals(this.bundle)) { + throw handleError('retention witness does not retain the materialization bundle'); + } + Object.freeze(this); + } +} + +type RuntimeClass = abstract new (...args: never[]) => T; + +function requireInstance(value: T, runtimeClass: RuntimeClass, field: string): T { + if (!(value instanceof runtimeClass)) { + throw handleError(`${field} has an invalid runtime identity`); + } + return value; +} + +function requireNonEmpty(value: string, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw handleError(`${field} must be a non-empty string`); + } + return value; +} + +function requireOptions(options: object): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw handleError('options must be an object'); + } +} + +function handleError(message: string): WarpError { + return new WarpError(`Materialization handle ${message}`, 'E_MATERIALIZATION_HANDLE'); +} diff --git a/src/domain/materialization/MaterializationRoots.ts b/src/domain/materialization/MaterializationRoots.ts new file mode 100644 index 00000000..48bb9922 --- /dev/null +++ b/src/domain/materialization/MaterializationRoots.ts @@ -0,0 +1,97 @@ +import WarpError from '../errors/WarpError.ts'; +import BundleHandle from '../storage/BundleHandle.ts'; + +export const MATERIALIZATION_ROOT_NAMES = defineRootNames( + 'adjacency', + 'edge-alive', + 'edge-births', + 'frontier', + 'node-alive', + 'properties', + 'provenance-support', + 'roaring-indexes', +); + +export type MaterializationRootName = (typeof MATERIALIZATION_ROOT_NAMES)[number]; + +export type MaterializationRootsOptions = Readonly<{ + adjacency: BundleHandle; + edgeAlive: BundleHandle; + edgeBirths: BundleHandle; + frontier: BundleHandle; + nodeAlive: BundleHandle; + properties: BundleHandle; + provenanceSupport: BundleHandle; + roaringIndexes: BundleHandle; +}>; + +/** Independently addressable retained roots for one materialized causal chart. */ +export default class MaterializationRoots { + private readonly handles: Readonly>; + readonly adjacency: BundleHandle; + readonly edgeAlive: BundleHandle; + readonly edgeBirths: BundleHandle; + readonly frontier: BundleHandle; + readonly nodeAlive: BundleHandle; + readonly properties: BundleHandle; + readonly provenanceSupport: BundleHandle; + readonly roaringIndexes: BundleHandle; + + constructor(options: MaterializationRootsOptions) { + requireOptions(options); + this.handles = Object.freeze({ + adjacency: requireBundle(options.adjacency, 'adjacency'), + 'edge-alive': requireBundle(options.edgeAlive, 'edgeAlive'), + 'edge-births': requireBundle(options.edgeBirths, 'edgeBirths'), + frontier: requireBundle(options.frontier, 'frontier'), + 'node-alive': requireBundle(options.nodeAlive, 'nodeAlive'), + properties: requireBundle(options.properties, 'properties'), + 'provenance-support': requireBundle(options.provenanceSupport, 'provenanceSupport'), + 'roaring-indexes': requireBundle(options.roaringIndexes, 'roaringIndexes'), + } satisfies Record); + this.adjacency = this.handles.adjacency; + this.edgeAlive = this.handles['edge-alive']; + this.edgeBirths = this.handles['edge-births']; + this.frontier = this.handles.frontier; + this.nodeAlive = this.handles['node-alive']; + this.properties = this.handles.properties; + this.provenanceSupport = this.handles['provenance-support']; + this.roaringIndexes = this.handles['roaring-indexes']; + Object.freeze(this); + } + + entries(): readonly (readonly [MaterializationRootName, BundleHandle])[] { + return Object.freeze( + MATERIALIZATION_ROOT_NAMES.map((name) => rootEntry(name, this.handles[name])), + ); + } +} + +function rootEntry( + name: MaterializationRootName, + handle: BundleHandle, +): readonly [MaterializationRootName, BundleHandle] { + return Object.freeze([name, handle]); +} + +function defineRootNames(...names: Names): Names { + Object.freeze(names); + return names; +} + +function requireBundle(handle: BundleHandle, field: string): BundleHandle { + if (!(handle instanceof BundleHandle)) { + throw rootsError(`${field} must be a BundleHandle`); + } + return handle; +} + +function requireOptions(options: object): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw rootsError('options must be an object'); + } +} + +function rootsError(message: string): WarpError { + return new WarpError(`Materialization roots ${message}`, 'E_MATERIALIZATION_ROOTS'); +} diff --git a/src/domain/warp/RuntimeHostBoot.ts b/src/domain/warp/RuntimeHostBoot.ts index 1b2ffc89..2437ce24 100644 --- a/src/domain/warp/RuntimeHostBoot.ts +++ b/src/domain/warp/RuntimeHostBoot.ts @@ -287,6 +287,7 @@ export async function resolveRuntimeHostConstructionOptions( const storageServices = await resolvedRuntimeStorage.createRuntimeStorageServices({ timelineName: graphName, codec: resolvedCodec, + crypto: resolvedCrypto, commitMessageCodec: resolvedCommitMessageCodec, ...(logger === undefined ? {} : { logger }), }); diff --git a/src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts b/src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts new file mode 100644 index 00000000..669d6dab --- /dev/null +++ b/src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts @@ -0,0 +1,438 @@ +import type { + BundleCapability, + BundleMember, + BundleMemberInput, + CacheHit, + CacheSet, + PageHandle, + PageCapability, + StagedBundle, +} from '@git-stunts/git-cas'; +import MaterializationCoordinate from '../../domain/materialization/MaterializationCoordinate.ts'; +import MaterializationHandle from '../../domain/materialization/MaterializationHandle.ts'; +import MaterializationRoots, { + MATERIALIZATION_ROOT_NAMES, + type MaterializationRootName, +} from '../../domain/materialization/MaterializationRoots.ts'; +import BundleHandle from '../../domain/storage/BundleHandle.ts'; +import type StorageRetentionWitness from '../../domain/storage/StorageRetentionWitness.ts'; +import WarpError from '../../domain/errors/WarpError.ts'; +import type CodecPort from '../../ports/CodecPort.ts'; +import type CryptoPort from '../../ports/CryptoPort.ts'; +import MaterializationStorePort, { + type RetainMaterializationRequest, +} from '../../ports/MaterializationStorePort.ts'; +import { adaptGitCasRetentionWitness } from './GitCasRetentionWitnessAdapter.ts'; + +const CACHE_NAMESPACE = 'git-warp/materializations'; +const DESCRIPTOR_PATH = 'meta/descriptor'; +const MAX_DESCRIPTOR_BYTES = 1024 * 1024; +const SCHEMA_VERSION = 1; +// A root-list change also requires a descriptor schema-version change. +const MATERIALIZATION_MEMBER_COUNT = MATERIALIZATION_ROOT_NAMES.length + 1; + +type MaterializationCacheSet = Pick; + +export type GitCasMaterializationFacade = { + readonly bundles: Pick; + readonly caches: { + open(options: { readonly namespace: string }): Promise; + }; + readonly pages: Pick; +}; + +type DecodedDescriptor = Readonly<{ + coordinate: MaterializationCoordinate; + stateHash: string; + laneName: string; +}>; + +type DecodedMaterializationMembers = Readonly<{ + descriptor: PageHandle; + roots: MaterializationRoots; +}>; + +type MaterializationMemberAccumulator = { + descriptor: PageHandle | null; + memberCount: number; + roots: Map; +}; + +/** git-cas-backed retained materialization lifecycle. */ +export default class GitCasMaterializationStoreAdapter extends MaterializationStorePort { + readonly #cas: GitCasMaterializationFacade; + readonly #codec: CodecPort; + readonly #crypto: CryptoPort; + readonly #laneName: string; + + constructor(options: { + readonly cas: GitCasMaterializationFacade; + readonly codec: CodecPort; + readonly crypto: CryptoPort; + readonly laneName: string; + }) { + super(); + requireAdapterOptions(options); + requireDependency(options.cas, 'cas'); + requireDependency(options.codec, 'codec'); + requireDependency(options.crypto, 'crypto'); + this.#cas = options.cas; + this.#codec = options.codec; + this.#crypto = options.crypto; + this.#laneName = requireNonEmpty(options.laneName, 'laneName'); + } + + override async retain(request: RetainMaterializationRequest): Promise { + requireRetainRequest(request); + const stateHash = requireNonEmpty(request.stateHash, 'stateHash'); + const bundle = await this.#writeBundle(request, stateHash); + const retention = await this.#retainBundle(bundle, request.coordinate); + return new MaterializationHandle({ + laneName: this.#laneName, + bundle: new BundleHandle(bundle.handle.toString()), + coordinate: request.coordinate, + roots: request.roots, + stateHash, + retention, + }); + } + + async #writeBundle( + request: RetainMaterializationRequest, + stateHash: string, + ): Promise { + const descriptorBytes = this.#codec.encode(descriptorData({ + coordinate: request.coordinate, + stateHash, + laneName: this.#laneName, + })); + requireDescriptorSize(descriptorBytes); + + const descriptorPage = await this.#cas.pages.put({ + source: descriptorBytes, + maxBytes: MAX_DESCRIPTOR_BYTES, + }); + const bundle = await this.#cas.bundles.putOrdered({ + members: materializationMembers(descriptorPage.handle.toString(), request.roots), + }); + return bundle; + } + + async #retainBundle( + bundle: StagedBundle, + coordinate: MaterializationCoordinate, + ): Promise { + const cache = await this.#cas.caches.open({ namespace: CACHE_NAMESPACE }); + const cacheKey = await this.#cacheKey(coordinate); + const stored = await cache.put(cacheKey, bundle.handle, { retention: 'evictable' }); + if (!stored.accepted || stored.hit === null || stored.witness === null) { + throw storageError('git-cas did not retain the materialization bundle'); + } + if (stored.hit.handle.toString() !== bundle.handle.toString()) { + throw storageError('git-cas retained an unexpected materialization handle'); + } + return adaptGitCasRetentionWitness(stored.witness.toJSON()); + } + + override async findExact( + coordinate: MaterializationCoordinate, + ): Promise { + requireCoordinate(coordinate); + const cache = await this.#cas.caches.open({ namespace: CACHE_NAMESPACE }); + const hit = await cache.get(await this.#cacheKey(coordinate)); + if (hit === null) { + return null; + } + if (hit.handle.kind !== 'bundle') { + throw storageError('cache entry does not reference a materialization bundle'); + } + return await this.#resolveHit(hit, coordinate); + } + + async #resolveHit( + hit: CacheHit, + requestedCoordinate: MaterializationCoordinate, + ): Promise { + const bundle = new BundleHandle(hit.handle.toString()); + const members = await this.#readMembers(bundle); + const descriptor = await this.#readDescriptor(members.descriptor); + if (descriptor.laneName !== this.#laneName) { + throw storageError('materialization descriptor belongs to another lane'); + } + if (!descriptor.coordinate.equals(requestedCoordinate)) { + throw storageError('materialization descriptor coordinate does not match its cache key'); + } + + return new MaterializationHandle({ + laneName: descriptor.laneName, + bundle, + coordinate: descriptor.coordinate, + roots: members.roots, + stateHash: descriptor.stateHash, + retention: adaptGitCasRetentionWitness(hit.evidence.toJSON()), + }); + } + + async #cacheKey(coordinate: MaterializationCoordinate): Promise { + const encoded = this.#codec.encode({ + schemaVersion: SCHEMA_VERSION, + laneName: this.#laneName, + coordinate: coordinateData(coordinate), + }); + const digest = requireNonEmpty( + await this.#crypto.hash('sha256', encoded), + 'coordinate digest', + ); + return `v${SCHEMA_VERSION}:${digest}`; + } + + async #readDescriptor(handle: PageHandle): Promise { + const bytes = await this.#cas.pages.get({ + handle, + maxBytes: MAX_DESCRIPTOR_BYTES, + }); + return decodeDescriptor(this.#codec.decode(bytes)); + } + + async #readMembers(bundle: BundleHandle): Promise { + const accumulator = createMemberAccumulator(); + for await (const member of this.#cas.bundles.iterateMembers({ + handle: bundle.toString(), + })) { + collectMaterializationMember(accumulator, member); + } + return finishMaterializationMembers(accumulator); + } +} + +function descriptorData(descriptor: DecodedDescriptor): object { + return { + schemaVersion: SCHEMA_VERSION, + laneName: descriptor.laneName, + stateHash: descriptor.stateHash, + coordinate: coordinateData(descriptor.coordinate), + }; +} + +function coordinateData(coordinate: MaterializationCoordinate): object { + return { + ceiling: coordinate.ceiling, + frontier: coordinate.frontierEntries.map((entry) => [entry.writerId, entry.patchSha]), + }; +} + +function* materializationMembers( + descriptorHandle: string, + roots: MaterializationRoots, +): Generator<[string, BundleMemberInput]> { + yield [DESCRIPTOR_PATH, descriptorHandle]; + for (const [name, handle] of roots.entries()) { + yield [`roots/${name}`, handle.toString()]; + } +} + +function decodeDescriptor(value: unknown): DecodedDescriptor { + requireRecord(value, 'descriptor'); + const descriptor = value; + if (descriptor['schemaVersion'] !== SCHEMA_VERSION) { + throw storageError('materialization descriptor schema is unsupported'); + } + const coordinateValue = descriptor['coordinate']; + requireRecord(coordinateValue, 'descriptor.coordinate'); + const frontier = decodeFrontier(coordinateValue['frontier']); + return Object.freeze({ + laneName: requireNonEmpty(descriptor['laneName'], 'descriptor.laneName'), + stateHash: requireNonEmpty(descriptor['stateHash'], 'descriptor.stateHash'), + coordinate: new MaterializationCoordinate({ + frontier, + ceiling: requireCeiling(coordinateValue['ceiling']), + }), + }); +} + +function decodeFrontier(value: unknown): Map { + if (!Array.isArray(value)) { + throw storageError('descriptor.coordinate.frontier must be an array'); + } + const frontier = new Map(); + for (const entry of value) { + const [writerId, patchSha] = decodeFrontierEntry(entry); + if (frontier.has(writerId)) { + throw storageError('descriptor coordinate contains a duplicate frontier writer'); + } + frontier.set(writerId, patchSha); + } + return frontier; +} + +function decodeFrontierEntry(value: unknown): readonly [string, string] { + if (!Array.isArray(value) || value.length !== 2) { + throw storageError('descriptor coordinate contains an invalid frontier entry'); + } + return Object.freeze([ + requireNonEmpty(value[0], 'descriptor frontier writerId'), + requireNonEmpty(value[1], 'descriptor frontier patchSha'), + ]); +} + +function rootsFromMap(roots: ReadonlyMap): MaterializationRoots { + return new MaterializationRoots({ + adjacency: requireRoot(roots, 'adjacency'), + edgeAlive: requireRoot(roots, 'edge-alive'), + edgeBirths: requireRoot(roots, 'edge-births'), + frontier: requireRoot(roots, 'frontier'), + nodeAlive: requireRoot(roots, 'node-alive'), + properties: requireRoot(roots, 'properties'), + provenanceSupport: requireRoot(roots, 'provenance-support'), + roaringIndexes: requireRoot(roots, 'roaring-indexes'), + }); +} + +function createMemberAccumulator(): MaterializationMemberAccumulator { + return { + descriptor: null, + memberCount: 0, + roots: new Map(), + }; +} + +function collectMaterializationMember( + accumulator: MaterializationMemberAccumulator, + member: BundleMember, +): void { + accumulator.memberCount += 1; + if (accumulator.memberCount > MATERIALIZATION_MEMBER_COUNT) { + throw storageError('materialization bundle has too many members'); + } + if (member.path === DESCRIPTOR_PATH) { + collectDescriptorMember(accumulator, member); + return; + } + collectRootMember(accumulator, member); +} + +function collectDescriptorMember( + accumulator: MaterializationMemberAccumulator, + member: BundleMember, +): void { + if (accumulator.descriptor !== null) { + throw storageError('materialization bundle has duplicate descriptor members'); + } + if (member.handle.kind !== 'page') { + throw storageError('materialization bundle has no descriptor page'); + } + accumulator.descriptor = member.handle; +} + +function collectRootMember( + accumulator: MaterializationMemberAccumulator, + member: BundleMember, +): void { + const rootName = parseRootName(member.path); + if (rootName === null) { + throw storageError(`materialization bundle has an unexpected member: ${member.path}`); + } + if (accumulator.roots.has(rootName)) { + throw storageError(`materialization bundle has duplicate ${rootName} root members`); + } + if (member.handle.kind !== 'bundle') { + throw storageError(`materialization bundle has no ${rootName} root bundle`); + } + accumulator.roots.set(rootName, new BundleHandle(member.handle.toString())); +} + +function finishMaterializationMembers( + accumulator: MaterializationMemberAccumulator, +): DecodedMaterializationMembers { + if (accumulator.descriptor === null) { + throw storageError('materialization bundle has no descriptor page'); + } + return Object.freeze({ + descriptor: accumulator.descriptor, + roots: rootsFromMap(accumulator.roots), + }); +} + +function requireRoot( + roots: ReadonlyMap, + name: MaterializationRootName, +): BundleHandle { + const root = roots.get(name); + if (root === undefined) { + throw storageError(`materialization bundle has no ${name} root bundle`); + } + return root; +} + +function parseRootName(path: string): MaterializationRootName | null { + const prefix = 'roots/'; + if (!path.startsWith(prefix)) { + return null; + } + const candidate = path.slice(prefix.length); + return MATERIALIZATION_ROOT_NAMES.find((name) => name === candidate) ?? null; +} + +function requireCeiling(value: unknown): number | null { + if (value === null) { + return null; + } + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw storageError('descriptor coordinate ceiling is invalid'); + } + return value; +} + +function requireRecord( + value: unknown, + field: string, +): asserts value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw storageError(`${field} must be an object`); + } +} + +function requireRetainRequest(request: RetainMaterializationRequest): void { + if (request === null || typeof request !== 'object' || Array.isArray(request)) { + throw storageError('retain request must be an object'); + } + requireCoordinate(request.coordinate); + if (!(request.roots instanceof MaterializationRoots)) { + throw storageError('retain request roots have an invalid runtime identity'); + } +} + +function requireCoordinate(coordinate: MaterializationCoordinate): void { + if (!(coordinate instanceof MaterializationCoordinate)) { + throw storageError('coordinate has an invalid runtime identity'); + } +} + +function requireDescriptorSize(bytes: Uint8Array): void { + if (bytes.byteLength > MAX_DESCRIPTOR_BYTES) { + throw storageError('materialization descriptor exceeds its byte limit'); + } +} + +function requireDependency(value: object, field: string): void { + if (value === null || typeof value !== 'object') { + throw storageError(`${field} dependency is required`); + } +} + +function requireAdapterOptions(options: object): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw storageError('adapter options must be an object'); + } +} + +function requireNonEmpty(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw storageError(`${field} must be a non-empty string`); + } + return value; +} + +function storageError(message: string): WarpError { + return new WarpError(`Materialization storage ${message}`, 'E_MATERIALIZATION_STORAGE'); +} diff --git a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts index e2f6f132..fb1b22ff 100644 --- a/src/infrastructure/adapters/GitCasRepositoryAdapter.ts +++ b/src/infrastructure/adapters/GitCasRepositoryAdapter.ts @@ -16,6 +16,9 @@ import GitCasAssetStorageAdapter from './GitCasAssetStorageAdapter.ts'; import GitCasAuditLogAdapter from './GitCasAuditLogAdapter.ts'; import GitCasStrandStoreAdapter from './GitCasStrandStoreAdapter.ts'; import GitCasIntentStoreAdapter from './GitCasIntentStoreAdapter.ts'; +import GitCasMaterializationStoreAdapter, { + type GitCasMaterializationFacade, +} from './GitCasMaterializationStoreAdapter.ts'; import type CasContentEncryptionPolicy from './CasContentEncryptionPolicy.ts'; import { CborCheckpointStoreAdapter } from './CborCheckpointStoreAdapter.ts'; import { CborIndexStoreAdapter } from './CborIndexStoreAdapter.ts'; @@ -42,6 +45,8 @@ export type GitCasFacade = Pick< > & { readonly assets: Pick; readonly bundles: Pick; + readonly caches: GitCasMaterializationFacade['caches']; + readonly pages: GitCasMaterializationFacade['pages']; readonly publications: Pick; readonly rootSets: { open(options: { readonly ref: string }): Promise; @@ -94,6 +99,7 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo patchJournal: this._createPatchJournal(request, content), checkpoints: this._createCheckpointStore(request, content), indexes: this._createIndexStore(request, content), + materializations: this._createMaterializationStore(request), stateSnapshots: this._createStateSnapshots(request), trie: new GitTrieStoreAdapter({ plumbing: this._plumbing }), }) @@ -178,6 +184,17 @@ export default class GitCasRepositoryAdapter implements RuntimeStorageProviderPo }); } + private _createMaterializationStore( + request: RuntimeStorageRequest, + ): GitCasMaterializationStoreAdapter { + return new GitCasMaterializationStoreAdapter({ + cas: this._cas, + codec: request.codec, + crypto: request.crypto, + laneName: request.timelineName, + }); + } + createTrustChain(crypto: CryptoPort): GitTrustChainAdapter { return new GitTrustChainAdapter({ cas: this._cas, diff --git a/src/ports/MaterializationStorePort.ts b/src/ports/MaterializationStorePort.ts new file mode 100644 index 00000000..1b53ce87 --- /dev/null +++ b/src/ports/MaterializationStorePort.ts @@ -0,0 +1,18 @@ +import type MaterializationCoordinate from '../domain/materialization/MaterializationCoordinate.ts'; +import type MaterializationHandle from '../domain/materialization/MaterializationHandle.ts'; +import type MaterializationRoots from '../domain/materialization/MaterializationRoots.ts'; + +export type RetainMaterializationRequest = Readonly<{ + coordinate: MaterializationCoordinate; + roots: MaterializationRoots; + stateHash: string; +}>; + +/** Storage-neutral lifecycle for retained, independently addressable materializations. */ +export default abstract class MaterializationStorePort { + abstract retain(_request: RetainMaterializationRequest): Promise; + + abstract findExact( + _coordinate: MaterializationCoordinate, + ): Promise; +} diff --git a/src/ports/RuntimeStorageProviderPort.ts b/src/ports/RuntimeStorageProviderPort.ts index dc497cf5..3317b4ab 100644 --- a/src/ports/RuntimeStorageProviderPort.ts +++ b/src/ports/RuntimeStorageProviderPort.ts @@ -4,9 +4,11 @@ import type AuditLogPort from './AuditLogPort.ts'; import type CheckpointStorePort from './CheckpointStorePort.ts'; import type CodecPort from './CodecPort.ts'; import type CommitMessageCodecPort from './CommitMessageCodecPort.ts'; +import type CryptoPort from './CryptoPort.ts'; import type IndexStorePort from './IndexStorePort.ts'; import type IntentStorePort from './IntentStorePort.ts'; import type LoggerPort from './LoggerPort.ts'; +import type MaterializationStorePort from './MaterializationStorePort.ts'; import type PatchJournalPort from './PatchJournalPort.ts'; import type StrandStorePort from './StrandStorePort.ts'; import type WarpStateCachePort from './WarpStateCachePort.ts'; @@ -15,6 +17,7 @@ import type WarpStateCacheRetentionPort from './WarpStateCacheRetentionPort.ts'; export type RuntimeStorageRequest = { readonly timelineName: string; readonly codec: CodecPort; + readonly crypto: CryptoPort; readonly commitMessageCodec: CommitMessageCodecPort; readonly logger?: LoggerPort; }; @@ -27,6 +30,7 @@ export type RuntimeStorageServices = { readonly checkpoints: CheckpointStorePort; readonly indexes: IndexStorePort; readonly intents: IntentStorePort; + readonly materializations: MaterializationStorePort; readonly stateSnapshots?: WarpStateCachePort & WarpStateCacheRetentionPort; readonly trie?: TrieStorePort; }; diff --git a/test/conformance/v18FirstUseOpticsHonesty.test.ts b/test/conformance/v18FirstUseOpticsHonesty.test.ts index 4a4ab3d4..cdcfd136 100644 --- a/test/conformance/v18FirstUseOpticsHonesty.test.ts +++ b/test/conformance/v18FirstUseOpticsHonesty.test.ts @@ -82,6 +82,7 @@ class FirstUseOpticsTrapStorage implements RuntimeStorageProviderPort { trap, ), intents: trapService(services.intents, 'intents', ['publish'], trap), + materializations: services.materializations, ...(services.stateSnapshots === undefined ? {} : { diff --git a/test/helpers/InMemoryGitCasFacade.ts b/test/helpers/InMemoryGitCasFacade.ts index 35255511..79d61dc6 100644 --- a/test/helpers/InMemoryGitCasFacade.ts +++ b/test/helpers/InMemoryGitCasFacade.ts @@ -1,10 +1,12 @@ import { AssetHandle as GitCasAssetHandle, BundleHandle, + CacheHit, PageHandle, RetentionWitness, StagedAsset, StagedBundle, + StagedPage, type ApplicationHandle, type ApplicationHandleInput, type AssetHandleInput, @@ -12,7 +14,9 @@ import { type BundleHandleInput, type BundleCapability, type BundleMember, + type CacheSet, type PageHandleInput, + type PageCapability, type PublicationCapability, } from '@git-stunts/git-cas'; import AssetHandle from '../../src/domain/storage/AssetHandle.ts'; @@ -43,13 +47,20 @@ const ENCRYPTED_ASSET_NONCE_BYTES = 12; export default class InMemoryGitCasFacade { readonly assets: Pick; readonly bundles: Pick; + readonly caches: { + open(options: { readonly namespace: string }): Promise>; + }; + readonly pages: Pick; readonly publications: Pick; readonly #history: PublicationHistory; readonly #storage: InMemoryBlobStorageAdapter; readonly #stagedAssetsByOid = new Map(); readonly #bundleMembers = new Map(); + readonly #cacheEntries = new Map>(); + readonly #pageBytes = new Map(); readonly #publicationRoots = new Map(); + #cacheGeneration = 0; constructor(options: { history: PublicationHistory; @@ -66,6 +77,13 @@ export default class InMemoryGitCasFacade { putOrdered: async (request) => await this.#putBundle(request.members), iterateMembers: (request) => this.#iterateBundleMembers(request.handle), }); + this.caches = Object.freeze({ + open: async ({ namespace }) => await this.#openCache(namespace), + }); + this.pages = Object.freeze({ + put: async (request) => await this.#putPage(request), + get: async (request) => await this.#getPage(request), + }); this.publications = Object.freeze({ commit: async (request) => await this.#publish(request), }); @@ -75,6 +93,18 @@ export default class InMemoryGitCasFacade { return this.#bundleMembers.get(handle) ?? Object.freeze([]); } + replaceBundleMembers(handle: string, members: readonly [string, string][]): void { + this.#bundleMembers.set(handle, Object.freeze([...members])); + } + + readCacheKeys(namespace: string): readonly string[] { + return Object.freeze([...(this.#cacheEntries.get(namespace)?.keys() ?? [])]); + } + + replaceStoredPage(handle: string, bytes: Uint8Array): void { + this.#pageBytes.set(handle, bytes.slice()); + } + readPublicationRoot(commitId: string): string | null { return this.#publicationRoots.get(commitId) ?? null; } @@ -209,6 +239,91 @@ export default class InMemoryGitCasFacade { } } + async #putPage( + request: Parameters[0], + ): Promise>> { + const bytes = await collectPageSource(request.source); + if (request.maxBytes !== undefined && bytes.byteLength > request.maxBytes) { + throw Object.assign(new Error('Page exceeds configured maximum'), { code: 'PAGE_TOO_LARGE' }); + } + const oid = await this.#history.writeBlob(bytes); + const handle = new PageHandle({ + oid, + hashAlgorithm: oid.length === 64 ? 'sha256' : 'sha1', + }); + this.#pageBytes.set(handle.toString(), bytes.slice()); + return new StagedPage({ + handle, + size: bytes.byteLength, + observedAt: new Date(0).toISOString(), + }); + } + + async #getPage( + request: Parameters[0], + ): Promise { + const handle = PageHandle.from(request.handle); + const bytes = this.#pageBytes.get(handle.toString()); + if (bytes === undefined) { + throw Object.assign(new Error(`Unknown page: ${handle.toString()}`), { + code: 'HANDLE_TARGET_MISSING', + }); + } + if (request.maxBytes !== undefined && bytes.byteLength > request.maxBytes) { + throw Object.assign(new Error('Page exceeds configured maximum'), { code: 'PAGE_TOO_LARGE' }); + } + return bytes.slice(); + } + + async #openCache(namespace: string): Promise> { + const entries = this.#cacheEntries.get(namespace) ?? new Map(); + this.#cacheEntries.set(namespace, entries); + return Object.freeze({ + get: async (key) => entries.get(key) ?? null, + put: async (key, handle, options) => { + const previous = entries.get(key) ?? null; + const target = parseApplicationHandle(handle); + this.#cacheGeneration += 1; + const generation = this.#cacheGeneration.toString(16).padStart(40, '0'); + const observedAt = new Date(0).toISOString(); + const evidence = new RetentionWitness({ + handle: target, + policy: options?.retention ?? 'evictable', + reachability: 'anchored', + root: { + kind: 'cache-set', + namespace, + ref: `refs/cas/caches/${namespace}`, + generation, + path: 'root-00000000', + }, + observedAt, + }); + const hit = new CacheHit({ + key, + handle: target, + policy: options?.retention ?? 'evictable', + expiresAt: null, + logicalBytes: 0, + createdAt: observedAt, + accessedAt: observedAt, + generation, + evidence, + }); + entries.set(key, hit); + return Object.freeze({ + changed: previous?.handle.toString() !== target.toString(), + accepted: true, + hit, + previous, + generation, + policy: null, + witness: evidence, + }); + }, + }); + } + async #publish( request: Parameters[0], ): Promise>> { @@ -316,6 +431,23 @@ async function* singleChunk(bytes: Uint8Array): AsyncGenerator { yield bytes; } +async function collectPageSource( + source: Parameters[0]['source'], +): Promise { + if (source instanceof Uint8Array) { + return source.slice(); + } + return await collectAsyncIterable(toAsyncIterable(source)); +} + +async function* toAsyncIterable( + source: Iterable | AsyncIterable, +): AsyncGenerator { + for await (const chunk of source) { + yield chunk; + } +} + function parseApplicationHandle(input: ApplicationHandleInput): ApplicationHandle { if (input instanceof GitCasAssetHandle || input instanceof BundleHandle || input instanceof PageHandle) { return input; diff --git a/test/helpers/MemoryRuntimeStorageAdapter.ts b/test/helpers/MemoryRuntimeStorageAdapter.ts index 3446e0f8..d3374c62 100644 --- a/test/helpers/MemoryRuntimeStorageAdapter.ts +++ b/test/helpers/MemoryRuntimeStorageAdapter.ts @@ -8,6 +8,7 @@ import { CborIndexStoreAdapter } from '../../src/infrastructure/adapters/CborInd import { CborPatchJournalAdapter } from '../../src/infrastructure/adapters/CborPatchJournalAdapter.ts'; import GitCasAuditLogAdapter from '../../src/infrastructure/adapters/GitCasAuditLogAdapter.ts'; import GitCasIntentStoreAdapter from '../../src/infrastructure/adapters/GitCasIntentStoreAdapter.ts'; +import GitCasMaterializationStoreAdapter from '../../src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts'; import GitCasStrandStoreAdapter from '../../src/infrastructure/adapters/GitCasStrandStoreAdapter.ts'; import GitCasAssetStorageAdapter from '../../src/infrastructure/adapters/GitCasAssetStorageAdapter.ts'; import CasContentEncryptionPolicy from '../../src/infrastructure/adapters/CasContentEncryptionPolicy.ts'; @@ -91,6 +92,12 @@ export default class MemoryRuntimeStorageAdapter implements RuntimeStorageProvid assetStorage: this.#content, cas: this.#cas, }), + materializations: new GitCasMaterializationStoreAdapter({ + cas: this.#cas, + codec: request.codec, + crypto: request.crypto, + laneName: request.timelineName, + }), })); } } diff --git a/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts b/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts new file mode 100644 index 00000000..dea086c9 --- /dev/null +++ b/test/integration/infrastructure/adapters/GitCasMaterializationStoreAdapter.integration.test.ts @@ -0,0 +1,173 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import ContentAddressableStore, { + BundleHandle as GitCasBundleHandle, +} from '@git-stunts/git-cas'; +import Plumbing from '@git-stunts/plumbing'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import MaterializationCoordinate from '../../../../src/domain/materialization/MaterializationCoordinate.ts'; +import MaterializationRoots from '../../../../src/domain/materialization/MaterializationRoots.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; +import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; +import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; +import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import type MaterializationStorePort from '../../../../src/ports/MaterializationStorePort.ts'; + +const execFileAsync = promisify(execFile); + +describe('GitCasMaterializationStoreAdapter integration', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await rm(harness.path, { recursive: true, force: true }); + }); + + it('retains the materialization graph and resumes from a fresh repository adapter', async () => { + const coordinate = new MaterializationCoordinate({ + frontier: new Map([['writer-a', 'a'.repeat(40)]]), + ceiling: null, + }); + const rootFixture = await createRoots(harness.cas); + const retained = await harness.materializations.retain({ + coordinate, + roots: rootFixture.roots, + stateHash: 'state-hash', + }); + + const reopenedCas = createCas(harness.plumbing); + const reopened = await createMaterializations( + harness.plumbing, + reopenedCas, + ); + const resolved = await reopened.findExact(coordinate); + const unreachable = await prunableOids(harness.path); + + expect(resolved?.bundle.equals(retained.bundle)).toBe(true); + expect(resolved?.roots.entries().map(([name, handle]) => [name, handle.toString()])) + .toEqual(rootFixture.roots.entries().map(([name, handle]) => [name, handle.toString()])); + expect(unreachable).not.toContain(GitCasBundleHandle.parse(retained.bundle.toString()).oid); + for (const oid of rootFixture.retainedOids) { + expect(unreachable).not.toContain(oid); + } + expect(await harness.plumbing.execute({ + args: ['show-ref', '--verify', '--hash', 'refs/cas/caches/git-warp/materializations'], + })).toMatch(/^[0-9a-f]{40}\n?$/u); + }); +}); + +type Harness = Readonly<{ + cas: ContentAddressableStore; + materializations: MaterializationStorePort; + path: string; + plumbing: Awaited>; +}>; + +async function createHarness(): Promise { + const path = await mkdtemp(join(tmpdir(), 'git-warp-materializations-')); + const plumbing = await Plumbing.createDefault({ cwd: path }); + await plumbing.execute({ args: ['init', '-q'] }); + await plumbing.execute({ args: ['config', 'user.email', 'test@example.com'] }); + await plumbing.execute({ args: ['config', 'user.name', 'Test'] }); + const cas = createCas(plumbing); + return Object.freeze({ + cas, + path, + plumbing, + materializations: await createMaterializations(plumbing, cas), + }); +} + +function createCas( + plumbing: Awaited>, +): ContentAddressableStore { + return ContentAddressableStore.createCbor({ + plumbing, + chunking: { strategy: 'cdc' }, + applicationRefPrefixes: ['refs/warp/'], + }); +} + +async function createMaterializations( + plumbing: Awaited>, + cas: ContentAddressableStore, +): Promise { + const history = new GitTimelineHistoryAdapter({ plumbing }); + const repository = new GitCasRepositoryAdapter({ plumbing, history, cas }); + const services = await repository.createRuntimeStorageServices({ + timelineName: 'events', + codec: defaultCodec, + crypto: new NodeCryptoAdapter(), + commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, + }); + return services.materializations; +} + +async function createRoots(cas: ContentAddressableStore): Promise> { + const handles: BundleHandle[] = []; + const retainedOids: string[] = []; + for (let index = 0; index < 8; index += 1) { + const page = await cas.pages.put({ source: new Uint8Array([index]) }); + const bundle = await cas.bundles.put({ members: { root: page.handle } }); + handles.push(new BundleHandle(bundle.handle.toString())); + retainedOids.push(page.handle.oid, bundle.handle.oid); + } + return Object.freeze({ + retainedOids: Object.freeze(retainedOids), + roots: rootsFromHandles(handles), + }); +} + +function rootsFromHandles(handles: readonly BundleHandle[]): MaterializationRoots { + const [ + adjacency, + edgeAlive, + edgeBirths, + frontier, + nodeAlive, + properties, + provenanceSupport, + roaringIndexes, + ] = handles; + if ( + adjacency === undefined || edgeAlive === undefined || edgeBirths === undefined || + frontier === undefined || nodeAlive === undefined || properties === undefined || + provenanceSupport === undefined || roaringIndexes === undefined + ) { + throw new Error('Root integration fixture did not create every root'); + } + return new MaterializationRoots({ + adjacency, + edgeAlive, + edgeBirths, + frontier, + nodeAlive, + properties, + provenanceSupport, + roaringIndexes, + }); +} + +async function prunableOids(path: string): Promise> { + const { stdout } = await execFileAsync( + 'git', + ['-C', path, 'prune', '-n', '--expire=now'], + ); + return new Set( + stdout + .split('\n') + .map((line) => line.trim().split(/\s+/u)[0]) + .filter((oid): oid is string => oid !== undefined && oid.length > 0), + ); +} diff --git a/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts b/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts index b03560ee..d88d799f 100644 --- a/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts +++ b/test/integration/infrastructure/adapters/GitCasWarpStateCacheAdapter.retention.integration.test.ts @@ -9,6 +9,7 @@ import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/Git import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import { CborCodec } from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; import type WarpStateCachePort from '../../../../src/ports/WarpStateCachePort.ts'; import type WarpStateCacheRetentionPort from '../../../../src/ports/WarpStateCacheRetentionPort.ts'; @@ -95,6 +96,7 @@ async function createHarness(): Promise { const services = await runtimeStorage.createRuntimeStorageServices({ timelineName: 'demo', codec: new CborCodec(), + crypto: new NodeCryptoAdapter(), commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); const cache = services.stateSnapshots; diff --git a/test/unit/domain/WarpCore.blobAutoConstruct.test.ts b/test/unit/domain/WarpCore.blobAutoConstruct.test.ts index 8980c919..cd015c4b 100644 --- a/test/unit/domain/WarpCore.blobAutoConstruct.test.ts +++ b/test/unit/domain/WarpCore.blobAutoConstruct.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { openRuntimeHostProduct } from '../../../src/domain/warp/RuntimeHostProduct.ts'; import defaultCodec from '../../../src/infrastructure/codecs/CborCodec.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; import { openMemoryRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; import InMemoryGraphAdapter from '../../helpers/InMemoryGraphAdapter.ts'; import MemoryRuntimeStorageAdapter from '../../helpers/MemoryRuntimeStorageAdapter.ts'; @@ -13,6 +14,7 @@ describe('runtime storage composition', () => { const services = await runtimeStorage.createRuntimeStorageServices({ timelineName: 'events', codec: defaultCodec, + crypto: defaultCrypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); diff --git a/test/unit/domain/WarpGraph.audit.test.ts b/test/unit/domain/WarpGraph.audit.test.ts index fe2b04d7..af5fa894 100644 --- a/test/unit/domain/WarpGraph.audit.test.ts +++ b/test/unit/domain/WarpGraph.audit.test.ts @@ -11,6 +11,7 @@ import InMemoryGraphAdapter from '../../../test/helpers/InMemoryGraphAdapter.ts' import MemoryRuntimeStorageAdapter from '../../helpers/MemoryRuntimeStorageAdapter.ts'; import defaultCodec, { decode } from '../../../src/infrastructure/codecs/CborCodec.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; +import defaultCrypto from '../../../src/infrastructure/adapters/NodeCryptoSingleton.ts'; describe('WarpCore — audit mode', () => { it('rejects audit: "yes" (non-boolean truthy)', async () => { @@ -162,6 +163,7 @@ describe('WarpCore — audit mode', () => { const storage = await runtimeStorage.createRuntimeStorageServices({ timelineName: 'events', codec: defaultCodec, + crypto: defaultCrypto, commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); const graph = await openRuntimeHostProduct({ diff --git a/test/unit/domain/materialization/MaterializationIdentity.test.ts b/test/unit/domain/materialization/MaterializationIdentity.test.ts new file mode 100644 index 00000000..ede2ae63 --- /dev/null +++ b/test/unit/domain/materialization/MaterializationIdentity.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; +import MaterializationCoordinate from '../../../../src/domain/materialization/MaterializationCoordinate.ts'; +import MaterializationHandle from '../../../../src/domain/materialization/MaterializationHandle.ts'; +import MaterializationRoots, { + MATERIALIZATION_ROOT_NAMES, + type MaterializationRootsOptions, +} from '../../../../src/domain/materialization/MaterializationRoots.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; +import StorageHandle from '../../../../src/domain/storage/StorageHandle.ts'; +import StorageRetentionWitness, { + StorageRetentionRoot, +} from '../../../../src/domain/storage/StorageRetentionWitness.ts'; + +describe('MaterializationCoordinate', () => { + it('freezes a sorted exact coordinate and returns defensive frontier maps', () => { + const coordinate = new MaterializationCoordinate({ + frontier: new Map([ + ['writer-b', 'patch-b'], + ['writer-a', 'patch-a'], + ]), + ceiling: 42, + }); + + expect(coordinate.frontierEntries).toEqual([ + { writerId: 'writer-a', patchSha: 'patch-a' }, + { writerId: 'writer-b', patchSha: 'patch-b' }, + ]); + expect(Object.isFrozen(coordinate)).toBe(true); + expect(Object.isFrozen(coordinate.frontierEntries)).toBe(true); + expect(Object.isFrozen(coordinate.frontierEntries[0])).toBe(true); + + const frontier = coordinate.frontier(); + frontier.set('writer-c', 'patch-c'); + expect(coordinate.frontier().has('writer-c')).toBe(false); + expect(coordinate.equals(new MaterializationCoordinate({ + frontier: new Map([ + ['writer-a', 'patch-a'], + ['writer-b', 'patch-b'], + ]), + ceiling: 42, + }))).toBe(true); + }); + + it('distinguishes ceiling, frontier size, writer, and patch differences', () => { + const coordinate = exactCoordinate(); + expect(coordinate.equals(null)).toBe(false); + expect(coordinate.equals(undefined)).toBe(false); + expect(coordinate.equals(new MaterializationCoordinate({ + frontier: coordinate.frontier(), + ceiling: 8, + }))).toBe(false); + expect(coordinate.equals(new MaterializationCoordinate({ + frontier: new Map(), + ceiling: 7, + }))).toBe(false); + expect(coordinate.equals(new MaterializationCoordinate({ + frontier: new Map([['writer-b', 'patch-a']]), + ceiling: 7, + }))).toBe(false); + expect(coordinate.equals(new MaterializationCoordinate({ + frontier: new Map([['writer-a', 'patch-b']]), + ceiling: 7, + }))).toBe(false); + }); + + it('orders frontier writers by protocol strings instead of locale collation', () => { + const coordinate = new MaterializationCoordinate({ + frontier: new Map([ + ['\u00e4', 'patch-a-umlaut'], + ['z', 'patch-z'], + ]), + ceiling: null, + }); + + expect(coordinate.frontierEntries.map(({ writerId }) => writerId)).toEqual([ + 'z', + '\u00e4', + ]); + }); + + it.each([ + ['options', null], + ['frontier', { frontier: {}, ceiling: null }], + ['writer', { frontier: new Map([['', 'patch']]), ceiling: null }], + ['patch', { frontier: new Map([['writer', '']]), ceiling: null }], + ['negative ceiling', { frontier: new Map(), ceiling: -1 }], + ['fractional ceiling', { frontier: new Map(), ceiling: 1.5 }], + ['unsafe ceiling', { frontier: new Map(), ceiling: Number.MAX_SAFE_INTEGER + 1 }], + ])('rejects invalid %s', (_field, options) => { + expect(() => construct(MaterializationCoordinate, options)).toThrowError( + /Materialization coordinate/u, + ); + }); +}); + +describe('MaterializationRoots', () => { + it('exposes every independent root in canonical bundle order', () => { + const roots = materializationRoots(); + + expect(roots.entries().map(([name]) => name)).toEqual([ + 'adjacency', + 'edge-alive', + 'edge-births', + 'frontier', + 'node-alive', + 'properties', + 'provenance-support', + 'roaring-indexes', + ]); + expect(MATERIALIZATION_ROOT_NAMES).toEqual(roots.entries().map(([name]) => name)); + expect(Object.isFrozen(MATERIALIZATION_ROOT_NAMES)).toBe(true); + expect(Object.isFrozen(roots)).toBe(true); + expect(Object.isFrozen(roots.entries())).toBe(true); + expect(Object.isFrozen(roots.entries()[0])).toBe(true); + }); + + it.each([ + 'adjacency', + 'edgeAlive', + 'edgeBirths', + 'frontier', + 'nodeAlive', + 'properties', + 'provenanceSupport', + 'roaringIndexes', + ])('rejects a non-bundle %s root', (field) => { + const options = rootsOptions(); + Reflect.set(options, field, new StorageHandle('not-a-bundle')); + expect(() => construct(MaterializationRoots, options)).toThrowError( + /Materialization roots/u, + ); + }); + + it('rejects a missing options object', () => { + expect(() => construct(MaterializationRoots, null)).toThrowError(/options/u); + }); +}); + +describe('MaterializationHandle', () => { + it('binds an exact coordinate and independent roots to retained bundle evidence', () => { + const bundle = bundleHandle('materialization'); + const handle = new MaterializationHandle({ + laneName: 'events', + bundle, + coordinate: exactCoordinate(), + roots: materializationRoots(), + stateHash: 'state-hash', + retention: retentionWitness(bundle), + }); + + expect(handle.laneName).toBe('events'); + expect(handle.bundle).toBe(bundle); + expect(handle.retention.handle.equals(bundle)).toBe(true); + expect(Object.isFrozen(handle)).toBe(true); + }); + + it.each([ + ['laneName', ''], + ['bundle', new StorageHandle('not-a-bundle')], + ['coordinate', { frontier: new Map(), ceiling: null }], + ['roots', rootsOptions()], + ['stateHash', ''], + ['retention', { policy: 'evictable' }], + ])('rejects invalid %s', (field, value) => { + const options = handleOptions(); + Reflect.set(options, field, value); + expect(() => construct(MaterializationHandle, options)).toThrowError( + /Materialization handle/u, + ); + }); + + it('rejects retention evidence for another bundle', () => { + const options = handleOptions(); + Reflect.set(options, 'retention', retentionWitness(bundleHandle('other'))); + expect(() => construct(MaterializationHandle, options)).toThrowError( + /does not retain/u, + ); + }); + + it('rejects a missing options object', () => { + expect(() => construct(MaterializationHandle, null)).toThrowError(/options/u); + }); +}); + +function exactCoordinate(): MaterializationCoordinate { + return new MaterializationCoordinate({ + frontier: new Map([['writer-a', 'patch-a']]), + ceiling: 7, + }); +} + +function bundleHandle(name: string): BundleHandle { + return new BundleHandle(`git-cas:1:bundle:${name}`); +} + +function rootsOptions(): MaterializationRootsOptions { + return { + adjacency: bundleHandle('adjacency'), + edgeAlive: bundleHandle('edge-alive'), + edgeBirths: bundleHandle('edge-births'), + frontier: bundleHandle('frontier'), + nodeAlive: bundleHandle('node-alive'), + properties: bundleHandle('properties'), + provenanceSupport: bundleHandle('provenance-support'), + roaringIndexes: bundleHandle('roaring-indexes'), + }; +} + +function materializationRoots(): MaterializationRoots { + return new MaterializationRoots(rootsOptions()); +} + +function retentionWitness(handle: BundleHandle): StorageRetentionWitness { + return new StorageRetentionWitness({ + handle, + policy: 'evictable', + reachability: 'anchored', + root: new StorageRetentionRoot({ + kind: 'cache-set', + namespace: 'git-warp/materializations', + locator: 'refs/cas/caches/git-warp/materializations', + generation: 'generation-1', + path: 'root-00000000', + }), + observedAt: '1970-01-01T00:00:00.000Z', + }); +} + +function handleOptions(): Record { + const bundle = bundleHandle('materialization'); + return { + laneName: 'events', + bundle, + coordinate: exactCoordinate(), + roots: materializationRoots(), + stateHash: 'state-hash', + retention: retentionWitness(bundle), + }; +} + +function construct(target: Function, value: object | null): void { + Reflect.construct(target, [value]); +} diff --git a/test/unit/helpers/InMemoryGitCasFacade.test.ts b/test/unit/helpers/InMemoryGitCasFacade.test.ts new file mode 100644 index 00000000..34239ff9 --- /dev/null +++ b/test/unit/helpers/InMemoryGitCasFacade.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import InMemoryBlobStorageAdapter from '../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../helpers/InMemoryGraphAdapter.ts'; + +describe('InMemoryGitCasFacade page handles', () => { + it.each([ + ['sha1', 40], + ['sha256', 64], + ])('derives %s metadata from the history OID width', async (hashAlgorithm, oidLength) => { + const oid = 'a'.repeat(oidLength); + const cas = new InMemoryGitCasFacade({ + history: new InMemoryGraphAdapter({ hash: () => oid }), + storage: new InMemoryBlobStorageAdapter(), + }); + + const page = await cas.pages.put({ source: new Uint8Array([1]) }); + + expect(page.handle.oid).toBe(oid); + expect(page.handle.hashAlgorithm).toBe(hashAlgorithm); + }); +}); diff --git a/test/unit/infrastructure/adapters/GitCasMaterializationStoreAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasMaterializationStoreAdapter.test.ts new file mode 100644 index 00000000..22bed298 --- /dev/null +++ b/test/unit/infrastructure/adapters/GitCasMaterializationStoreAdapter.test.ts @@ -0,0 +1,541 @@ +import { describe, expect, it } from 'vitest'; +import type { CacheStoreResult } from '@git-stunts/git-cas'; +import MaterializationCoordinate from '../../../../src/domain/materialization/MaterializationCoordinate.ts'; +import MaterializationRoots from '../../../../src/domain/materialization/MaterializationRoots.ts'; +import BundleHandle from '../../../../src/domain/storage/BundleHandle.ts'; +import GitCasMaterializationStoreAdapter, { + type GitCasMaterializationFacade, +} from '../../../../src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts'; +import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; +import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; +import InMemoryBlobStorageAdapter from '../../../helpers/InMemoryBlobStorageAdapter.ts'; +import InMemoryGitCasFacade from '../../../helpers/InMemoryGitCasFacade.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; + +const CACHE_NAMESPACE = 'git-warp/materializations'; +const ROOT_PATHS = Object.freeze([ + 'roots/adjacency', + 'roots/edge-alive', + 'roots/edge-births', + 'roots/frontier', + 'roots/node-alive', + 'roots/properties', + 'roots/provenance-support', + 'roots/roaring-indexes', +]); + +describe('GitCasMaterializationStoreAdapter', () => { + it('retains deterministic independent roots and resolves the exact coordinate', async () => { + const harness = await createHarness(); + const coordinate = exactCoordinate(); + const roots = await createRoots(harness.cas); + + const retained = await harness.adapter.retain({ + coordinate, + roots, + stateHash: 'state-hash', + }); + const resolved = await harness.adapter.findExact(new MaterializationCoordinate({ + frontier: new Map([ + ['writer-b', 'patch-b'], + ['writer-a', 'patch-a'], + ]), + ceiling: 12, + })); + + expect(retained.laneName).toBe('events'); + expect(retained.retention).toMatchObject({ + policy: 'evictable', + reachability: 'anchored', + root: { + kind: 'cache-set', + namespace: CACHE_NAMESPACE, + }, + }); + expect(resolved).not.toBeNull(); + expect(resolved?.bundle.equals(retained.bundle)).toBe(true); + expect(resolved?.coordinate.equals(coordinate)).toBe(true); + expect(resolved?.stateHash).toBe('state-hash'); + expect(resolved?.roots.entries().map(([name, handle]) => [name, handle.toString()])) + .toEqual(roots.entries().map(([name, handle]) => [name, handle.toString()])); + + const members = harness.cas.readBundleMembers(retained.bundle.toString()); + expect(members.map(([path]) => path)).toEqual(['meta/descriptor', ...ROOT_PATHS]); + const cacheKeys = harness.cas.readCacheKeys(CACHE_NAMESPACE); + expect(cacheKeys).toHaveLength(1); + expect(cacheKeys[0]).toMatch(/^v1:[0-9a-f]{64}$/u); + expect(cacheKeys[0]?.length).toBeLessThan(1024); + }); + + it('returns null for a coordinate with no retained materialization', async () => { + const harness = await createHarness(); + expect(await harness.adapter.findExact(exactCoordinate())).toBeNull(); + }); + + it('round-trips an unbounded live coordinate with a null ceiling', async () => { + const harness = await createHarness(); + const coordinate = new MaterializationCoordinate({ frontier: new Map(), ceiling: null }); + await harness.adapter.retain({ + coordinate, + roots: await createRoots(harness.cas), + stateHash: 'empty-state-hash', + }); + expect((await harness.adapter.findExact(coordinate))?.coordinate.ceiling).toBeNull(); + }); + + it('fails closed when git-cas declines materialization retention', async () => { + const harness = await createHarness(); + const facade = withCacheResult(harness.cas, (stored) => Object.freeze({ + ...stored, + accepted: false, + hit: null, + witness: null, + })); + const adapter = adapterFor(facade); + + await expect(adapter.retain({ + coordinate: exactCoordinate(), + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + })).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('did not retain'), + }); + }); + + it('fails closed when git-cas reports an unexpected retained target', async () => { + const harness = await createHarness(); + const page = await harness.cas.pages.put({ source: new Uint8Array([1]) }); + const baseCache = await harness.cas.caches.open({ namespace: CACHE_NAMESPACE }); + const unexpected = await baseCache.put('unexpected', page.handle); + if (unexpected.hit === null) { + throw new Error('Expected the test cache to retain its unexpected target'); + } + const unexpectedHit = unexpected.hit; + const facade = withCacheResult(harness.cas, (stored) => Object.freeze({ + ...stored, + hit: unexpectedHit, + })); + const adapter = adapterFor(facade); + + await expect(adapter.retain({ + coordinate: exactCoordinate(), + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + })).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('unexpected materialization handle'), + }); + }); + + it('rejects a cache entry whose target is not a bundle', async () => { + const harness = await createHarness(); + const coordinate = exactCoordinate(); + await harness.adapter.retain({ + coordinate, + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + }); + const cacheKey = requireSingleCacheKey(harness.cas); + const page = await harness.cas.pages.put({ source: new Uint8Array([1]) }); + const cache = await harness.cas.caches.open({ namespace: CACHE_NAMESPACE }); + await cache.put(cacheKey, page.handle); + + await expect(harness.adapter.findExact(coordinate)).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('does not reference a materialization bundle'), + }); + }); + + it.each([ + ['missing descriptor', (members: readonly [string, string][]) => + members.filter(([path]) => path !== 'meta/descriptor')], + ['bundle descriptor', (members: readonly [string, string][]) => { + const root = requireMember(members, 'roots/adjacency'); + return replaceMember(members, 'meta/descriptor', root); + }], + ['missing root', (members: readonly [string, string][]) => + members.filter(([path]) => path !== 'roots/properties')], + ['unexpected member path', (members: readonly [string, string][]) => + renameMember(members, 'roots/properties', 'unexpected')], + ['unknown root', (members: readonly [string, string][]) => + renameMember(members, 'roots/properties', 'roots/unknown')], + ['duplicate descriptor', (members: readonly [string, string][]) => + renameMember(members, 'roots/properties', 'meta/descriptor')], + ['duplicate root', (members: readonly [string, string][]) => + renameMember(members, 'roots/properties', 'roots/adjacency')], + ['too many members', (members: readonly [string, string][]) => [ + ...members, + memberEntry('roots/unknown', requireMember(members, 'roots/adjacency')), + ]], + ])('rejects a materialization bundle with a %s', async (_case, mutate) => { + const harness = await createHarness(); + const coordinate = exactCoordinate(); + const retained = await harness.adapter.retain({ + coordinate, + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + }); + const members = harness.cas.readBundleMembers(retained.bundle.toString()); + harness.cas.replaceBundleMembers(retained.bundle.toString(), mutate(members)); + + await expect(harness.adapter.findExact(coordinate)).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + }); + }); + + it('rejects a materialization root member that is not a bundle', async () => { + const harness = await createHarness(); + const coordinate = exactCoordinate(); + const retained = await harness.adapter.retain({ + coordinate, + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + }); + const page = await harness.cas.pages.put({ source: new Uint8Array([1]) }); + const members = harness.cas.readBundleMembers(retained.bundle.toString()); + harness.cas.replaceBundleMembers( + retained.bundle.toString(), + replaceMember(members, 'roots/edge-alive', page.handle.toString()), + ); + + await expect(harness.adapter.findExact(coordinate)).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('edge-alive root bundle'), + }); + }); + + it.each([ + ['non-object descriptor', null, 'descriptor must be an object'], + ['schema', { schemaVersion: 2 }, 'schema is unsupported'], + [ + 'coordinate object', + descriptor({ coordinate: null }), + 'descriptor.coordinate must be an object', + ], + [ + 'frontier collection', + descriptor({ coordinate: { ceiling: 12, frontier: {} } }), + 'frontier must be an array', + ], + [ + 'frontier tuple', + descriptor({ coordinate: { ceiling: 12, frontier: [['writer-a']] } }), + 'invalid frontier entry', + ], + [ + 'frontier writer', + descriptor({ coordinate: { ceiling: 12, frontier: [['', 'patch-a']] } }), + 'writerId must be a non-empty string', + ], + [ + 'frontier patch', + descriptor({ coordinate: { ceiling: 12, frontier: [['writer-a', '']] } }), + 'patchSha must be a non-empty string', + ], + [ + 'duplicate writer', + descriptor({ + coordinate: { + ceiling: 12, + frontier: [['writer-a', 'patch-a'], ['writer-a', 'patch-b']], + }, + }), + 'duplicate frontier writer', + ], + [ + 'ceiling', + descriptor({ coordinate: { ceiling: -1, frontier: [] } }), + 'coordinate ceiling is invalid', + ], + ['lane', descriptor({ laneName: '' }), 'laneName must be a non-empty string'], + ['state hash', descriptor({ stateHash: '' }), 'stateHash must be a non-empty string'], + ])('rejects an invalid %s', async (_case, value, message) => { + const harness = await retainedHarness(); + replaceDescriptor(harness, value); + await expect(harness.adapter.findExact(harness.coordinate)).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining(message), + }); + }); + + it('rejects a descriptor for another lane', async () => { + const harness = await retainedHarness(); + replaceDescriptor(harness, descriptor({ laneName: 'other' })); + await expect(harness.adapter.findExact(harness.coordinate)).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('belongs to another lane'), + }); + }); + + it('rejects a descriptor coordinate that does not match the cache key', async () => { + const harness = await retainedHarness(); + replaceDescriptor(harness, descriptor({ + coordinate: { ceiling: 99, frontier: [] }, + })); + await expect(harness.adapter.findExact(harness.coordinate)).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('does not match its cache key'), + }); + }); + + it('enforces the descriptor page read bound', async () => { + const harness = await retainedHarness(); + const descriptorHandle = requireMember( + harness.cas.readBundleMembers(harness.retainedBundle.toString()), + 'meta/descriptor', + ); + harness.cas.replaceStoredPage(descriptorHandle, new Uint8Array(1024 * 1024 + 1)); + await expect(harness.adapter.findExact(harness.coordinate)).rejects.toMatchObject({ + code: 'PAGE_TOO_LARGE', + }); + }); + + it('validates adapter dependencies and retain request identities', async () => { + const harness = await createHarness(); + const roots = await createRoots(harness.cas); + + expect(() => Reflect.construct(GitCasMaterializationStoreAdapter, [null])) + .toThrowError(/adapter options/u); + for (const field of ['cas', 'codec', 'crypto']) { + const options = adapterOptions(harness.cas); + Reflect.set(options, field, null); + expect(() => Reflect.construct(GitCasMaterializationStoreAdapter, [options])) + .toThrowError(new RegExp(`${field} dependency`, 'u')); + } + expect(() => new GitCasMaterializationStoreAdapter({ + ...adapterOptions(harness.cas), + laneName: '', + })).toThrowError(/laneName/u); + + await expect(Reflect.apply(harness.adapter.retain, harness.adapter, [null])) + .rejects.toMatchObject({ code: 'E_MATERIALIZATION_STORAGE' }); + await expect(Reflect.apply(harness.adapter.retain, harness.adapter, [{ + coordinate: { frontier: new Map(), ceiling: null }, + roots, + stateHash: 'state-hash', + }])).rejects.toMatchObject({ code: 'E_MATERIALIZATION_STORAGE' }); + await expect(Reflect.apply(harness.adapter.retain, harness.adapter, [{ + coordinate: exactCoordinate(), + roots: roots.entries(), + stateHash: 'state-hash', + }])).rejects.toMatchObject({ code: 'E_MATERIALIZATION_STORAGE' }); + await expect(harness.adapter.retain({ + coordinate: exactCoordinate(), + roots, + stateHash: '', + })).rejects.toMatchObject({ code: 'E_MATERIALIZATION_STORAGE' }); + await expect(Reflect.apply(harness.adapter.findExact, harness.adapter, [{ + frontier: new Map(), + ceiling: null, + }])).rejects.toMatchObject({ code: 'E_MATERIALIZATION_STORAGE' }); + }); + + it('rejects descriptors that exceed the write bound', async () => { + const harness = await createHarness('x'.repeat(1024 * 1024 + 1)); + await expect(harness.adapter.retain({ + coordinate: exactCoordinate(), + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + })).rejects.toMatchObject({ + code: 'E_MATERIALIZATION_STORAGE', + message: expect.stringContaining('exceeds its byte limit'), + }); + }); +}); + +type Harness = Readonly<{ + adapter: GitCasMaterializationStoreAdapter; + cas: InMemoryGitCasFacade; +}>; + +type RetainedHarness = Harness & Readonly<{ + coordinate: MaterializationCoordinate; + retainedBundle: BundleHandle; +}>; + +async function createHarness(laneName = 'events'): Promise { + const history = new InMemoryGraphAdapter(); + const cas = new InMemoryGitCasFacade({ + history, + storage: new InMemoryBlobStorageAdapter(), + }); + return Object.freeze({ + cas, + adapter: new GitCasMaterializationStoreAdapter({ + ...adapterOptions(cas), + laneName, + }), + }); +} + +function adapterOptions(cas: InMemoryGitCasFacade): { + cas: InMemoryGitCasFacade; + codec: typeof defaultCodec; + crypto: NodeCryptoAdapter; + laneName: string; +} { + return { + cas, + codec: defaultCodec, + crypto: new NodeCryptoAdapter(), + laneName: 'events', + }; +} + +function adapterFor(cas: GitCasMaterializationFacade): GitCasMaterializationStoreAdapter { + return new GitCasMaterializationStoreAdapter({ + cas, + codec: defaultCodec, + crypto: new NodeCryptoAdapter(), + laneName: 'events', + }); +} + +function withCacheResult( + cas: InMemoryGitCasFacade, + rewrite: (stored: CacheStoreResult) => CacheStoreResult, +): GitCasMaterializationFacade { + return { + bundles: cas.bundles, + pages: cas.pages, + caches: { + open: async (options) => { + const cache = await cas.caches.open(options); + return { + get: async (key) => await cache.get(key), + put: async (key, handle, entryOptions) => rewrite( + await cache.put(key, handle, entryOptions), + ), + }; + }, + }, + }; +} + +async function retainedHarness(): Promise { + const harness = await createHarness(); + const coordinate = exactCoordinate(); + const retained = await harness.adapter.retain({ + coordinate, + roots: await createRoots(harness.cas), + stateHash: 'state-hash', + }); + return Object.freeze({ + ...harness, + coordinate, + retainedBundle: retained.bundle, + }); +} + +async function createRoots(cas: InMemoryGitCasFacade): Promise { + const handles: BundleHandle[] = []; + for (let index = 0; index < ROOT_PATHS.length; index += 1) { + const page = await cas.pages.put({ source: new Uint8Array([index]) }); + const bundle = await cas.bundles.putOrdered({ + members: [['root', page.handle]], + }); + handles.push(new BundleHandle(bundle.handle.toString())); + } + const [ + adjacency, + edgeAlive, + edgeBirths, + frontier, + nodeAlive, + properties, + provenanceSupport, + roaringIndexes, + ] = handles; + if ( + adjacency === undefined || edgeAlive === undefined || edgeBirths === undefined || + frontier === undefined || nodeAlive === undefined || properties === undefined || + provenanceSupport === undefined || roaringIndexes === undefined + ) { + throw new Error('Root fixture did not create every materialization root'); + } + return new MaterializationRoots({ + adjacency, + edgeAlive, + edgeBirths, + frontier, + nodeAlive, + properties, + provenanceSupport, + roaringIndexes, + }); +} + +function exactCoordinate(): MaterializationCoordinate { + return new MaterializationCoordinate({ + frontier: new Map([ + ['writer-a', 'patch-a'], + ['writer-b', 'patch-b'], + ]), + ceiling: 12, + }); +} + +function descriptor(overrides: Record = {}): object { + return { + schemaVersion: 1, + laneName: 'events', + stateHash: 'state-hash', + coordinate: { + ceiling: 12, + frontier: [['writer-a', 'patch-a'], ['writer-b', 'patch-b']], + }, + ...overrides, + }; +} + +function replaceDescriptor(harness: RetainedHarness, value: object | null): void { + const descriptorHandle = requireMember( + harness.cas.readBundleMembers(harness.retainedBundle.toString()), + 'meta/descriptor', + ); + harness.cas.replaceStoredPage(descriptorHandle, defaultCodec.encode(value)); +} + +function requireSingleCacheKey(cas: InMemoryGitCasFacade): string { + const keys = cas.readCacheKeys(CACHE_NAMESPACE); + const key = keys[0]; + if (keys.length !== 1 || key === undefined) { + throw new Error('Expected exactly one materialization cache key'); + } + return key; +} + +function requireMember(members: readonly [string, string][], path: string): string { + const member = members.find(([candidate]) => candidate === path); + if (member === undefined) { + throw new Error(`Expected materialization member ${path}`); + } + return member[1]; +} + +function replaceMember( + members: readonly [string, string][], + path: string, + handle: string, +): readonly [string, string][] { + return members.map(([candidate, existing]) => [ + candidate, + candidate === path ? handle : existing, + ]); +} + +function renameMember( + members: readonly [string, string][], + path: string, + replacement: string, +): readonly [string, string][] { + return members.map(([candidate, handle]) => memberEntry( + candidate === path ? replacement : candidate, + handle, + )); +} + +function memberEntry(path: string, handle: string): [string, string] { + return [path, handle]; +} diff --git a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts index 1bdf5b49..3719940b 100644 --- a/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts +++ b/test/unit/infrastructure/adapters/GitCasRepositoryAdapter.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest'; import { TrustRecord } from '../../../../src/domain/trust/TrustRecord.ts'; import WarpState from '../../../../src/domain/services/state/WarpState.ts'; import GitCasRepositoryAdapter from '../../../../src/infrastructure/adapters/GitCasRepositoryAdapter.ts'; +import GitCasMaterializationStoreAdapter from '../../../../src/infrastructure/adapters/GitCasMaterializationStoreAdapter.ts'; import GitTimelineHistoryAdapter from '../../../../src/infrastructure/adapters/GitTimelineHistoryAdapter.ts'; import { DEFAULT_COMMIT_MESSAGE_CODEC } from '../../../../src/infrastructure/adapters/TrailerCommitMessageCodecAdapter.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; @@ -126,6 +127,8 @@ describe('GitCasRepositoryAdapter', () => { open: highLevelCas.assets.open, }, bundles: highLevelCas.bundles, + caches: highLevelCas.caches, + pages: highLevelCas.pages, publications: highLevelCas.publications, rootSets: { open: vi.fn(async () => rootSet) }, readManifest: vi.fn(), @@ -138,6 +141,7 @@ describe('GitCasRepositoryAdapter', () => { const services = await repository.createRuntimeStorageServices({ timelineName: 'events', codec: defaultCodec, + crypto: new TestCrypto(), commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); @@ -156,6 +160,7 @@ describe('GitCasRepositoryAdapter', () => { createdAt: '2026-07-13T00:00:00.000Z', state: WarpState.empty(), }); + expect(services.materializations).toBeInstanceOf(GitCasMaterializationStoreAdapter); plumbing.execute .mockResolvedValueOnce('f'.repeat(40)) diff --git a/test/unit/scripts/v16-to-v17-upgrade.test.ts b/test/unit/scripts/v16-to-v17-upgrade.test.ts index 5e1cc798..41c0fe09 100644 --- a/test/unit/scripts/v16-to-v17-upgrade.test.ts +++ b/test/unit/scripts/v16-to-v17-upgrade.test.ts @@ -72,6 +72,7 @@ describe('v16 to v17 top-level upgrade utility', () => { const services = await runtimeStorage.createRuntimeStorageServices({ timelineName: 'alpha', codec: defaultCodec, + crypto: new NodeCryptoAdapter(), commitMessageCodec: DEFAULT_COMMIT_MESSAGE_CODEC, }); const checkpointSha = await createCheckpointEnvelope({ diff --git a/vitest.config.ts b/vitest.config.ts index 2096b27f..96b96dfa 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.62, + lines: 92.78, autoUpdate: shouldAutoUpdateCoverageRatchet(), }, },