transactional-state-migrations
Transactional State Migrations
Use this skill when an application stores mutable registries in JSON or other whole-file documents and needs a transactional embedded database without changing product behavior or risking live state.
The governing principle is kernel first, adapters second, runtime cutover last. Never combine live-data migration, business-logic rewrites, and deployment into one unverifiable step.
When evolving an atomic file-backed operation ledger into crash-recoverable per-effect execution state, follow references/durable-effect-journals.md. It covers schema-on-mutation migration, store-owned authority, acknowledgement ambiguity, reconciliation, proxy-safe validation, atomic terminal state, and the single-process queue boundary.
When a central account tier must cross a signed handoff into separately deployed tenant runtimes, follow references/versioned-tenant-access-contracts.md. Version the wire contract per tenant, preserve legacy behavior by default, keep product tiers separate from local roles, bootstrap first-handoff state atomically, and enforce hidden scopes server-side.
When tenant data predates current project/connection/profile/OAuth boundaries, follow references/legacy-tenant-contract-modernization.md. Preserve strict modern authorization invariants, audit both persisted relationships and stale callers, keep credential status truthful, split obsolete combined authorities, and prove the exact user-facing path after an idempotent rehearsal.
When an existing tenant must be cleared and rebuilt around current contracts while preserving its identity, access, channels, and managed provider runtime, follow references/clean-tenant-rebootstrap.md. Extract canonical brand/product sources before deletion, stage a genuinely empty current-schema replacement, rebuild through current domain lifecycles, reprovision database-bound agent credentials, reset stale conversation state selectively, and distinguish staged success from a completed live cutover.
Legacy tenant contract modernization
Treat legacy compatibility as a data-and-caller forward migration, not permission to weaken the current runtime:
- Decide whether the new server invariant is a real authorization boundary. If so, keep it strict.
- Audit the legacy entity graph for missing or foreign project ownership, not just the row named in the error.
- Classify intentionally portfolio-shared resources with an explicit allowlist; scope every other connection to its real business.
- Audit every UI/CLI caller for the current parameter names and required authority dimensions. A corrected database can still fail through an obsolete
?project= link.
- Do not copy encrypted credentials as ordinary rows. If no usable token exists, preserve historical identity but normalize the connection, surface, and dependent account statuses to reconnect/unavailable states.
- Split legacy combined provider authorities when the modern runtime uses separate flows—for example, Facebook/Meta versus direct Instagram.
- Rehearse
dry-run → apply → second apply on an exact copy. Require unchanged dry-run hash and zero changes on the second apply.
- After live cutover, authenticate through the real session path and probe the exact previously failing request. For OAuth starts, intercept and validate the redirect, then deterministically remove the unused test state.
Do not force unrelated profile/chat cutovers merely to make an inventory look modern. If the runtime already creates isolated current profiles and safely shadows legacy threads, preserve that boundary until its own canary succeeds.
Staged workflow
1. Inventory the state contract
For every mutable registry, record:
- Current path and environment override
- Empty/default schema
- Public class or function API
- Ordering, limits, deduplication, and idempotency behavior
- Restart behavior, including how in-flight records are marked interrupted
- Whether callers are synchronous or asynchronous
- Secret-bearing fields and existing redaction rules
- All writers, not just the obvious primary class
Capture hashes of live source files before testing. Tests and worktrees must use disposable state directories and must prove those hashes remain unchanged.
2. Build the database kernel independently
Before wiring application classes, implement a focused store with:
- Explicit database path; no accidental live default in migration tooling
- Schema versioning and refusal to open a newer unsupported schema
- WAL where supported
- Foreign keys enabled
- Bounded busy timeout
- Safe, idempotent close
- Explicit key/table allowlists
- Transaction helper that preserves the original error if rollback also fails
- Integrity/status reporting without document contents or secrets
For whole-document registries, a versioned document table is a valid first cut when it preserves behavior and makes migration reversible. Normalize later only when query or integrity requirements justify it.
3. Prove atomic import before runtime wiring
The importer must:
- Require an explicit complete source map or source directory.
- Read and parse every source before opening the write transaction.
- Canonicalize documents and calculate both source-byte and canonical-document SHA-256 hashes.
- Reject malformed, unreadable, unknown, or conflicting inputs before writes. Keep strict complete import as the default. If inventory proves a registry is optional/lazily created, expose a separate explicit bootstrap operation that substitutes its exact legacy empty schema only for
ENOENT; never reinterpret malformed input as missing.
- Import or explicitly bootstrap every registry in one transaction.
- Record provenance, counts, checksums, and an aggregate import run.
- Treat an identical checksum rerun as idempotent.
- Refuse initialized-state overwrite unless an explicit recovery operation requests it.
- Support an injected failure test proving all writes roll back.
4. Build deterministic rollback export
Export must target a caller-provided disposable directory and:
- Recreate the prior file schemas exactly enough for the old runtime to consume
- Sort object keys deterministically
- Use stable filenames and trailing-newline policy
- Refuse existing targets by default
- Check all collisions before writing any file
- Report hashes and counts
- Never overwrite live legacy files as a side effect of a normal command
Rollback proof should separate two different guarantees:
- Compatibility: parse source and exported JSON and compare structural values. Canonical export may sort object keys or normalize trailing newlines, so raw source-byte equality is not the right compatibility assertion.
- Determinism: export the same database twice to separate disposable directories and compare exported hashes byte-for-byte.
Also hash the original live source files before and after the entire disposable proof to demonstrate that migration testing did not mutate them.
See references/sqlite-json-cutover.md for a concrete Node/SQLite implementation pattern and recovery checklist.
5. Convert adapters without changing product APIs
Retain the old JSON backend until rollback confidence is established. Inject the new backend through constructors or a narrow repository interface.
Critical rule: a mutation must not become load() followed by an independent put(). Use one database transaction around the complete read-modify-write operation:
store.update('registryKey', (current) => {
const next = normalize(current);
// Apply the existing business operation synchronously.
return next;
});
If the public method must return a derived record, capture that result inside the transaction callback and return it after commit. Preserve validation, redaction, timestamps, sorting, caps, and error status codes.
6. Wire runtime state deliberately
Add one explicit database path contract, normally derived from the isolated state directory. In test mode, fail closed if it resolves into live state.
At startup or during an explicit cutover command:
- Open and verify the database.
- If all registries are uninitialized and all legacy inputs exist, import all atomically.
- If no legacy inputs exist, initialize all empty schemas atomically.
- If only some legacy inputs exist, classify each missing registry from the legacy contract. Fail closed for required state; for confirmed optional/lazily-created state, bootstrap its exact empty schema in the same transaction and record
defaulted:missing-source provenance.
- Keep strict import and partial bootstrap as separate operator-visible commands; do not silently weaken import requirements.
- Never treat unreadable or malformed existing input as missing.
- Never silently overwrite initialized database documents from changed JSON files.
- Mark stale in-flight records interrupted using the same behavior as the legacy runtime.
Keep the database connection lifecycle explicit and close it during graceful shutdown.
See references/partial-legacy-bootstrap.md for the optional-missing registry classification, provenance, and rollback test pattern.
7. Verification gates
Minimum focused tests:
- Persistence across close/reopen
- Atomic rollback on induced error
- Identical-import idempotency
- Malformed input rejection and strict-import partial-input rejection
- Explicit partial-bootstrap behavior for confirmed optional missing registries, including
defaulted:missing-source provenance
- Initialized-state conflict rejection
- Two adapters sharing one database do not lose sequential updates
- Busy timeout/lock behavior
- Deterministic export and no-overwrite behavior
- Legacy JSON round-trip compatibility
- Existing source files unchanged
- Runtime test mode cannot touch live state
Then run the repository's complete unit, security, readiness, and production-build gates. Run browser tests only in an environment with enough resources; do not weaken them to accommodate a constrained shared host.
Worktree and dependency pitfall
Git worktrees change relative filesystem location. Local package dependencies such as file:../sibling can resolve under the worktree parent instead of the canonical application parent. Before accepting unrelated module-resolution test failures:
- Inspect the dependency's actual resolved target.
- Install dependencies in the worktree.
- If the repository intentionally consumes canonical sibling checkouts, repair only ignored
node_modules links to those checkouts or use the project's documented worktree bootstrap.
- Rerun the unchanged test suite.
Do not modify tests or package manifests merely to hide a worktree-local resolution problem.
Live Entity Consolidation (SQLite)
Use this pattern when an operator corrects a product model after launch: two projects, tenants, accounts, or brands that should have been one must become one without losing the live operating record.
Decide the survivor from evidence, not the prettier name
Before changing anything, inventory both entities and select the record that owns the operational truth: live provider connections, payment rails, contracts, review/audit history, deployed infrastructure, and external identifiers. Rename that record to the canonical business name; absorb the other record’s assets, brand kit, collections, and historical rows. This avoids breaking every foreign reference merely to preserve an ID/name pairing.
Required safeguards
- Create a timestamped online SQLite backup before mutation and verify its SHA-256 plus
PRAGMA integrity_check.
- Write a migration script with an explicit
--dry-run and --apply; require exact expected source/target IDs and names so it refuses a wrong database.
- Inventory every table with
project_id / entity foreign keys, plus indirect references such as chat scopes, canvas layout scopes, module-connection rows, and JSON metadata.
- Run the move in one transaction. Handle unique constraints deliberately (for example, merge duplicate module types before deleting the absorbed project).
- Preserve actual third-party identity facts. Re-contextualize internal mappings to the canonical business, but do not claim an external provider account/page/ad-account was renamed unless a verified provider-side action succeeded. Store the last externally observed name separately when useful.
- Normalize future defaults too: update seed/bootstrap code so a fresh deployment does not recreate the old mistaken identity.
- After the transaction, verify: absorbed entity absent; every moved table has zero source rows; survivor has expected connections/products/assets; foreign-key check has no new violations compared with the preflight backup; browser/API show the canonical business exactly once.
Common pitfalls
setState(null) followed by an immediate request can still carry the prior entity ID from the closure; pass an explicit null/new-session override when a new entity must be created.
- A project’s public brand name and its linked external social handles can differ. Keep this distinction visible rather than overwriting real account names with a desired internal label.
PRAGMA foreign_key_check may reveal historical violations. Compare its exact output with the pre-migration backup; do not attribute pre-existing defects to the consolidation.
- Do not delete the duplicate before moving rows and resolving unique child records; SQLite foreign keys make an early delete irreversible without rollback.
Completion criteria
A migration is not complete until:
- All runtime writers use transactional storage
- Import and export are independently verified
- Rollback instructions have been exercised in disposable paths
- Live source hashes are unchanged during tests
- Full verification passes
- Cutover and rollback are separate, explicit operator actions