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 viaexpo-sqlite(default) orop-sqlite(native). Renderers and@rasd/synconly ever seeStorageAdapter; no SQL or Dexie type leaks upward. - Every adapter keeps the same six logical tables —
forms,submissions,attachments,datasets,outbox,kv— plus private_rasd_migrationsand_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
patchwith optimisticclientRevlocking; attachment bytes live outside the indexed columns (Blob rows on web, files on native) and carry a cleartextsha256for 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 forbind.sensitivewhen 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, autosavepatch≤ 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
| Package | Contents | Runtime deps |
|---|---|---|
@rasd/storage | StorageAdapter 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-dexie | createDexieStorage(), 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-sqlite | createSqliteStorage(); 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, datasetkey,labelNormand 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. instanceNameis cleartext so the "My submissions" list renders without decrypting 1 000 rows;open({ cleartextInstanceName: false })moves it intobodyfor forms whose name embeds PII (lists then show the id).- The
SubmissionJSON from 00 §6 is the unit of exchange; the adapter splits it into columns on write and reassembles it on read. Elementnameis 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/createsrasd__<namespace>, runs migrations (§6), requests persistence (§4.3) and starts the change channel. It is idempotent;open()with a differentnamespacethrowsRASD_STORAGE_NOT_OPEN(close first), as does every other method beforeopen()resolves.submissions.patchis the autosave primitive:datais replaced whole (the engine owns full state),appendAuditavoids rewriting a growing array,expectedRevis the per-submission optimistic lock from 03 §12,bumpRevis used only on finalize/re-finalize.attachments.putwith{ uri }(native) moves the file into the namespace directory atomically (.tmp+ rename); with aBlob(web) it stores the encrypted blob row. It never mutatessubmission.attachments[]— the renderer does that in the same transaction.- Reserved attachment-id prefix
basemap:. Ids beginningbasemap:are host assets, not submission attachments: the parts of an offline map archive, written asbasemap:<name>#<n>with their manifest inkv(14 §4.4). They carry the reserved sentinelsubmissionId'$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, viaattachments.delete(id)per part. Their bytes are counted bysizeOf()and byestimate()(details.attachmentsBytes), because they consume the same quota as everything else. No other producer may use thebasemap:prefix or the$assetsentinel. outbox.peekwithleaseMsmarks rowsleaseduntilleasedUntil; expired leases become visible again. Leasing is optional (the sync engine is a single leader, 00 §8) but makes crash recovery explicit.transactionhands the callback repos bound to one engine transaction. The callback may await only storage calls — an awaitedfetchinside a Dexie transaction lets IndexedDB auto-commit and the next call fails withRASD_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 byBroadcastChannel('rasd:' + namespace);useSubmission/useSyncsubscribe to it.
3.1 Storage error codes
| Code | When | retryable |
|---|---|---|
RASD_STORAGE_QUOTA | QuotaExceededError (web), SQLITE_FULL (native), or estimate() headroom < 20 MB on a write | true (after freeing space) |
RASD_STORAGE_LOCKED | stale expectedRev; SQLITE_BUSY after busy_timeout; premature commit; deleting a referenced form | true |
RASD_STORAGE_MIGRATION | a migration failed or its checksum mismatched | false |
RASD_STORAGE_DOWNGRADE | database version newer than this build knows | false |
RASD_STORAGE_KEY_UNAVAILABLE | key missing/undecryptable (Keystore reset, wrong host key, restored backup) | false |
RASD_STORAGE_CORRUPT | SQLITE_CORRUPT/SQLITE_NOTADB; IndexedDB UnknownError on open; AES-GCM tag failure | false |
RASD_STORAGE_INTEGRITY | checksum/sha256 mismatch on put/import; conflicting definitionHash | false |
RASD_STORAGE_NOT_OPEN | call before open() / after close() / during blocked; re-open() with a different namespace (details.reason: 'namespace-mismatch') | false |
RASD_STORAGE_UNAVAILABLE | no IndexedDB (feature-detect failed), private-mode refusal, no SQLite driver | false |
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]serveslist({ formId, status, updatedSince })as one range:where('[formId+status+updatedAt]').between([f, s, since], [f, s, Dexie.maxKey]);countuses the same index;countByStatusruns onecount()per status (7 small range counts, ≤ 5 ms total at 10 k rows).nkis a multiEntry index of strings"<name>\0<filterKey>=<value>", which is how per-datasetfilterKeysare 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/workerabove it (03 §11).- Encrypted payloads are
Uint8Arrayproperties (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()wrapsDexie.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:
| Question | Decision |
|---|---|
| Blob rows vs OPFS | Blob 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 limits | Per 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 path | get() 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. |
| Cleanup | gc() 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 AttachmentStatus — pending, 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:
- Calls
navigator.storage.persist()inopen()when the document is visible and again after the first successful sync (storage.requestPersistence()); the result is kept inkv(rasd:persisted) and exposed byestimate().persisted. Chromium and Safari decide silently by heuristics; Firefox prompts, so call it from a user-gesture path where possible. - Exposes
estimate()={ usageBytes, quotaBytes, persisted, details: { attachmentsBytes, submissionsCount } }fromnavigator.storage.estimate()(padded, disk-size based) plus its own counters. - 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 criticalattachments.putrefuses withRASD_STORAGE_QUOTAwhilesubmissions.patchis still attempted, so text answers are never dropped for lack of photo space. - Flags ephemeral contexts heuristically (
quotaBytes < 120 MBon Chromium incognito;persisted === falseafter a grant in Safari private) assecurityReport().ephemeral; the host should push Safari users to install (11 §7). - Writes a sentinel
kvrow at open; a database without the sentinel means eviction/reset and is reported as anerrorevent carryingRasdError { 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) | op | better-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 queries | better-sqlite3 ('better-sqlite3', via @rasd/storage-sqlite/node) |
| Minimum | Expo SDK ≥ 54 (spine support matrix); the APIs above are as documented for SDK 57 | current release | current release |
| SQLCipher | config plugin useSQLCipher: true; not available in Expo Go | SQLCipher build flag | none |
| FTS5 | enableFTS (default true) | yes | yes |
| Blob | Uint8Array / blob bind parameters | yes | yes |
| Where used | Expo managed/prebuild apps | bare RN or hosts needing FTS5/JSI throughput | Vitest 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 SQLCipherbodyis plain JSON andjson_extract(body, '$.meta.geo')works for ad-hoc host queries; under the plaintext fallback onlybind.sensitivevalues become envelope strings (§8.3), so JSON1 keeps working for everything else. Generated columns overbodywould break the moment a value is encrypted. - JSON1 is used for
patch(json_set(body, '$.data', ?);json_insert(body, '$.audit[#]', json(?))forappendAudit— no whole-row read-modify-write), fordatasets.queryfallbacks (json_each) and for payload-rewriting migrations. JSONB (op-sqlite, SQLite ≥ 3.45) is used forbodywhen 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 AndroiddataExtractionRulesand the bundled module setsisExcludedFromBackupon the namespace directory (07 §9, research/11 §4). - The SQLCipher licence text ships in the package (
LICENSES/SQLCipher.txt) and is exposed viasecurityReport().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):
- Forward-only. No
down; rollback is "restore backup + previous app build". A stored version above the highest knowntarget(DexieVersionError/user_versioncheck) throwsRASD_STORAGE_DOWNGRADEand refuses to open —export()still works through a read-only path over cleartext columns and decryptable payloads. - 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 whereALTER TABLEcannot express the change. Never touchschema_version. - Data steps run after the schema upgrade, each chunk in its own transaction, idempotent (
WHERE schema_rev < nor a per-row marker), cursor checkpointed in_rasd_migrationsso a crash on a low-end device resumes rather than restarts. Steps needing decryption declareneedsKeys: trueand wait for the key. - Backup first when
estimate().rows > 5 000and free space ≥ 2 × database size: nativebackupDatabaseAsyncto<namespace>.pre-<target>.db(deleted after success), webexport()snapshot when OPFS is available; otherwise warn and proceed. - Checksums.
_rasd_migrationsstores(id, checksum, state: 'applied' | 'running' | 'failed', cursor, applied_at); a released step whose checksum changed refuses to run (Prisma/Drizzle behaviour). - Host migrations (
open({ migrations })) run after Rasd's own, may only touchx_*tables, and are tracked in the same table underhost:ids. - Multi-process. Web: one upgrader via the Web Lock; native: migrations finish before the app shell renders (
open()is awaited in the provider'sstoragefactory).
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_seton SQLite; row rewrite on IndexedDB), debounced by the renderer tosettings.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 insidewithExclusiveTransactionAsync(SQLite) in batches of 100–500 rows;putRowsandimport()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/countrun 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-cloneCryptoKeyin_rasd_keys(kid: 'k1',alg: 'A256GCM'); usable but never readable, even by same-origin script. - Wrapped mode. If the host passes an
encryptionKey(aCryptoKeywithwrapKey/unwrapKeyusage, typically fromderiveKeyFromPassphrase(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 aswrappedbytes and unwrapped atopen()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?)createsk2, marksk1retired and runs a chunkeddatamigration re-encrypting 500 rows per transaction; readers accept bothkids meanwhile;k1is deleted after the last row. Rotate on staff turnover or suspected compromise, not by calendar. - Unencrypted mode (
encryptionKeyabsent) logs a red console warning once per session and setssecurityReport().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 underrasd.dbkey.<namespace>withkeychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY(never MMKV/AsyncStorage; never migrates through iCloud/Keychain backup). OptionalrequireAuthenticationbinds it to biometrics (re-enrolment invalidates it — document). SecureStore items survive reinstall on iOS, sowipe()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_versionis 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 withfallback: 'refuse'unless the host setsencryption.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
| Phase | Web | Native |
|---|---|---|
| Derive/generate | generateKey non-extractable at first open(); or unwrap with host KEK | 32 random bytes → hex at first open() |
| Persist | _rasd_keys (IndexedDB) | SecureStore rasd.dbkey.<namespace> |
| Unlock | automatic; or { unlock() } for passphrase | automatic; or requireAuthentication |
| Rotate | rotateKey() + chunked re-encrypt | PRAGMA rekey inside a backup-first migration; SecureStore updated after success |
| Wipe | delete _rasd_keys row → Dexie.delete | SecureStore.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 insidemeta),status,serverRev,auditandupdatedAtare excluded so the hash survives the sync lifecycle unchanged. Computed byengine.finalize(), stored in the cleartextchecksumcolumn, verified by the sync engine before push and by the server (echoed in the ack); a mismatch on read (bit-rot, tampering) raisesRASD_STORAGE_INTEGRITYand the item is surfaced, never silently resent. - Attachment sha256 is computed by
attachments.putand stored cleartext, sent as tusUpload-Metadata/Upload-Checksumand re-verified before the upload starts.crypto.subtle.digestneeds the whole buffer in memory, so blobs ≥ 8 MiB are hashed incrementally instead: 1 MiB slices fed to the streaming@noble/hashessha256 in a Web Worker on web, and to the driver's streaming digest on native. A contiguousArrayBufferis never allocated for a video. - Definitions store
definitionHashand optionaljws;forms.putrecomputes 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/storagewrites the container:manifest.json,<table>.jsonl,attachments/<id>inside a ZIP (.rasdx); default is encrypted-only (encryptTorequired; AES-256-GCM per entry with a passphrase-derived or recipient key, per research/11 §7);allowPlaintext: trueis an explicit host opt-in that emits an audit event.- Availability guarantee.
@rasd/storagehas no license dependency andexport()reads only through the adapter's own repos, so it works in every license state of 00 §9 — includinglimitedunder bothsoftandhardenforcement, andinvalid— as well as on aRASD_STORAGE_DOWNGRADEdatabase and offline.rasd doctorand 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_INTEGRITYon mismatch), writes in 200-row transactions, never overwrites a localfinalized|queued|sendingsubmission with adraft, re-creates outbox rows for importedqueueditems 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 isclose()+open();listNamespaces()enumerates them for a "who is logged in on this device" screen. A hostlogout()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-namespaceestimate()and per-namespaceexport()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 andBroadcastChannelnames 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:
| Adapter | Runner | Engine |
|---|---|---|
| memory | Vitest 4 (node) | — |
| dexie | Vitest 4 with fake-indexeddb/auto (fast path); Vitest browser mode + Playwright on Chromium/Firefox/WebKit incl. an offline and a private-context project | real IndexedDB |
| sqlite | Vitest 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 builds | on-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.
| Benchmark | Dexie (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 blobs | streams, heap < 50 MB | — | streams |
| 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
| Failure | Detection | Behaviour |
|---|---|---|
| Quota exceeded | QuotaExceededError / SQLITE_FULL; estimate() thresholds | RASD_STORAGE_QUOTA; renderer keeps the draft in memory and retries; attachments.put refused first; on('quota') for the banner; suggest sync + gc() |
| Corrupted database | SQLITE_CORRUPT/NOTADB; IndexedDB UnknownError; AES tag failure on many rows | RASD_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 > known | RASD_STORAGE_DOWNGRADE; read-only export() path; host prompts to update the app |
| Private browsing / ephemeral | quota heuristics; Safari persisted === false after grant | securityReport().ephemeral; banner "data may be lost when you close the browser"; encourage install |
| Storage evicted / reset | sentinel kv row missing | error 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 open | RASD_STORAGE_KEY_UNAVAILABLE; never open plaintext, never re-key; host's onStorageError fallback; export impossible by design |
| Multi-tab upgrade blocked | Dexie blocked | state = 'blocked', onBlocked, retry every 2 s for 30 s, then RASD_STORAGE_NOT_OPEN |
SQLITE_BUSY beyond 5 s | driver error | RASD_STORAGE_LOCKED (retryable); usually a host holding its own connection open in a transaction |
| App killed mid-transaction | journaling | WAL/IndexedDB atomicity; nothing to do; gc() removes .tmp files at open |
| SecureStore/SQLCipher unavailable | probe at open | onSecureStoreUnavailable; field-level fallback or refuse per policy; securityReport() reflects it |
| Disk-full during migration | SQLITE_FULL in a chunk | chunk 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
codeplus structureddetails— never 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,assertiveat critical, RNAccessibilityInfo.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") andRASD_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 fromprogress(done, total)and the exportcounts, announced at intervals rather than per row, and must remain cancellable via theAbortSignalevery method accepts. - The two-step local-wipe confirmation (§11) states the
unsyncedCountin 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
numberingsetting 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).
-
patchwith a staleexpectedRevfails withRASD_STORAGE_LOCKEDand leaves the row unchanged. - Web: payload columns are
Uint8Arrayenvelopes; no index contains a blob or a value > 2 000 bytes; the DEK is non-extractable (exportKeythrows); AAD transplant test fails to decrypt. - Native: with
useSQLCipher,PRAGMA cipher_versionis non-empty and the file starts with random bytes, notSQLite format 3; without it, sensitive values inbodyareenc:v1:envelopes andsecure_deleteis 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 thelimitedlicense state and offline; the container is encrypted unlessallowPlaintextis 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-limitbudgets 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.statusvalues are exactly the seven of 00 §6 andattachments.statusexactly the four — a type test plus the SQLiteCHECKconstraints prove no adapter invents a state (purge-after-sync leaves the rowuploaded). - Accessibility (§15.1): no adapter error or event carries a pre-formatted user-facing sentence;
vitest-axeon the host banner fixtures passes; the quota banner announces politely at warning and assertively at critical;blockedand downgrade states keep a reachable retry control; migration/export progress is determinate and abortable.
Open questions
- Should
instanceNamedefault 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
dexieitself? 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-sqlitedriver worth shipping at launch given the missing SQLCipher story, or only afterreact-native-nitro-sqlitedocuments 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?
Related documents
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