# Building a Serverless, Offline-First iOS App with Expo: expo-sqlite Migrations, Encrypted Backups, and StoreKit 2 One-Time Purchases in Production Code

> A design guide for an Expo (SDK 57) iOS app with no server and no login: expo-sqlite migrations via user_version, an encrypted backup format for moving to a new phone, StoreKit 2 one-time purchases without RevenueCat plus layered paywall gates, and a ports-and-adapters structure that runs under Jest without a simulator — all from an app live on the App Store.

- Published: 2026-09-25
- Author: 友田 陽大
- Tags: React Native, Expo, iOS, オフラインファースト, データベース, 決済, アーキテクチャ設計, テスト, 個人開発
- URL: https://tomodahinata.com/en/blog/expo-offline-first-ios-app-sqlite-migration-storekit2-guide
- Category: モバイルアプリ開発（Expo / React Native）
- Pillar guide: https://tomodahinata.com/en/blog/expo-production-guide-router-eas-cng-ota

## Key points

- Drop the server and its jobs don't disappear — schema migration, backup, purchase verification and failure recovery all move onto the device. The design goal becomes: nothing breaks when something fails on the user's phone.
- expo-sqlite migrations need only `PRAGMA user_version` and one transaction per version. But never edit a shipped version, and test upgrades from the databases old releases left behind, not just from an empty file.
- expo-sqlite's `withTransactionAsync` is not exclusive. Two operations interleaving across `await` on one connection can let one rollback erase the other's writes. Don't nest transactions; queue them.
- For a single non-consumable, RevenueCat is optional. Treat StoreKit 2 as the only source of truth, keep the on-device cache for offline display, and enforce paid gates in both the UI and the service layer.
- Hide every Expo native dependency behind a port and wire them in one composition root, and the domain and services run under Jest on sql.js, Node's fs and node:crypto. What Jest can't reach is covered by Maestro end-to-end flows.

---

"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](https://goshuinbako.com/en/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_expo_offline)** — 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](/labs/goshuin).

We'll cover four things:

1. Running expo-sqlite schema migrations safely on the device
2. An encrypted backup format and implementation for moving to a new phone
3. StoreKit 2 one-time purchases without RevenueCat, with layered paywall gates
4. 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's `apps/mobile/package.json` and in `SQLITE_VERSION` in `expo-sqlite/ios/sqlite3.h`. How to write the Swift native module itself is covered in [React Native / Expo × Swift Native Modules](/blog/react-native-expo-swift-native-module-bridge-guide); 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.

## 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:

```text
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.

```ts
// 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`.

```js
// 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:

```ts
// 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:

```ts
// 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`).

```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.

```sql
-- 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 COLUMN` arrived 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**.

```ts
// 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:

```ts
// 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:

```ts
// 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):

1. **Standard iOS backup**: the database and downscaled photos live in Documents, so iCloud Backup and Quick Start carry them over as-is.
2. **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:

```text
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.

```swift
// 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:

1. **Export compliance**: using only OS-provided cryptography lets you pin `ITSAppUsesNonExemptEncryption` to `false` in Info.plist and skip the question on every submission (the final call still rests on Apple's help pages).
2. **Memory**: holding a large file in a JS `Uint8Array` means copies every time it crosses the bridge. Streaming 4 MiB at a time natively keeps memory flat.
3. **Auditability**: a homemade AES-GCM or a pure-JS npm implementation is costly to audit and makes no side-channel guarantees.
4. **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](/blog/react-native-expo-swift-native-module-bridge-guide).

### 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.part` and the finished file is moved within the same volume into `Documents/exports/`. A force-quit mid-export never leaves a truncated `.goshuin` in the history — which a restore would otherwise misdiagnose as a wrong passphrase.
- **Sweep leftovers on the next launch.** `finally` doesn't run on a force-quit. That's what `sweepScratch()` 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_space` before entering the slow key derivation.
- **Make import an idempotent merge.** Insert if the id is missing, update if `updatedAt` is 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.db` never 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](/blog/revenuecat-in-app-subscription-entitlements-webhooks-production-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.

```ts
// 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:

```ts
// 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

```ts
// 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`:

```ts
// 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:

```tsx
// 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 from `useEntitlement()`. That catches a forgotten check, but not a screen that wrongly passes `true`. To harden it, inject `EntitlementService` into 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, restore
- `06-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)](/blog/untrusted-client-postgres-rls-offline-first) is one option. Expo's release infrastructure (EAS, CNG, OTA updates) is covered in the [Expo production operation guide](/blog/expo-production-guide-router-eas-cng-ota).

## The app this code runs in

Every excerpt here comes from the repository of **[Goshuin Ledger, an iOS app for recording goshuin temple stamps](https://goshuinbako.com/en/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_expo_offline)**, 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](/labs/goshuin).

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](/services) for details.
