Analyzed the full 131-file docs tree (4 parallel classifiers) against the shipped woc/wovm toolchain. Removed 21 stale docs, kept all intentional history (Rust-track plans/done, the runtime/database design series cited by current specs, syscall/postgres/assembly/c-runtime studies, discarded/ learnings). Deleted: - old-runtime "front door": writeonce-pl.md, runtime/wo-language.md (pitched the Rust wo runtime -- REST/LiveView/SQL+Cypher -- as the current language; contradicted the new README) - v1 design set: 02-recovery, 03-data, 04-ui, 05-datalayer, 06-markdown-render, 07-ssl; runtime/database/05-go-sdk - future-scope/ai-agents-content-management (unfinished old-runtime CMS) - the ##ui/.htmlx LiveView frontend track (product decision to abandon): 9 plan/exploration/ui/*, plan/14-mvc-ui-implementation, superpowers/plans/2026-08-01-ui-htmlx-live; 13d pricing-UI board row Tree left link-clean: 46 dead links to the removed docs neutralized to plain text or deleted as pure see-also bullets across 20 kept docs; whole-tree link-resolving scan reports zero links to any deleted file. Removal recorded in discarded.md; board Frontend section + project-structure tree updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
Phase 7 — Replacing wo-seg with the writeonce Database
A phased coexistence plan: abstract the article store behind a trait, stand up the
.woengine as a second implementation, dual-run, cut over, decommission.
Previous: Phase 6 — Low-Code Full-Stack | Index: database.md
Context
Today's writeonce runtime stores articles in a hand-rolled append-only file format:
crates/wo-seg(~475 LOC) —.segbinary file: magic + header +[u32 length][u8 flags][payload]records serialized withbincode.SegWriter::append()returns a byte offset usable as an index pointer. Tombstoning flips a flag byte. No transactions, no MVCC, no concurrent writers.crates/wo-index— sidecartitle.idx,date.idx,tags.idxfiles built from the.seg. Queries hit the index to resolve to a byte offset, then the.segto load the record.crates/wo-store— composes the two above, owns cold-start (rebuild fromcontent/), and exposes the query API the rest of the system calls:get_by_title,list_published,list_by_tag,list_by_date_range,count_published,ingest_article,article_version.
This is the Phase 1 answer ("no external DB — add a petgraph-backed mappings.idx") made flesh. It is correct for a single-writer blog and wrong for everything Phases 2–6 want to deliver: no ACID across multiple shapes, no live subscriptions, no cross-paradigm queries, no declarative schema, no codegen.
The six-phase .wo design is the replacement. This doc plans the migration — how to swap wo-seg for the .wo engine without halting writeonce while the engine is built over multiple quarters.
Intended Outcome
crates/wo-segis deleted.crates/wo-storeeither (a) becomes a thin facade over the.woengine or (b) disappears, with callers depending directly on the engine's Rust SDK.- Writeonce's serving path is unchanged from the user's perspective throughout the migration.
- The
.woengine reaches production-ready status incrementally; each milestone is independently shippable.
Strategy — Phased Coexistence
Do not big-bang. The seg-based store works; replacing it takes many months. Instead:
- Abstract the existing store behind a Rust trait — one weekend of mechanical refactor, zero behavior change.
- Build the
.woengine crates next to seg, not in its place. Port the C++ prototype (prototypes/wo-db/) to Rust so the engine lives in the same Cargo workspace as the blog. - Dual-run — writes go to both backends, reads to seg. Compare results in CI and on production data. This surfaces engine bugs without user impact.
- Cut over reads once the engine passes dual-run. Writes still hit seg as a cold standby.
- Decommission seg when enough time has passed without rollback and a restore-from-seg fallback is no longer load-bearing.
wo-seg + wo-store ─────────► trait-abstracted ─────► dual-write, read seg ─────► read wo-db, write both ─────► wo-db only, delete wo-seg
(today) (phase A) (phase B) (phase C) (phase D)
Each transition is reversible — flip one feature flag or swap one trait object back.
Proposed Crate Layout
Port the C++ prototype (prototypes/wo-db/src/*) to Rust, split along the natural seams. New-runtime crates are unprefixed; v1 crates keep wo- in reference/crates/.
| Crate | Purpose | Prototype source | Phase |
|---|---|---|---|
ql |
.wo grammar: lexer, parser, AST |
src/lexer.*, src/parser.*, src/ast.hpp |
2 |
value |
tagged Value + path utilities |
src/value.hpp, path helpers in src/storage.cpp |
2 |
engine |
in-memory executor (sql / doc / graph), schema catalog | src/storage.*, src/executor.* |
2 |
txn |
MVCC, snapshot isolation, RETURNING alias table |
new (Phase 2 milestone 3) | 2 |
wal |
write-ahead log + fsync + crash recovery | new (Phase 3) | 3 |
sub |
live subscriptions — delta frames on commit | new (Phase 4) | 4 |
http |
wire protocol — REST / GraphQL-over-WS / native codec | new (Phase 4) | 4 |
db |
top-level facade: open(), Tx, Query, Subscribe — the Rust SDK |
integrates the above | 2–4 |
gen |
codegen: .wo type → Rust structs, Go structs, TypeScript |
sa-gen/wo-gen in Phase 5 |
5 |
All 15 crates (these 14 plus the existing rt binary crate) now exist as empty skeletons in crates/. See crates/README.md and docs/plan/done/01-scafolding-crates.md for the scaffolding plan that landed them.
Today's reference/crates/wo-seg and reference/crates/wo-index remain in the v1 nested workspace for the entire migration window. They disappear only at the end of Phase D.
reference/crates/wo-store evolves but survives — it becomes the writeonce-specific glue layer (trait, article domain model, content-directory cold-start) whose backend is swappable.
Phase A — Abstract the Article Store
Goal: every caller depends on a trait, not on wo_store::Store directly. Zero behavior change.
Work:
- Define
trait ArticleStoreinwo-store/src/lib.rswith the existing public API:pub trait ArticleStore: Send + Sync { fn get_by_title(&self, sys_title: &str) -> io::Result<Option<Article>>; fn list_published(&self, skip: usize, limit: usize) -> io::Result<Vec<Article>>; fn list_by_tag(&self, tag: &str) -> io::Result<Vec<Article>>; fn list_by_date_range(&self, start: i64, end: i64) -> io::Result<Vec<Article>>; fn count_published(&self) -> io::Result<usize>; fn ingest_article(&mut self, json_path: &Path) -> io::Result<String>; fn article_version(&self, sys_title: &str) -> Option<u64>; fn content_dir(&self) -> &Path; } - Rename the existing
Storestruct toSegStoreand implementArticleStorefor it. Re-exportSegStore as Storefor one release to avoid churn at call sites. - Change
wo-route,wo-serve,wo-sub,wo-htmlxto take&dyn ArticleStore(or generic<S: ArticleStore>). The trait import stays inwo-store; concrete impls move to sibling crates. - Add a tiny
wo-store::open(content_dir, data_dir) -> Arc<dyn ArticleStore>factory that picks the backend based on a config env var (WO_STORE_BACKEND=seg|db|dual).
Exit criteria: cargo test passes; wo serve boots unchanged; git log shows one PR.
Phase B — Stand Up wo-db in Rust
Goal: a Rust wo-db crate that speaks the full .wo grammar from the prototype, stored in memory, with an ArticleStore impl mapping writeonce's Article onto the relational paradigm.
Work:
- Port
prototypes/wo-db/(C++) to Rust crates per the layout table above. Thewonamespace becomes thewo_*crate family; the test suites (tests/smoke.wo,tests/checkout.wo) run as Rust integration tests. - Define a
.woschema for the writeonce domain (in a new file,crates/wo-store/schema.wo):type Article { id: Id sys_title: Slug @unique title: Text published: Bool = false published_at: Timestamp? author: Text tags: [Text] meta: { excerpt: Text, body_md: Markdown } } - Add
DbStore— a secondArticleStoreimpl that translates calls into.woqueries:get_by_title(t)→SELECT * FROM Article WHERE sys_title = $t(one row)list_published(skip, limit)→SELECT * FROM Article WHERE published = true ORDER BY published_at DESC LIMIT $limit OFFSET $skiplist_by_tag(t)→SELECT * FROM Article WHERE $t IN tagslist_by_date_range(a, b)→SELECT * FROM Article WHERE published_at BETWEEN $a AND $bcount_published→SELECT COUNT(*) FROM Article WHERE published = trueingest_article(path)→ load JSON →INSERT INTO Article (…)
- Cold-start path: when the data dir is empty,
DbStore::openloads allcontent/*.jsonthe same waySegStore::opendoes today and inserts into the engine. - Gate behind
#[cfg(feature = "db-backend")]so seg-only builds keep working until Phase C.
Exit criteria: DbStore passes the same unit tests as SegStore (rename Store → ArticleStore in test assertions). Memory footprint and per-query latency measured against seg; both within an order of magnitude.
Phase C — Dual-Write, Read Seg
Goal: every mutation hits both backends; reads stay on seg; a differ flags mismatches.
Work:
- Add
DualStore— a thirdArticleStoreimpl that forwards writes to bothSegStoreandDbStoreand returnsSegStoreresults for reads. - Add a background task (
wo-store::differ) that on every ingest runs every query method against both backends and compares results. Mismatches → structured log entry (store_mismatchevent) + a Prometheus counter. - Set
WO_STORE_BACKEND=dualon staging for two weeks, then on prod behind a rollout flag.
Exit criteria: zero store_mismatch events for 14 consecutive days on production traffic.
Phase D — Cut Over Reads, Keep Seg as Fallback
Goal: reads served from DbStore; seg still receives writes and is kept queryable as a cold standby.
Work:
- Invert
DualStore: writes to both, reads fromDbStore. - Add an admin command
wo db verify --against segthat re-runs the differ on demand (for post-incident checks). - After a stable month, remove
DualStoreentirely.WO_STORE_BACKEND=dbbecomes the only supported value.
Exit criteria: one month with no read-path regressions; no active rollback capability needed for routine ops.
Phase E — Decommission wo-seg
Goal: delete crates/wo-seg, shrink crates/wo-store to the trait + content-directory cold-start.
Work:
- Delete
crates/wo-seg. Removewo-segfromCargo.tomlworkspace members and fromwo-store/Cargo.tomldeps. - Delete
SegStorefromwo-store. The traitArticleStoreandDbStoreremain. - Delete the
.segfile from production data directories (via a migration: verifyDbStorehas every record, thenrm). - Delete
crates/wo-indexif and only ifDbStorehas replaced its indexes with the engine's internal ones. If the LSM/graph indexes insidewo-dbcover the three sidecar indexes (title, date, tags) — expected — then wo-index goes too. If any index is still load-bearing outside the engine, keep it.
Exit criteria: CI is green with the deletions; production runs a release cycle without rollback; rg "wo-seg\|wo_seg" returns zero hits.
Integration Touchpoints
These crates reference the store today and will need light updates for Phase A (trait swap):
| Crate | Current coupling | Change |
|---|---|---|
wo-store |
owns Store, depends on wo-seg + wo-index |
gains trait + factory + dual-write impl (A–C); shrinks to facade in E |
wo-route |
likely consumes &Store |
accept &dyn ArticleStore |
wo-serve |
HTTP handlers read the store | accept Arc<dyn ArticleStore> |
wo-sub |
subscription layer | later — see below |
wo-htmlx |
may read article state during render | accept trait or projection |
wo-watch |
inotify-driven ingest | unchanged; still calls ingest_article |
wo-rt |
runtime glue | pass the trait object through |
wo-sub is a special case. Today it likely polls or reacts to article_version monotonic counters. When Phase 4 activates LIVE queries inside wo-db, wo-sub should stop doing its own diffing and become a pass-through for engine-emitted deltas. That transition happens in Phase C/D, not Phase A — it's not required for the trait refactor.
Risks
- Cold-start cost.
SegStorebuilds its indexes in one pass over.seg.DbStorehas to parse JSON fromcontent/the same way but also commit through the engine's WAL. If this is slow, add awo db import --from-seg <path>shortcut that bulk-loads from an existing.segwithout going through the ingest path. - Memory footprint. Today's seg-based path
mmaps the file; the.woengine is RAM-primary. For a blog with hundreds of articles, immaterial; for a larger dataset, Phase 3's SSD-backed variant is what's needed. - Article →
.wotype drift.wo_model::Articleis the canonical domain type today. The.woschema mirrors it, but if the two diverge (a new field is added toArticlebut not to the schema), queries silently drop that field. Mitigation:wo-genshould include a--verify wo_model::Articlemode in Phase 5 that fails CI on drift. - Dual-write contention. If ingest becomes the bottleneck during Phase C, time-box dual-write: drop it after 14 clean days rather than running it indefinitely.
- Feature flag sprawl.
WO_STORE_BACKENDshould be the only config knob. Resist per-method flags.
What's Out of Scope for This Doc
- The engine internals themselves — those live in Phases 2–4.
- The Phase 5 SDK (
wo-gen, typed Go client) — wo-store callers are Rust, and Rust codegen is part ofwo-genbut not a blocker. ##ui/##policy/##logic/##service— those are Phase 6 and assume the engine is already running.- Any graph-first features (mappings,
RELATED_TOtraversal) — they become trivially available onceDbStoreis live, but don't need to gate the seg → db cutover.
Verification
Each phase has its own exit criteria above. End-to-end verification for the whole migration:
- Parity — After Phase B: a shadow script replays one week of production ingest through
DbStorein a sandbox; every query from the shadow matches seg. - Latency — After Phase D: p50/p95/p99 of
get_by_title,list_published,list_by_tagare at or below the seg baseline. Measured by the existing request-timing middleware, not synthetic benchmarks. - Crash safety — After Phase 3 WAL ships:
kill -9during write, reopen, confirm the committed state matches and uncommitted writes are gone. Automated test. - Decommission audit — After Phase E:
rg 'wo-seg|wo_seg|\.seg\b' crates/returns zero;cargo deny checkpasses; production restart ingests fromcontent/with no.segfile present.
Related Documents
- 02-wo-language.md — the two-layer
.wolanguage the engine speaks - 03-inmemory-engine.md — the storage engine behind
wo-db - 04-client-api.md — wire protocol and
LIVEsubscriptions - 01-evaluation.md — why writeonce built
wo-segin the first place, and why that choice still looks right for the blog even as the platform grows past it prototypes/wo-db/— the C++ prototype of the.woengine, the reference implementation the Rust port follows