Skip to content

Commit 925f7bf

Browse files
authored
fix(offline): adopt server identities safely (#29)
1 parent 83a2894 commit 925f7bf

7 files changed

Lines changed: 508 additions & 21 deletions

‎projects/kit/README.md‎

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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
347347
requests 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+
357387
provideOffline({
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
371397
SQLite; a successful create adds `serverId` without replacing `localId`. Entity projection and outbox
372398
append/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+
374436
Each synchronization cycle pulls authoritative server deltas before replaying the outbox. Every page carries the
375437
replica schema version/hash and advances a durable user/group cursor in the same transaction as its rows. A schema
376438
mismatch, 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,
446512
removing, or changing nullability of a Drizzle column breaks the app build until its replica mapping is updated.
447513
At runtime, `values` contains only the mapped column projection; `localId` and `serverId` remain dedicated replica
448514
fields and ignored server fields are never persisted.
@@ -452,6 +518,8 @@ Encrypted native builds also require the plugin's SQLCipher platform setup: enab
452518
CocoaPods, or enable the `SQLCipher` package trait when using Swift Package Manager on iOS. Follow
453519
the [Capawesome SQLite installation guide](https://capawesome.io/docs/plugins/sqlite/#installation)
454520
for 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.

‎projects/kit/offline/src/lib/offline-replica-schema.spec.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,4 +741,44 @@ describe('offline-replica-schema types', () => {
741741
},
742742
});
743743
});
744+
745+
it('type: primitive literal unions use their SQLite builder primitive', () => {
746+
type LiteralSelect = {
747+
id: number;
748+
kind: 0 | 1;
749+
role: 'admin' | 'member';
750+
};
751+
752+
defineReplicaEntity<LiteralSelect>()({
753+
table: 'literal_items',
754+
sourceKey: 'literal_items',
755+
scope: 'group',
756+
fields: {
757+
id: serverId(),
758+
kind: integer(),
759+
role: text(),
760+
},
761+
});
762+
});
763+
764+
it('type: literal unions reject mismatched primitive builders', () => {
765+
type LiteralSelect = {
766+
id: number;
767+
kind: 0 | 1;
768+
role: 'admin' | 'member';
769+
};
770+
771+
defineReplicaEntity<LiteralSelect>()({
772+
table: 'literal_mismatch_items',
773+
sourceKey: 'literal_mismatch_items',
774+
scope: 'group',
775+
fields: {
776+
id: serverId(),
777+
// @ts-expect-error — numeric literal unions require integer().
778+
kind: text(),
779+
// @ts-expect-error — string literal unions require text().
780+
role: integer(),
781+
},
782+
});
783+
});
744784
});

‎projects/kit/offline/src/lib/offline-replica-schema.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,29 @@ type OfflineReplicaFieldDef = OfflineReplicaColumnDef | OfflineReplicaServerIdDe
8787

8888
type StripNullish<T> = Exclude<T, null | undefined>;
8989

90+
type NormalizeReplicaColumnValue<T> = T extends string
91+
? string
92+
: T extends number
93+
? number
94+
: T extends boolean
95+
? boolean
96+
: T extends Date
97+
? string | Date
98+
: T;
99+
90100
type IsNullableSelectValue<T> = null extends T ? true : undefined extends T ? true : false;
91101

102+
type OfflineReplicaColumnDefForValue<T> = IsNullableSelectValue<T> extends true
103+
? OfflineReplicaColumnDef<
104+
NormalizeReplicaColumnValue<StripNullish<T>>,
105+
{ readonly [replicaNullableBrand]: 'nullable' }
106+
>
107+
: OfflineReplicaColumnDef<NormalizeReplicaColumnValue<T>, { readonly [replicaNullableBrand]: 'required' }>;
108+
92109
type OfflineReplicaFieldDefForKey<TSelect extends Record<string, unknown>, K extends keyof TSelect> =
93110
| (StripNullish<TSelect[K]> extends number ? OfflineReplicaServerIdDef : never)
94111
| OfflineReplicaIgnoredDef
95-
| (IsNullableSelectValue<TSelect[K]> extends true
96-
? OfflineReplicaColumnDef<StripNullish<TSelect[K]>, { readonly [replicaNullableBrand]: 'nullable' }>
97-
: OfflineReplicaColumnDef<TSelect[K], { readonly [replicaNullableBrand]: 'required' }>);
112+
| OfflineReplicaColumnDefForValue<TSelect[K]>;
98113

99114
type ExactSelectKeys<TSelect extends Record<string, unknown>, TFields> =
100115
Exclude<keyof TFields, keyof TSelect> extends never ? (Exclude<keyof TSelect, keyof TFields> extends never ? TFields : never) : never;

‎projects/kit/offline/src/lib/offline-repository.spec.ts‎

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,159 @@ describe('IonicOfflineRepository', () => {
10721072
});
10731073
});
10741074

1075+
describe('replica serverId uniqueness', () => {
1076+
const scope = { userId: 1, groupId: 10 };
1077+
const groupRow = {
1078+
sourceKey: 'test_group_items' as const,
1079+
serverId: 55,
1080+
confirmedValues: null,
1081+
serverRevision: null,
1082+
fetchedAt: 1,
1083+
syncState: 'confirmed' as const,
1084+
};
1085+
const userRow = {
1086+
sourceKey: 'test_items' as const,
1087+
serverId: 42,
1088+
confirmedValues: null,
1089+
serverRevision: null,
1090+
fetchedAt: 1,
1091+
syncState: 'confirmed' as const,
1092+
};
1093+
1094+
it('group-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => {
1095+
await repository.transactReplica({
1096+
putRows: [
1097+
{
1098+
...groupRow,
1099+
userId: 1,
1100+
groupId: 10,
1101+
localId: '019d-aaaa',
1102+
values: { id: 55, name: 'A' },
1103+
},
1104+
],
1105+
});
1106+
await expect(
1107+
repository.transactReplica({
1108+
putRows: [
1109+
{
1110+
...groupRow,
1111+
userId: 1,
1112+
groupId: 10,
1113+
localId: '019d-bbbb',
1114+
values: { id: 55, name: 'B' },
1115+
},
1116+
],
1117+
}),
1118+
).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.');
1119+
});
1120+
1121+
it('user-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => {
1122+
await repository.transactReplica({
1123+
putRows: [
1124+
{
1125+
...userRow,
1126+
userId: 1,
1127+
groupId: 10,
1128+
localId: '019d-aaaa',
1129+
values: { id: 42, title: 'A' },
1130+
},
1131+
],
1132+
});
1133+
await expect(
1134+
repository.transactReplica({
1135+
putRows: [
1136+
{
1137+
...userRow,
1138+
userId: 1,
1139+
groupId: 10,
1140+
localId: '019d-bbbb',
1141+
values: { id: 42, title: 'B' },
1142+
},
1143+
],
1144+
}),
1145+
).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.');
1146+
});
1147+
1148+
it('同一transaction内のserverId重複は部分永続化せずrejectする', async () => {
1149+
await expect(
1150+
repository.transactReplica({
1151+
putRows: [
1152+
{
1153+
...groupRow,
1154+
userId: 1,
1155+
groupId: 10,
1156+
localId: '019d-aaaa',
1157+
values: { id: 55, name: 'A' },
1158+
},
1159+
{
1160+
...groupRow,
1161+
userId: 1,
1162+
groupId: 10,
1163+
localId: '019d-bbbb',
1164+
values: { id: 55, name: 'B' },
1165+
},
1166+
],
1167+
}),
1168+
).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.');
1169+
expect(await repository.getReplicaRow(scope, 'test_group_items', '019d-aaaa')).toBeNull();
1170+
expect(await repository.getReplicaRow(scope, 'test_group_items', '019d-bbbb')).toBeNull();
1171+
});
1172+
1173+
it('group-scopedは別groupなら同じserverIdを許容する', async () => {
1174+
await repository.transactReplica({
1175+
putRows: [
1176+
{
1177+
...groupRow,
1178+
userId: 1,
1179+
groupId: 10,
1180+
localId: '019d-aaaa',
1181+
values: { id: 55, name: 'G10' },
1182+
},
1183+
{
1184+
...groupRow,
1185+
userId: 1,
1186+
groupId: 11,
1187+
localId: '019d-bbbb',
1188+
values: { id: 55, name: 'G11' },
1189+
},
1190+
],
1191+
});
1192+
await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({
1193+
localId: '019d-aaaa',
1194+
});
1195+
await expect(repository.getReplicaRowByServerId({ userId: 1, groupId: 11 }, 'test_group_items', 55)).resolves.toMatchObject({
1196+
localId: '019d-bbbb',
1197+
});
1198+
});
1199+
1200+
it('user-scopedは別groupでも同じserverIdをrejectする', async () => {
1201+
await repository.transactReplica({
1202+
putRows: [
1203+
{
1204+
...userRow,
1205+
userId: 1,
1206+
groupId: 10,
1207+
localId: '019d-aaaa',
1208+
values: { id: 42, title: 'G10' },
1209+
},
1210+
],
1211+
});
1212+
await expect(
1213+
repository.transactReplica({
1214+
putRows: [
1215+
{
1216+
...userRow,
1217+
userId: 1,
1218+
groupId: 11,
1219+
localId: '019d-bbbb',
1220+
values: { id: 42, title: 'G11' },
1221+
},
1222+
],
1223+
}),
1224+
).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.');
1225+
});
1226+
});
1227+
10751228
describe('getReplicaRows', () => {
10761229
const baseRow = {
10771230
sourceKey: 'test_items',

0 commit comments

Comments
 (0)