"No sign-up, and your data stays on your phone." Read app reviews and you'll see that property alone is enough to win users. For developers it looks attractive too: no server means no incident response, no database backups, no monthly bill.
Build one, though, and you find that the server's jobs don't go away — they move onto the device. Schema migrations run on the user's iPhone, where nobody can fix a failed one by hand. Moving to a new phone is something you have to build yourself. And there's no server to verify purchases.
This article walks through the design for running an Expo app in production with no server and no login, using real code from Goshuin Ledger, a goshuin (temple stamp) journal app — an iOS app I build alone and ship on the App Store. The story behind the product and its design decisions is in the /labs write-up.
We'll cover four things:
- Running expo-sqlite schema migrations safely on the device
- An encrypted backup format and implementation for moving to a new phone
- StoreKit 2 one-time purchases without RevenueCat, with layered paywall gates
- The layering that lets all three run under Jest without a simulator
Baseline versions: Expo SDK 57 (
expo~57.0.20), React Native 0.86.3, expo-sqlite 57.0.2 (bundling SQLite 3.50.3), expo-iap 5.5.0. I checked these in the app'sapps/mobile/package.jsonand inSQLITE_VERSIONinexpo-sqlite/ios/sqlite3.h. How to write the Swift native module itself is covered in React Native / Expo × Swift Native Modules; here the focus is what to push native and how to test it from JS.
0. The short version: where the server's jobs end up
Here's the map. The left column is work a server would normally absorb.
| Job a server would do | Who takes it on without a server | Implementation in this app |
|---|---|---|
| Schema migration | Run at launch, before any screen renders | PRAGMA user_version plus one transaction per version |
| Consistency under concurrent writes | One connection, transactions serialized | A hand-rolled FIFO queue |
| Backup and device migration | Standard iOS backup plus an encrypted export file | A custom .goshuin format built on CryptoKit |
| Purchase verification and entitlements | StoreKit 2 as the only source of truth | expo-iap plus EntitlementService |
| Gating paid features | Checks in both the UI and the service layer | Pure functions plus GateError |
| Near-production integration tests | Native code behind ports, stand-ins running on Node | sql.js, Node's fs, node:crypto |
The app's design decisions are recorded as 40 ADRs (Architecture Decision Records) in docs/ADR-*.md (ls docs/ADR-*.md | wc -l returns 40). When I write "ADR-0003" below, I mean that record.
I can take on the implementation from this article as an engagement
iOS native features (Swift) for Expo / React Native apps — from design through App Review
1. Layering: hide Expo behind ports, wire it in one place
The layering comes first because the other three topics all depend on it.
ADR-0001 draws the dependency direction:
ui (screens, hooks) → services (use cases) → domain (pure functions, types)
↓ depends on interfaces only
ports (interfaces) ← adapters (Expo implementations) ← src/composition/container.ts (composition root)
data (repositories, SqlDriver) is used by services; only SqlDriver's implementation is an adapter (expo-sqlite / sql.js)
This is ports and adapters (hexagonal architecture). Three details make it work.
1-1. Only cut a port for what Jest can't run
Too many ports and maintaining the interfaces becomes a job in itself. The rule here is "cut a port only for capabilities Jest cannot execute natively" (src/ports/index.ts). The composition root injects 19 ports — besides SQLite: files, crypto, image picker, StoreKit, share sheet, OCR, location, clock, ID generation and so on (count the entries under ports: { … } in src/composition/container.ts).
SQLite gets the thinnest possible port. Only the driver that accepts SQL is abstracted, so repositories still write real SQL.
// apps/mobile/src/data/sqlDriver.ts (excerpt)
export interface SqlDriver {
execAsync(sql: string): Promise<void>;
runAsync(sql: string, params?: SqlParams): Promise<SqlRunResult>;
getAllAsync<T extends object>(sql: string, params?: SqlParams): Promise<T[]>;
getFirstAsync<T extends object>(sql: string, params?: SqlParams): Promise<T | null>;
/** Calls are serialized per connection. Never call this from inside a task. */
withTransactionAsync<T>(task: () => Promise<T>): Promise<T>;
closeAsync(): Promise<void>;
}
expo-sqlite implements it in production; sql.js (SQLite compiled to WebAssembly) implements it in tests. Repository SQL is never mocked — tests run it against real SQLite.
1-2. Enforce the dependency direction with ESLint
"Services don't import Expo" is a promise nobody keeps unless a tool enforces it. This app uses ESLint's no-restricted-imports.
// eslint.config.mjs (excerpt)
{
files: ['apps/mobile/src/{domain,data,services}/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['react', 'react-native', 'react-native/*'], message: 'Business logic must stay UI-free.' },
{ group: ['expo', 'expo-*', 'expo/*'], message: 'Use a port in src/ports instead of Expo modules.' },
{
// Banning npm packages alone isn't enough: importing @/adapters directly bypasses the inversion
group: ['@/adapters', '@/adapters/*', '@/composition', '@/composition/*'],
message: 'Depend on a port in src/ports; adapters are wired only in src/composition/container.ts.',
},
],
}],
},
}
The third pattern is the important one. Banning expo-* alone still lets someone import @/adapters/expoIap and get the same result.
1-3. Don't put the composition root in src/app
Adapters are assembled in exactly one place: src/composition/container.ts. There's an Expo-specific trap here. If a src/app directory exists, Expo Router adopts it as the route root (ahead of app/). When the composition root lived at src/app/container.ts, the tests and wiring files got bundled as screens. ADR-0001 now says "don't create src/app/" to keep it from happening again.
The composition root reads like the startup sequence itself:
// apps/mobile/src/composition/container.ts (excerpt)
export async function buildAppServices(): Promise<AppServices> {
const logger = createRingBufferLogger({ echoToConsole: __DEV__ });
const driver = await createExpoSqlDriver();
const migrated = await migrate(driver); // migrate before any screen renders
logger.log('info', 'database ready', migrated);
const services = assembleServices({
config: readConfig(),
driver,
repos: createSqliteRepositories(driver),
ports: {
fs: createExpoFileSystem(/* App Group */),
crypto: createGoshuinCrypto(), // Swift / CryptoKit
iap: createIap(storeKitDouble()), // StoreKit 2 (a stand-in only in screenshot builds)
// …16 more
},
});
await services.maintenance.sweepScratch(); // remove temp files a force-quit left behind
// …
return services;
}
On the test side, src/test/makeTestServices.ts passes sql.js, Node's fs over a temp directory, a node:crypto reference implementation, and scriptable fakes into the same assembleServices. Production and tests differ only in adapters; the wiring code is identical. That's what makes the tests in later sections possible.
2. Running expo-sqlite migrations safely on the device
On a server, an engineer can repair a failed migration. On a user's iPhone, nobody can. And once you ship, devices holding old schema versions stay out there indefinitely.
2-1. The minimal shape: user_version and one transaction per version
SQLite reserves an integer in the database header, PRAGMA user_version, that SQLite itself never uses — so an app can use it as its schema version. The Expo docs show a migration built exactly this way.
This app's migration function is about 30 lines:
// apps/mobile/src/data/database.ts (excerpt)
export async function migrate(driver: SqlDriver) {
await driver.execAsync('PRAGMA journal_mode = WAL');
await driver.execAsync('PRAGMA foreign_keys = ON');
const from = await readSchemaVersion(driver); // PRAGMA user_version (0 for a new database)
let current = from;
for (const migration of MIGRATIONS) {
if (migration.version <= current) continue;
await driver.withTransactionAsync(async () => {
for (const statement of migration.statements) {
await driver.execAsync(statement);
}
await driver.execAsync(`PRAGMA user_version = ${String(migration.version)}`);
});
current = migration.version;
}
return { from, to: current };
}
With one transaction per version, a failure mid-way rolls that version back entirely and leaves user_version untouched. The next launch retries from the same version. This works because SQLite rolls back DDL (CREATE TABLE and friends) inside a transaction too.
A migration is just a version number and an array of SQL. There are six versions today (MIGRATIONS in apps/mobile/src/data/migrations.ts).
// apps/mobile/src/data/migrations.ts (excerpt)
export const MIGRATIONS: readonly Migration[] = [
{ version: 1, statements: [/* CREATE TABLE IF NOT EXISTS … */] },
{
version: 2,
statements: [
// migrate turns foreign keys on first, and SQLite then requires an added REFERENCES
// column to default to NULL — which is also how existing rows should read
`ALTER TABLE import_queue ADD COLUMN temple_id TEXT REFERENCES temples(id) ON DELETE SET NULL`,
],
},
// … versions 3–6
];
The comment at the top of the file sets three rules: append only, never edit a shipped version, run each version inside a transaction.
2-2. Three ALTER TABLE traps
Stack up a few versions and you hit SQLite's ALTER TABLE limits. Here are three recorded in this app's migration comments.
1. ADD COLUMN has no IF NOT EXISTS. Version 1's CREATE TABLE IF NOT EXISTS is safe to run twice; ALTER TABLE … ADD COLUMN fails the second time. Only the user_version check prevents a double run, and that relies on the check and the version stamp sharing one transaction.
2. An added foreign-key column must default to NULL. The SQLite docs say that with foreign key constraints enabled, a column added with a REFERENCES clause must have a default of NULL. Version 2 embraces this: existing rows read the new column as NULL (unset).
3. Rebuilding a table is effectively off the table. SQLite can't alter a CHECK constraint after the fact; the only way is to rebuild the table. But PRAGMA foreign_keys is a no-op inside a transaction (per the SQLite docs), so you can't switch foreign keys off inside the migration transaction. Drop a parent table with foreign keys on and ON DELETE CASCADE wipes the child rows with it.
To avoid trap 3, this app deliberately left the CHECK constraint off the "kind of stamp" column added in version 3. Instead, the repository reads unknown values as the default, and every writer is constrained by TypeScript types. Giving up a database constraint in favor of types is a compromise — but it beats the risk of a future rebuild cascading into the photos table.
2-3. Don't store absolute paths in the database
Version 5 repairs a design mistake.
-- apps/mobile/src/data/migrations.ts version 5 (excerpt)
ALTER TABLE import_queue RENAME COLUMN temp_path TO staged_file_name;
UPDATE import_queue
SET staged_file_name = substr(
staged_file_name,
instr(staged_file_name, '/import-staging/') + length('/import-staging/')
)
WHERE instr(staged_file_name, '/import-staging/') > 0;
Imported-photo rows used to store an absolute path like …/Application/<UUID>/Documents/import-staging/<uuid>.jpg. The UUID in an iOS app container path can change when a device is restored, leaving rows that point at files the app can no longer find. Version 5 reduces the column to the file name and joins it with the current Documents directory at read time.
Two things to watch:
RENAME COLUMNarrived in SQLite 3.25. expo-sqlite 57.0.2 bundles 3.50.3, so it's fine here, but check if you target an older SQLite.- Only values containing
/import-staging/are rewritten; anything else is left alone rather than "fixed" by guesswork. A migration that rewrites data on a hunch can't be undone when the hunch is wrong.
To be clear about the evidence: ADR-0002's revision states this bug was inferred from the code and not reproduced on a real device. Apple's File System Programming Guide asks apps not to persist absolute container paths, and the fix follows that guidance.
2-4. Test from the databases old releases left behind
The most valuable migration tests don't start from an empty database. They upgrade from each shipped version to the latest.
// apps/mobile/src/data/database.test.ts (excerpt)
/**
* The database exactly as a shipped app left it at schema `version`. Devices in the field hold
* these, so every later migration is tested from here and not only from an empty file — a failed
* upgrade cannot be undone on a published app.
*/
async function migrateThrough(driver: SqlDriver, version: number): Promise<void> {
const shipped = MIGRATIONS.filter((migration) => migration.version <= version);
await driver.execAsync('PRAGMA foreign_keys = ON');
for (const migration of shipped) {
for (const statement of migration.statements) await driver.execAsync(statement);
}
await driver.execAsync(`PRAGMA user_version = ${String(version)}`);
}
The tests insert rows using version 1's column list, run migrate, and check that the rows survive and that the new columns read as NULL as expected.
There are failure tests too. A wrapper driver makes execAsync fail on a specific statement to simulate a full disk:
// apps/mobile/src/data/database.test.ts (excerpt)
await expect(migrate(flaky)).rejects.toThrow('disk full');
expect(await readSchemaVersion(driver)).toBe(0);
// DDL is transactional in SQLite: the tables created before the failure are gone too.
expect(await tableNames(driver)).toEqual([]);
// The connection is still usable and a healthy retry succeeds.
expect(await migrate(driver)).toEqual({ from: 0, to: LATEST_SCHEMA_VERSION });
Because of the driver abstraction from 1-1, injecting a fault like this takes a few lines.
2-5. withTransactionAsync is not exclusive: serialize transactions with a queue
Beyond migrations, everyday writes have a trap of their own. The expo-sqlite docs for withTransactionAsync say "this transaction is not exclusive and can be interrupted by other async queries" (I confirmed the same doc comment in expo-sqlite 57.0.2's src/SQLiteDatabase.ts).
JavaScript is single-threaded, but any other task can run at each await. Say one screen is bulk-placing photos while another screen triggers a delete. A naive implementation that tracks nesting with a counter lets the delete conclude "I'm nested" and write inside the placement's transaction. If the placement then rolls back, the delete's writes vanish with it — after the delete's caller already received a resolved Promise.
This app stops guessing about nesting and puts transactions in a FIFO queue:
// apps/mobile/src/data/transactionQueue.ts (excerpt)
export function createTransactionQueue(): Serializer {
// Tail of the FIFO. Always settles fulfilled, so one failed transaction can't wedge the queue.
let tail: Promise<unknown> = Promise.resolve();
return <T>(run: () => Promise<T>): Promise<T> => {
const result = tail.then(run);
tail = result.then(() => undefined, () => undefined);
return result;
};
}
The expo-sqlite adapter routes every withTransactionAsync through this queue.
The trade-off: a genuinely nested call would now wait on its own parent forever. So src/test/architecture.test.ts reads the source under services, data, ui and app, looks for a withTransactionAsync( inside another withTransactionAsync(, and fails if it finds one.
The comments also explain why withExclusiveTransactionAsync isn't used. The exclusive variant opens a second native connection, and only SQL issued through its txn handle joins that transaction. Repositories only know SqlDriver and can't use txn, and the per-connection PRAGMA foreign_keys that migrate set wouldn't apply to that second connection anyway.
3. An encrypted backup for moving to a new phone
With no server, device migration is yours to build too. This app's answer has two layers (ADR-0002, ADR-0004):
- Standard iOS backup: the database and downscaled photos live in Documents, so iCloud Backup and Quick Start carry them over as-is.
- An encrypted export file (
.goshuin): all records and photos in one file, moved off the device via "Save to Files" in the share sheet or AirDrop. The app itself uploads nothing.
3-1. The format: a 45-byte header and per-chunk AES-256-GCM
The comment in apps/mobile/src/services/backupContainer.ts is the spec:
header (45 bytes)
magic "GSHN" 4 | version 1 | kdfIterations u32be 4 | salt 16 | nonceBase 8
| chunkSize u32be 4 | plaintextLength u64be 8
body
( u32be(len) ‖ ciphertext ‖ tag(16) )* AES-256-GCM per chunk
The plaintext is a ZIP holding manifest.json (validated by a zod schema), records.csv (openable in a spreadsheet), and the photos. The key decisions:
| Concern | Decision | Why |
|---|---|---|
| Key derivation | PBKDF2-HMAC-SHA256, 600,000 iterations by default | The OWASP Password Storage Cheat Sheet recommendation; an export is a one-off action, so the time is acceptable |
| Chunking | Encrypt every 4 MiB of plaintext | Keeps native memory flat even for multi-hundred-MB files |
| Nonce | nonceBase ‖ u32be(chunk index) | Salt and nonceBase are random per export, so keys and nonces never repeat even with the same passphrase |
| AAD | The full 45-byte header on every chunk | Tampering with the iteration count or chunk size fails authentication |
| Truncation | Check against plaintextLength | Detects a dropped final chunk |
| Header bounds | 1,000–2,000,000 iterations, 1 KiB–64 MiB chunks | These values set the workload before anything is authenticated; a crafted 45-byte file mustn't pin the CPU or memory |
| Purchases | Never in the manifest (rejected by the schema's strict()) | Importing a backup must not unlock paid features |
The header bounds are easy to miss. AES-GCM authentication happens after you derive the key and read a chunk — which means the iteration count and chunk size decide the workload while still unauthenticated. This app checks the same ranges in three places: TypeScript's decodeHeader, the Swift module, and the Node reference implementation.
3-2. Why the crypto lives in native code (CryptoKit)
The encryption itself runs in a local Expo module written in Swift (apps/mobile/modules/goshuin-crypto), which calls CryptoKit's AES.GCM, CommonCrypto's CCKeyDerivationPBKDF and SecRandomCopyBytes on file handles. The JS side only passes file paths and small base64 values.
// apps/mobile/modules/goshuin-crypto/ios/GoshuinCryptoModule.swift (excerpt)
AsyncFunction("sealFile") {
(inputPath: String, outputPath: String, keyBase64: String, headerBase64: String,
nonceBaseBase64: String, chunkSize: Int) throws in
let (key, header, nonceBase) = try Self.decodeMaterial(keyBase64, headerBase64, nonceBaseBase64)
guard chunkSize >= Self.minChunkSize, chunkSize <= Self.maxChunkSize else {
throw BadArgumentException("chunkSize")
}
let input = try FileHandle(forReadingFrom: Self.url(inputPath))
defer { try? input.close() }
let output = try Self.openForWriting(outputPath)
defer { try? output.close() }
try output.write(contentsOf: header)
var index: UInt32 = 0
while true {
let chunk = try input.read(upToCount: chunkSize) ?? Data()
if chunk.isEmpty { break }
let nonce = try AES.GCM.Nonce(data: nonceBase + Self.bigEndian(index))
let box = try AES.GCM.seal(chunk, using: key, nonce: nonce, authenticating: header)
let body = box.ciphertext + box.tag
try output.write(contentsOf: Self.bigEndian(UInt32(body.count)))
try output.write(contentsOf: body)
index &+= 1
}
}
ADR-0002 gives four reasons:
- Export compliance: using only OS-provided cryptography lets you pin
ITSAppUsesNonExemptEncryptiontofalsein Info.plist and skip the question on every submission (the final call still rests on Apple's help pages). - Memory: holding a large file in a JS
Uint8Arraymeans copies every time it crosses the bridge. Streaming 4 MiB at a time natively keeps memory flat. - Auditability: a homemade AES-GCM or a pure-JS npm implementation is costly to audit and makes no side-channel guarantees.
- Testability: a node:crypto reference implementation (
src/test/nodeCryptoPort.ts) reproduces the same byte format, so Jest can run a full export-and-restore round trip with real SQL, real files and real crypto.
The mechanics of calling a Swift module from Expo — AsyncFunction queues, mapping Exception to error codes, local modules without a podspec silently not linking — are covered in React Native / Expo × Swift Native Modules.
3-3. An honest gap: nothing mechanically checks that Swift and Node produce the same bytes
This weakness deserves a mention. Jest runs the Node reference implementation, not the Swift one, and no automated test compares the two byte for byte. What is verified:
- Jest: a full export-to-restore round trip with the Node implementation (
src/services/backupService.test.ts) - Maestro: on a device or simulator, the Swift implementation restoring a file it wrote itself (
.maestro/05-backup-roundtrip.yaml)
A Swift-to-Swift round trip on one device doesn't prove the output matches the spec: if the writer and reader share the same mistake, the round trip still passes. The next step to tighten this would be a known-answer test — commit a file produced by Swift from a fixed plaintext and key, and decrypt it with the Node implementation.
3-4. Never let a broken file masquerade as a wrong passphrase
Beyond the format, the app guards against failures specific to devices (ADR-0002):
- Write to a temp file, then rename. Sealing happens in
Library/Caches/backup-tmp/<name>.goshuin.partand the finished file is moved within the same volume intoDocuments/exports/. A force-quit mid-export never leaves a truncated.goshuinin the history — which a restore would otherwise misdiagnose as a wrong passphrase. - Sweep leftovers on the next launch.
finallydoesn't run on a force-quit. That's whatsweepScratch()in the composition root is for. - Check free space before restoring. Without twice the archive size free (decrypted ZIP plus extracted photos), the restore stops with
no_spacebefore entering the slow key derivation. - Make import an idempotent merge. Insert if the id is missing, update if
updatedAtis newer, otherwise skip. Importing the same file twice breaks nothing. - Restrict file names inside the ZIP. Photo entries must be flat names matching
^[A-Za-z0-9._-]+$(a zip-slip defense). File names in the manifest are narrower still, because they later reach delete operations — values like../SQLite/goshuin.dbnever get in.
Forget the passphrase and nobody can restore the file. With no server and no key escrow, that's by design. To compensate, Settings shows when you last exported and nudges you to export again as records accumulate.
4. StoreKit 2 one-time purchases without RevenueCat
There's one non-consumable product, displayed as "台帳フル" (Full Ledger); the landing page lists it at ¥1,200 including tax (priceHintJpy in packages/shared/src/product.ts, and ADR-0003). In the app, the only price ever shown is the displayPrice StoreKit returns.
4-1. Why RevenueCat isn't in the app
RevenueCat shines at subscription state and webhooks; I cover running subscriptions on it in the RevenueCat implementation guide. ADR-0003 records why this app went without it:
- A single non-consumable needs none of the renewal, grace-period and cancellation state handling
- There's nowhere else to share entitlements (no Android or web version, no login)
- RevenueCat's SDK sends purchase history to RevenueCat's servers, which means declaring it in App Privacy (RevenueCat's own App Privacy guide lists Purchase History). That conflicts with the policy of exchanging purchase data only with Apple
As a rule of thumb:
| Condition | Call StoreKit 2 directly | Use RevenueCat or similar |
|---|---|---|
| Products | A few non-consumables | Mostly subscriptions |
| Where entitlements are shared | The iOS app only | Across Android, web and accounts |
| Server-side work | None | Receipt validation, webhooks, CRM integration |
| Purchase data | Exchanged only with Apple | Aggregated for analysis |
4-2. StoreKit is the only source of truth; the cache is for display
The entitlement decision (has this been bought?) lives in one place, EntitlementService. Its only source is StoreKit. The cache in the on-device settings table exists so the UI has an answer while offline.
// apps/mobile/src/services/entitlementService.ts (excerpt)
/** Load cache, then refresh from StoreKit; a StoreKit failure keeps the cached answer. */
async initialize(): Promise<EntitlementState> {
const cached = parseCache(await this.deps.settings.get(SETTING_KEYS.entitlementCache));
if (cached) {
await this.set({ entitled: cached.entitled, source: 'cache', checkedAt: cached.checkedAt }, false);
}
try {
const purchases = await this.bounded('entitlements', STORE_READ_TIMEOUT_MS, this.readEntitlements());
await this.set({
entitled: isEntitled(purchases, this.deps.productId),
source: 'storekit',
checkedAt: this.deps.clock.nowIso(),
});
} catch (error) {
// Unreachable or slow, the answer is the same: keep whatever the cache published above.
this.deps.logger.log('warn', 'entitlement refresh failed; using cache', { message: messageOf(error) });
}
return this.state;
}
The state carries source: 'storekit' | 'cache' | 'none' to separate "just verified" from "last time's answer." Right after a reinstall with no network, there's no cache, so the user reads as not entitled; purchase and restore both need a network anyway, so that's accepted. Keeping purchase data out of backup files follows from the same principle: StoreKit is the only source.
4-3. Always put a time limit on StoreKit calls
On a device that can't reach StoreKit (airplane mode, no Sandbox account, a simulator launched outside Xcode), fetching products can simply never finish. This app found out when an App Store screenshot of the paywall showed it stuck on "loading price" (ADR-0003, revision 2).
There are now three limits:
| Call | Limit | Reason (from the code comments) |
|---|---|---|
| Product lookup, entitlement read at launch | 10 seconds | Nothing of Apple's is on screen; this covers a slow round trip and still settles while the sheet is being read |
| Restore purchases | 60 seconds | Apple's sign-in sheet may appear and wait for a password |
| Purchase | 5 minutes | Waits on the payment sheet, Face ID and Ask to Buy |
The limits live in one spot inside EntitlementService (bounded). The promise "the app stays usable when the store is unreachable" belongs to the service layer, and keeping the limit there means it survives an adapter swap. Hitting a limit raises a typed StoreUnreachableError, distinct from "the store answered and has no such product" (null) and "the device doesn't allow purchases" (PurchasesNotAllowedError). The paywall renders all three differently, because only the first one can be fixed by retrying.
4-4. The expo-iap adapter: subscribe first, always finish stray transactions
StoreKit 2's Transaction.updates delivers transactions that happen outside the app (Ask to Buy approvals, App Store code redemptions) as well as unfinished ones. Apple's documentation says unfinished transactions arrive on updates once, immediately after the app launches, so you should start listening as soon as the app starts.
To handle this correctly through expo-iap, the adapter does two things:
// apps/mobile/src/adapters/expoIap.ts (excerpt)
/**
* Anything that arrives outside an in-flight purchase() is finished here.
* Entitlement is re-read through currentEntitlements() by the service, never taken from this event.
*/
const finishStray = (purchase: Purchase): void => {
if (inFlight.has(purchase.productId) || purchase.purchaseState === 'pending') return;
finishTransaction({ purchase, isConsumable: false }).catch((error: unknown) => {
console.warn('[iap] could not finish a replayed transaction', messageOf(error));
});
};
return {
async connect(): Promise<void> {
// StoreKit hands unfinished transactions to Transaction.updates as soon as initConnection
// starts it, and expo-iap only forwards to listeners already registered: subscribe first.
replay ??= purchaseUpdatedListener(finishStray);
await initConnection();
},
// …
};
First, register the listener before initConnection(). Reverse the order and transactions delivered right after connecting are lost.
Second, finish transactions that don't belong to an in-flight purchase. Apple's docs say to call finish() after delivering the purchased content; an unfinished transaction comes back on the next launch. Transactions that do belong to an in-flight purchase() are finished by that call itself, and the inFlight set tells the two apart.
A purchase result arrives through both the update event and the request promise, in either order; a cancel arrives through both the error event and the rejection. The adapter lets the first signal win so nothing is processed twice.
Entitlement is updated only from a just-completed purchase or from getAvailablePurchases({ onlyIncludeActiveItemsIOS: true }) (StoreKit 2's current entitlements). The "Restore Purchases" action that App Store Review Guideline 3.1.1 requires is available both in Settings and on the paywall.
5. Gate paid features in both the UI and the service layer
There are three paid features (ADR-0003): creating a second book, placing imported photos into a book in bulk, and exporting PDF and CSV. Exporting and importing the encrypted backup is not paid — the restore path is never put behind a purchase.
5-1. The rules live in pure functions, in one place
// apps/mobile/src/domain/entitlement.ts (excerpt)
export const FREE_BOOK_LIMIT = 1;
/** The second book onward is paid. Closing the paywall continues within the free range. */
export function canCreateBook(existingBookCount: number, entitled: boolean): GateDecision {
if (entitled || existingBookCount < FREE_BOOK_LIMIT) return ALLOW;
return { allowed: false, gate: 'multi_book' };
}
/** PDF/CSV export is paid. Encrypted ZIP export/import is free (never gate the restore path). */
export function canExportPdfCsv(entitled: boolean): GateDecision {
return entitled ? ALLOW : { allowed: false, gate: 'export_pdf_csv' };
}
Knowledge of which features are paid exists only here. With no dependency on React or Expo, testing it means checking inputs against outputs.
5-2. The service throws too; the UI catches it and routes to the paywall
The service layer applies the same function and throws GateError:
// apps/mobile/src/services/ledgerService.ts (excerpt)
async createBook(input: NewBookInput, entitled: boolean): Promise<Book> {
const count = await repos.books.count();
if (!canCreateBook(count, entitled).allowed) throw new GateError('multi_book');
// …
}
A screen may check up front, but ultimately it catches the service's GateError and routes to the paywall:
// apps/mobile/src/ui/screens/BookNewScreen.tsx (excerpt)
try {
await services.ledger.createBook({ title: form.title /* … */ }, entitled);
// Count only after it succeeds. The second book throws GateError at the gate, so counting
// before the call would inflate "books created" by every paywall hit.
services.analytics.record({ name: 'book_created', entitled });
// …
} catch (error) {
if (error instanceof GateError) {
router.push(paywallHref(error.gate));
return;
}
// …
}
Now a screen that forgets the check, or a new entry point added later, still can't slip past the paywall. The in-progress form is autosaved as a draft, so closing the paywall and coming back loses nothing (hence a "soft" paywall).
5-3. Where this design falls short
Two layers still aren't airtight. To be upfront:
- The caller passes
entitled. The service doesn't read the purchase state itself; it takes the value the UI got fromuseEntitlement(). That catches a forgotten check, but not a screen that wrongly passestrue. To harden it, injectEntitlementServiceinto the service and let it read the state directly — at the cost of more setup in tests. - Every check lives on the device. Nothing is verified on a server, so a tampered device that rewrites the check can get around it. For a ¥1,200 one-time purchase, with "no server" as a core policy, this risk is accepted. For expensive products, or features that consume server-side resources, you should look at server-side verification (the App Store Server API, for example).
6. Jest without a simulator, Maestro for the rest
Much of the design above exists for testing.
6-1. Jest: run whole services on real SQL, files and crypto
makeTestServices() hands these into the same assembleServices production uses:
| Port | Production | Jest |
|---|---|---|
| SQL | expo-sqlite | sql.js (SQLite in WebAssembly) |
| Files | expo-file-system | Node's fs (temp directory) |
| Crypto | Swift / CryptoKit | node:crypto reference implementation |
| StoreKit, pickers, share sheet, etc. | Each Expo module | Scriptable fakes |
That makes it possible to import photos, create records, export encrypted, and restore into an empty database — all inside Jest, on real SQL and real files. Running the migration, EntitlementService and backup tests locally, 125 tests across 6 files passed in about 5 seconds (npx jest src/data src/services/entitlementService.test.ts src/services/backupService.test.ts).
jest.config.js enforces a 100% threshold on lines, branches, functions and statements for src/domain, src/data and src/services. Adapters are native wrappers that can't run in Jest, so they're excluded from the threshold.
One caveat: sql.js and expo-sqlite don't necessarily bundle the same SQLite version — this app's migration comments list both side by side. When you reach for newer SQL syntax, confirm it works on both.
6-2. Maestro: the native parts Jest can't reach
What Jest can't run is native: the Swift crypto module, real StoreKit, file sharing. Maestro end-to-end flows cover those. There are 11 numbered flows (ls apps/mobile/.maestro/[0-9]*.yaml), for example:
04-paywall-second-book.yaml: the paywall appears on the second book. It checks the sheet's structure, not the price (prices are verified with a StoreKit configuration or Sandbox)05-backup-roundtrip.yaml: save, search, encrypted export, delete, restore06-offline-smoke.yaml: the same steps as 05, with the Mac's network off
Flow 06 has an interesting constraint. The iOS simulator has no airplane mode and shares the Mac's connection, and Maestro's airplane-mode commands are Android-only. So the flow's comments tell you to turn off the Mac's Wi-Fi and unplug Ethernet before running it.
7. "No server" doesn't mean "no network"
One last point that's easy to get wrong. This app runs no server of its own, but it isn't silent on the network:
- App Store traffic (product lookup, purchase, restore)
- MapKit map tiles when a map is shown
- Crash reports (Sentry) and usage analytics (PostHog)
The third item started out off by default and was switched on by default in ADR-0036; the ADR records that the decision was made after objections about consent requirements in the EEA and elsewhere were raised. Users can turn it off in Settings, and devices set to off send nothing. Only allow-listed events go out — never records or photos. Paywall views and purchase outcomes (success, cancelled and so on) are sent as events, but transaction IDs are not.
The app's own code contains no fetch, XMLHttpRequest or WebSocket, and architecture.test.ts checks that statically; Sentry's and PostHog's traffic happens inside their SDKs. Writing "no server means no network" would be false, so a privacy explanation has to draw this line explicitly.
8. When this design fits, and when it doesn't
| Fits | Doesn't fit |
|---|---|
| Data stays within one user (logs, journals, collections) | Sharing among people, or real-time sync across devices |
| Not requiring sign-up is itself a selling point | Features that assume accounts (push delivery, leaderboards) |
| One-time purchases with few products | Subscription-led, or entitlements shared with Android or the web |
| You want minimal fixed costs and operations | Server-side fraud control or data aggregation is core to the business |
If you're on the right-hand side, choose a design with sync or a server. For offline writes that sync later, an approach like Designing for an Untrusted Client (pushing consistency and authorization into PostgreSQL) is one option. Expo's release infrastructure (EAS, CNG, OTA updates) is covered in the Expo production operation guide.
The app this code runs in
Every excerpt here comes from the repository of Goshuin Ledger, an iOS app for recording goshuin temple stamps, live on the App Store. With no sign-up, it lets you bulk-import the goshuin photos you've been collecting, orders them by shot date (EXIF), and organizes them into books and pages while you correct dates written in the Japanese era calendar. There's also on-device OCR that suggests a date from the photo, but its accuracy hasn't been measured yet. The background and design decisions are in the /labs write-up.
I also take on this kind of design as development work — serverless mobile apps, on-device data migration, encrypted backups, in-app purchases, and a layering that keeps it all testable — including reviews of an existing Expo app's architecture. See Services for details.