إنتقل إلى المحتوى الرئيسي

09 · Offline storage

Purpose: Specify the local persistence layer of Rasd Forms — the StorageAdapter contract in @rasd/storage, the IndexedDB implementation @rasd/storage-dexie, the SQLite implementation @rasd/storage-sqlite, and the cross-cutting rules for migrations, encryption at rest, integrity, export/import, wipe and testing. Audience: Engineers building the adapters and the sync engine; host-app developers choosing drivers, keys and retention policies; security reviewers at adopting organisations.

TL;DR

  • One contract, three adapters: MemoryStorage (tests/SSR), Dexie 4 on IndexedDB (web/PWA), SQLite via expo-sqlite (default) or op-sqlite (native). Renderers and @rasd/sync only ever see StorageAdapter; no SQL or Dexie type leaks upward.
  • Every adapter keeps the same six logical tables — forms, submissions, attachments, datasets, outbox, kv — plus private _rasd_migrations and _rasd_keys; index columns are cleartext, payloads are encrypted.
  • Writes are storage-first and transactional: finalize writes the submission and its outbox row in one transaction; autosave is a patch with optimistic clientRev locking; attachment bytes live outside the indexed columns (Blob rows on web, files on native) and carry a cleartext sha256 for integrity.
  • Web encryption = AES-256-GCM (WebCrypto) with a non-extractable data key, optionally wrapped by a host key; native = SQLCipher whole-database with the key in expo-secure-store, falling back to field-level AES-GCM for bind.sensitive when SQLCipher is unavailable. Destroying the key is the wipe primitive.
  • Migrations are ordered, checksum-tracked, forward-only; schema steps run inside the engine's upgrade transaction, data steps run in resumable 500-row chunks.
  • export() streams JSONL + blobs and works in every license state and from any schema version; wipe() and remote wipe crypto-shred first, delete second.
  • Budgets: open() ≤ 300 ms cold, autosave patch ≤ 20 ms p95, 1 000 submissions written in ≤ 3 s (web desktop) / ≤ 10 s (Moto G-class), 50 MB of photos stored and hashed in ≤ 15 s.

1. Packages and responsibilities

PackageContentsRuntime deps
@rasd/storageStorageAdapter and repo interfaces, row types, RasdError storage codes, createMemoryStorage(), migrations runner (runMigrations), envelope encryption helpers (createCipher, wrapKey, unwrapKey, deriveKeyFromPassphrase), canonical JSON + SHA-256 helpers, export/import codec (packExport / unpackExport), conformance test suite (@rasd/storage/conformance)@rasd/core, @noble/hashes, @noble/ciphers (RN fallback cipher only; tree-shaken on web)
@rasd/storage-dexiecreateDexieStorage(), listNamespaces(); Dexie schema and upgraders; Blob store; quota/persistence helpers; BroadcastChannel change fan-out; optional search worker (@rasd/storage-dexie/worker)@rasd/storage, dexie ^4.4 (peer)
@rasd/storage-sqlitecreateSqliteStorage(); drivers expo and op (+ better-sqlite3 under @rasd/storage-sqlite/node for tests); DDL, pragmas, prepared statements; file-based attachment store; secureStoreKeyProvider(); Expo config plugin for backup exclusion@rasd/storage; peers expo-sqlite or @op-engineering/op-sqlite, plus expo-file-system and expo-secure-store (optional) — Expo SDK ≥ 54 per the spine's support matrix (00 §12)

The exact factory and helper signatures are owned by 17 · API reference §storage; this document is the normative behaviour behind them.

@rasd/storage never imports @rasd/license: storage cannot be gated, which is what makes export "always available" a property rather than a promise. Facts on the underlying engines (Dexie 4.4.x; expo-sqlite 57.x with the useSQLCipher config plugin, sessions/changesets and kv-store; op-sqlite 18.x with SQLCipher, FTS5 and JSONB; deprecated react-native-quick-sqlite; unmaintained WatermelonDB/Realm sync) come from research/04 and research/12 §9. Those are the versions verified at research time, not the supported floor: the peer range follows the spine's Expo SDK ≥ 54, and the adapter feature-detects (SQLCipher, FTS5, JSONB, session API) rather than gating on a version.

2. Data model

erDiagram
FORMS ||--o{ SUBMISSIONS : "formId+formVersion"
SUBMISSIONS ||--o{ ATTACHMENTS : "submissionId"
SUBMISSIONS ||--o{ OUTBOX : "targetId (kind=submission)"
ATTACHMENTS ||--o{ OUTBOX : "targetId (kind=attachment)"
DATASETS }o--o{ FORMS : "referenced by choiceLists"
FORMS {
string id PK
string version PK
string definitionHash
string title
string updatedAt
blob definition "encrypted payload"
}
SUBMISSIONS {
string id PK "UUID v7"
string formId
string formVersion
string status
string instanceName
int clientRev
string createdAt
string updatedAt
string syncedAt
string checksum
blob body "encrypted: data, meta, attachments, audit, ext"
}
ATTACHMENTS {
string id PK "UUID v7"
string submissionId
string field
string mime
int bytes
string sha256
string status
string localUri "web: Blob row / native: relative path"
string remoteId
}
DATASETS {
string name PK
string key PK
string labelNorm
string updatedAt
bool deleted
blob row "encrypted"
}
OUTBOX {
string id PK
string kind
string targetId
string status
int attempts
string nextAttemptAt
blob payload "encrypted"
}
KV {
string key PK
blob value "encrypted"
}

Rules that hold in every adapter:

  • Cleartext columns are exactly the ones drawn without "encrypted": ids, formId, formVersion, definitionHash, status, timestamps, clientRev, checksum, instanceName, attachment metadata, dataset key, labelNorm and filter-key values, outbox scheduling columns, kv keys. Everything else lives in an encrypted payload column, so list/count/sort queries never decrypt; opening a submission decrypts one row.
  • instanceName is cleartext so the "My submissions" list renders without decrypting 1 000 rows; open({ cleartextInstanceName: false }) moves it into body for forms whose name embeds PII (lists then show the id).
  • The Submission JSON from 00 §6 is the unit of exchange; the adapter splits it into columns on write and reassembles it on read. Element name is the data key (00 §4.3b), so nothing in storage depends on page/group paths.

3. The StorageAdapter contract

The spine (00 §7) fixes the shape; this section is the full signature set. All methods reject with RasdError (never raw driver errors), accept an optional trailing { signal?: AbortSignal } (omitted below for brevity), and are safe to call concurrently — the adapter serialises writes per row.

import type { FormDefinition, Submission, SubmissionStatus, AttachmentStatus, AuditEvent, RasdError } from '@rasd/core';

export type CryptoKeyLike =
| CryptoKey // web: AES-GCM 256, may be a wrapping key (see §8)
| string // native: 64 hex chars = 256-bit SQLCipher raw key
| { unlock(): Promise<CryptoKey | string> }; // lazy provider (biometric / passphrase prompt)

export interface OpenOptions {
namespace: string; // ^[a-z0-9][a-z0-9._-]{0,63}$ ; one database per namespace
// the factory options are defaults and open() merges over them,
// so `namespace` may come from either ([17 §storage](17-api-reference.md))
encryptionKey?: CryptoKeyLike; // absent ⇒ unencrypted + console.warn (web) / onSecureStoreUnavailable (native)
migrations?: Migration[]; // host-owned extra steps; may only create tables prefixed `x_`
cleartextInstanceName?: boolean; // default true
onBlocked?: (info: { reason: 'upgrade' | 'versionchange'; otherTabs: boolean }) => void;
}

export interface StorageAdapter {
readonly kind: 'dexie' | 'sqlite' | 'memory' | (string & {});
readonly state: 'closed' | 'opening' | 'open' | 'blocked' | 'error';
open(opts: OpenOptions): Promise<void>; // idempotent; runs migrations; resolves when usable
close(): Promise<void>; // flushes, releases locks/handles; safe to call twice
forms: FormRepo;
submissions: SubmissionRepo;
attachments: BlobStore;
datasets: DatasetRepo;
outbox: OutboxQueue;
kv: KeyValueStore;
transaction<T>(scope: StorageScope[], fn: (tx: StorageTx) => Promise<T>): Promise<T>;
estimate(): Promise<RasdStorageEstimate>;
export(opts?: ExportOptions): AsyncIterable<ExportChunk>;
import(chunks: AsyncIterable<ExportChunk>, opts?: ImportOptions): Promise<ImportReport>;
wipe(): Promise<void>;
on(event: 'change' | 'blocked' | 'quota' | 'error' | 'migration', h: (e: RasdStorageEvent) => void): () => void;
securityReport(): StorageSecurityReport;
}
export type StorageScope = 'forms' | 'submissions' | 'attachments' | 'datasets' | 'outbox' | 'kv';
export type StorageTx = Pick<StorageAdapter, StorageScope>; // same repos, bound to the transaction

// Names are prefixed because `StorageEstimate` and `StorageEvent` are lib.dom globals; an unprefixed
// declaration would silently resolve to the DOM type in any package that includes the DOM lib.
export interface RasdStorageEstimate {
usageBytes: number; quotaBytes: number | null; persisted: boolean | null;
details?: { attachmentsBytes: number; submissionsCount: number };
}
export type RasdStorageEvent =
| { type: 'change'; table: StorageScope; ids: string[]; source: 'local' | 'other-tab' | 'sync' }
| { type: 'blocked'; reason: 'upgrade' | 'versionchange'; otherTabs: boolean }
| { type: 'quota'; level: 'warning' | 'critical'; usageBytes: number; quotaBytes: number | null }
| { type: 'error'; error: RasdError }
| { type: 'migration'; id: string; phase: 'start' | 'progress' | 'done'; done: number; total: number | null }; // §6, determinate progress for the host banner
export interface StorageSecurityReport {
encryption: 'sqlcipher' | 'aes-gcm' | 'field' | 'none';
keyStore: string; persisted: boolean | null; backupExcluded: boolean | null;
ephemeral: boolean; notices: string[]; // aggregated by the renderer's securityReport() ([16 §7](16-security-and-data-protection.md))
}

export interface FormRepo {
get(id: string, version?: string): Promise<StoredForm | undefined>; // latest when version omitted
listLatest(): Promise<FormSummary[]>; // one row per id, highest version
listVersions(id: string): Promise<FormSummary[]>; // ascending by version
put(def: FormDefinition, meta?: { publishedAt?: string; jws?: string }): Promise<StoredForm>; // computes definitionHash; idempotent on same hash; RASD_STORAGE_INTEGRITY if same (id,version) with a different hash
delete(id: string, version: string): Promise<void>; // RASD_STORAGE_LOCKED if a draft/outbox item references it
prune(policy?: { keepLast?: number }): Promise<{ deleted: number }>; // default keepLast 3; never deletes referenced versions
}

export interface SubmissionRepo {
get(id: string): Promise<Submission | undefined>;
put(sub: Submission): Promise<void>; // full replace; sets updatedAt if missing
patch(id: string, partial: Partial<Submission>, opts?: {
bumpRev?: boolean; // clientRev + 1 (finalize, re-finalize)
expectedRev?: number; // optimistic lock ⇒ RASD_STORAGE_LOCKED on mismatch
appendAudit?: AuditEvent[]; // appended, not replaced
}): Promise<{ clientRev: number }>;
list(q: {
formId?: string; status?: SubmissionStatus | SubmissionStatus[]; updatedSince?: string;
limit?: number; // default 50, max 500
cursor?: string | null; // opaque; from previous page
order?: 'updatedAt' | 'createdAt'; direction?: 'asc' | 'desc'; // default updatedAt desc
select?: 'summary' | 'full'; // summary = cleartext columns only, no decryption (default)
}): Promise<{ items: SubmissionSummary[] | Submission[]; cursor: string | null }>;
count(q?: { formId?: string; status?: SubmissionStatus | SubmissionStatus[] }): Promise<number>;
countByStatus(formId?: string): Promise<Record<SubmissionStatus, number>>;
delete(id: string): Promise<void>; // cascades attachments + outbox rows
}

export interface BlobStore {
put(id: string, data: Blob | Uint8Array | { uri: string }, meta: {
submissionId: string; field: string; mime: string; name?: string; sha256?: string;
}): Promise<AttachmentRow>; // computes bytes + sha256; verifies meta.sha256 if given (RASD_STORAGE_INTEGRITY)
get(id: string): Promise<Blob | Uint8Array | undefined>; // decrypted bytes
getUri(id: string): Promise<string | undefined>; // web: blob: URL (caller revokes); native: file:// URI
patch(id: string, meta: Partial<Pick<AttachmentRow, 'status' | 'remoteId' | 'uploadUrl' | 'uploadOffset'>>): Promise<void>;
delete(id: string): Promise<void>;
sizeOf(id?: string): Promise<number>; // one blob, or total bytes when omitted
listByStatus(status: AttachmentStatus | AttachmentStatus[], opts?: { limit?: number }): Promise<AttachmentRow[]>;
gc(): Promise<{ deleted: number; freedBytes: number }>; // orphans + purged-after-sync; never touches ids prefixed 'basemap:'
}

export interface DatasetRepo {
putRows(name: string, rows: DatasetRow[], opts: { since: string; keyField: string; indexColumns?: string[]; replace?: boolean }): Promise<void>;
query(name: string, q: { filter?: Record<string, string>; search?: string; limit?: number; cursor?: string | null }): Promise<{ rows: DatasetRow[]; cursor: string | null }>;
meta(name: string): Promise<DatasetMeta | undefined>; // { name, rowCount, cursor, updatedAt, keyField, indexColumns, hash? }
drop(name: string): Promise<void>;
}

export interface OutboxQueue {
enqueue(op: { kind: 'submission' | 'attachment' | 'record'; targetId: string; formId?: string; priority?: number; payload?: unknown }): Promise<string>;
peek(n: number, opts?: { kinds?: string[]; leaseMs?: number }): Promise<OutboxOp[]>; // due (nextAttemptAt ≤ now), FIFO by priority desc, createdAt asc
ack(ids: string[]): Promise<void>; // removes rows
fail(id: string, err: { code: string; message: string; terminal?: boolean }, nextAttemptAt: string): Promise<void>; // attempts++, status 'pending' | 'dead'
retry(ids: string[]): Promise<void>; // dead ⇒ pending, attempts = 0
size(opts?: { includeDead?: boolean }): Promise<number>;
dead(): Promise<OutboxOp[]>;
}

export interface KeyValueStore {
get<T = unknown>(key: string): Promise<T | undefined>;
set<T = unknown>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
keys(prefix?: string): Promise<string[]>;
}

Semantics worth stating explicitly:

  • open() resolves the key (§8), opens/creates rasd__<namespace>, runs migrations (§6), requests persistence (§4.3) and starts the change channel. It is idempotent; open() with a different namespace throws RASD_STORAGE_NOT_OPEN (close first), as does every other method before open() resolves.
  • submissions.patch is the autosave primitive: data is replaced whole (the engine owns full state), appendAudit avoids rewriting a growing array, expectedRev is the per-submission optimistic lock from 03 §12, bumpRev is used only on finalize/re-finalize.
  • attachments.put with { uri } (native) moves the file into the namespace directory atomically (.tmp + rename); with a Blob (web) it stores the encrypted blob row. It never mutates submission.attachments[] — the renderer does that in the same transaction.
  • Reserved attachment-id prefix basemap:. Ids beginning basemap: are host assets, not submission attachments: the parts of an offline map archive, written as basemap:<name>#<n> with their manifest in kv (14 §4.4). They carry the reserved sentinel submissionId '$asset', so they have no owning submission; gc() therefore never deletes them (§4.2, 00 §7) and sync never uploads them (10 §4.3). Only an explicit host action ("delete offline map") removes them, via attachments.delete(id) per part. Their bytes are counted by sizeOf() and by estimate() (details.attachmentsBytes), because they consume the same quota as everything else. No other producer may use the basemap: prefix or the $asset sentinel.
  • outbox.peek with leaseMs marks rows leased until leasedUntil; expired leases become visible again. Leasing is optional (the sync engine is a single leader, 00 §8) but makes crash recovery explicit.
  • transaction hands the callback repos bound to one engine transaction. The callback may await only storage calls — an awaited fetch inside a Dexie transaction lets IndexedDB auto-commit and the next call fails with RASD_STORAGE_LOCKED (cause: PrematureCommitError). Nested calls reuse the outer transaction when scopes are a subset, else throw; a 5 s watchdog aborts.
  • on('change') emits { table, ids, source: 'local' | 'other-tab' | 'sync' }, fed on web by BroadcastChannel('rasd:' + namespace); useSubmission/useSync subscribe to it.

3.1 Storage error codes

CodeWhenretryable
RASD_STORAGE_QUOTAQuotaExceededError (web), SQLITE_FULL (native), or estimate() headroom < 20 MB on a writetrue (after freeing space)
RASD_STORAGE_LOCKEDstale expectedRev; SQLITE_BUSY after busy_timeout; premature commit; deleting a referenced formtrue
RASD_STORAGE_MIGRATIONa migration failed or its checksum mismatchedfalse
RASD_STORAGE_DOWNGRADEdatabase version newer than this build knowsfalse
RASD_STORAGE_KEY_UNAVAILABLEkey missing/undecryptable (Keystore reset, wrong host key, restored backup)false
RASD_STORAGE_CORRUPTSQLITE_CORRUPT/SQLITE_NOTADB; IndexedDB UnknownError on open; AES-GCM tag failurefalse
RASD_STORAGE_INTEGRITYchecksum/sha256 mismatch on put/import; conflicting definitionHashfalse
RASD_STORAGE_NOT_OPENcall before open() / after close() / during blocked; re-open() with a different namespace (details.reason: 'namespace-mismatch')false
RASD_STORAGE_UNAVAILABLEno IndexedDB (feature-detect failed), private-mode refusal, no SQLite driverfalse

4. Web adapter: @rasd/storage-dexie

4.1 Dexie 4 schema

Dexie versions are declared for every historical version with an upgrader (research/12 §9); the Migration[] list in @rasd/storage-dexie/migrations is the single source and createDexieStorage folds it into db.version(n).stores().upgrade() calls.

db.version(1).stores({
forms: '[id+version], id, updatedAt, definitionHash',
submissions: 'id, [formId+status+updatedAt], [formId+status], [status+updatedAt], updatedAt, createdAt, syncedAt',
attachments: 'id, submissionId, [status+createdAt], sha256',
datasets: '[name+key], name, [name+labelNorm], *nk, [name+updatedAt]',
outbox: 'id, [status+nextAttemptAt+createdAt], [kind+status], targetId',
kv: 'key',
_rasd_migrations: 'id',
_rasd_keys: 'kid',
});
  • [formId+status+updatedAt] serves list({ formId, status, updatedSince }) as one range: where('[formId+status+updatedAt]').between([f, s, since], [f, s, Dexie.maxKey]); count uses the same index; countByStatus runs one count() per status (7 small range counts, ≤ 5 ms total at 10 k rows).
  • nk is a multiEntry index of strings "<name>\0<filterKey>=<value>", which is how per-dataset filterKeys are indexed without per-dataset schema (IndexedDB indexes are static). [name+labelNorm] gives prefix search; substring/word search over the filtered set runs in memory below 10 k rows and in the @rasd/storage-dexie/worker above it (03 §11).
  • Encrypted payloads are Uint8Array properties (body, row, definition, payload, value) and are never indexed; Dexie's own guidance is that indexed binaries make apps "slower and finally crash", and indexed values should stay under ~2 000 bytes (research/04 §1.1).
  • The database name is rasd__<namespace>; listNamespaces() wraps Dexie.getDatabaseNames().

4.2 Attachment blobs

Blobs are stored as Blob values in the attachments table (Dexie stores them natively; no base64), encrypted when a key is present (§8). Design decisions:

QuestionDecision
Blob rows vs OPFSBlob rows by default. OPFS is unavailable in Safari private mode, closes handles when a tab is suspended or a WKWebView backgrounds, and cannot be opened from a SharedWorker (research/04 §1.2); an opfs blob store is an optional adapter (createDexieStorage({ blobs: 'opfs' })) for hosts that store video.
Size limitsPer attachment props.maxBytes (renderer-enforced), 25 MB hard cap in put (RASD_STORAGE_QUOTA with details.reason: 'attachmentTooLarge'), 10 MB total per submission by default (06 §12); every ceiling here is normative only in 00 §7.1, which also fixes the native single-file cap, the video defaults and the server-side cap — this table restates the two web numbers and adds no others. Hosts that must store larger single blobs on web switch to createDexieStorage({ blobs: 'opfs' }). Video ≥ 8 MiB is hashed incrementally in 1 MiB slices so it never needs a contiguous ArrayBuffer.
Read pathget() returns a decrypted Blob; getUri() returns URL.createObjectURL(blob) and the caller revokes it (the renderer does on unmount). Thumbnails are separate attachments (field: '<name>#thumb') so lists never decrypt a full photo.
Cleanupgc() deletes (a) blobs whose submissionId no longer exists, (b) blobs of uploaded attachments past retention.purgeSyncedAfterDays (default 7, 10 §8) — the metadata row stays, only localUri is cleared, (c) .tmp residue. Rows whose id starts with the reserved prefix basemap: are skipped by every gc() rule (00 §7): offline map tiles are host assets belonging to no submission, so rule (a) must not treat them as orphans (14 §4.4). Runs after each successful sync and at open(), capped at 200 deletions per pass.

The four values are the spine's AttachmentStatuspending, uploading, uploaded, failed (00 §6, 17 §types). Purging after sync is not a fifth status: it frees the bytes and clears localUri while the row stays uploaded.

stateDiagram-v2
[*] --> pending: attachments.put
pending --> uploading: sync leases the outbox op
uploading --> uploaded: tus complete, server echoes sha256
uploading --> failed: terminal error
failed --> pending: outbox.retry
uploaded --> uploaded: gc frees bytes after ACK, localUri cleared, status unchanged
pending --> [*]: submission deleted or retake, gc removes the blob

4.3 Quotas, persistence and eviction

Facts from research/04 §1.3 and research/05 §6: Chromium grants up to 60 % of disk per origin and evicts best-effort origins LRU past 80 % disk use; Firefox best-effort is min(10 % of disk, 10 GiB) and prompts for persistent; Safari 17+ gives ~60 % per origin in the browser, ~15 % inside third-party WKWebViews and 10 % of the parent's quota to cross-origin frames; Safari's ITP deletes all script-writable storage after 7 days of Safari use without interaction unless the app is on the Home Screen. Eviction is whole-origin and not a secure erase.

The adapter therefore:

  1. Calls navigator.storage.persist() in open() when the document is visible and again after the first successful sync (storage.requestPersistence()); the result is kept in kv (rasd:persisted) and exposed by estimate().persisted. Chromium and Safari decide silently by heuristics; Firefox prompts, so call it from a user-gesture path where possible.
  2. Exposes estimate() = { usageBytes, quotaBytes, persisted, details: { attachmentsBytes, submissionsCount } } from navigator.storage.estimate() (padded, disk-size based) plus its own counters.
  3. Emits on('quota') at warning (< 100 MB or < 10 % headroom, whichever is smaller) and critical (< 20 MB); the renderer shows the persistent banner from 03 §10; at critical attachments.put refuses with RASD_STORAGE_QUOTA while submissions.patch is still attempted, so text answers are never dropped for lack of photo space.
  4. Flags ephemeral contexts heuristically (quotaBytes < 120 MB on Chromium incognito; persisted === false after a grant in Safari private) as securityReport().ephemeral; the host should push Safari users to install (11 §7).
  5. Writes a sentinel kv row at open; a database without the sentinel means eviction/reset and is reported as an error event carrying RasdError { code: 'RASD_STORAGE_CORRUPT', details: { reason: 'evicted-or-reset' } } so the host explains the loss instead of showing an empty list.

4.4 Multi-tab

Cross-tab versionchange closes the connection: the adapter sets state = 'blocked', calls onBlocked({ reason: 'versionchange' }) and rejects calls with RASD_STORAGE_NOT_OPEN until reload. Upgrades take the Web Lock rasd-storage:<namespace> so one tab migrates; Dexie blocked surfaces as onBlocked({ reason: 'upgrade', otherTabs: true }). Draft-level exclusivity (rasd-sub:<id>) is a renderer concern (06 §8).

5. Native adapter: @rasd/storage-sqlite

5.1 Drivers

expo (default)opbetter-sqlite3 (Node, tests only)
Package (driver value)expo-sqlite ('expo') — openDatabaseAsync, prepared statements, withExclusiveTransactionAsync, backupDatabaseAsync, serializeAsync, session/changeset API@op-engineering/op-sqlite ('op') — JSI, sync API, FTS5, JSONB, reactive queriesbetter-sqlite3 ('better-sqlite3', via @rasd/storage-sqlite/node)
MinimumExpo SDK ≥ 54 (spine support matrix); the APIs above are as documented for SDK 57current releasecurrent release
SQLCipherconfig plugin useSQLCipher: true; not available in Expo GoSQLCipher build flagnone
FTS5enableFTS (default true)yesyes
BlobUint8Array / blob bind parametersyesyes
Where usedExpo managed/prebuild appsbare RN or hosts needing FTS5/JSI throughputVitest conformance + migration tests

react-native-quick-sqlite is deprecated and react-native-nitro-sqlite does not document SQLCipher, so neither ships; a nitro driver can be added behind the internal SqliteDriver interface (exec, prepare(sql).run/get/all/each, transaction(mode, fn), close, serialize, backup — under 300 lines per driver) (research/04 §2).

5.2 Pragmas and DDL

-- executed by open(), in this order
PRAGMA key = "x'<64 hex>'"; -- SQLCipher raw-key mode (skips PBKDF2; the key is already random)
PRAGMA cipher_memory_security = ON; -- SQLCipher only
SELECT count(*) FROM sqlite_master; -- fails fast with SQLITE_NOTADB if the key is wrong ⇒ RASD_STORAGE_KEY_UNAVAILABLE
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; -- durable across app crash; FULL only when host sets durability: 'strict'
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA journal_size_limit = 67108864; -- 64 MiB WAL cap
PRAGMA secure_delete = ON; -- plaintext fallback only (SQLCipher pages are ciphertext anyway)

CREATE TABLE IF NOT EXISTS forms (
id TEXT NOT NULL, version TEXT NOT NULL, definition_hash TEXT NOT NULL,
title TEXT, rasd TEXT NOT NULL, published_at TEXT, updated_at TEXT NOT NULL,
jws TEXT, definition BLOB NOT NULL, -- JSON text under SQLCipher; envelope when field-level
PRIMARY KEY (id, version)
);
CREATE INDEX IF NOT EXISTS forms_updated ON forms(updated_at);

CREATE TABLE IF NOT EXISTS submissions (
id TEXT PRIMARY KEY, form_id TEXT NOT NULL, form_version TEXT NOT NULL, definition_hash TEXT,
status TEXT NOT NULL CHECK (status IN ('draft','finalized','queued','sending','synced','rejected','conflict')),
instance_name TEXT, client_rev INTEGER NOT NULL DEFAULT 0, server_rev INTEGER,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, finalized_at TEXT, synced_at TEXT,
checksum TEXT, body BLOB NOT NULL -- JSON {data, meta, attachments, audit, ext}
);
CREATE INDEX IF NOT EXISTS sub_form_status_updated ON submissions(form_id, status, updated_at);
CREATE INDEX IF NOT EXISTS sub_status_updated ON submissions(status, updated_at);
CREATE INDEX IF NOT EXISTS sub_updated ON submissions(updated_at);

CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, submission_id TEXT NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
field TEXT NOT NULL, mime TEXT NOT NULL, bytes INTEGER NOT NULL, sha256 TEXT NOT NULL,
local_path TEXT, -- relative to <documents>/rasd/<namespace>/ ; NULL after purge
status TEXT NOT NULL CHECK (status IN ('pending','uploading','uploaded','failed')),
remote_id TEXT, upload_url TEXT, upload_offset INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS att_status_created ON attachments(status, created_at);
CREATE INDEX IF NOT EXISTS att_submission ON attachments(submission_id);

CREATE TABLE IF NOT EXISTS datasets (
name TEXT NOT NULL, key TEXT NOT NULL, label_norm TEXT, updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0, row BLOB NOT NULL,
PRIMARY KEY (name, key)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ds_label ON datasets(name, label_norm);
CREATE TABLE IF NOT EXISTS dataset_fk ( -- filter-key projection maintained by putRows
name TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, key TEXT NOT NULL,
PRIMARY KEY (name, k, v, key)
) WITHOUT ROWID;
CREATE VIRTUAL TABLE IF NOT EXISTS datasets_fts USING fts5( -- created only when the driver reports FTS5
name UNINDEXED, key UNINDEXED, label_norm, tokenize = 'unicode61 remove_diacritics 2'
);

CREATE TABLE IF NOT EXISTS outbox (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, target_id TEXT NOT NULL, form_id TEXT,
status TEXT NOT NULL CHECK (status IN ('pending','leased','dead')),
priority INTEGER NOT NULL DEFAULT 0, attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT NOT NULL, leased_until TEXT, last_error TEXT,
created_at TEXT NOT NULL, payload BLOB
);
CREATE INDEX IF NOT EXISTS outbox_due ON outbox(status, next_attempt_at, priority, created_at);
CREATE INDEX IF NOT EXISTS outbox_target ON outbox(target_id);

CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value BLOB NOT NULL, updated_at TEXT NOT NULL) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS _rasd_migrations (id TEXT PRIMARY KEY, checksum TEXT NOT NULL, state TEXT NOT NULL, cursor TEXT, applied_at TEXT);
CREATE TABLE IF NOT EXISTS _rasd_keys (kid TEXT PRIMARY KEY, alg TEXT NOT NULL, wrapped BLOB, created_at TEXT NOT NULL, retired_at TEXT);
PRAGMA user_version = 1;

Design notes:

  • Index columns are maintained by the adapter, never generated from body. Under SQLCipher body is plain JSON and json_extract(body, '$.meta.geo') works for ad-hoc host queries; under the plaintext fallback only bind.sensitive values become envelope strings (§8.3), so JSON1 keeps working for everything else. Generated columns over body would break the moment a value is encrypted.
  • JSON1 is used for patch (json_set(body, '$.data', ?); json_insert(body, '$.audit[#]', json(?)) for appendAudit — no whole-row read-modify-write), for datasets.query fallbacks (json_each) and for payload-rewriting migrations. JSONB (op-sqlite, SQLite ≥ 3.45) is used for body when the driver reports it; the adapter reads both.
  • Prepared statements are cached per connection (LRU 64) — after WAL and batching the biggest win on low-end Android (research/04 §2).
  • Files live at <documents>/rasd/<namespace>/attachments/<id>.<ext> and …/thumbs/<id>.jpg, stored as relative paths (the iOS container path changes between installs), written atomically (.tmp + rename); the Expo config plugin writes Android dataExtractionRules and the bundled module sets isExcludedFromBackup on the namespace directory (07 §9, research/11 §4).
  • The SQLCipher licence text ships in the package (LICENSES/SQLCipher.txt) and is exposed via securityReport().notices — the community licence requires user-accessible attribution.

6. Migrations framework (@rasd/storage)

export interface Migration {
id: string; // "0007_outbox_add_lease" — ordered, immutable once released
target: number; // Dexie version / PRAGMA user_version after the step
checksum: string; // sha256 of the step source; mismatch ⇒ RASD_STORAGE_MIGRATION
kind: 'schema' | 'data';
up(ctx: MigrationContext): Promise<void>; // no `down`: forward-only (rule 1)
estimate?(ctx: MigrationContext): Promise<{ rows: number }>;
chunk?: { size: number; cursorKey: string }; // data steps only; default size 500
needsKeys?: boolean; // data step decrypts/re-encrypts ⇒ waits for ctx.keys
}
export interface MigrationContext {
engine: 'dexie' | 'expo-sqlite' | 'op-sqlite' | 'better-sqlite3' | 'memory';
tx: unknown; // engine transaction (schema steps)
progress(done: number, total: number): void;
checkpoint(cursor: string): Promise<void>; // persisted in _rasd_migrations.cursor
signal: AbortSignal; log: Logger; keys?: KeyProvider; // keys present only for encrypted data steps
}

Rules (from research/12 §9):

  1. Forward-only. No down; rollback is "restore backup + previous app build". A stored version above the highest known target (Dexie VersionError / user_version check) throws RASD_STORAGE_DOWNGRADE and refuses to open — export() still works through a read-only path over cleartext columns and decryptable payloads.
  2. Schema steps run inside the engine's upgrade transaction: Dexie runs upgraders sequentially in one transaction and rolls back on any error; SQLite runs BEGIN … PRAGMA user_version = n; COMMIT, using the 12-step table-rewrite procedure where ALTER TABLE cannot express the change. Never touch schema_version.
  3. Data steps run after the schema upgrade, each chunk in its own transaction, idempotent (WHERE schema_rev < n or a per-row marker), cursor checkpointed in _rasd_migrations so a crash on a low-end device resumes rather than restarts. Steps needing decryption declare needsKeys: true and wait for the key.
  4. Backup first when estimate().rows > 5 000 and free space ≥ 2 × database size: native backupDatabaseAsync to <namespace>.pre-<target>.db (deleted after success), web export() snapshot when OPFS is available; otherwise warn and proceed.
  5. Checksums. _rasd_migrations stores (id, checksum, state: 'applied' | 'running' | 'failed', cursor, applied_at); a released step whose checksum changed refuses to run (Prisma/Drizzle behaviour).
  6. Host migrations (open({ migrations })) run after Rasd's own, may only touch x_* tables, and are tracked in the same table under host: ids.
  7. Multi-process. Web: one upgrader via the Web Lock; native: migrations finish before the app shell renders (open() is awaited in the provider's storage factory).
flowchart TD
A["open opts"] --> B{"key resolvable?"}
B -- "no, encryption required" --> KE["throw RASD_STORAGE_KEY_UNAVAILABLE"]
B -- "no, none configured" --> W["warn — unencrypted"]
W --> C
B -- "yes" --> C["open db rasd__ns and verify key"]
C --> D{"stored version vs known"}
D -- "newer" --> DG["throw RASD_STORAGE_DOWNGRADE — export still works"]
D -- "equal" --> P
D -- "older" --> L["acquire Web Lock rasd-storage per namespace"]
L --> S["schema steps in upgrade tx"]
S --> Dt["data steps chunked with checkpoints"]
Dt --> P
P["persist request, sentinel, gc pass"] --> R["state open"]

Testing requirements: fixture databases for every released version (fixtures/dexie-v<n>.json via the §10 codec, fixtures/sqlite-v<n>.db); each fixture upgraded to HEAD must pass the conformance suite; plus a crash-mid-chunk test (abort at chunk 3, reopen, assert completion without duplicated work), a checksum-tamper test and a downgrade test.

7. Transactions and write batching

  • Finalize = transaction(['submissions','outbox'], tx => { tx.submissions.put(sub{status:'queued'}); tx.outbox.enqueue({ kind:'submission', targetId: sub.id, formId }) }) — one commit, so a crash cannot leave a queued submission without an outbox row (03 §5.1).
  • Autosave = single-row patch (json_set on SQLite; row rewrite on IndexedDB), debounced by the renderer to settings.autosaveMs, so storage sees ≤ 1 write per 2 s per open form. Budget: ≤ 20 ms p95 for a 50 kB draft on the reference device.
  • Sync apply = transaction(['forms','datasets','kv'], …) per pulled page (≤ 1 000 dataset rows per transaction), cursor written last in the same transaction so a crash never advances it past applied rows.
  • Bulk writes use bulkPut (Dexie) / one prepared statement inside withExclusiveTransactionAsync (SQLite) in batches of 100–500 rows; putRows and import() take this path; putRows({ replace: true }) truncates and reloads a dataset in one transaction (the "replacement" full-resync escape hatch).
  • Per-submission write queue. Writes are serialised per id (promise chain) so an autosave and a finalize on the same submission cannot interleave; different submissions proceed in parallel.
  • Read isolation. list/count run outside transactions and may see a concurrent write's before/after state but never a partial row.

8. Encryption at rest

The threat model is research/11 §2: lost/stolen or seized devices, second enumerators, backups, forensic extraction. Encryption at rest defends against disk inspection and cross-app leakage; it does not defend against a script in the same origin (web), a rooted device with a debugger attached, or a coerced unlock — the host docs must say so.

The policy key is encryption.atRest: 'off' | 'preferred' | 'required', default 'preferred' (00 §7, 16 §7.1): encrypt wherever the platform supports it and warn loudly where it does not; 'required' refuses to open an unencrypted database; 'off' is an explicit, deliberate host opt-out.

8.1 Web: AES-256-GCM via WebCrypto

  • Data key (DEK). crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']) — non-extractable, stored as a structured-clone CryptoKey in _rasd_keys (kid: 'k1', alg: 'A256GCM'); usable but never readable, even by same-origin script.
  • Wrapped mode. If the host passes an encryptionKey (a CryptoKey with wrapKey/unwrapKey usage, typically from deriveKeyFromPassphrase(passphrase, salt) in @rasd/storage — PBKDF2-HMAC-SHA-256, 600 000 iterations, 16-byte salt kept in _rasd_keys), the DEK is generated extractable, wrapped with AES-KW, stored as wrapped bytes and unwrapped at open() into a non-extractable in-memory key. At rest the DEK is only ciphertext, so browser-profile theft without the passphrase yields nothing; { unlock() } providers let the host prompt lazily.
  • Envelope. [0x01 version][kidLen][kid][12-byte IV][ciphertext‖16-byte tag], IV random per write, AAD = "<table>/<primaryKey>" so a ciphertext cannot be transplanted to another row. Payload columns are encrypted whole; index columns are not.
  • Rotation. rotateKey(newKey?) creates k2, marks k1 retired and runs a chunked data migration re-encrypting 500 rows per transaction; readers accept both kids meanwhile; k1 is deleted after the last row. Rotate on staff turnover or suspected compromise, not by calendar.
  • Unencrypted mode (encryptionKey absent) logs a red console warning once per session and sets securityReport().encryption = 'none'.

8.2 Native: SQLCipher whole-database

  • 256-bit random key from expo-crypto, hex-encoded (64 chars, far below SecureStore's ~2 KB limit), stored under rasd.dbkey.<namespace> with keychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY (never MMKV/AsyncStorage; never migrates through iCloud/Keychain backup). Optional requireAuthentication binds it to biometrics (re-enrolment invalidates it — document). SecureStore items survive reinstall on iOS, so wipe() deletes them explicitly.
  • PRAGMA key = "x'…'" raw-key mode; SQLCipher encrypts every page (AES-256-CBC + per-page HMAC-SHA-512) including WAL and journals (research/11 §4.4); PRAGMA cipher_version is checked and reported.
  • Fallbacks are explicit, never silent: no SQLCipher in the binary (Expo Go, plugin missing) ⇒ onSecureStoreUnavailable({ fallback: 'plaintext-db' }) plus field-level encryption (§8.3); no SecureStore ⇒ same event with fallback: 'refuse' unless the host sets encryption.atRest: 'off'; missing key with an existing database ⇒ RASD_STORAGE_KEY_UNAVAILABLE (never re-key, never open plaintext).

8.3 Field-level encryption for bind.sensitive

Applies when whole-database encryption is unavailable (native fallback) or as defence in depth (open({ fieldEncryption: 'sensitive' })). Values of elements with bind.sensitive: true (and consent, always) are replaced inside data, audit[].old/new and meta.instanceName by envelope strings "enc:v1:<base64url>" (AES-256-GCM) through the platform cipher adapter — WebCrypto on web; @noble/ciphers with expo-crypto randomness on RN, react-native-quick-crypto optional for throughput. Index columns and non-sensitive answers stay readable, so JSON1 and list queries are unaffected. This is distinct from the RFD's settings.encryption (end-to-end submission encryption to a server public key, 16 · Security); both can be active.

8.4 Key lifecycle

PhaseWebNative
Derive/generategenerateKey non-extractable at first open(); or unwrap with host KEK32 random bytes → hex at first open()
Persist_rasd_keys (IndexedDB)SecureStore rasd.dbkey.<namespace>
Unlockautomatic; or { unlock() } for passphraseautomatic; or requireAuthentication
RotaterotateKey() + chunked re-encryptPRAGMA rekey inside a backup-first migration; SecureStore updated after success
Wipedelete _rasd_keys row → Dexie.deleteSecureStore.deleteItemAsync → delete DB + WAL/SHM + files

8.5 What is not protected

Cleartext index columns (formId, status, timestamps, instanceName unless disabled, dataset keys/labels/filter values, attachment sizes and hashes), database and file names (rasd__<namespace> reveals the namespace), row counts and sizes, anything the host copies out (logs, crash reports, screenshots — see the redacting logger and createSentryScrubber() in 16), OS-level artefacts (keyboard cache, thumbnails), and data on rooted/jailbroken devices.

9. Integrity

  • Submission checksum — the single normative definition lives in 00 §6.1; this document does not restate the field list. In short: "sha256:" + hex(sha256(JCS(payload))) over the allowlist { id, formId, formVersion, definitionHash, data, meta, attachments:[{ id, field, mime, bytes, sha256 }] }. clientRev, finalizedAt (covered inside meta), status, serverRev, audit and updatedAt are excluded so the hash survives the sync lifecycle unchanged. Computed by engine.finalize(), stored in the cleartext checksum column, verified by the sync engine before push and by the server (echoed in the ack); a mismatch on read (bit-rot, tampering) raises RASD_STORAGE_INTEGRITY and the item is surfaced, never silently resent.
  • Attachment sha256 is computed by attachments.put and stored cleartext, sent as tus Upload-Metadata / Upload-Checksum and re-verified before the upload starts. crypto.subtle.digest needs the whole buffer in memory, so blobs ≥ 8 MiB are hashed incrementally instead: 1 MiB slices fed to the streaming @noble/hashes sha256 in a Web Worker on web, and to the driver's streaming digest on native. A contiguous ArrayBuffer is never allocated for a video.
  • Definitions store definitionHash and optional jws; forms.put recomputes the hash and refuses a conflicting hash for an existing (id, version) — published versions are immutable (00 §4.3b).
  • Datasets carry a per-page hash in meta; a full resync compares row counts and hashes.

10. Export and import

export() is a pull-based AsyncIterable<ExportChunk> so a 500 MB dataset streams with < 50 MB heap:

type ExportChunk =
| { kind: 'manifest'; rasd: '1.0'; storageVersion: number; namespace: string; exportedAt: string; tables: string[]; encryptedTo?: string }
| { kind: 'row'; table: StorageScope | 'x_*'; row: Record<string, unknown> } // one JSONL line, payloads decrypted
| { kind: 'blob'; id: string; mime: string; sha256: string; bytes: number; offset: number; data: Uint8Array | Blob; last: boolean }
| { kind: 'end'; counts: Record<string, number>; sha256: string }; // hash over all JSONL lines
interface ExportOptions { tables?: StorageScope[]; formId?: string; status?: SubmissionStatus[]; since?: string; includeBlobs?: boolean; encryptTo?: CryptoKeyLike | { passphrase: string } }
  • packExport(storage.export(opts), sink) in @rasd/storage writes the container: manifest.json, <table>.jsonl, attachments/<id> inside a ZIP (.rasdx); default is encrypted-only (encryptTo required; AES-256-GCM per entry with a passphrase-derived or recipient key, per research/11 §7); allowPlaintext: true is an explicit host opt-in that emits an audit event.
  • Availability guarantee. @rasd/storage has no license dependency and export() reads only through the adapter's own repos, so it works in every license state of 00 §9 — including limited under both soft and hard enforcement, and invalid — as well as on a RASD_STORAGE_DOWNGRADE database and offline. rasd doctor and the host's "Export data" screen call the same code.
  • import(chunks, { mode: 'merge' | 'replace', onConflict: 'keep-higher-rev' | 'keep-local' | 'keep-import' }) verifies manifest, per-row checksums and blob hashes (RASD_STORAGE_INTEGRITY on mismatch), writes in 200-row transactions, never overwrites a local finalized|queued|sending submission with a draft, re-creates outbox rows for imported queued items and returns { imported, skipped, conflicts, bytes }. Use cases: device replacement, supervisor consolidation, forensic recovery.

11. Wipe and remote wipe

wipe() order: (1) close() engines and abort in-flight transactions; (2) destroy keys — delete _rasd_keys rows / SecureStore.deleteItemAsync('rasd.dbkey.<namespace>') (crypto-shredding: everything else is now ciphertext); (3) plaintext-fallback databases run PRAGMA secure_delete = ON + DELETE + VACUUM before file deletion; (4) delete the database (Dexie.delete('rasd__<ns>') / deleteDatabaseAsync + -wal/-shm), the attachment or OPFS directory and .tmp files; (5) resolve — the caller owns the onRemoteWipe/onLocalWipeRequested UX. wipe() never checks unsynced counts itself: the sync engine computes unsyncedCount and the host must confirm local wipes in two steps; a remote wipe (X-Rasd-Wipe: <nonce> header or policy.wipeAt from POST /v1/devices) is validated against the host token by @rasd/sync before it calls storage.wipe() (10 §7, research/11 §9). Server-side Clear-Site-Data: "storage" on logout is the recommended web backstop.

12. Multi-user devices

  • Namespace per user: namespace = <orgSlug>.<userId> (e.g. wfp-jo.u_4821) ⇒ separate database, key and file directory; switching users is close() + open(); listNamespaces() enumerates them for a "who is logged in on this device" screen. A host logout() never wipes a namespace with unsynced items — it only closes it.
  • Admin-locked shared devices (ODK-style access control) are a host concern; the adapter contributes securityReport(), per-namespace estimate() and per-namespace export() for supervisor consolidation. Android Enterprise ephemeral users and lock-task mode are documented options (research/11 §8).
  • Two <RasdProvider> instances with different namespaces are fully isolated on one origin/app (partner-agency mode, 06 §2); Web Lock and BroadcastChannel names include the namespace.

13. MemoryStorage and testing

createMemoryStorage({ quotaBytes?, latencyMs?, faults? }) implements the full contract with Maps and structured-clone snapshots, honours transactions (copy-on-write, rollback on throw), simulates quota (RASD_STORAGE_QUOTA above quotaBytes) and is what <RasdProvider> uses when no storage is passed (dev warning). @rasd/testing's fakeStorage() wraps it with fault injection — failNext('submissions.patch', new RasdError('RASD_STORAGE_QUOTA')), delay('attachments.put', 800), disconnect().

The conformance suite (runConformanceSuite(factory, opts?) from @rasd/storage/conformance — the name is owned by 17 §storage; framework-agnostic, ~180 cases) runs against:

AdapterRunnerEngine
memoryVitest 4 (node)
dexieVitest 4 with fake-indexeddb/auto (fast path); Vitest browser mode + Playwright on Chromium/Firefox/WebKit incl. an offline and a private-context projectreal IndexedDB
sqliteVitest 4 with @rasd/storage-sqlite/node (driver: 'better-sqlite3', plaintext + field-level path)bundled SQLite ≥ 3.45 (JSONB)
sqlite (expo/op)Jest + RNTL 14 for wiring; Maestro smoke on Android emulator (2 GB RAM profile) and iOS simulator, incl. useSQLCipher buildson-device

Additional suites: migration fixtures (§6), encryption round-trips incl. wrong-key and AAD-transplant negatives, export→import round-trip equality (deepEqual after normalising updatedAt), fuzzed concurrent patch with expectedRev, and the backup-restore test (bmgr backupnow on the emulator → reinstall → assert no database) from research/11 §12.

14. Performance targets and benchmarks

Reference devices: desktop Chrome (M-class laptop), Moto G-class Android (2 GB RAM, Chrome ≥ 100), iPhone SE-class. pnpm bench:storage runs the web suite headless in CI and the Node SQLite suite as an indicator; a nightly job runs Maestro-driven benchmarks on the Android emulator profile; results are published on the docs compatibility page and a > 20 % regression fails the nightly.

BenchmarkDexie (desktop)Dexie (Moto G)SQLite (Android 2 GB)
open() cold, no migration≤ 150 ms≤ 300 ms≤ 300 ms (incl. key + pragmas)
Write 1 000 submissions (5 kB data, 20 audit events, encrypted), batches of 100≤ 3 s≤ 10 s≤ 2 s
Autosave patch (50 kB draft) p95≤ 10 ms≤ 20 ms≤ 15 ms
list({ formId, status }) page of 50 at 10 k rows≤ 30 ms≤ 60 ms≤ 20 ms
countByStatus at 10 k rows≤ 20 ms≤ 40 ms≤ 10 ms
Store 50 MB of photos (200 × 250 kB) incl. sha256 + encryption≤ 8 s≤ 15 s≤ 10 s
getUri thumbnail≤ 15 ms≤ 30 ms≤ 5 ms
datasets.query prefix search, 50 k rows≤ 50 ms≤ 100 ms≤ 30 ms
export() 10 k submissions + 500 MB blobsstreams, heap < 50 MBstreams
Migration: data step over 50 k rows≤ 60 s, resumable≤ 180 s≤ 45 s

Micro-facts from research/04 §1.2 that set expectations: IndexedDB small writes ≈ 0.17 ms vs OPFS-worker ≈ 1.5 ms and WASM-SQLite-on-IndexedDB ≈ 3 ms; IndexedDB init ≈ 46 ms vs WASM SQLite ≈ 535 ms — hence Dexie by default and no WASM SQLite in the runner bundle. Bundle budgets: @rasd/storage ≤ 8 kB, @rasd/storage-dexie ≤ 12 kB own code (Dexie is a peer and counted separately), @rasd/storage-sqlite ≤ 15 kB (min+gzip, size-limit in CI).

15. Failure modes

FailureDetectionBehaviour
Quota exceededQuotaExceededError / SQLITE_FULL; estimate() thresholdsRASD_STORAGE_QUOTA; renderer keeps the draft in memory and retries; attachments.put refused first; on('quota') for the banner; suggest sync + gc()
Corrupted databaseSQLITE_CORRUPT/NOTADB; IndexedDB UnknownError; AES tag failure on many rowsRASD_STORAGE_CORRUPT; refuse to open normally; offer export() salvage (skips undecodable rows, reports them), then wipe() + resync (putRows({ replace }), forms pull)
Version downgrade (old build opens new DB)Dexie VersionError / user_version > knownRASD_STORAGE_DOWNGRADE; read-only export() path; host prompts to update the app
Private browsing / ephemeralquota heuristics; Safari persisted === false after grantsecurityReport().ephemeral; banner "data may be lost when you close the browser"; encourage install
Storage evicted / resetsentinel kv row missingerror event with details.reason: 'evicted-or-reset'; host explains; sync engine re-registers device
Key unavailable (Keystore reset, wrong KEK, backup restored DB without key)key verify at openRASD_STORAGE_KEY_UNAVAILABLE; never open plaintext, never re-key; host's onStorageError fallback; export impossible by design
Multi-tab upgrade blockedDexie blockedstate = 'blocked', onBlocked, retry every 2 s for 30 s, then RASD_STORAGE_NOT_OPEN
SQLITE_BUSY beyond 5 sdriver errorRASD_STORAGE_LOCKED (retryable); usually a host holding its own connection open in a transaction
App killed mid-transactionjournalingWAL/IndexedDB atomicity; nothing to do; gc() removes .tmp files at open
SecureStore/SQLCipher unavailableprobe at openonSecureStoreUnavailable; field-level fallback or refuse per policy; securityReport() reflects it
Disk-full during migrationSQLITE_FULL in a chunkchunk rolls back, cursor preserved, RASD_STORAGE_MIGRATION with retryable: true; resumes on next open

15.1 Surfacing storage state accessibly

Storage has no UI of its own, but it is the source of the most consequential messages a field worker ever sees ("your photo was not saved"), so the contract carries accessibility obligations for whoever renders them (13 · i18n, RTL & accessibility):

  • Every event and error the adapter emits carries a stable code plus structured detailsnever a pre-formatted English sentence. The renderer supplies the localized, RTL-safe string, so nothing here blocks translation or forces LTR text into an Arabic UI.
  • The quota banner is a persistent role="status" region (aria-live="polite" at warning, assertive at critical, RN AccessibilityInfo.announceForAccessibility), never a toast that disappears before a one-handed user reads it, and never colour-only: the warning/critical distinction must also be carried by text and icon.
  • blocked ("close the other tabs") and RASD_STORAGE_DOWNGRADE ("update the app") are recoverable dead-ends, so the host must move focus to the explanation and keep a keyboard/screen-reader-reachable retry control; the adapter's 2 s retry loop exists so that closing the other tab resolves the state without a reload.
  • Long-running storage work — migrations, export(), import(), key rotation — must expose determinate progress from progress(done, total) and the export counts, announced at intervals rather than per row, and must remain cancellable via the AbortSignal every method accepts.
  • The two-step local-wipe confirmation (§11) states the unsyncedCount in text; destructive confirmation must not rely on a colour-coded button alone.
  • Numbers in these messages (byte counts, row counts, days) go through the locale's numbering setting from 00 §4.3a like any other figure in the UI.

16. Acceptance criteria

  • All three adapters pass the conformance suite; kind, state, every repo method and every error code above behave as specified.
  • Finalize writes submission + outbox row atomically; killing the process between the two is impossible to observe (fault-injection test).
  • patch with a stale expectedRev fails with RASD_STORAGE_LOCKED and leaves the row unchanged.
  • Web: payload columns are Uint8Array envelopes; no index contains a blob or a value > 2 000 bytes; the DEK is non-extractable (exportKey throws); AAD transplant test fails to decrypt.
  • Native: with useSQLCipher, PRAGMA cipher_version is non-empty and the file starts with random bytes, not SQLite format 3; without it, sensitive values in body are enc:v1: envelopes and secure_delete is ON.
  • estimate() returns sane numbers on Chrome, Firefox, Safari and native; persist() is requested and its result recorded; quota events fire at 100 MB/10 % and 20 MB.
  • Every released schema fixture upgrades to HEAD; a mid-chunk crash resumes; a tampered checksum refuses; a downgrade refuses to open but export() succeeds.
  • export()import() round-trip is lossless (rows, blobs, hashes) and works in the limited license state and offline; the container is encrypted unless allowPlaintext is set.
  • wipe() deletes key, database, WAL/SHM and files; on iOS the SecureStore item is gone after reinstall; the Android backup-restore test finds no database.
  • Benchmarks in §14 pass on the reference devices; size-limit budgets hold.
  • securityReport() correctly reports encryption mode, key store, persistence, backup exclusion, ephemeral context and notices in each topology, and its shape matches the aggregate in 16 §7.
  • Vocabulary conformance: stored submissions.status values are exactly the seven of 00 §6 and attachments.status exactly the four — a type test plus the SQLite CHECK constraints prove no adapter invents a state (purge-after-sync leaves the row uploaded).
  • Accessibility (§15.1): no adapter error or event carries a pre-formatted user-facing sentence; vitest-axe on the host banner fixtures passes; the quota banner announces politely at warning and assertively at critical; blocked and downgrade states keep a reachable retry control; migration/export progress is determinate and abortable.

Open questions

  • Should instanceName default to cleartext (fast lists) or encrypted (safer for PII-bearing names)? Current default is cleartext with a per-form opt-out; a form-level flag in the RFD (settings.instanceNameSensitive) may be cleaner.
  • Does the 120 kB form-runner budget in 00 §12 include dexie itself? This document budgets only Rasd's own code in @rasd/storage-dexie.
  • Should datasets flagged as PII get a blinded (HMAC) prefix index instead of cleartext labelNorm, at the cost of substring search?
  • Is a nitro-sqlite driver worth shipping at launch given the missing SQLCipher story, or only after react-native-nitro-sqlite documents encryption?
  • Do we ship the OPFS blob store in v1 or defer until a host needs video capture?
  • Where do host-owned x_* tables sit in export/import and wipe (included by default here) — should hosts be able to exclude them?

00 · Decisions & conventions · 03 · Architecture · 04 · Form schema spec · 06 · Renderer React · 07 · Renderer native · 08 · Builder · 10 · Sync protocol · 11 · PWA & embedding · 13 · i18n, RTL & accessibility · 14 · Media & field capture · 15 · Licensing & billing · 16 · Security & data protection · 17 · API reference · 18 · Engineering practices · 20 · Interoperability