- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
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 - 05-go-sdk.md — the Go SDK built from
.wotypes viawo-gen - 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 - ../05-datalayer.md — current
.seg+.idximplementation details prototypes/wo-db/— the C++ prototype of the.woengine, the reference implementation the Rust port follows