npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fitfak/database

v2.3.0

Published

Encrypted binary database engine for Node.js with an mTLS gRPC server and client (part of the FITFAK stack)

Readme

@fitfak/database

An encrypted, binary-storage database engine for Node.js, plus an mTLS gRPC server and client for reaching it across a network. The engine itself uses only Node built-ins; the transport layer uses @fitfak/grpc, which does too.

Three layers, each usable on its own:

src/                    the engine — encrypted append-only storage, indexes, object store
src/grpc/               DatabaseService: server and client, mTLS-authenticated
src/provisioning/       how a service with no certificate gets one

require('@fitfak/database') in a CLI tool does not drag in an HTTP/2 server — the transport and provisioning modules load on first use.

src/
  snowflake.js            Snowflake ID generator (db ids, record ids, capability jti)
  rbac.js                 Bitmask permissions + per-database AccessControlList
  async-queue.js          Serializes writes per collection
  crypto-core.js          HKDF / AES-256-GCM / blind-index primitives
  key-provider.js         Pluggable KEK sources: ClientSecret, MLS export secret
  crypto-worker-pool.js   Size-gated worker_threads AES-GCM offload (measured, off by default)
  binary-codec.js         Schema-driven TLV codec — the "smaller than JSON" layer
  cache.js                Segmented LRU record cache
  sorted-index-segment.js SSTable-style on-disk sorted index
  secondary-index-store.js Bounded-memory memtable + tombstones over that segment
  storage-engine.js       Encrypted segments, primary/secondary/blind/range indexes, compaction
  change-stream.js        Ordered change events with gap detection — the alternative to polling
  schema-migration.js     Schema diffing: what may change, what must be rebuilt, what is refused
  version-watermark.js    Client-side rollback/replay detection
  database.js             Collection/Database, per-collection subkeys, watch(), snapshot()
  database-manager.js     create/open/close, manifest, session cache, O(1) key rewrap
  capability-token.js     kid+HMAC scoped, single-use, row-filterable tokens
  direct-protocol.js      Multiplexed wire framing for capability-token direct access
  direct-server.js        TCP/TLS listener authenticated purely by capability token
  encrypted-blob.js       Chunked AEAD for large object bytes (true streaming)
  object-store.js         S3-bucket-style file storage integrated with the engine
  object-http-server.js   HTTP server for public objects
  secret-store.js         Certificates, keys and tokens — versioned, expiry-queryable
  dns-store.js            Authoritative DNS zones + an always-current in-memory ZoneCache
  grpc/
    schemas.js            Wire schemas for DatabaseService
    server.js             DatabaseServer — every method mTLS-gated
    client.js             connectDatabase, RemoteCollection, WatchedCollection
    identity.js           client certificate → principal → per-database ACL
    record-codec.js       JSON/TLV payloads, int64-safe
  provisioning/
    enrollment-service.js Registration Authority: bootstrap and renewal endpoints
    enrollment-client.js  enrol → upgrade to mTLS → auto-renew
    attestor.js           who may enrol as what (pluggable per service)
    ca-backend.js         where certificates come from (@fitfak/ssl, ACME, custom)
    csr-provider.js       key generation and CSR construction

npm test runs thirteen suites; every file under test/ is an executable self-check.

Installing

The engine has no dependenciesnpm run test:engine (ten suites) runs from a bare checkout. The gRPC layer needs @fitfak/grpc, which is a sibling package in this stack rather than a third-party one; vendoring a second copy of it here would be worse than depending on it. Until it is published:

npm run link:grpc                     # side-by-side checkout at ../grpc
node scripts/link-grpc.js /path/to/grpc

@fitfak/ssl is an optional peer dependency, needed only by the @fitfak/ssl CA and CSR adapters. A deployment using ACME, or one whose certificates are managed entirely outside this process, never loads it.


1. Getting a client connected

The central question this package answers is not "how do I query the database" but "how does a service that has never run before get permission to". The data plane is mTLS-only, so a service needs a client certificate — and it cannot have one until something has vouched for it.

  1. bootstrap TLS      server proves who it is; client proves nothing yet
  2. fetch anchors      the CA bundle, so the client can verify the server from now on
  3. enrol              prove entitlement, submit a CSR, receive a certificate
  4. upgrade            same client object, now mutually authenticated
  5. steady state       everything else, mTLS only
  6. renew              over mTLS, well before expiry — no bootstrap credential involved

That is RFC 7030 (EST) semantics carried over gRPC. In this stack the CA is central (the identity provider at trust.fitfak.net), and this server is a Registration Authority, never a CA: it authenticates the bootstrapping peer and decides what identity it may hold, then delegates the signature. It holds no signing key.

Before step 1 there is a step 0. With admission configured, the server starts sealed: it wears an ephemeral self-signed certificate generated at boot, and it serves nobody — including the flow above — until the identity provider has handed it a server certificate, a key, and the trust anchors it will validate clients against.

The reason is that every service here takes its authority from the IdP, so a database reachable before the IdP is up is a database answering authorisation questions nobody can yet ask — and that gap is where a database grows a second identity system. A service that connects too early gets FAILED_PRECONDITION with a message saying so.

Nothing in this section changes for ordinary services. See docs/ZERO-TRUST.md for the state machine, the two-message handover, the threat model and the replay analysis, and examples/idp-bootstrap.js for a runnable walk-through.

Client side

const { enroll, connectDatabase, createFitfakSslCsrProvider } = require('@fitfak/database');

const identity = await enroll({
  target: 'https://db.internal.fitfak.net:8443',
  serviceName: 'idp-service',
  csrProvider: createFitfakSslCsrProvider(),
  trust: { pinnedFingerprints: [process.env.DB_CA_FINGERPRINT] },
  bootstrap: { secret: Buffer.from(process.env.ENROLMENT_SECRET, 'base64') },
  altNames: ['idp.internal.fitfak.net'],
});

identity.startAutoRenewal();          // renews at ~2/3 of the certificate's lifetime
const handle = await connectDatabase({ target, identity });

identity.client is already upgraded to mTLS. Renewal generates a fresh key pair rather than re-certifying the old one: renewing onto the same key would carry any past exposure forward for another full lifetime.

Server side

const { createDatabaseServer, createSharedSecretAttestor, createRenewalAttestor,
        createCompositeAttestor, createAcmeCaBackend } = require('@fitfak/database');

const server = createDatabaseServer({
  baseDir: '/var/lib/fitdb',
  principals: {
    'idp-service':  { roles: ['admin'] },
    'dns-resolver': { roles: ['reader'] },
  },
  enrollment: {
    caBackend: createAcmeCaBackend({ acme, trustAnchorsPem: [caPem] }),
    attestor: createCompositeAttestor([
      createSharedSecretAttestor({ enrolments: { 'idp-service': { secret, roles: ['admin'] } } }),
      createRenewalAttestor(),
    ]),
  },
});

server.listen(8443, { tls: { key, cert, ca, requestCert: true, rejectUnauthorized: false } });

requestCert: true, rejectUnauthorized: false is what lets one port serve both channels: every client is asked for a certificate, a client without one still completes the handshake, and the per-method security level does the rest. Enrolment declares minSecurityLevel: 'tls'; every DatabaseService method declares 'mtls'. The transport enforces this before the handler runs, so it cannot be forgotten in a handler.

The three ways to trust the server on first contact

Until a peer has a certificate it cannot authenticate itself, but it must still authenticate the server it is about to hand an enrolment credential to. enroll() refuses to proceed without one of:

| trust | What it means | |---|---| | { caPem } | The CA bundle was delivered out of band. Verified during the handshake. Preferred. | | { pinnedFingerprints } | Verified immediately after connect, against the leaf or a trust anchor. Pinning the CA survives a legitimate server-certificate rotation; pinning the leaf does not. | | { trustOnFirstUse: true } | Explicitly accepting an unverified first connection. Logged loudly. |

Trust anchors are fetched before verification completes — they are public data and the request carries no credential. Nothing secret is sent until verification has settled.

What the enrolment proof binds to

HMAC(secret, "fitdb-enroll-v1" ‖ serviceName ‖ nonce ‖ timestamp ‖ channelBinding ‖ SHA256(csr))

Each element closes a specific hole:

  • channelBinding — the RFC 9266 tls-exporter value. Without it, anyone who can observe the bootstrap exchange, including a proxy that terminates TLS, can replay the proof on their own connection. The test suite demonstrates the relay being rejected.
  • SHA256(csr) — without it, an interceptor can keep a valid proof and substitute a CSR over a key they control, and the CA will certify the attacker's key under the victim's name.
  • nonce — burned unconditionally, whether or not the enrolment succeeds.
  • timestamp — bounds how long a captured proof stays useful.

A bootstrap credential is single-use by default; renewal goes through the mTLS path, which needs no secret at all. A use is spent only once a certificate has actually been issued — an authenticated request rejected downstream (wrong CN, CA unreachable) leaves the credential usable, so a recoverable mistake does not become a re-provisioning job.

The CSR is checked against the grant on both CN and SAN. Checking only the CN would leave the field most TLS stacks actually match on unchecked.

Other services, other mechanisms

createSharedSecretAttestor is the bootstrap case. Once an IdP exists, createTokenAttestor delegates the decision to it; a device might present a TPM attestation; a workload might present a platform token. createCompositeAttestor runs several, so one endpoint serves a bootstrapping IdP, an IdP-issued workload token and an ordinary renewal.


2. Using the database

The remote API mirrors the embedded one, so code moves between them by changing how the handle is obtained and nothing else.

const db = await handle.openDatabase({ dbId, clientSecret });

await db.defineCollection('kullanicilar', {
  fields: [
    { no: 2, name: 'email',    type: 'string', blindIndex: true, required: true },
    { no: 3, name: 'tenant',   type: 'string', index: true },
    { no: 4, name: 'createdAt', type: 'int64', rangeBucket: { width: 86400000 }, diskBacked: true },
  ],
});

const users = db.collection('kullanicilar');
const id = await users.insert({ email: '[email protected]', tenant: 'core', createdAt: Date.now() });
await users.findOne('email', '[email protected]');
await users.findRange('createdAt', weekAgo, now);
for await (const record of users.scan()) { /* pages transparently */ }

clientSecret is returned by CreateDatabase exactly once and is never persisted server-side. Losing it means losing the data.

int64 and ids. JSON has no int64, and every Snowflake id is above 2^53, so JSON.parse would silently round them — insert a record, read it back, get a different id. Ids and int64 fields therefore cross the wire as strings and are coerced back by schema type. A JSON number that has already lost precision is rejected rather than stored corrupted.

Optimistic concurrency. update(id, patch, { expectedVersion }) returns ABORTED if the record moved. Without it, two concurrent read-modify-write callers silently lose one write.

Schema evolution

Redefining a collection with an extra field is a migration, not a no-op. (It used to be a no-op: the new field was absent from the in-memory schema, so the encoder had no tag for it, every value written to it was discarded, get() never returned it and find() threw "no such field" — with no error anywhere. Adding a field and redeploying is ordinary, so it has to work or fail loudly.)

await db.defineCollection('certificates', {
  fields: [...existing, { no: 14, name: 'skidHex', type: 'string', index: true }],
});
// → { migrated: true, indexesRebuilt: true, schemaVersion: 2, changes: [{ kind: 'add-field', ... }] }

Field number is identity — it is what is written to disk; the name only exists in the schema. So the rules follow from the wire format:

| Change | Result | |---|---| | new field number | applied. Older records simply lack it, as in protobuf | | index added/changed/removed | applied, index rebuilt from the stored records | | field renamed (same number) | applied — matching is by number, so this is a rename, not a drop plus an add | | type changed | refused. Every stored record carries bytes under that tag and would decode as the new type | | field removed | refused unless named in dropFields; its number is then reserved forever | | reserved number reused | refused. Records written while it was live still carry bytes under that tag |

The last one is the trap that arrives months late: a field is dropped, and someone later picks a number that looks free. The manifest records reserved per collection, so it is caught at definition time rather than surfacing as corrupted reads.

Whole registries — the shape a project's schemas.js already has — go through in one call, and are all-or-nothing: every collection is checked first, and one refusal blocks the set. A half-applied migration leaves the application facing a schema that is half old and half new.

await db.applySchemaRegistry(require('./schemas'));                 // embedded
await remoteDb.applySchemaRegistry(require('./schemas'));           // over gRPC
db.inspectMigration('certificates', fields);                        // dry run, no writes
await remoteDb.defineCollection('certificates', { fields, dryRun: true });

inspectMigration / dryRun exist for a deploy-time check: a refused migration is far better found by a pipeline than by the first write after a rollout.

Binary payloads. After describe(), a client can send payloadBin — the collection's own TLV encoding — instead of payloadJson: same method, roughly 2.5x less on the wire.

Existing clients

DatabaseService is wire-compatible with the hand-written schema map already deployed in the IdP's grpc-db-adapter: OpenDatabase, InsertRecord, UpdateRecord, DeleteRecord and FindRecord keep their field numbers, and the fields added since (collections, sessionExpiresAt, payloadBin, expectedVersion) sit at numbers that adapter does not declare, so proto3 skips them. find('_id', id) and find('*', '') are special-cased so the adapter's get() and scan() work unchanged. test/adapter-compat-demo.js pins this by running that adapter's exact schema map against the server.

The one change an existing adapter does need is transport-level: the data plane is mTLS-only, so { rejectUnauthorized: false } with no client certificate is no longer sufficient. Pass real credentials, or enrol for them.


3. Not polling

Two workloads motivated the change stream, and both are unworkable with per-read decryption:

  • DNS, answered at query rate. Every answer would be a round trip plus a decrypt, for data that changed hours ago.
  • Certificates and keys, where a stale cache means a service is still presenting a certificate that was rotated away.
const view = users.watch();
await view.ready();

view.get(id);     // map lookup, no network, no decrypt
view.all();
view.fresh;       // false when the stream reported a gap

Correctness rests on two things the server provides. Every event carries a per-collection sequence number as well as a global one — a watcher following one collection must check contiguity on the per-collection counter, because writes to other collections consume global numbers and would look like gaps. And when the server can no longer prove continuity it sends an explicit RESET rather than a stream with a hole in it.

Database#snapshot() reads its sequence counters before scanning, not after. Reading after would let a write that landed mid-scan be both missed by the scan and covered by the returned counter — the watcher would discard its event as already-applied and serve a view missing that record forever. Reading first can only cause the harmless opposite: a re-applied change.

Reconnection uses full jitter. A fixed retry interval turns a server restart into a synchronised stampede from every watcher at once.

The opening snapshot is chunked (watchSnapshotChunkSize, 500 records per message) and the client applies it as one atomic load when the last chunk lands, so a view is never briefly a partial copy. One message per snapshot worked only up to the transport's 4 MiB ceiling, past which the write failed, the stream died, and the watcher reconnected and asked for the same snapshot again — a loop that re-decrypted the whole collection on every pass and never finished.


4. Secrets: certificates and keys

const vault = await SecretStore.open(db);

await vault.putCertificate({ name: 'api.fitfak.net', certPem, privateKeyPem, chainPem });
const pair = await vault.getCertificatePair('api.fitfak.net');   // ready for a TLS context

await vault.listExpiring(30 * 86400000);   // what needs renewing

A private key written into a collection is encrypted at rest with the same construction as every other record — a copy of the data directory is useless without the root secret. A PEM file in /etc is not. This is not an HSM: material is decrypted into process memory whenever it is read, exactly like every other record. If the threat model includes a memory dump of a live process, the answer is a TPM/HSM-backed signer that never releases the key, not a different database.

Expiry and fingerprint are read out of the certificate rather than taken from the caller — an expiry sweep that depends on a caller remembering to pass notAfter is a sweep that misses things.

Rotation is a transition, not a cutover. Versions are never overwritten. A new version can be staged as PENDING and promoted later; the superseded version is RETIRED, not deleted, so a consumer that has not yet reloaded still has something real to verify against. markCompromised() marks and records a reason but does not erase — incident response needs to know what was exposed — and it is the local half of a revocation only; revoking at the CA is the CA's job.

Browsing goes through kind (a plain index), never through name (blind-indexed). That asymmetry is deliberate: the set of secret kinds in use is not sensitive, the set of secret names is.


5. DNS

const dns = await DnsStore.open(db);
await dns.put({ zone: 'internal.fitfak.net', name: 'db.internal.fitfak.net',
                type: 'A', ttl: 300, rdata: { address: '10.0.0.10' } });

const cache = await dns.zoneCache('internal.fitfak.net');
cache.query('anything.apps.internal.fitfak.net', 'A');
// → { status: 'NOERROR', records: [...], chain: [] }

resolve() does an exact (name, type) lookup through the blind index — correct, but one decrypt per call. zoneCache() is the intended way to serve traffic: one snapshot, then the change stream.

Everything a resolver needs that a blind index cannot provide happens against the decrypted zone, because all of it requires knowing what is absent, and an index that cannot enumerate can never establish absence:

  • NODATA vs NXDOMAIN. A missing type at an existing name is NOERROR with no records. Answering NXDOMAIN there tells the world the name does not exist and breaks every other type at it.
  • Wildcards, per RFC 4592 §3.3.1 via the closest encloser. *.apps.example.com answers for a.b.apps.example.com — at any depth, not just one label — unless something between them exists as a node. Given a real sub.apps.example.com, a query for x.sub.apps.example.com is NXDOMAIN, not the wildcard's address. The naive "walk up trying *.<suffix>" version gets this wrong, and getting it wrong means a wildcard silently capturing traffic meant for a real subtree. Empty non-terminals are tracked for exactly this reason.
  • CNAME chains, including referrals out of the zone.

putRRset() replaces a record set wholesale, which is the only way to avoid leaving a stale member in a round-robin A set. A record outside its zone is refused rather than stored: it would be present in the database, absent from DNS, and very hard to notice.

cache.fresh is load-bearing. When the change stream reports a gap the cache stops claiming to be authoritative, and a resolver should answer SERVFAIL rather than serve a set it can no longer vouch for.


6. Identity and authorization

The client certificate is the identity. createPrincipalResolver maps it to a principal; the per-database ACL decides what that principal may do. Certificates and grants are separate on purpose — adding a service should not require re-issuing anyone's certificate.

createPrincipalResolver({
  subjectField: 'CN',
  principals: { 'idp-service': { roles: ['admin'] } },   // also the allow-list
  pinnedIssuers: ['fitfak Issuing CA'],
})

Verified vs. presented certificates. On a listener that accepts unauthenticated clients, a peer can present a certificate signed by any CA — including its own — and Node returns it from getPeerCertificate() exactly as it returns a valid one. call.peer.certificate is populated only when the chain validated, so a handler reading commonName from it can never get an attacker-chosen string. The unverified form is available as peer.presentedCertificate, under a name that cannot be mistaken for a trusted one.

Revocation at this layer is the allow-list. Removing a principal locks that certificate out on its next connection however long it stays cryptographically valid. That is a real mechanism for a closed set of known services and an inadequate one for anything larger, where the CA's own CRL/OCSP infrastructure is what you want in front of this.

Capability tokens still work for the direct-access path and may only ever narrow authority: a token wider than the issuer's own grant is refused, so ISSUE_CAPABILITY is a delegation primitive rather than an escalation one.

ADMIN implies every other permission, in both places authorisation is decided: the ACL's own can() and requirePermission(). maskFor() reports the grant exactly as stored, which is what GrantAccess/RevokeAccess return; effectiveMaskFor() reports it with ADMIN expanded, which is what every gate uses.

await db.grantAccess('smtp-service', DB_PERMISSIONS.ADMIN);
// smtp-service can now open, read, write and migrate this database.

6a. Several clients on one server

One server process, several services, all of them connected and writing at once, is the normal deployment — see bin/db-server.js for a complete one and npm run test:multi-client for the properties it relies on.

Everything below is enforced by the engine, not by convention:

  • Concurrent opens converge on one handle. Requests arriving while a database is still opening share that open instead of each starting their own. Two handles over one directory each track their own segment offsets, so writes through the loser record locations that do not match the file and reads come back as AEAD authentication failures — and the orphaned handles hold file descriptors that no close() can reach, so a busy server eventually hits its descriptor limit and stops answering every client at once.
  • Key material is re-checked on every open, including when the database is already open for someone else. Being on the ACL is not enough to attach without the clientSecret.
  • Schema changes are serialized per collection. Two clients applying the same migration simultaneously cannot overlap an index rebuild, and each is told what its own call did rather than what the other one's did.
  • ACL grants and manifest writes are serialized per database and published by atomic rename, so a grant cannot be lost to a concurrent schema change.
  • Writes to a collection are already serialized by the storage engine's write queue, and insertUnique performs its check and its append inside one queued step — the check-then-insert spelling of that does not hold when two clients race for the same key.
  • Ids stay unique across a backwards clock step. These are primary keys, and a repeated one overwrites rather than fails.

The per-database ACL is what keeps services apart: a principal that is admin at the server level still reaches only the databases its own ACL entry names.


7. Key hierarchy

root secret                         (clientSecret, OR an MLS group's exportSecret())
  --KeyProvider.deriveKek()-->      KEK
    --HKDF(KEK, 'fitdb-manifest')-> manifestKey    (decrypts manifest.bin: schema + ACL + wrappedDDK)
    --AES-256-GCM unwrap(KEK)-->    DDK             (Database Data Key, 32 random bytes)
      --HKDF(DDK, 'collection:X')-> per-collection key
        --HKDF(DDK,'blind:field')-> per-field blind-index key (HMAC only, never encrypts)

The server persists only wrappedDDK. KEK derivation is delegated entirely to a KeyProvider the storage layer never sees the internals of, which is what lets key management evolve without touching storage: ClientSecretKeyProvider for a single credential-holder, MlsExportSecretKeyProvider for an RFC 9420 group. rewrapDatabaseKey() gives O(1) rotation — re-wrap the same DDK under a new KEK, zero record I/O.

Honest caveat. "The server cannot decrypt" is true of data at rest: a copy of the disk is useless without the root secret. During a live session the DDK necessarily sits in RAM so the engine can encode, decode and index. That is inherent to any queryable encrypted store, not specific to this design — a memory dump of a live process is a different threat from disk theft, and this system does not claim to defend against the former.


8. Storage format

Records are TLV-encoded (binary-codec.js, protobuf-style field-number tags and varints) before encryption — roughly 2.5x smaller than the equivalent JSON, and unknown field numbers are skipped rather than erroring, so schemas can gain fields without invalidating stored records.

[op:1][flags:1][id:8 BE][version:4 BE][payloadLen:4 BE][payload = iv(12)+tag(16)+ciphertext]

payload is AES-256-GCM over the (optionally deflated) record with AAD = id ‖ op ‖ flags ‖ version. Binding version into the AAD is what makes the monotonic per-id counter tamper-evident: a storage node cannot serve an older but perfectly authentic frame without a client tracking watermarks (version-watermark.js) detecting the regression. AEAD proves a record is genuine, not that it is the latest.

Recovery: a binary index.snapshot means a clean reopen replays nothing; an unclean shutdown replays only the tail past the snapshot. Compaction copies live frames byte-for-byte — no decrypt/re-encrypt, since the AAD binds nothing that relocation changes.


9. Indexes, and what each one leaks

| Kind | Query | What the server learns | |---|---|---| | index: true | equality, enumerable | the plaintext value | | blindIndex: true | equality with an already-known value | which records share a value | | rangeBucket: { width } | range | which bucket (e.g. which day), not the value or cross-bucket order | | diskBacked: true + index | ordered range scan | the real order of every indexed value |

A blind index answers exactly one question: "does a record with this exact value exist". It cannot enumerate — doing so would mean testing every possible value, which is precisely the plaintext-searchable structure blind indexing exists to avoid. Anything meant to be browsable belongs on a plain index or a scan. Both object-store.js and secret-store.js are shaped around this: ownerId/kind are plain, originalFileName/name are blind.

Range queries are bucketed blind indexing, explicitly not order-preserving encryption. OPE is a research-grade primitive that is easy to implement in a way that looks fine and leaks far more than intended; bucketing's leakage is explicit and tunable by width.

diskBacked: true moves an index into an SSTable-style segment with a bounded memtable — measured at ~200 resident entries and ~38 sparse-index blocks for a 5000-record field, versus 5000 for a plain Map. Deliberately one on-disk segment, fully rewritten per flush: a much smaller correctness surface than leveled compaction, at the cost of O(total) flush I/O.


10. Logging: what it says, and where

const { createLogger, createDatabaseServer } = require('@fitfak/database');
const log = createLogger('db');
createDatabaseServer({ baseDir, logger: log, /* … */ });

Level comes from FITDB_LOG_LEVEL (TRACE/DEBUG/INFO/WARN/ERROR), DEBUG by default; FITDB_LOG_JSON=1 switches to one JSON object per line for a collector. Anything whose key looks like key material (secret, token, ddk, privateKey, …) is masked at every level — "we only print it at trace" is not protection when the person who turned trace on is about to paste the output somewhere.

Pass any logger you like. A logger with only info/warn/errorconsole, for instance — is wrapped rather than trusted, so the engine gets the full surface it calls (child, timer, hex) and your logger still receives every line. Passing logger: console works, but prints raw objects with no timestamp or level, because that is what console.info does with an object; createLogger is what produces the formatted output below.

| Component | Level | What it tells you | |---|---|---| | fitdb:server | INFO | listening (host, port, enrolment on/off, principals); database created/opened; collection defined/migrated | | fitdb:server | DEBUG | every RPC: name, principal, dbId, collection, duration | | fitdb:server | WARN | an RPC refused, with its gRPC status — wrong secret, no ACL entry, unknown collection, a refused migration | | fitdb:server | ERROR | an unexpected handler failure, with stack | | enrolment | INFO/WARN | issuance (principal, method, serial, expiry) and refusal with the real reason — the usual answer to "why can't this client connect", since the client only ever sees PERMISSION_DENIED | | fitdb:manager | INFO | database created / opened (timed) / failed to open | | fitdb:<db>:<collection> | DEBUG | collection open, index rebuild, compaction, range-query strategy — all timed, and escalating to WARN when slow, which is how a query that quietly got 100× more expensive announces itself | | fitdb:<db>:<collection> | WARN | an index snapshot discarded in favour of a full replay; a collection that failed to open |

A line is HH:MM:SS.mmm LEVEL [component] message with its detail as an indented JSON block beneath — nested detail (a migration's change list, a query's strategy and counts) stays readable instead of being flattened onto one line.

13:10:40.639 INFO  [fitdb:server] database server listening
{
  "host": "127.0.0.1",
  "port": 51572,
  "enrolment": "enabled",
  "principals": ["idp-service", "smtp-service"],
  "logLevel": "DEBUG"
}
13:10:40.705 DEBUG [fitdb:server] rpc ok
{
  "rpc": "FindRange",
  "principal": "smtp-service",
  "dbId": "358990028442963968",
  "collection": "outbound_queue",
  "ms": 12
}

11. Known limitations

  • One writer process per data directory. Every serialization guarantee above is in-process; two server processes over the same baseDir would each keep their own segment offsets and corrupt each other, exactly as two handles inside one process used to.
  • A WatchCollection snapshot is chunked so an arbitrarily large collection can be streamed, but the server does not observe HTTP/2 drain on that stream — a watcher that stops reading entirely has its backlog buffered by Node rather than throttled at the source.
  • SecondaryIndexStore keeps one on-disk segment; a flush costs O(total field entries). Leveled compaction is the natural next step if that ever measures.
  • SCAN sorts the full remaining candidate set per page; a collection that outgrows this should move its ordering field to a diskBacked plain index.
  • No CRL or OCSP client. Revocation at this layer is the principal allow-list.
  • The direct-access object protocol is JSON+base64 framed (~33% overhead), consistent with that channel's control-plane-simplicity tradeoff.
  • No hardware-enclave key custody; that needs native bindings, out of scope for a pure Node implementation.
  • _id is a BigInt in a decoded record, while insert() returns a string, as do dbId and capability-token sub. bigintValue === stringValue is always false in JS — compare with an explicit String(...) on one side. This bit the test suite three times while the engine was being built.