Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: bitpacker-compressed (bitpacker-stub-completion)
**Package**: `@suaveplan/bitpacker-compressed`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: CompressedBitPacker subclass
The package SHALL export `CompressedBitPacker<T extends BitPackerSchema> extends BitPacker<T>`. The subclass SHALL run packed bytes through a configured `CompressionAdapter` before returning them, and SHALL run incoming bytes through `decompressData` before delegating to `BitPacker.unpack`. Compression behavior SHALL be configurable via `BitPackerConfig.compression` (`algorithm`, `options`, `enabled`); accessors `compressionAlgorithm`, `compressionOptions`, `compressionEnabled` SHALL surface and mutate the current settings.
#### Scenario: pack → decompress round-trip is byte-identical
- **WHEN** a `CompressedBitPacker` packs `{ a: 1, b: "hello" }` with `algorithm: "gzip"` and the result is decompressed and unpacked
- **THEN** the unpacked value deep-equals the input
#### Scenario: `enabled: false` skips compression entirely
- **WHEN** `compressionEnabled` is `false`
- **THEN** `pack` returns bytes byte-identical to `BitPacker.pack` (no compression header, no codec invocation)
#### Scenario: `algorithm: "none"` invokes NoCompressionAdapter
- **WHEN** `algorithm` is `"none"`
- **THEN** `pack` produces the same bytes as `BitPacker.pack` AND the OTel counter `bitpacker.compress.bytes_in` records the input length while `bitpacker.compress.bytes_out` records the same value
**Verified by:** `packages/bitpacker/bitpacker-compressed/src/compressed-bit-packer/compressed-bit-packer.test.ts::"pack-decompress round-trip is byte-identical for gzip"` AND `packages/bitpacker/bitpacker-compressed/src/compressed-bit-packer/compressed-bit-packer.test.ts::"compressionEnabled false skips codec invocation"` AND `packages/bitpacker/bitpacker-compressed/src/compressed-bit-packer/compressed-bit-packer.test.ts::"algorithm 'none' yields BitPacker.pack output"`
---
### Requirement: CompressionAdapter contract and built-in adapters
The package SHALL expose a `CompressionAdapter` interface with `compress(data, options): Promise<Uint8Array>` and `decompress(data, options): Promise<Uint8Array>`. The package SHALL ship `NoCompressionAdapter`, `GzipCompressionAdapter`, and `DeflateCompressionAdapter` matching upstream behavior (`compression.ts` L121-L336).
#### Scenario: gzip adapter round-trip
- **WHEN** `GzipCompressionAdapter` compresses 1 KiB of zeros, then decompresses
- **THEN** the output is the original 1 KiB AND the compressed length is < 50 bytes
#### Scenario: deflate adapter round-trip
- **WHEN** `DeflateCompressionAdapter` compresses random 1 KiB bytes, then decompresses
- **THEN** the decompressed output deep-equals the original
#### Scenario: NoCompressionAdapter passes bytes through
- **WHEN** `NoCompressionAdapter.compress(input)` is called
- **THEN** the result is byte-identical to the input
**Verified by:** `packages/bitpacker/bitpacker-compressed/src/compression-registry/compression-registry.test.ts::"GzipCompressionAdapter round-trip"` AND `packages/bitpacker/bitpacker-compressed/src/compression-registry/compression-registry.test.ts::"DeflateCompressionAdapter round-trip"` AND `packages/bitpacker/bitpacker-compressed/src/compression-registry/compression-registry.test.ts::"NoCompressionAdapter passes bytes through"`
---
### Requirement: CompressionRegistry replacement and unknown-algorithm error
The package SHALL expose a `CompressionRegistry` allowing callers to `register(algorithm, adapter)` and `resolve(algorithm)`. Resolving an unknown algorithm SHALL throw `BitpackerError` with code `BITPACKER.UNKNOWN_COMPRESSION_ALGORITHM` and `context.algorithm`.
#### Scenario: registering an adapter overrides the default
- **WHEN** a custom `BrotliCompressionAdapter` is registered under key `"brotli"` then `resolve("brotli")` is called
- **THEN** the registered adapter is returned; subsequent `pack` calls with `algorithm: "brotli"` route through it
#### Scenario: resolving an unknown algorithm throws coded error
- **WHEN** `resolve("zstd")` is called and no adapter is registered for that key
- **THEN** the call throws `BitpackerError` with code `BITPACKER.UNKNOWN_COMPRESSION_ALGORITHM` and `context.algorithm === "zstd"`
**Verified by:** `packages/bitpacker/bitpacker-compressed/src/compression-registry/compression-registry.spec-conformance.test.ts::"register overrides default adapter"` AND `packages/bitpacker/bitpacker-compressed/src/compression-registry/compression-registry.spec-conformance.test.ts::"resolve throws UNKNOWN_COMPRESSION_ALGORITHM for missing key"`
---
### Requirement: Compression failure surfaces coded error
When the underlying codec rejects (e.g. truncated gzip stream), the package SHALL wrap the cause and throw `BitpackerError` with code `BITPACKER.COMPRESSION_FAILED` (during compress) or `BITPACKER.DECOMPRESSION_FAILED` (during decompress); `context.algorithm` SHALL identify the codec.
#### Scenario: truncated gzip stream emits DECOMPRESSION_FAILED
- **WHEN** a half-truncated gzip-compressed buffer is passed to `decompressData(buf, "gzip")`
- **THEN** a `BitpackerError` with code `BITPACKER.DECOMPRESSION_FAILED` is thrown with the underlying codec error chained as `cause`
**Verified by:** `packages/bitpacker/bitpacker-compressed/src/compression-registry/compression-registry.spec-conformance.test.ts::"truncated gzip stream emits DECOMPRESSION_FAILED with chained cause"`
## MODIFIED Requirements
### Requirement: RLE pass-through helpers
The package SHALL keep `compressRle` / `decompressRle` as exported free functions for the bytes-only fast path; they SHALL be available both at the package root (one-release deprecation) and at the `./rle` subpath. Their behavior SHALL match the existing 61-line implementation byte-for-byte (no semantic change).
#### Scenario: existing RLE call sites still resolve from package root
- **WHEN** a consumer imports `{ compressRle } from "@suaveplan/bitpacker-compressed"`
- **THEN** the import resolves and `compressRle` produces the same bytes as before this change
#### Scenario: canonical import location is `./rle`
- **WHEN** a consumer imports `{ compressRle } from "@suaveplan/bitpacker-compressed/rle"`
- **THEN** the import resolves and the function is the same reference as the root export
**Verified by:** `packages/bitpacker/bitpacker-compressed/src/rle/rle.test.ts::"compressRle output unchanged from pre-rewrite snapshot"` AND `packages/bitpacker/bitpacker-compressed/src/rle/rle.test.ts::"./rle subpath and root export resolve to the same function"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,105 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: bitpacker-delta (bitpacker-stub-completion)
**Package**: `@suaveplan/bitpacker-delta`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Field-level change detection
The package SHALL expose a `DeltaManager` class that, given a current object, a `BitPackerSchema` describing it, and a previous state retrieved by state key, returns the ordered list of fields whose values changed since that previous state. Field order in the returned `FieldChange[]` SHALL match `Object.keys(schema)` order so encoders can rely on field indexes.
#### Scenario: first call returns empty changes
- **WHEN** `detectChanges` is called for a state key with no stored previous state
- **THEN** the result is an empty array AND the current state is stored under that key
#### Scenario: changed fields surface as ordered FieldChange[]
- **WHEN** the previous state had `{ a: 1, b: "x" }` and the current state is `{ a: 1, b: "y" }` under schema `{ a: "u8", b: "-32" }`
- **THEN** the result is `[{ index: 1, key: "b", previousValue: "x", currentValue: "y" }]`
#### Scenario: maxFieldCount cap enforced
- **WHEN** the schema has more entries than `DeltaConfig.maxFieldCount`
- **THEN** the manager throws `BitpackerError` with code `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED` and the offending field count in `context.fieldCount`
**Verified by:** `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.test.ts::"detects changed fields in schema order"` AND `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.test.ts::"returns empty array on first call for a state key"` AND `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.test.ts::"throws DELTA_FIELD_LIMIT_EXCEEDED when schema exceeds maxFieldCount"`
---
### Requirement: Configurable delta modes via DeltaConfig
The package SHALL accept a `DeltaConfig` object (`mode`, `xorNumbers`, `diffStrings`, `threshold`, `maxFieldCount`, `stateKey`, `equalityMode`) validated by Zod. Unknown fields and invalid `mode` values SHALL be rejected at construction time with `BitpackerError` code `BITPACKER.DELTA_INVALID_CONFIG`.
#### Scenario: unknown mode rejected
- **WHEN** `new DeltaManager({ mode: "magic" } as never)` is constructed
- **THEN** it throws `BitpackerError` with code `BITPACKER.DELTA_INVALID_CONFIG` and `context.mode === "magic"`
#### Scenario: `equalityMode: "json"` opts into stringify-based equality
- **WHEN** `equalityMode` is `"json"`
- **THEN** the manager uses `JSON.stringify` for equality (parity with upstream behavior)
#### Scenario: structural equality is the default
- **WHEN** `equalityMode` is omitted
- **THEN** equality is computed by walking the schema tree, not by `JSON.stringify`, so key-order differences in nested objects do not register as changes
**Verified by:** `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"rejects invalid DeltaConfig with DELTA_INVALID_CONFIG"` AND `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"uses structural equality by default"` AND `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"opts into JSON equality when equalityMode is 'json'"`
---
### Requirement: State storage with LRU eviction
The package SHALL expose a `DeltaStateManager` class that stores previous states keyed by string, deep-clones on store to prevent caller mutation, and evicts in insertion order when the configured `maxStates` (default 100) is exceeded.
#### Scenario: stored state is isolated from mutation
- **WHEN** a caller stores a state, then mutates the original object
- **THEN** `getPreviousState` returns the unmutated snapshot
#### Scenario: oldest state evicted at capacity
- **WHEN** `maxStates` is 3 and a 4th key is stored
- **THEN** the first key is evicted; `hasPreviousState(firstKey)` returns false
#### Scenario: re-store under existing key does not advance LRU position
- **WHEN** a key is stored, then re-stored after intervening writes
- **THEN** the key remains at its original LRU position (re-store updates value, not order — matches upstream behavior in `delta-state.ts` L22-L37)
**Verified by:** `packages/bitpacker/bitpacker-delta/src/delta-state/delta-state.test.ts::"deep-clones stored states to isolate from caller mutation"` AND `packages/bitpacker/bitpacker-delta/src/delta-state/delta-state.test.ts::"evicts oldest state when maxStates exceeded"` AND `packages/bitpacker/bitpacker-delta/src/delta-state/delta-state.test.ts::"re-store under existing key preserves LRU position"`
---
### Requirement: Deterministic clock injection
The package SHALL accept an optional `Clock` from `@suaveplan/types`; all timestamps recorded internally (e.g. for OTel histograms) SHALL come from the injected clock. The package SHALL NOT call `Date.now()` at module scope or inside hot paths.
#### Scenario: injected clock observed in OTel histogram
- **WHEN** a fake `Clock` advancing 7ms per `now()` call is injected and `detectChanges` is called twice
- **THEN** the OTel histogram `bitpacker.delta.changes_detected` records two samples whose timestamps differ by exactly the fake clock's delta
#### Scenario: no module-scope `Date.now()`
- **WHEN** `grep -r "Date\.now()" packages/bitpacker/bitpacker-delta/src/` is run
- **THEN** there are zero matches in implementation files
**Verified by:** `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"records OTel timestamps from injected Clock"` AND `packages/bitpacker/bitpacker-delta/src/delta-manager/delta-manager.spec-conformance.test.ts::"no module-scope Date.now in implementation files"`
## REMOVED Requirements
### Requirement: Inline 35-line DeltaManager in `index.ts`
**Reason:** Stub. Used `Date.now()` and `JSON.stringify` (CLAUDE.md determinism rule violation), tracked no field-level diffs, and held only one global previous state. Replaced wholesale by the `DeltaManager` + `DeltaStateManager` pair above.
**Migration:** Existing call sites doing `new DeltaManager()` continue to work — the new class accepts an optional config and clock. Call sites that read `manager.getPreviousState()` (no arg) should switch to `manager.getPreviousState(stateKey)`; the default `stateKey` is `"default"`.
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,110 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: bitpacker-network (bitpacker-stub-completion)
**Package**: `@suaveplan/bitpacker-network`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: NetworkSimulator class with full upstream feature surface
The package SHALL expose a `NetworkSimulator extends EventEmitter` class implementing bandwidth-limited delivery, configurable latency baseline + jitter, packet loss probability, packet duplication probability, packet corruption probability, in-flight reordering, burstiness scheduling, and random-disconnect scheduling. Public API matches upstream `network-simulation.ts` (L15-L593): `send(packet)`, `start()`, `stop()`, `disconnect(durationMs)`, `reconnect()`, `getStats()`, `clearStats()`, `setOptions(partial)`.
#### Scenario: bandwidth limit honoured across a 1-second window
- **WHEN** `bandwidthBytesPerSecond` is 1000 and 5000 bytes are submitted in 100ms
- **THEN** the simulator emits exactly 1000 bytes' worth of `delivered` events in the first second; the remaining 4000 bytes queue and deliver across the next 4 seconds
#### Scenario: latency + jitter inside configured bounds
- **WHEN** `latencyMs` is 50 and `jitterMs` is 10
- **THEN** every delivered packet's measured delivery delay is within `[40, 60]` ms
#### Scenario: packet-loss probability hits empirical bucket
- **WHEN** `lossProbability` is 0.25 and 1000 packets are sent through a deterministic `Rng`
- **THEN** the recorded `stats.packetsLost` falls within `[230, 270]` (25% ± 2.5 σ for n=1000)
#### Scenario: duplication, corruption, reordering surface as events
- **WHEN** the corresponding probabilities are non-zero
- **THEN** `duplicate`, `corrupt`, and `reorder` events fire with the affected `PacketInfo` payload AND `stats.packetsDuplicated` / `stats.packetsCorrupted` / `stats.packetsReordered` increment accordingly
#### Scenario: burstiness scheduler activates burst then ends it
- **WHEN** `enableBurstiness: true` with `burstIntervalMs: 100`, `burstDurationMs: 50`, `burstLossMultiplier: 4`
- **THEN** during the burst window, effective `lossProbability` is 4× the configured value, and after `burstDurationMs` it returns to baseline
#### Scenario: disconnect / reconnect lifecycle
- **WHEN** `disconnect(200)` is called
- **THEN** `connected` becomes false, all pending sends emit `dropped`, and after 200ms `connected` becomes true again with a `reconnected` event
**Verified by:** `packages/bitpacker/bitpacker-network/src/simulator/simulator.test.ts::"honours bandwidth limit across a 1-second window"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.test.ts::"applies latency + jitter inside configured bounds"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.test.ts::"loss probability hits empirical bucket over 1000 packets"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.test.ts::"emits duplicate / corrupt / reorder events with PacketInfo"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.test.ts::"burstiness scheduler activates and deactivates the loss multiplier"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.test.ts::"disconnect drops in-flight, reconnect restores delivery"`
---
### Requirement: NETWORK_CONDITIONS preset bindings
The package SHALL export a `NETWORK_CONDITIONS` map binding each of `IDEAL`, `HOME`, `MOBILE_4G`, `MOBILE_3G`, `SATELLITE`, `LOSSY_WIFI`, `CUSTOM` to a `NetworkSimulationOptions` object. The values SHALL match upstream constants byte-for-byte (cited in `proposal.md` source mapping).
#### Scenario: preset selection populates options
- **WHEN** a `NetworkSimulator` is constructed with `{ condition: "MOBILE_3G" }`
- **THEN** the resulting options match `NETWORK_CONDITIONS.MOBILE_3G` for `latencyMs`, `jitterMs`, `bandwidthBytesPerSecond`, `lossProbability`
#### Scenario: explicit fields override preset
- **WHEN** `{ condition: "MOBILE_3G", lossProbability: 0 }` is passed
- **THEN** the resulting options take `lossProbability: 0` and inherit the rest from the preset
**Verified by:** `packages/bitpacker/bitpacker-network/src/simulator/simulator.spec-conformance.test.ts::"NETWORK_CONDITIONS presets bind to options as documented"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.spec-conformance.test.ts::"explicit options override preset values"`
---
### Requirement: Deterministic Rng and Clock injection
The package SHALL accept an optional `Rng` and `Clock` from `@suaveplan/types`. Default `Rng` is the `crypto.getRandomValues`-backed `Rng.crypto()`. The package SHALL NOT call `Math.random()` or `Date.now()` at module scope or in production code paths.
#### Scenario: deterministic Rng yields reproducible loss sequence
- **WHEN** a seeded `Rng` is injected and the same 100-packet input is replayed
- **THEN** the set of dropped packet indexes is byte-identical between runs
#### Scenario: deterministic Clock advances burst windows
- **WHEN** a fake clock is injected and ticked manually
- **THEN** burst start / end fire at exactly the configured offsets
#### Scenario: no module-scope `Math.random()` or `Date.now()`
- **WHEN** `grep -E "Math\.random\(\)|Date\.now\(\)" packages/bitpacker/bitpacker-network/src/` is run
- **THEN** there are zero matches in implementation files
**Verified by:** `packages/bitpacker/bitpacker-network/src/simulator/simulator.spec-conformance.test.ts::"seeded Rng yields reproducible loss sequence"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.spec-conformance.test.ts::"fake Clock advances burst windows deterministically"` AND `packages/bitpacker/bitpacker-network/src/simulator/simulator.spec-conformance.test.ts::"no module-scope Math.random or Date.now in implementation files"`
---
### Requirement: Stat counters bounded; overflow signalled
The simulator SHALL track `packetsSent`, `packetsReceived`, `packetsLost`, `packetsDuplicated`, `packetsCorrupted`, `packetsReordered`, `bytesSent`, `bytesReceived`. When any counter would exceed `Number.MAX_SAFE_INTEGER`, the simulator SHALL emit `BitpackerError` with code `BITPACKER.NETWORK_STAT_OVERFLOW` and `context.counter` naming the affected field; the counter SHALL freeze at `MAX_SAFE_INTEGER`.
#### Scenario: stat overflow throws coded error
- **WHEN** `packetsSent` is one below `MAX_SAFE_INTEGER` and one more packet is sent
- **THEN** a `BitpackerError` with code `BITPACKER.NETWORK_STAT_OVERFLOW` is emitted as a `stat-overflow` event AND `stats.packetsSent === Number.MAX_SAFE_INTEGER`
**Verified by:** `packages/bitpacker/bitpacker-network/src/simulator/simulator.spec-conformance.test.ts::"emits NETWORK_STAT_OVERFLOW when a counter would exceed MAX_SAFE_INTEGER"`
## REMOVED Requirements
### Requirement: 48-line `Math.random()`-based stub at `bitpacker-network/src/index.ts`
**Reason:** Stub used module-scope `Math.random()` (CLAUDE.md determinism rule violation) and exposed only three free functions; the real 622-line implementation lived (misplaced) at `bitpacker-game/src/network/network-simulation.ts`.
**Migration:** The three existing free functions (`simulateLatency`, `shouldDropPacket`, `applyNetworkConditions`) are preserved as thin wrappers around the new simulator. Existing call sites compile unchanged. The misplaced copy at `bitpacker-game/src/network/network-simulation.ts` is deleted; a one-release shim re-exports the simulator from `@suaveplan/bitpacker-network` so any consumer importing it via the game package's deep path keeps compiling.
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,118 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: bitpacker-schema (bitpacker-stub-completion)
**Package**: `@suaveplan/bitpacker-schema`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: SchemaRegistryStorage interface
The package SHALL expose a `SchemaRegistryStorage` interface with the four async methods `saveSchema(entry)`, `loadSchema(namespace, name, version?)`, `listSchemas(namespace?, tag?)`, `deleteSchema(namespace, name, version?)`. The interface SHALL be the contract that every storage adapter implements; both adapters shipped in this change SHALL pin to the same `spec-conformance` test suite.
#### Scenario: contract pinned across adapters
- **WHEN** the same `spec-conformance` suite is run against `InMemorySchemaRegistryStorage` and `LocalStorageSchemaRegistryStorage`
- **THEN** every assertion passes for both
**Verified by:** `packages/bitpacker/bitpacker-schema/src/registry/registry.spec-conformance.test.ts::"InMemorySchemaRegistryStorage satisfies SchemaRegistryStorage contract"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.spec-conformance.test.ts::"LocalStorageSchemaRegistryStorage satisfies SchemaRegistryStorage contract"`
---
### Requirement: InMemorySchemaRegistryStorage adapter
The package SHALL ship `InMemorySchemaRegistryStorage` matching upstream `registry.ts` L13-L141: stores entries in a process-local array, treats `(namespace, name, version)` as the unique key, and updates in place when re-saving an existing triple. `loadSchema` without `version` SHALL return the entry whose `metadata.version` is highest. `listSchemas` SHALL filter by `namespace` AND `tag` when both are given. `deleteSchema` without `version` SHALL drop every entry matching `(namespace, name)`.
#### Scenario: re-save under existing triple updates in place
- **WHEN** an entry is saved twice with the same `(namespace, name, version)` and different schemas
- **THEN** `listSchemas()` returns one entry whose schema is the second value
#### Scenario: loadSchema without version returns highest version
- **WHEN** versions 1, 3, and 2 of `(ns: "game", name: "player")` exist
- **THEN** `loadSchema("game", "player")` returns version 3
#### Scenario: deleteSchema without version drops all versions
- **WHEN** `deleteSchema("game", "player")` is called with no version
- **THEN** every entry matching that namespace+name is removed; entries for other names remain
**Verified by:** `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"in-memory adapter: re-save under existing triple updates in place"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"in-memory adapter: loadSchema without version returns highest version"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"in-memory adapter: deleteSchema without version drops all versions"`
---
### Requirement: LocalStorageSchemaRegistryStorage adapter
The package SHALL ship `LocalStorageSchemaRegistryStorage` matching upstream `registry.ts` L146-L368. The constructor SHALL accept an optional `storageKey` (default `"bitpacker-schema-registry"`). The adapter SHALL serialize entries to JSON in `localStorage[storageKey]`; SHALL roundtrip `Date` fields (`createdAt` / `updatedAt`); SHALL be safe to construct in environments where `localStorage` is undefined (in that case all methods reject with `BitpackerError` code `BITPACKER.SCHEMA_STORAGE_UNAVAILABLE`).
#### Scenario: round-trip through localStorage preserves Date fields
- **WHEN** an entry with `metadata.createdAt: new Date("2026-01-01")` is saved, the adapter is recreated, and the entry is reloaded
- **THEN** the reloaded `createdAt` is a `Date` instance equal to the original
#### Scenario: missing localStorage emits coded error
- **WHEN** the adapter is constructed in a Node environment without `localStorage` and `saveSchema` is called
- **THEN** the call rejects with `BitpackerError` code `BITPACKER.SCHEMA_STORAGE_UNAVAILABLE`
**Verified by:** `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"localStorage adapter: round-trip preserves Date fields"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.spec-conformance.test.ts::"localStorage adapter rejects with SCHEMA_STORAGE_UNAVAILABLE when localStorage is undefined"`
---
### Requirement: Storage-backed SchemaRegistry
The package SHALL replace the legacy `Map`-based `SchemaRegistry` with a storage-backed class matching upstream `registry.ts` L372-L554. The class SHALL accept a `SchemaRegistryStorage` (default `InMemorySchemaRegistryStorage`) and expose async `registerSchema(schema, metadata)`, `registerVersionedSchema(versionedSchema, metadata)`, `loadSchema(namespace, name, version?)`, `listSchemas(namespace?, tag?)`, `deleteSchema(namespace, name, version?)`. `registerSchema` SHALL auto-increment version: existing entries return `existing.metadata.version + 1`; new entries start at 1. `createdAt` / `updatedAt` SHALL come from an injectable `Clock` (`@suaveplan/types::Clock`).
#### Scenario: registerSchema auto-increments version
- **WHEN** `registerSchema(schema, { namespace: "n", name: "x", description: "..." })` is called twice
- **THEN** the first returned entry has `metadata.version === 1`; the second has `metadata.version === 2`
#### Scenario: createdAt / updatedAt come from injected clock
- **WHEN** a `Clock` returning a fixed instant `T0` is injected and a schema is registered
- **THEN** the returned entry has `metadata.createdAt === T0` and `metadata.updatedAt === T0`
#### Scenario: missing schema emits SCHEMA_NOT_FOUND
- **WHEN** `loadSchema("ns", "missing", 5)` is called and no such entry exists
- **THEN** the call rejects with `BitpackerError` code `BITPACKER.SCHEMA_NOT_FOUND` with `context.namespace`, `context.name`, `context.version`
**Verified by:** `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"registerSchema auto-increments version"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.spec-conformance.test.ts::"createdAt and updatedAt come from injected Clock"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.spec-conformance.test.ts::"loadSchema rejects with SCHEMA_NOT_FOUND for unknown entry"`
---
### Requirement: Factory helpers
The package SHALL export `createInMemorySchemaRegistry()` and `createLocalStorageSchemaRegistry(storageKey?)` matching upstream `registry.ts` L560-L576. Both SHALL accept an optional `clock` for deterministic timestamping in tests.
#### Scenario: createInMemorySchemaRegistry returns a usable registry
- **WHEN** `createInMemorySchemaRegistry()` is called and `registerSchema` is invoked
- **THEN** the entry persists for the lifetime of the registry instance
#### Scenario: createLocalStorageSchemaRegistry honours custom storageKey
- **WHEN** `createLocalStorageSchemaRegistry("my-app")` is called and a schema is registered
- **THEN** `localStorage["my-app"]` contains the serialized entry
**Verified by:** `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"createInMemorySchemaRegistry returns a working registry"` AND `packages/bitpacker/bitpacker-schema/src/registry/registry.test.ts::"createLocalStorageSchemaRegistry honours custom storageKey"`
## MODIFIED Requirements
### Requirement: Legacy Map-based registry preserved as deprecated alias
The legacy 50-line `Map`-based `SchemaRegistry` SHALL be moved to `bitpacker-schema/src/legacy/legacy-registry.ts` and re-exported as `LegacySchemaRegistry` with a `@deprecated` JSDoc tag. The deprecation note SHALL specify removal in the next breaking release of `@suaveplan/bitpacker-schema`. Existing call sites importing it directly via `@suaveplan/bitpacker-schema/legacy` SHALL keep compiling.
#### Scenario: legacy alias retained for one release
- **WHEN** a consumer imports `{ LegacySchemaRegistry } from "@suaveplan/bitpacker-schema/legacy"`
- **THEN** the import resolves and the class behaves as the original 50-line `Map` wrapper (`register`, `resolve`, `unregister`, `has`, `names`, `clear`)
**Verified by:** `packages/bitpacker/bitpacker-schema/src/legacy/legacy-registry.test.ts::"LegacySchemaRegistry behaves as original Map wrapper"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,100 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: bitpacker-streaming (bitpacker-stub-completion)
**Package**: `@suaveplan/bitpacker-streaming`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: StreamingBitPacker class
The package SHALL export `StreamingBitPacker<T extends BitPackerSchema> extends EventEmitter` matching upstream `streaming.ts` (L14-L406). The class SHALL expose `packStream(data, options)` and `unpackStream(packed, options)`, both returning `Promise<Uint8Array>` (pack) / `Promise<SchemaTypeToDataType<T>>` (unpack). Both methods SHALL emit `progress` events as chunks complete and `chunk` events with the bytes of each completed chunk; chunk size SHALL come from `options.chunkSize` (default 1024).
#### Scenario: packStream round-trips through unpackStream
- **WHEN** a 100 KiB object is `packStream`-ed and the result is `unpackStream`-ed under the same schema
- **THEN** the unpacked value deep-equals the input
#### Scenario: progress events fire in chunk order
- **WHEN** `packStream` is called on data that produces 8 chunks
- **THEN** 8 `progress` events fire with `{ index, total: 8, percent }` in monotonically increasing order; the final event has `percent === 1`
#### Scenario: chunk events carry chunk bytes
- **WHEN** `packStream` runs with `emitProgress: true`
- **THEN** each `chunk` event's payload is a `Uint8Array` whose concatenation equals the final return value byte-for-byte
**Verified by:** `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"packStream / unpackStream round-trip preserves the value"` AND `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"emits progress events in monotonic chunk order"` AND `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"chunk events concatenate to the final byte output"`
---
### Requirement: chunkProcessor invoked per chunk
If `options.chunkProcessor` is supplied, the streaming bit-packer SHALL invoke it on each chunk before emitting / returning, awaiting the result. If the processor throws, `packStream` / `unpackStream` SHALL throw `BitpackerError` with code `BITPACKER.STREAM_CHUNK_PROCESSOR_FAILED`, chaining the original error.
#### Scenario: chunkProcessor mutates chunks before delivery
- **WHEN** `chunkProcessor` is `(chunk) => xor(chunk, 0xff)` and the produced bytes are XOR'd back before unpack
- **THEN** the round-trip succeeds
#### Scenario: chunkProcessor failure surfaces as coded error
- **WHEN** `chunkProcessor` throws on chunk 3
- **THEN** `packStream` rejects with `BitpackerError` code `BITPACKER.STREAM_CHUNK_PROCESSOR_FAILED` whose `cause` is the original error and `context.chunkIndex === 3`
**Verified by:** `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.spec-conformance.test.ts::"chunkProcessor mutates chunks before delivery"` AND `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.spec-conformance.test.ts::"chunkProcessor failure surfaces as STREAM_CHUNK_PROCESSOR_FAILED"`
---
### Requirement: validateChunks gates pack/unpack on validation
When `options.validateChunks` is `true`, the streaming bit-packer SHALL run the underlying `BitPacker.validate` over the input (pack) or unpacked output (unpack) and reject before emitting if validation fails, throwing `BitpackerError` with code `BITPACKER.STREAM_VALIDATION_FAILED`.
#### Scenario: validation gate rejects malformed input
- **WHEN** `validateChunks: true` and the input violates the schema (e.g. value out of `u8` range)
- **THEN** `packStream` rejects with `BitpackerError` code `BITPACKER.STREAM_VALIDATION_FAILED` AND no `chunk` events fire
#### Scenario: validation gate disabled by default
- **WHEN** `validateChunks` is omitted
- **THEN** packing proceeds without validation; encoder errors surface natively
**Verified by:** `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.spec-conformance.test.ts::"validateChunks: true rejects malformed input with STREAM_VALIDATION_FAILED"` AND `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.spec-conformance.test.ts::"validateChunks default is false"`
---
### Requirement: AbortSignal propagation
`packStream` and `unpackStream` SHALL accept `options.signal: AbortSignal`. When the signal is already aborted at call time, the call SHALL reject immediately with `BitpackerError` code `BITPACKER.STREAM_ABORTED`. When abort fires mid-stream, the in-flight chunk SHALL complete and then the call SHALL reject with the same code; no subsequent chunks SHALL be processed or emitted.
#### Scenario: pre-aborted signal rejects immediately
- **WHEN** `signal.aborted === true` at call time
- **THEN** `packStream` rejects synchronously with `BitpackerError` code `BITPACKER.STREAM_ABORTED`
#### Scenario: mid-stream abort halts further chunks
- **WHEN** `abort()` fires after the 2nd of 8 chunks completes
- **THEN** chunks 3..8 do NOT emit `chunk` events and the promise rejects with `BITPACKER.STREAM_ABORTED`
**Verified by:** `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"pre-aborted signal rejects immediately with STREAM_ABORTED"` AND `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"mid-stream abort halts further chunk emission"`
## MODIFIED Requirements
### Requirement: Synchronous chunked helpers delegate to StreamingBitPacker
The existing `packToChunks`, `unpackFromChunks`, and `unpackFromChunksAsync` functions SHALL be preserved with their current signatures, but their implementation SHALL delegate to `StreamingBitPacker` so behavior stays synchronized. The async helper SHALL honour `AbortSignal` (already true today; pin behavior in test).
#### Scenario: packToChunks output equals StreamingBitPacker.packStream concatenation
- **WHEN** the same data + schema is run through `packToChunks` and through `packStream` and the chunks of each are concatenated
- **THEN** the byte-arrays are equal
**Verified by:** `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"packToChunks delegates to packStream"` AND `packages/bitpacker/bitpacker-streaming/src/streaming-bit-packer/streaming-bit-packer.test.ts::"unpackFromChunksAsync honours AbortSignal"`
<!-- markdownlint-enable MD024 -->