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
82 changes: 47 additions & 35 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,27 +506,33 @@ provideOffline({
});
```

Every SQLite entity uses an immutable client-generated UUID as `localId`; the outbox references only
`aggregateLocalId`. Remote identity is declared per entity:
Replica identity follows the product database:

| Remote identity | SQLite representation | Executor target |
| ------------------------------- | --------------------------------------------- | ----------------------------------------- |
| `serverId()` | nullable `server_id` | `{ localId, serverId }` |
| `naturalKey(['a', 'b'])` | mapped `a`/`b` columns; no `server_id` column | `{ localId, serverId: null, naturalKey }` |
| no remote identity (local-only) | mapped values only | `{ localId, serverId: null }` |
| Identity declaration | SQLite primary key | Row / executor identity |
| ---------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------- |
| `generatedId('integer' \| 'text')` | scope columns + immutable `local_id`; scoped nullable unique `server_id` | `{ kind: 'generated', localId, remoteId }` |
| `naturalKey(['a', 'b'])` | scope columns plus mapped `a`, `b` columns | `{ kind: 'natural', naturalKey: { a, b } }` |
| `identity: localOnly()` | scope columns + immutable `local_id` | `{ kind: 'local', localId }` |

A successful numeric create adds `serverId` without replacing `localId`. A natural key is derived from current
mapped values in declaration order; it is not duplicated as hidden row metadata. Entity projection and outbox
append/removal are committed in one local transaction.
Natural-key tables contain neither `local_id` nor `server_id`; their scoped key columns are the SQLite composite
`PRIMARY KEY`. Generated remote ids may be positive safe integers or non-empty text ids such as server-generated
UUIDs. A successful create adds `remoteId` without replacing `localId`. Local-only rows cannot enter the Outbox.
All TEXT identity components are opaque and compared byte-for-byte with SQLite's `BINARY` collation. The server
database must use the same binary equality, or the Hono/API converter must return one canonical form before the
value reaches `naturalKey(...)` or `generatedId('text')`. Do not pass case- or accent-insensitive primary keys
through unchanged: values that the server considers equal must first be canonicalized to identical TEXT.

`OfflineScope.userId` accepts `number | string`. A type-tagged TEXT codec keeps numeric `7` and text `"7"` in
different metadata, manifest, cursor, Outbox, and entity boundaries.

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.

For a DB row whose deletion is represented by absence, enqueue the same full row values with
`replicaMutation: 'delete'`. The runtime stores a library-owned durable tombstone: normal `getReplicaRow()` and
`getReplicaRows()` reads stop returning the row immediately, while synchronization retains its immutable `localId`,
remote identity, confirmed baseline, and Outbox command. A successful tombstone acknowledgement removes the row
`getReplicaRows()` reads stop returning the row immediately, while synchronization retains its immutable generated
or natural identity, confirmed baseline, and Outbox command. A successful tombstone acknowledgement removes the row
physically; retry, authorization failure, and conflict keep it hidden; discarding restores the latest confirmed
baseline unless the server has also deleted it.

Expand All @@ -539,7 +545,10 @@ tombstone for replay, lost-ACK reconciliation, conflict handling, and discard.
await offlineSync.enqueue({
scopeId,
aggregateType: 'favorites',
aggregateLocalId: favorite.localId,
identity: {
kind: 'natural',
naturalKey: { favFrom: favorite.values.favFrom, favTo: favorite.values.favTo },
},
operation: 'favorite.delete',
payload: { favTo: favorite.values.favTo },
optimisticValue: favorite.values,
Expand All @@ -555,10 +564,11 @@ may lower these limits with `outboxLimits: { maxCommandsPerUser, maxBytesPerUser
existing replica and Outbox remain unchanged and enqueue rejects with `OfflineOutboxCapacityError`, so the UI can ask
the user to reconnect or resolve/discard an attention item.

Use `serverId()` for a positive safe-integer `AUTO_INCREMENT` key. Use `naturalKey([...])` when the product table's
remote identity is an existing composite primary key. Natural-key components must be required mapped `text()` or
`integer()` columns; nullable, ignored, JSON, empty declarations, duplicates, and mixed `serverId()`/`naturalKey()`
declarations reject. Text components reject NUL, malformed Unicode, and values larger than 1,024 UTF-8 bytes.
Use `generatedId('integer')` for a positive safe-integer `AUTO_INCREMENT` key and `generatedId('text')` for a
server-generated text key. Use `naturalKey([...])` for an existing single or composite natural primary key.
Natural-key components must be required mapped `text()` or `integer()` columns; nullable, ignored, JSON, empty
declarations, duplicates, and mixed generated/natural declarations reject. Text identities reject NUL, malformed
Unicode, and values larger than 1,024 UTF-8 bytes.

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
Expand All @@ -568,19 +578,18 @@ omitted id would otherwise make the executor interpret the row as a not-yet-crea
await offlineSync.enqueue({
scopeId,
aggregateType: 'items',
aggregateLocalId: localId,
serverId: existingApiItem.id,
identity: { kind: 'generated', localId, remoteId: 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 natural key, or assigning the same remote identity to another `localId`, rejects before persistence.
Web storage enforces the rule transactionally. SQLite uses a scoped partial unique index for `server_id` and a
scoped composite unique index over the natural-key columns: partition-scoped entities are unique per
user/partition/source, and user-scoped entities are unique per user/source across partitions. If an adopted row has
The mapping is immutable and unique inside its effective replica scope. Reassigning one generated `localId` to
another `remoteId`, or assigning the same remote identity to another `localId`, rejects before persistence. A
natural-key change is a delete of the old key plus an insert of the new key, never an update of the primary key.
Web storage enforces the same rule transactionally. SQLite uses a scoped partial unique index for generated
`server_id`; natural identity is enforced by the table's scoped composite primary key. 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.
Expand All @@ -596,20 +605,20 @@ retention rule: without pending commands it removes the row, while a pending row
The command adapter must send `commandId` as the server-side idempotency key. The server persists that key with the
mutation and returns all keys represented by a delta row as `acknowledgedCommandIds`. This correlation is required:
if the server commits a create/update/delete but its HTTP acknowledgement is lost, the next pull reconciles the
server result into the original `localId`, removes the acknowledged outbox prefix, and rebases later commands without
creating a second local identity.
server result into the same generated or natural identity, removes the acknowledged Outbox prefix, and rebases later
commands without creating a second local identity.

Versioned replica schemas lock web and native storage. Web metadata stores
`replicaSchemaVersion` and `replicaSchemaHash`; native stores the same pair in
`offline_replica_schema_metadata`. Bump `version` for every intentional shape change and supply a
complete one-step migration chain. Native runs each step's SQL `statements`; web runs
`migrateWebRow`, which receives only `{ sourceKey, values, confirmedValues }` and may return the same
shape or `null` to delete a row. Identity and sync metadata (`localId`, `serverId`, scope, revision,
shape or `null` to delete a row. Identity and sync metadata (`identity`, scope, revision,
`syncState`) stay outside the callback. The bundle fingerprint hashes `version`, entity layouts, and
migration `fromVersion`/`statements` — never function bodies.

Changing a natural-key component list or its order changes the schema fingerprint and SQLite unique index. For a
released schema, bump `version`; native migration SQL must `DROP INDEX` and recreate the scoped composite index, and
Changing a natural-key component list or its order changes the schema fingerprint and SQLite primary key. For a
released schema, bump `version`; native migration SQL must rebuild the table with the new composite primary key, and
`migrateWebRow` must preserve every natural-key value. Keep the transaction journal and schema-migration recovery
records until the replacement rows, index, and metadata commit together. This natural-key API is currently
unreleased, so adopting it before the first release needs no compatibility helper or legacy-row migration.
Expand All @@ -618,10 +627,11 @@ unreleased, so adopting it before the first release needs no compatibility helpe
import {
defineOfflineReplicaSchema,
defineReplicaEntity,
generatedId,
integer,
localOnly,
naturalKey,
provideOffline,
serverId,
text,
} from '@rdlabo/ionic-angular-kit/offline';

Expand All @@ -633,7 +643,7 @@ const itemEntityV2 = defineReplicaEntity<ItemSelect>()({
sourceKey: 'items',
scope: 'partition',
fields: {
id: serverId(),
id: generatedId('integer'),
title: text(),
subtitle: text(),
},
Expand Down Expand Up @@ -687,12 +697,14 @@ not a compatibility option: without strict null checking, TypeScript cannot dist
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)`. A remotely replicated entity declares either one numeric
`serverId()` or one ordered `naturalKey([...])`; a local-only projection declares neither.
a SQLite column, `generatedId('integer' | 'text')`, or `ignored(reason)`. A remotely replicated entity declares
either one generated-id field or one ordered `naturalKey([...])`; a local-only projection explicitly declares
`identity: localOnly()`.
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 numeric `serverId` remain dedicated
replica fields, natural identity is always derived from `values`, and ignored server fields are never persisted.
At runtime, `values` contains only the mapped column projection. Identity is a discriminated union: generated rows
carry `localId`/`remoteId`, natural rows carry only `naturalKey`, and local-only rows carry only `localId`. Ignored
server fields are never persisted.

- **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
4 changes: 2 additions & 2 deletions projects/kit/offline/src/lib/offline-auth-bridge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,11 @@ describe('createOfflineAuthBridge', () => {

it('不正なremote identityをsessionへ永続化する前に拒否する', async () => {
const { bridge, offline } = setupBridge({
exchangeImpl: async () => ({ ...identity, userId: 0 }),
exchangeImpl: async () => ({ ...identity, userId: Number.NaN }),
});
const { lease } = createLease();

await expect(bridge.onAuthorized!(stateStub, lease)).rejects.toThrow('Offline remote identity userId must be a positive safe integer.');
await expect(bridge.onAuthorized!(stateStub, lease)).rejects.toThrow('Offline principal id number must be a finite safe integer.');
expect(offline.prepareRemoteSession).not.toHaveBeenCalled();
});
});
7 changes: 3 additions & 4 deletions projects/kit/offline/src/lib/offline-auth-bridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { inject } from '@angular/core';
import { canonicalOfflinePrincipalId, type OfflinePrincipalId } from './offline-identity';
import { toObservable } from '@angular/core/rxjs-interop';
import type { RouterStateSnapshot } from '@angular/router';
import type { KitAuthAccessLease, KitAuthConfig, KitRemoteAccessRecovery } from '@rdlabo/ionic-angular-kit';
Expand All @@ -8,7 +9,7 @@ import { OfflineCoordinatorService } from './offline-coordinator.service';

/** Remote identity fields required to activate an offline session boundary. */
export interface OfflineRemoteIdentity {
readonly userId: number;
readonly userId: OfflinePrincipalId;
readonly scopeIds: readonly string[];
readonly authSubject: string;
}
Expand Down Expand Up @@ -157,9 +158,7 @@ export function createOfflineAuthBridge<TIdentity extends OfflineRemoteIdentity>
}

function assertOfflineRemoteIdentity(identity: OfflineRemoteIdentity): void {
if (!Number.isSafeInteger(identity.userId) || identity.userId <= 0) {
throw new Error('Offline remote identity userId must be a positive safe integer.');
}
canonicalOfflinePrincipalId(identity.userId);
if (!Array.isArray(identity.scopeIds) || identity.scopeIds.some((scopeId) => typeof scopeId !== 'string' || scopeId.length === 0)) {
throw new Error('Offline remote identity scopeIds must contain only non-empty strings.');
}
Expand Down
55 changes: 41 additions & 14 deletions projects/kit/offline/src/lib/offline-command-executor.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,29 @@
import { InjectionToken } from '@angular/core';
import type { OfflineCommand, OfflineScope } from './offline-repository';
import type { OfflineNaturalKey } from './offline-replica-schema';
import type { OfflineCommandIdentity, OfflinePrincipalId, OfflineReplicaIdentity } from './offline-identity';
import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema';

/** Server acknowledgement used to reconcile one optimistic local mutation. */
export interface OfflineCommandResult {
/** AUTO_INCREMENT id returned by a successful create. */
serverId?: number;
/** Remote id returned by a successful generated-identity mutation. */
remoteId?: OfflineGeneratedRemoteId;
serverRevision?: string | number;
/** Full server-confirmed domain values after applying the mutation. */
confirmedValues?: unknown;
/** Removes the local replica row after a confirmed server delete. */
removeReplica?: boolean;
/**
* Releases the deleted row's remote AUTO_INCREMENT identity while keeping
* Releases the deleted row's remote identity while keeping
* its immutable local id for a queued recreate of the same logical target.
*/
clearServerId?: boolean;
clearRemoteId?: boolean;
response?: unknown;
}

/** Target ids resolved from the local replica immediately before transport. */
export interface OfflineCommandTarget {
localId: string;
serverId: number | null;
/** Composite identity derived from current replica values for a natural-key entity. */
naturalKey?: OfflineNaturalKey;
}
/** Target identity resolved from the local replica immediately before transport. */
export type OfflineCommandTarget =
| { readonly kind: 'generated'; readonly localId: string; readonly remoteId: OfflineGeneratedRemoteId | null }
| { readonly kind: 'natural'; readonly naturalKey: OfflineNaturalKey };

/** 不透明なoperationを製品APIへ送信し、local replicaへ投影するadapter。 */
/** Product adapter that sends commands and projects acknowledgements into entities. */
Expand All @@ -35,7 +33,7 @@ export interface OfflineCommandExecutor {
withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand;
/**
* Removes the deleted remote row's revision from a queued recreate.
* Required only when `clearServerId` completes while later commands remain.
* Required only when `clearRemoteId` completes while later commands remain.
*/
withoutServerRevision?(command: OfflineCommand): OfflineCommand;
}
Expand All @@ -45,7 +43,7 @@ export const OFFLINE_COMMAND_EXECUTOR = new InjectionToken<OfflineCommandExecuto

/** Authenticated user and partition scopes currently eligible for synchronization. */
export interface OfflineSyncSession {
userId: number;
userId: OfflinePrincipalId;
scopes: OfflineScope[];
}

Expand All @@ -59,3 +57,32 @@ export interface OfflineSyncContext {

/** DI token for authenticated synchronization context. */
export const OFFLINE_SYNC_CONTEXT = new InjectionToken<OfflineSyncContext>('OFFLINE_SYNC_CONTEXT');

/** Resolves the transport target from a replica row. */
export function offlineCommandTargetFromReplicaRow(row: { readonly identity: OfflineReplicaIdentity }): OfflineCommandTarget {
if (row.identity.kind === 'natural') return { kind: 'natural', naturalKey: row.identity.naturalKey };
if (row.identity.kind === 'local') throw new Error('Local-only replica rows cannot be synchronized.');
return { kind: 'generated', localId: row.identity.localId, remoteId: row.identity.remoteId };
}

/** Resolves a command lookup identity from an enqueue request identity. */
export function offlineCommandLookupIdentity(identity: EnqueueOfflineCommandIdentity): OfflineCommandIdentity {
if (identity.kind === 'generated') return { kind: 'generated', localId: identity.localId };
return { kind: 'natural', naturalKey: identity.naturalKey };
}

/** Enqueue identity for generated entities. */
export interface EnqueueOfflineGeneratedIdentity {
readonly kind: 'generated';
readonly localId: string;
readonly remoteId?: OfflineGeneratedRemoteId | null;
readonly remoteIdHint?: OfflineGeneratedRemoteId | null;
}

/** Enqueue identity for natural-key entities. */
export interface EnqueueOfflineNaturalIdentity {
readonly kind: 'natural';
readonly naturalKey: OfflineNaturalKey;
}

export type EnqueueOfflineCommandIdentity = EnqueueOfflineGeneratedIdentity | EnqueueOfflineNaturalIdentity;
Loading