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.
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
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.dbin 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
setIntervaltimer in Node.js, a goroutine in Go) that runs everysweep_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 bysweepInterval) — Laravel apps should also schedule thelytecache:maintainartisan 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()andCache::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
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
| Command | Description |
|---|---|
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 / touch | Inspect and manage a key's TTL |
incr / decr | Atomically 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 |
flush | Delete every key in the current namespace (prompts unless --yes) |
maintain / vacuum | Run a maintenance pass / reclaim disk space |
which | Print 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:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | A read found nothing ((nil)), or a boolean result was false |
| 2 | Usage error (bad arguments/flags) |
| 3 | Database 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:
cache.set("config", {"theme": "dark", "timeout": 30})
$ 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.