writeonce/docs/runtime/database/07-wo-seg-migration.md
shoney.arickathil a55971d857 docs: status board at docs/00-status.md; gap-closure spec applied; recover lost doc
- 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.
2026-08-10 23:42:26 +02:00

15 KiB
Raw Blame History

Phase 7 — Replacing wo-seg with the writeonce Database

A phased coexistence plan: abstract the article store behind a trait, stand up the .wo engine 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) — .seg binary file: magic + header + [u32 length][u8 flags][payload] records serialized with bincode. 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 — sidecar title.idx, date.idx, tags.idx files built from the .seg. Queries hit the index to resolve to a byte offset, then the .seg to load the record.
  • crates/wo-store — composes the two above, owns cold-start (rebuild from content/), 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-seg is deleted.
  • crates/wo-store either (a) becomes a thin facade over the .wo engine 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 .wo engine 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:

  1. Abstract the existing store behind a Rust trait — one weekend of mechanical refactor, zero behavior change.
  2. Build the .wo engine 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.
  3. 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.
  4. Cut over reads once the engine passes dual-run. Writes still hit seg as a cold standby.
  5. 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 ArticleStore in wo-store/src/lib.rs with 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 Store struct to SegStore and implement ArticleStore for it. Re-export SegStore as Store for one release to avoid churn at call sites.
  • Change wo-route, wo-serve, wo-sub, wo-htmlx to take &dyn ArticleStore (or generic <S: ArticleStore>). The trait import stays in wo-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. The wo namespace becomes the wo_* crate family; the test suites (tests/smoke.wo, tests/checkout.wo) run as Rust integration tests.
  • Define a .wo schema 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 second ArticleStore impl that translates calls into .wo queries:
    • 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 $skip
    • list_by_tag(t) → SELECT * FROM Article WHERE $t IN tags
    • list_by_date_range(a, b) → SELECT * FROM Article WHERE published_at BETWEEN $a AND $b
    • count_published → SELECT COUNT(*) FROM Article WHERE published = true
    • ingest_article(path) → load JSON → INSERT INTO Article (…)
  • Cold-start path: when the data dir is empty, DbStore::open loads all content/*.json the same way SegStore::open does 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 third ArticleStore impl that forwards writes to both SegStore and DbStore and returns SegStore results 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_mismatch event) + a Prometheus counter.
  • Set WO_STORE_BACKEND=dual on 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 from DbStore.
  • Add an admin command wo db verify --against seg that re-runs the differ on demand (for post-incident checks).
  • After a stable month, remove DualStore entirely. WO_STORE_BACKEND=db becomes 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. Remove wo-seg from Cargo.toml workspace members and from wo-store/Cargo.toml deps.
  • Delete SegStore from wo-store. The trait ArticleStore and DbStore remain.
  • Delete the .seg file from production data directories (via a migration: verify DbStore has every record, then rm).
  • Delete crates/wo-index if and only if DbStore has replaced its indexes with the engine's internal ones. If the LSM/graph indexes inside wo-db cover 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

  1. Cold-start cost. SegStore builds its indexes in one pass over .seg. DbStore has to parse JSON from content/ the same way but also commit through the engine's WAL. If this is slow, add a wo db import --from-seg <path> shortcut that bulk-loads from an existing .seg without going through the ingest path.
  2. Memory footprint. Today's seg-based path mmaps the file; the .wo engine 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.
  3. Article → .wo type drift. wo_model::Article is the canonical domain type today. The .wo schema mirrors it, but if the two diverge (a new field is added to Article but not to the schema), queries silently drop that field. Mitigation: wo-gen should include a --verify wo_model::Article mode in Phase 5 that fails CI on drift.
  4. 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.
  5. Feature flag sprawl. WO_STORE_BACKEND should 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 of wo-gen but not a blocker.
  • ##ui / ##policy / ##logic / ##service — those are Phase 6 and assume the engine is already running.
  • Any graph-first features (mappings, RELATED_TO traversal) — they become trivially available once DbStore is 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:

  1. Parity — After Phase B: a shadow script replays one week of production ingest through DbStore in a sandbox; every query from the shadow matches seg.
  2. Latency — After Phase D: p50/p95/p99 of get_by_title, list_published, list_by_tag are at or below the seg baseline. Measured by the existing request-timing middleware, not synthetic benchmarks.
  3. Crash safety — After Phase 3 WAL ships: kill -9 during write, reopen, confirm the committed state matches and uncommitted writes are gone. Automated test.
  4. Decommission audit — After Phase E: rg 'wo-seg|wo_seg|\.seg\b' crates/ returns zero; cargo deny check passes; production restart ingests from content/ with no .seg file present.
  • 02-wo-language.md — the two-layer .wo language the engine speaks
  • 03-inmemory-engine.md — the storage engine behind wo-db
  • 04-client-api.md — wire protocol and LIVE subscriptions
  • 05-go-sdk.md — the Go SDK built from .wo types via wo-gen
  • 01-evaluation.md — why writeonce built wo-seg in 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 + .idx implementation details
  • prototypes/wo-db/ — the C++ prototype of the .wo engine, the reference implementation the Rust port follows