#211 Implement W1: Repository and RowState (extension catalog rearchitecture)
Opened by stack72 · 5/1/2026· Shipped 5/4/2026
Problem
The extension catalog (.swamp/_extension_catalog.db) has no aggregate root and no ownership graph. Issue #201 (stale rows, non-deterministic resolution) and the pattern that produced PRs #208 and #1286 (per-failure-mode sentinels and BOOL columns) are symptoms of the same structural gap. Full architectural analysis and sequenced workstream plan: design/extension-rearchitecture.md.
This issue covers W1 — Repository and RowState, the keystone workstream. W1 is a pure refactor + schema migration with no user-visible behavior changes. It must land before W2–W6 can begin.
Scope
Carve into two PRs.
W1a — Schema migration only
- Extend
migrateSchema()insrc/infrastructure/persistence/extension_catalog_store.ts:- Absorb the
validation_failedBOOL column (added in PR #1286) into a newstate TEXT NOT NULLcolumn representing a 7-tagRowStatediscriminant. - Add
extension_name TEXT NOT NULLandextension_version TEXT NOT NULLcolumns. Backfill via path heuristic: rows under<repo>/.swamp/pulled-extensions/<name>/<version>/parse name+version from path; rows under<repo>/extensions/<kind>/get `@local/` and `"0.0.0"`. Drop unmatched rows. - Bump `BUNDLE_LAYOUT_VERSION` to `"per-extension-aggregate-v3"` so the cold-start guard rescans on first run after upgrade.
- Absorb the
- New helpers under `src/infrastructure/persistence/`:
- `canonicalizePath(p: string): string`
- `findRepoRoot(start: string): string`
W1b — Domain model + repository
- New value objects in `src/domain/extensions/`: `Extension` (aggregate root), `Source`, `RowState` (discriminated union, 7 states), `SourceLocation`, `BundleLocation`.
- New `ExtensionRepository` in `src/infrastructure/persistence/` with diff-based `save(extension)` and `saveAll(extensions[])` inside SQLite transactions. `saveAll` evaluates the global `(kind, typeNormalized)` uniqueness invariant on post-save state — supports the upgrade-as-atomic-transition pattern `saveAll([vN.tombstoneAll(), vN+1])`.
- Replace per-loader cold-start guard sets with a single call to `repository.invalidationGuards(kind)` — closes the audit's "model loader has four guards, the four siblings have one" gap as a side effect.
- Fold the standalone `forceCatalogRescan` helper into `repository.invalidateAll()`.
Full state model, invariants, transition methods, and the `RowState` enumeration are specified in `design/extension-rearchitecture.md` under "The aggregate / state model" and "W1 — Repository and RowState (the keystone)".
Pre-work decisions to pin in the W1a PR description
These contracts bake into every subsequent workstream:
- Path canonicalization rule — recommendation: lowercased + forward-slash on Windows; raw on POSIX.
- Repo-root identification — recommendation: nearest ancestor of the source path containing a `.swamp/` directory; first match wins.
- Unmatched-row backfill behavior — recommendation: drop the row; cold-start guard repopulates from disk.
Out of scope (deferred to later workstreams)
- `extension rm` pruning rows / lifecycle services owning catalog writes — W2 (this is what closes #201)
- Cross-extension `DuplicateType` errors at lifecycle save time — W2
- `ReconcileFromDisk` service / freshness-as-aggregate-query / removing `UNREADABLE_DEP_SENTINEL` — W3
- Loader unification / `KindAdapter` — W4
- Per-fingerprint import URLs and subprocess test harness — W5
- `swamp doctor extensions` — W6
Success criteria
- `pragma_table_info('bundle_types')` post-migration shows `state`, `extension_name`, `extension_version`; no `validation_failed`.
- Migration is idempotent; falls back to "drop catalog and rebuild from disk" on backfill verification failure.
- `Extension`, `Source`, `RowState`, `SourceLocation`, `BundleLocation` exported from `src/domain/extensions/`.
- `ExtensionRepository` round-trips aggregates with diff-based saves; `saveAll` evaluates the global uniqueness invariant on post-save state and supports the upgrade-as-atomic-transition pattern.
- The four sibling loaders' cold-start invalidation matches the model loader's coverage.
- All existing tests pass on Linux, macOS, and Windows.
Suggested test additions
- Migration backfill against fixture catalogs covering: post-#1286 schema with `validation_failed = 1` rows, mixed pulled + local rows, unmatched-path rows.
- Migration idempotence (run twice, second is a no-op).
- Repository diff-save: add Source → INSERT; drop Source → DELETE; transition Source state → UPDATE.
- `saveAll` atomicity: `[vN.tombstoneAll(), vN+1]` succeeds; two extensions with overlapping `(kind, type)` rejects with a `DuplicateType` event.
- Cross-platform: `SourceLocation` equality on Windows fixtures with mixed casing.
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.