diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ea06365..83d59f4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING:** `checkpointPolicy` now defaults to `{ every: 64 }` instead of + being disabled. Auto-checkpointing is what bounds replay depth, so leaving it + off unless a caller opted in made unbounded growth the default: a graph opened + without a policy replayed every patch since its last explicit checkpoint on + each materialize, forever, and reads paid for it. Observed in practice at 262 + unreplayed patches, where a single read spawned 5,267 Git subprocesses and the + backlog grew by two commits per write with no upper bound. + + An omitted policy now takes the default. `checkpointPolicy: null` remains the + explicit opt-out and is unchanged, so callers that genuinely want no + compaction keep it by asking for it rather than by forgetting. + + This alters observable write behaviour for any consumer that never supplied a + policy: such graphs will begin writing checkpoint commits + once their replay depth reaches or exceeds 64 patches. State hashes are + unaffected — a checkpoint is a snapshot, not a semantic change. + ## [19.0.2] - 2026-07-29 ### Release notes diff --git a/src/domain/RuntimeHost.ts b/src/domain/RuntimeHost.ts index f745915ce..65f5c95ad 100644 --- a/src/domain/RuntimeHost.ts +++ b/src/domain/RuntimeHost.ts @@ -94,6 +94,7 @@ import { type RuntimeHostOpenInput, type RuntimeHostConstructionOptions, } from './warp/RuntimeHostBoot.ts'; +import type CheckpointPolicy from './warp/CheckpointPolicy.ts'; import type { NeighborEdge } from '../ports/NeighborProviderPort.ts'; @@ -210,7 +211,7 @@ export default class RuntimeHost { _patchesSinceGC: number; _patchesSinceCheckpoint: number; _maxObservedLamport: number; - _checkpointPolicy: { every: number } | null; + _checkpointPolicy: CheckpointPolicy | null; _checkpointing: boolean; _autoMaterialize: boolean; traverse: LogicalTraversal; @@ -321,7 +322,7 @@ export default class RuntimeHost { this._patchesSinceGC = 0; this._patchesSinceCheckpoint = 0; this._maxObservedLamport = 0; - this._checkpointPolicy = checkpointPolicy || null; + this._checkpointPolicy = checkpointPolicy; this._checkpointing = false; this._autoMaterialize = autoMaterialize; this._materializedGraph = null; diff --git a/src/domain/WarpGraph.ts b/src/domain/WarpGraph.ts index e2af3ca4f..6aa613fe5 100644 --- a/src/domain/WarpGraph.ts +++ b/src/domain/WarpGraph.ts @@ -38,6 +38,8 @@ import type { ExternalizationPolicy } from './types/ExternalizationPolicy.ts'; import type { GCPolicyConfig } from './services/GCPolicy.ts'; import type RuntimeStorageProviderPort from '../ports/RuntimeStorageProviderPort.ts'; import type TrustCryptoPort from '../ports/TrustCryptoPort.ts'; +import type CheckpointPolicy from './warp/CheckpointPolicy.ts'; +import type { CheckpointPolicyConfig } from './warp/CheckpointPolicy.ts'; // --------------------------------------------------------------------------- // WarpGraph — frozen capability bag, organized by architectural moment @@ -143,7 +145,7 @@ export interface WarpGraphDeps { // Governing policy readonly trust?: { mode?: TrustMode; pin?: string | null }; readonly gcPolicy?: GCPolicyConfig; - readonly checkpointPolicy?: { every: number }; + readonly checkpointPolicy?: CheckpointPolicyConfig | CheckpointPolicy | null; readonly onDeleteWithData?: 'reject' | 'cascade' | 'warn'; readonly autoMaterialize?: boolean; diff --git a/src/domain/services/controllers/ForkController.ts b/src/domain/services/controllers/ForkController.ts index 3d8983563..744b80157 100644 --- a/src/domain/services/controllers/ForkController.ts +++ b/src/domain/services/controllers/ForkController.ts @@ -27,6 +27,7 @@ import type AssetStoragePort from '../../../ports/AssetStoragePort.ts'; import type GCPolicy from '../GCPolicy.ts'; import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts'; import type PatchJournalPort from '../../../ports/PatchJournalPort.ts'; +import type CheckpointPolicy from '../../warp/CheckpointPolicy.ts'; const HEX_CHARS = '0123456789abcdef'; type ForkRuntimeOpenOptions = RuntimeHostOpenOptions; @@ -49,7 +50,7 @@ type ForkHost = { _runtimeStorage: RuntimeStorageProviderPort; _graphName: string; _gcPolicy: GCPolicy; - _checkpointPolicy: { every: number } | null; + _checkpointPolicy: CheckpointPolicy | null; _autoMaterialize: boolean; _onDeleteWithData: 'reject' | 'cascade' | 'warn'; _logger: LoggerPort | null; @@ -123,10 +124,13 @@ export default class ForkController { forkName ?? `${host._graphName}-fork-${randomSuffix()}`; try { validateGraphName(resolvedForkName); - } catch (err) { - throw new ForkError(`Invalid fork name: ${(err as Error).message}`, { + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + throw new ForkError(`Invalid fork name: ${error.message}`, { code: 'E_FORK_NAME_INVALID', - context: { forkName: resolvedForkName, originalError: (err as Error).message }, + context: { forkName: resolvedForkName, originalError: error.message }, }); } @@ -142,10 +146,13 @@ export default class ForkController { const resolvedForkWriterId = (forkWriterId !== undefined && forkWriterId !== null && forkWriterId !== '') ? forkWriterId : generateWriterId(); try { validateWriterId(resolvedForkWriterId); - } catch (err) { - throw new ForkError(`Invalid fork writer ID: ${(err as Error).message}`, { + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + throw new ForkError(`Invalid fork writer ID: ${error.message}`, { code: 'E_FORK_WRITER_ID_INVALID', - context: { forkWriterId: resolvedForkWriterId, originalError: (err as Error).message }, + context: { forkWriterId: resolvedForkWriterId, originalError: error.message }, }); } @@ -161,7 +168,7 @@ export default class ForkController { graphName: resolvedForkName, writerId: resolvedForkWriterId, gcPolicy: host._gcPolicy, - ...(host._checkpointPolicy ? { checkpointPolicy: host._checkpointPolicy } : {}), + checkpointPolicy: host._checkpointPolicy, autoMaterialize: host._autoMaterialize, onDeleteWithData: host._onDeleteWithData, ...(host._logger ? { logger: host._logger } : {}), diff --git a/src/domain/services/controllers/detachedOpen.ts b/src/domain/services/controllers/detachedOpen.ts index 5c5834d6d..411a06a59 100644 --- a/src/domain/services/controllers/detachedOpen.ts +++ b/src/domain/services/controllers/detachedOpen.ts @@ -12,6 +12,7 @@ import type { NormalizedTrustConfig } from '../../runtimeHelpers.ts'; import type GCPolicy from '../GCPolicy.ts'; import type { DetachedGraphInternalReadSurface } from '../../capabilities/DetachedGraphFactory.ts'; import type RuntimeStorageProviderPort from '../../../ports/RuntimeStorageProviderPort.ts'; +import type CheckpointPolicy from '../../warp/CheckpointPolicy.ts'; export type DetachedOpenOptions = { persistence: CorePersistence; @@ -24,7 +25,7 @@ export type DetachedOpenOptions = { crypto: CryptoPort; codec: CodecPort; audit: false; - checkpointPolicy?: { every: number }; + checkpointPolicy?: CheckpointPolicy | null; logger?: LoggerPort; trust?: NormalizedTrustConfig; }; @@ -37,7 +38,7 @@ export type DetachedOpenHost = { _graphName: string; _writerId: string; _gcPolicy: GCPolicy; - _checkpointPolicy: { every: number } | null; + _checkpointPolicy: CheckpointPolicy | null; _logger: LoggerPort | null; _trustConfig: NormalizedTrustConfig; _onDeleteWithData: 'reject' | 'cascade' | 'warn'; @@ -61,7 +62,7 @@ function coreOptions(graph: DetachedOpenHost): DetachedOpenOptions { } function addReadPolicy(opts: DetachedOpenOptions, g: DetachedOpenHost): void { - if (g._checkpointPolicy) { opts.checkpointPolicy = g._checkpointPolicy; } + opts.checkpointPolicy = g._checkpointPolicy; if (g._logger) { opts.logger = g._logger; } } diff --git a/src/domain/warp/CheckpointPolicy.ts b/src/domain/warp/CheckpointPolicy.ts new file mode 100644 index 000000000..362c9bb3a --- /dev/null +++ b/src/domain/warp/CheckpointPolicy.ts @@ -0,0 +1,42 @@ +import WarpError from '../errors/WarpError.ts'; + +export type CheckpointPolicyConfig = { + readonly every: number; +}; + +const DEFAULT_CHECKPOINT_INTERVAL = 64; + +/** Immutable, validated cadence for automatic replay checkpoints. */ +export default class CheckpointPolicy { + readonly every: number; + + constructor(every: number) { + if (!Number.isInteger(every) || every <= 0) { + throw new WarpError( + 'checkpointPolicy.every must be a positive integer', + 'E_CHECKPOINT_POLICY_EVERY', + ); + } + this.every = every; + Object.freeze(this); + } + + static readonly DEFAULT: CheckpointPolicy = new CheckpointPolicy( + DEFAULT_CHECKPOINT_INTERVAL, + ); + + static from( + value: CheckpointPolicyConfig | CheckpointPolicy, + ): CheckpointPolicy { + if (value instanceof CheckpointPolicy) { + return value; + } + if (typeof value !== 'object' || value === null) { + throw new WarpError( + 'checkpointPolicy must be an object with { every: number }', + 'E_CHECKPOINT_POLICY_TYPE', + ); + } + return new CheckpointPolicy(value.every); + } +} diff --git a/src/domain/warp/RuntimeHostBoot.ts b/src/domain/warp/RuntimeHostBoot.ts index 48ffd1c93..19e0e51f5 100644 --- a/src/domain/warp/RuntimeHostBoot.ts +++ b/src/domain/warp/RuntimeHostBoot.ts @@ -44,6 +44,7 @@ import type { EffectPipeline } from '../services/EffectPipeline.ts'; import type { ExternalizationPolicy } from '../types/ExternalizationPolicy.ts'; import GCPolicy, { type GCPolicyConfig } from '../services/GCPolicy.ts'; import type { MaterializeSessionOpener } from '../services/controllers/MaterializeSessionBridge.ts'; +import CheckpointPolicy, { type CheckpointPolicyConfig } from './CheckpointPolicy.ts'; type DeletePolicy = 'reject' | 'cascade' | 'warn'; const VALID_DELETE_POLICIES: ReadonlyArray = ['reject', 'cascade', 'warn']; @@ -54,7 +55,7 @@ export type RuntimeHostConstructionOptions = { graphName: string; writerId: string; gcPolicy?: GCPolicyConfig | GCPolicy; - checkpointPolicy?: { every: number }; + checkpointPolicy: CheckpointPolicy | null; autoMaterialize?: boolean; onDeleteWithData?: DeletePolicy; logger?: LoggerPort; @@ -89,7 +90,7 @@ export type RuntimeHostOpenOptions = { graphName: string; writerId: string; gcPolicy?: GCPolicyConfig | GCPolicy; - checkpointPolicy?: { every: number } | null; + checkpointPolicy?: CheckpointPolicyConfig | CheckpointPolicy | null; autoMaterialize?: boolean; onDeleteWithData?: DeletePolicy; logger?: LoggerPort; @@ -113,7 +114,7 @@ export class WarpOpenOptions { readonly graphName: string; readonly writerId: string; readonly gcPolicy: GCPolicyConfig | GCPolicy; - readonly checkpointPolicy?: { every: number }; + readonly checkpointPolicy: CheckpointPolicy | null; readonly autoMaterialize?: boolean; readonly onDeleteWithData?: DeletePolicy; readonly logger?: LoggerPort; @@ -148,10 +149,7 @@ export class WarpOpenOptions { if (options.codec !== undefined) { this.codec = options.codec; } if (options.trustCrypto !== undefined) { this.trustCrypto = options.trustCrypto; } - const checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy); - if (checkpointPolicy !== undefined) { - this.checkpointPolicy = checkpointPolicy; - } + this.checkpointPolicy = normalizeCheckpointPolicy(options.checkpointPolicy); if (options.autoMaterialize !== undefined) { this.autoMaterialize = normalizeBooleanOption( options.autoMaterialize, @@ -202,6 +200,18 @@ export class WarpOpenOptions { export type RuntimeHostOpenInput = RuntimeHostOpenOptions | WarpOpenOptions; +/** + * Auto-checkpoint cadence applied when a caller supplies no `checkpointPolicy`. + * + * `every` is compared against the replay depth reported by a materialize (the + * patch count since the last checkpoint), not against writes performed by the + * current process, so short-lived callers still compact once the backlog + * crosses the threshold. + * + * Pass `checkpointPolicy: null` to disable auto-checkpointing entirely. + */ +export const DEFAULT_CHECKPOINT_POLICY: CheckpointPolicy = CheckpointPolicy.DEFAULT; + function normalizeBooleanOption(value: boolean, label: string, code: string): boolean { if (typeof value !== 'boolean') { throw new WarpError(`${label} must be a boolean`, code); @@ -210,18 +220,20 @@ function normalizeBooleanOption(value: boolean, label: string, code: string): bo } function normalizeCheckpointPolicy( - checkpointPolicy: { every: number } | null | undefined, -): { every: number } | undefined { - if (checkpointPolicy === null || checkpointPolicy === undefined) { - return undefined; - } - if (typeof checkpointPolicy !== 'object') { - throw new WarpError('checkpointPolicy must be an object with { every: number }', 'E_CHECKPOINT_POLICY_TYPE'); + checkpointPolicy: CheckpointPolicyConfig | CheckpointPolicy | null | undefined, +): CheckpointPolicy | null { + // An omitted policy takes the default. Auto-checkpointing is what bounds + // replay depth, so leaving it off by default made every caller that never + // supplied a policy replay its entire patch history on each materialize, + // growing without limit. `null` remains the explicit opt-out for callers + // that genuinely want no compaction. + if (checkpointPolicy === undefined) { + return DEFAULT_CHECKPOINT_POLICY; } - if (!Number.isInteger(checkpointPolicy.every) || checkpointPolicy.every <= 0) { - throw new WarpError('checkpointPolicy.every must be a positive integer', 'E_CHECKPOINT_POLICY_EVERY'); + if (checkpointPolicy === null) { + return null; } - return Object.freeze({ every: checkpointPolicy.every }); + return CheckpointPolicy.from(checkpointPolicy); } function snapshotGCPolicy(value: GCPolicyConfig | GCPolicy | undefined): GCPolicyConfig | GCPolicy { @@ -371,7 +383,7 @@ export async function resolveRuntimeHostConstructionOptions( graphName, writerId, gcPolicy, - ...(checkpointPolicy !== undefined ? { checkpointPolicy } : {}), + checkpointPolicy, ...(autoMaterialize !== undefined ? { autoMaterialize } : {}), ...(onDeleteWithData !== undefined ? { onDeleteWithData } : {}), ...(logger !== undefined ? { logger } : {}), diff --git a/src/domain/warp/RuntimeHostProduct.ts b/src/domain/warp/RuntimeHostProduct.ts index 0045c0ceb..1807db5e6 100644 --- a/src/domain/warp/RuntimeHostProduct.ts +++ b/src/domain/warp/RuntimeHostProduct.ts @@ -38,6 +38,7 @@ import type { RuntimeHostOpenInput as RuntimeHostBootOpenInput, RuntimeHostOpenOptions as RuntimeHostBootOpenOptions, } from './RuntimeHostBoot.ts'; +import type CheckpointPolicy from './CheckpointPolicy.ts'; import { openRuntimeHost } from '../RuntimeHost.ts'; export type RuntimeCapabilitySurface = @@ -143,7 +144,7 @@ export type RuntimeHostProduct = RuntimeGraphHostProduct & { _patchesSinceGC: number; _patchesSinceCheckpoint: number; _maxObservedLamport: number; - _checkpointPolicy: { every: number } | null; + _checkpointPolicy: CheckpointPolicy | null; _autoMaterialize: boolean; _assetStorage: AssetStoragePort; _checkpointStore: CheckpointStorePort; diff --git a/test/unit/CheckpointPolicyChangelog.test.ts b/test/unit/CheckpointPolicyChangelog.test.ts new file mode 100644 index 000000000..1e2161b23 --- /dev/null +++ b/test/unit/CheckpointPolicyChangelog.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CHANGELOG_PATH = fileURLToPath( + new URL('../../CHANGELOG.md', import.meta.url), +); + +describe('checkpoint policy changelog', () => { + it('describes the default threshold as inclusive', () => { + const changelog = readFileSync(CHANGELOG_PATH, 'utf8'); + + expect(changelog).toContain( + 'once their replay depth reaches or exceeds 64 patches', + ); + }); +}); diff --git a/test/unit/domain/WarpGraph.checkpointPolicy.test.ts b/test/unit/domain/WarpGraph.checkpointPolicy.test.ts index b1c3669dd..658d2cd6f 100644 --- a/test/unit/domain/WarpGraph.checkpointPolicy.test.ts +++ b/test/unit/domain/WarpGraph.checkpointPolicy.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { openMemoryRuntimeHostProduct as openRuntimeHostProduct } from '../../helpers/MemoryRuntimeHost.ts'; import { createMockPersistence } from '../../helpers/warpGraphTestUtils.ts'; +import { DEFAULT_CHECKPOINT_POLICY } from '../../../src/domain/warp/RuntimeHostBoot.ts'; describe('WarpCore checkpointPolicy (AP/CKPT/1)', () => { it('stores checkpointPolicy when opened with { every: 500 }', async () => { @@ -25,14 +26,18 @@ describe('WarpCore checkpointPolicy (AP/CKPT/1)', () => { expect((graph)._checkpointPolicy).toEqual({ every: 1 }); }); - it('defaults _checkpointPolicy to null when not provided', async () => { + it('applies the default checkpoint policy when none is provided', async () => { const graph = await openRuntimeHostProduct({ persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', }); - expect((graph)._checkpointPolicy).toBeNull(); + // Opting in was the old contract, and it made unbounded replay growth the + // default: a caller that never supplied a policy accumulated every patch + // since its last explicit checkpoint forever, with reads paying for it. + expect((graph)._checkpointPolicy).toEqual(DEFAULT_CHECKPOINT_POLICY); + expect(DEFAULT_CHECKPOINT_POLICY.every).toBe(64); }); it('rejects every: 0', async () => { @@ -63,7 +68,10 @@ describe('WarpCore checkpointPolicy (AP/CKPT/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - checkpointPolicy: { every: ('foo' as any) }, + checkpointPolicy: { + // @ts-expect-error exercising runtime validation for JavaScript callers + every: 'foo', + }, }) ).rejects.toThrow('checkpointPolicy.every must be a positive integer'); }); @@ -85,7 +93,8 @@ describe('WarpCore checkpointPolicy (AP/CKPT/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - checkpointPolicy: ('auto' as any), + // @ts-expect-error exercising runtime validation for JavaScript callers + checkpointPolicy: 'auto', }) ).rejects.toThrow('checkpointPolicy must be an object with { every: number }'); }); @@ -95,9 +104,29 @@ describe('WarpCore checkpointPolicy (AP/CKPT/1)', () => { persistence: createMockPersistence(), graphName: 'test', writerId: 'writer-1', - checkpointPolicy: (null as any), + checkpointPolicy: null, }); expect((graph)._checkpointPolicy).toBeNull(); }); + + it('distinguishes an explicit null opt-out from an omitted policy', async () => { + const base = { + persistence: createMockPersistence(), + graphName: 'test', + writerId: 'writer-1', + }; + const optedOut = await openRuntimeHostProduct({ + ...base, + persistence: createMockPersistence(), + checkpointPolicy: null, + }); + const defaulted = await openRuntimeHostProduct({ + ...base, + persistence: createMockPersistence(), + }); + + expect((optedOut)._checkpointPolicy).toBeNull(); + expect((defaulted)._checkpointPolicy).toEqual(DEFAULT_CHECKPOINT_POLICY); + }); }); diff --git a/test/unit/domain/services/controllers/ForkController.checkpointPolicy.test.ts b/test/unit/domain/services/controllers/ForkController.checkpointPolicy.test.ts new file mode 100644 index 000000000..985dcc350 --- /dev/null +++ b/test/unit/domain/services/controllers/ForkController.checkpointPolicy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import InMemoryGraphAdapter from '../../../../helpers/InMemoryGraphAdapter.ts'; +import { openMemoryRuntimeHostProduct } from '../../../../helpers/MemoryRuntimeHost.ts'; + +describe('ForkController checkpoint policy', () => { + it('preserves an explicit null opt-out in the forked runtime', async () => { + const persistence = new InMemoryGraphAdapter(); + const runtime = await openMemoryRuntimeHostProduct({ + persistence, + graphName: 'fork-policy-parent', + writerId: 'writer-1', + checkpointPolicy: null, + }); + const at = await runtime.patch((patch) => { + patch.addNode('node:base'); + }); + + const fork = await runtime.fork({ + from: 'writer-1', + at, + forkName: 'fork-policy-child', + forkWriterId: 'writer-2', + }); + + expect(fork._checkpointPolicy).toBeNull(); + }); +}); diff --git a/test/unit/domain/services/controllers/ForkController.policy.test.ts b/test/unit/domain/services/controllers/ForkController.policy.test.ts new file mode 100644 index 000000000..4e7f823bc --- /dev/null +++ b/test/unit/domain/services/controllers/ForkController.policy.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const FORK_CONTROLLER_PATH = fileURLToPath( + new URL('../../../../../src/domain/services/controllers/ForkController.ts', import.meta.url), +); + +describe('ForkController source policy', () => { + it('narrows caught values without type assertions', () => { + const source = readFileSync(FORK_CONTROLLER_PATH, 'utf8'); + + expect(source.match(/\bas\s+Error\b/u)).toBeNull(); + }); +}); diff --git a/test/unit/domain/services/controllers/ForkController.validation.test.ts b/test/unit/domain/services/controllers/ForkController.validation.test.ts new file mode 100644 index 000000000..93639fbac --- /dev/null +++ b/test/unit/domain/services/controllers/ForkController.validation.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../../src/domain/utils/RefLayout.ts', async (importOriginal) => { + const actual = await importOriginal< + typeof import('../../../../../src/domain/utils/RefLayout.ts') + >(); + return { + ...actual, + validateGraphName: vi.fn(actual.validateGraphName), + validateWriterId: vi.fn(actual.validateWriterId), + }; +}); + +import ForkError from '../../../../../src/domain/errors/ForkError.ts'; +import { + validateGraphName, + validateWriterId, +} from '../../../../../src/domain/utils/RefLayout.ts'; +import InMemoryGraphAdapter from '../../../../helpers/InMemoryGraphAdapter.ts'; +import { openMemoryRuntimeHostProduct } from '../../../../helpers/MemoryRuntimeHost.ts'; + +describe('ForkController identity validation', () => { + it('returns a typed error for an invalid fork writer ID', async () => { + const persistence = new InMemoryGraphAdapter(); + const runtime = await openMemoryRuntimeHostProduct({ + persistence, + graphName: 'fork-validation', + writerId: 'writer-1', + }); + const at = await runtime.patch((patch) => { + patch.addNode('node:base'); + }); + const originalError = "Invalid writer ID: contains path traversal sequence '..': ../invalid"; + + await expect(runtime.fork({ + from: 'writer-1', + at, + forkName: 'valid-fork-name', + forkWriterId: '../invalid', + })).rejects.toMatchObject({ + name: ForkError.name, + code: 'E_FORK_WRITER_ID_INVALID', + message: `Invalid fork writer ID: ${originalError}`, + context: { forkWriterId: '../invalid', originalError }, + }); + }); + + it('preserves validator detail for an invalid fork name', async () => { + const persistence = new InMemoryGraphAdapter(); + const runtime = await openMemoryRuntimeHostProduct({ + persistence, + graphName: 'fork-validation', + writerId: 'writer-1', + }); + const at = await runtime.patch((patch) => { + patch.addNode('node:base'); + }); + const originalError = "Invalid graph name: contains path traversal sequence '..': ../invalid"; + + await expect(runtime.fork({ + from: 'writer-1', + at, + forkName: '../invalid', + forkWriterId: 'writer-2', + })).rejects.toMatchObject({ + name: ForkError.name, + code: 'E_FORK_NAME_INVALID', + message: `Invalid fork name: ${originalError}`, + context: { forkName: '../invalid', originalError }, + }); + }); + + it('rethrows a non-Error fork-name validation failure unchanged', async () => { + const runtime = await openMemoryRuntimeHostProduct({ + persistence: new InMemoryGraphAdapter(), + graphName: 'fork-validation', + writerId: 'writer-1', + }); + const at = await runtime.patch((patch) => { + patch.addNode('node:base'); + }); + const failure = 'non-error graph-name failure'; + vi.mocked(validateGraphName) + .mockImplementationOnce(() => { + throw failure; + }); + + await expect(runtime.fork({ + from: 'writer-1', + at, + forkName: 'valid-fork-name', + forkWriterId: 'writer-2', + })).rejects.toBe(failure); + }); + + it('rethrows a non-Error writer-ID validation failure unchanged', async () => { + const runtime = await openMemoryRuntimeHostProduct({ + persistence: new InMemoryGraphAdapter(), + graphName: 'fork-validation', + writerId: 'writer-1', + }); + const at = await runtime.patch((patch) => { + patch.addNode('node:base'); + }); + const failure = 'non-error writer-ID failure'; + vi.mocked(validateWriterId) + .mockImplementationOnce(() => { + throw failure; + }); + + await expect(runtime.fork({ + from: 'writer-1', + at, + forkName: 'valid-fork-name', + forkWriterId: 'writer-2', + })).rejects.toBe(failure); + }); +}); diff --git a/test/unit/domain/strandAndRuntimeSeams.test.ts b/test/unit/domain/strandAndRuntimeSeams.test.ts index 54bff2006..b05c0b689 100644 --- a/test/unit/domain/strandAndRuntimeSeams.test.ts +++ b/test/unit/domain/strandAndRuntimeSeams.test.ts @@ -125,6 +125,7 @@ describe('strand and runtime host seams', () => { audit: false, }); expect(options.trust).toEqual({ mode: 'off', pin: null }); + expect(options.checkpointPolicy).toBeNull(); }); it('delegates patch collection through a strict runtime host wrapper', async () => { diff --git a/test/unit/domain/warp/CheckpointPolicy.test.ts b/test/unit/domain/warp/CheckpointPolicy.test.ts new file mode 100644 index 000000000..7bab83240 --- /dev/null +++ b/test/unit/domain/warp/CheckpointPolicy.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import CheckpointPolicy from '../../../../src/domain/warp/CheckpointPolicy.ts'; +import { openMemoryRuntimeHostProduct } from '../../../helpers/MemoryRuntimeHost.ts'; +import InMemoryGraphAdapter from '../../../helpers/InMemoryGraphAdapter.ts'; + +describe('CheckpointPolicy', () => { + it('provides an immutable default cadence of exactly 64 patches', () => { + expect(CheckpointPolicy.DEFAULT).toBeInstanceOf(CheckpointPolicy); + expect(CheckpointPolicy.DEFAULT.every).toBe(64); + expect(Object.isFrozen(CheckpointPolicy.DEFAULT)).toBe(true); + }); + + it('constructs an immutable policy from boundary configuration', () => { + const config = { every: 5 }; + const policy = CheckpointPolicy.from(config); + + expect(policy).toBeInstanceOf(CheckpointPolicy); + expect(policy.every).toBe(5); + expect(policy).not.toBe(config); + expect(Object.isFrozen(policy)).toBe(true); + }); + + it('preserves an already-validated policy instance', () => { + expect(CheckpointPolicy.from(CheckpointPolicy.DEFAULT)).toBe(CheckpointPolicy.DEFAULT); + }); + + it.each([0, -1, 1.5])('rejects invalid cadence %s', (every) => { + expect(() => CheckpointPolicy.from({ every })).toThrow( + 'checkpointPolicy.every must be a positive integer', + ); + }); + + it('checkpoints at exactly the default threshold, but not before it', async () => { + const persistence = new InMemoryGraphAdapter(); + const graph = await openMemoryRuntimeHostProduct({ + persistence, + graphName: 'test', + writerId: 'writer-1', + autoMaterialize: false, + }); + for (let patchNumber = 1; patchNumber < 64; patchNumber += 1) { + await graph.patch((patch) => { + patch.addNode(`node:${patchNumber}`); + }); + } + const createCheckpoint = vi.spyOn(graph, 'createCheckpoint').mockResolvedValue('checkpoint-sha'); + + await graph.materialize(); + expect(createCheckpoint).not.toHaveBeenCalled(); + + await graph.patch((patch) => { + patch.addNode('node:64'); + }); + await graph.materialize(); + expect(createCheckpoint).toHaveBeenCalledOnce(); + }); +}); diff --git a/test/unit/domain/warp/CheckpointPolicySourcePolicy.test.ts b/test/unit/domain/warp/CheckpointPolicySourcePolicy.test.ts new file mode 100644 index 000000000..2e2b380de --- /dev/null +++ b/test/unit/domain/warp/CheckpointPolicySourcePolicy.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const CHECKPOINT_POLICY_TEST_PATH = fileURLToPath( + new URL('../WarpGraph.checkpointPolicy.test.ts', import.meta.url), +); +const RUNTIME_HOST_PRODUCT_PATH = fileURLToPath( + new URL('../../../../src/domain/warp/RuntimeHostProduct.ts', import.meta.url), +); + +describe('checkpoint policy test source', () => { + it('contains no any escape hatch', () => { + const source = readFileSync(CHECKPOINT_POLICY_TEST_PATH, 'utf8'); + + expect(source.match(/\bany\b/u)).toBeNull(); + }); + + it('pins the public default cadence to exactly 64 patches', () => { + const source = readFileSync(CHECKPOINT_POLICY_TEST_PATH, 'utf8'); + + expect(source).toContain('expect(DEFAULT_CHECKPOINT_POLICY.every).toBe(64)'); + }); + + it('does not expose the internal auto-checkpoint method on runtime products', () => { + const source = readFileSync(RUNTIME_HOST_PRODUCT_PATH, 'utf8'); + + expect(source).not.toContain('_tryAutoCheckpoint'); + }); +}); diff --git a/test/unit/domain/warp/WarpOpenOptions.test.ts b/test/unit/domain/warp/WarpOpenOptions.test.ts index fab104569..625d53f8f 100644 --- a/test/unit/domain/warp/WarpOpenOptions.test.ts +++ b/test/unit/domain/warp/WarpOpenOptions.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import { + DEFAULT_CHECKPOINT_POLICY, resolveRuntimeHostConstructionOptions, WarpOpenOptions, } from '../../../../src/domain/warp/RuntimeHostBoot.ts'; +import CheckpointPolicy from '../../../../src/domain/warp/CheckpointPolicy.ts'; import { openRuntimeHostProduct } from '../../../../src/domain/warp/RuntimeHostProduct.ts'; import defaultCodec from '../../../../src/infrastructure/codecs/CborCodec.ts'; import NodeCryptoAdapter from '../../../../src/infrastructure/adapters/NodeCryptoAdapter.ts'; @@ -26,7 +28,9 @@ describe('WarpOpenOptions', () => { expect(options.gcPolicy).toEqual({}); expect(options.codec).toBeUndefined(); expect(options.crypto).toBeUndefined(); - expect(options.checkpointPolicy).toBeUndefined(); + // Ports stay unresolved, but the checkpoint cadence is a policy rather than + // a port: omitting it must not silently disable compaction. + expect(options.checkpointPolicy).toEqual(DEFAULT_CHECKPOINT_POLICY); }); it('normalizes checkpointPolicy into a frozen value object', () => { @@ -39,6 +43,7 @@ describe('WarpOpenOptions', () => { }); expect(options.checkpointPolicy).toEqual({ every: 5 }); + expect(options.checkpointPolicy).toBeInstanceOf(CheckpointPolicy); expect(options.checkpointPolicy).not.toBe(checkpointPolicy); expect(Object.isFrozen(options.checkpointPolicy)).toBe(true); });