Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 79 additions & 11 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,22 +346,48 @@ requests only, a transport failure with `status=0` may return a local replica re
`X-Offline-Response: local`. `POST` and other write methods always go to transport unchanged; outbox replay
requests bypass policy with `OFFLINE_BYPASS` while still using the same transport observation.

Native applications install the Insiders package in the application, then pass its `Sqlite` export
and an encryption key loaded from secure device storage to the kit:
The native offline runtime currently requires Capacitor 8, `@capawesome-team/capacitor-sqlite` 0.3.x, and
`@capawesome-team/capacitor-secure-preferences` 0.2.x. Configure the private Insiders registry with the license key
before installing the SQLite and Secure Preferences packages plus the SQLite WASM runtime. Supply the license key
through a local/CI secret; never commit it to `.npmrc`.

```bash
npm install @capawesome-team/capacitor-sqlite
npm config set @capawesome-team:registry https://npm.registry.capawesome.io
npm config set //npm.registry.capawesome.io/:_authToken "$CAPAWESOME_LICENSE_KEY"
npm install @capawesome-team/capacitor-sqlite@^0.3.0 \
@capawesome-team/capacitor-secure-preferences@^0.2.0 \
@sqlite.org/sqlite-wasm
npx cap sync
```

Applications on an older Capacitor major must not install those versions; upgrade to Capacitor 8 before enabling
the standard native offline runtime. After installation, follow both plugins' platform steps. In particular, exclude
`CAPAWESOME_SECURE_PREFERENCES.xml` from Android 11-and-lower `fullBackupContent` and Android 12+ cloud backup rules,
so the database key is not restored independently of its device keystore material.

Pass the `Sqlite` export and a database key loaded from secure device storage to the kit. Never hard-code or derive
the database key from a user identifier or access token.

```ts
import { SecurePreferences } from '@capawesome-team/capacitor-secure-preferences';
import { Sqlite } from '@capawesome-team/capacitor-sqlite';

const OFFLINE_DATABASE_KEY = 'product-offline-database-key';

async function offlineDatabaseKey(): Promise<string> {
const { value } = await SecurePreferences.get({ key: OFFLINE_DATABASE_KEY });
if (value) return value;

const bytes = crypto.getRandomValues(new Uint8Array(32));
const generated = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
await SecurePreferences.set({ key: OFFLINE_DATABASE_KEY, value: generated });
return generated;
}

provideOffline({
// ...product policies, puller, and executor
sqlitePlugin: Sqlite,
encryptionKey: async () => {
const { value } = await securePreferences.get({ key: 'offline-database-key' });
if (!value) throw new Error('offline-database-key is missing');
return value;
},
encryptionKey: offlineDatabaseKey,
});
```

Expand All @@ -371,6 +397,42 @@ Immediately before each send, the executor receives the latest `{ localId, serve
SQLite; a successful create adds `serverId` without replacing `localId`. Entity projection and outbox
append/removal are committed in one local transaction.

| Identity | SQLite column | Before synchronization | After server acknowledgement |
| --- | --- | --- | --- |
| `localId` | `local_id` | client-generated UUID | unchanged UUID |
| `serverId` | `server_id` | `NULL` for a new entity | positive server `AUTO_INCREMENT` id |

The write lifecycle is: update the replica immediately → append an outbox command in the same transaction → render
the optimistic value → replay in the background → validate the server revision → store the confirmed value and
revision. The server remains authoritative; SQLite is the durable local working database, not an HTTP response cache.

`serverId()` supports positive safe integers only. Products must expose an internal numeric primary key for a
replicated entity; a human-facing string such as a public code, slip number, or SKU remains an ordinary replicated
column. There is intentionally no text-server-id overload.

When the application already knows the numeric server id but the first replica pull has not materialized the row,
pass that identity explicitly while adopting the entity. This is required for updates and especially deletes: an
omitted id would otherwise make the executor interpret the row as a not-yet-created local entity.

```ts
await offlineSync.enqueue({
groupId,
aggregateType: 'items',
aggregateLocalId: localId,
serverId: existingApiItem.id,
operation: 'items.delete',
payload: { method: 'DELETE' },
optimisticValue: existingApiItem,
});
```

The mapping is immutable and unique inside its effective replica scope. Reassigning one `localId` to another
`serverId`, or assigning the same `serverId` to another `localId`, rejects before persistence. Web storage enforces
the same rule transactionally as SQLite's unique indexes: group-scoped entities are unique per user/group/source,
and user-scoped entities are unique per user/source across groups. If an adopted row has no confirmed baseline and
its final command is discarded, the local row is removed; the next pull may materialize the authoritative server
row again. A row with a confirmed baseline rolls back to that baseline instead.

Each synchronization cycle pulls authoritative server deltas before replaying the outbox. Every page carries the
replica schema version/hash and advances a durable user/group cursor in the same transaction as its rows. A schema
mismatch, malformed row, or non-advancing cursor rejects synchronization without advancing that cursor. If a remote
Expand Down Expand Up @@ -440,9 +502,13 @@ provideOffline({
});
```

The schema definition must map every `ItemSelect` key exactly once as a SQLite column, `serverId()`, or
`ignored(reason)`, with exactly one `serverId()` per replicated entity. Nullable Hono columns require
`nullable(...)`; non-null columns reject it. Therefore adding,
Offline replica schema consumers must compile with TypeScript `strictNullChecks: true`. This is part of the standard,
not a compatibility option: without strict null checking, TypeScript cannot distinguish a nullable Hono property
from a required one and the schema lock cannot prove the SQLite mapping.

The schema definition must import the Hono package's exported `$inferSelect` type and map every key exactly once as
a SQLite column, `serverId()`, or `ignored(reason)`, with exactly one numeric `serverId()` per replicated entity.
Nullable Hono columns require `nullable(...)`; non-null columns reject it. Therefore adding,
removing, or changing nullability of a Drizzle column breaks the app build until its replica mapping is updated.
At runtime, `values` contains only the mapped column projection; `localId` and `serverId` remain dedicated replica
fields and ignored server fields are never persisted.
Expand All @@ -452,6 +518,8 @@ Encrypted native builds also require the plugin's SQLCipher platform setup: enab
CocoaPods, or enable the `SQLCipher` package trait when using Swift Package Manager on iOS. Follow
the [Capawesome SQLite installation guide](https://capawesome.io/docs/plugins/sqlite/#installation)
for the exact native configuration and export-compliance notes.
Also follow the [Capawesome Secure Preferences installation guide](https://capawesome.io/docs/plugins/secure-preferences/#installation),
including its Android backup exclusion rules.

- **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.
- **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.
Expand Down
40 changes: 40 additions & 0 deletions projects/kit/offline/src/lib/offline-replica-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,4 +741,44 @@ describe('offline-replica-schema types', () => {
},
});
});

it('type: primitive literal unions use their SQLite builder primitive', () => {
type LiteralSelect = {
id: number;
kind: 0 | 1;
role: 'admin' | 'member';
};

defineReplicaEntity<LiteralSelect>()({
table: 'literal_items',
sourceKey: 'literal_items',
scope: 'group',
fields: {
id: serverId(),
kind: integer(),
role: text(),
},
});
});

it('type: literal unions reject mismatched primitive builders', () => {
type LiteralSelect = {
id: number;
kind: 0 | 1;
role: 'admin' | 'member';
};

defineReplicaEntity<LiteralSelect>()({
table: 'literal_mismatch_items',
sourceKey: 'literal_mismatch_items',
scope: 'group',
fields: {
id: serverId(),
// @ts-expect-error — numeric literal unions require integer().
kind: text(),
// @ts-expect-error — string literal unions require text().
role: integer(),
},
});
});
});
21 changes: 18 additions & 3 deletions projects/kit/offline/src/lib/offline-replica-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,29 @@ type OfflineReplicaFieldDef = OfflineReplicaColumnDef | OfflineReplicaServerIdDe

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

type NormalizeReplicaColumnValue<T> = T extends string
? string
: T extends number
? number
: T extends boolean
? boolean
: T extends Date
? string | Date
: T;

type IsNullableSelectValue<T> = null extends T ? true : undefined extends T ? true : false;

type OfflineReplicaColumnDefForValue<T> = IsNullableSelectValue<T> extends true
? OfflineReplicaColumnDef<
NormalizeReplicaColumnValue<StripNullish<T>>,
{ readonly [replicaNullableBrand]: 'nullable' }
>
: OfflineReplicaColumnDef<NormalizeReplicaColumnValue<T>, { readonly [replicaNullableBrand]: 'required' }>;

type OfflineReplicaFieldDefForKey<TSelect extends Record<string, unknown>, K extends keyof TSelect> =
| (StripNullish<TSelect[K]> extends number ? OfflineReplicaServerIdDef : never)
| OfflineReplicaIgnoredDef
| (IsNullableSelectValue<TSelect[K]> extends true
? OfflineReplicaColumnDef<StripNullish<TSelect[K]>, { readonly [replicaNullableBrand]: 'nullable' }>
: OfflineReplicaColumnDef<TSelect[K], { readonly [replicaNullableBrand]: 'required' }>);
| OfflineReplicaColumnDefForValue<TSelect[K]>;

type ExactSelectKeys<TSelect extends Record<string, unknown>, TFields> =
Exclude<keyof TFields, keyof TSelect> extends never ? (Exclude<keyof TSelect, keyof TFields> extends never ? TFields : never) : never;
Expand Down
153 changes: 153 additions & 0 deletions projects/kit/offline/src/lib/offline-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,159 @@ describe('IonicOfflineRepository', () => {
});
});

describe('replica serverId uniqueness', () => {
const scope = { userId: 1, groupId: 10 };
const groupRow = {
sourceKey: 'test_group_items' as const,
serverId: 55,
confirmedValues: null,
serverRevision: null,
fetchedAt: 1,
syncState: 'confirmed' as const,
};
const userRow = {
sourceKey: 'test_items' as const,
serverId: 42,
confirmedValues: null,
serverRevision: null,
fetchedAt: 1,
syncState: 'confirmed' as const,
};

it('group-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => {
await repository.transactReplica({
putRows: [
{
...groupRow,
userId: 1,
groupId: 10,
localId: '019d-aaaa',
values: { id: 55, name: 'A' },
},
],
});
await expect(
repository.transactReplica({
putRows: [
{
...groupRow,
userId: 1,
groupId: 10,
localId: '019d-bbbb',
values: { id: 55, name: 'B' },
},
],
}),
).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.');
});

it('user-scopedで別localIdに同じserverIdを割り当てるとrejectする', async () => {
await repository.transactReplica({
putRows: [
{
...userRow,
userId: 1,
groupId: 10,
localId: '019d-aaaa',
values: { id: 42, title: 'A' },
},
],
});
await expect(
repository.transactReplica({
putRows: [
{
...userRow,
userId: 1,
groupId: 10,
localId: '019d-bbbb',
values: { id: 42, title: 'B' },
},
],
}),
).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.');
});

it('同一transaction内のserverId重複は部分永続化せずrejectする', async () => {
await expect(
repository.transactReplica({
putRows: [
{
...groupRow,
userId: 1,
groupId: 10,
localId: '019d-aaaa',
values: { id: 55, name: 'A' },
},
{
...groupRow,
userId: 1,
groupId: 10,
localId: '019d-bbbb',
values: { id: 55, name: 'B' },
},
],
}),
).rejects.toThrow('Offline replica serverId 55 is already mapped to localId 019d-aaaa.');
expect(await repository.getReplicaRow(scope, 'test_group_items', '019d-aaaa')).toBeNull();
expect(await repository.getReplicaRow(scope, 'test_group_items', '019d-bbbb')).toBeNull();
});

it('group-scopedは別groupなら同じserverIdを許容する', async () => {
await repository.transactReplica({
putRows: [
{
...groupRow,
userId: 1,
groupId: 10,
localId: '019d-aaaa',
values: { id: 55, name: 'G10' },
},
{
...groupRow,
userId: 1,
groupId: 11,
localId: '019d-bbbb',
values: { id: 55, name: 'G11' },
},
],
});
await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({
localId: '019d-aaaa',
});
await expect(repository.getReplicaRowByServerId({ userId: 1, groupId: 11 }, 'test_group_items', 55)).resolves.toMatchObject({
localId: '019d-bbbb',
});
});

it('user-scopedは別groupでも同じserverIdをrejectする', async () => {
await repository.transactReplica({
putRows: [
{
...userRow,
userId: 1,
groupId: 10,
localId: '019d-aaaa',
values: { id: 42, title: 'G10' },
},
],
});
await expect(
repository.transactReplica({
putRows: [
{
...userRow,
userId: 1,
groupId: 11,
localId: '019d-bbbb',
values: { id: 42, title: 'G11' },
},
],
}),
).rejects.toThrow('Offline replica serverId 42 is already mapped to localId 019d-aaaa.');
});
});

describe('getReplicaRows', () => {
const baseRow = {
sourceKey: 'test_items',
Expand Down
Loading