λ Lytecache

Embedded key-value store

Redis-like caching. Zero infrastructure.

One local SQLite file stands in for the server. set, get, TTLs, atomic counters, eviction, and process-safe locks — implemented natively in whichever language you already write, all reading and writing the very same file.

Python Java Node.js Go PHP + more coming
Install
pip install lytecache
implementation 'io.github.lytecache:lytecache:0.2.0'
<dependency>
  <groupId>io.github.lytecache</groupId>
  <artifactId>lytecache</artifactId>
  <version>0.2.0</version>
</dependency>
npm install lytecache
go get github.com/lytecache/lytecache-go
composer require lytecache/lytecache
Then use it
from lytecache import LyteCache

cache = LyteCache()
cache.set("hits", 0)
cache.incr("hits")
import io.lytecache.LyteCache;

try (LyteCache cache = new LyteCache()) {
    cache.set("hits", 0L);
    cache.incr("hits");
}
import { LyteCache } from "lytecache";

const cache = new LyteCache();
cache.set("hits", 0);
cache.incr("hits");
cache, _ := lytecache.New()
defer cache.Close()

cache.Set("hits", 0)
cache.Incr("hits", 1)
use Lytecache\LyteCache;

$cache = new LyteCache();
$cache->set("hits", 0);
$cache->incr("hits");

Zero config

The first write creates the file, directories, and schema. No init step.

One file format

Every implementation reads and writes the identical schema — even the same counters.

Atomic under load

Counters and locks are single SQL statements, correct across threads and processes.

Nothing to run

WAL-mode SQLite instead of a daemon. Ships inside your process.

Overview

Quickstart

Add the dependency, then use it. No path, no server, no migration step.

from lytecache import LyteCache

cache = LyteCache()                     # no path, no setup -- just works
cache.set("user:42", {"name": "Samson"}, ttl=300)
cache.get("user:42")                    # {"name": "Samson"}
cache.incr("hits")                      # 1
import io.lytecache.LyteCache;
    import java.time.Duration;

    try (LyteCache cache = new LyteCache()) {
        cache.set("user:42", "Samson", Duration.ofMinutes(5));
        cache.getString("user:42");          // "Samson"
        cache.incr("hits");                  // 1
}
import { LyteCache } from "lytecache";

const cache = new LyteCache();               // no path, no setup -- just works
cache.set("user:42", { name: "Samson" }, { ttl: 300 });
cache.get("user:42");                        // { name: "Samson" }
cache.incr("hits");                          // 1
cache, _ := lytecache.New()          // no path, no setup -- just works
defer cache.Close()

cache.Set("user:42", map[string]any{"name": "Samson"}, lytecache.TTL(5*time.Minute))
var user map[string]any
cache.Get("user:42", &user)                // {"name": "Samson"}
cache.Incr("hits", 1)                        // 1
use Lytecache\LyteCache;

$cache = new LyteCache();               // no path, no setup -- just works
$cache->set("user:42", ["name" => "Samson"], ttl: 300);
$cache->get("user:42");                 // ["name" => "Samson"]
$cache->incr("hits");                   // 1

All five are disposable: with LyteCache() as cache: in Python, try (LyteCache cache = new LyteCache()) in Java, using cache = new LyteCache() in Node.js (or call cache[Symbol.dispose]() directly), defer cache.Close() in Go, and PHP's __destruct() flushes and closes automatically even if you never call $cache->close() yourself. Closing flushes any buffered state and stops the background sweeper cleanly (PHP has no background sweeper -- see TTL & expiration).

Overview

Where is my data?

By default, every implementation resolves the exact same file for the same project — so a Python, Java, Node.js, Go, or PHP process started from the same directory all share one cache automatically.

<platform cache dir>/lytecache/<project-id>.db
Platform Default location
Linux $XDG_CACHE_HOME/lytecache/<project-id>.db, or ~/.cache/lytecache/<project-id>.db
macOS ~/Library/Caches/lytecache/<project-id>.db
Windows %LOCALAPPDATA%\lytecache\<project-id>.db

<project-id> is the first 12 hex characters of the SHA-256 hash of your current working directory's resolved, absolute path — an identical derivation in every implementation, so every project gets its own file and nothing is left behind in your repo. (See the glossary if "SHA-256" is new to you.)

Overriding it

  • Pass an explicit path — LyteCache("/data/cache.db") in Python, .path(Path.of("/data/cache.db")) on the Java builder, new LyteCache({ path: "/data/cache.db" }) in Node.js, lytecache.WithPath("/data/cache.db") in Go, new LyteCache(path: "/data/cache.db") in PHP.
  • Set LYTECACHE_PATH=/data/cache.db in the environment — takes priority over the default in every language.

Introspect it programmatically: LyteCache.default_path() / cache.path in Python, LyteCache.defaultPath() / cache.path() in Java, LyteCache.defaultPath() / cache.path in Node.js, lytecache.DefaultPath() / cache.Path() in Go, LyteCache::defaultPath() / $cache->path() in PHP. The file is never a mystery.

Laravel is the one exception: its cache driver defaults to storage_path('framework/cache/lytecache.db') instead of the platform cache directory above, since that's where a Laravel app expects its cache files to live — configurable in config/cache.php's stores.lytecache.path.

Guide

TTL & expiration

Expiration applies identically to every value type — strings, numbers, dataclasses, dicts, POJOs, records. There's nothing special about storing an object.

Watch the units

Python's, Node.js's, and PHP's ttl are all plain numbers, in seconds (a float in Python and PHP, a number in Node). ttl=5000 / { ttl: 5000 } means "expire in 5000 seconds" (~83 minutes), not 5 seconds — the most common reason a key seems to "never expire." Java's and Go's are explicit (a Java Duration, a Go time.Duration), so there's no unit ambiguity there.

cache.set("session", {"user_id": 42}, ttl=300)   # expires in 300s (5 min)
cache.set("otp", "123456", ttl=0.5)              # expires in 500ms

cache.ttl("session")        # seconds remaining, e.g. 298.7
cache.ttl("permanent_key")  # -1 if no TTL was set
cache.ttl("missing")        # None if the key doesn't exist

cache.expire("session", 60)   # overwrite the TTL: now expires in 60s
cache.persist("session")      # remove the TTL entirely
cache.touch("session", 300)   # sliding expiration: refresh back to 300s
cache.set("session", "42", Duration.ofMinutes(5));
cache.set("otp", "123456", Duration.ofMillis(500));

cache.ttl("session");     // Duration remaining
cache.ttl("permanent");   // Duration.ofSeconds(-1) if no TTL
cache.ttl("missing");     // null if the key doesn't exist

cache.expire("session", Duration.ofSeconds(60));  // overwrite the TTL
cache.persist("session");                          // remove the TTL entirely
cache.touch("session", Duration.ofMinutes(5));     // sliding expiration
cache.set("session", { userId: 42 }, { ttl: 300 });  // expires in 300s (5 min)
cache.set("otp", "123456", { ttl: 0.5 });            // expires in 500ms

cache.ttl("session");        // seconds remaining, e.g. 298.7
cache.ttl("permanentKey");   // -1 if no TTL was set
cache.ttl("missing");        // undefined if the key doesn't exist

cache.expire("session", 60);   // overwrite the TTL: now expires in 60s
cache.persist("session");      // remove the TTL entirely
cache.touch("session", 300);   // sliding expiration: refresh back to 300s
cache.Set("session", 42, lytecache.TTL(5*time.Minute))   // expires in 5 min
cache.Set("otp", "123456", lytecache.TTL(500*time.Millisecond))

_, hasExpiry, found, _ := cache.TTLOf("session")   // remaining time.Duration
_, hasExpiry, found, _ = cache.TTLOf("permanent")  // hasExpiry=false if no TTL
_, _, found, _ = cache.TTLOf("missing")            // found=false if the key doesn't exist

cache.Expire("session", 60*time.Second)  // overwrite the TTL
cache.Persist("session")                 // remove the TTL entirely
cache.Touch("session", 5*time.Minute)    // sliding expiration: refresh back to 5 min
$cache->set("session", ["user_id" => 42], ttl: 300);   // expires in 300s (5 min)
$cache->set("otp", "123456", ttl: 0.5);                // expires in 500ms

$cache->ttl("session");        // seconds remaining, e.g. 298.7
$cache->ttl("permanent_key");  // -1 if no TTL was set
$cache->ttl("missing");        // null if the key doesn't exist

$cache->expire("session", 60);   // overwrite the TTL: now expires in 60s
$cache->persist("session");      // remove the TTL entirely
$cache->touch("session", 300);   // sliding expiration: refresh back to 300s

Lazy and active expiration

Expiration is enforced two ways, in every implementation:

  • Lazily, on every read (get / exists / getAll / getMany / TTLOf): an expired row is treated as a miss and deleted on the spot, even with the background sweeper disabled.
  • Actively, by a background sweeper (a thread in Python/Java, a setInterval timer in Node.js, a goroutine in Go) that runs every sweep_interval / sweepInterval / WithSweepInterval (default 60s) and deletes expired rows in batches, so disk isn't held by dead keys even if nothing ever reads them again. PHP has no background threads, so instead it runs bounded maintenance passes opportunistically (roughly every 100 operations, throttled by sweepInterval) — Laravel apps should also schedule the lytecache:maintain artisan command so a quiet cache still gets swept.

If you inspect the .db file directly (a SQLite browser, say) moments after a key expires but before the next sweep tick, the row may still be physically present — that's expected. get()/exists() already treat it as gone. Lower the sweep interval, or call vacuum(), if you need the file to shrink sooner.

Guide

Flush & delete

cache.delete("key1", "key2")   # delete specific keys; returns how many existed
cache.flush()                  # delete EVERYTHING in the current namespace
cache.delete("key1", "key2");   // delete specific keys; returns how many existed
cache.flush();                  // delete EVERYTHING in the current namespace
cache.delete("key1", "key2");   // delete specific keys; returns how many existed
cache.flush();                  // delete EVERYTHING in the current namespace
cache.Delete("key1", "key2")   // delete specific keys; returns how many existed
cache.Flush()                  // delete EVERYTHING in the current namespace
$cache->delete("key1", "key2");   // delete specific keys; returns how many existed
$cache->flush();                  // delete EVERYTHING in the current namespace

flush() takes no key or pattern argument by design — it always clears the entire namespace (the default namespace, unless you configured a different one). To clear a subset, delete by key or by pattern instead:

for key in list(cache.keys("session:*")):
cache.delete(key)
cache.keys("session:*").forEach(cache::delete);
for (const key of cache.keys("session:*")) cache.delete(key);
for key, _ := range cache.Keys("session:*") {
    cache.Delete(key)
}
foreach ($cache->keys("session:*") as $key) {
    $cache->delete($key);
}

Namespaces

If two LyteCache instances point at the same file but different namespaces, they're fully isolated — flush() on one never touches the other's keys. That's the usual reason flush() looks like it "didn't delete everything": check you're flushing the same namespace you wrote to.

Overview

When to use lytecache

Good fit

  • Single-node apps (or single-machine, multi-process apps) that want caching, counters, or TTLs with zero infrastructure
  • Scripts, CLIs, notebooks, small web services, background jobs, test fixtures
  • A cache that survives process restarts without a separate daemon
  • Multi-process coordination via the process-safe distributed lock
  • Mixed-language systems — Python, Java, Node.js, Go, and PHP processes can all share one cache file
  • Laravel apps that want Cache::remember() and Cache::lock() with zero infrastructure — swap in the driver by changing one config line

Not a good fit

  • A cache shared live across multiple servers/hosts — SQLite is a local file, not a network service. Use Redis/Memcached.
  • Heavy concurrent write throughput from many processes — SQLite's single-writer model will serialize writes and become a bottleneck
  • Pub/sub, streams, or other Redis data structures beyond key-value + counters
  • Complex queries over cached data — use a real database

Guide

Configuration

Every option has a sane default; nothing here is required to get started.

LyteCache(
    path=None,             # explicit file path; default: LyteCache.default_path()
    namespace="default",   # logical partition within the database file
    max_keys=None,         # evict when the namespace exceeds this many keys
    max_bytes=None,        # evict when the namespace exceeds this many bytes
    eviction="lru",        # "lru" | "ttl" | "random" | "noeviction"
    sweep_interval=60.0,   # seconds between maintenance passes; None disables
                           # the thread and sweeps opportunistically instead
    serializer="auto",     # "auto" | "json" (strict) | "pickle"
    strict=False,          # True: raise on internal read errors
    allow_pickle=False,    # "auto" mode only: allow reading pickled values
)
LyteCache cache = LyteCache.builder()
.path(Path.of("/data/my-cache.db"))    // optional; platform cache dir if omitted
.namespace("sessions")                  // isolate multiple caches in one file
.maxKeys(100_000)                       // evict when exceeded (default: 1M)
.maxBytes(256L * 1024 * 1024)           // evict when exceeded (default: 1 GB)
.eviction(Eviction.LRU)                 // LRU (default), TTL, RANDOM, NOEVICTION
.sweepInterval(Duration.ofSeconds(60))  // background cleanup frequency
.strict(false)                          // on read error: log+miss, or throw
.build();
new LyteCache({
  path: undefined,       // explicit file path; default: LyteCache.defaultPath()
  namespace: "default",  // logical partition within the database file
  maxKeys: undefined,    // evict when the namespace exceeds this many keys
  maxBytes: undefined,   // evict when the namespace exceeds this many bytes
  eviction: "lru",       // "lru" | "ttl" | "random" | "noeviction"
  sweepInterval: 60,     // seconds between maintenance passes; null disables
                         // the timer and sweeps opportunistically instead
  strict: false,         // true: throw on internal read errors instead of
                         // degrading to a miss
  logger: console.warn,  // called on a non-strict degraded read
});
cache, err := lytecache.New(
    lytecache.WithPath("/data/cache.db"),        // optional; default: lytecache.DefaultPath()
    lytecache.WithNamespace("sessions"),          // isolate multiple caches in one file
    lytecache.WithMaxKeys(100_000),               // evict when exceeded (default: no limit)
    lytecache.WithMaxBytes(256<<20),              // evict when exceeded (default: no limit)
    lytecache.WithEviction(lytecache.LRU),        // LRU (default), TTLPolicy, Random, NoEviction
    lytecache.WithSweepInterval(60*time.Second),  // 0 disables the goroutine -> opportunistic mode
    lytecache.WithStrict(false),                  // true: return read errors instead of a miss
)
new LyteCache(
    path: null,               // explicit file path; default: LyteCache::defaultPath()
    namespace: "default",     // logical partition within the database file
    maxKeys: null,            // evict when the namespace exceeds this many keys
    maxBytes: null,           // evict when the namespace exceeds this many bytes
    eviction: Eviction::LRU,  // LRU (default), TTL, Random, NoEviction
    sweepInterval: 60.0,      // minimum seconds between opportunistic maintenance passes
    strict: false,            // true: throw on internal read errors instead of a miss
);

Eviction policies: lru (default, evicts least-recently-used), ttl (soonest-to-expire first), random, and noeviction (rejects the write instead of evicting). LFU is a documented TODO in every implementation. Unfamiliar with an abbreviation like LRU? See the glossary.

Laravel

None of this is required to use lytecache from Laravel — set CACHE_STORE=lytecache and go. The options above are still there if you need them, in config/cache.php's stores.lytecache (publish the stub with php artisan vendor:publish --tag=lytecache-config), and path defaults to storage_path('framework/cache/lytecache.db') instead of the platform cache directory.

API reference

Method reference

Grouped by what you're trying to do. Every group below shows all five languages side by side — flip the toggle once and it stays flipped for the rest of the page.

Set & get

cache.set("key", "value")
cache.set("key", "value", ttl=300)
cache.get("key")                 # None if missing or expired
cache.get("key", "default")      # custom default on miss
cache.delete("key1", "key2")     # returns count actually deleted
cache.exists("key")
cache.set("key", "value");
cache.set("key", "value", Duration.ofMinutes(5));
String val = cache.getString("key");   // null if missing or expired
Long n = cache.getLong("key");
Double d = cache.getDouble("key");
byte[] b = cache.getBytes("key");
cache.delete("key1", "key2");          // returns count actually deleted
cache.exists("key");
cache.set("key", "value");
cache.set("key", "value", { ttl: 300 });
cache.get("key");                // undefined if missing or expired
cache.get("key", "default");     // custom default on miss
cache.delete("key1", "key2");    // returns count actually deleted
cache.exists("key");
cache.Set("key", "value")
cache.Set("key", "value", lytecache.TTL(5*time.Minute))
var v string
found, _ := cache.Get("key", &v)      // found=false if missing or expired
s, found, _ := cache.GetString("key")  // typed convenience wrapper
cache.Delete("key1", "key2")           // returns count actually deleted
cache.Exists("key")
$cache->set("key", "value");
$cache->set("key", "value", ttl: 300);
$cache->get("key");                 // null if missing or expired
$cache->get("key", "default");      // custom default on miss
$cache->delete("key1", "key2");     // returns count actually deleted
$cache->has("key");

Objects & generics

Anything JSON can represent — dicts, lists, dataclasses, POJOs (a plain object with no special base class or annotations — see glossary), records — round-trips as portable JSON, readable from any of the languages.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

cache.set("p:1", Person("Samson", 30))
cache.get("p:1")                # plain dict by default
cache.get("p:1", cls=Person)    # typed: Person(name="Samson", age=30)
record Person(String name, int age) {}

cache.set("p:1", new Person("Samson", 30));
cache.get("p:1", Person.class);

// Generic types a raw Class can't express:
Map<String, Long> scores = cache.get("scores",
    new TypeReference<Map<String, Long>>() {});

class Person {
  constructor(public name: string, public age: number) {}
}

cache.set("p:1", new Person("Samson", 30));
cache.get("p:1");                             // plain object by default
cache.get("p:1", undefined, { into: Person }); // typed: instanceof Person
type Person struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

cache.Set("p:1", Person{Name: "Samson", Age: 30})
var p Person
found, _ := cache.Get("p:1", &p) // typed: struct fields via encoding/json tags
final class Person {
    public function __construct(
        public string $name,
        public int $age,
    ) {}
}

$cache->set("p:1", new Person("Samson", 30));
$cache->get("p:1");                        // plain array by default
$cache->get("p:1", class: Person::class);  // typed: Person(name: "Samson", age: 30)

Add / replace / atomic swap

"NX" and "XX" below are the classic Redis flag names for these two semantics; see the glossary.

cache.add("key", "value", ttl=60)      # set only if absent (SET NX)
cache.replace("key", "value", ttl=60)  # set only if present (SET XX)
cache.get_set("key", "new")            # atomic swap, returns the old value
cache.add("key", "value", ttl);      // set only if absent (SET NX)
cache.replace("key", "value", ttl);  // set only if present (SET XX)
cache.getSet("key", "new");          // atomic swap, returns the old value
cache.add("key", "value", { ttl: 60 });      // set only if absent (SET NX)
cache.replace("key", "value", { ttl: 60 });  // set only if present (SET XX)
cache.getSet("key", "new");                  // atomic swap, returns the old value
cache.Add("key", "value", lytecache.TTL(60*time.Second))      // set only if absent (SET NX)
cache.Replace("key", "value", lytecache.TTL(60*time.Second))  // set only if present (SET XX)
var old string
cache.GetSet("key", "new", &old) // atomic swap, decodes the old value into old
$cache->add("key", "value", ttl: 60);      // set only if absent (SET NX)
$cache->replace("key", "value", ttl: 60);  // set only if present (SET XX)
$cache->getSet("key", "new");              // atomic swap, returns the old value

Batch operations

cache.set_many({"a": "1", "b": "2"}, ttl=60)   # single transaction
cache.get_many(["a", "b", "missing"])           # {"a": "1", "b": "2"}
cache.setAll(Map.of("a", "1", "b", "2"), ttl);   // single transaction
cache.getAll(List.of("a", "b", "missing"));       // {a=1, b=2}
cache.setMany({ a: "1", b: "2" }, { ttl: 60 });   // single transaction
cache.getMany(["a", "b", "missing"]);              // Map(2) {'a' => '1', 'b' => '2'}
cache.SetMany(map[string]any{"a": "1", "b": "2"}, lytecache.TTL(60*time.Second))
results, _ := cache.GetMany([]string{"a", "b", "missing"}) // map[string]RawValue, decode per key
$cache->setMany(["a" => "1", "b" => "2"], ttl: 60);   // single transaction
$cache->getMany(["a", "b", "missing"]);                // ["a" => "1", "b" => "2"]

Atomic counters

A single SQL UPSERT (an insert-or-update statement -- see glossary) per call — correct under concurrent access from many threads or processes, never a read-modify-write race.

cache.incr("hits")             # +1
cache.incr("hits", 5)          # +5
cache.decr("hits")             # -1
cache.incr_float("ratio", 0.5)
cache.incr("hits");              // +1
cache.incr("hits", 5);           // +5
cache.decr("hits");              // -1
cache.incrDouble("ratio", 0.5);
cache.incr("hits");              // +1
cache.incr("hits", 5);           // +5
cache.decr("hits");              // -1
cache.incrFloat("ratio", 0.5);
cache.Incr("hits", 1)          // +1
cache.Incr("hits", 5)          // +5
cache.Decr("hits", 1)          // -1
cache.IncrFloat("ratio", 0.5)
$cache->incr("hits");             // +1
$cache->incr("hits", 5);          // +5
$cache->decr("hits");             // -1
$cache->incrFloat("ratio", 0.5);

Key scanning

for key in cache.keys("session:*"):   # lazy iterator, glob syntax
print(key)
cache.keys("session:*")                // lazy Stream<String>, GLOB syntax
.forEach(System.out::println);
for (const key of cache.keys("session:*")) {  // lazy generator, GLOB syntax
  console.log(key);
}
for key, err := range cache.Keys("session:*") { // iter.Seq2, GLOB syntax
    fmt.Println(key, err)
}
foreach ($cache->keys("session:*") as $key) {  // lazy Generator, GLOB syntax
    echo $key;
}

Stats & maintenance

cache.stats()
# {'hits': 42, 'misses': 3, 'hit_rate': 0.93, 'key_count': 12,
#  'size_bytes': 8291, 'evictions': 0, 'path': '...'}
cache.vacuum()   # reclaim disk space
cache.close()    # shut down cleanly
CacheStats stats = cache.stats();
stats.hitRate();     // percentage
stats.keyCount();
stats.sizeBytes();
stats.path();
cache.vacuum();      // reclaim disk space
cache.close();       // shut down cleanly (or use try-with-resources)
cache.stats();
// { hits: 42, misses: 3, hitRate: 0.93, keyCount: 12,
//   sizeBytes: 8291, evictions: 0, path: '...' }
cache.vacuum();   // reclaim disk space
cache.close();    // shut down cleanly (or use Symbol.dispose)
stats, _ := cache.Stats()
// {Hits:42 Misses:3 HitRate:0.93 KeyCount:12 SizeBytes:8291 Evictions:0 Path:"..."}
cache.Vacuum()  // reclaim disk space
cache.Close()   // shut down cleanly (idempotent)
$cache->stats();
// CacheStats(hits: 42, misses: 3, hitRate: 0.93, keyCount: 12,
//   sizeBytes: 8291, evictions: 0, path: "...")
$cache->vacuum();   // reclaim disk space
$cache->close();    // shut down cleanly (idempotent; __destruct() also calls it)

Distributed locks

Process-safe: built on the same atomic add() semantics as everything else, so only one holder — across threads and processes — can hold a given lock name at once.

with cache.lock("resource", timeout=30):
do_critical_work()
try (CacheLock lock = cache.lock("resource", Duration.ofSeconds(30))) {
    doCriticalWork();
}
using lock = cache.lock("resource", { timeoutMs: 30_000 });
doCriticalWork();
lock, err := cache.Lock("resource", 30*time.Second)
defer lock.Release()
doCriticalWork()
$cache->lock("resource", timeout: 30.0)->block(function () {
    doCriticalWork();
});

Read-through memoization

@cache.memoize(ttl=3600)
def expensive(x):
    return compute(x)
String result = cache.memoize("expensive:" + x, Duration.ofHours(1),
() -> compute(x));
const result = cache.memoize(`expensive:${x}`, 3600, () => compute(x));
result, err := lytecache.Memoize(cache, fmt.Sprintf("expensive:%v", x), time.Hour,
    func() (T, error) { return compute(x) })
$result = $cache->remember("expensive:{$x}", 3600, fn () => compute($x));

Cross-language

Storage spec & type codes

Every implementation reads and writes the identical schema — one SQLite file, WAL mode, PRAGMA busy_timeout=5000 so cross-process contention waits instead of failing. Every value is tagged with a value_type code:

Code Type Encoding
0 Bytes Raw bytes, stored as-is
1 String UTF-8 text
2 Integer UTF-8 decimal text — not binary. This is what lets incr/decr be one atomic SQL UPSERT in every language.
3 Float UTF-8 decimal text, same reasoning as integers
4 JSON Any object, dict, list, dataclass, POJO, record, JS object, Go struct/map/slice, or PHP array/object/enum
5 Python pickle Python-only escape hatch; reading it from Java, Node.js, Go, or PHP raises a clear error, never garbage
6 Java serialized Reserved, never written; reading it from Python, Node.js, Go, or PHP raises a clear error

Node.js has a single number type rather than Python's int/float or Java's long/double, so its implementation picks type code 2 vs. 3 by shape (Number.isInteger()) rather than by caller intent, using bigint for integers beyond Number.MAX_SAFE_INTEGER. See lytecache-node/SPEC.md for the full rules.

Go, like Python and Java, has distinct integer and floating-point types, so it decides type codes by the value's actual Go type rather than by shape: any Go integer type stores as code 2, float32/float64 as code 3. A uint64 beyond what signed 64-bit integers can hold is rejected outright rather than silently truncated. See lytecache-go/SPEC.md for the full rules.

PHP strings are ambiguous between "raw bytes" and "text," unlike the other languages' distinct byte-array types, so lytecache uses a Bytes wrapper class to mean code 0 explicitly — a plain PHP string always stores as code 1. Objects serialize via JsonSerializable when implemented, else their public properties; a DateTimeInterface serializes to an RFC 3339 string and a BackedEnum to its backing value, matching the conventions above. See lytecache-php/SPEC.md for the full rules.

The zero-config default path derivation (<project-id> = SHA-256 of the resolved working directory) is byte-for-byte identical across every implementation, so all of them land on the same file for the same project directory — see Where is my data? above.

Cross-language

Glossary

This page uses a handful of abbreviations without expanding them inline. Here's what each one means.

Term Full name What it means here
API Application Programming Interface The set of methods a library exposes for other code to call — in this case, set/get/incr/etc.
CLI Command-Line Interface A program you run and control from a terminal, as opposed to a GUI.
TTL Time To Live How long a cached value is kept before it's treated as expired.
LRU Least Recently Used Eviction policy: when the cache is full, remove the key that hasn't been read or written in the longest time.
LFU Least Frequently Used Eviction policy that removes the key with the fewest accesses. Documented as a future addition, not yet implemented.
SQL Structured Query Language The language used to talk to SQLite — every read and write here compiles down to one SQL statement.
WAL Write-Ahead Logging A SQLite journaling mode that lets readers and a writer access the database file concurrently without blocking each other.
JSON JavaScript Object Notation The plain-text format used to store objects, arrays, and other structured values portably across every language.
POJO Plain Old Java Object An ordinary Java object with no special base class, interface, or framework annotation required.
GLOB Global (pattern matching) Shell-style wildcard syntax (*, ?, [...]) used by keys(pattern) to match key names.
NX / XX Not eXists / already eXists Redis-style flag names: add() behaves like SET NX (only if absent), replace() like SET XX (only if present).
UPSERT Update + Insert A single SQL statement that inserts a new row or updates the existing one, used so counters and locks stay atomic under concurrency.
CJS / ESM CommonJS / ECMAScript Modules Node.js's two module systems (require() vs. import). The Node.js package ships both.
SHA-256 Secure Hash Algorithm, 256-bit A one-way fingerprint function used to derive each project's default cache filename from its working directory.

Tooling

Command-line interface coming soon

Not released yet

The lytecache CLI is built and tested, just not published yet. Watch github.com/lytecache/lytecache-cli for the first release.

Inspect and manipulate any lytecache database file from a shell, the way redis-cli does for a server. It's built entirely on lytecache-go's public API, so it can read and write a file created by any of the five languages above.

Install

brew install lytecache/tap/lytecache
# or, without Homebrew:
curl -fsSL https://raw.githubusercontent.com/lytecache/lytecache-cli/main/install.sh | sh
scoop bucket add lytecache https://github.com/lytecache/scoop-bucket
scoop install lytecache
go install github.com/lytecache/lytecache-cli/cmd/lytecache@latest

Prebuilt binaries, SHA-256 checksums, and .deb/.rpm packages are attached to every GitHub release; a winget manifest ships the same way, pending submission to microsoft/winget-pkgs.

A session

samson@lytecache: ~

One-shot commands (script-friendly, shown above) and the interactive REPL share the exact same command tree. The REPL just opens the database once for the whole session instead of reopening it per command. Command names are case-insensitive there, and Ctrl-C cancels the current line without leaving.

Commands

CommandDescription
get <key>Print a value, JSON pretty-printed by default, or with --raw for exact bytes
set <key> [value]Set a value; type is inferred unless --type forces it
del <key>...Delete one or more keys; prints how many actually existed
exists <key>Prints 1/0; exit code matches
ttl / expire / persist / touchInspect and manage a key's TTL
incr / decrAtomically adjust a counter; prints the new value
keys [pattern] (alias scan)List matching keys; --long adds type/ttl/size columns
stats (alias info)Key count, size, hit/miss counters, evictions, path
flushDelete every key in the current namespace (prompts unless --yes)
maintain / vacuumRun a maintenance pass / reclaim disk space
whichPrint the resolved database path and whether it exists
dump <key>Raw row metadata, the view you reach for when debugging
watch [interval]Redraw stats every interval seconds until Ctrl-C

Exit codes

Scripts can rely on these without parsing output:

CodeMeaning
0Success
1A read found nothing ((nil)), or a boolean result was false
2Usage error (bad arguments/flags)
3Database error

Cross-language example

Since every implementation shares one on-disk format (see Storage spec above), the CLI can inspect a cache file written by any of them. A Python process wrote this:

Python
cache.set("config", {"theme": "dark", "timeout": 30})
Terminal
$ lytecache --db ~/.cache/lytecache/abc123.db get config
{
  "theme": "dark",
  "timeout": 30
}

No Python installation, no shared server, no export step: just the same SQLite file on disk. The two type codes the CLI can't decode — 5 for Python pickle, 6 for Java serialization — render as (non-portable value: python-pickle, N bytes) instead of erroring out or dumping garbage.