kvstore: add experimental node:kvstore module #1
Open
dej611 wants to merge 4 commits into
Open
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds
node:kvstore, a new experimental core module: a NoSQL-stylekey-value store built as a pure-JavaScript layer over the existing
node:sqlitemodule. It provides anopenKv()factory returning aKVStoreinstance with synchronous
get,getMany,set,delete,clear,keys(prefix and range iteration),
watch(key-mutation reactive streams), andpublish/watch({ topic })(in-process pub/sub). No native code of itsown — it inherits
node:sqlite's platform support completely.The module is gated behind
--experimental-kvstore, defaulting to enabled(opt-out via
--no-experimental-kvstore) when SQLite is available, mirroringhow
node:sqlitebehaves sincenodejs/node#55890.
Why in core
node:sqliteestablished that Node ships a storage engine in core. Akey-value façade over it is the natural, minimal next step for the common
case where developers want "put a value under a key" rather than hand-rolled
SQL.
string < number < bigint < boolean,numerically correct — not lexicographic — within a type) and a rich-value
codec (
v8.serialize, coveringDate/Map/Set/BigInt/circularreferences) are correctness-sensitive and easy to get subtly wrong. Doing
it once in core is better than in every userland package that wants this.
The ordering bug was real and confirmed empirically before being fixed (see
Design decisions: N1 below).
Deno.openKv()demonstrates real developer demand forexactly this API shape on top of SQLite.
API at a glance
Key design decisions
Composite keys and codec
Keys are tuples of
string | number | bigint | booleanencoded as a binaryBLOB using an order-preserving encoding. This was the hardest correctness
problem: decimal-string encoding (
"n:2","n:10") causedkv.keys({ start: [1], end: [100] })to return[1, 10, 100]instead of[1, 2, 3, …, 100]— confirmed empirically before being fixed.Type-segment ordering is
string < number < bigint < boolean(a deliberatedivergence from Deno KV's ordering, documented in
doc/api/kvstore.md).Numbers use sign-flipped float64 encoding. Bigints use
[sign][length][BE magnitude], capped at 255 bytes (ERR_KVSTORE_BIGINT_TOO_LARGEbeyond).NaNis invalid;±Infinityare valid;-0normalizes to0.The property-test file (
test-kvstore-key-codec-property.js) checks threeinvariants across 3 000 random keys using an independently-written
reference comparator (not the codec's own logic), specifically mixing
ASCII, Latin-1, CJK, and astral-plane characters because UTF-16 and UTF-8
byte order diverge in exactly those cases.
Value codec
Values are serialized with
v8.serialize/v8.deserialize(the samealgorithm as
worker_threads.postMessage). This round-tripsDate,RegExp,Map,Set, typed arrays,BigInt,undefined, sparse arrays, and circularreferences losslessly. Class identity does not survive. The on-disk format is
V8-internal: stable across Node versions, not portable cross-runtime.
Error model
Errors are plain
Error/TypeError/RangeErrorinstances with a stable.code, followingfs/http/net/cryptoconvention. No error classes areexported. Two domain-specific codes are registered in
lib/internal/errors.js:ERR_KVSTORE_CLOSED— any operation afterkv.close()ERR_KVSTORE_BIGINT_TOO_LARGE— bigint segment exceeds 255-byte magnitudeAll other validation errors reuse existing Node generic codes
(
ERR_INVALID_ARG_TYPE,ERR_INVALID_ARG_VALUE,ERR_OUT_OF_RANGE,ERR_INVALID_STATE).Watch scope
key/keys/prefixwatchers only observe mutations made through thesame
KVStoreinstance in the same process. A file-backed store mutatedby a second
openKv({ path })call in the same process, or by anotherprocess, produces no watch events on this instance —
node:sqliteprovidesno cross-connection change feed. This limitation is documented in
doc/api/kvstore.mdand tested (seetest-kvstore.js).{ prefix: [] }— full store scanAn empty prefix is valid in
kv.keys({ prefix: [] })and matches every keyin the store. Key validation and prefix validation are separate code paths
(
encodeKeyvsencodePrefix) — the empty-prefix handling was a distinctbug that was fixed and explicitly regression-tested.
Concurrent iterators
keys()range/prefix scans use a pooled prepared-statement approach(
lib/internal/kvstore/statements.js): each call acquires a statement fromthe pool at iteration start and releases it on completion or break. Concurrent
and interleaved
keys()calls therefore never share a cursor. (An earliersingle-shared-statement design corrupted concurrent scans — see
SPEC-DECISIONS.mdN3 in the source repo.)
Security
v8.deserializeon user-controlled file paths. Every value read fromthe store — including via
kv.keys()range/prefix scans — passes throughv8.deserialize. Opening apathcontaining attacker-controlled bytes isequivalent to deserializing attacker-controlled V8 payloads.
doc/api/kvstore.mddocuments this under
kvstore.openKv(): "only open files you trust." Afuture PR could add a magic-byte/format-version header to reject foreign
database files fast, but that is a file-format change with migration
implications and was deliberately left out of this first PR.
All SQL is fully parameterized. The only interpolated identifier is the
compile-time constant table name (
kvstore_v1), never user input.Known limitations and deliberately deferred items
These are not regressions — they are documented 1.0 constraints, explicitly
tracked for follow-up:
null(versionstamp)kv.get()returnsvalue: nullfor both "no entry" and "storednull" — documented in the API reference; disambiguation deferred to a future PR that introduces aversionstampfieldatomic()/CAS — deferred to a follow-up PR; requires its own design passwatchBuffer/Uint8Arraywould remove one allocation per operation but requires rewriting thewatchpredicate layer (===/<comparisons,Set/Mapmembership). Left as a future PR.Open questions for reviewers
Error-code granularity. This PR maps broad validation categories to
existing generic codes (
ERR_INVALID_ARG_TYPE,ERR_INVALID_ARG_VALUE,ERR_OUT_OF_RANGE). An alternative would be finer-grained per-subsystemcodes (e.g.
ERR_KVSTORE_INVALID_SELECTOR,ERR_KVSTORE_INVALID_KEY)at the cost of more entries in
lib/internal/errors.js. Which directiondoes core prefer?
Sync-only API. All operations are synchronous, matching
DatabaseSync.Given
node:sqlite's own experience with sync-only as a starting point,is this acceptable for the initial experimental release, or should an
async variant be considered from the start?
watch()in-process scope. The current scope (same-instance-only) isa strict but honest v1 constraint. Is this an acceptable limitation for
Stability: 1, or a blocker that requires a path to cross-connectionchange feeds before shipping?
Feature scope.
versionstamp(to disambiguate absent from storednull) and atomic/CAS operations are both deferred. Does core want eitheron day one given how much harder they are to add post-stabilization?
Testing
test-kvstore.js): ~121 tests covering the full public API,including error paths, watch lifecycle, backpressure/lag, topic pub/sub,
persistence with a file-backed store, and cross-instance watch isolation.
test-kvstore-key-codec.js): example-based regression testspinning the specific ordering cases (including the
[1,10,100]vs[1,2,3,…,100]N1 bug), codec round-trips, and boundary values.test-kvstore-key-codec-property.js): 3 000random composite keys per run, three invariants (round-trip, order-preserving,
prefix containment), checked against an independently-written reference
comparator that cannot silently mirror a codec bug.
test-kvstore-statements.js): direct tests ofprepareDb()including a coverage gap not reachable through the public API(
getMany([])early-return).test-kvstore-module-flags.js): schemelessrequire('kvstore')→MODULE_NOT_FOUND;--no-experimental-kvstoredisables
require('node:kvstore').test-process-get-builtin.mjs):node:kvstoregated onhasKVStore.Benchmarks
benchmark/kvstore/contains four scenarios (set, get, getMany, range-scan),each using Node's standard
createBenchmark()API. For reference, theNoNoSQLite source repo's own benchmark (
bench/run.mjs) compares theseoperations against a hand-rolled
node:sqliteKV table using naiveJSON-text keys and values — the overhead of
node:kvstore's correctness layer(order-preserving binary keys, v8 codec, validation) is approximately
1.5–2.6× across scenarios on an in-memory store.
Documentation
doc/api/kvstore.mdcovers:KeyValuetype and key ordering rules (with per-type edge cases)KVStoreclass (blocking I/O warning with measured data: ~20µs/call on afile-backed store on a local SSD)
ESM and CJS
watch()event shapes, scope,eventsfilter, backpressure/lag contract,and migration note from the pre-1.0 positional form
openKv()options, storage/WAL sidecar notes, and the untrusted-filesecurity warning
.codeto base class and trigger conditionqueue, reactive audit log
Smoke test
After building:
Flag opt-out:
Schemeless access blocked: