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.
Val Town gives every val two other scoped persistence options — std/blob and
std/sqlite. Townstash competes with each on a different axis.
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 atomic —
stash.incris 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.
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.
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.
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 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.
- 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.
stashincludes 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.