Live Mutation & Growable Views
Unlike Apache Arrow, FlatBuffers, or Cap’n Proto—where binary buffers are strictly read-only or cannot shrink/grow variable-length fields—capnstruct separates the developer-facing JS interface from the underlying binary arena mechanics.
1. Mutating Strings (str) & Slices (slice) on Any View
Section titled “1. Mutating Strings (str) & Slices (slice) on Any View”Every StructView and StructArrayView supports direct property assignment for both fixed-size primitives and variable-length str / slice(T) fields:
import { defineStruct, u32, str, slice, pack } from 'capnstruct';
const User = defineStruct('User', { id: u32, name: str, scores: slice(u32),});
const users = User.array( pack(User, [ { id: 1, name: 'Alexander', scores: [10, 20, 30, 40] }, { id: 2, name: 'Bob', scores: [50, 60] }, ]),);
// Case A: Shorter or equal-length UTF-8 string / slice// -> Overwrites existing tail bytes in-place in O(1) with ZERO allocations!users.at(0).name = 'Alex';users.at(0).scores = [100, 200];
// Case B: Longer UTF-8 string / slice// -> Automatically bump-allocates at the end of the tail arena (growing the// backing ArrayBuffer or WasmArena if needed) and updates [relOffset, len] in place!users.at(1).name = 'Bartholomew-Christopherson';users.at(1).scores = [1, 2, 3, 4, 5, 6, 7, 8];How It Works Under the Hood
Section titled “How It Works Under the Hood”Each str or slice(T) field stores an 8-byte descriptor [u32 relOffsetFromStructBase, u32 elementCount] in the fixed struct body:
- In-Place Overwrite ($O(1)$, 0 allocations): When
newByteLength <= oldByteLength,capnstructwrites the new UTF-8 bytes or slice elements directly into the existing tail slot, updateslenin the 8-byte descriptor, and records any leftover bytes (oldByteLength - newByteLength) in the view’s dead-byte counter. - Tail Arena Bump-Allocation: When
newByteLength > oldByteLength,capnstructbump-allocates a new aligned region at the end of the tail arena (automatically doubling the backingArrayBufferor growing theWasmArenaif capacity is exceeded), writes the new data there, and updates[relOffset, len]in the struct’s 8-byte descriptor.
2. Fragmentation Tracking & .compact()
Section titled “2. Fragmentation Tracking & .compact()”When variable-length fields are repeatedly overwritten with shorter or longer values, dead bytes accumulate in the tail arena. You can inspect the fragmentation ratio at any time via view.compactionRatio (0 = 0% dead bytes, 0.25 = 25% dead bytes) and strip dead tail bytes via view.compact():
console.log(users.compactionRatio); // e.g. 0.28 (28% dead tail bytes)
// Re-packs only live rows + live tail strings/slices and rebinds the view in-place:const tightWireBytes: Uint8Array = users.compact();
console.log(users.compactionRatio); // 0Even better: accessing users.bytes on a dirty variable-length array automatically compacts the buffer so you always send a clean, zero-waste Uint8Array over the network.
3. Growable StructArrayView (.push(), .pop(), .alloc())
Section titled “3. Growable StructArrayView (.push(), .pop(), .alloc())”You don’t need to build a giant JS array before calling pack(). Start with StructDef.alloc(0) (or any existing StructArrayView) and .push() / .pop() rows directly on the binary view:
import { defineStruct, u32, f64, str } from 'capnstruct';
const Metric = defineStruct('Metric', { ts: u32, val: f64, tag: str,});
// Start with an empty binary viewconst metrics = Metric.alloc(0);
// Append rows directly into binary memory (automatically doubles capacity as needed)metrics.push( { ts: 1000, val: 1.25, tag: 'cpu' }, { ts: 1005, val: 2.50, tag: 'mem' },);console.log(metrics.length); // 2
// Pop the last row (returns PlainStructValues<F> and reclaims fixed-struct capacity)const last = metrics.pop();console.log(last); // { ts: 1005, val: 2.5, tag: 'mem' }
// Export clean wire bytes ready for fetch / WebSocket / Worker postMessageconst payload: Uint8Array = metrics.bytes;