Status: frozen for
1.x. The write path is canonical v4 only; the read path accepts v3 (legacy, read-only migration) + v4. Any persisted index restores or rejects with an actionable migration reason — never reads under the wrong format/profile.
Versioned binary persistence for DocumentIndex (encodeSnapshot /
index.serialize(), worker serialize()).
- Legacy v3 (read-only): 48-byte LE header, magic
0x55324433(LEGACY_SNAPSHOT_MAGIC), version 3 (LEGACY_SNAPSHOT_VERSION). Layout:header[48] + schema + tokens + offsets + docs. No columnar segment (columnarByteLengthreports0). - Canonical v4 (write path, 1.0): 56-byte LE header, magic
0x55324434(SNAPSHOT_MAGIC), version 4 (SNAPSHOT_FORMAT_VERSION, 8-byte aligned). Layout:header[56] + schema + tokens + offsets + columnar + docs. Header words: magic@0, version@4, profile@8, unicode@12, scoring@16, docCount@20, rowCount@24, tokenCount@28, normalized@32, schemaLen@36, docsLen@40, columnarLen@44, reserved(0)@48, CRC32@52 coveringheader[0..52) + schema + tokens + offsets + columnar + docs.
Magic bytes are frozen: old snapshots stay readable forever via the
migration read path. Only the exported constant names changed
(SNAPSHOT_MAGIC, SNAPSHOT_FORMAT_VERSION, SNAPSHOT_HEADER_BYTES;
legacy: LEGACY_SNAPSHOT_MAGIC, LEGACY_SNAPSHOT_VERSION,
LEGACY_SNAPSHOT_HEADER_BYTES). The old names (FORMAT_VERSION*,
U2D4_*, SERIALIZED_*) were removed — use DATASET_* /
SNAPSHOT_* / LEGACY_SNAPSHOT_*.
1.0 ships v4 as the canonical wire format. A storage-semantic change
(new magic, version, header layout, CRC coverage, MAX_SNAPSHOT_*
tightening that rejects previously valid snapshots, hookIds /
hasGetter semantics) requires a major version per
docs/public-api.md §7.
| Source | Writer | Reader (this release) | Result | Migration |
|---|---|---|---|---|
v3 legacy (48 B, 0x55324433) |
pre-v4 releases | decodeSnapshot / restoreSnapshot / DocumentIndex.fromSnapshot / fromSnapshotData / instance restore() / SearchWorkerClient.restore |
✅ Restores (migration read path). columnarByteLength: 0; authoritative filtering rebuilds via ColumnarStore.init(records) |
Re-serialize() to upgrade to v4; no host action unless filters/hooks were added (see §§4–5) |
v4 canonical (56 B, 0x55324434) |
encodeSnapshot / index.serialize() / worker serialize() (this release) |
Same read set | ✅ Restores; byte-length must equal header + schema + tokens + offsets + columnar + docs, CRC must match, shapes must validate |
None — round-trips identically |
v4 with decoupled: true (docsLen 0) |
serialize({ decoupled: true }) |
fromSnapshot(buf, { documents }) / IDB decoupled stores |
✅ Restores iff options.documents length matches header.docCount or embedded schema.docIds cover the docs; else IncompatibleIndexError |
Supply the external docs array or re-serialize embedded |
| v3/v4 with custom filter getters | Writer records hasGetter: true per field |
Reader without a matching getter override |
❌ IncompatibleIndexError (filter-getter:<name>) |
Re-restore with options.options.filterFields carrying the same getters |
v4 with hookIds |
Writer records declarative hookIds (never closures) |
Reader without matching options.options.hooks handlers (or mismatched hookId) |
❌ IncompatibleHookError |
Supply matching handlers (myFn.hookId = 'stable-v1') and re-restore |
v4 with mismatched caseSensitive / textProfile override |
Any writer | fromSnapshot(buf, { options: { caseSensitive } }) disagreeing with !header.normalized, or textProfile !== 'unicode-default'; instance restore() where live !normalized disagrees with snapshot |
❌ ProfileMismatchError (caseSensitive / textProfile) |
Rebuild the index under the snapshot polarity or re-restore without the override — never reinterpret tokens |
| Unknown magic / version / enum / non-zero reserved / truncated / CRC / shape / oversize | Forged, corrupt, or future-major bytes | decodeSnapshotHeader / decodeSnapshot |
❌ IncompatibleIndexError (fail-closed, no partial index) |
Rebuild from source records; future-major snapshots require a reader that knows the new version |
| v4 read by an older v3-only reader | This release | Pre-v4 host pinned to the legacy magic | ❌ IncompatibleIndexError on the old reader |
Upgrade the reader; v4 is the 1.0 write path |
Write path is canonical-only (encodeSnapshot, index.serialize(),
worker serialize()). Older readers accepting only the legacy magic throw
IncompatibleIndexError fail-closed.
| Category | Error | When | Actionable message carries |
|---|---|---|---|
| Version / magic / header width | IncompatibleIndexError |
Unknown magic, formatVersion !== 3|4, truncated < 48 B, v4 < 56 B, non-zero reserved, schema.caseSensitive !== !header.normalized |
expected = known magic/version/0/polarity, actual = offending word; hosts rebuild or upgrade the reader |
| Profile enums | IncompatibleIndexError |
Unknown profile / unicode / scoring enum in the header |
expected 'valid-enum', actual = bad enum; future profiles require a new major reader |
| Checksum | IncompatibleIndexError |
crc32Parts(...) mismatch (single-bit schema/columnar/offset/docs tamper) |
expected = header CRC, actual = recomputed; rebuild from source |
| Shape | IncompatibleIndexError |
Length mismatch, bad schema.fields / hookIds envelope, columnar field/row mismatch, stripped columnar on filtered non-empty index, non-monotonic offsets, docIds/records length mismatch, decoupled docs missing, oversize MAX_SNAPSHOT_* caps |
expected = schema/row/field contract, actual = offending value; fix the producer or drop the tampered bytes |
| Missing filter getter | IncompatibleIndexError |
Snapshot filterFields[].hasGetter: true without a matching getter override |
expected 'filter-getter:<name>'; supply options.options.filterFields with the getter |
| Missing / mismatched hooks | IncompatibleHookError |
Snapshot hookIds without matching options.options.hooks handlers, or hookId mismatch |
hookId + reason; supply the recorded handler (hookId, function.name, or 'anonymous') |
| Case / text profile | ProfileMismatchError |
caseSensitive override disagreeing with the snapshot (override === header.normalized), textProfile !== 'unicode-default', instance restore() where live polarity disagrees with snapshot polarity, prefixMatch.exactCase disagreement at query time |
property = 'caseSensitive' / 'textProfile' / 'prefixMatch.exactCase'; rebuild under one polarity instead of varying per query/restore |
AbortError never surfaces from decode/restore. Budget overruns throw
CostBudgetExceededError at query time, never as partial restores.
- Custom
filterFields[].getterclosures are never serialized. The snapshot persistshasGetter: trueper field; restore requires a matching getter override viaoptions.options.filterFieldsor throwsIncompatibleIndexErrorfail-closed (mirrors thehookIdsidiom). - Plain
doc[name]fields (hasGetterabsent/false) restore without overrides. - Columnar segment is advisory + integrity-checked; authoritative filtering
rebuilds via
ColumnarStore.init(records). A stripped empty segment on a filtered non-empty index throwsIncompatibleIndexError.
serialize()records only declarativeExtensionHookIds(stablehookIdproperty when set and non-blank, elsefunction.name, else'anonymous'); closures never cross the snapshot boundary.restore/fromSnapshot/fromSnapshotData/ instancerestore()require matching handlers viaoptions.options.hooksor throwIncompatibleHookError(seeassertHooksSatisfiedinhooks.ts). Restore-supplied handlers replace live hooks wholesale (not per-key merged).- Name-derived IDs can collide; assign explicit
hookId(myScorer.hookId = 'recency-v1') for any persisted hook. - Worker boundary:
SearchWorkerClientinit/search/restorereject real hooks withIncompatibleHookError(closures cannot be cloned; empty{}is a no-op).
decodeSnapshotrejects unknown magics/versions/enums, non-zero reserved, andschema.caseSensitive/header.normalizeddisagreement withIncompatibleIndexError— tampered or future-major bytes never decode.fromSnapshot/fromSnapshotDatareject acaseSensitiveoverride that disagrees with the snapshot polarity and anytextProfile !== 'unicode-default'withProfileMismatchError.- Instance
restore()additionally rejects when the live index polarity (!this.normalized) disagrees with the snapshot polarity (!header.normalized) — the retained token stream is never reinterpreted. getStats().formatVersionreports the live format (4) — what the index will emit on serialize — not the source snapshot version. Immediately after a legacy v3 restore it still reads 4 (pinned bytest:snapshot§10).
- Snapshots persist raw log docs unencrypted (origin-scoped). Logs may carry real PII in production — treat IDB contents accordingly.
MAX_SNAPSHOT_*caps enforce fail-closed bounds before decode (schema 16 MiB, columnar 64 MiB, docs 256 MiB, total 512 MiB, docCount 10M, tokenCount 256M). Oversize snapshots throwIncompatibleIndexErrorbefore anyTextDecoder/JSON.parse/sliceallocation.saveIndexToIDBserializes fully before opening the transaction; connections close infinallyfor future version migrations.
bun run bench:snapshot-matrixruns headless CPU only (preferGpu: false). Numbers are deterministic medians of 10 (serialize/restore also 10× sampled) withsamplespreserved in JSON. They are not comparable to browser/WebGPU runs.environmentin the JSON records runtime/platform.- CRC32 is corruption detection, not authenticity — forged snapshots with recomputed CRC are rejected only by shape/schema/offset validation.
bun run test:snapshot # §§1–17: canonical + legacy + columnar + getters + caps + hooks + worker + profile guards
bun run test:records # DocumentIndex engine + mutations + ProfileMismatch at search time
bun run bench:snapshot-matrix # headless CPU matrix (serialize/restore bytes + time)