Execution plan for
warden-roadmap.md(the warden-embedding roadmap). Each task below is sized for one agent, one isolated worktree, one PR. Tasks are self-contained: an agent should be able to implement its task from this document + the roadmap + the codebase, without prior conversation context. Design decisions are already made and recorded here — do not re-open them inside a task; if a task uncovers a reason a decision is wrong, stop and surface it instead.
These were decided up front after reading the engine source. Every task assumes them.
internal/engine, internal/store,
and internal/query all become public (engine/, store/, query/).
store.Entry and query.Filter are on the engine's public API surface;
re-exporting them via aliases creates type-identity friction for importers.
Move them._key approach (roadmap option b), not a
generalized primary index (option a). The uint64 internal id stays
untouched everywhere (Index, IndexEntry, WatchEvent, CommitTx,
compaction). String keys are a reserved _key field in data, enforced
unique by a mandatory unique secondary index and resolved to the uint64
id via IndexLookup. Rationale: option (a) touches every offset/tombstone
path in the engine; option (b) reuses the secondary-index machinery that
already survives compaction and rebuild (sidx_*.json reload in
Collection.load).
Rev uint64. Do not derive the
revision from Entry.Ts (not collision-proof). Rev is added to
store.Entry (json:"rev,omitempty" — old segments decode as rev 0) and
tracked in IndexEntry so the current rev is readable without a segment
read.DB today opens every
collection with a single defaultCfg. Add
DB.CollectionWithConfig(name string, cfg CollectionConfig) (lazy
create-or-open with an explicit config) so the façade can give each
collection its own durability/compaction settings.SyncModeInterval (~1s) by default, opt-in
SyncModeAlways per collection. The engine default (SyncModeNone) does
not change.T1 EMB-1 (package move) ── must land ALONE first; every branch conflicts with it
├── T2 EMB-3 deps-check CI (parallel-safe after T1)
├── T9 OPS-2 Watch docs + example (parallel-safe after T1)
└── T3 KEY-2 unique index
└── T4 KEY-1 string keys
├── T5 KEY-3 CAS / rev ┐ all touch collection.go —
├── T6 KEY-4 upsert │ run SEQUENTIALLY in this
└── T7 QRY-3 count/exists ┘ order, not in parallel
T5 ──► T8 EMB-2 + OPS-1 façade + embedded defaults
T4 ──► T10 OPS-3 bulk-load helper + migration docs (parallel with T8)
T8, and all of T2–T10 ──► T11 EMB-4 semver + docs/embedding.md + tag
Parallelization rules
engine/collection.go (and several also engine/secondary_index.go);
parallel agents would conflict on every hunk.Every task follows CLAUDE.md conventions: race detector on
(make test), lint (make lint), docs updated in the same PR, conventional
commit style, one PR per task. Commits must use the
29410402+srjn45@users.noreply.github.com author email or pushes are rejected.
feat/emb-1-public-enginegit mv internal/engine engine, git mv internal/store store,
git mv internal/query query.github.com/srjn45/scriva/internal/{engine,store,query}
→ github.com/srjn45/scriva/{engine,store,query}) across server/,
cmd/, and all _test.go files. Mechanical — sed/gofmt, no logic edits.engine/index.go checksums only the serialized entries map — no source
paths — so existing on-disk index.json files stay valid. Nothing on disk
changes format.internal/engine.make test and make lint pass unchanged.import "github.com/srjn45/scriva/engine", call engine.Open, and
build (verify manually with a temp module; the permanent CI check is T2).ci/emb-3-deps-checkmake deps-check: fail if
go list -deps ./engine ./store ./query contains
grpc|protobuf|prometheus|cobra|grpc-gateway.embeddemo/ module (own go.mod, replaced to the repo root)
that imports only engine and builds in CI.OnCompaction hook in CollectionConfig. This task locks that in;
if the check fails, that's a finding to fix, not to suppress.make deps-check passes locally and in CI; deliberately
adding a grpc import to engine makes it fail.feat/key-2-unique-indexEnsureIndex(field string) to accept uniqueness — keep the old
signature working (e.g. add EnsureUniqueIndex(field) or variadic
options; pick whichever fits existing call sites in server/grpc.go and
the CLI with least churn) and persist the unique flag in sidx_<field>.json.sidxIndexEntry/sidxUpdateEntry under c.mu.Lock, so a
check-then-set there is atomic. A unique index mapping the value to a
different live id ⇒ reject the write with a typed
var ErrDuplicateKey = errors.New("engine: duplicate key")
(wrap with field/value context). Reject on insert and update; the
rejected write must not append to the segment or mutate any index.CommitTx must enforce it too (pre-validate staged ops before applying).sidx.rebuild) must tolerate historical duplicates
gracefully (last-write-wins already resolved them) — uniqueness is
enforced on new writes.errors.Is(err, ErrDuplicateKey).feat/key-1-string-keys_key field, decision #2):
_key. Insert/Update reject user data that
sets _key directly (typed error) — it is only settable via the keyed
API.Collection.InsertWithKey(key string, data map[string]any) (uint64, time.Time, error)
— stamps data["_key"] = key, ensures the collection's unique _key
index exists (create lazily on first keyed write, or at
collection-open via config), inserts; duplicate key ⇒ ErrDuplicateKey
(from T3).FindByKey(key), UpdateByKey(key, data), DeleteByKey(key) — resolve
via IndexLookup("_key", key) (O(1)) then the uint64 path. Missing key ⇒
typed ErrKeyNotFound._key field lives in data, so this mostly
falls out — test it anyway).Insert's uint64 return, the primary index type,
or WatchEvent (its Data already carries _key)._key injection via plain
Insert rejected.FindByKey("sess-abc123") is O(1) and stable across
compaction and restart.feat/key-3-casstore.Entry gains Rev uint64 (json:"rev,omitempty"); old segment
lines decode as rev 0 — fully backward compatible.IndexEntry gains Rev uint64 so current rev is readable without a
segment read. Insert ⇒ rev 1; each update ⇒ rev+1. Rebuild recomputes
revs by replay order (count writes per id). Compaction preserves the
latest entry's rev.Rev to ScanResult and return it from
FindByID/FindByKey (extend return types or add a Get-style method
returning a small record struct — prefer a Record{ID, Key, Rev, Ts, Data}
struct to stop the return-tuple growth).c.mu.Lock critical section:
UpdateIfRev(key string, expectedRev uint64, data map[string]any) (applied bool, err error)UpdateIfMatch(key string, pred func(cur map[string]any) bool, data map[string]any) (applied bool, err error)
(warden's UpdateStatusIf/FinalizeExit/ctxstore CAS check current
values, so the predicate form is the one warden mostly uses).(false, nil) — a clean
no-op, never an error.(false, nil); rev increments
across update/compaction/reopen; old rev-less segments load fine.UpdateStatusIf and context CAS map 1:1 with no
app-side locking.feat/key-4-upsertcollection.go conflicts).Upsert(key string, data map[string]any) — one c.mu.Lock
critical section: key present ⇒ append update (rev+1); absent ⇒ append
insert (rev 1). Emits the corresponding Watch event.feat/qry-3-count-existsCount(f query.Filter) (uint64, error):
nil/MatchAll ⇒ index.Len() (O(1)); single eq-filter on an indexed
field ⇒ len(IndexLookup(...)); otherwise fall back to a scan that
counts without materializing results (do not reuse Scan and take
len — the slow path currently builds a full latest map of all data;
counting needs ids/ops only, not Data).Exists(key string) (bool, error) — IndexLookup("_key", key), O(1).len(Scan(...)) for indexed, non-indexed, and
match-all filters; exists true/false; exists O(1) (no segment reads —
assert via a counter hook or by construction).feat/emb-2-facadeDB.CollectionWithConfig(name string, cfg CollectionConfig) (*Collection, error)
— open-or-create with an explicit per-collection config; plain
Collection/CreateCollection behavior unchanged.scriva (public):
scriva.Open(dir string, opts ...Option) (*DB, error)(*DB).Collection(name string, opts ...CollectionOption) (*engine.Collection, error)
and MustCollection.SyncModeInterval @ 1s, engine defaults
otherwise. Per-collection override to SyncModeAlways (e.g. a spend
ledger) must be one option. Document the rationale (crash-torn-write
safety without per-write fsync; matches warden's temp+rename trade-off).docs/embedding.md with the open-collections-CRUD-close
example (T11 completes this doc).sessions, events, messages,
context, spend) stands up in <10 lines.docs/ops-2-embedded-watchCollection.Subscribe already returns a
buffered channel with the OpOverflow resync sentinel, fully in-process.
OpOverflow) in docs/embedding.md
(create the file if T8 hasn't; merge cleanly if it has).examples/watch/ or an Example* test) showing
subscribe → writes → events → overflow handling, no server.feat/ops-3-bulk-loadCollection.LoadJSONL(r io.Reader, keyField string) (n int, err error) —
stream NDJSON records, insert each (via InsertWithKey when keyField
is non-empty, else Insert), batching under the write lock for
throughput; document that it uses the normal write path (indexes, watch,
durability all apply).docs/embedding.md: a "migrating an existing JSON store" section
covering the warden layout — per-file sessions (split embedded Events
into an events collection), mailbox files → messages,
context.json → keyed context records. The importer itself is
warden-side; this documents the ScrivaDB half of the contract.docs/emb-4-semverdocs/embedding.md: full API reference for the stable surface
(scriva.Open, engine.DB/Collection incl. keyed/CAS/upsert/count,
query.Filter, store.Entry, Watch contract, durability modes,
migration section) — reconcile the seeds from T8/T9/T10 into one doc.README.md: add an "Embedding" section; note distribution is go get
for this use case (brew/apt/GHCR remain for the standalone server).v0.x.0 (per CLAUDE.md: push the tag; CI releases — coordinate with
the release-roadmap versioning before choosing the number).go get github.com/srjn45/scriva/engine@v0.x.0 works from
a clean module and the documented API matches what ships.| Task | Item | Depends on | Status | PR |
|---|---|---|---|---|
| T1 | EMB-1 public engine 🔴 | — | ☑ done | #19 |
| T2 | EMB-3 deps-check CI | T1 | ☑ done | #23 |
| T3 | KEY-2 unique index | T1 | ☑ done | #25 |
| T4 | KEY-1 string keys 🔴 | T3 | ☑ done | #26 |
| T5 | KEY-3 CAS / rev 🔴 | T4 | ☑ done | #28 |
| T6 | KEY-4 upsert | T4 (after T5) | ☑ done | #29 |
| T7 | QRY-3 count/exists | T4 (after T6) | ☑ done | #30 |
| T8 | EMB-2 + OPS-1 façade | T5 | ☑ done | #31 |
| T9 | OPS-2 Watch docs | T1 | ☑ done | #22 |
| T10 | OPS-3 bulk load | T4 | ☑ done | #33 |
| T11 | EMB-4 semver + docs | T2–T10 | ☑ done | #35 |
Cross-referenced release-roadmap dependencies (no tasks here): QRY-1 → Q2, QRY-2 → Q3, OPS-4 → F2, OPS-5 → F3.
Spawn each task in an isolated worktree off up-to-date main, one PR per
task. A sufficient agent brief is:
Read
docs/embedding-implementation-plan.mdanddocs/warden-roadmap.md, then implement task <Tn> exactly as scoped, honoring the locked design decisions. Do not start if a dependency task's PR is unmerged. Follow CLAUDE.md for build/test/lint/docs/commit conventions. Open a PR titled with the task's conventional-commit subject.
Checklist for every task PR:
make test (race detector) and make lint passserver/grpc_integration_test.go where applicable29410402+srjn45@users.noreply.github.comCan you improve this documentation? These fine people already did:
Srajan Pathak & srjn45Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |