From b5ab67fe34984e2f71fa0174a9e686fdfc57c97b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 28 Jul 2026 07:43:12 +0900 Subject: [PATCH] feat(offline): centralize transport and cache contracts --- projects/kit/README.md | 19 ++++- .../offline/src/lib/offline-contract.spec.ts | 75 +++++++++++++++++++ .../kit/offline/src/lib/offline-identity.ts | 8 ++ .../offline/src/lib/offline-kit-options.ts | 2 + .../kit/offline/src/lib/offline-provider.ts | 56 +++++++++++--- .../src/lib/offline-replica-pull.service.ts | 1 + .../offline/src/lib/offline-replica-puller.ts | 54 ++++++++++++- .../src/lib/offline-session.service.ts | 10 +++ .../src/lib/offline-sync.service.spec.ts | 20 +++++ .../offline/src/lib/offline-sync.service.ts | 3 + 10 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-contract.spec.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index 742db22..88a8227 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -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. @@ -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 @@ -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, diff --git a/projects/kit/offline/src/lib/offline-contract.spec.ts b/projects/kit/offline/src/lib/offline-contract.spec.ts new file mode 100644 index 0000000..1493022 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-contract.spec.ts @@ -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; + }); +}); diff --git a/projects/kit/offline/src/lib/offline-identity.ts b/projects/kit/offline/src/lib/offline-identity.ts index 0c91d2d..8db5e3b 100644 --- a/projects/kit/offline/src/lib/offline-identity.ts +++ b/projects/kit/offline/src/lib/offline-identity.ts @@ -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') { diff --git a/projects/kit/offline/src/lib/offline-kit-options.ts b/projects/kit/offline/src/lib/offline-kit-options.ts index 5ac0633..b8bc744 100644 --- a/projects/kit/offline/src/lib/offline-kit-options.ts +++ b/projects/kit/offline/src/lib/offline-kit-options.ts @@ -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. */ diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 29a693a..8b5264e 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -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'; @@ -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; - /** Product transport for explicit cursor-based server delta pulls. */ - replicaPuller: Type; +interface ProvideOfflineOptionsBase extends OfflineKitOptions { /** Product policies that map URLs and DTOs to generic replica/outbox operations. */ requestPolicies: readonly Type[]; /** Optional product hooks for entity projection and command cleanup. */ @@ -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; + /** Product transport for explicit cursor-based server delta pulls. */ + replicaPuller: Type; +} + +/** 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 => { + 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. * @@ -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, @@ -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 ?? []), diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.ts index 193d194..5392bf8 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -43,6 +43,7 @@ export class OfflineReplicaPullService { #schemaHash: Promise | null = null; async pull(scope: OfflineScope): Promise { + if (this.#options.mode === 'readCacheOnly') return; const schemaHash = await (this.#schemaHash ??= sha256OfflineReplicaSchema(this.#options.replicaSchema)); let cursor = (await this.#repository.getReplicaCursor(scope))?.cursor ?? ''; diff --git a/projects/kit/offline/src/lib/offline-replica-puller.ts b/projects/kit/offline/src/lib/offline-replica-puller.ts index 1ede91b..8439332 100644 --- a/projects/kit/offline/src/lib/offline-replica-puller.ts +++ b/projects/kit/offline/src/lib/offline-replica-puller.ts @@ -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 { @@ -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; /** @@ -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; @@ -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; diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index 02a53eb..3d6a5ab 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -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; diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts index 08b92ff..5067ba1 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -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( diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 9a90fbb..d0bd209 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -229,6 +229,9 @@ export class OfflineSyncService { async #enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean }, generation: number): Promise { 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);