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
19 changes: 17 additions & 2 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,10 @@ A fleet-canonical HTTP interceptor with:

The optional `offline` entry point provides a user/partition-scoped local replica, durable outbox, authenticated
session boundary, cursor-based delta pull, aggregate-ordered replay, optimistic updates, retry classification, and a
read-only request-policy interceptor. Applications provide URL/DTO read policies, a replica puller, and a command
executor through `provideOffline(...)`.
read-only request-policy interceptor. Synchronized applications provide URL/DTO read policies, a replica puller, and
a command executor through `provideOffline(...)`. External-source or HTTP read caches use
`mode: 'readCacheOnly'`; the kit then supplies the empty pull/executor boundary instead of making every product
declare dummy adapters.
Mutations are queued explicitly with `OfflineSyncService.enqueue`, not through HTTP interceptor policy.
Web storage uses Ionic Storage; iOS and Android use encrypted `@capacitor-community/sqlite`. Importing either the
primary entry point or `/offline` does not pull the optional native SQLite plugin into web-only applications.
Expand Down Expand Up @@ -602,6 +604,10 @@ to `conflict`; the new server value is retained as the confirmed baseline. A rem
retention rule: without pending commands it removes the row, while a pending row and its Outbox remain under
`remote_deleted` conflict so the user can resolve or discard them.

Hono payloads may call a generated database key `serverId`, while the database-independent runtime calls it
`remoteId`. Product pullers must use `normalizeOfflineReplicaPullPage(response)` at the HTTP boundary; do not
reimplement `serverId` stripping/injection per application. Natural-key changes pass through unchanged.

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
Expand Down Expand Up @@ -690,6 +696,15 @@ provideOffline({
commandExecutor: ProductCommandExecutor,
// ...request policies, databaseName, createEncryptionKey
});

// Stripe or another external system is the source of truth; no Outbox exists.
provideOffline({
mode: 'readCacheOnly',
replicaSchema: readCacheSchema,
requestPolicies: [ReadCacheRequestPolicy],
databaseName: 'product-read-cache',
createEncryptionKey,
});
```

Offline replica schema consumers must compile with TypeScript `strictNullChecks: true`. This is part of the standard,
Expand Down
75 changes: 75 additions & 0 deletions projects/kit/offline/src/lib/offline-contract.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { requirePositiveOfflineInteger } from './offline-identity';
import { provideOffline } from './offline-provider';
import { normalizeOfflineReplicaPullPage } from './offline-replica-puller';
import { defineOfflineReplicaSchema } from './offline-replica-schema';
import { offlineSessionManifestAllows } from './offline-session.service';

describe('shared offline boundary contracts', () => {
it('normalizes database serverId exactly once at the pull transport boundary', () => {
expect(
normalizeOfflineReplicaPullPage({
schemaVersion: 1,
schemaHash: 'hash',
changes: [
{
sourceKey: 'items',
serverId: 42,
serverRevision: 3,
values: { id: 42 },
deleted: false,
},
{
sourceKey: 'favorites',
naturalKey: { from: 7, to: 21 },
serverRevision: 4,
values: null,
deleted: true,
},
],
nextCursor: '2',
hasMore: false,
}).changes,
).toEqual([
{
sourceKey: 'items',
remoteId: 42,
serverRevision: 3,
values: { id: 42 },
deleted: false,
},
{
sourceKey: 'favorites',
naturalKey: { from: 7, to: 21 },
serverRevision: 4,
values: null,
deleted: true,
},
]);
});

it('narrows positive database ids without accepting coercion or zero', () => {
expect(requirePositiveOfflineInteger(7, 'User id')).toBe(7);
expect(() => requirePositiveOfflineInteger('7', 'User id')).toThrow('User id must be a positive safe integer.');
expect(() => requirePositiveOfflineInteger(0, 'User id')).toThrow('User id must be a positive safe integer.');
});

it('authorizes a manifest only for the same subject and requested scope', () => {
const manifest = { userId: 7, scopeIds: ['10'], authSubject: 'uid-7', updatedAt: 1 };
expect(offlineSessionManifestAllows(manifest, 'uid-7', '10')).toBe(true);
expect(offlineSessionManifestAllows(manifest, 'uid-7', '11')).toBe(false);
expect(offlineSessionManifestAllows(manifest, 'uid-other', '10')).toBe(false);
});

it('supports a read cache without product dummy transport adapters', () => {
const compileOnly = (): void => {
provideOffline({
mode: 'readCacheOnly',
databaseName: 'read-cache',
replicaSchema: defineOfflineReplicaSchema({ version: 1, entities: [], migrations: [] }),
requestPolicies: [],
});
};
void compileOnly;
});
});
8 changes: 8 additions & 0 deletions projects/kit/offline/src/lib/offline-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import {
/** Product-agnostic authenticated principal identifier. */
export type OfflinePrincipalId = string | number;

/** Narrows a product DB id without duplicating the numeric identity boundary. */
export function requirePositiveOfflineInteger(value: unknown, label = 'Offline id'): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} must be a positive safe integer.`);
}
return value;
}

/** Type-tagged SQLite/web key; numeric 7 and text "7" never share a boundary. */
export function canonicalOfflinePrincipalId(value: OfflinePrincipalId): string {
if (typeof value === 'number') {
Expand Down
2 changes: 2 additions & 0 deletions projects/kit/offline/src/lib/offline-kit-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface OfflineOutboxLimits {

/** Product-independent native offline persistence settings. */
export interface OfflineKitOptions {
/** Runtime transport mode. Defaults to full synchronized replica/outbox behavior. */
mode?: 'synchronized' | 'readCacheOnly';
/** Encrypted SQLite database name used on iOS and Android. */
databaseName: string;
/** Creates the native database encryption key on first install. Required on iOS and Android. */
Expand Down
56 changes: 46 additions & 10 deletions projects/kit/offline/src/lib/offline-provider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { EnvironmentProviders, Provider, Type } from '@angular/core';
import { inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core';
import { Capacitor } from '@capacitor/core';
import type { OfflineCommandExecutor } from './offline-command-executor';
import type { OfflineCommandExecutor, OfflineCommandResult } from './offline-command-executor';
import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor';
import type { OfflineCommandHooks } from './offline-command-hooks';
import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks';
Expand All @@ -22,11 +22,7 @@ import {
} from './sqlite-offline-repository';

/** Configuration for the standard offline repository, outbox, and request-policy runtime. */
export interface ProvideOfflineOptions extends OfflineKitOptions {
/** Product adapter that sends opaque commands to its API. */
commandExecutor: Type<OfflineCommandExecutor>;
/** Product transport for explicit cursor-based server delta pulls. */
replicaPuller: Type<OfflineReplicaPuller>;
interface ProvideOfflineOptionsBase extends OfflineKitOptions {
/** Product policies that map URLs and DTOs to generic replica/outbox operations. */
requestPolicies: readonly Type<OfflineRequestPolicy>[];
/** Optional product hooks for entity projection and command cleanup. */
Expand All @@ -37,6 +33,41 @@ export interface ProvideOfflineOptions extends OfflineKitOptions {
sqliteConnection?: CommunitySqliteConnection;
}

/** Full replica pull and durable Outbox synchronization. */
export interface ProvideSynchronizedOfflineOptions extends ProvideOfflineOptionsBase {
mode?: 'synchronized';
/** Product adapter that sends opaque commands to its API. */
commandExecutor: Type<OfflineCommandExecutor>;
/** Product transport for explicit cursor-based server delta pulls. */
replicaPuller: Type<OfflineReplicaPuller>;
}

/** Server- or external-source read cache with no mutation transport or Outbox. */
export interface ProvideReadCacheOfflineOptions extends ProvideOfflineOptionsBase {
mode: 'readCacheOnly';
commandExecutor?: never;
replicaPuller?: never;
}

export type ProvideOfflineOptions = ProvideSynchronizedOfflineOptions | ProvideReadCacheOfflineOptions;

const READ_CACHE_ONLY_COMMAND_EXECUTOR: OfflineCommandExecutor = {
execute: async (): Promise<OfflineCommandResult> => {
throw new Error('This offline provider is configured as a read-only cache.');
},
withServerRevision: (command) => command,
};

const READ_CACHE_ONLY_REPLICA_PULLER: OfflineReplicaPuller = {
pull: async (request) => ({
schemaVersion: request.schemaVersion,
schemaHash: request.schemaHash,
changes: [],
nextCursor: request.cursor,
hasMore: false,
}),
};

/**
* Provide the standard scoped offline runtime.
*
Expand All @@ -46,12 +77,13 @@ export interface ProvideOfflineOptions extends OfflineKitOptions {
* isolation.
*/
export function provideOffline(options: ProvideOfflineOptions): EnvironmentProviders {
const synchronized = options.mode !== 'readCacheOnly';
return makeEnvironmentProviders([
options.commandExecutor,
options.replicaPuller,
...(synchronized ? [options.commandExecutor, options.replicaPuller] : []),
{
provide: OFFLINE_KIT_OPTIONS,
useValue: {
mode: options.mode ?? 'synchronized',
databaseName: options.databaseName,
createEncryptionKey: options.createEncryptionKey,
replicaSchema: options.replicaSchema,
Expand All @@ -67,8 +99,12 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi
useFactory: () => selectOfflineRepository(Capacitor.getPlatform(), inject(IonicOfflineRepository), inject(SqliteOfflineRepository)),
},
{ provide: OFFLINE_SYNC_CONTEXT, useExisting: OfflineSessionService },
{ provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.commandExecutor },
{ provide: OFFLINE_REPLICA_PULLER, useExisting: options.replicaPuller },
synchronized
? { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.commandExecutor }
: { provide: OFFLINE_COMMAND_EXECUTOR, useValue: READ_CACHE_ONLY_COMMAND_EXECUTOR },
synchronized
? { provide: OFFLINE_REPLICA_PULLER, useExisting: options.replicaPuller }
: { provide: OFFLINE_REPLICA_PULLER, useValue: READ_CACHE_ONLY_REPLICA_PULLER },
...(options.commandHooks ? [options.commandHooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: options.commandHooks }] : []),
...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)),
...(options.providers ?? []),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class OfflineReplicaPullService {
#schemaHash: Promise<string> | null = null;

async pull(scope: OfflineScope): Promise<void> {
if (this.#options.mode === 'readCacheOnly') return;
const schemaHash = await (this.#schemaHash ??= sha256OfflineReplicaSchema(this.#options.replicaSchema));
let cursor = (await this.#repository.getReplicaCursor(scope))?.cursor ?? '';

Expand Down
54 changes: 52 additions & 2 deletions projects/kit/offline/src/lib/offline-replica-puller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { InjectionToken } from '@angular/core';
import type { OfflineScope } from './offline-repository';
import type { OfflineReplicaRemoteIdentity } from './offline-replica-schema';
import type {
OfflineGeneratedRemoteId,
OfflineNaturalKey,
OfflineReplicaRemoteIdentity,
} from './offline-replica-schema';

/** Server pull request for one user or partition-scoped replica. */
export interface OfflineReplicaPullRequest {
Expand All @@ -11,7 +15,7 @@ export interface OfflineReplicaPullRequest {
}

/** One server-side replica mutation returned by an explicit pull page. */
interface OfflineReplicaChangeBase {
export interface OfflineReplicaChangeBase {
sourceKey: string;
serverRevision: string | number;
/**
Expand All @@ -26,6 +30,22 @@ interface OfflineReplicaChangeBase {
/** One server-side mutation with the identity kind declared by its replica schema. */
export type OfflineReplicaChange = OfflineReplicaChangeBase & OfflineReplicaRemoteIdentity;

/**
* Backend wire identity accepted at the HTTP boundary.
*
* `serverId` is the database-facing name used by Hono applications. The
* offline runtime deliberately calls the same value `remoteId`, because the
* repository is not tied to a particular server database. Normalize exactly
* once at the transport boundary with {@link normalizeOfflineReplicaPullPage}.
*/
export type OfflineReplicaWireIdentity =
| { readonly serverId: OfflineGeneratedRemoteId }
| { readonly remoteId: OfflineGeneratedRemoteId }
| { readonly naturalKey: OfflineNaturalKey };

/** One backend pull mutation before its DB-facing identity is normalized. */
export type OfflineReplicaWireChange = OfflineReplicaChangeBase & OfflineReplicaWireIdentity;

/** One explicit replica pull response page from the application backend. */
export interface OfflineReplicaPullPage {
schemaVersion: number;
Expand All @@ -35,6 +55,36 @@ export interface OfflineReplicaPullPage {
hasMore: boolean;
}

/** Backend response accepted by the shared pull-page normalizer. */
export interface OfflineReplicaWirePullPage {
schemaVersion: number;
schemaHash: string;
changes: readonly OfflineReplicaWireChange[];
nextCursor: string;
hasMore: boolean;
}

/**
* Converts a backend DB identity (`serverId`) to the runtime identity
* (`remoteId`) without allowing product pullers to each reimplement the rule.
*/
export function normalizeOfflineReplicaPullPage(page: OfflineReplicaWirePullPage): OfflineReplicaPullPage {
return {
...page,
changes: page.changes.map((change, index) => {
if ('naturalKey' in change) return change;
if ('serverId' in change && 'remoteId' in change) {
throw new Error(`Offline replica pull page changes[${index}] cannot contain both serverId and remoteId.`);
}
if ('serverId' in change) {
const { serverId, ...rest } = change;
return { ...rest, remoteId: serverId };
}
return change;
}),
};
}

/** Application-provided transport that fetches explicit replica pull pages from the server. */
export interface OfflineReplicaPuller {
pull(request: OfflineReplicaPullRequest): Promise<OfflineReplicaPullPage>;
Expand Down
10 changes: 10 additions & 0 deletions projects/kit/offline/src/lib/offline-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ export interface OfflineSessionManifest {
updatedAt: number;
}

/** Checks the authenticated subject and optional scope against one persisted session boundary. */
export function offlineSessionManifestAllows(
manifest: OfflineSessionManifest | null,
authSubject: string | null,
scopeId?: string,
): manifest is OfflineSessionManifest {
if (!manifest || manifest.authSubject !== authSubject) return false;
return scopeId === undefined || manifest.scopeIds.includes(scopeId);
}

/** Structural lease used to reject stale asynchronous session commits. */
export interface OfflineSessionTransitionLease {
isCurrent(): boolean;
Expand Down
20 changes: 20 additions & 0 deletions projects/kit/offline/src/lib/offline-sync.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,26 @@ describe('OfflineSyncService', () => {
service = TestBed.inject(OfflineSyncService);
});

it('readCacheOnly mode rejects enqueue before creating replica or Outbox state', async () => {
options.mode = 'readCacheOnly';

await expect(
service.enqueue(
{
scopeId: '10',
aggregateType: 'documents',
identity: { kind: 'generated', localId: 'forbidden-write' },
operation: 'documents.create',
payload: { title: 'write' },
optimisticValue: { id: 0, title: 'write' },
},
{ flush: false },
),
).rejects.toThrow('read-only cache');
expect(commands).toEqual([]);
expect(rows).toEqual([]);
});

it('Outbox件数上限では既存commandを失わず新規enqueueを拒否する', async () => {
options.outboxLimits = { maxCommandsPerUser: 1 };
await service.enqueue(
Expand Down
3 changes: 3 additions & 0 deletions projects/kit/offline/src/lib/offline-sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ export class OfflineSyncService {

async #enqueue<T>(request: EnqueueOfflineCommand<T>, options: { flush?: boolean }, generation: number): Promise<string> {
await this.initialize();
if (this.#options.mode === 'readCacheOnly') {
throw new Error('This offline provider is configured as a read-only cache and cannot enqueue commands.');
}
const session = await this.#getLocalSession();
if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user');
this.#assertSessionPrincipalBoundary(session);
Expand Down