Skip to content

Address #128 review: unopenable journals, repository binding, and the node:sqlite guard - #138

Merged
PaulgSmith merged 3 commits into
feature/encrypted-storage-unlock-foundationfrom
feature/address-encrypted-storage-unlock-foundation
Aug 18, 2026
Merged

Address #128 review: unopenable journals, repository binding, and the node:sqlite guard#138
PaulgSmith merged 3 commits into
feature/encrypted-storage-unlock-foundationfrom
feature/address-encrypted-storage-unlock-foundation

Conversation

@Jberma23

Copy link
Copy Markdown
Collaborator

What this does

Follow-ups to #128 from reviewing it, targeted at that branch rather than main so 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_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 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 — from the ordinary act of replacing a handset.

getOrCreateDatabaseKey now 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 now UnrecoverableJournalError, 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.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, 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.ts that were wrong rather than thin: keychainAccessible is iOS-only, so the Google-backup half of that claim comes from the 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, 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 and create() return valueupdate reached find through this, so const { update } = repo and onPress={repo.update} both threw TypeError at runtime, uncaught by TypeScript. And create() built its return from the caller's input, so an omitted field was stored as NULL but 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/catch could, 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_version guard is still outstanding; this only corrects the comment in database.ts that overstated what the sqlite_master probe catches.

Testing

  • Covered by tests

cd mobile && npm test11 suites, 109 tests, all pass (95 before). npx tsc --noEmit and npm run lint both 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

Jberma23 and others added 3 commits August 16, 2026 19:44
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>
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
PaulgSmith merged commit d515cf6 into feature/encrypted-storage-unlock-foundation Aug 18, 2026
6 checks passed
@PaulgSmith
PaulgSmith deleted the feature/address-encrypted-storage-unlock-foundation branch August 18, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants