@@ -346,22 +346,48 @@ requests only, a transport failure with `status=0` may return a local replica re
346346` X-Offline-Response: local ` . ` POST ` and other write methods always go to transport unchanged; outbox replay
347347requests bypass policy with ` OFFLINE_BYPASS ` while still using the same transport observation.
348348
349- Native applications install the Insiders package in the application, then pass its ` Sqlite ` export
350- and an encryption key loaded from secure device storage to the kit:
349+ The native offline runtime currently requires Capacitor 8, ` @capawesome-team/capacitor-sqlite ` 0.3.x, and
350+ ` @capawesome-team/capacitor-secure-preferences ` 0.2.x. Configure the private Insiders registry with the license key
351+ before installing the SQLite and Secure Preferences packages plus the SQLite WASM runtime. Supply the license key
352+ through a local/CI secret; never commit it to ` .npmrc ` .
351353
352354``` bash
353- npm install @capawesome-team/capacitor-sqlite
355+ npm config set @capawesome-team:registry https://npm.registry.capawesome.io
356+ npm config set //npm.registry.capawesome.io/:_authToken " $CAPAWESOME_LICENSE_KEY "
357+ npm install @capawesome-team/capacitor-sqlite@^0.3.0 \
358+ @capawesome-team/capacitor-secure-preferences@^0.2.0 \
359+ @sqlite.org/sqlite-wasm
360+ npx cap sync
354361```
355362
363+ Applications on an older Capacitor major must not install those versions; upgrade to Capacitor 8 before enabling
364+ the standard native offline runtime. After installation, follow both plugins' platform steps. In particular, exclude
365+ ` CAPAWESOME_SECURE_PREFERENCES.xml ` from Android 11-and-lower ` fullBackupContent ` and Android 12+ cloud backup rules,
366+ so the database key is not restored independently of its device keystore material.
367+
368+ Pass the ` Sqlite ` export and a database key loaded from secure device storage to the kit. Never hard-code or derive
369+ the database key from a user identifier or access token.
370+
356371``` ts
372+ import { SecurePreferences } from ' @capawesome-team/capacitor-secure-preferences' ;
373+ import { Sqlite } from ' @capawesome-team/capacitor-sqlite' ;
374+
375+ const OFFLINE_DATABASE_KEY = ' product-offline-database-key' ;
376+
377+ async function offlineDatabaseKey(): Promise <string > {
378+ const { value } = await SecurePreferences .get ({ key: OFFLINE_DATABASE_KEY });
379+ if (value ) return value ;
380+
381+ const bytes = crypto .getRandomValues (new Uint8Array (32 ));
382+ const generated = Array .from (bytes , (byte ) => byte .toString (16 ).padStart (2 , ' 0' )).join (' ' );
383+ await SecurePreferences .set ({ key: OFFLINE_DATABASE_KEY , value: generated });
384+ return generated ;
385+ }
386+
357387provideOffline ({
358388 // ...product policies, puller, and executor
359389 sqlitePlugin: Sqlite ,
360- encryptionKey : async () => {
361- const { value } = await securePreferences .get ({ key: ' offline-database-key' });
362- if (! value ) throw new Error (' offline-database-key is missing' );
363- return value ;
364- },
390+ encryptionKey: offlineDatabaseKey ,
365391});
366392```
367393
@@ -371,6 +397,42 @@ Immediately before each send, the executor receives the latest `{ localId, serve
371397SQLite; a successful create adds ` serverId ` without replacing ` localId ` . Entity projection and outbox
372398append/removal are committed in one local transaction.
373399
400+ | Identity | SQLite column | Before synchronization | After server acknowledgement |
401+ | --- | --- | --- | --- |
402+ | ` localId ` | ` local_id ` | client-generated UUID | unchanged UUID |
403+ | ` serverId ` | ` server_id ` | ` NULL ` for a new entity | positive server ` AUTO_INCREMENT ` id |
404+
405+ The write lifecycle is: update the replica immediately → append an outbox command in the same transaction → render
406+ the optimistic value → replay in the background → validate the server revision → store the confirmed value and
407+ revision. The server remains authoritative; SQLite is the durable local working database, not an HTTP response cache.
408+
409+ ` serverId() ` supports positive safe integers only. Products must expose an internal numeric primary key for a
410+ replicated entity; a human-facing string such as a public code, slip number, or SKU remains an ordinary replicated
411+ column. There is intentionally no text-server-id overload.
412+
413+ When the application already knows the numeric server id but the first replica pull has not materialized the row,
414+ pass that identity explicitly while adopting the entity. This is required for updates and especially deletes: an
415+ omitted id would otherwise make the executor interpret the row as a not-yet-created local entity.
416+
417+ ``` ts
418+ await offlineSync .enqueue ({
419+ groupId ,
420+ aggregateType: ' items' ,
421+ aggregateLocalId: localId ,
422+ serverId: existingApiItem .id ,
423+ operation: ' items.delete' ,
424+ payload: { method: ' DELETE' },
425+ optimisticValue: existingApiItem ,
426+ });
427+ ```
428+
429+ The mapping is immutable and unique inside its effective replica scope. Reassigning one ` localId ` to another
430+ ` serverId ` , or assigning the same ` serverId ` to another ` localId ` , rejects before persistence. Web storage enforces
431+ the same rule transactionally as SQLite's unique indexes: group-scoped entities are unique per user/group/source,
432+ and user-scoped entities are unique per user/source across groups. If an adopted row has no confirmed baseline and
433+ its final command is discarded, the local row is removed; the next pull may materialize the authoritative server
434+ row again. A row with a confirmed baseline rolls back to that baseline instead.
435+
374436Each synchronization cycle pulls authoritative server deltas before replaying the outbox. Every page carries the
375437replica schema version/hash and advances a durable user/group cursor in the same transaction as its rows. A schema
376438mismatch, malformed row, or non-advancing cursor rejects synchronization without advancing that cursor. If a remote
@@ -440,9 +502,13 @@ provideOffline({
440502});
441503```
442504
443- The schema definition must map every ` ItemSelect ` key exactly once as a SQLite column, ` serverId() ` , or
444- ` ignored(reason) ` , with exactly one ` serverId() ` per replicated entity. Nullable Hono columns require
445- ` nullable(...) ` ; non-null columns reject it. Therefore adding,
505+ Offline replica schema consumers must compile with TypeScript ` strictNullChecks: true ` . This is part of the standard,
506+ not a compatibility option: without strict null checking, TypeScript cannot distinguish a nullable Hono property
507+ from a required one and the schema lock cannot prove the SQLite mapping.
508+
509+ The schema definition must import the Hono package's exported ` $inferSelect ` type and map every key exactly once as
510+ a SQLite column, ` serverId() ` , or ` ignored(reason) ` , with exactly one numeric ` serverId() ` per replicated entity.
511+ Nullable Hono columns require ` nullable(...) ` ; non-null columns reject it. Therefore adding,
446512removing, or changing nullability of a Drizzle column breaks the app build until its replica mapping is updated.
447513At runtime, ` values ` contains only the mapped column projection; ` localId ` and ` serverId ` remain dedicated replica
448514fields and ignored server fields are never persisted.
@@ -452,6 +518,8 @@ Encrypted native builds also require the plugin's SQLCipher platform setup: enab
452518CocoaPods, or enable the ` SQLCipher ` package trait when using Swift Package Manager on iOS. Follow
453519the [ Capawesome SQLite installation guide] ( https://capawesome.io/docs/plugins/sqlite/#installation )
454520for the exact native configuration and export-compliance notes.
521+ Also follow the [ Capawesome Secure Preferences installation guide] ( https://capawesome.io/docs/plugins/secure-preferences/#installation ) ,
522+ including its Android backup exclusion rules.
455523
456524- ** Status classification** : ` 0 ` →` onNetworkError ` (connected only), ` 429 ` →` onRateLimited ` , ` 502/503/504 ` →` onServerBusy ` , ` 400/422/500 ` +message→` onServerError ` , ` 401 ` →` onUnauthorized ` , ` 403 ` →` onForbidden ` . Other statuses (e.g. ` 404 ` ) are left to the caller.
457525- ** Universal 60s timeout** — every request fails with a synthetic (retryable) ` 408 ` if it hangs for 60s. Deliberately generous (catches a dead server without cutting off a large upload / AI generation; ` timeout({ each }) ` resets per emission, so streaming is unaffected). Not configurable — one fleet-wide behavior.
0 commit comments