Public
Redis-style key/value storage for Val Town, backed by SQLite
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

Townstash

Townstash is a Redis-inspired library for Val Town. It stores binary-safe string values in the SQLite database scoped to the library val and exports a ready-to-use client named stash.

Townstash is persistent application storage, not an in-memory Redis server. It does not expose RESP, accept connections from Redis clients, or promise Redis latency, memory use, or distributed-system behavior.

Why Townstash over blob or raw SQLite

Val Town gives every val two other scoped persistence options — std/blob and std/sqlite. Townstash competes with each on a different axis.

vs. std/blob (a performance argument)

Both are val-scoped key/value stores, so the comparison is speed. Benchmarks (see BENCHMARKS.md) show:

  • Reads are ~3× faster than blob at every payload size tested, up to 256 KB.
  • Counters are ~2× faster and atomicstash.incr is one round trip, while the blob equivalent is a racy get→modify→set pair.
  • Single writes and deletes are a tie with blob.

Reach for blob instead when you need high concurrent write throughput (SQLite's single-writer model plateaus around 30–50 writes/sec, where blob scales 2–3× higher) or are storing large opaque assets.

vs. std/sqlite (a convenience argument)

Townstash is a thin layer over std/sqlite — the same backend, so performance is essentially identical (there's nothing to benchmark). What it adds is a Redis-shaped key/value API over that backend so you don't hand-roll one:

  • Binary-safe get/set, JSON helpers, and canonical 64-bit counters over a flat keyspace — no schema, table, or migration to manage.
  • Existence-dependent operations decided atomically at execution time: conditional writes (nx/xx), incr/decr, and TTL expiration, instead of racy application-level read-then-write SQL.
  • Composable key prefixes for organizing the keyspace.

Reach for raw std/sqlite when your data is genuinely relational — multiple columns, joins, ad-hoc queries, secondary indexes — rather than values addressed by a single key.

The three are complementary: Townstash for keyed reads, counters, and small structured values; blob for bulk writes and big files; raw SQLite when you need SQL.

Usage

Import the canonical client from the townstash library val:

import { stash } from "townstash"; await stash.set("greeting", "Hello, Val Town!"); const greeting = await stash.get("greeting"); // string | undefined const bytes = new Uint8Array([0, 255, 128, 1]); await stash.setBytes("payload", bytes); const storedBytes = await stash.getBytes("payload"); await stash.setJSON("user:123", { name: "Ada", roles: ["admin"] }); const user = await stash.getJSON<{ name: string; roles: string[] }>("user:123");

get decodes a stored value as UTF-8 text. Use getBytes and setBytes when the value is arbitrary binary data. JSON helpers use JSON.stringify and JSON.parse over the same string representation. A top-level value that serializes to undefined is rejected, and malformed stored JSON raises a SyntaxError.

Writes and key lifecycle

set and setBytes return true when they write and false when a condition prevents the write:

const claimed = await stash.set("job:123", "claimed", { nx: true, ttlMs: 30_000, }); await stash.set("job:123", "finished", { xx: true, keepTtl: true }); await stash.expire("job:123", 60_000); await stash.expireAt("job:123", new Date(Date.now() + 60_000)); const remainingMs = await stash.ttl("job:123"); await stash.persist("job:123");

nx means only if absent and xx means only if present. They are mutually exclusive. ttlMs is relative to the timestamp sampled immediately before the atomic database command executes; expiresAt is an absolute Date; the two expiration forms are mutually exclusive. A normal replacement clears an existing expiration unless keepTtl is supplied.

Expiration is lazy. Every command that observes existence treats a key whose expiration is at or before the current time as missing, even when its SQLite row has not been cleaned up. ttl returns the remaining milliseconds for a visible expiring key, -1 for a visible persistent key, and undefined for a missing or expired key. exists returns a boolean, type returns "string" or "none", and del returns the number of visible keys removed:

if (await stash.exists("job:123")) { console.log(await stash.type("job:123")); } const removed = await stash.del("job:123", "job:missing"); await stash.cleanup(); // storage reclamation; not required for correctness

Counters and prefixes

Counters are string values interpreted as canonical signed 64-bit integers. Missing and expired keys start at zero. incr and decr accept optional signed number or bigint amounts, preserve a visible expiration, and return the result as an exact bigint:

const visits = await stash.incr("visits"); // 1n await stash.incr("visits", 4); await stash.decr("visits", 2n); await stash.decr("visits", -1); // increments by one

bigint is JavaScript's exact integer primitive, but it is a different type from number. Use bigint literals for arithmetic and strict comparisons, such as visits + 1n and visits === 1n. A consumer that requires a number can convert with https://www.val.town/x/nbbaier/townstash/code/src/stash.ts#L440, which throws a RangeError instead of silently losing precision outside JavaScript's safe-integer range:

import { toSafeNumber } from "townstash"; const visitsAsNumber = toSafeNumber(await stash.incr("visits"));

JSON.stringify does not serialize bigint directly. Convert it to a decimal string, or to a safely bounded number, before including it in JSON:

JSON.stringify({ visits: exactVisits.toString() });

Non-integer values, non-integer number amounts, out-of-range amounts, wrong-type values, and signed 64-bit overflow fail without mutating the stored value.

Prefixes are transparent key helpers over one flat keyspace:

const sessions = stash.withPrefix("session:"); await sessions.set("abc", "payload"); await stash.get("session:abc"); const versioned = sessions.withPrefix("v1:"); await versioned.set("abc", "next"); // stores session:v1:abc

A prefix is not a persisted namespace, isolation boundary, or authorization mechanism. The complete prefixed key remains directly addressable through an unprefixed client.

v1 contract

  • The keyspace is flat. The v1 value type is a binary-safe byte sequence; text, JSON, and counters are conveniences or interpretations of that value.
  • Conditional writes, counter updates, and other existence-dependent commands determine their result atomically at SQLite execution time. Application-level read-then-write sequences are not part of the correctness guarantee.
  • Each library-val instance uses its own val-scoped SQLite database. Schema initialization is idempotent and preserves existing v1 rows when repeated. v1 has no versioned migration path; future schema changes must add an explicit migration before they are supported.
  • stash includes string and byte reads/writes, JSON helpers, conditional writes, deletion, existence and type inspection, expiration management, cleanup, counters, and compositional prefixes.

Townstash deliberately does not provide RESP or server compatibility, blocking operations, pub/sub, Lua or Redis Functions, replication, clustering, eviction policies, distributed locks stronger than the documented atomic commands, or first-class hashes, sets, sorted sets, lists, and streams in v1. Those collection types require dedicated storage and command semantics rather than mutable JSON strings.