value-graph 0.1.0: JSON-free, bigint-free binary snapshot layer
CI / test (push) Successful in 20s
CI / publish (push) Has been skipped

- src/snapshot.js: compact portable wire (VGST store / VGGP graph), binary maps for
  params/pattern/objects (no JSON), buffer values (no JS bigint), CRC-32 guarded.
- createFileStore: binary per-key files (encodeEntryBytes/decodeEntryBytes).
- ValueGraph snapshot/snapshotFile/restore/loadFile; keys base64url(params-bytes).
- 60 tests (node:test + rigor incl. 'flip ANY byte never decodes silently').
This commit is contained in:
2026-08-04 17:37:00 -07:00
commit 1da6bc7842
12 changed files with 5228 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
/**
* @arbiter/value-graph
*
* The "second graph": a graph-structured, lazy, TTL + version-invalidated store
* of derived values, built on @push-stream-std. Queries are duplex streams
* (push `{ get }` up, receive values down); invalidation pushes `{ stale }`
* triggers down the graph and cascades to dependents. Backing store is
* swappable (in-memory default; `createFileStore` disk-backed for graphs
* larger than memory).
*
* Persistence & transport: `ValueGraph.snapshot()` / `snapshotFile(path)` /
* `ValueGraph.restore(buf)` / `ValueGraph.loadFile(path)` move a graph between
* processes and machines as a single compact, CRC-guarded binary file — schema
* metadata plus every entry (functions re-registered via `{ resolvers }`).
* Stores expose `snapshotFile`/`restoreFile` (`createFileStore` also accepts
* `{ loadFrom }`) for raw key->entry dumps.
*/
export { ValueGraph, createMapStore, createFileStore, owa, weightFor, validateValueType } from './value-graph.js';
export {
encodeSnapshot, decodeSnapshot, encodeGraphSnapshot, decodeGraphSnapshot,
encodeEntryBytes, decodeEntryBytes, encodeParams, decodeParams, crc32
} from './snapshot.js';
+532
View File
@@ -0,0 +1,532 @@
/**
* Compact, portable binary snapshots for @arbiter/value-graph.
*
* Two snapshot flavours share one value/entry wire format:
*
* STORE snapshot ('VGST') — an opaque dump of a backing store: key -> entry.
* Transportable between stores/processes/machines; the working per-key
* files of createFileStore are NOT portable, this is. Bounded-memory
* writing (record at a time) so a larger-than-memory graph can be moved.
*
* GRAPH snapshot ('VGGP') — schema (relation metadata, minus functions) +
* structured records { subject, relation, params, entry }. This is the
* seamless artifact: ValueGraph.restore(buf) / ValueGraph.loadFile(path)
* reconstruct a working graph (resolvers re-registered by the caller).
*
* DESIGN RULES (hard constraints, not preferences):
* - NO JSON anywhere on the wire. params/pattern/objects are encoded as
* deterministic BINARY MAPS (sorted keys, recursive value encoding) —
* JSON.stringify/parse is banned (slow, no zero-copy).
* - NO JS bigint. Large integers are Buffers/Uint8Array (tag 0x04, raw
* bytes) — JS bigints are not a value-graph type.
* - Everything is CRC-32 guarded and parse-guarded on load; a corrupt or
* truncated snapshot throws, it never silently mis-reads.
*
* Wire format (all integers little-endian, varints LEB128):
*
* HEADER: MAGIC(4 ASCII) VERSION(u8=1) FLAGS(u8)
* CREATED(f64 ms) [SCHEMA_COUNT(varint) ENTRY_COUNT(varint)]
* SCHEMA record (graph snapshots only): REL(varstr) OPERATOR(varstr)
* PARENTS(varint, varstr*) ATTR(varstr) PATTERN(value)
* WEIGHTS(varint, f64*) PRIORITIES(varint, f64*) CAPSUM(u8)
* HAS_TTL(u8[, f64]) RETURNTYPE(varstr) PARAMS(value)
* HAS_VALIDATE(u8)
* ENTRY record: KEY(varstr) [subject+relation+params for graph snapshots]
* VALUE(v) UNIT(u8 tag + payload) AT(f64) SOURCE(varstr)
* VERSION(varint) DEPS(varint, KEYVAR+VALUE*)
* TRAILER: CRC32(u32) over every byte before it.
*
* VALUE(v): tag u8 + payload:
* 0 null | 1 f64 | 2 string(varstr) | 3 bool(u8) | 4 buffer(bytes: varint
* len + raw) | 5 array(varint, v*) | 6 interval(v lower, v upper) |
* 7 object (binary map: varint count + sorted str-key + value per entry)
*
* Transportability: no paths, no machine-dependent floats beyond IEEE-754
* doubles, big integers as raw bytes, no JSON parsing.
*
* This module is fs-free; the store / ValueGraph do the file I/O, streaming
* record-at-a-time via the header/record encoders + snapshotCrc32().
*/
export const STORE_MAGIC = 'VGST';
export const GRAPH_MAGIC = 'VGGP';
export const SNAPSHOT_VERSION = 1;
// ─────────────────────────────────────────────────────────────────────────────
// CRC-32 (IEEE 802.3 / zlib polynomial) — integrity guard for every snapshot.
// ─────────────────────────────────────────────────────────────────────────────
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
export function crc32(bytes) {
let c = 0xFFFFFFFF;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/**
* Incremental CRC-32 for streaming writes: `update(chunk)` per chunk, `digest()`
* at the end. Keeps memory bounded for larger-than-memory snapshot files.
*/
export function snapshotCrc32() {
let c = 0xFFFFFFFF;
return {
update(bytes) {
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
},
digest() { return (c ^ 0xFFFFFFFF) >>> 0; }
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Writer / Reader — little-endian byte accumulation and parse.
// ─────────────────────────────────────────────────────────────────────────────
class Writer {
constructor(cap = 1024) {
this.buf = new Uint8Array(cap);
this.len = 0;
}
ensure(n) {
if (this.len + n <= this.buf.length) return;
let cap = this.buf.length * 2;
while (cap < this.len + n) cap *= 2;
const nb = new Uint8Array(cap);
nb.set(this.buf.subarray(0, this.len));
this.buf = nb;
}
u8(v) { this.ensure(1); this.buf[this.len++] = v & 0xff; return this; }
u32(v) {
this.ensure(4);
const dv = new DataView(this.buf.buffer);
dv.setUint32(this.len, v, true);
this.len += 4;
return this;
}
f64(v) {
this.ensure(8);
const dv = new DataView(this.buf.buffer);
dv.setFloat64(this.len, v, true);
this.len += 8;
return this;
}
varint(v) {
let n = v >>> 0;
while (n >= 0x80) { this.u8((n & 0x7f) | 0x80); n >>>= 7; }
this.u8(n);
return this;
}
bytes(b) {
this.varint(b.length);
this.ensure(b.length);
this.buf.set(b, this.len);
this.len += b.length;
return this;
}
// Append already-encoded bytes verbatim (records/headers are self-describing).
raw(b) {
this.ensure(b.length);
this.buf.set(b, this.len);
this.len += b.length;
return this;
}
str(s) { return this.bytes(Buffer.from(s, 'utf8')); }
toBytes() { return this.buf.subarray(0, this.len); }
}
class Reader {
constructor(buf) {
this.buf = buf;
this.dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
this.off = 0;
}
u8() { const v = this.dv.getUint8(this.off); this.off += 1; return v; }
u32() { const v = this.dv.getUint32(this.off, true); this.off += 4; return v; }
f64() { const v = this.dv.getFloat64(this.off, true); this.off += 8; return v; }
varint() {
let shift = 0;
let v = 0;
for (;;) {
const b = this.u8();
v |= (b & 0x7f) << shift;
if (!(b & 0x80)) return v >>> 0;
shift += 7;
if (shift > 28) throw new Error('value-graph: snapshot malformed (varint overflow)');
}
}
take(n) {
if (n > this.remaining()) throw new Error('value-graph: snapshot corrupt (length past end of buffer)');
const v = this.buf.subarray(this.off, this.off + n);
this.off += n;
return v;
}
bytes() { return this.take(this.varint()); }
str() { return Buffer.from(this.bytes()).toString('utf8'); }
remaining() { return this.buf.length - this.off; }
}
// ─────────────────────────────────────────────────────────────────────────────
// Binary map + params — the JSON-free encoding for objects (params, pattern,
// nested object values). Keys are sorted by UTF-8 so identical objects always
// encode to identical bytes (determinism: same params → same store key).
// ─────────────────────────────────────────────────────────────────────────────
function encodeMap(obj, w) {
const keys = Object.keys(obj).sort();
w.varint(keys.length);
for (const k of keys) { w.str(k); encodeValue(obj[k], w); }
}
function decodeMap(r) {
const count = r.varint();
if (count > r.remaining()) throw new Error('value-graph: snapshot corrupt (map count past end of buffer)');
const o = {};
for (let i = 0; i < count; i++) o[r.str()] = decodeValue(r);
return o;
}
/**
* Encode a params object to bytes for use as the key tail (JSON-free) — the
* same bytes every call for the same params, so the store key is stable.
*/
export function encodeParams(params) {
const w = new Writer();
encodeValue(params ?? {}, w);
return w.toBytes();
}
/** Reverse of encodeParams — decodes the key tail back to a params object. */
export function decodeParams(bytes) {
const r = new Reader(bytes);
const v = decodeValue(r);
return (v === null || v === undefined) ? {} : v;
}
// ─────────────────────────────────────────────────────────────────────────────
// Value encoding (recursive) — the stored `value` payload of every entry.
// ─────────────────────────────────────────────────────────────────────────────
export function encodeValue(v, w) {
if (v === null || v === undefined) { w.u8(0); return; }
const t = typeof v;
if (t === 'number') { w.u8(1); w.f64(v); return; }
if (t === 'string') { w.u8(2); w.str(v); return; }
if (t === 'boolean') { w.u8(3); w.u8(v ? 1 : 0); return; }
if (v instanceof Uint8Array) { w.u8(4); w.bytes(v); return; }
if (Array.isArray(v)) {
w.u8(5); w.varint(v.length);
for (const x of v) encodeValue(x, w);
return;
}
if (typeof v === 'object' && 'lower' in v && 'upper' in v) {
w.u8(6);
encodeValue(v.lower, w);
encodeValue(v.upper, w);
return;
}
// Plain object (params, pattern, nested objects): deterministic binary map.
w.u8(7);
encodeMap(v, w);
}
export function decodeValue(r) {
const tag = r.u8();
switch (tag) {
case 0: return null;
case 1: return r.f64();
case 2: return r.str();
case 3: return r.u8() === 1;
case 4: return r.bytes(); // Uint8Array view (zero-copy, no JSON, no bigint)
case 5: {
const n = r.varint();
if (n > r.remaining()) throw new Error('value-graph: snapshot corrupt (array count past end of buffer)');
const a = new Array(n);
for (let i = 0; i < n; i++) a[i] = decodeValue(r);
return a;
}
case 6: {
const lower = decodeValue(r);
const upper = decodeValue(r);
return { lower, upper };
}
case 7: return decodeMap(r);
default: throw new Error(`value-graph: snapshot unknown value tag ${tag}`);
}
}
function encodeUnit(unit, w) {
if (unit === null || unit === undefined) { w.u8(0); return; }
if (typeof unit === 'number') { w.u8(1); w.f64(unit); return; }
w.u8(2); w.str(String(unit));
}
function decodeUnit(r) {
const t = r.u8();
if (t === 0) return null;
if (t === 1) return r.f64();
return r.str();
}
// Entry payload shared by both snapshot flavours: the value, its unit, the
// wall-clock it was computed at, provenance, version stamp and deps metadata.
function encodeEntryPayload(entry, w) {
encodeValue(entry.value, w);
encodeUnit(entry.unit, w);
w.f64(typeof entry.at === 'number' ? entry.at : 0);
w.str(entry.source || '');
w.varint(typeof entry.version === 'number' ? entry.version : 0);
const deps = entry.deps || {};
const dk = Object.keys(deps).sort();
w.varint(dk.length);
for (const k of dk) { w.str(k); encodeValue(deps[k], w); }
}
function decodeEntryPayload(r) {
const value = decodeValue(r);
const unit = decodeUnit(r);
const at = r.f64();
const source = r.str();
const version = r.varint();
const depsCount = r.varint();
if (depsCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (deps count past end of buffer)');
const deps = {};
for (let i = 0; i < depsCount; i++) deps[r.str()] = decodeValue(r);
return { value, unit, at, source, version, deps };
}
/**
* Encode JUST the entry payload (no key) to bytes — the on-disk representation
* for createFileStore's binary per-key files (JSON-free).
*/
export function encodeEntryBytes(entry) {
const w = new Writer();
encodeEntryPayload(entry, w);
return w.toBytes();
}
/** Reverse of encodeEntryBytes — decode a bare entry payload. */
export function decodeEntryBytes(buffer) {
const r = new Reader(buffer);
return decodeEntryPayload(r);
}
// ─────────────────────────────────────────────────────────────────────────────
// Headers + record encoders — shared by the in-memory encoders and the
// streaming file writers in the store / ValueGraph.
// ─────────────────────────────────────────────────────────────────────────────
export function storeHeader(entryCount, { createdAt } = {}) {
const w = new Writer();
w.raw(Buffer.from(STORE_MAGIC, 'ascii'));
w.u8(SNAPSHOT_VERSION);
w.u8(0); // flags (unused)
w.f64(typeof createdAt === 'number' ? createdAt : Date.now());
w.varint(entryCount);
return w.toBytes();
}
export function graphHeader(schemaCount, entryCount, { createdAt } = {}) {
const w = new Writer();
w.raw(Buffer.from(GRAPH_MAGIC, 'ascii'));
w.u8(SNAPSHOT_VERSION);
w.u8(0); // flags (unused)
w.f64(typeof createdAt === 'number' ? createdAt : Date.now());
w.varint(schemaCount);
w.varint(entryCount);
return w.toBytes();
}
export function encodeStoreRecord(key, entry) {
const w = new Writer();
w.str(key);
encodeEntryPayload(entry, w);
return w.toBytes();
}
export function encodeSchemaRecord(rel, meta) {
const w = new Writer();
w.str(rel);
w.str(meta.operator || '');
const parents = meta.parents ? [...meta.parents] : [];
w.varint(parents.length);
for (const p of parents) w.str(p);
w.str(meta.attribute || '');
encodeValue(meta.pattern ?? null, w);
const weights = meta.weights || [];
w.varint(weights.length);
for (const x of weights) w.f64(x);
const priorities = meta.priorities || [];
w.varint(priorities.length);
for (const x of priorities) w.f64(x);
w.u8(meta.capSum ? 1 : 0);
w.u8(meta.ttl === undefined || meta.ttl === null ? 0 : 1);
if (meta.ttl !== undefined && meta.ttl !== null) w.f64(meta.ttl);
w.str(meta.returnType || '');
encodeValue(meta.params ?? null, w);
w.u8(meta.validate ? 1 : 0);
return w.toBytes();
}
export function encodeGraphRecord({ subject, relation, params, entry }) {
const w = new Writer();
w.str(subject);
w.str(relation);
encodeValue(params ?? {}, w);
encodeEntryPayload(entry, w);
return w.toBytes();
}
// ─────────────────────────────────────────────────────────────────────────────
// In-memory encoders (whole snapshot as one Buffer) and decoders (with
// integrity verification). File streaming lives in the store / ValueGraph.
// ─────────────────────────────────────────────────────────────────────────────
export function encodeSnapshot(entries, { createdAt } = {}) {
const list = [...entries];
const w = new Writer(4096);
w.raw(storeHeader(list.length, { createdAt }));
for (const [key, entry] of list) w.raw(encodeStoreRecord(key, entry));
const body = w.toBytes();
const tail = Buffer.alloc(4);
tail.writeUInt32LE(crc32(body));
return Buffer.concat([body, tail]);
}
export function encodeGraphSnapshot({ schema = [], records = [] } = {}, { createdAt } = {}) {
const slist = [...schema];
const rlist = [...records];
const w = new Writer(4096);
w.raw(graphHeader(slist.length, rlist.length, { createdAt }));
for (const [rel, meta] of slist) w.raw(encodeSchemaRecord(rel, meta));
for (const rec of rlist) w.raw(encodeGraphRecord(rec));
const body = w.toBytes();
const tail = Buffer.alloc(4);
tail.writeUInt32LE(crc32(body));
return Buffer.concat([body, tail]);
}
function verifyCrc(buffer, r) {
if (r.remaining() !== 4) throw new Error('value-graph: snapshot truncated (missing CRC)');
const stored = r.u32();
const actual = crc32(buffer.subarray(0, buffer.length - 4));
if (stored !== actual) throw new Error('value-graph: snapshot CRC mismatch (corrupt or truncated)');
}
function checkMagic(r, expected) {
const magic = Buffer.from(r.take(4)).toString('ascii');
if (magic !== expected) {
throw new Error(`value-graph: not a value-graph snapshot (magic '${magic}')`);
}
const version = r.u8();
if (version !== SNAPSHOT_VERSION) {
throw new Error(`value-graph: unsupported snapshot version ${version} (this build reads v${SNAPSHOT_VERSION})`);
}
}
export function decodeSnapshot(buffer) {
const r = new Reader(buffer);
checkMagic(r, STORE_MAGIC);
return decodeBody(buffer, r, () => {
r.u8(); // flags
const createdAt = r.f64();
const entryCount = r.varint();
if (entryCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (entry count past end of buffer)');
const entries = new Array(entryCount);
for (let i = 0; i < entryCount; i++) entries[i] = decodeStoreRecord(r);
verifyCrc(buffer, r);
return { createdAt, entries };
});
}
export function decodeGraphSnapshot(buffer) {
const r = new Reader(buffer);
checkMagic(r, GRAPH_MAGIC);
return decodeBody(buffer, r, () => {
r.u8(); // flags
const createdAt = r.f64();
const schemaCount = r.varint();
const entryCount = r.varint();
if (schemaCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (schema count past end of buffer)');
const schema = new Array(schemaCount);
for (let i = 0; i < schemaCount; i++) schema[i] = [r.str(), decodeSchema(r)];
if (entryCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (entry count past end of buffer)');
const records = new Array(entryCount);
for (let i = 0; i < entryCount; i++) records[i] = decodeGraphRecord(r);
verifyCrc(buffer, r);
return { createdAt, schema, records };
});
}
/**
* A malformed record (corrupt bytes, truncated file, bad version) throws a
* coherent `value-graph: snapshot corrupt (...)` error rather than leaking a
* low-level RangeError — snapshots are CRC-guarded AND parse-guarded. Already-
* prefixed errors (magic/version/CRC) pass through unchanged.
*/
function decodeBody(buffer, r, bodyFn) {
try {
return bodyFn();
} catch (e) {
if (e && typeof e.message === 'string' && e.message.startsWith('value-graph: snapshot')) throw e;
throw new Error(`value-graph: snapshot corrupt (${e && e.message ? e.message : e})`);
}
}
function decodeStoreRecord(r) {
const key = r.str();
return [key, decodeEntryPayload(r)];
}
function decodeGraphRecord(r) {
const subject = r.str();
const relation = r.str();
const params = decodeValue(r);
const entry = decodeEntryPayload(r);
return { subject, relation, params: (params === null || params === undefined) ? {} : params, entry };
}
function decodeSchema(r) {
const operator = r.str();
const parentCount = r.varint();
if (parentCount > r.remaining()) throw new Error('value-graph: snapshot corrupt (parent count past end of buffer)');
const parents = new Array(parentCount);
for (let i = 0; i < parentCount; i++) parents[i] = r.str();
const attribute = r.str() || null;
const pattern = decodeValue(r);
const weightCount = r.varint();
if (weightCount > r.remaining() / 8) throw new Error('value-graph: snapshot corrupt (weights count past end of buffer)');
const weights = new Array(weightCount);
for (let i = 0; i < weightCount; i++) weights[i] = r.f64();
const priorityCount = r.varint();
if (priorityCount > r.remaining() / 8) throw new Error('value-graph: snapshot corrupt (priorities count past end of buffer)');
const priorities = new Array(priorityCount);
for (let i = 0; i < priorityCount; i++) priorities[i] = r.f64();
const capSum = r.u8() === 1;
const hasTtl = r.u8() === 1;
const ttl = hasTtl ? r.f64() : undefined;
const returnType = r.str() || null;
const params = decodeValue(r);
const hasValidate = r.u8() === 1;
return {
operator,
parents,
attribute,
pattern: (pattern === undefined) ? null : pattern,
weights,
priorities,
capSum,
ttl,
returnType,
params: (params === null || params === undefined) ? null : params,
hasValidate
};
}
+1152
View File
File diff suppressed because it is too large Load Diff