Implement database layer with schema initialization #2
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Task 2 — Plan reference:
docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.mdUser story: As a developer, I need a database singleton that initializes the schema on first run so that all routes can read and write data without managing connections.
Acceptance criteria
src/lib/db.tsexportsgetDb()returning abetter-sqlite3Database instancegetDb()is idempotent — creates singleton, safe to call from any server modulePRAGMA journal_mode = WAL)PRAGMA foreign_keys = ON)codes,artikel,artikel_fotos,reservierungenUNIQUE(artikel_id)onreservierungenenforces atomic first-come-first-served reservationON DELETE CASCADEfromcodes → reservierungenandartikel → artikel_fotos_resetForTesting()export allows Vitest to reset the singleton between testsSchema (from spec)
Files to create
src/lib/db.tssrc/lib/db.test.tsDepends on: #1 | Size: S | Spec: system-design §3
👤 Markus Keller — Application Architect
Observations
getDb()as the public API, but the system-design spec (lib/db.ts) shows a pattern where prepared statements are also exported fromdb.ts(e.g.,stmtArtikelAll). The issue does not mention exporting prepared statements — the implementation plan must decide whetherdb.tsexports raw statements or only the singleton. Doing neither now and adding them per-route later creates import chaos._resetForTesting()export is correctly specified. This is the right mechanism to avoid the singleton persisting across Vitest tests. However, the issue does not specify what_resetForTesting()should do exactly: close the current connection and set the module-level variable tonull/undefinedso the nextgetDb()call re-initialises from theDATABASE_PATHenv var — or return a fresh:memory:instance. These are two different contracts. The test file must clarify this before implementation, not after.DATABASE_PATHmust come fromprocess.env.DATABASE_PATHper the system-design spec and deployment plan. The issue does not mention what happens when this env var is missing. Per architecture convention, the module must fail loudly at startup — not silently fall back to a default path. A default path silently persists data somewhere unexpected inside the Docker image layer.db.exec(SCHEMA_SQL)withIF NOT EXISTSguards — this is correct and must be a singleexec()call with all four tables, not four separate calls. A single exec is atomic in SQLite.SCHEMA_SQLconstant should be a named export fromdb.tssodb.test.tscan reuse it to build:memory:databases without duplicating the schema string.Recommendations
SCHEMA_SQLas a named constant fromdb.ts. Tests import it directly. No schema duplication, no drift between production schema and test schema._resetForTesting()behaviour precisely in the implementation: it closes the active connection and sets the module-level singleton variable tonull, so the nextgetDb()call creates a fresh connection to whateverDATABASE_PATHis at call time. Tests must setprocess.env.DATABASE_PATH = ':memory:'before callinggetDb()in theirbeforeEach.process.env.DATABASE_PATHis absent or empty, thrownew Error('DATABASE_PATH environment variable is required')before any SQLite call. This is consistent with theSESSION_SECRETguard pattern inauth.ts.CREATE TABLE IF NOT EXISTSstatements in a singledb.exec(SCHEMA_SQL)call — not split across multiple calls or conditional logic.Open Decisions (omit if none)
_resetForTesting()contract — Does it reset the singleton sogetDb()re-opensDATABASE_PATH(allowing tests to set:memory:via env), or does it accept a path argument and return a new instance? The first is simpler; the second allows tests to avoid env mutation. Either is workable, but the choice must be decided before the test file is written, as it determines the entire test setup pattern.👤 Felix Brandt — Fullstack Developer
Observations
src/lib/db.ts,src/lib/db.test.ts) and the right acceptance criteria. The implementation is straightforward for an S-sized task — all four tables, two PRAGMAs, one singleton, one reset function, four tests. No ambiguity in the SQL itself.it('creates all 4 tables'),it('WAL mode is active'),it('UNIQUE constraint on reservierungen.artikel_id throws'),it('cascade delete from artikel removes artikel_fotos rows'). These are the correct integration tests using:memory:SQLite — no mocking, no approximation.db.test.tsshould usebeforeEach/afterEachwith:memory:databases or use_resetForTesting(). These serve different purposes:beforeEachwith a fresh:memory:instance tests the schema in isolation;_resetForTesting()tests the singleton's reset contract. Both are needed, but for different test categories.better-sqlite3is synchronous. Noawaitanywhere indb.ts. The implementation should make this impossible to misuse by not wrapping anything inasyncor returning Promises.vitest.config.tsmust be present withenvironment: 'node'to runbetter-sqlite3correctly. JSDOM will break the native module. This is a prerequisite that should be confirmed before the first test run.Recommendations
db.test.tsas two groups: (1) schema creation tests — each uses a freshnew Database(':memory:')withdb.exec(SCHEMA_SQL)directly, completely isolated from the singleton; (2) singleton tests — use_resetForTesting()inbeforeEachwithprocess.env.DATABASE_PATH = ':memory:'to exercisegetDb()directly.getDb(), rundb.pragma('journal_mode')and assert the result equals'wal'. Note that on:memory:databases, WAL is silently ignored by SQLite — the pragma call succeeds but returns'memory'. Test WAL on a temp file path if the WAL assertion must be strict.reservierung, then wrap the second insert inexpect(() => ...).toThrow(/UNIQUE constraint failed/). Verify the original row is still intact with a follow-upSELECT.artikel→ insertartikel_fotos→ deleteartikel→ assertartikel_fotoscount is zero. Keepforeign_keys = ONin the test setup or the cascade will not fire.'creates codes, artikel, artikel_fotos, and reservierungen tables on init', not'db init'.Open Decisions (omit if none)
:memory:databases — SQLite silently ignores WAL for in-memory databases. If the WAL acceptance criterion must be proven by a test, the test either uses a temp file path (slower, requires cleanup) or the criterion is verified by code review only. Which is acceptable?👤 Tobias Wendt — DevOps & Platform Engineer
Observations
db.tsreadsprocess.env.DATABASE_PATHat module load time. In the Docker container, this resolves to/app/db/erbstuecke.db, which is on the nameddbvolume. This is correct — the file persists acrossdocker compose up --buildcycles./app/db/directory creation. SQLite'sbetter-sqlite3will throwSQLITE_CANTOPENif the parent directory does not exist at startup. In production, the named volume is mounted at/app/dband Docker creates the mount point, so the directory exists. In a fresh local dev run without the volume, the directory may not exist. AmkdirSync(path.dirname(DATABASE_PATH), { recursive: true })call before thenew Database()call prevents a confusing startup crash in both environments.better-sqlite3compiled against the correct Node.js ABI. The multi-stage build must runnpm ci --omit=devin the runner stage;better-sqlite3is a production dependency (not devDependency) since it's used at runtime. Verifypackage.jsonplaces it independencies, notdevDependencies._resetForTesting()export is a test-only API. It must never be called in the production code path. Since SvelteKit server modules are not tree-shaken in the Node adapter build, this function will appear in the production bundle — that is acceptable as long as nothing calls it. A// FOR TESTING ONLYcomment is sufficient.SESSION_SECRETstartup guard is mentioned in other specs. The same pattern (fail loudly if missing) should apply toDATABASE_PATHindb.tsfor consistency with the deployment posture.Recommendations
fs.mkdirSync(path.dirname(dbPath), { recursive: true })beforenew Database(dbPath)in the singleton init. This is a one-liner that prevents the most common "works on my machine, fails in Docker on first run" failure mode.better-sqlite3is independencies(notdevDependencies) inpackage.json. The runner stage of the Dockerfile runsnpm ci --omit=dev— ifbetter-sqlite3is a devDep, the production container crashes on startup.if (!process.env.DATABASE_PATH) throw new Error('DATABASE_PATH is required'). Fail at module load, not at firstgetDb()call — the error surface is cleaner and appears immediately in container logs.docker run --rm ... alpine tar) depends on WAL checkpoint completing before the copy.better-sqlite3checkpoints automatically ondb.close(). Ensure the SvelteKit Node adapter callsdb.close()on graceful shutdown, or trigger a manual checkpoint (db.pragma('wal_checkpoint(TRUNCATE)')) before the backup window. This is out of scope for this issue but should be tracked.👤 Nora "NullX" Steiner — Security Engineer
Observations
codestable is the authentication source of truth for family members, and its integrity is enforced entirely by the UNIQUE constraint oncode. No additional application-level deduplication is needed — and none should be added (race conditions in pre-checks are a classic vulnerability, per the architecture doc).DATABASE_PATHcomes from the environment. If this path is user-controllable in any context (e.g., a misconfigured Docker setup that reads.envfrom a user-writable location), an attacker could point it at a database they control, pre-populated with a knownfamily_codevalue. This is a deployment/ops concern, not a code concern — but it is worth documenting as a threat._resetForTesting()export is named with a leading underscore convention to signal it is not for production use. This is a reasonable convention. However, if the production SvelteKit application ever had a route that called this (e.g., a leftover debug endpoint), it would wipe all in-memory state. The function should include a runtime guard:if (process.env.NODE_ENV === 'production') throw new Error('_resetForTesting called in production'). This is a one-line defense.ON DELETE CASCADEfromcodes → reservierungenis correct: when a family member's access code is revoked, their reservations are removed atomically by the database. No orphaned reservation row can remain referencing a deleted code.PRAGMA foreign_keys = ONmust be applied per-connection in SQLite — it is not persisted. The singleton pattern ensures it runs exactly once per Node.js process lifetime. If any part of the codebase creates a secondbetter-sqlite3connection (e.g., in a migration script or test helper that doesn't usegetDb()), that second connection will have foreign keys disabled by default. All test setup that creates:memory:databases must explicitly enable foreign keys.Recommendations
if (process.env.NODE_ENV === 'production') throw new Error('_resetForTesting() must not be called in production')as the first line of_resetForTesting(). Zero runtime cost in tests; prevents catastrophic misuse in production.PRAGMA foreign_keys = ONthat this must be set per-connection and is not a persistent database setting. Developers who add a second connection later (e.g., a one-off migration script) will miss this without the comment.db.test.ts, every test that creates its own:memory:database must explicitly calldb.pragma('foreign_keys = ON')— do not assume it is the default. The cascade delete test will silently pass even without cascade if foreign keys are off, producing a false green.codecolumn in thecodestable isTEXT NOT NULL UNIQUE. The code generation function (inauth.ts, out of scope for this issue) must usecrypto.randomBytes— notMath.random. This is documented in the auth persona but worth flagging here: the database constraint enforces uniqueness, but onlycrypto.randomBytesensures codes are not predictable.Open Decisions (omit if none)
DATABASE_PATHpointing to:memory:in tests via env mutation — Tests that setprocess.env.DATABASE_PATH = ':memory:'and then call_resetForTesting()+getDb()are mutating the process environment. In a parallel Vitest worker setup, this could cause race conditions between test files. Vitest runs each test file in a separate worker by default, so this is safe — but it should be documented as a constraint:db.test.tsmust not be run with--pool=forkswithout understanding this env mutation.👤 Sara Holt — QA Engineer
Observations
src/lib/db.test.tsbut does not specify whether these tests exercise the singleton (getDb()) or test the schema SQL in isolation using directnew Database(':memory:')calls. This is an important distinction: testing the schema SQL directly is fast and reliable; testinggetDb()additionally verifies singleton behaviour but requires env var management.:memory:databases because SQLite ignores the WAL journal mode for in-memory databases and returns'memory'for the pragma query. A test that assertspragma('journal_mode') === 'wal'will fail on:memory:. This must be handled by either testing on a temp file, or by accepting that WAL is verified by code review and tested only on real files.ON DELETE CASCADEis actually wired up, which requiresPRAGMA foreign_keys = ONto be active at test time. A cascade test that passes without foreign keys enabled is a false positive.UNIQUE(artikel_id, position)onartikel_fotosis specified. This constraint matters — duplicate photo positions on the same article would corrupt gallery ordering. It is a gap in the specified test suite.Recommendations
new Database(':memory:')+db.exec(SCHEMA_SQL)directly, bypassing the singleton. This removes env var complexity from the test setup and makes each test a true unit of the schema definition.fs.mkdtempSync+ a.dbfilename, open it withnew Database(tempPath), applypragma('journal_mode = WAL'), then assertpragma('journal_mode')returns'wal'. Clean up inafterEach. This avoids the:memory:WAL limitation.UNIQUE(artikel_id, position)onartikel_fotos. Insert two photos for the sameartikel_idwith the samepositionand assert the second insert throwsUNIQUE constraint failed. This is a spec requirement that has no test coverage in the issue as written.afterEach(() => db.close())to release the SQLite file handle, especially the WAL temp-file test. Leaking handles will cause intermittent test failures on subsequent runs.'creates all four tables: codes, artikel, artikel_fotos, reservierungen','enables WAL journal mode','throws UNIQUE constraint error on duplicate reservierungen.artikel_id','cascade deletes artikel_fotos rows when the parent artikel is deleted'.Open Decisions (omit if none)
👤 Elicit — Requirements Engineer
Observations
getDb()and call it per-request, or import pre-prepared statements exported fromdb.ts. These are two different contracts, and the downstream route implementations (Issues #3+) will be authored against whichever patterndb.tsestablishes. The issue should specify the intended import pattern before implementation starts._resetForTesting()acceptance criterion is present but underspecified. "Allows Vitest to reset the singleton between tests" is behavioural intent, not a testable criterion. A testable criterion would be: "After_resetForTesting()is called, a subsequent call togetDb()returns a new Database instance, not the previously returned one."UNIQUE(artikel_id)onreservierungenandON DELETE CASCADEentries are correct.v1.0 — MVPand depends on #1. No labels are set. Atype: featureorarea: backendlabel would improve backlog health but is not a blocker.Recommendations
getDb()from$lib/dband call it at the top of load functions and actions to obtain the singleton instance." This prevents ambiguity when Issue #3 is implemented._resetForTesting()criterion as: "_resetForTesting()closes the active connection and sets the singleton to null; a subsequentgetDb()call creates a new connection to the currentDATABASE_PATH." This is now a testable assertion.DATABASE_PATHdefaults to if the env var is absent. Add to acceptance criteria: "IfDATABASE_PATHis not set, the module throws at load time with a descriptive error message." This aligns with the startup-guard pattern used elsewhere in the system design.👤 Leonie Voss — UI/UX Design Lead
Observations
reservierungen.UNIQUE(artikel_id)constraint is the mechanism that produces the "Dieser Artikel wurde soeben von jemand anderem reserviert." conflict message in the gallery. The database constraint here is the foundation of that user-facing error state. If the constraint is not correctly set up, the conflict message can never be correctly triggered — and family members could see stale "Frei" status for an item that is already reserved.artikel_fotos.UNIQUE(artikel_id, position)constraint directly affects the photo gallery ordering inArtikelModal. Corrupt or duplicate positions would cause photos to appear in wrong order or disappear. This is a silent data corruption from the user's perspective.Recommendations
SQLITE_CONSTRAINT_UNIQUEon thereservierungeninsert — not from a pre-check SELECT. The constraint-driven approach is what makes the user-facing error message reliable under concurrent access.ArtikelModalreads photos ordered byposition ASC. TheUNIQUE(artikel_id, position)constraint guarantees no gaps or duplicates, but the query must include anORDER BY positionclause or the display order is undefined.🗳️ Decision Queue — Action Required
5 decisions need your input before implementation starts.
Singleton Reset Contract
_resetForTesting()behaviour — Option A: function closes the active connection, sets module-level singleton tonull, and the nextgetDb()call reopensprocess.env.DATABASE_PATH(tests set this to':memory:'inbeforeEach). Option B: function accepts an optional path argument, creates and returns a fresh instance, replacing the singleton. Option A avoids an extra argument but requires env mutation in tests; Option B is explicit but slightly more complex. (Raised by: Markus Keller, Nora Steiner)WAL Mode Test Strategy
fs.mkdtempSync) for the WAL test only; assertpragma('journal_mode')returns'wal'; clean up inafterEach. Option B: skip a live WAL assertion in tests and verify by code review only, since SQLite silently ignores WAL for:memory:databases. Option A is strict and trustworthy; Option B is simpler but leaves a gap. (Raised by: Felix Brandt, Sara Holt)Missing Test Coverage
UNIQUE(artikel_id, position)onartikel_fotos— The four specified acceptance-criteria tests do not cover this constraint. Option A: add it to the Definition of Done for this issue (5 tests total). Option B: track it as a separate test-gap issue for a later cleanup. The constraint is load-bearing for gallery photo ordering. (Raised by: Sara Holt)Startup Guards
DATABASE_PATHmissing at startup — Shoulddb.tsthrow immediately at module load ifprocess.env.DATABASE_PATHis absent, or silently fall back to a default path? Throwing is consistent with theSESSION_SECRETguard pattern and surfaces the problem in container logs immediately. Falling back hides misconfiguration. (Raised by: Markus Keller, Tobias Wendt, Elicit)Vitest Environment Config
environment: 'node'invitest.config.ts—better-sqlite3is a native Node module and will fail under JSDOM.vitest.config.tsmust settest.environment = 'node'. This is a prerequisite, not a decision — but it must be confirmed as part of the Issue #1 scaffold before this issue's tests can run. Ifvitest.config.tswas not included in Issue #1, it needs to be added here. (Raised by: Felix Brandt)