Platform databases (MongoDB, Redis, Neo4j) run inside the Kubernetes cluster and are not reachable from localhost. The koko CLI proxies all operations through the Koko API — authenticated, read-safe, and type-preserving.
koko is the default for every read and write on a Tawa service database — any database declared in a service's catalog-info.yaml (MongoDB, Redis, Neo4j), in prod, sandbox or uat.
Before you tell a user you have no database access, or ask them to change data by hand (for example a setting in the app's settings collection), try it:
koko databases -s <service>
koko collections -s <service> -e prod
Writes still follow Editing Prod Data: back up first, key on _id, --expect the count, and report the write.
# Install
npm install -g koko-db
# Authenticate (shares your tawa login session — run tawa login first)
tawa login
koko --version # should be 0.5.0+
Never use curl, kubectl port-forward, or raw API calls to access databases.
| Wrong | Right |
|---|---|
curl http://localhost:3001/api/db/... | koko query users -s my-api -e prod |
kubectl port-forward ... && mongosh | koko copy users --from prod --to sandbox |
| Direct MongoDB connection strings | koko export → koko import |
The koko CLI handles auth, safety guards, EJSON type preservation, and pagination automatically.
Writing to prod? Read Editing Prod Data first. The guards above do not catch a filter that matched more rows than you meant — only
--expectdoes.
koko resolves your service name automatically from the project directory. You can also pass it explicitly:
# From inside a service directory (reads catalog-info.yaml or .tawa.yaml)
koko query products -e prod
# Explicit service name
koko query products -s fairshare-cms -e prod
# List databases provisioned for a service
koko databases -s my-api
# Collection stats
koko stats -s my-api -e prod
koko collections -s my-api -e prod
# Sample a few documents (random)
koko sample orders -s my-api -e prod --limit 5
A service that declares mongodb, redis, and neo4j in catalog-info.yaml
shows all three in koko databases — each as its own row. They share the
default name {service}-{env} but are distinguished by type, so
koko tunnel / koko query can reach the MongoDB (and Neo4j) even when a Redis
binding is also present. If you only see one row (e.g. just redis) after a
deploy, your Koko predates the type-keyed registry (iec-builder#38) — redeploy
after the platform is updated and all declared databases will register.
# Find with filter
koko query users -s my-api -e prod \
--filter '{"status":"active"}' \
--limit 20 \
--sort '{"createdAt":-1}'
# With projection
koko query products -s my-api -e prod \
--filter '{"active":true}' \
--projection '{"name":1,"slug":1,"tiers":1}'
# Aggregate
koko query orders -s my-api -e prod \
--aggregate '[{"$group":{"_id":"$paymentStatus","count":{"$sum":1}}}]'
# JSON output (for piping/scripting)
koko query users -s my-api -e prod --filter '{"email":"[email protected]"}' --json
koko export outputs EJSON (MongoDB Extended JSON). ObjectIds are serialized as {"$oid":"..."} and Dates as {"$date":"..."} — not plain strings. This means the NDJSON file can be re-imported with correct BSON types and no corruption.
# Export a collection to file
koko export products -s my-api -e prod -o /tmp/products.ndjson
# Export with a filter
koko export orders -s my-api -e prod \
--filter '{"createdAt":{"$gte":"2026-01-01"}}' \
-o /tmp/recent-orders.ndjson
Import via koko import — EJSON is handled natively, no flags needed:
koko import products -s my-api -e sandbox -i /tmp/products.ndjson --upsert
If you must use mongoimport (into local MongoDB), add --legacy so it recognises $oid/$date markers:
mongoimport --db my-api-dev --collection products \
--file /tmp/products.ndjson --legacy
Prefer
koko importovermongoimport— no flags to remember, round-trip is always correct.
Server-side copy — no download, no EJSON conversion, fastest option:
# Preview count first
koko copy products -s my-api --from prod --to sandbox --dry-run
# Execute
koko copy products -s my-api --from prod --to sandbox
When you need your local MongoDB to exactly match production:
# Step 1: Export all collections from prod
koko export products -s my-api -e prod -o /tmp/products.ndjson
koko export users -s my-api -e prod -o /tmp/users.ndjson
koko export orders -s my-api -e prod -o /tmp/orders.ndjson
# Step 2: Import into local MongoDB (--legacy handles $oid/$date markers)
mongoimport --db my-api-dev --collection products --file /tmp/products.ndjson --legacy --drop
mongoimport --db my-api-dev --collection users --file /tmp/users.ndjson --legacy --drop
mongoimport --db my-api-dev --collection orders --file /tmp/orders.ndjson --legacy --drop
# OR: sync to sandbox via koko (skip --legacy, it's handled server-side)
koko import products -s my-api -e sandbox -i /tmp/products.ndjson --upsert
koko import users -s my-api -e sandbox -i /tmp/users.ndjson --upsert
After importing to local, restart your dev server to clear any in-memory caches:
# If using Next.js
rm -rf .next && npm run dev
For development seeds committed to the repo:
# Seeds live in ./seeds/<collection>.ndjson
koko seed -s my-api -e sandbox
# Single collection
koko seed -s my-api -e sandbox --collection products
# Dry-run
koko seed -s my-api -e sandbox --dry-run
koko keys -s my-api -e prod --pattern 'session:*' --limit 100
koko get -s my-api -e prod 'my-api:some-key'
koko redis-info -s my-api -e prod
koko cypher "MATCH (n) RETURN labels(n), count(n) LIMIT 10" -s my-api -e prod
koko neo4j-stats -s my-api -e prod --json
Koko connects to Neo4j as the service's own svc_* user. The builder registers the
database with an authenticated bolt://user:pass@host:port URI on every deploy, and Koko
passes those credentials to the driver as an auth token (the Neo4j driver ignores URI
userinfo by itself).
Correction (2026-10-03): before iec-builder#228 / iec-koko#47, the builder registered Neo4j as a bare
bolt://host:portand Koko opened the driver with no auth token, so both commands failed withAn unexpected error occurred(server log:Unsupported authentication token, scheme 'none') on every service. Redeploy a service that declaresneo4jto re-register it with credentials. Nothing is backfilled.
All koko operations are read-only by default:
$out and $merge aggregate stages are blockedCREATE, MERGE, DELETE, SET, REMOVE, DROP are blockedWrite operations (koko import, koko copy, koko seed) require explicit --upsert or --allow-prod confirmation for production targets.
_id, and Assert the CountThe export → modify → import loop is how prod data actually gets fixed, and it has one
failure mode that none of the guards above catch: a filter that quietly matches more
rows than you meant. koko import --upsert replaces whole documents keyed by _id, so
every extra row your filter caught gets rewritten too.
A real instance: an export filtered on {"ticketNumber":7} returned 9 documents, not
1 — ticket numbers were unique per space, not globally. Importing that file back would
have replaced 8 unrelated tickets in other spaces. Nothing was broken; every step printed
its count correctly. Nobody compared those counts to an expectation, because nothing asked
for one.
--expect (koko 0.8.0+)koko import tickets -s my-api -e prod -i fixed.ndjson --upsert --allow-prod --expect 1
# ✗ Refusing to import: the input holds 9 document(s), but --expect 1 was given — more than intended.
Pass it on every prod import. It costs one integer and turns "I assume this is one row"
into a claim the tool can refuse. Rehearse first — --dry-run --expect n performs no write
and works against prod, so you can check the count against the real data before touching it.
Check your version with koko --version; upgrade with npm install -g kokopelli.
_id, and know that --filter cannot take onekoko export --filter does not accept {"$oid": "..."}. To pull an exact document,
match on the string form of _id through an aggregate:
koko query tickets -s my-api -e prod --json \
--aggregate '[{"$match":{"$expr":{"$eq":[{"$toString":"$_id"},"6a5eba929c378de086202d91"]}}}]'
A field that looks unique often is not — it may be unique only within a parent (per space, per org). Check the model's compound indexes before trusting one as a key.
# 1. Back up the untouched rows FIRST — this is your undo.
koko export <collection> -s <svc> -e prod --filter '<filter>' -o rows.backup.ndjson
# 2. Read the count it printed. Is it the number you expected? If not, STOP —
# your filter is wrong, and no later step will tell you.
# 3. Modify a copy, never the backup. Diff each document field-by-field against
# its original so only the fields you intend actually move.
# 4. Import, asserting the count.
koko import <collection> -s <svc> -e prod -i rows.new.ndjson --upsert --allow-prod --expect <n>
# 5. Verify with a query that is CAPABLE OF FAILING (see below).
A check that cannot fail looks exactly like a check that passed. The common trap is comparing a date against a string in an aggregate — it matches nothing, every time, and reads as a clean result:
# ✗ WRONG — matches nothing regardless of the data
--aggregate '[{"$match":{"archivedAt":{"$gte":"2026-09-03T22:44:00Z"}}}]'
# ✅ RIGHT
--aggregate '[{"$match":{"$expr":{"$gte":["$archivedAt",{"$dateFromString":{"dateString":"2026-09-03T22:44:00Z"}}]}}}]'
Before trusting a verification, ask what it would print if the change had gone wrong. If the answer is "the same thing", it is not a verification.
MongoDB stores ObjectIds and Dates as special BSON types (not plain strings). When exported as plain JSON, ObjectId("abc123") becomes the string "abc123" — which looks valid but fails as a MongoDB _id on re-import because the type is wrong.
EJSON preserves the type information:
{ "_id": {"$oid": "abc123..."}, "createdAt": {"$date": "2026-01-15T..."} }
koko export always outputs EJSON. koko import always deserializes it. The round-trip is type-safe by default — no manual conversion scripts needed.
Last updated: October 6, 2026