mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 23:55:43 +00:00
The 4.5c.1 PR (#6727) shipped a refinement of option B for ChangedHero that the design doc never recorded, so the doc and the merged code diverged. What actually merged: loyalty/vigor StatChange oneofs as typed nullable column pairs (column name discriminates), and newBackstoryEvents as per-event EventForHeroBackstory blobs in a child table. No whole-entity changed_hero_proto blob. changed_hero.proto and ChangedHeroConverter become deletable, only event_for_hero_backstory.proto is retained as a leaf blob format. Reconciles the doc with that reality: - New authoritative "Refinement" subsection under Status defining the three-tier pattern (scalar columns, typed columns for sealed value-oneofs, per-row leaf-proto blobs in child tables) and the per-entity classification all-scalar / refined / deep. - Scope, "Deleting the proto", ChangedHero section, Decisions #1-#3, and Sequencing rows 4.5c and 4.5i updated to match. 4.5c is split into 4.5c.1 (merged) and 4.5c.2 (live write/read path, pending). - 4.5i is no longer "near-nothing": refined entities' protos and converters get deleted there, scope scales with how many entities went refined. Documentation only. No code or schema change. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
336 lines
30 KiB
Markdown
336 lines
30 KiB
Markdown
# SQLite Action Result Decomposition
|
||
|
||
Follow-up to [SQLITE_HISTORY_DESIGN.md](SQLITE_HISTORY_DESIGN.md). This document plans the second wave of the SQLite migration: decomposing the `ActionResult` proto blob in `action_results.payload` into native relational tables, deleting the proto representation entirely for everything in scope.
|
||
|
||
## Status — option B adopted (updated 2026-05-17)
|
||
|
||
**This section supersedes the original plan below. Where the body conflicts with this section, this section wins.** The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.
|
||
|
||
The original plan (decompose every field into relational tables; "decision locked") was implemented for `ChangedProvince` first — ~37 child tables, ~1500 lines (PR #6721) — and then **rejected on cost/benefit**. We adopted **option B**: apply the "stopping rule" (originally carved out only for `Quest`) *consistently*. Shipped and merged as Phase 4.5b in **PR #6725**:
|
||
|
||
- Keep the per-entity **top-level table with scalar / entity-reference columns + indexes** (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
|
||
- Store the **rest of the aggregate as one `<entity>_proto BLOB` column** on that row: lossless round-trip / replay, no child tables for the deep nesting.
|
||
- Scalar columns are pure query denormalization; **the proto blob is authoritative**. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.
|
||
|
||
Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing `Quest`. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need **no SQL migration** (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).
|
||
|
||
## Refinement — "refined option B" for shallow entities (updated 2026-05-19)
|
||
|
||
**This section further supersedes the body and the Status section above where they conflict; it wins.** It records the pattern actually shipped for `ChangedHero` in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.
|
||
|
||
Option B as originally stated keeps **one `<entity>_proto BLOB`** per row for the entire non-scalar residual. For a *shallow* entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:
|
||
|
||
- **Tier 1 — scalar / entity-reference columns + indexes.** The queryable surface (deltas, touched entities, enum ordinals). Always present; unchanged from 4.5a.
|
||
- **Tier 2 — typed columns for sealed value-oneofs.** A sealed choice over simple values (e.g. `ChangedHeroC`'s `loyalty` / `vigor` `StatChange`, subtypes `StatDelta`/`StatAbsolute`/`StatNoChange`) becomes a **pair of nullable columns** (`loyalty_delta` / `loyalty_absolute`, …). The *column name is the discriminator*; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No `kind` column.
|
||
- **Tier 3 — per-row leaf-proto blobs in a child table.** A repeated/optional nested aggregate becomes a child table keyed by `(action_seq, entity_id, n)` storing the **leaf** proto's bytes one row per element (e.g. `action_changed_heroes_new_backstory_events` holding `EventForHeroBackstory` blobs) — *not* a whole-entity blob.
|
||
|
||
**Consequence (changes Decisions #1–#3 and 4.5i):** an entity fully covered by Tiers 1–3 has **no whole-entity blob**. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (`changed_hero.proto` / `ChangedHeroConverter`) become genuinely dead code, deletable in 4.5i. Only the **leaf** protos used as Tier-3 blob payloads (`event_for_hero_backstory.proto`) are retained as a wire format. The whole-entity `<entity>_proto BLOB` is now reserved for **deep** entities where Tiers 2–3 would be more work than value (`ChangedProvince`, the "new entity" snapshots).
|
||
|
||
Per-entity decision (supersedes Decisions #2): classify each entity as **all-scalar** (Tier 1 only, no blob — e.g. likely `ChangedFaction`), **refined** (Tiers 1–3, no whole-entity blob, proto deletable — `ChangedHero`), or **deep** (Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained — `ChangedProvince`, new-entity snapshots). Default to **refined** for shallow entities; fall back to **deep** only when the residual is too varied to be worth Tiers 2–3. Wherever the body or Status says "the proto blob is authoritative," for a *refined* entity read "the columns + per-leaf-proto blobs are authoritative."
|
||
|
||
## Why
|
||
|
||
The first wave (Phases 1–4) stores each action as a single proto blob in `action_results.payload`. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The `action_result_type`, `round_id`, and date columns we denormalized are the only handles SQL has into actions.
|
||
|
||
Two observations make full decomposition more attractive than the original design admitted:
|
||
|
||
1. **The proto exists for storage only.** 92 Scala files reference `ChangedProvince`, but exactly one imports the proto type — `ChangedProvinceConverter.scala`. Everything else (action handlers, AI, utils) operates on `ChangedProvinceC`. The proto is **not** in any client-facing RPC contract — it lives under `eagle.internal`. Same is true for `ChangedHero`, `ChangedFaction`, `ChangedBattalion`, `Notification`, `GeneratedTextRequest`, and `ActionResult` itself. Replacing proto serialization with SQL writes does **not** lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.
|
||
|
||
2. **`ActionResultC` has no polymorphism at the top level.** It's a 34-field flat struct — `Option`s and `Vector`s, but no `sealed trait` discriminator at the action level. Every action has the same shape; the `actionResultType` enum tells you *which fields* are populated. This means the entire structure is relationalizable without per-action-type tables.
|
||
|
||
The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.
|
||
|
||
## Scope
|
||
|
||
**In:** `action_results.payload` (the whole-`ActionResult` blob) is gone by the end. Per-entity scalar / entity-reference columns make the *queryable* surface (deltas, touched entities) native SQL. **Under refined option B (see Status):** `internal/action_result.proto` and *deep* entities' sub-message protos (`changed_province.proto`, …) are retained as blob payloads; *refined* entities' protos (`changed_hero.proto`) are decomposed away and deleted, with only their Tier-3 leaf protos (`event_for_hero_backstory.proto`) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)
|
||
|
||
**Out:**
|
||
- `state_snapshots.payload` — stays as proto-blob `GameState`. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
|
||
- Shardok tables — already designed in Phase 3, no changes here. The shardok protos (`ShardokActionResult`, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
|
||
- Client text — already SQLite-backed (`SqliteClientTextStore`).
|
||
|
||
## Principles
|
||
|
||
1. **One table per non-trivial aggregate type.** Every `Vector[X]` or `Option[X]` where `X` is a Scala case class with multiple fields gets its own table.
|
||
2. **Vectors of primitives → join tables.** `Vector[Int]` becomes a two-column table: `(parent_key, value)`. No JSON arrays, no comma-separated strings — those defeat the point.
|
||
3. **Optional aggregates → presence by row existence.** `Option[X]` for aggregate `X` is a row that may or may not exist with the matching parent key. The natural primary key on `(action_seq)` enforces 0-or-1-row semantics for `Option[X]` aggregates and `(action_seq, n)` for vectors.
|
||
4. **Sealed traits get a discriminator column.** When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with `kind TEXT NOT NULL` + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
|
||
5. **`ON DELETE CASCADE` everywhere.** Every child table has `FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE`. `truncateTo` becomes a single `DELETE FROM action_results WHERE action_seq >= ?` and the cascades do the rest.
|
||
6. **Schema migrations are now real.** Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The `metadata` table's `schema_version` becomes load-bearing.
|
||
7. **(Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it.** The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual *relational* representation to keep in sync.
|
||
|
||
## Top-level: `action_results`
|
||
|
||
The `payload BLOB` column goes away. Scalar fields become columns; aggregate fields move to related tables.
|
||
|
||
```sql
|
||
CREATE TABLE action_results (
|
||
action_seq INTEGER PRIMARY KEY,
|
||
action_result_type INTEGER NOT NULL,
|
||
-- existing scalar denormalizations
|
||
round_id INTEGER NOT NULL,
|
||
date_year INTEGER,
|
||
date_month INTEGER,
|
||
-- new scalar columns (formerly inside payload)
|
||
acting_hero_id INTEGER,
|
||
acting_faction_id INTEGER,
|
||
province_id INTEGER,
|
||
province_id_acted INTEGER,
|
||
new_round_phase INTEGER,
|
||
new_round_id INTEGER,
|
||
last_command_type_for_acting_province INTEGER,
|
||
resolved_battle TEXT,
|
||
new_victor_faction_id INTEGER,
|
||
game_ended INTEGER, -- 0/1/NULL
|
||
new_random_seed INTEGER,
|
||
new_game_type INTEGER
|
||
);
|
||
|
||
-- Vectors of bare IDs become join tables:
|
||
CREATE TABLE action_destroyed_battalion_ids (
|
||
action_seq INTEGER NOT NULL,
|
||
battalion_id INTEGER NOT NULL,
|
||
PRIMARY KEY (action_seq, battalion_id),
|
||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE action_removed_hero_ids (
|
||
action_seq INTEGER NOT NULL,
|
||
hero_id INTEGER NOT NULL,
|
||
PRIMARY KEY (action_seq, hero_id),
|
||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE action_removed_faction_ids (...);
|
||
CREATE TABLE action_affected_faction_ids (...);
|
||
|
||
-- Singleton optional aggregates: a row exists if and only if Option is Some.
|
||
CREATE TABLE action_new_battles (
|
||
action_seq INTEGER PRIMARY KEY,
|
||
-- ShardokBattle fields here, columns or sub-tables as needed
|
||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE TABLE action_new_chronicle_entries (action_seq INTEGER PRIMARY KEY, ...);
|
||
CREATE TABLE action_new_eagle_map_info (action_seq INTEGER PRIMARY KEY, ...);
|
||
```
|
||
|
||
Each `Vector[Aggregate]` field on `ActionResultC` gets its own table (covered below).
|
||
|
||
### Indexes
|
||
|
||
We keep the existing indexes (`round_id`, `(date_year, date_month)`) and add a few that obvious queries need:
|
||
|
||
- `idx_action_results_acting_hero_id`
|
||
- `idx_action_results_acting_faction_id`
|
||
- `idx_action_results_province_id`
|
||
|
||
Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:
|
||
|
||
- `idx_action_changed_provinces_province_id`
|
||
- `idx_action_changed_heroes_hero_id`
|
||
- `idx_action_changed_faction_faction_id`
|
||
- `idx_action_changed_battalions_battalion_id`
|
||
|
||
## Changed-entity tables (four parallel structures)
|
||
|
||
### `action_changed_provinces`
|
||
|
||
`ChangedProvinceC` has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on `action_changed_provinces`; aggregate fields become child tables.
|
||
|
||
```sql
|
||
CREATE TABLE action_changed_provinces (
|
||
action_seq INTEGER NOT NULL,
|
||
province_id INTEGER NOT NULL,
|
||
-- resource changes
|
||
gold_delta INTEGER,
|
||
food_delta INTEGER,
|
||
-- stat changes
|
||
new_price_index REAL,
|
||
economy_delta REAL,
|
||
agriculture_delta REAL,
|
||
infrastructure_delta REAL,
|
||
economy_devastation_delta REAL,
|
||
agriculture_devastation_delta REAL,
|
||
infrastructure_devastation_delta REAL,
|
||
support_delta REAL,
|
||
-- round properties
|
||
set_has_acted INTEGER, -- 0/1/NULL
|
||
set_ruler_is_traveling INTEGER,
|
||
-- ruling faction changes
|
||
clear_ruling_faction_id INTEGER NOT NULL DEFAULT 0,
|
||
new_ruling_faction_id INTEGER,
|
||
-- misc scalar fields
|
||
new_locked_improvement_kind TEXT, -- 'none', 'new'
|
||
new_locked_improvement_value INTEGER, -- ImprovementType if kind='new'
|
||
new_province_orders INTEGER, -- ProvinceOrderType enum
|
||
clear_defending_army INTEGER NOT NULL DEFAULT 0,
|
||
cleared_pending_conquest_info INTEGER NOT NULL DEFAULT 0,
|
||
removed_deferred_change_index INTEGER,
|
||
PRIMARY KEY (action_seq, province_id),
|
||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||
);
|
||
|
||
CREATE INDEX idx_action_changed_provinces_province_id
|
||
ON action_changed_provinces(province_id);
|
||
```
|
||
|
||
The vector and optional-aggregate fields of `ChangedProvinceC` become per-aggregate child tables, all keyed by `(action_seq, province_id)`:
|
||
|
||
- `action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...)` — one row per added hero, with `UnaffiliatedHero` fields as columns
|
||
- `action_changed_provinces_changed_unaffiliated_heroes(...)` — same shape
|
||
- `action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)` — join table for ID vector
|
||
- `action_changed_provinces_new_captured_heroes(...)` — `CapturedHero` fields
|
||
- `action_changed_provinces_recruitment_attempted_captured_hero_ids(...)` — join table
|
||
- `action_changed_provinces_removed_captured_hero_ids(...)` — join table
|
||
- `action_changed_provinces_new_ruling_faction_hero_ids(...)` — join table
|
||
- `action_changed_provinces_removed_ruling_faction_hero_ids(...)` — join table
|
||
- `action_changed_provinces_new_battalion_ids(...)` — join table
|
||
- `action_changed_provinces_removed_battalion_ids(...)` — join table
|
||
- `action_changed_provinces_new_incoming_armies(...)` — `MovingArmy` decomposition
|
||
- `action_changed_provinces_removed_incoming_army_ids(...)` — join table
|
||
- `action_changed_provinces_new_withdrawing_armies(...)` — `MovingArmy` decomposition
|
||
- `action_changed_provinces_removed_withdrawing_army_ids(...)` — join table
|
||
- `action_changed_provinces_new_hostile_armies(...)` — `HostileArmyGroup` decomposition
|
||
- `action_changed_provinces_removed_hostile_army_faction_ids(...)` — join table
|
||
- `action_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status)` — small flat aggregate
|
||
- `action_changed_provinces_new_defending_army` — `Option[Army]` (singleton row if present)
|
||
- `action_changed_provinces_new_incoming_shipments(...)` — `MovingSupplies` decomposition
|
||
- `action_changed_provinces_removed_incoming_shipment_ids(...)` — join table
|
||
- `action_changed_provinces_new_incoming_end_turn_actions(...)` — `IncomingEndTurnAction` decomposition
|
||
- `action_changed_provinces_removed_incoming_end_turn_actions(...)` — same shape (these are values, not IDs)
|
||
- `action_changed_provinces_new_battle_revelations(...)` — `BattleRevelation` decomposition
|
||
- `action_changed_provinces_removed_battle_revelations(...)` — same shape
|
||
- `action_changed_provinces_new_pending_conquest_info` — `Option[PendingConquestInfo]` (singleton row)
|
||
- `action_changed_provinces_new_province_events` — `Vector[ProvinceEvent]`
|
||
- `action_changed_provinces_new_deferred_change` — `Option[DeferredChange]` (singleton, with discriminator since `DeferredChangeT` is sealed)
|
||
|
||
That's a lot of tables — ~25 just under `ChangedProvince`. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, `n` (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.
|
||
|
||
### `action_changed_heroes`, `action_changed_factions`, `action_changed_battalions`
|
||
|
||
Same pattern. Each gets its top-level table keyed by `(action_seq, entity_id)`, plus child tables for nested aggregates and ID vectors. Indexes on the entity id (`hero_id`, `faction_id`, `battalion_id`) so cross-game queries are fast.
|
||
|
||
`ChangedHeroC` is a *refined* entity (see "Refinement" in Status): no whole-entity blob. Its sealed `StatChange` (`StatDelta`/`StatAbsolute`/`StatNoChange`) `loyalty` and `vigor` oneofs are Tier-2 column pairs (`loyalty_delta`/`loyalty_absolute`, `vigor_delta`/`vigor_absolute`; column name discriminates, both-NULL ⇒ `StatNoChange`). Its `newBackstoryEvents: Vector[EventForHeroBackstory]` is Tier-3: per-event `EventForHeroBackstory` blobs, one row each, in `action_changed_heroes_new_backstory_events`. Columns + per-event blobs round-trip losslessly, so `changed_hero.proto`/`ChangedHeroConverter` become deletable (4.5i) and only `event_for_hero_backstory.proto` is retained as a blob format.
|
||
|
||
## "New" entity tables
|
||
|
||
`ActionResultC.newProvinces: Vector[ProvinceT]`, `newHeroes`, `newFactions`, `newBattalions`. These are *full snapshots* of new entities at creation.
|
||
|
||
These are bigger and recursive: `ProvinceT` itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing `state_snapshots` blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in `ActionResultC`, which we're decomposing.
|
||
|
||
**Decision**: go fully relational here too. Each "new entity" gets its top-level table (`action_new_provinces` etc.) keyed by `(action_seq, entity_id)`, with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the `changed_*` family (preferred where the field shape is identical, e.g., `MovingArmy`, `UnaffiliatedHero`) or get their own (where they diverge).
|
||
|
||
This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.
|
||
|
||
## Notifications and generated text requests
|
||
|
||
```sql
|
||
CREATE TABLE action_new_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
|
||
CREATE TABLE action_removed_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
|
||
CREATE TABLE action_new_generated_text_requests (...);
|
||
CREATE TABLE action_client_text_visibility_extensions (...);
|
||
```
|
||
|
||
`NotificationT` and `GeneratedTextRequestT` are both sealed traits. Use the discriminator-column pattern: `kind TEXT NOT NULL` plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.
|
||
|
||
## Schema evolution
|
||
|
||
We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.
|
||
|
||
Conventions:
|
||
- `metadata['schema_version']` is a small integer, currently `1`.
|
||
- A `Migrations` object holds an ordered `Vector[(Int, Connection => Unit)]` of migration steps.
|
||
- On `SqliteHistory.loaded`, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
|
||
- New games start at the latest version.
|
||
- Migration steps are append-only — never edit an existing step.
|
||
|
||
This is the standard Rails / Flyway / etc. pattern, scaled down to one file.
|
||
|
||
## Read path: reconstructing `ActionResultC`
|
||
|
||
The current pattern:
|
||
```scala
|
||
val proto = ActionResult.parseFrom(payloadBlob)
|
||
val scala = ActionResultProtoConverter.fromProto(proto)
|
||
```
|
||
|
||
The new pattern:
|
||
```scala
|
||
val scala = ActionResultDbReader.read(connection, actionSeq) // assembles from action_results + child tables
|
||
```
|
||
|
||
`ActionResultDbReader.read` is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.
|
||
|
||
For bulk reads (`since`, `all`, `sinceDate`, etc.), the existing `replayFrom` helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting `ActionResultC` instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.
|
||
|
||
## Write path
|
||
|
||
`withNewResults` becomes:
|
||
1. INSERT into `action_results` (scalar fields).
|
||
2. For each non-empty aggregate: INSERT N rows into the corresponding child table.
|
||
|
||
All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 0–3 provinces, 0–5 heroes, 0–10 changed entities total, plus 0–2 notifications. So the typical insert is ~5–20 rows. With WAL and a single per-batch transaction, this is cheap.
|
||
|
||
## Replay performance
|
||
|
||
Today's `replayFrom` parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100–500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.
|
||
|
||
## Deleting the proto
|
||
|
||
> **Superseded by refined option B (see Status).** Whole-`ActionResult` `payload` is removed in 4.5h. After that it depends on per-entity classification: *refined* entities' per-entity sub-message protos + converters (e.g. `changed_hero.proto`) **do** become dead code and are deleted in 4.5i; *deep* entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`), and `Quest` are *retained*. The original full-deletion plan below is kept for historical context.
|
||
|
||
Once `SqliteHistory` writes and reads exclusively from the new tables:
|
||
|
||
1. Delete `ActionResultProtoConverter.scala`, the `Changed{Province,Hero,Faction,Battalion}Converter.scala` files, the `Notification`/`GeneratedTextRequest`/etc. converters.
|
||
2. Delete the proto definitions: `action_result.proto`, `changed_province.proto`, `changed_hero.proto`, `changed_faction.proto`, `notification.proto`, `generated_text_request.proto`, anything else in this scope.
|
||
3. Delete the corresponding `_scala_proto` Bazel targets.
|
||
4. Gazelle should drop the unused deps from downstream targets.
|
||
|
||
This is satisfying but should land *last*, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.
|
||
|
||
## Sequencing
|
||
|
||
**Phase 4** (cutover) lands as planned: `GamesManager` swaps to `SqliteHistory`, save dirs get nuked, action data is the single proto-blob `payload`. Days of work, validates the lifecycle integration without conflating with schema design.
|
||
|
||
**Phase 4.5** (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. **Re-planned for option B (see Status):** each entity gets the 4.5a scalar/entity-ref columns + a `<entity>_proto BLOB`; no child-table explosion. Each decomposition PR splits into `.1` (schema + backfill over existing `payload`) and `.2` (live `withNewResults` write path + read path), per the precedent set by 4.5b.
|
||
|
||
| PR | Work | State |
|
||
|---|---|---|
|
||
| 4.5a | Schema-migration scaffolding + four changed-entity top-level tables (scalar columns) | ✅ merged |
|
||
| 4.5b | `ChangedProvince`: scalar columns + `changed_province_proto BLOB` + v3 backfill (option B, #6725) | ✅ merged |
|
||
| 4.5c.1 | `ChangedHero` (**refined option B**, see Status): 4.5a scalars + loyalty/vigor Tier-2 column pairs + `newBackstoryEvents` as per-event `EventForHeroBackstory` Tier-3 blobs in `action_changed_heroes_new_backstory_events`; **no whole-entity blob** — `changed_hero.proto`/`ChangedHeroConverter` become deletable, `event_for_hero_backstory.proto` retained. Schema v4 + backfill over existing `payload` (#6727) | ✅ merged |
|
||
| 4.5c.2 | `ChangedHero`: live `withNewResults` write path + read path | planned |
|
||
| 4.5d | `ChangedFaction`: same pattern (mostly scalar already; small/empty blob — see Decisions #2) | planned |
|
||
| 4.5e | `ChangedBattalion`: `changed_battalion_proto BLOB` (sole field is the contained `BattalionT`) + battalion_id index from 4.5a | planned |
|
||
| 4.5f | "New entity" vectors (`newProvinces`/`newHeroes`/`newFactions`/`newBattalions`): one table per type keyed by `(action_seq, entity_id)` + a couple of queryable handles + `<entity>_proto BLOB`. Biggest win for B — these are the deepest snapshots | planned |
|
||
| 4.5g | Notifications + generated text requests: `(action_seq, n, kind, <entity>_proto BLOB)` — `kind` discriminator queryable, payload blobbed (quest-hybrid shape) | planned |
|
||
| 4.5h | Top-level `ActionResultC` scalar/entity-ref columns on `action_results` (acting_hero_id, acting_faction_id, province_id, …) + **drop `payload BLOB`**. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) | planned |
|
||
| 4.5i | Delete proto files/targets that became dead code. *Refined* entities' per-entity protos + converters (e.g. `changed_hero.proto`/`ChangedHeroConverter`) **are deleted** here. *Deep* entities' protos + leaf Tier-3 protos (e.g. `event_for_hero_backstory.proto`) + `Quest` are **retained** as blob payloads. No longer near-nothing — scope scales with how many entities went *refined* | planned |
|
||
|
||
Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly **1 week**, not 3–4.
|
||
|
||
After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.
|
||
|
||
## What we gain at the end
|
||
|
||
- **Single source of truth.** Action data lives in tables; no proto representation for the things in scope.
|
||
- **Native SQL analytics.** Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
|
||
- **Inspectable saves.** `sqlite3 game.db` shows readable data. Debugging is normal SQL work, not "parse this binary blob."
|
||
- **Cleaner replay path.** No proto parsing during replay. The `ActionResultProtoConverter` (currently called per replayed action in `replayFrom`) goes away entirely.
|
||
- **Less code.** ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
|
||
- **Schema migrations as a first-class concept.** Future field additions are SQL migrations, which is the right shape for a system that owns its storage.
|
||
|
||
## What we lose
|
||
|
||
- **The "edit one proto" workflow** for adding fields. Adding `newFooDelta: Double` to `ChangedProvinceC` is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
|
||
- **A safety net for unrecognized fields.** Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.
|
||
|
||
## Decisions locked
|
||
|
||
1. **Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule).** Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity `<entity>_proto BLOB` is used **only for deep entities** where Tiers 2–3 cost more than they're worth. No child-table explosion for deep aggregates.
|
||
2. **Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed").** *All-scalar* → Tier 1 only, no blob (likely `ChangedFaction`). *Refined* → Tiers 1–3, no whole-entity blob, per-entity proto deletable (`ChangedHero`). *Deep* → Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained (`ChangedProvince`, new-entity snapshots). Default to *refined* for shallow entities; fall back to *deep* only when the residual is too varied for Tiers 2–3.
|
||
3. **What's retained vs. deleted depends on the classification.** `action_results.payload` (the whole-`ActionResult` blob) still goes away in 4.5h. *Refined* entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the **leaf** protos they use as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`) are retained. *Deep* entities' protos + `Quest` stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
|
||
4. **Sub-phase ordering** as in the Sequencing table; each decomposition PR splits into `.1` (schema + backfill) and `.2` (live write + read).
|
||
5. **Phase 4 landed separately, before 4.5a** (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.
|