Skip to main content
モバイルアプリ開発(Expo / React Native)
React Native
Expo
iOS
オフラインファースト
データベース
決済
アーキテクチャ設計
テスト
個人開発

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
Reading time
26 min read
Author
友田 陽大
Share
Contents

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

  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; 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 doWho takes it on without a serverImplementation in this app
Schema migrationRun at launch, before any screen rendersPRAGMA user_version plus one transaction per version
Consistency under concurrent writesOne connection, transactions serializedA hand-rolled FIFO queue
Backup and device migrationStandard iOS backup plus an encrypted export fileA custom .goshuin format built on CryptoKit
Purchase verification and entitlementsStoreKit 2 as the only source of truthexpo-iap plus EntitlementService
Gating paid featuresChecks in both the UI and the service layerPure functions plus GateError
Near-production integration testsNative code behind ports, stand-ins running on Nodesql.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 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.

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

  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:

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:

ConcernDecisionWhy
Key derivationPBKDF2-HMAC-SHA256, 600,000 iterations by defaultThe OWASP Password Storage Cheat Sheet recommendation; an export is a one-off action, so the time is acceptable
ChunkingEncrypt every 4 MiB of plaintextKeeps native memory flat even for multi-hundred-MB files
NoncenonceBase ‖ u32be(chunk index)Salt and nonceBase are random per export, so keys and nonces never repeat even with the same passphrase
AADThe full 45-byte header on every chunkTampering with the iteration count or chunk size fails authentication
TruncationCheck against plaintextLengthDetects a dropped final chunk
Header bounds1,000–2,000,000 iterations, 1 KiB–64 MiB chunksThese values set the workload before anything is authenticated; a crafted 45-byte file mustn't pin the CPU or memory
PurchasesNever 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:

  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.

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

ConditionCall StoreKit 2 directlyUse RevenueCat or similar
ProductsA few non-consumablesMostly subscriptions
Where entitlements are sharedThe iOS app onlyAcross Android, web and accounts
Server-side workNoneReceipt validation, webhooks, CRM integration
Purchase dataExchanged only with AppleAggregated 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:

CallLimitReason (from the code comments)
Product lookup, entitlement read at launch10 secondsNothing of Apple's is on screen; this covers a slow round trip and still settles while the sheet is being read
Restore purchases60 secondsApple's sign-in sheet may appear and wait for a password
Purchase5 minutesWaits 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 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:

PortProductionJest
SQLexpo-sqlitesql.js (SQLite in WebAssembly)
Filesexpo-file-systemNode's fs (temp directory)
CryptoSwift / CryptoKitnode:crypto reference implementation
StoreKit, pickers, share sheet, etc.Each Expo moduleScriptable 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

FitsDoesn'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 pointFeatures that assume accounts (push delivery, leaderboards)
One-time purchases with few productsSubscription-led, or entitlements shared with Android or the web
You want minimal fixed costs and operationsServer-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.

Frequently asked questions

How should I write schema migrations for expo-sqlite?
Read PRAGMA user_version as the current version, run only the newer versions' SQL — each in its own transaction — and bump user_version at the end of each. The Expo docs show the same pattern. The rules that matter: never rewrite a shipped version, and test from databases older releases left behind. ALTER TABLE … ADD COLUMN has no IF NOT EXISTS, so the version check is the only thing preventing a double run.
Can a serverless app survive a phone upgrade without losing data?
Yes. The app in this article does it in two layers. First, the database and photos live in Documents, so the standard iOS backup carries them. Second, everything can be exported as one encrypted file and moved off the device via the share sheet or AirDrop. Encryption uses CryptoKit's AES-GCM and CommonCrypto's PBKDF2, and the passphrase is never stored anywhere. Forget it and nobody can restore the file — that is accepted by design.
Do I need RevenueCat for a one-time in-app purchase?
Not if you sell a single non-consumable. StoreKit 2's currentEntitlements tells you whether it was bought, and Apple already provides restore. RevenueCat earns its place with subscription state, entitlements shared across platforms, or server-side receipt validation and webhooks. The app in this article needs none of those, so it calls StoreKit 2 directly through expo-iap.
Isn't checking the paywall in the UI enough?
A UI-only check lets a screen that forgot the check — or another entry point like a deep link or share extension — reach the paid feature. This app keeps the rule in pure functions: the UI uses them to route to the paywall, and the service layer uses the same functions to throw GateError. Because there is no server, though, every check lives on the device, and a tampered device can still get around it.
Can I test Expo app logic without a simulator?
Yes, if the Expo native modules sit behind ports (interfaces). Swap SQLite for sql.js (SQLite compiled to WebAssembly), files for Node's fs, and crypto for a node:crypto reference implementation, and assemble them with the same wiring function production uses. This app does exactly that and enforces a 100% coverage threshold on its domain, data and service layers. The native implementations themselves don't run in Jest, so Maestro end-to-end flows cover them.
Does a serverless app make zero network calls?
Not necessarily. This app talks to the App Store for purchases and loads map tiles, and it also sends crash reports (Sentry) and usage analytics (PostHog). Those two are on by default and can be turned off in Settings. Only allow-listed events are sent — never records or photos. The app's own code contains no fetch or similar calls, and a test checks that statically.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

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

I implement the iOS capabilities that existing libraries can't reach as Swift native modules: on-device OCR (Vision), device-integrity attestation with App Attest, WidgetKit and Live Activity integration, and vendor SDK integration. Having written seven local Expo modules (Swift / Kotlin) for my own app, I cover the thread design, the type and error-code contract with JS, and the privacy manifest — so the native layer, which an OTA update can't fix, is built right from the start.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading