npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@chipmobilesdk/rn-local-db

v0.1.1

Published

Offline-first, scope-isolated local storage for ChipMobileSdk apps.

Readme

@chipmobilesdk/rn-local-db

Offline-first, scope-isolated local storage for ChipMobileSdk apps.

License: UNLICENSED. Published publicly for use by the owner's applications; no open-source license is granted.

  • Version: 0.1.0
  • Entrypoints: @chipmobilesdk/rn-local-db and the test-only @chipmobilesdk/rn-local-db/testing

Structured records, queries a list screen can actually use, transactions with real rollback, versioned migrations that fail loudly, an explicit deletion lifecycle, package-owned file attachments, opt-in encryption at rest, and typed diagnostics that never carry the user's data.

Nothing leaves the device. The package makes no network request, holds no business rules, declares no collections of its own, and depends on no sibling package.


Contents


Install

npm install @chipmobilesdk/rn-local-db
npm install @op-engineering/op-sqlite @dr.pogodin/react-native-fs
# Only if you will enable encryption:
npm install react-native-quick-crypto
cd ios && pod install

Enable SQLCipher (only if you will encrypt)

SQLCipher is a build flag in your app, not a separate package. Add it to your app's package.json:

{
  "op-sqlite": {
    "sqlcipher": true
  }
}

Then rebuild. Without the flag, openDatabase with encryption.enabled fails with ENCRYPTION_UNAVAILABLE rather than writing plaintext into a file you believe is encrypted.

Android backup exclusion (required)

Android reads backup participation from your manifest, which is build-time and app-owned. This package authors no native code, so it cannot contribute the entry — it verifies coverage at runtime and reports the observed posture instead. See Backup posture for the exact XML.

Remove the inherited storage permission (recommended)

@dr.pogodin/react-native-fs declares WRITE_EXTERNAL_STORAGE unconditionally in its own manifest, so it merges into your app whether or not you use it. This package never writes outside the app-private documents directory — every filesystem call is confined to chipmobilesdk-localdb/ — so unless something else in your app needs it, the permission is declared and unused. That shows up in your Play listing and invites a data-safety question with no true answer.

<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

  <uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    tools:node="remove" />

rn-local-db itself requires no permissions on either platform.


The scope model

A scope is the isolation boundary for all data. Every read and write happens inside exactly one, and a handle's scope is fixed when you open it.

There is no switchScope, and no read, write, or query operation takes a scope argument. That absence is the guarantee: a cross-scope leak is not something you are prevented from writing, it is something you cannot express.

import { openDatabase } from '@chipmobilesdk/rn-local-db';

const guest = await openDatabase({ ...config, scope: { kind: 'guest' } });
const alice = await openDatabase({
  ...config,
  scope: { kind: 'identity', id: '[email protected]' },
});
const aliceAtAcme = await openDatabase({
  ...config,
  scope: { kind: 'identity', id: '[email protected]', tenant: 'acme' },
});

Those are three different scopes holding three separate sets of data. The same record id can exist in all three, and neither overwrites the others.

Scope components are treated as sensitive. They are normalized, joined canonically, and hashed; only the SHA-256 digest reaches disk, an error, or a log. A directory named after an email address would be readable even when the database inside it is encrypted, and would travel in any crash report that includes a file path.

alice.scopeKey; // "9f2c…" — the digest, never "[email protected]"

Worked example: an account switcher

type Session = { handle: DatabaseHandle; scope: ScopeDescriptor };

const sessions = new Map<string, Session>();

async function signIn(accountId: string): Promise<Session> {
  const scope = { kind: 'identity', id: accountId } as const;
  const handle = await openDatabase({ ...config, scope });
  const session = { handle, scope };
  sessions.set(accountId, session);
  return session;
}

async function signOut(accountId: string): Promise<void> {
  // Closes the handle. Deletes nothing — see the deletion section.
  await sessions.get(accountId)?.handle.close();
  sessions.delete(accountId);
}

async function forgetMe(accountId: string): Promise<void> {
  await signOut(accountId);
  const outcome = await deleteScopeData({
    name: config.name,
    scope: { kind: 'identity', id: accountId },
  });
  console.log(`removed ${outcome.removedRecords} records`);
}

Up to eight handles may be open at once, so keeping a previous account warm while the user switches is a supported arrangement rather than a leak.


Declaring a schema

Declaring a field makes it queryable. It does not restrict what a record may contain: undeclared fields are stored and returned faithfully, they simply cannot be filtered or sorted on.

const config: DatabaseConfig = {
  name: 'my_app',
  scope: { kind: 'guest' },
  schemaVersion: 2,
  collections: [
    {
      name: 'notes',
      fields: [
        { name: 'title', type: 'string' },
        { name: 'body', type: 'string', nullable: true },
        { name: 'score', type: 'number' },
        { name: 'pinned', type: 'boolean' },
        { name: 'updated', type: 'timestamp' },
      ],
      indexes: [
        { name: 'notes_by_score', fields: ['score'] },
        { name: 'notes_by_pinned_score', fields: ['pinned', 'score'] },
      ],
      softDelete: true, // opt-in
      timestamps: true, // opt-in: the package maintains createdAt/updatedAt
    },
  ],
  migrations: [/* see below */],
};

Validation runs to completion before any write. A duplicate collection name, an index over an undeclared field, or a broken migration chain each raise before a file is touched.


Records and the four value conditions

const notes = handle.collection<Note>('notes');

await notes.insert({ id: 'n1', data: { title: 'First', score: 1, pinned: false } });
await notes.update('n1', { data: { title: 'Renamed', score: 1, pinned: false } });
const outcome = await notes.upsert({ id: 'n1', data: { …} });
outcome.operation; // 'created' | 'updated' — it tells you which it was

await notes.get('n1');   // rejects with RECORD_NOT_FOUND when absent
await notes.find('n1');  // returns null when absent

await notes.delete('n1');      // hard
await notes.softDelete('n1');  // requires softDelete: true
await notes.restore('n1');

get and find both exist because these four states are four different answers, and an app that cannot tell them apart writes bugs that look like data loss:

| Condition | How you see it | |---|---| | The record does not exist | find returns null; get rejects with RECORD_NOT_FOUND | | The field is present and null | 'body' in record.data is true, record.data.body is null | | The field is absent | 'body' in record.data is false | | The record is soft-deleted | Excluded from reads unless includeSoftDeleted: true |

These survive a write-and-read cycle because reads reconstruct the record from the stored payload alone. Nothing is coerced: numbers keep their precision, booleans stay booleans, nested structures come back identically.

Mutating the object you passed in does not change what was stored, and mutating what you got back does not change the store.


Queries

const page = await notes.list({
  filter: {
    op: 'and',
    nodes: [
      { op: 'gte', field: 'score', value: 80 },
      { op: 'eq', field: 'pinned', value: true },
    ],
  },
  sort: [{ field: 'score', direction: 'desc' }],
  page: { size: 25, cursor: previousPage?.cursor },
});

page.records; // StoredRecord<Note>[]
page.hasMore; // whether another page exists
page.cursor;  // opaque; absent on the final page

const total = await notes.count(filter); // agrees with the list, always

Operators: eq, ne, lt, lte, gt, gte, in, nin, between, isNull, isNotNull, exists, combined with and, or, not.

exists is the one that answers a question about presence, which is what keeps "absent" distinguishable from "null". An absent field satisfies no other operator — not even ne.

Paging is keyset-based, anchored to the last row's sort values and the record id. Page 2,000 costs the same as page 1, and a concurrent write cannot make a record appear twice or vanish. OFFSET has neither property. The record id is always appended as the final sort key, so ties break deterministically.

An invalid query is an error, never a result. A filter on an undeclared field raises QUERY_INVALID rather than returning everything or nothing — both of which look like data and neither of which is.

// Development-mode diagnostic: is this query actually using the index?
const report = await handle.explain('notes', query);
report.usesIndex; // answered by the engine's plan, not by reading declarations

Transactions and batches

await handle.transaction(async tx => {
  await tx.collection('orders').insert({ id: 'o1', data: order });
  await tx.collection('lines').insert({ id: 'l1', data: line });
  // Throw, or call tx.abort(), and neither write survives.
});

Reads inside a transaction see that transaction's own writes; reads outside do not until it commits. A settled transaction rejects further use with TRANSACTION_STATE, so cleanup code cannot accidentally write outside the rollback it was meant to be part of.

A transaction never spans two scopes — with one database file per scope, that is a type error rather than a runtime check.

const outcome = await handle.batch([
  { type: 'upsert', collection: 'notes', record: { id: 'a', data } },
  { type: 'softDelete', collection: 'notes', id: 'b' },
]);
outcome.applied;  // number
outcome.outcomes; // one WriteOutcome per operation

A batch is one atomic unit, up to 1,000 operations. Every operation is resolved before any of them runs, so a conflict is reported with the database untouched.


Migrations

const migrations: Migration[] = [
  { version: 1, migrate: async () => {} },
  {
    version: 2,
    migrate: async ctx => {
      const page = await ctx.collection('notes').list({ page: { size: 500 } });
      for (const record of page.records) {
        await ctx.collection('notes').upsert({
          id: record.id,
          data: { ...record.data, pinned: false },
        });
      }
    },
  },
];

Rules, each of which exists because the alternative loses data:

  • Ascending, pending only, at most once per scope. The ledger is per scope, so an account created after a migration shipped runs the whole chain on first open rather than being assumed current.
  • One transaction per migration. The recorded version advances only on success, inside the same transaction as the migration's own writes.
  • A failure returns no handle at all. Not a degraded handle, not a read-only one — nothing. MIGRATION_FAILED carries recordedVersion, failingVersion, and outcome so you can tell a transient failure from a permanent one.
  • A retry starts from the same version. Ten consecutive failed opens alter zero records.
  • A chain that does not reach schemaVersion, has a gap, or repeats a version is refused before anything runs, with MIGRATION_CONFIG_INVALID.
  • Opening a database written by a newer schema raises SCHEMA_DOWNGRADE and truncates nothing. A user who rolls back a build gets their data again when they roll forward.

Testing a migration

import { createMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';

const adapter = createMemoryAdapter();
const v1 = await adapter.open({ ...config, schemaVersion: 1, migrations: [migrations[0]] });
await v1.collection('notes').insert({ id: 'n1', data: legacyShape });
await v1.close();

const v2 = await adapter.open({ ...config, schemaVersion: 2, migrations });
expect((await v2.collection('notes').get('n1')).data.pinned).toBe(false);

Recovering from a broken migration

resetScope discards one scope's data and leaves it ready for a clean first run. It is separately named and never reachable from openDatabase on purpose: an automatic fallback would turn a bad migration shipped to production into silent data loss for every user who launched the app.

const outcome = await resetScope({ name: config.name, scope });
outcome.removedRecords; // it tells you what it destroyed

Deletion, and why sign-out is not deletion

The package never deletes anything unasked. No expiry, no cache eviction, no sweep on app upgrade, nothing on close(). Closing a handle is what a sign-out looks like to this package, and it does nothing to the data.

// Sign-out: the data survives, and is there when the user signs back in.
await handle.close();

// A deletion request: one scope, every collection, and nothing else.
const outcome = await deleteScopeData({ name: config.name, scope });

// Uninstall-equivalent: the whole database directory.
await deleteDatabase({ name: config.name });

These are module-level functions rather than handle methods because they must work whether or not a handle is open on the target.

Every one returns a DeletionOutcome rather than a bare success — what was removed, what was orphaned, and what could not be removed. A file the package could not delete is still on disk holding the user's data, and an app that reported "deleted" would have said something untrue.

If a deletion is interrupted — a force-quit, a battery death — the next open reports SCOPE_DELETED rather than presenting the scope as healthy. Call deleteScopeData again to finish it.

A handle whose scope is deleted beneath it reports SCOPE_DELETED on its next use. The deletion does not wait for it.


Attachments

The package owns both halves: the file on disk and the reference to it.

const attachments = handle.attachments();

const reference = await attachments.write({
  id: 'photo-1',
  owner: { collection: 'notes', id: 'n1' },
  contentType: 'image/jpeg',
  content: base64,
});

await attachments.read('photo-1');                        // whole
await attachments.read('photo-1', { offset: 0, length: 4096 }); // a range
await attachments.availability('photo-1');                // never opens the file
await attachments.listFor({ collection: 'notes', id: 'n1' });
await attachments.remove('photo-1');

AttachmentInput.content is one complete base64 string. The current public write contract therefore requires the app to hold the encoded payload, and the native adapter decodes that full string before processing encrypted chunks. Use range reads to avoid loading an entire stored artifact when consuming it.

Availability answers from stat() and, for an encrypted artifact, the 22-byte header — never a content byte and never a decryption. A gallery showing fifty thumbnails does not decrypt fifty files to ask whether they are there.

| State | Meaning | |---|---| | available | The file is there | | missing | The file is gone — data loss for that artifact | | inaccessible | The file exists but could not be reached | | undecryptable | The file exists and will not decrypt: a wrong key or a tampered file, often recoverable |

A missing artifact never makes its record unreadable. A note with a broken image is worth more than a note that will not load.

Cascade

Set cascadeAttachments: true in the config and deleting a record removes its artifacts too. Off by default: deleting a user's files as a side effect is not a decision this package makes for you.

Reconciliation

A filesystem write cannot join a database transaction. So a rolled-back transaction leaves an artifact on disk with nothing pointing at it, and the operating system can reclaim a file the database still references. Both are real, and reconciliation is how you find them:

const report = await attachments.reconcile();
report.orphanedArtifacts;   // files with no reference
report.danglingReferences;  // references with no file

// Reclaim the disk. Off by default — deleting a file is never an implied yes.
await attachments.reconcile({ removeOrphans: true });

App-triggered only. There is no background sweep, because a sweep cannot tell an orphan from a file a transaction has not committed yet.

The boundary rule

Every filesystem operation is confined to the managed storage area. An attachment id must be a safe single path segment; anything else raises ATTACHMENT_OUT_OF_BOUNDS and touches no file.


Encryption

Opt-in, under a key your app supplies. The package never generates, derives from its own material, persists, caches beyond the handle, transmits, or returns a key — which is also why it has no keychain dependency.

const keyProvider: KeyProvider = {
  async getKey(scope) {
    // scope.key is the digest, so you can key per identity.
    return Keychain.get(`db-key-${scope.key}`);
  },
};

const handle = await openDatabase({
  ...config,
  encryption: { enabled: true, keyProvider },
});

The provider receives the resolved scope so you can key per identity — which is what makes destroying one identity's key a meaningful answer to a deletion request, rather than a gesture that leaves the data readable under a shared key.

Records are protected by SQLCipher. Artifacts use chunked AES-256-GCM: 256 KB chunks, a per-artifact subkey derived with HKDF, and an authentication tag per chunk. Encryption and range decryption operate by chunk, but the current write API first decodes the complete base64 input. Peak write memory is therefore not flat in artifact size; size large media before passing it to this version.

A key-provider failure while the device is locked is an expected condition, not a bug:

| Code | Means | Retryable | |---|---|---| | KEY_PROVIDER_FAILED | Your provider could not answer — usually a locked device | Yes | | KEY_INVALID | The key was wrong, empty, or unusable | No | | ENCRYPTION_MISMATCH | The stored database has a different posture than requested | No | | ENCRYPTION_UNAVAILABLE | Encryption was asked for and cannot be delivered | No |

None of the four opens, truncates, or recreates anything. Each is a state where the data is still there and still recoverable.

Changing posture in place is not supported in this version. To convert, export, delete the scope, and re-import.


Backup posture

Excluded from platform backup by default. The two platforms are not symmetric, and the package reports which one you are getting rather than smoothing over it.

handle.backupPosture;
// { requested: 'excluded', observed: 'excluded', enforcedBy: 'package' }         // iOS
// { requested: 'excluded', observed: 'unknown',  enforcedBy: 'app-manifest', warning: '…' } // Android

iOS: the package sets NSURLIsExcludedFromBackupKey on the managed root at creation, and the attribute is inherited by everything beneath it. Enforced.

Android: participation is declared in your manifest, which is build-time and app-owned. A library that authors no native code cannot set it. The package verifies whether the managed area is where the documented rule addresses and reports the observed posture, warning when it cannot confirm.

Ship both files — dataExtractionRules is read from Android 12, fullBackupContent below it:

<!-- android/app/src/main/res/xml/data_extraction_rules.xml -->
<data-extraction-rules>
  <cloud-backup>
    <exclude domain="file" path="chipmobilesdk-localdb/" />
  </cloud-backup>
  <device-transfer>
    <exclude domain="file" path="chipmobilesdk-localdb/" />
  </device-transfer>
</data-extraction-rules>
<!-- android/app/src/main/res/xml/backup_rules.xml -->
<full-backup-content>
  <exclude domain="file" path="chipmobilesdk-localdb/" />
</full-backup-content>
<!-- android/app/src/main/AndroidManifest.xml -->
<application
  android:dataExtractionRules="@xml/data_extraction_rules"
  android:fullBackupContent="@xml/backup_rules">

To opt in instead, set backup: { includeInPlatformBackup: true } and remove the exclusion from your rules.


Diagnostics

Every failure is a StorageError with a stable code. Never parse a message — use isCode:

import { DiagnosticCodes, isCode, isStorageError } from '@chipmobilesdk/rn-local-db';

try {
  await notes.insert({ id, data });
} catch (error) {
  if (isCode(error, DiagnosticCodes.DISK_FULL)) {
    return promptToFreeSpace();
  }
  if (isStorageError(error) && error.retryable) {
    return retryLater();
  }
  throw error;
}

No error ever carries a record payload, a field value, a query parameter, attachment content, a scope component, or key material — in any build. Redaction happens at construction rather than at logging, so an error object that never held a payload cannot leak one through a crash reporter. A native message is reduced to an engine identifier (SQLITE_FULL, ENOENT) or to [redacted]; detail keeps only allowlisted keys.

Every diagnostic identifier

| Code | Condition | Retryable | Recommended recovery | |---|---|---|---| | CONFIG_INVALID | The configuration is malformed | No | Fix the declaration; this is a developer error | | INVALID_SCOPE | The scope descriptor is missing, malformed, or too long | No | Correct the descriptor — it is never sanitized for you | | MIGRATION_CONFIG_INVALID | Duplicate version, a gap, or a chain short of schemaVersion | No | Fix the chain before shipping | | OPEN_FAILED | The database could not be opened | Yes | Retry; if it persists, report or offer a reset | | CLOSED_HANDLE | The handle was already closed | No | A lifecycle bug in the app | | SCOPE_DELETED | The scope was deleted, or a deletion was interrupted | No | Call deleteScopeData to finish, then reopen | | CORRUPTION | The database file is malformed | No | Offer resetScope; never assume it | | STORAGE_ACCESS | The filesystem refused | Yes | Retry after unlock | | MIGRATION_FAILED | A migration threw | Yes | Retry on next launch; ship a fix, or offer resetScope | | SCHEMA_DOWNGRADE | The stored schema is newer than the declared one | No | Do not open; the newer build reads it fine | | RECORD_NOT_FOUND | No record with that id | No | Refresh the list; the id is stale | | RECORD_CONFLICT | A record with that id already exists | No | Use upsert, or choose a different id | | RECORD_TOO_LARGE | Over 256 KB serialized | No | Store the bulk as an attachment | | BATCH_TOO_LARGE | Over 1,000 operations | No | Split the batch | | DISK_FULL | No space left | Yes | Prompt to free space, then retry | | WRITE_FAILED | The write failed for another reason | Yes | Retry once | | QUERY_INVALID | Undeclared field, unsupported operator, or wrong value shape | No | Fix the query; a developer error | | PAGE_SIZE_INVALID | Page size outside 1–500 | No | Clamp the requested size | | CURSOR_INVALID | The cursor is malformed or from a different query shape | No | Restart from the first page | | TRANSACTION_FAILED | The transaction could not commit | Yes | Retry the whole transaction | | TRANSACTION_STATE | Used after commit or abort | No | A lifecycle bug in the app | | ATTACHMENT_MISSING | The artifact is gone | No | Show the record without it; offer to re-attach | | ATTACHMENT_OUT_OF_BOUNDS | The id is not a safe path segment | No | Use [A-Za-z0-9._-], no leading dot | | ATTACHMENT_WRITE_FAILED | The artifact could not be written | Yes | Retry; check free space | | ATTACHMENT_UNDECRYPTABLE | The artifact will not authenticate | No | A wrong key or a tampered file — not data loss | | KEY_INVALID | The key was unusable | No | Re-derive or re-provision the key | | KEY_PROVIDER_FAILED | Your provider could not answer | Yes | Retry after unlock | | ENCRYPTION_MISMATCH | Posture differs from what is stored | No | Export, delete, re-import | | ENCRYPTION_UNAVAILABLE | SQLCipher or the cipher peer is absent | No | Enable the build flag, or install the peer |

Development logging

setDiagnosticLogger(event => console.log(event.operation, event.durationMs, event.count));

Operation names, durations, counts, and outcome codes. There is deliberately no path that accepts a payload, and the logger is inert in production builds.


Public API

Production code imports only from @chipmobilesdk/rn-local-db:

  • Lifecycle: openDatabase, deleteScopeData, resetScope, deleteDatabase
  • Handle contract: DatabaseHandle, CollectionOps, Transaction, Attachments, StorageAdapter, plus batch/transaction result types
  • Configuration and isolation: database/target/scope descriptors, collection schema, migrations, encryption/key provider, and backup posture types
  • Records and queries: record/value/write types, filter/sort/page/query types, IndexCoverageReport, and Budgets
  • Attachments: input/reference/owner/range/availability/deletion/reconciliation types
  • Diagnostics: StorageError, DiagnosticCodes, DiagnosticCode, isCode, isStorageError, setDiagnosticLogger, DiagnosticLogger

Tests import createMemoryAdapter, reset/seed/fault helpers, and the conformance suite only from @chipmobilesdk/rn-local-db/testing. Other package paths are private and unsupported.


Testing against the in-memory adapter

import { createMemoryAdapter, resetMemoryAdapter } from '@chipmobilesdk/rn-local-db/testing';

const adapter = createMemoryAdapter();
beforeEach(() => resetMemoryAdapter(adapter));

it('archives a note', async () => {
  const handle = await adapter.open(config);
  const repository = new NoteRepository(handle); // your production class
  await repository.archive('n1');
  expect(await repository.findById('n1')).toBeNull();
  await handle.close();
});

A repository written against the interface needs no source change to run here. Seeding goes through the same write operations production code uses, so a test cannot set up data the package would have refused.

import { injectFault, clearFaults, DiagnosticCodes } from '@chipmobilesdk/rn-local-db/testing';

injectFault({ at: 'write', code: DiagnosticCodes.DISK_FULL, times: 1 });
// …assert your recovery path…
clearFaults();

Fault points: open, read, write, commit, migration, attachmentWrite, keyProvider.

Running the conformance suite

The suite is the executable definition of what the package guarantees, and it is exported so you can run it against your own adapter if you ever write one:

import { runConformanceSuite } from '@chipmobilesdk/rn-local-db/testing';

runConformanceSuite(() => myAdapter, reset, hooks);

Documented divergences

The in-memory adapter is faithful, not identical. These six differences are known, deliberate, and the complete list — anything else that differs is a bug:

| Divergence | Why | |---|---| | Index coverage: explain() always reports usesIndex: false | There is no query planner in memory. Index declarations are validated but have no performance effect. | | Open-handle budget: not enforced | The budget exists because each handle holds a native connection and its write-ahead log. In memory there is nothing to exhaust. | | Encryption: the posture is recorded, no ciphertext is produced | Key acquisition, mismatch detection, and every failure code behave identically; the bytes do not. | | Backup posture: reports the requested value | There is no filesystem to set an attribute on and no manifest to verify. | | Artifact chunking: content is stored whole | Chunk boundaries, nonces, and authentication tags are exercised by the cipher's own tests. | | Corruption and disk-full: not spontaneously reachable | Both are device conditions. Use injectFault to reach the codes. |


Budgets

Four are hard limits with a typed error. The rest are soft: exceeding them is slow, not an error — refusing the 100,001st write would destroy data the app already holds.

| Budget | Value | Hard? | Behaviour past it | |---|---|---|---| | Record size | 256 KB serialized | Yes | RECORD_TOO_LARGE | | Batch operations | 1,000 | Yes | BATCH_TOO_LARGE | | Page size | 1–500 | Yes | PAGE_SIZE_INVALID | | Artifact size | 100 MB | Yes | ATTACHMENT_WRITE_FAILED | | Records per collection per scope | 100,000 | No | Query latency grows; unindexed queries degrade first | | Collections per database | 50 | Yes at open | CONFIG_INVALID | | Attachment references per scope | 50,000 | No | Reconciliation slows in proportion | | Concurrently open handles | 8 | Yes | OPEN_FAILED |

Budgets is exported, so you can check before you write rather than after.


Compatibility

| | | |---|---| | React | 19.2+ | | React Native | 0.85+ (New Architecture) | | Node (tooling) | 22.11+ | | Android | minSdkVersion 24, targetSdkVersion 36 | | iOS | 15.1+ | | @op-engineering/op-sqlite | >= 17.1.0 | | @dr.pogodin/react-native-fs | >= 2.39.2 | | react-native-quick-crypto | >= 1.0.0 — optional, only for encryption |

Storage location: app-private documents directory, under chipmobilesdk-localdb/. Backup mechanism: NSURLIsExcludedFromBackupKey on iOS; dataExtractionRules / fullBackupContent on Android. File protection: iOS default data protection applies; on Android the app sandbox applies. Neither is a substitute for encryption, which is what protects a file that has been extracted or restored.

Zero runtime dependencies. No dependency on any @chipmobilesdk/* package.


What this package stores, and where

Written for the privacy disclosure a consuming app has to make.

What is stored: exactly the records and files your app writes. The package generates no data of its own beyond a scope digest, timestamps you opted into, and a migration ledger.

Where: the app-private documents directory, under chipmobilesdk-localdb/<databaseName>/ — a catalog database, one SQLite file per scope, and a per-scope artifact directory. Nothing is written anywhere else, ever.

What leaves the device: nothing. The package makes no network request under any configuration, and the filesystem surface it uses does not include the peer's upload and download helpers.

Protection at rest: platform file protection by default; SQLCipher plus chunked AES-256-GCM for artifacts when encryption is enabled. The key is yours; the package holds it only for the lifetime of the handle.

Backup: excluded by default. Enforced on iOS, declared by your manifest on Android. Opting in with includeInPlatformBackup: true means the user's local data travels to their platform backup, which is a disclosure you may need to make.

Deletion: deleteScopeData removes one scope's records and artifacts and reports what it removed. That satisfies a deletion request for the data this package stores. It does not guarantee anything about residual data outside the package: filesystem free-space is not overwritten, and platform backups already taken are unaffected. If the scope was encrypted with a per-identity key, destroying that key — which your app controls — is what makes any residue unreadable.

Your remaining obligations: this package collects nothing and declares nothing on your behalf. If your app stores health, financial, or other sensitive categories in it, those categories are yours to declare in App Privacy and Data Safety.


Demo and validation

The demo app exposes ten src/screens/LocalDb*Screen.tsx scenarios and uses the testing entrypoint's memory adapter so the UI can run without native database setup. Run from the repository root:

npm run typecheck:local-db
npm run pack:local-db
npm run validate:local-db

Physical-device SQLCipher, backup, corruption, and large-artifact evidence are separate release gates; the memory demo does not prove those native behaviors.