P0.2.2 — Step 2 Contract
Behavioral contract for src/db/index.ts. Everything in this file is testable; the verification step (5) re-reads this contract and matches tests to clauses.
§1. Module identity
- File:
src/db/index.ts - Owner concept: α System Core (owns the file, not the schema)
- Consumer (future): P0.2.3 (two-phase startup) calls
initDb(config.COLIBRI_DB_PATH)during Phase 2 - Exports (public surface):
initDb(path: string): Database.DatabasegetDb(): Database.DatabasecloseDb(): voidtype Database = import('better-sqlite3').Database.Database(re-export for convenience)
- Internal (not exported):
let instance: Database.Database | nulldiscoverMigrations(): Migration[]applyMigration(db, migration)insideinitDb
The module is pure at import time: importing it does nothing; no side-effects, no DB open, no file read. This matches the P0.4.1 src/modes.ts pattern and is asserted by test.
§2. initDb(path) — contract
2.1 Inputs
path: string— required. Absolute or relative (relative paths resolve fromprocess.cwd()). No default; callers MUST pass explicitly. The P0.2.3 production caller will passconfig.COLIBRI_DB_PATH; tests pass a unique temp path per test.
2.2 Output
- Returns a live
Database.Databasehandle with the pragmas applied and all pending migrations run. - The handle is also cached as the module’s singleton, retrievable via
getDb().
2.3 Sequence (observable side-effects, in order)
- Parent directory creation. Compute
dirname(path). If the directory does not exist, create it recursively (fs.mkdirSync(dir, { recursive: true })). Ifdirname(path)is'.'(path has no directory component), skip. The module does not race any other writer for the directory; if another process creates it concurrently therecursive: trueflag makesmkdirSyncidempotent. - Open the database.
new Database(path)— creates the file if absent, opens if present. Better-sqlite3’s constructor is sync. - Pragmas (set before migrations).
PRAGMA journal_mode = WAL— MUST succeed and return'wal'. If the disk doesn’t support WAL (rare; some network filesystems), better-sqlite3 falls back to'memory'or'truncate'; contract asserts WAL. Tests run on local disk.PRAGMA foreign_keys = ON— MUST succeed.
- Integrity check.
db.pragma('integrity_check', { simple: true }). If the result is not exactly the string'ok':- Close the database handle (
db.close()). - Throw
Errorwith message"Database integrity check failed: <result>"where<result>is the actual return. - The singleton is NOT set (caller sees a fresh throw;
getDb()continues to throw the “not initialized” error).
- Close the database handle (
- Migration discovery. Read
src/db/migrations/relative to the compiled module location (fileURLToPath(import.meta.url)→dirname→join(dirname, 'migrations')). Filter*.sql. Parse theNNN_prefix asparseInt(..., 10); skip files whose prefix is not a positive integer (e.g..gitkeep, README files). Sort ascending by numeric prefix. - Migration application.
- Read
PRAGMA user_version→current. - For each migration
min discovered order wherem.version > current:- Read
m.pathas UTF-8 string. - Strip SQL comments (
--line comments) and trim whitespace. If the remainder contains only whitespace or is empty, skip thedb.execcall (empty migration) but still bumpuser_version. - Run the migration body and the
user_versionbump inside a single SQLite transaction viadb.transaction(() => { ... })(). The pragma bump isdb.pragma(\user_version = ${m.version}`)— note: SQLite'suser_versionpragma does not accept parameters; string-interpolate after validatingm.version` is a safe integer. - On migration error, the transaction rolls back automatically; re-throw wrapped:
Error("Migration <file> failed: <original-message>"). Close the handle before re-throw. Singleton NOT set.
- Read
- Read
- Set singleton. Only after all migrations succeed:
instance = db. - Return the handle.
2.4 Idempotence guarantee
A second call to initDb(samePath):
- If
instanceis set (singleton populated by a prior call on any path): close the existing handle and open the new one. This lets tests and hot-reload scenarios re-seat the DB. - If
instanceis null (first call, or post-closeDb): proceed as §2.3.
The on-disk DB is unchanged across re-calls with the same path provided no new migration files have appeared between calls. The test for idempotence:
- Call
initDb(p); recorduser_version. - Close via the returned handle (not
closeDb, to test the singleton-replace path). - Call
initDb(p)again. Assertuser_versionis unchanged (because the set of migration files is unchanged).
2.5 Failure modes (tested)
| Failure | Cause | Outcome |
|---|---|---|
| Parent dir create fails | Permission denied on dirname(path) |
fs.mkdirSync throws EACCES; uncaught; handle never opens; singleton untouched. |
new Database(path) fails |
File locked by another writer, disk full, permission denied | SqliteError propagates; singleton untouched. |
| Pragma set fails | Corrupt file, broken SQLite | SqliteError propagates after the handle is opened; handle leaks (acceptable: OS closes on process exit — matches the Wave A discipline of not over-engineering cleanup on unrecoverable boot failure). |
| Integrity check fails | Corrupt DB file | db.close() called, then throw "Database integrity check failed: <result>". Singleton untouched. |
| Migration file fails | SQL error, constraint violation | Transaction rolls back; db.close(); throw "Migration <file> failed: <msg>". Singleton untouched. |
| Migration file prefix non-integer | abc_foo.sql |
Silently skipped (with any other discovery filter). Acceptable: prefix-less files are fixtures or drafts. |
§3. getDb() — contract
3.1 Inputs
None.
3.2 Output
- Returns the cached
Database.Databaseinstance from the most recent successfulinitDb.
3.3 Sequence
- If
instance === null: throwError("Database not initialized — call initDb() first"). Exact message (leading capital D, em-dash — not hyphen). - Otherwise: return
instance.
3.4 Invariants
getDb()does NOT lazy-init fromconfig.COLIBRI_DB_PATH. This is a deliberate deviation from the spec file’s example (lines 437-442), approved by Sigma. Rationale:- Phase 2 startup (P0.2.3) will explicitly call
initDbduring its heavy-init phase — there is exactly one lifecycle owner. - Lazy init would couple
getDb()toconfig, which makes the module harder to test (subprocess tests for env isolation would become mandatory). - Throwing fast on misuse is consistent with how
audit-exitstage 5 ofsrc/server.tshandles sink failures (log-and-continue there; throw-loudly-and-crash here because a missing DB is unrecoverable).
- Phase 2 startup (P0.2.3) will explicitly call
getDb()has O(1) runtime complexity and no side-effects.- Calling
getDb()multiple times MUST return the same reference (getDb() === getDb()for every call on the same singleton).
§4. closeDb() — contract
4.1 Inputs
None.
4.2 Output
Returns void.
4.3 Sequence
- If
instance !== null: callinstance.close(). Better-sqlite3’sclose()is sync and idempotent at the handle level (double-close raises), but this function only calls it once per cached singleton. - Set
instance = null. - Return.
4.4 Invariants
- Calling
closeDb()beforeinitDb()is a no-op (no throw). - After
closeDb():getDb()throws the “not initialized” error;initDb(path)succeeds fresh. closeDb()does NOT delete the on-disk DB file. The file persists across restarts; that is the whole point of SQLite.
§5. Migration runner — contract
5.1 Discovery
- Root:
join(dirname(fileURLToPath(import.meta.url)), 'migrations'). - Glob:
*.sql(case-sensitive). - Prefix pattern:
/^(\d+)_/. Files not matching are silently skipped. - Ordering: ascending by parsed integer prefix. Ties (two files with the same prefix) are prohibited — the runner throws
"Migration prefix collision at <version>: <fileA> vs <fileB>"at discovery time.
5.2 Version tracking
PRAGMA user_versionis a 32-bit signed integer stored in the SQLite database header (positions 60-63). It is invisible tosqlite_masterand therefore does not violate the “zero user tables at α floor” acceptance criterion.- The initial value on a brand-new database is
0.001_init.sqlbumps it to1,002_beta.sqlto2, etc. The version number is the targetuser_version, not a sequence index. - Gaps in the sequence are legal (if
001and003exist but002does not,003runs after001). The R75 convention reserves one migration per concept in order; gaps indicate a concept that opted out of its own migration.
5.3 Transactional semantics
Each migration + version bump runs in a single SQLite transaction:
const tx = db.transaction((version: number, sql: string) => {
if (sql.trim() !== '') {
db.exec(sql);
}
db.pragma(`user_version = ${version}`);
});
tx(m.version, body);
If the migration throws, the transaction aborts; the on-disk user_version is unchanged; initDb re-throws with the migration-name prefix and closes the handle.
§6. schema.sql — contract
src/db/schema.sql is a shipped asset, not executed. Its purpose:
- Declared in
package.json#files(line 15), so it is included innpm pack/npm publishoutput. Clients reading the published package can inspect the high-level schema description. - Contains a human-readable header describing the α earning rule and pointing to
docs/2-plugin/database.mdfor the full story. NoCREATE TABLE, noPRAGMA, no executable SQL. - Kept short (<30 lines) so it is a reference card, not a doc in its own right.
Any future PR that adds CREATE TABLE to schema.sql is a scope-drift bug. Tables go in migrations/NNN_<concept>.sql only.
§7. migrations/001_init.sql — contract
First migration slot. Comment-only body so α ships with zero user tables. Purpose:
- Anchors the migration-numbering convention so later concepts (β, ε, …) start at
002+. - Exercises the empty-migration path in the runner (§2.3 step 6, “strip comments + whitespace; skip exec”).
- Visible in the file tree as proof of the earning rule.
§8. Pragma invariants (tested)
After a successful initDb:
| Pragma | Expected | Verification |
|---|---|---|
journal_mode |
'wal' |
db.pragma('journal_mode', { simple: true }) === 'wal' |
foreign_keys |
1 |
db.pragma('foreign_keys', { simple: true }) === 1 |
integrity_check |
'ok' |
run inside initDb; result MUST be 'ok' or boot aborts |
user_version |
>= 1 after 001_init.sql applied |
db.pragma('user_version', { simple: true }) === N where N is the highest migration prefix discovered |
§9. Earning rule (acceptance criterion “zero user tables at α floor”)
Per docs/2-plugin/database.md §”‘Earned’ Tables” and the stale-but-aspirational docs/architecture/data-model.md §2, α owns the file but no tables. The assertion:
SELECT COUNT(*) AS c
FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
MUST return 0 on a freshly-initialized DB with only 001_init.sql applied. Every future concept task (P0.3 β, P0.6 ε, P0.7 ζ, P0.8 η, P0.9 ν) introduces its own migration and rebases this assertion forward in its own test.
This assertion is the anchor that prevents schema drift. If a later task copies donor DDL into schema.sql, its test fails; if a later task creates tables outside its owning concept’s migration, its test fails; if the migration runner applies an unexpected migration, this test fails.
§10. Logging
- No stdout writes (donor bug #3 — would corrupt MCP JSON-RPC wire format).
- No stderr writes at the module level. Boot-time diagnostics are the caller’s responsibility (P0.2.3 logs boot progress via
ctx.loggerfromsrc/server.ts). - Errors propagate via
throw;initDbnever catches-and-logs.
§11. Concurrency
initDbis sync; noasync/await. Matches better-sqlite3’s sync API.- The module is not thread-safe in the multi-worker sense, but Node.js is single-threaded for user code;
initDbis called exactly once per process in Phase 0 (during P0.2.3 heavy-init). Any “concurrent” call is a bug in the caller. - SQLite’s WAL mode allows external readers (other processes) to open the file in read-only mode concurrently. The contract does not guarantee behavior in multi-writer scenarios — Phase 0 is single-writer per
docs/2-plugin/database.md§”Single Writer, Multiple Readers”.
§12. Testability requirements
- Every assertion in §§2-10 MUST have at least one test in
src/__tests__/db-init.test.ts. - Tests use
os.tmpdir()+ acrypto.randomUUID()-derived suffix for unique paths; cleanup viafs.rmSync(path, { recursive: true, force: true })inafterEach, swallowingENOENT. - No test touches
data/colibri.dbor any path underdata/. - The module is exercised via the public exports only; no monkey-patching of
fs, nojest.mock. The 999-migration test uses a real file injected into the migrations directory then removed in cleanup. - Target coverage on
src/db/index.ts: 100% stmt/func/line, ≥90% branch. Matches P0.2.1 (98.2 stmt / 92.6 branch / 100 func / 98.2 line) and P0.4.1 (100/100/100/100).
§13. Open questions — none
All ambiguities from §1 of the packet have been resolved. No decisions are deferred to implementation.