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

@drupflare/durabledb

v0.1.3

Published

A codec that encodes the real platform limits of Cloudflare Durable Object SQLite: 100 bound parameters, a 50-byte LIKE pattern, and lossy integers above 2^53.

Readme

🗄️ durabledb

The real limits of Cloudflare Durable Object SQLite, encoded rather than documented

Build Prettier codecov License

A ctx.storage.sql host and a value codec that encode what the platform actually does, not what SQLite documents. ctx.storage.sql caps a statement at 100 bound parameters where local PDO allows 32,766, refuses a LIKE pattern over 50 bytes, has no named parameters at all, and reads integers above 2^53 lossily. Every one of those is a measured refusal from a deployed object, and every one has produced a defect that a local SQLite passed.


📋 Table of Contents


🎯 Why

A Durable Object's SQLite is a genuinely good home for a small database: strongly consistent, in the same isolate as the code, and synchronous to read so blocking callers compose with it. Then you write against it as though it were SQLite and it refuses things SQLite does not.

The refusals are not documented as a set, they are not tunable, and each one fails in a different register — a thrown error for the parameter cap, a silently wrong number for a wide integer. A value that comes back wrong rather than erroring cannot be told apart from correct output until much later.

So the limits live in code, as named constants and named refusals, with a test per limit.


📥 Install

bun add @drupflare/durabledb

Three entry points, and the split is not cosmetic:

| Import | Gives you | Needs @drupflare/cartridge | | -------------------------------- | ----------------------------------------------- | ---------------------------- | | @drupflare/durabledb | everything below | yes | | @drupflare/durabledb/codec | encode, decode, codecGuard, PHP_CODEC | no | | @drupflare/durabledb/do-sqlite | SiteDurableObject, toPositional, bindable | yes |

src/do-sqlite.ts imports @drupflare/cartridge/gate and /mask; src/codec.ts imports nothing at all, so a consumer that only needs the codec pays for no dependency. There is no deep import: the exports map is the whole surface, and @drupflare/durabledb/src/codec.ts is refused by the resolver.


🧱 Limits

Each one broke something real. None is a conservative guess.

| Limit | Value | What it broke | | ------------------------------ | -------------------- | ----------------------------------------------------------------------- | | bound parameters per statement | 100 | the cache write path; a cold cache_discovery set needed 574 | | LIKE / GLOB pattern length | 50 bytes | binds plain LIKE, not only GLOB, so it is invisible to a caller | | bytes per record | 2,199,995 | a heap snapshot row | | statement text | 100,000 chars | a chunked DDL replay | | integer reads | lossy above 2^53 | wrote 9007199254740993, read back 9007199254740992 | | named parameters | none exist | every Drupal query; toPositional() converts them |

Two carry a further consequence:

Writing a wide integer is exact; reading one is not. ctx.storage.sql hands INTEGER columns back as JS doubles, so the precision is gone before any consumer can see it. CAST(col AS TEXT) returns every digit, so the storage is fine — the loss is in the cursor. UnreadableIntegerError exists to make that a refusal rather than a wrong answer.

"There is no smaller unit than a row" is false, and it was this project's own unverified claim, believed for a while. Three 520 KB rows each overran the record cap and looked indivisible; SQLite builds a value across statements with col = col || ?.

[!WARNING] Never widen a limit because a test passes. These came from a deployed object, and a local SQLite will happily accept all of them. If you think one is wrong, re-measure on a deployed worker and say so. Do not relax a guard.


🔁 Codec

The consumer runtime is a 32-bit PHP wasm build (PHP_INT_SIZE is 4), so any JS number at or above 2^31 wraps silently on the way in. Measured: Date.now() arrived in PHP as -397708726 instead of ~1.78e12. Two instances were found separately, a timestamp and a node id — and finding two separately means the class was still open.

The obvious fix, stringifying anything unsafe, is lossy in the other direction. PHP receives "1780000000000" and cannot tell whether that was an integer too large to represent or a genuine string; sending it back produces a string where a number belongs, and the corruption reappears one hop later.

So values that cannot cross natively are wrapped in a tagged envelope instead of flattened, and decode() is the exact inverse of encode():

| Envelope | Carries | | -------------------------------- | -------------------------------------- | | {__t: 'i', v: '1780000000000'} | integer outside 32-bit range | | {__t: 'n', v: '1.5e300'} | non-finite or precision-risky float | | {__t: 'd', v: '1780000000000'} | Date | | {__t: 'b', v: '<base64>'} | bytes | | {__t: 'u'} | undefined, which PHP has no word for |

Anything representable on both sides crosses unwrapped, so the common path costs nothing.


🔗 Bridge Contract

Two entry points, and the second is the interesting one.

execSql(sql: string, params?: SqlBindings): ExecSqlResult
execTxn(req: TxnRequest): ExecTxnResult

execTxn runs a list of statements inside one ctx.storage.transactionSync(), optionally with a trailing read. commit: false runs them, evaluates the read inside the same transaction, then throws a private sentinel so the runtime rolls back — and still returns the results, which is what makes a speculative row count and insert id possible.

ExecTxnResult is a discriminated union rather than one shape with optionals: a failed replay has already been rolled back, so there are no results to read, and the discriminant is what stops a caller reading them anyway.


🔧 API

| Export | What it is | | ----------------------------------------------- | -------------------------------------------------------------------- | | SiteDurableObject | the Durable Object; execSql, execTxn, installBridge, nowMs | | encode / decode | the codec, exact inverses of each other | | codecGuard(host) | wraps a host surface so every value crossing it is encoded | | PHP_CODEC | the PHP half of the codec, as source, for hosts that evaluate it | | toPositional(sql, params) | rewrites named parameters, because the engine has none | | bindable(value) | the one value transform sql.exec() needs; a JS BigInt is refused | | UnreadableIntegerError | a read above 2^53, refused rather than answered wrongly | | ExecSqlResult | { rows, rowsRead, rowsWritten, lastInsertRowid, changes } | | TxnRequest / TxnStatement / ExecTxnResult | the replay contract above | | SqlBindings | unknown[] \| Record<string, unknown> \| null |


🧪 Testing

bun run typecheck
bun run test # 87 assertions across 3 specs
bun run test:coverage

87 passing, 0 failed, at a measured 61.68% statements (codec.ts 82.29%, do-sqlite.ts 49.69% — the Durable Object routes and alarm() need a real ctx.storage.sql, so they are covered in the consumer rather than here). The lane runs in node, not workerd, and coverage uses provider: 'v8' rather than istanbul. The sibling repositories split on exactly that axis: a workerd lane must use istanbul, because the v8 provider reads coverage off the Node inspector and attributes zero from inside the isolate; a node lane uses v8.

Two rules the suite is built on:

  • The codec must round-trip. A codec that can decode a type it cannot encode is a bug to fix, not a test to skip.
  • Fail with a named refusal rather than truncating. A silently truncated value cannot be told apart from correct output until much later.

🔗 Related Repositories

| Repository | What it is | | --------------------------------------------------------------- | ---------------------------------------------------------------------------- | | drupflare/worker | the consumer: Drupal 11 on Cloudflare Workers | | drupflare/rom | composer require drupflare/rom:0.* — the Drupal 11 driver that sits on top | | drupflare/cartridge | the reentrancy gate and interrupt mask this package imports |


📄 License

MIT (c) Gregory Mitchell 2026. See LICENSE.