A major architectural rewrite of ImageBase, moving off V/veb and onto
TrailBase for the underlying framework. The custom
sync endpoint preserves the original CRDT (last-write-wins) semantics;
everything authentication-shaped is delegated to TrailBase. The custom
code is a Rust → wasm32-wasip2 component, which keeps the trail process
at ~63 MB RSS even under load.
ImageBase/
├── Makefile # build + run helpers
├── traildepot/
│ ├── config.textproto # TrailBase server config
│ ├── migrations/main/*.sql # SQL migrations (run at startup)
│ └── wasm/imagebase_guest.wasm # built guest component (gitignored)
├── guests/rust/ # custom endpoint source
│ ├── Cargo.toml
│ └── src/lib.rs # /api/data/{app} handler
├── tests/ # Hurl integration suite
├── tasks/ # start.sh, test.sh wrappers
└── tools/import-from-v.py # one-shot V → TrailBase importer
trailCLI on$PATH- Rust toolchain via rustup with the
wasm32-wasip2target:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --target wasm32-wasip2
sqlite3,python3,hurlfor the test runner
make dev # build + run with --dev (permissive CORS, email logged to stderr)
# or
make build && make runOn first start TrailBase prints an auto-generated admin password. The admin
UI lives at http://localhost:4000/_/admin/.
POST /api/data/{app} is the only custom route. The {app} segment
namespaces every read and write, so a single TrailBase instance can back
multiple frontends without cross-tenant reads. Pick a stable id per app
(e.g. imagebase, notes, recipes) and reuse it from the client.
Request:
{
"lastSyncedId": 0,
"data": [{ "key": <json-value>, "data": <json-value>, "id": 0 }]
}Response:
{
"data": [{ "key": "<json-string>", "data": "<json-string>", "id": 42 }],
"saved": [{ "key": "<json-string>", "id": 43 }],
"conflicted": [{ "key": "<json-string>", "data": "<json-string>", "id": 12, "timestamp": "..." }],
"lastSyncedId": 43
}key and data are stored and echoed back as the JSON-serialised text of
whatever the client sent, matching the V server's @[raw] behaviour so
existing clients do not need to change their wire format.
The V server shipped hand-written auth under /api/authentication/*. On
TrailBase those endpoints live under /api/auth/v1/* and return JWT auth
tokens (plus a refresh token and CSRF token). Frontends should call:
| V server | TrailBase equivalent |
|---|---|
POST /api/authentication/register |
POST /api/auth/v1/register |
POST /api/authentication/login |
POST /api/auth/v1/login |
POST /api/authentication/logout |
POST /api/auth/v1/logout |
POST /api/authentication/forgot-password |
POST /api/auth/v1/reset_password/request |
POST /api/authentication/reset-password |
POST /api/auth/v1/reset_password/update |
session cookie named session |
auth_token (Bearer) + refresh token |
/shutdown?key=... |
drop — send SIGTERM to the process |
Schema-side:
users/sessions/password_resettables are gone; TrailBase owns_user,_session, and JWT-based reset codes.databecomesuser_datawith aBLOB user(UUIDv4) and a newapp TEXT NOT NULLcolumn scoping each row to one frontend.
tools/import-from-v.py reads a V SQLite database and inserts users +
data into TrailBase's main.db.
make build # ensures the schema is in place
python3 tools/import-from-v.py \
--old-db /path/to/v/app/imagebase.db \
--app imagebase # the app id you'll pass in the URLUse --dry-run first to see counts. Add --wipe-app to clear an existing
namespace before re-importing.
What gets ported:
usersrows →_user(fresh UUIDv4,verified=1, emptypassword_hash)datarows →user_datascoped to the chosen--app, in original id order so client-sidelastSyncedIdcursors stay monotonically usable
What does not get ported:
- Password hashes. V used SHA-256+salt; TrailBase uses Argon2id.
Imported users cannot log in until they go through
POST /api/auth/v1/reset_password/requestfollowed by/reset_password/update— emptypassword_hashrejects every login. Tell users to use the "forgot password" link on first login. - Sessions and
password_resetrows. Short-lived; TrailBase manages its own.
The script registers Python sqlite stubs for TrailBase's custom
is_uuid() / is_email() SQL functions so the inserts go through against
plain sqlite3 with no need to load a TrailBase extension.
tests/ contains Hurl scripts mirroring the original
../ImageBase/tests/, adapted to TrailBase's auth endpoints. The runner
spawns its own short-lived trail on a free port (so rate-limit state
starts empty every run and the password-reset JWT can be auto-extracted
from --dev stderr):
./tasks/test.shBy default the runner uses an isolated, throwaway data dir seeded from the
project depot (config.textproto + migrations/ + the freshly built
wasm), so autoincrement ids start clean — several tests assert exact row
ids — and real dev data is never read or written. It's removed on exit.
Set DATA_DIR to run against an existing depot instead, e.g.
DATA_DIR=../traildepot ./tasks/test.sh.
The test app id is test; routes hit are POST /api/data/test. The
prune-job tests use a separate prune app id and trigger the nightly
prune_overwritten_data cron job on demand via TrailBase's admin API.