sugarcraft / candy-kv
PHP port of charmbracelet/skate — a personal key/value store with multi-database support, binary data, and glob/list filtering.
Requires
- php: ^8.3
- ext-sqlite3: *
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-fuzzy: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- symfony/yaml: ^6.0||^7.0
Suggests
- symfony/yaml: ^6.0||^7.0 — used for YAML import/export when installed; without it candy-kv uses its own flat YAML dialect
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:00:51 UTC
README
candy-kv
Formerly published as
sugarcraft/sugar-skate(renamed 2026-10). Namespace changed:SugarCraft\Skate\→SugarCraft\Kv\.
candy-kv is a personal key/value store for PHP 8.3+: multi-database support, binary-safe values, glob filtering, TTL expiry, JSON/YAML import/export, and a small strict CLI. Values land in plain SQLite files you own.
Scope note: upstream skate also ships
an interactive full-screen browser. candy-kv does not — it is the storage
library plus a scriptable command front end. Terminal UIs that want a browse
pane build one on top of Store::list().
Features
- Multi-database — separate stores with the
key@dbnamesuffix; the SQLite file for a name is created the first time something is written to it (reads of a missing database just see emptiness) - Binary-safe — values containing NUL bytes or invalid UTF-8 are stored base64-encoded with the binary flag (auto-detected;
--binary/--textoverride); binary entries export as base64 envelopes and reimport byte-exact - Glob patterns —
listanddeleteaccept*and?; matching is byte-wise and case-sensitive,\*/\?stand for literals;gettakes an exact key only - Ordered, filterable listing — forward or reverse, keys only, values only, or full entries
- SQLite-backed — one file per database under
$XDG_CONFIG_HOME/skate/(or~/.config/skate/; see Data directory) - Iterable streams —
list()yields results without loading everything into memory - TTL / expiry — integer epoch seconds, so an entry means the same instant on every machine and timezone;
pruneremoves expired residue - Levenshtein suggestions — a key miss prints the closest live key (within half the query's character length) to stderr
- Import/Export — JSON and YAML (
ymlaccepted), fail-fast: a bad document or an unround-trippable value refuses before writing anything - Atomic transactions —
transaction()wraps a callback in BEGIN IMMEDIATE/COMMIT/ROLLBACK - Plain text output — no color, no escapes ever, script-friendly by contract
Install
composer require sugarcraft/candy-kv
The CLI lands at vendor/bin/candy-kv; invoke it as vendor/bin/candy-kv or
add vendor/bin to your $PATH. A rolling dev-channel phar (self-contained,
stamped dev-<sha>) is also published:
wget https://github.com/detain/sugarcraft/releases/download/candy-kv/dev/candy-kv.phar -O candy-kv chmod +x candy-kv
YAML import/export works out of the box with candy-kv's own flat YAML dialect.
Installing symfony/yaml
(composer require symfony/yaml) upgrades the YAML route to the full
component — same data contract, sturdier quoting. Source-tree builds report
--version as dev-master; phar builds carry their stamped version.
Quick Start
use SugarCraft\Kv\Store; $store = new Store(); // Set and get $store->set('greeting', 'Hello, World!'); echo $store->get('greeting'); // Hello, World! // With a database — target it with the `key@db` suffix. // (set()'s 3rd parameter is `bool $binary`, get()'s is a fallback string — // a db name NEVER goes there; the suffix is the only routing mechanism.) // Values are stored UNENCRYPTED (see Privacy & file modes below). $store->set('token@passwords', 'ghp_xxxx'); echo $store->get('token@passwords'); // List all entries (lazy generator of Entry objects) foreach ($store->list() as $entry) { echo "{$entry->key} => {$entry->rawValue()}\n"; } // Glob patterns (list/delete only — get is exact-key) foreach ($store->list(pattern: 'user-*') as $entry) { ... } // Delete (returns the number of entries removed; patterns too) $removed = $store->delete('greeting');
CLI
To try the CLI without touching your real store, sandbox it first:
export XDG_CONFIG_HOME=$(mktemp -d).
candy-kv set <key> [value] [--ttl SECONDS] [--binary|--text] # reads stdin if no value candy-kv get <key> [--sanitize] # exact stored bytes by default candy-kv list [-k|-v] [-r] [-d DELIM] [pattern] # one row per entry candy-kv delete <key> [--dry-run] # exact key or glob pattern candy-kv list-dbs # databases present in the dir candy-kv prune # remove expired residue (default db) candy-kv import <json|yaml|yml> <path> [--no-atomic] # path=- reads STDIN candy-kv export <json|yaml|yml> [db] [pattern] candy-kv --help # or: help candy-kv --version
--ttlaccepts both attached and space forms and must be a positive whole number of seconds (1..999999999).--ttl 0is a usage error, not "expire immediately" and not "forever" — omit--ttlfor a forever entry.setauto-detects binary: a value containing a NUL byte or invalid UTF-8 is stored base64-encoded with the binary flag.--binaryforces that,--textforbids it.getprints the exact stored bytes (no added newline, no scrubbing) so round-trips and$(candy-kv get k)stay honest.--sanitizecollapses the value to one control-free line for terminal printing.--dry-runreports how many entriesdeletewould touch and writes nothing.--ends option processing:set backup -- --ttl=60stores the literal value--ttl=60. Unknown options and surplus positionals are refused (exit 2) rather than silently ignored — a typo'd--ttl300never becomes a forever secret.-daccepts the delimiter attached (-d,) or spaced (-d ,); the printed delimiter is the raw bytes you asked for.
Exit codes
| Situation | Code |
|---|---|
| success | 0 |
key/pattern found nothing (get miss, delete miss) |
1 |
| a runtime error (unreadable store, failed write) | 1 |
usage error: unknown command, unknown option, bad/missing arguments, --ttl 0, pattern passed to get |
2 |
Nothing is written on exit 2 paths.
Output format
Real transcript (sandboxed $XDG_CONFIG_HOME; # annotations are not part of
the output):
$ candy-kv set greeting 'Hello, World!' # silent on success
$ candy-kv set color blue
$ candy-kv get greeting
Hello, World! # exact bytes, no trailing newline
$ candy-kv list
color blue # key, TAB, value; rows newline-terminated
greeting Hello, World!
$ candy-kv get colar
key not found; did you mean 'color'? # stderr; exit 1
$ candy-kv delete 'user-*' --dry-run
Nothing deleted: no entries match "user-*". # stderr; exit 1 (no user-* keys exist)
$ candy-kv list -v
blue
Hello, World!
$ candy-kv list-dbs
default
notes
Newline discipline: get never adds a newline (piped set stores stdin
byte-verbatim, so echo token | keeps the trailing newline — use printf %s
or pass the value as an argument for exact bytes); list, delete, prune,
and import success lines are newline-terminated one-shots. list scrubs
control bytes from stored keys/values for the terminal (a multi-line value
prints as one collapsed line); the -d delimiter itself prints raw. Binary
entries print as <binary N bytes> in list — their decoded bytes come from
get (exact) or saveTo().
TTL (Time-To-Live)
candy-kv set session:abc123 '{"user":"bob"}' --ttl=3600 # expires in one hour
Expiry is stored as an integer epoch second, so a database written in one
timezone reads with the same true instants everywhere. Pre-2026-10 databases
stored offset-bearing ISO strings — candy-kv migrates those rows once, on
open, preserving each row's exact instant. A row is live at expires_at >= now; Entry::isExpired() applies the same instant rule. Expired entries are
invisible to every reader (get, list, delete, count) but still occupy
disk until you candy-kv prune, which deletes exactly the complement set.
STDIN
When no value is given as an argument, set reads from stdin. For import, use - or /dev/stdin as the path:
printf %s my-secret-token | candy-kv set api-token candy-kv import json - < backup.json
Note: piped values are stored verbatim — the full stdin is read with no trimming, byte-for-byte (and without a size bound; see Import/export memory).
Key naming, @, and escapes
The rightmost unescaped @ routes the key: cache@notes stores entry
cache in database notes. A literal @ inside an entry key is spelled
\@, so support\@acme is one key regardless of other @ signs. A key with
no separator stays in the default database, and a leading @ (@x) is a
literal key, never a database suffix. More than one unescaped @ in the
entry part (a@b@c) is refused rather than guessed. The same law is public
in the library: Store::splitKey('a\@b@db', 'default') returns
['db', 'a@b'].
Import / Export
Import and export support JSON and YAML (yml alias). Import runs inside an
atomic transaction by default; pass --no-atomic for multi-database or
non-atomic imports:
candy-kv import json backup.json candy-kv import yaml - # read from STDIN candy-kv export json > dump.json candy-kv export yaml mydb 'user-*' # export filtered keys from one database
The shape contract is strict in both directions. A top-level JSON list or
YAML sequence is refused ("got a list/sequence") instead of importing
indices as keys; values that cannot round-trip as stored text (booleans,
arrays, objects) refuse the whole import before anything is written —
no silent drops, no partial loads. "_ttl" entries must be whole seconds; a
zero/negative TTL on an entry refuses that import too (a forever entry omits
the TTL instead). An atomic import that would write to more than one database
refuses unless --no-atomic (library: atomic: false) is given, so the
BEGIN/COMMIT guard cannot be bypassed by value shapes.
Exporting with an explicit [db] argument qualifies every key to
key@dbname so the dump reimports into the same databases; a pattern or
default-database export prints bare keys. An empty database exports as an
empty JSON object ({}), not a list.
Both formats are value-preserving: backslashes, newlines, colons, empty
strings and control bytes round-trip, and binary entries come back as
binary — through JSON and both YAML engines. Remaining TTLs are exported
for live entries only (expired rows are invisible to the reader the export
walks). Note that import/export buffer the whole document in memory (a
binary value costs ~1.33× in base64 text); the streaming surface is
list()/saveTo().
JSON envelope. Plain "key": "value" pairs. Binary entries ride
base64; their keys are listed in a reserved "_binary" array. Remaining
TTLs (whole seconds) collect into a reserved "_ttl" object:
{
"session:abc123": "{\"user\":\"bob\"}",
"icon": "iVBORw0KGgo=",
"_binary": ["icon"],
"_ttl": { "session:abc123": 3540 }
}
YAML envelope. A flat key: value dialect — one pair per line, keys
quoted when needed, values quoted/escaped as required. Binary entries ride
the same base64; TTLs become skate_ttl_<key> integer lines and binary
flags skate_binary_<key> lines. When symfony/yaml is installed it renders
and parses the identical flat shape (plus !!binary tags for stray control
bytes, which the fallback parser also reads). A single process always uses
one engine; the fallback refuses constructs only symfony wraps for very long
plain scalars, and it says which line failed. Export refuses keys that would
collide with the reserved shapes of the format being written — _ttl /
_binary for JSON, skate_ttl_… / skate_binary_… for YAML — instead of
emitting unreadable files.
CLI surface vs library surface
The CLI covers set, get, list, delete, list-dbs, prune, import
and export. Everything else is library-only, by design: Store::entry(),
suggestionFor(), setWithTtl(), fuzzyFilter(), setFile(), saveTo(),
transaction(), deleteDatabase(), listDatabases(), and the whole
single-file surface (Database::count(), raw(), allKeys(),
pruneExpired(), close()) have no CLI verbs — the binary stays a thin
front end for the everyday loop. Key-bearing commands route to a named
database through the key@dbname suffix; list alone always scans the
default database (patterns cannot select a db), while export <format> [db]
takes an explicit database name.
Public API
Store
| Method | Purpose |
|---|---|
__construct(?string $dataDir = null, string $defaultDb = 'default') |
Optional data dir (else resolveDataDir()); touches nothing on disk — databases open on first use |
resolveDataDir(): string (static) |
XDG_CONFIG_HOME/skate → $HOME/.config/skate → passwd home; refuses relative env values; throws when no home exists |
const DEFAULT_SUBDIR |
.config/skate — the directory segment under the home (historical name, see Data directory) |
set(string $key, string $value, bool $binary = false, ?int $ttlSeconds = null): Entry |
Store a value; $binary base64-wraps it; TTL in whole seconds (the CLI auto-detects what this parameter forces) |
setWithTtl(string $key, mixed $value, int $ttlSeconds): void |
Convenience setter — stringable values (scalars plus __toString objects); arrays, resources and non-stringable objects throw; ttlSeconds <= 0 is a documented NO-OP (stores nothing) |
get(string $key, string $fallback = ''): string |
Exact-key read; pure — no terminal writes, expired or missing keys return the fallback |
entry(string $key): ?Entry |
Full entry (metadata included, live rows only) |
suggestionFor(string $key): ?string |
Closest live key within half the query's codepoint count (what the CLI prints on a miss); null when nothing is near |
list(?string $pattern = null, ?string $dbName = null, bool $reverse = false, string $mode = 'all', string $delimiter = "\t") |
Lazy generator — Entry objects, or plain string keys/values with $mode 'keys'/'values'. The $delimiter parameter is accepted for signature compatibility but no path applies it any more (the CLI prints the raw delimiter; kept because removing it breaks the public API — slated for a v2 removal) |
delete(string $key): int |
Exact key or glob; returns live entries removed |
pruneExpired(): int |
Permanently delete expired rows from the default database (readers never see them; this reclaims the disk) |
setFile(string $key, string $filePath): Entry |
Store file contents as a binary entry |
saveTo(string $key, string $filePath): bool |
Write an entry to disk, created/tightened 0600 before any byte lands |
fuzzyFilter(string $query, ?string $dbName = null): array |
Live Smith-Waterman scored key search (sugar-fuzzy), best match first |
listDatabases(): array |
Database names in the data dir (settings is reserved — see Database::listDatabases()) |
deleteDatabase(string $dbName): bool |
Closes the handle, unlinks <db>.db + WAL/SHM sidecars; false if any file survives |
transaction(string $dbName, callable $fn): mixed |
BEGIN IMMEDIATE/COMMIT/ROLLBACK around a callback (no nesting) |
sanitizeForTty(string $text): string (static) |
Control-char scrub used for terminal printing (the CLI's lossy list preview; exact bytes always come from get/rawValue()) |
splitKey(string $key, string $defaultDb): array{0:string,1:string} (static) |
The escape-aware @ routing law, public |
dataDir(): string / defaultDatabase(): string |
Accessors |
Entry / Database
Entry carries key, value (the stored form — base64 text for binary
entries), binary, createdAt/modifiedAt (DateTimeImmutable, display
timestamps only), expiresAt (?DateTimeImmutable), isExpired(), and
rawValue() which base64-decodes when binary is set (a binary row holding
corrupt base64 raises rather than printing its own encoding as content).
Entry::binary($key, $bytes) is the factory for the binary form.
Database is the single-file layer behind a store name:
get/raw/set/delete/deleteMany/list/allKeys/count/pruneExpired/transaction/ close — count(?pattern) counts live entries, raw() skips the expiry
filter (the documented bypass: a live-only read never shows residue, raw
and pruneExpired are how you see and clear it), and pattern operations
share the byte-exact, case-sensitive semantics of exact ones.
Privacy & file modes
- Values are stored unencrypted — SQLite is a container, not a vault. The
token example above is readable with
sqlite3or any text editor. - Database files are created
0600(owner-only) and existing files are tightened to0600on open; the data directory is created0700. WAL mode adds*-wal/*-shmsidecars, also0600.saveTo()likewise chmods its target before writing. geton a typo prints the closest key to stderr. On shared machines, remember key names can themselves be sensitive — stderr is still a log. Suggestions fire only within half the query's length; a far-from-anything miss says justkey not found.- Command lines are not secret storage. Anything passed as an argument
(
candy-kv set token "$SECRET") is visible tops, shell history, and any process accounting. Piping via stdin keeps the value out of argv; use--binary/stdin for high-value entries where you can. - Backups: copy the
.dbtogether with its-walsidecar (or checkpoint first). Copying only*.dbunder WAL can lose the most recent writes.deleteDatabase()reports failure (returnsfalse) when a sidecar could not be removed — a surviving-walcan still hold recoverable rows.
Data directory
Default: ~/.config/skate/ (respects $XDG_CONFIG_HOME). Each database is
its own SQLite file: <dir>/<dbname>.db (default.db for unsuffixed keys).
Neither the directory nor any database file is created until the first
write. XDG_CONFIG_HOME and HOME, when set, must be absolute paths — a
relative home would silently move the store with the working directory, so
candy-kv refuses it. Database names are [A-Za-z0-9_-]+.
The directory keeps the pre-rename skate name on disk so existing
installs (and upstream skate itself) keep working; new code can point
$XDG_CONFIG_HOME anywhere. If neither XDG_CONFIG_HOME nor $HOME is set,
candy-kv falls back to the passwd home and throws loudly when even that is
unavailable — it never writes into the install tree.
Language
CLI diagnostics and success lines are localized: the binary selects a locale
from LC_ALL → LC_MESSAGES → LANG at start-up (16 shipped locales, see
lang/). candy-kv --help and the argument parser's own errors stay English
by design — they are the reference contract and are quoted verbatim by the
test suite. Library diagnostics throw English exceptions (matching the rest
of the fleet); the translated surface is what end users see.
Demos
Glob pattern matching
Shared foundations
candy-kv uses sugar-fuzzy for Smith-Waterman scored matching: Store::fuzzyFilter() is a live API that ranks keys against a free-text query. Glob-to-SQLite LIKE pattern filtering remains the active filter path for list/delete.
Accessor deprecation (2026-10-24)
Per the house bare-accessor convention (AGENTS.md: "Bare accessors (no get"), a fleet-wide sweep), two names were swept. Database::getRaw() became its bare twin raw() — raw was unclaimed on the class (the store verbs get/set/list are the canonical vocabulary, no raw among them). Store::getFile() became saveTo(): the operation writes store data out to disk, so a direction verb is the era-correct name; saveTo() won over export() because the package's own CLI already owns the export command (a whole-database dump) and a per-entry Store::export() would blur that vocabulary. The store-side mutator setFile() keeps its name — it is the file form of the canonical set() verb. The legacy spellings stayed as byte-identical one-line BC forwarders, each marked @deprecated with REMOVED-IN: 2.0.0; they will be removed in candy-kv v2.0.0 (see CHANGELOG.md).
| Namespace | Class | Deprecated | Use instead |
|---|---|---|---|
Kv |
Database |
getRaw() |
raw() |
Kv |
Store |
getFile() |
saveTo() |
tests/AccessorDeprecationTest.php pins each BC leg on live objects (entry
equality through getRaw()/raw() including the missing-key path; identical
bool and landed bytes through getFile()/saveTo() on a hit and a miss), the
forwarder shape (single return line + markers), a discovery-first src/
census with an inverse leg that reddens any unmarked public get*(), and this
table's agreement with the census. Internal call sites in src/ and
examples/ use the bare forms. The CLI get command is unrelated to the
accessor rule and stays.
Contributing & extending
Public classes are final by design — compose them rather than inherit. The
familiar loop is: store verbs in Store, single-file mechanics in
Database, argument discipline in src/Cli/ArgParser.php (every flag gets
answered or refused — never silently ignored), and copy in lang/*.php
(tests/LocaleParityTest.php requires every locale to carry every key and
placeholder). Repo-wide conventions live in the monorepo root AGENTS.md;
lib-local gotchas accumulate in CALIBER_LEARNINGS.md.
License
MIT
Credits & inspiration
Originally inspired by the Go Charm ecosystem (and the skate key/value tool that grew out of it); candy-kv is a native PHP project, not a line-by-line port.
