SLICE-3.3: SQLite Persistence + Schema Versioning
- Status: Proposed
- Date: 2026-05-07
- Depends on: Requirements, Evolution Roadmap, Phase 2+3 Strategy, SLICE-002: Persistent Run History
Goal
Replace the file-backed JSON run-history store with a SQLite-backed implementation using Dapper (not EF Core), introduce a schema-versioning + migrations infrastructure that future Phase 3 slices (3.1 rich defect model, 3.4 identity + audit) can extend, and add alarm history persistence that is currently lost across process restarts. The existing run-history.json is migrated into SQLite on first startup with a one-time import path. The slice's load-bearing measurement is that opening the app with 10 000 historical runs loads the most recent page of history in < 200 ms, and that the existing per-slice rate metrics (frames, telemetry, encoder, GC, working-set drift) reproduce within their established bounds against the new store.
This is the first Phase 3 slice opened under the 2026-05-07 strategy doc. It deliberately starts here because SQLite persistence under sustained run cycles is the most likely Phase 2 trigger candidate — its row block will surface whether AppStateStore.Update's allocation share or lock-wait time changes materially under real persistence pressure, which is what SLICE-2.0 is being instrumented to detect.
Why This Slice
The current JsonRunHistoryStore (SLICE-002) writes the entire history file on every SaveAsync call: load all → upsert by RunId → serialize all → atomic-rename. At 10 000 runs that's O(N) writes for every terminal run summary. The Phase 1 captures show 5 109 runs in 8 hours under Soak8h, which means the JSON path would have been writing increasingly-large files for the entire duration. The fact that this didn't surface as a measurement problem reflects the test profile, not the implementation — a deployed inspection tool running for weeks would hit the wall.
Independent of throughput, the JSON file is one of the things phase-1-capabilities-and-limits.md §4.1 names as not-real-world. Real wafer inspection tools persist run history to databases (sometimes SQLite, sometimes vendor-specific), with schema migrations as a first-class concern. Moving to SQLite + Dapper:
- Replaces the O(N)-write-per-save path with a single
INSERT OR REPLACEper run. - Introduces schema versioning so Phase 3.1 (defect model), Phase 3.4 (identity + audit), and any future schema growth land cleanly without the "rewrite the whole file" pattern the JSON store needs.
- Adds alarm history persistence, which is currently in-memory only — alarms raised and cleared during a run are visible in
AppState.ActiveAlarmswhile the process runs but lost on restart. Real production tools need an audit trail. - Surfaces real persistence-side load shapes (lock-wait under concurrent reads + writes, journal mode trade-offs, transaction batching) that the JSON path can't model.
Dapper, not EF Core. Operator preference and project fit. Dapper is a thin micro-ORM — direct SQL with parameter binding and POCO mapping, no LINQ-to-SQL translation, no change tracking, no DbContext lifecycle. The persistence layer here is small (two tables, simple queries, no joins to traverse) and benefits more from explicit SQL than from an entity graph. Dapper also keeps the diff small: ~5 query strings + 2 mapper helpers replace what would be a DbContext + entity classes + OnModelCreating configuration.
Requirements Coverage
- 04. UI and Technical Requirements: the canonical store must remain reactive under load; persistence must not block the UI thread; pagination must scale to historical operating windows
- 05. Failure Modes and Workflow Requirements: alarm history must survive process restart for audit-trail purposes
- 07. AI Delivery Constraints and Roadmap: each phase ships measurable before-and-after; this is row
slice-3-3-sqlite-persistencein the newphase-3-measurements.mdtable
In Scope
Database file
- SQLite database at
%LocalAppData%/LcnWaferInspection/inspection.db(configurable viaSqlitePersistenceOptions.DatabasePath). - Connection string:
Data Source=<path>;Mode=ReadWriteCreate;Foreign Keys=True;Cache=Shared. - Journal mode:
WAL(Write-Ahead Logging) for better concurrent reader/writer behavior. Set on first connection. - The directory is created on first connection if missing (matches
JsonRunHistoryStore's behavior).
Schema versioning
A schema_version table tracks applied migrations:
CREATE TABLE schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL -- ISO 8601
);A MigrationRunner (in Infrastructure.Data.Migrations) loads embedded .sql resources from Migrations/M{NNN}_{description}.sql, queries MAX(version) from schema_version, applies any missing migrations in order inside a single transaction per migration, and inserts the version row on success. Failure rolls back the migration's transaction; the runner aborts the host startup.
Initial migration M001_initial_schema.sql creates schema_version, run_summaries, and alarm_history. Future slices add M002_*.sql, M003_*.sql, etc. — never editing prior migrations.
Tables (initial migration M001)
CREATE TABLE run_summaries (
run_id TEXT PRIMARY KEY, -- Guid as canonical 36-char string
recipe_name TEXT NOT NULL,
started_at_utc TEXT NOT NULL, -- ISO 8601 with offset
ended_at_utc TEXT NOT NULL,
terminal_status TEXT NOT NULL, -- enum string: Completed/Stopped/Aborted/Faulted
defect_count INTEGER NOT NULL,
defects_minor INTEGER NOT NULL,
defects_major INTEGER NOT NULL,
defects_critical INTEGER NOT NULL,
completed_scan_points INTEGER NOT NULL,
total_scan_points INTEGER NOT NULL,
simulator_profile_name TEXT, -- nullable
major_alarms_json TEXT NOT NULL DEFAULT '[]' -- JSON array of strings
);
CREATE INDEX idx_run_summaries_started_at_utc ON run_summaries(started_at_utc DESC);
CREATE TABLE alarm_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alarm_code TEXT NOT NULL,
severity TEXT NOT NULL, -- enum string: Critical/Major/Minor/Info
message TEXT NOT NULL,
raised_at_utc TEXT NOT NULL,
cleared_at_utc TEXT, -- nullable while active
acknowledged_at_utc TEXT, -- nullable
run_id TEXT -- nullable; correlates to run_summaries.run_id
);
CREATE INDEX idx_alarm_history_raised_at_utc ON alarm_history(raised_at_utc DESC);
CREATE INDEX idx_alarm_history_run_id ON alarm_history(run_id);major_alarms_json is a JSON array of strings rather than a separate table because (a) the existing RunSummary.MajorAlarms shape is IReadOnlyList<string>, (b) the data is read together and never queried by individual alarm string, (c) keeping it inline avoids a join on the hot path. SQLite's JSON1 extension is available if future queries need to filter on it.
IRunHistoryStore interface evolution
The current interface has two methods (LoadAsync returning everything; SaveAsync upserts). After this slice:
public interface IRunHistoryStore
{
/// <summary>
/// Returns the most recent N run summaries, ordered newest first.
/// Used by HistoryHydrationService at startup; does not load all history into memory.
/// </summary>
Task<IReadOnlyList<RunSummary>> LoadRecentAsync(int count = 50, CancellationToken ct = default);
/// <summary>
/// Returns a page of run summaries, ordered newest first, for explicit pagination.
/// Used by UI surfaces that need older history beyond the recent window.
/// </summary>
Task<IReadOnlyList<RunSummary>> LoadPageAsync(int skip, int take, CancellationToken ct = default);
/// <summary>Total count of persisted run summaries.</summary>
Task<long> CountAsync(CancellationToken ct = default);
/// <summary>Persists a terminal run summary. Upserts by RunId.</summary>
Task SaveAsync(RunSummary summary, CancellationToken ct = default);
/// <summary>Loads a single summary by RunId, or null if not present.</summary>
Task<RunSummary?> GetAsync(Guid runId, CancellationToken ct = default);
}The LoadAsync() overload that returned everything is removed. The only production caller (HistoryHydrationService) is updated to call LoadRecentAsync(50) + CountAsync().
IAlarmHistoryStore (new)
public interface IAlarmHistoryStore
{
/// <summary>Persists a newly raised alarm. Returns the assigned database id.</summary>
Task<long> SaveAsync(Alarm alarm, DateTimeOffset raisedAt, Guid? runId, CancellationToken ct = default);
/// <summary>Marks the most recent open occurrence of alarmCode as cleared.</summary>
Task MarkClearedAsync(string alarmCode, DateTimeOffset clearedAt, CancellationToken ct = default);
/// <summary>Marks the most recent open occurrence of alarmCode as acknowledged.</summary>
Task MarkAcknowledgedAsync(string alarmCode, DateTimeOffset acknowledgedAt, CancellationToken ct = default);
/// <summary>Returns the most recent N alarm-history entries, newest first.</summary>
Task<IReadOnlyList<AlarmHistoryEntry>> LoadRecentAsync(int count = 100, CancellationToken ct = default);
/// <summary>Total count of persisted alarm-history entries.</summary>
Task<long> CountAsync(CancellationToken ct = default);
}
public record AlarmHistoryEntry(
long Id,
string AlarmCode,
AlarmSeverity Severity,
string Message,
DateTimeOffset RaisedAtUtc,
DateTimeOffset? ClearedAtUtc,
DateTimeOffset? AcknowledgedAtUtc,
Guid? RunId);MarkClearedAsync and MarkAcknowledgedAsync find the most recent row matching alarm_code with cleared_at_utc IS NULL (or acknowledged_at_utc IS NULL) and update that single row. If no open occurrence exists, the call is a no-op (logged at Debug level, not an error — alarm-clear races can produce double-clear calls).
WorkflowService integration
OnFaultInjected, OnFaultCleared, and AcknowledgeFault gain calls to the alarm-history store:
private void OnFaultInjected(FaultInjectedEventArgs args)
{
// ... existing AppState update ...
_ = _alarmHistoryStore.SaveAsync(alarm, DateTimeOffset.UtcNow,
_store.Current.ActiveRun?.RunId);
}
private void OnFaultCleared(string alarmCode)
{
// ... existing AppState update ...
_ = _alarmHistoryStore.MarkClearedAsync(alarmCode, DateTimeOffset.UtcNow);
}
public void AcknowledgeFault(string alarmCode)
{
// ... existing AppState update ...
_ = _alarmHistoryStore.MarkAcknowledgedAsync(alarmCode, DateTimeOffset.UtcNow);
}The fire-and-forget pattern (no await in the event-handler path) is intentional: alarm history persistence is not on the critical path for state transitions. If the SQLite write fails, the in-memory ActiveAlarms is still correct. An exception inside the persistence call is logged but does not propagate to the workflow handler — the same swallow-and-log pattern AlarmBursterService uses.
AppState evolution
AppState.RunHistory keeps its shape (IReadOnlyList<RunSummary>) but its semantic narrows: it is now "the most recent 50 summaries" (the value LoadRecentAsync returned at startup), not "all history." A new field is added:
public long TotalRunCount { get; init; }Updated by HistoryHydrationService at startup via _historyStore.CountAsync() and by WorkflowService.RunLoopAsync.finally when a new summary is saved (incremented atomically through the standard _store.Update reducer).
One-time import from JSON
SqliteRunHistoryStore's constructor (or a separate JsonImportService running before HistoryHydrationService) checks:
- Is
inspection.dbpresent? If yes → skip import. - Is
run-history.jsonpresent? If no → skip import. - Read all summaries from the JSON file, batch-insert into
run_summaries, rename the JSON file torun-history.json.imported-<timestamp>so the import doesn't repeat. - Log Info "Imported N run summaries from legacy JSON store."
The import runs after the migration runner (so the schema exists) and before HistoryHydrationService (so the loaded recent-50 includes imported rows).
If the JSON file is corrupted, the import is skipped (logged Warning) and the SQLite database remains empty — same fail-soft behavior JsonRunHistoryStore.LoadAsync had.
Configuration
public sealed class SqlitePersistenceOptions
{
public const string SectionName = "Persistence:Sqlite";
public string DatabasePath { get; set; } = string.Empty;
public string JournalMode { get; set; } = "WAL"; // valid: WAL / DELETE / TRUNCATE / MEMORY
}Defaults set in InfrastructureServiceCollectionExtensions.AddInfrastructure:
services.AddOptions<SqlitePersistenceOptions>().Configure(o =>
{
o.DatabasePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LcnWaferInspection",
"inspection.db");
o.JournalMode = "WAL";
});The existing RunHistoryStoreOptions (JSON file path) is kept for the import path's source location, then the migration tool renames the JSON file out of the way. A subsequent slice can remove RunHistoryStoreOptions entirely once enough deployments have migrated.
Packages
Add to Directory.Packages.props:
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.0" />Both reference the latest stable versions at slice-open time. The Microsoft.Data.Sqlite minor must align with the .NET TFM (currently net10.0). Dapper is targeted-TFM-agnostic.
Measurement scenario and row
A new file docs/reviews/phase-3-measurements.md is created (mirrors phase-1-measurements.md structure: Conventions / Fixed metric set / Phase 3 rows). One row block tagged slice-3-3-sqlite-persistence.
A new runbook section §5.2 — SLICE-3.3 SQLite persistence capture (where §5.1 is the SLICE-2.0 store-allocation scenario from Phase 2) describes the procedure: 30-min MultiTag profile capture under the new SQLite store, with the database pre-populated to 10 000 rows so the persistence path is exercised at scale.
The row block extends the standard 22-26-metric set with three persistence-specific metrics:
| Metric | How extracted |
|---|---|
runs.persisted (count) | Sum of new rows in run_summaries over the capture window |
alarms.persisted (count) | Sum of new rows in alarm_history over the capture window |
recent-history-load p95 (ms) | p95 of LoadRecentAsync(50) wall-clock at startup, measured by an in-process counter or a one-off log line + extraction |
MeasurementExtraction.psm1 gains Get-RunsPersistedCount, Get-AlarmsPersistedCount, and Get-RecentHistoryLoadP95 helpers (the first two are simple SQLite queries; the last reads the startup log).
Out of Scope
- Per-run
TagSamplesnapshot persistence — the original roadmap §5 named it; SLICE-3.3 explicitly defers to SLICE-3.1 (rich defect model) or a follow-up. The reason: per-run TagSample storage is a non-trivial schema design (snapshot frequency, compression, retention), and conflating it with the run-history migration risks both being done badly. - Defect persistence (rich shape:
Id, FrameId, BoundingBox, Classification, Confidence, ImageRef) — SLICE-3.1's whole point. SLICE-3.3 ships the per-run defect counts (already onRunSummary) and leaves the defect records for 3.1. - Concurrent-writer scenarios — multiple WPF processes writing to the same SQLite file. The single-instance mutex from SLICE-006 prevents this in production; tests don't simulate it.
- Backup / restore tooling — copy
inspection.dbis the entire backup procedure. Versioned backups, encryption-at-rest, and remote replication are deferred to Phase 4 (real-deployment concerns). - Schema rollback — migrations are forward-only. Reverting to an earlier schema requires restoring a backup.
- EF Core or any other ORM — explicitly Dapper; this slice is the precedent.
InspectionResult(per-frame defect detection result) persistence — the workflow currently passesInspectionResultthrough the pipeline service intoActiveRun.DefectCount; the result itself is in-memory only. 3.1 lifts this.- Removing
RunHistoryStoreOptions— kept as the import path's source; removal is a follow-up after enough deployments have migrated. - UI changes —
AppState.RunHistorykeeps its shape so the existing UI binding works. The newTotalRunCountfield is wired but not yet displayed; UI consumption is a Phase 3.1 / 3.2 concern.
Runtime Behavior
Startup sequence
host startup
│
▼
1. AddInfrastructure()
- registers SqlitePersistenceOptions with default DatabasePath
- registers MigrationRunner as IHostedService (priority: very high)
- registers JsonImportService as IHostedService (priority: high, after MigrationRunner)
- registers SqliteRunHistoryStore as IRunHistoryStore (singleton)
- registers SqliteAlarmHistoryStore as IAlarmHistoryStore (singleton)
- HistoryHydrationService unchanged (consumer of IRunHistoryStore)
▼
2. MigrationRunner.StartAsync
- opens connection, sets PRAGMA journal_mode=WAL
- reads MAX(version) FROM schema_version (or 0 if table absent)
- applies M001_initial_schema.sql (and any later Mxxx) inside per-migration transaction
- on failure: logs Critical, throws → host startup aborts
▼
3. JsonImportService.StartAsync
- if inspection.db exists AND has any rows in run_summaries → no-op (already imported)
- else if run-history.json exists → batch-insert all summaries, rename JSON to *.imported-<ts>
- logs Info "Imported N run summaries"
▼
4. HistoryHydrationService.StartAsync
- LoadRecentAsync(50) → AppState.RunHistory
- CountAsync() → AppState.TotalRunCount
- sets LastRunSummary if any summaries exist
▼
5. Rest of hosted services (SimulatedTagSource, FramePipelineService, etc.) startRun-end persistence flow (changed)
WorkflowService.RunLoopAsync.finally:
build RunSummary
call _historyStore.SaveAsync(summary) ◀── now writes to SQLite, single-row INSERT OR REPLACE
call _store.Update(s => s with {
RunHistory = ([summary] + s.RunHistory).Take(50).ToList(), // sliding window
TotalRunCount = s.TotalRunCount + 1,
LastRunSummary = summary
})The "load all → modify → write all" pattern of JsonRunHistoryStore is replaced by a single INSERT OR REPLACE. The in-memory RunHistory list is kept as a sliding window of the most-recent 50 (a simple bounded list — the older entries are still in SQLite, accessible via LoadPageAsync).
Alarm-history flow (new)
fault injected → WorkflowService.OnFaultInjected:
- existing: AppState update (Faulted state, ActiveAlarms list)
- NEW: _alarmHistoryStore.SaveAsync(alarm, DateTimeOffset.UtcNow, runId)
(fire-and-forget; logged on failure; does not block the handler)
fault cleared → WorkflowService.OnFaultCleared:
- existing: AppState update (alarm.IsActive = false)
- NEW: _alarmHistoryStore.MarkClearedAsync(alarmCode, DateTimeOffset.UtcNow)
operator acks → WorkflowService.AcknowledgeFault:
- existing: AppState update (alarm.IsAcknowledged = true)
- NEW: _alarmHistoryStore.MarkAcknowledgedAsync(alarmCode, DateTimeOffset.UtcNow)The AlarmBursterService (SLICE-1.4) automatically exercises all three paths under ChaosMonkey load — useful for the row-block evidence.
Pagination access pattern
HistoryHydrationService at startup:
recent = await _historyStore.LoadRecentAsync(50);
total = await _historyStore.CountAsync();
_store.Update(s => s with {
RunHistory = recent,
TotalRunCount = total,
LastRunSummary = recent.FirstOrDefault() ?? s.LastRunSummary
});UI surfaces wanting older history (Phase 3.1 / 3.2) call _historyStore.LoadPageAsync(skip, take) directly. AppState.RunHistory deliberately does NOT grow as the UI pages — the canonical store holds the recent window only; pages are loaded on demand.
Connection management
SqliteRunHistoryStore and SqliteAlarmHistoryStore each construct a fresh SqliteConnection per call (Dapper's standard pattern with connection pooling enabled by Microsoft.Data.Sqlite). No long-lived connection is held — the pool reuses underlying handles.
WAL mode is set on first connection by the MigrationRunner's opening transaction; subsequent connections inherit it from the database file.
Acceptance Criteria
This slice is satisfied only if all of the following are true:
Dapper 2.1+andMicrosoft.Data.Sqlite 10.0+are added toDirectory.Packages.props(noVersion=attribute on individual.csprojfiles; centralized version management per Phase 0 conventions).Infrastructure/Data/Migrations/M001_initial_schema.sqlexists as an embedded resource and creates theschema_version,run_summaries, andalarm_historytables with the indexes specified in "In Scope". Loading the migration via theMigrationRunnerproduces the exact schema (verified by a test that queriessqlite_masterpost-migration).MigrationRunner(Infrastructure.Data.Migrations.MigrationRunner) is registered as anIHostedServicethat runs beforeHistoryHydrationService. On a fresh database it applies M001; on a DB at version N it applies M(N+1) onward. Each migration runs in a single transaction; failure rolls back and aborts host startup with aCriticallog line.IRunHistoryStoreevolved per "In Scope":LoadRecentAsync(int count = 50),LoadPageAsync(int skip, int take),CountAsync(),GetAsync(Guid),SaveAsync(RunSummary). The previousLoadAsync()(returning everything) is removed; all callsites updated.SqliteRunHistoryStore(Dapper) implementsIRunHistoryStore.SaveAsyncisINSERT OR REPLACE INTO run_summaries (...) VALUES (...).LoadRecentAsyncandLoadPageAsyncareSELECT ... ORDER BY started_at_utc DESC LIMIT @take OFFSET @skip.CountAsyncisSELECT COUNT(*) FROM run_summaries.GetAsyncisSELECT ... WHERE run_id = @runId.IAlarmHistoryStoreexists with the shape in "In Scope".SqliteAlarmHistoryStoreimplements it.MarkClearedAsync/MarkAcknowledgedAsyncfind the most recent open row byalarm_codeand(cleared|acknowledged)_at_utc IS NULL, then update; if no row matches, the call is a Debug-logged no-op.WorkflowServicecalls the alarm-history store fromOnFaultInjected,OnFaultCleared, andAcknowledgeFaultper "Runtime Behavior". The calls are fire-and-forget (_ = ...) and do not block the workflow handlers. Exceptions inside the persistence call are caught and logged at Warning, never propagated.JsonImportServiceis registered as anIHostedServicerunning betweenMigrationRunnerandHistoryHydrationService. On first SQLite-mode startup with a non-emptyrun-history.json, it imports every summary (preserving order), then renames the JSON file torun-history.json.imported-<yyyy-MM-ddTHH-mm-ssZ>so the import doesn't repeat. Idempotent: re-running on a populated SQLite DB is a no-op.HistoryHydrationServiceupdated:LoadRecentAsync(50)+CountAsync()instead ofLoadAsync().AppState.TotalRunCountpopulated.AppState.RunHistoryis the recent-50 window.AppStategainslong TotalRunCount(immutable record property).WorkflowService.RunLoopAsync.finallyupdates it (TotalRunCount + 1per terminal summary persisted) inside the same_store.Updatereducer that updatesRunHistoryandLastRunSummary.JsonRunHistoryStoreis removed from the codebase (file deleted, DI registration replaced withSqliteRunHistoryStore). TheRunHistoryStoreOptionsclass stays for the import-source path; remove in a follow-up slice once enough deployments have migrated.- 10 000-row pagination benchmark. A test (
SqliteRunHistoryStorePerformanceTests.LoadRecent50_AfterPopulating10K_CompletesUnder200Ms) populates a temp database with 10 000 syntheticRunSummaryrows, then assertsLoadRecentAsync(50)completes in < 200 ms wall-clock. The test usesStopwatchand is run under Release configuration. If the test fails, investigate the index (idx_run_summaries_started_at_utc) — without it, the LIMIT/OFFSET path scans the whole table. - Reproducibility of prior Phase 1 rows. Running
Capture-Measurements.ps1 -Profile MultiTag -DurationSeconds 1800on the SQLite branch produces a row whose first 22 metrics (frames / tags / encoder / GC / CPU) are within ±10% ofslice-1-1-multi-tag-telemetry. The persistence change must not perturb the data plane materially. - A new file
docs/reviews/phase-3-measurements.mdexists with the same conventions asphase-1-measurements.md. One row block taggedslice-3-3-sqlite-persistencecovers the standard metrics plus three new persistence-specific rows:runs.persisted (count),alarms.persisted (count),recent-history-load p95 (ms). The CSV is committed atdocs/captures/slice-3-3-sqlite-persistence-<date>.csv. MeasurementExtraction.psm1gainsGet-RunsPersistedCount,Get-AlarmsPersistedCount,Get-RecentHistoryLoadP95.ConvertTo-MeasurementRowemits the three new metrics when present,"—"otherwise. Pester tests cover each helper.docs/runbook/capturing-measurements.mdgains a §5.2 entry (Phase 3 captures, after the §5.1 placeholder for SLICE-2.0). Procedure: pre-populateinspection.dbwith 10 000 synthetic rows, run the standardMultiTag30-min capture, extract row, append.- The full existing test suite still passes (Phase 1 + Phase 2.0 tests reproduce against the SQLite-backed history). New tests: migration runner applies M001 cleanly; one-time import path works idempotently and handles missing/corrupted JSON; SQLite roundtrip for
RunSummaryandAlarmHistoryEntry;MarkClearedAsync/MarkAcknowledgedAsyncno-op on missing rows; pagination perf (criterion 12);WorkflowServiceintegration test verifies fault inject → SQLite row → fault clear → row updated.
Verification Notes
- WAL mode on test databases. Tests using a temp
.dbfile should explicitlyPRAGMA journal_mode = WALon first connection; otherwise tests may interact with leftover WAL/SHM files from prior test runs. Consider cleanup in test teardown (File.Delete(dbPath); File.Delete(dbPath + "-wal"); File.Delete(dbPath + "-shm")). Microsoft.Data.Sqliteconnection-stringCache=Shared. Required for in-memory test databases (Data Source=:memory:) to persist across pooled connections. For file-based test DBs, omit it (file pooling works without).- Time precision. SQLite stores TEXT for timestamps;
DateTimeOffsetround-trips through ISO 8601 with millisecond precision. If a future test depends on sub-millisecond precision (alarm raise → clear in < 1 ms), the precision is lost. Real wafer inspection alarm raises and clears are ms-or-coarser, so this is fine for current scope. - Concurrent reads + writes. WAL mode allows readers to proceed without blocking writers. The fire-and-forget alarm-history calls run on the thread pool; the
LoadRecentAsyncon the UI startup path runs concurrently. Verified by an integration test that issues 100 concurrentSaveAsynccalls and asserts no exception + count matches. - Schema-version forward-migration test. Stub a future M002 (a SQL file added under
Migrations/that adds a no-op column). Run migrations on a fresh DB; assertMAX(version) = 2. Run again on the populated DB; assert it's still2(no double-apply). - JSON import corruption handling. A malformed
run-history.jsonin the user's%LocalAppData%should NOT abort startup — the import is best-effort. The original JSON is renamed with a.malformed-<ts>suffix so the user can inspect it; SQLite history starts empty. - Existing
Capture-Measurements.ps1doesn't currently invoke any DB pre-population. The Pass 3 capture procedure documents the pre-population step as manual SQL (or a one-off helper script) so the operator can prepare the 10K-row state before the capture window starts. - Phase 2 trigger watch. This slice's row block is one of the candidate triggers for Phase 2.x slices (per the 2026-05-07 strategy doc). When the row lands, compare its
cpu-usage avg,working-set growth, andalloc-rate avgcolumns againstslice-2-0-store-profiling; if SQLite persistence pressure visibly elevatesAppStateStore.Update's alloc share, that's the SLICE-2.1 / 2.2 trigger. Document the observation in the row's Notes section.