Address #128 review: unopenable journals, repository binding, and the node:sqlite guard - #138
Merged
PaulgSmith merged 3 commits intoAug 18, 2026
Conversation
Two gaps between what 0015 decided and what the code does. The first is the case 0015 designs for. It keeps the key WHEN_UNLOCKED_THIS_DEVICE_ONLY precisely so it does not travel in an iCloud backup - but journal.db lives in Documents and does. Restoring a backup onto a new phone, which is what people do when they replace a handset, brings the file back without the key. Nothing handled that. SecureStore returns null both for "nothing stored yet" and for "the entry was invalidated", key.ts read null as a first run and minted a fresh key, and database.ts asked for the key before opening the file so it could not know better. The result was a generic "Could not open the journal database" on that launch and every launch after, with no explanation and nowhere to go. getOrCreateDatabaseKey now reports whether it minted the key, and database.ts uses that: a key we just created cannot fail to read a file we just created, so if the decrypt probe fails the file was already there and belonged to a key that is gone. That is now UnrecoverableJournalError rather than a generic failure, and the useless key is deleted so the next launch reaches the same branch instead of degrading to something undiagnosable. The journal really is unrecoverable - 0007 commits to saying so during onboarding - but "made on a different phone" is a true thing to say and leaves the user somewhere to go. The UI for starting over is not built; this is the mechanism it needs. The second gap is that the unlock gate locked the view and nothing else. database.ts held a decrypted handle and the key stayed in memory for the life of the process, which made WHEN_UNLOCKED_THIS_DEVICE_ONLY a property of the first launch and nothing after it, because the keychain was never asked again. The gate now closes the database on the same background event that re-locks. Also corrects two comments in key.ts that were wrong rather than merely thin. keychainAccessible is iOS-only, so THIS_DEVICE_ONLY keeps the key out of iCloud but does nothing about Google backup - that comes from the expo-secure-store config plugin's backup rules, which makes the bare plugin entry in app.json load-bearing. And WHEN_PASSCODE_SET_THIS_DEVICE_ONLY is recorded as considered and rejected: it looks like a free upgrade, but it cannot store a key at all on the devices 0018 is about, and it turns removing a passcode into a data-loss event. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in the repeatable-entry layer that #118 and #49 are both built on. Neither was caught by the tests, and neither is caught by TypeScript. update() reached its sibling method through `this`, which is only bound when it is called as repo.update(...). `const { update } = repo` and `onPress={repo.update}` are both ordinary React and both threw "TypeError: find is not a function" at runtime. find is now a plain function above the returned object, so nothing in the repository depends on how it was called. create() built its return value from the caller's input rather than from what it wrote. A field the caller omitted was stored as NULL and returned as absent, so create() and find() disagreed about the same row; and keys that were never columns were dropped on insert but echoed back, making them look saved. It now returns the declared fields and only those, with omissions turned into the NULL that actually goes to the database. Nothing consumes createRepository yet, so this is not a regression - but the cost of fixing it rises with every screen written against the old behaviour, and #118 is next. Fixes #134 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
HAS_NODE_SQLITE was meant to let the SQLite-backed suites skip on a Node without node:sqlite. It could never do that: the static import is resolved before any module code runs, so on Node below 22.5 the module threw at import time and the try/catch around the guard was never reached. The two suites errored out instead of skipping, on a version engines still permits (>=20.19.4). CI runs 22.x, which is exactly why it could sit unnoticed. The require is now lazy, so the guard evaluates, and createInMemoryDatabase fails with a message naming the Node version it needs rather than a resolution error from inside node_modules. Adds a test for the helper itself. Making the require throw is the only way to exercise the old-Node path from a new Node, and without it nothing in CI would notice this regressing back - which is how it got here. Fixes #133 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 16, 2026
PaulgSmith
added a commit
that referenced
this pull request
Aug 18, 2026
…ng note Two follow-ups to #138. The fix for #133 makes the SQLite-backed suites skip on a Node without node:sqlite, but the test added to prove it asserts HAS_NODE_SQLITE is true with no guard. On Node 20.19.4 - permitted by `engines`, and the exact range #133 is about - the fix therefore fails its own regression test while the suites it protects skip cleanly. Verified on Node 18: before, 1 failure; after, 109 tests with 2 suites skipped. The absent direction is not fakeable, so it takes the same `describeSql` guard migrations.test.ts and repository.test.ts already use. The file-before-key comment on destroyJournalDatabase described neither interrupted state: file first leaves a key with nothing to open, not "an unreadable database". #138 supplies the reason it was missing - key first would strand a file no key can open, which is UnrecoverableJournalError, reached by the very path destroy is meant to be the escape from. Reworded here rather than deferred to #135, which is about destroy reporting success without deleting and does not touch the ordering rationale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PaulgSmith
merged commit Aug 18, 2026
d515cf6
into
feature/encrypted-storage-unlock-foundation
6 checks passed
PaulgSmith
deleted the
feature/address-encrypted-storage-unlock-foundation
branch
August 18, 2026 01:43
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Follow-ups to #128 from reviewing it, targeted at that branch rather than
mainso the foundation lands with these already in it. Three commits, one concern each.1. Handle a journal no key can open, and close it when locking — two gaps between what 0015 decided and what the code does.
0015 keeps the key
WHEN_UNLOCKED_THIS_DEVICE_ONLYprecisely so it does not travel in an iCloud backup — butjournal.dblives inDocumentsand does. Restoring a backup onto a new phone brings the file back without the key. Nothing handled that: SecureStore returnsnullboth for "nothing stored yet" and for "the entry was invalidated",key.tsreadnullas a first run and minted a fresh key, anddatabase.tsasked for the key before opening the file so it could not know better. The result was a genericCould not open the journal databaseon that launch and every launch after, with no explanation and nowhere to go — from the ordinary act of replacing a handset.getOrCreateDatabaseKeynow reports whether it minted the key. A key we just created cannot fail to read a file we just created, so a failed decrypt on a fresh key means the file predates it. That is nowUnrecoverableJournalError, and the useless key is deleted so the next launch reaches the same branch instead of degrading to something undiagnosable. The journal really is gone — 0007 commits to saying so during onboarding — but "made on a different phone" is true and leaves the user somewhere to go. The UI for starting over isn't built; this is the mechanism it needs.Separately, the unlock gate locked the view and nothing else:
database.tsheld a decrypted handle and the key stayed in memory for the life of the process, which madeWHEN_UNLOCKED_THIS_DEVICE_ONLYa property of the first launch and nothing after it, since the keychain was never asked again. The gate now closes the database on the same background event that re-locks.Also corrects two comments in
key.tsthat were wrong rather than thin:keychainAccessibleis iOS-only, so the Google-backup half of that claim comes from the config plugin's backup rules (which makes the bare plugin entry inapp.jsonload-bearing); andWHEN_PASSCODE_SET_THIS_DEVICE_ONLYis recorded as considered-and-rejected, because it looks like a free upgrade but cannot store a key at all on the devices 0018 is about.2. Fix repository
update()binding andcreate()return value —updatereachedfindthroughthis, soconst { update } = repoandonPress={repo.update}both threwTypeErrorat runtime, uncaught by TypeScript. Andcreate()built its return from the caller's input, so an omitted field was stored asNULLbut returned absent, and keys that were never columns were echoed back looking saved.3. Make the node:sqlite guard actually run — the static import resolved before the
try/catchcould, so the guard never evaluated and the two SQLite suites errored instead of skipping on Node < 22.5.Issue
Closes #134
Closes #133
Not closing #130 — the
PRAGMA cipher_versionguard is still outstanding; this only corrects the comment indatabase.tsthat overstated what thesqlite_masterprobe catches.Testing
cd mobile && npm test→ 11 suites, 109 tests, all pass (95 before).npx tsc --noEmitandnpm run lintboth clean, on Node 22.20.0.14 new tests. Five cover the orphaned-journal paths, including the two that must not fire: a decrypt failure against a stored key stays a generic error and does not delete the key, and a genuine first run is left alone. Three prove the repository defects, all of which I reproduced against the in-memory SQLite harness before fixing. Three more are a new suite for the jest helper itself — making the require throw is the only way to exercise the old-Node path from a new Node, and without it nothing in CI would notice #133 regressing, which is how it got here.
Still not tested anywhere: that the file is actually encrypted. That needs hardware — #101, and #130 for the build-flag half.
Notes for review
getOrCreateDatabaseKeynow returns{ key, created }instead of a string. Breaking change to a public export, one consumer today. The alternative was splitting it intoreadDatabaseKey+createDatabaseKey; I preferred one atomic call that reports what it did.deleteDatabaseKeywas mocked as a barejest.fn()indatabase.test.ts, which returnsundefinedrather than a promise. Chaining.catch()onto it threw aTypeErrorthat swallowed the real error and made three tests fail for a completely misleading reason. GivenmockResolvedValue(undefined)with a comment — worth knowing about as a harness sharp edge, not just here.database.ts:96-99is untouched, since rewording it is tangled up with how destroyJournalDatabase() can report success when the journal file was not deleted #135 resolves the ordering.