From 6d2ab786e69f5f8aaf9ffecaa9a4cb27cd113db6 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 19:51:40 +0900 Subject: [PATCH 1/7] feat: add standard offline sync runtime --- angular.json | 1 + package-lock.json | 108 ++++++ package.json | 1 + projects/kit/README.md | 8 + projects/kit/offline/ng-package.json | 6 + .../src/lib/offline-command-executor.ts | 27 ++ .../offline/src/lib/offline-command-hooks.ts | 18 + .../src/lib/offline-coordinator.service.ts | 50 +++ .../offline/src/lib/offline-kit-options.ts | 10 + .../src/lib/offline-network.service.ts | 53 +++ .../kit/offline/src/lib/offline-provider.ts | 52 +++ .../src/lib/offline-repository.spec.ts | 124 ++++++ .../kit/offline/src/lib/offline-repository.ts | 243 ++++++++++++ .../offline/src/lib/offline-request-policy.ts | 54 +++ .../src/lib/offline-session.service.spec.ts | 67 ++++ .../src/lib/offline-session.service.ts | 94 +++++ .../src/lib/offline-sync.service.spec.ts | 109 ++++++ .../offline/src/lib/offline-sync.service.ts | 337 ++++++++++++++++ .../src/lib/offline.interceptor.spec.ts | 69 ++++ .../offline/src/lib/offline.interceptor.ts | 76 ++++ .../src/lib/sqlite-offline-repository.ts | 366 ++++++++++++++++++ projects/kit/offline/src/public-api.ts | 13 + projects/kit/package.json | 1 + projects/kit/src/public-api.ts | 3 +- projects/kit/tsconfig.spec.json | 1 + 25 files changed, 1890 insertions(+), 1 deletion(-) create mode 100644 projects/kit/offline/ng-package.json create mode 100644 projects/kit/offline/src/lib/offline-command-executor.ts create mode 100644 projects/kit/offline/src/lib/offline-command-hooks.ts create mode 100644 projects/kit/offline/src/lib/offline-coordinator.service.ts create mode 100644 projects/kit/offline/src/lib/offline-kit-options.ts create mode 100644 projects/kit/offline/src/lib/offline-network.service.ts create mode 100644 projects/kit/offline/src/lib/offline-provider.ts create mode 100644 projects/kit/offline/src/lib/offline-repository.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-repository.ts create mode 100644 projects/kit/offline/src/lib/offline-request-policy.ts create mode 100644 projects/kit/offline/src/lib/offline-session.service.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-session.service.ts create mode 100644 projects/kit/offline/src/lib/offline-sync.service.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-sync.service.ts create mode 100644 projects/kit/offline/src/lib/offline.interceptor.spec.ts create mode 100644 projects/kit/offline/src/lib/offline.interceptor.ts create mode 100644 projects/kit/offline/src/lib/sqlite-offline-repository.ts create mode 100644 projects/kit/offline/src/public-api.ts diff --git a/angular.json b/angular.json index ba6229e..0db4f61 100644 --- a/angular.json +++ b/angular.json @@ -262,6 +262,7 @@ "../theme/src/**/*.spec.ts", "../review/src/**/*.spec.ts", "../live-update/src/**/*.spec.ts", + "../offline/src/**/*.spec.ts", "../auth-firebase/src/**/*.spec.ts", "../auth-firebase/social/src/**/*.spec.ts" ] diff --git a/package-lock.json b/package-lock.json index bccaa10..bf3c05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build", "@capacitor-community/facebook-login": "^8.0.0", "@capacitor-community/in-app-review": "^8.0.0", + "@capacitor-community/sqlite": "^8.1.0", "@capacitor/cli": ">=6.0.0 <9.0.0", "@capacitor/haptics": "^8.0.2", "@capacitor/keyboard": "^8.0.5", @@ -3512,6 +3513,22 @@ "@capacitor/core": ">=8.0.0" } }, + "node_modules/@capacitor-community/sqlite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-8.1.0.tgz", + "integrity": "sha512-yhKZDAVPDPcM3QE6UGB3LXyV25a6Rve1SjZ1aUpTE0E2isnYTVM0PG9+JOI241f+NdsHzPTE7ESJiYSqKsKnuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jeep-sqlite": "^2.7.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@capacitor/core": ">=8.0.0" + } + }, "node_modules/@capacitor/android": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz", @@ -11321,6 +11338,13 @@ "node": ">=8" } }, + "node_modules/browser-fs-access": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz", + "integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -16857,6 +16881,20 @@ "node": ">=10" } }, + "node_modules/jeep-sqlite": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/jeep-sqlite/-/jeep-sqlite-2.8.0.tgz", + "integrity": "sha512-FWNUP6OAmrUHwiW7H1xH5YUQ8tN2O4l4psT1sLd7DQtHd5PfrA1nvNdeKPNj+wQBtu7elJa8WoUibTytNTaaCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stencil/core": "^4.20.0", + "browser-fs-access": "^0.35.0", + "jszip": "^3.10.1", + "localforage": "^1.10.0", + "sql.js": "^1.11.0" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -17138,6 +17176,62 @@ "node": ">=0.6.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", @@ -23218,6 +23312,13 @@ "optional": true, "peer": true }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -23681,6 +23782,13 @@ "node": ">= 10.x" } }, + "node_modules/sql.js": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", + "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "dev": true, + "license": "MIT" + }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", diff --git a/package.json b/package.json index f967b41..030f375 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build", "@capacitor-community/facebook-login": "^8.0.0", "@capacitor-community/in-app-review": "^8.0.0", + "@capacitor-community/sqlite": "^8.1.0", "@capacitor/cli": ">=6.0.0 <9.0.0", "@capacitor/haptics": "^8.0.2", "@capacitor/keyboard": "^8.0.5", diff --git a/projects/kit/README.md b/projects/kit/README.md index eb66455..54620ab 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -330,6 +330,14 @@ A fleet-canonical HTTP interceptor with: - Configurable bypass (CDN, S3, external URLs) - **Safe retry**: only idempotent methods (`GET`/`HEAD`/`OPTIONS`, or a request with an `Idempotency-Key`) are retried, and only on a transient status `[0, 408, 429, 502, 503, 504]`, up to 2 times with a short jittered backoff (honoring `Retry-After`). **Writes are never auto-retried** (no duplicate saves). - **Offline fast-fail**: when the device is offline the interceptor stops retrying immediately and hands off to `offlineFallback` instead of waiting out the backoff. + +### Scoped cache and outbox (`@rdlabo/ionic-angular-kit/offline`) + +The optional `offline` entry point provides a user/group-scoped cache, durable outbox, authenticated +session boundary, aggregate-ordered replay, retry classification, and request-policy interceptor. +Applications provide only their URL/DTO policy and command executor through `provideOffline(...)`. +Web storage uses Ionic Storage; iOS and Android use encrypted Capacitor SQLite. Importing the primary +kit entry point does not pull in the SQLite peer. - **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. - **Optional `treatAsError(response)`** — reject a 2xx (e.g. `204`/`206`) as an error when a backend uses it to signal a condition. The one genuinely app-specific bit (some apps receive a normal `204`), kept optional so class interceptors with a 2xx-as-error convention can migrate to `provideKitHttp`. diff --git a/projects/kit/offline/ng-package.json b/projects/kit/offline/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/kit/offline/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts new file mode 100644 index 0000000..38a5329 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -0,0 +1,27 @@ +import { InjectionToken } from '@angular/core'; +import type { OfflineCommand, OfflineEntity, OfflineScope } from './offline-repository'; + +export interface OfflineCommandResult { + serverRevision?: string | number; + response?: unknown; +} + +/** 不透明なoperationを製品APIへ送信し、cacheへ投影するadapter。 */ +export interface OfflineCommandExecutor { + execute(command: OfflineCommand): Promise; + withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand; + projectEntity?(command: OfflineCommand, entity: OfflineEntity, result: OfflineCommandResult): OfflineEntity; +} + +export const OFFLINE_COMMAND_EXECUTOR = new InjectionToken('OFFLINE_COMMAND_EXECUTOR'); + +export interface OfflineSyncSession { + userId: number; + scopes: OfflineScope[]; +} + +export interface OfflineSyncContext { + getSession(): Promise; +} + +export const OFFLINE_SYNC_CONTEXT = new InjectionToken('OFFLINE_SYNC_CONTEXT'); diff --git a/projects/kit/offline/src/lib/offline-command-hooks.ts b/projects/kit/offline/src/lib/offline-command-hooks.ts new file mode 100644 index 0000000..82a3134 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-command-hooks.ts @@ -0,0 +1,18 @@ +import { InjectionToken } from '@angular/core'; +import type { OfflineCommand } from './offline-repository'; + +export interface OfflineCommandHooks { + cacheEntityType(command: Pick): string; + shouldUpdateCache(command: OfflineCommand, result: { serverRevision?: string | number; response?: unknown }): boolean; + onCommandRemoved?(command: OfflineCommand): Promise; +} + +export const DEFAULT_OFFLINE_COMMAND_HOOKS: OfflineCommandHooks = { + cacheEntityType: (command) => command.aggregateType, + shouldUpdateCache: () => true, +}; + +export const OFFLINE_COMMAND_HOOKS = new InjectionToken('OFFLINE_COMMAND_HOOKS', { + providedIn: 'root', + factory: () => DEFAULT_OFFLINE_COMMAND_HOOKS, +}); diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts new file mode 100644 index 0000000..ce587f3 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -0,0 +1,50 @@ +import { inject, Injectable } from '@angular/core'; +import { OfflineNetworkService } from './offline-network.service'; +import { OFFLINE_REPOSITORY } from './offline-repository'; +import { OfflineSessionService } from './offline-session.service'; +import { OfflineSyncService } from './offline-sync.service'; + +export type OfflineLogoutAction = 'sync' | 'discard' | 'cancel'; + +@Injectable({ providedIn: 'root' }) +export class OfflineCoordinatorService { + readonly #repository = inject(OFFLINE_REPOSITORY); + readonly #network = inject(OfflineNetworkService); + readonly #sync = inject(OfflineSyncService); + readonly #session = inject(OfflineSessionService); + + readonly networkState = this.#network.state; + readonly syncState = this.#sync.syncState; + readonly pendingCount = this.#sync.pendingCount; + readonly conflicts = this.#sync.conflicts; + + async initialize(): Promise { + await Promise.all([this.#repository.initialize(), this.#network.initialize()]); + await this.#session.initialize(); + await this.#sync.initialize(); + } + + async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise { + await this.#session.activateSession(userId, scopeIds, authSubject); + await this.#sync.refreshSession(); + } + + async clearActiveSession(): Promise { + await this.#session.clearActiveSession(); + await this.#sync.resetSession(); + } + + async prepareLogout(action: OfflineLogoutAction): Promise { + if (action === 'cancel') return false; + if (action === 'discard') { + await this.#sync.discardAllPending(); + return true; + } + await this.#sync.flush(); + return this.#sync.pendingCount() === 0; + } + + flush(): Promise { + return this.#sync.flush(); + } +} diff --git a/projects/kit/offline/src/lib/offline-kit-options.ts b/projects/kit/offline/src/lib/offline-kit-options.ts new file mode 100644 index 0000000..e41f271 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-kit-options.ts @@ -0,0 +1,10 @@ +import { InjectionToken } from '@angular/core'; + +/** Product-independent native offline persistence settings. */ +export interface OfflineKitOptions { + /** Encrypted SQLite database name used on iOS and Android. */ + databaseName: string; +} + +/** DI token for product-independent offline persistence settings. */ +export const OFFLINE_KIT_OPTIONS = new InjectionToken('OFFLINE_KIT_OPTIONS'); diff --git a/projects/kit/offline/src/lib/offline-network.service.ts b/projects/kit/offline/src/lib/offline-network.service.ts new file mode 100644 index 0000000..d4fb072 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-network.service.ts @@ -0,0 +1,53 @@ +import { computed, Injectable, signal } from '@angular/core'; +import { App } from '@capacitor/app'; +import type { PluginListenerHandle } from '@capacitor/core'; +import { Network } from '@capacitor/network'; + +export type OfflineNetworkState = 'online' | 'offline' | 'unverified'; + +/** transport不能(status=0)だけをcache fallback対象にし、HTTPエラーは隠さない。 */ +export function isOfflineFallbackError(error: unknown): boolean { + return typeof error === 'object' && error !== null && (error as { status?: unknown }).status === 0; +} + +@Injectable({ providedIn: 'root' }) +export class OfflineNetworkService { + readonly #osConnected = signal(null); + readonly #apiReachable = signal(null); + readonly #listeners: PluginListenerHandle[] = []; + #initialized = false; + + readonly state = computed(() => { + if (this.#osConnected() === false || this.#apiReachable() === false) return 'offline'; + if (this.#osConnected() === true && this.#apiReachable() === true) return 'online'; + return 'unverified'; + }); + readonly connected = computed(() => this.state() !== 'offline'); + + async initialize(): Promise { + if (this.#initialized) return; + this.#initialized = true; + this.#osConnected.set((await Network.getStatus()).connected); + this.#listeners.push( + await Network.addListener('networkStatusChange', ({ connected }) => { + this.#osConnected.set(connected); + this.#apiReachable.set(connected ? null : false); + }), + await App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void this.#refreshOsStatus(); + }), + ); + } + + markApiSuccess(): void { + this.#apiReachable.set(true); + } + + markApiFailure(): void { + this.#apiReachable.set(false); + } + + async #refreshOsStatus(): Promise { + this.#osConnected.set((await Network.getStatus()).connected); + } +} diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts new file mode 100644 index 0000000..29b25b0 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -0,0 +1,52 @@ +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 { 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'; +import type { OfflineKitOptions } from './offline-kit-options'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { OfflineCoordinatorService } from './offline-coordinator.service'; +import { IonicOfflineRepository, OFFLINE_REPOSITORY, selectOfflineRepository } from './offline-repository'; +import type { OfflineRequestPolicy } from './offline-request-policy'; +import { provideOfflineRequestPolicy } from './offline-request-policy'; +import { OfflineSessionService } from './offline-session.service'; +import { SqliteOfflineRepository } 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 policies that map URLs and DTOs to generic cache/outbox operations. */ + requestPolicies: readonly Type[]; + /** Optional product hooks for cache projection and command cleanup. */ + commandHooks?: Type; + /** Optional additional providers required by product adapters. */ + providers?: readonly Provider[]; +} + +/** + * Provide the standard scoped offline runtime. + * + * @remarks + * Web uses Ionic Storage. Native iOS/Android uses encrypted Capacitor SQLite. The application owns + * URL/DTO policy and command execution; the kit owns persistence, ordering, retries, and session + * isolation. + */ +export function provideOffline(options: ProvideOfflineOptions): EnvironmentProviders { + return makeEnvironmentProviders([ + options.commandExecutor, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: options.databaseName } }, + { + provide: OFFLINE_REPOSITORY, + useFactory: () => selectOfflineRepository(Capacitor.getPlatform(), inject(IonicOfflineRepository), inject(SqliteOfflineRepository)), + }, + { provide: OFFLINE_SYNC_CONTEXT, useExisting: OfflineSessionService }, + { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.commandExecutor }, + ...(options.commandHooks ? [options.commandHooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: options.commandHooks }] : []), + ...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), + ...(options.providers ?? []), + provideAppInitializer(() => inject(OfflineCoordinatorService).initialize()), + ]); +} diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts new file mode 100644 index 0000000..bd12c01 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -0,0 +1,124 @@ +import { TestBed } from '@angular/core/testing'; +import { KitStorageService } from '@rdlabo/ionic-angular-kit'; +import { describe, beforeEach, expect, it, vi } from 'vitest'; +import { isOfflineFallbackError } from './offline-network.service'; +import { + IonicOfflineRepository, + OFFLINE_REPOSITORY, + selectOfflineRepository, + type OfflineCommand, + type OfflineRepository, +} from './offline-repository'; + +class MemoryStorage { + readonly values = new Map(); + get(key: string): Promise { + return Promise.resolve((this.values.get(key) as T | undefined) ?? null); + } + set(key: string, value: T): Promise { + this.values.set(key, structuredClone(value)); + return Promise.resolve(value); + } + remove(key: string): Promise { + this.values.delete(key); + return Promise.resolve(); + } + keys(): Promise { + return Promise.resolve([...this.values.keys()]); + } +} + +describe('IonicOfflineRepository', () => { + let repository: OfflineRepository; + let storage: MemoryStorage; + + beforeEach(() => { + storage = new MemoryStorage(); + TestBed.configureTestingModule({ + providers: [ + IonicOfflineRepository, + { provide: KitStorageService, useValue: storage }, + { provide: OFFLINE_REPOSITORY, useExisting: IonicOfflineRepository }, + ], + }); + repository = TestBed.inject(OFFLINE_REPOSITORY); + }); + + it('cacheを user/group scope ごとに分離する', async () => { + await repository.putQuery({ + userId: 1, + groupId: 10, + queryKey: 'snapshot', + value: { records: [1] }, + orderedIds: [], + cursor: null, + etag: null, + fetchedAt: 1, + isComplete: true, + }); + expect(await repository.getQuery({ userId: 1, groupId: 10 }, 'snapshot')).toMatchObject({ + value: { records: [1] }, + }); + expect(await repository.getQuery({ userId: 1, groupId: 11 }, 'snapshot')).toBeNull(); + expect(await repository.getQuery({ userId: 2, groupId: 10 }, 'snapshot')).toBeNull(); + }); + + it('outboxを作成順で保持し、group削除時はそのscopeだけを消す', async () => { + const base: Omit = { + userId: 1, + aggregateType: 'documents', + aggregateId: '1', + operation: 'documents.upsert', + payload: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'pending' as const, + attempts: 0, + retryAt: null, + lastErrorCode: null, + }; + await repository.putCommand({ ...base, groupId: 10, commandId: 'later', createdAt: 20 }); + await repository.putCommand({ ...base, groupId: 10, commandId: 'earlier', createdAt: 10 }); + await repository.putCommand({ ...base, groupId: 11, commandId: 'keep', createdAt: 5 }); + expect((await repository.getCommands({ userId: 1, groupId: 10 })).map((item) => item.commandId)).toEqual(['earlier', 'later']); + + await repository.clearGroup({ userId: 1, groupId: 10 }); + expect(await repository.getCommands({ userId: 1, groupId: 10 })).toEqual([]); + expect(await repository.getCommands({ userId: 1, groupId: 11 })).toHaveLength(1); + }); + + it('local resource idを並行要求でも永続的に単調採番する', async () => { + await expect(Promise.all([repository.allocateLocalId(), repository.allocateLocalId()])).resolves.toEqual([ + 2_000_000_000_001, 2_000_000_000_002, + ]); + expect((storage.values.get('offline:metadata') as { nextLocalId: number }).nextLocalId).toBe(2_000_000_000_002); + }); + + it('未知schemaではoffline領域だけ初期化し他のstorage keyを保持する', async () => { + storage.values.set('offline:metadata', { schemaVersion: 999, lastUserId: 1 }); + storage.values.set('offline:outbox:commands', { stale: {} }); + storage.values.set('firebaseToken', { token: 'keep' }); + await repository.initialize(); + expect(storage.values.has('offline:outbox:commands')).toBe(false); + expect(storage.values.get('firebaseToken')).toEqual({ token: 'keep' }); + }); + + it('cache fallbackは通信不能だけを対象にする', () => { + expect(isOfflineFallbackError({ status: 0 })).toBe(true); + expect(isOfflineFallbackError({ status: 403 })).toBe(false); + expect(isOfflineFallbackError({ status: 500 })).toBe(false); + }); +}); + +describe('selectOfflineRepository', () => { + const web = { initialize: vi.fn() } as unknown as OfflineRepository; + const native = { initialize: vi.fn() } as unknown as OfflineRepository; + + it.each(['ios', 'android'])('%s は暗号化SQLiteを使う', (platform) => { + expect(selectOfflineRepository(platform, web, native)).toBe(native); + }); + + it('web はIonic Storageを使う', () => { + expect(selectOfflineRepository('web', web, native)).toBe(web); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts new file mode 100644 index 0000000..3c1a9ae --- /dev/null +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -0,0 +1,243 @@ +import { inject, Injectable, InjectionToken } from '@angular/core'; +import { KitStorageService } from '@rdlabo/ionic-angular-kit'; + +export const OFFLINE_SCHEMA_VERSION = 2; +const FIRST_LOCAL_RESOURCE_ID = 2_000_000_000_000; + +export interface OfflineScope { + userId: number; + groupId: number; +} + +export interface OfflineEntity extends OfflineScope { + entityType: string; + entityId: string; + value: T; + serverRevision: string | number | null; + fetchedAt: number; +} + +export interface OfflineQuery extends OfflineScope { + queryKey: string; + value: T; + orderedIds: string[]; + cursor: string | null; + etag: string | null; + fetchedAt: number; + isComplete: boolean; +} + +export type OfflineCommandState = 'pending' | 'sending' | 'retry_wait' | 'blocked_auth' | 'rejected' | 'conflict'; + +export interface OfflineCommand extends OfflineScope { + commandId: string; + aggregateType: string; + aggregateId: string; + operation: string; + payload: T; + payloadHash: string; + baseRevision: string | number | null; + state: OfflineCommandState; + attempts: number; + retryAt: number | null; + createdAt: number; + lastErrorCode: string | null; +} + +export interface OfflineRepository { + initialize(): Promise; + getLastUserId(): Promise; + setLastUserId(userId: number): Promise; + allocateLocalId(): Promise; + getEntity(scope: OfflineScope, entityType: string, entityId: string): Promise | null>; + putEntity(entity: OfflineEntity): Promise; + getQuery(scope: OfflineScope, queryKey: string): Promise | null>; + putQuery(query: OfflineQuery): Promise; + getCommands(scope: OfflineScope): Promise; + putCommand(command: OfflineCommand): Promise; + replaceCommand(command: OfflineCommand): Promise; + removeCommand(commandId: string): Promise; + clearUser(userId: number): Promise; + clearGroup(scope: OfflineScope): Promise; +} + +export const OFFLINE_REPOSITORY = new InjectionToken('OFFLINE_REPOSITORY'); + +export function selectOfflineRepository( + platform: string, + webRepository: OfflineRepository, + nativeRepository: OfflineRepository, +): OfflineRepository { + return platform === 'ios' || platform === 'android' ? nativeRepository : webRepository; +} + +interface OfflineMetadata { + schemaVersion: number; + lastUserId: number | null; + nextLocalId: number; +} + +const METADATA_KEY = 'offline:metadata'; +const ENTITIES_KEY = 'offline:business_cache:entities'; +const QUERIES_KEY = 'offline:business_cache:queries'; +const OUTBOX_KEY = 'offline:outbox:commands'; + +/** WebはIonic StorageのIndexedDB driverを利用する。 */ +@Injectable({ providedIn: 'root' }) +export class IonicOfflineRepository implements OfflineRepository { + readonly #storage = inject(KitStorageService); + #initialization: Promise | null = null; + #writes: Promise = Promise.resolve(); + + initialize(): Promise { + this.#initialization ??= this.#migrate(); + return this.#initialization; + } + + async getLastUserId(): Promise { + await this.initialize(); + return (await this.#metadata()).lastUserId; + } + + async setLastUserId(userId: number): Promise { + await this.initialize(); + await this.#storage.set(METADATA_KEY, { ...(await this.#metadata()), lastUserId: userId }); + } + + async allocateLocalId(): Promise { + await this.initialize(); + let allocated = FIRST_LOCAL_RESOURCE_ID; + const write = this.#writes.then(async (): Promise => { + const metadata = await this.#metadata(); + allocated = Math.max(metadata.nextLocalId, FIRST_LOCAL_RESOURCE_ID) + 1; + await this.#storage.set(METADATA_KEY, { ...metadata, nextLocalId: allocated }); + }); + this.#writes = write.catch((): void => undefined); + await write; + return allocated; + } + + async getEntity(scope: OfflineScope, entityType: string, entityId: string): Promise | null> { + await this.initialize(); + const entities = await this.#readRecord>(ENTITIES_KEY); + return entities[this.#entityKey(scope, entityType, entityId)] ?? null; + } + + async putEntity(entity: OfflineEntity): Promise { + await this.initialize(); + await this.#mutateRecord>(ENTITIES_KEY, (entities) => { + entities[this.#entityKey(entity, entity.entityType, entity.entityId)] = entity; + return entities; + }); + } + + async getQuery(scope: OfflineScope, queryKey: string): Promise | null> { + await this.initialize(); + const queries = await this.#readRecord>(QUERIES_KEY); + return queries[this.#queryKey(scope, queryKey)] ?? null; + } + + async putQuery(query: OfflineQuery): Promise { + await this.initialize(); + await this.#mutateRecord>(QUERIES_KEY, (queries) => { + queries[this.#queryKey(query, query.queryKey)] = query; + return queries; + }); + } + + async getCommands(scope: OfflineScope): Promise { + await this.initialize(); + const commands = await this.#readRecord(OUTBOX_KEY); + return Object.values(commands) + .filter((command) => command.userId === scope.userId && command.groupId === scope.groupId) + .sort((left, right) => left.createdAt - right.createdAt); + } + + async putCommand(command: OfflineCommand): Promise { + await this.initialize(); + await this.#mutateRecord(OUTBOX_KEY, (commands) => { + commands[command.commandId] = command; + return commands; + }); + } + + replaceCommand(command: OfflineCommand): Promise { + return this.putCommand(command); + } + + async removeCommand(commandId: string): Promise { + await this.initialize(); + await this.#mutateRecord(OUTBOX_KEY, (commands) => { + delete commands[commandId]; + return commands; + }); + } + + async clearUser(userId: number): Promise { + await this.initialize(); + await Promise.all([ + this.#filterRecord(ENTITIES_KEY, (value) => value.userId !== userId), + this.#filterRecord(QUERIES_KEY, (value) => value.userId !== userId), + this.#filterRecord(OUTBOX_KEY, (value) => value.userId !== userId), + ]); + const metadata = await this.#metadata(); + if (metadata.lastUserId === userId) { + await this.#storage.set(METADATA_KEY, { ...metadata, lastUserId: null }); + } + } + + async clearGroup(scope: OfflineScope): Promise { + await this.initialize(); + const belongsToGroup = (value: OfflineScope) => value.userId === scope.userId && value.groupId === scope.groupId; + await Promise.all([ + this.#filterRecord(ENTITIES_KEY, (value) => !belongsToGroup(value)), + this.#filterRecord(QUERIES_KEY, (value) => !belongsToGroup(value)), + this.#filterRecord(OUTBOX_KEY, (value) => !belongsToGroup(value)), + ]); + } + + async #migrate(): Promise { + const metadata = await this.#storage.get(METADATA_KEY); + if (metadata?.schemaVersion === OFFLINE_SCHEMA_VERSION) return; + await Promise.all([this.#storage.remove(ENTITIES_KEY), this.#storage.remove(QUERIES_KEY), this.#storage.remove(OUTBOX_KEY)]); + await this.#storage.set(METADATA_KEY, { + schemaVersion: OFFLINE_SCHEMA_VERSION, + lastUserId: metadata?.lastUserId ?? null, + nextLocalId: metadata?.nextLocalId ?? FIRST_LOCAL_RESOURCE_ID, + }); + } + + async #metadata(): Promise { + const metadata = await this.#storage.get>(METADATA_KEY); + return { + schemaVersion: metadata?.schemaVersion ?? OFFLINE_SCHEMA_VERSION, + lastUserId: metadata?.lastUserId ?? null, + nextLocalId: metadata?.nextLocalId ?? FIRST_LOCAL_RESOURCE_ID, + }; + } + + async #readRecord(key: string): Promise> { + return (await this.#storage.get>(key)) ?? {}; + } + + async #filterRecord(key: string, predicate: (value: T) => boolean): Promise { + await this.#mutateRecord(key, (record) => Object.fromEntries(Object.entries(record).filter(([, value]) => predicate(value)))); + } + + #mutateRecord(key: string, mutate: (record: Record) => Record): Promise { + const write = this.#writes.then(async (): Promise => { + const record = await this.#readRecord(key); + await this.#storage.set(key, mutate(record)); + }); + this.#writes = write.catch((): void => undefined); + return write; + } + + #entityKey(scope: OfflineScope, entityType: string, entityId: string): string { + return `${scope.userId}:${scope.groupId}:${entityType}:${entityId}`; + } + + #queryKey(scope: OfflineScope, queryKey: string): string { + return `${scope.userId}:${scope.groupId}:${queryKey}`; + } +} diff --git a/projects/kit/offline/src/lib/offline-request-policy.ts b/projects/kit/offline/src/lib/offline-request-policy.ts new file mode 100644 index 0000000..a61865e --- /dev/null +++ b/projects/kit/offline/src/lib/offline-request-policy.ts @@ -0,0 +1,54 @@ +import type { Provider, Type } from '@angular/core'; +import { inject, Injectable, InjectionToken } from '@angular/core'; +import type { HttpRequest, HttpResponse } from '@angular/common/http'; +import { HttpContextToken } from '@angular/common/http'; + +/** outbox再送時にoffline interceptorだけを迂回する。認証・retryは維持する。 */ +export const OFFLINE_BYPASS = new HttpContextToken(() => false); +export const OFFLINE_RESPONSE_HEADER = 'X-Offline-Response'; +export type OfflineResponseSource = 'cache' | 'queued'; + +export interface OfflineReadRequestPlan { + kind: 'read'; + readCached(): Promise | null>; + storeFresh(response: HttpResponse): Promise; +} + +export interface OfflineQueuedMutation { + commandId: string; + response?: HttpResponse; +} + +export interface OfflineMutationRequestPlan { + kind: 'mutation'; + enqueue?: () => Promise; + storeFresh?: (response: HttpResponse) => Promise; +} + +export type OfflineRequestPlan = OfflineReadRequestPlan | OfflineMutationRequestPlan; + +/** URL・DTO・cache key・feature flagは製品Policyだけが知る。 */ +export interface OfflineRequestPolicy { + resolve(request: HttpRequest): OfflineRequestPlan | null; +} + +export const OFFLINE_REQUEST_POLICIES = new InjectionToken('OFFLINE_REQUEST_POLICIES', { + factory: () => [], +}); + +export function provideOfflineRequestPolicy(policy: Type): Provider[] { + return [policy, { provide: OFFLINE_REQUEST_POLICIES, useExisting: policy, multi: true }]; +} + +@Injectable({ providedIn: 'root' }) +export class OfflineRequestPolicyRegistry { + readonly #policies = inject(OFFLINE_REQUEST_POLICIES); + + resolve(request: HttpRequest): OfflineRequestPlan | null { + for (const policy of this.#policies) { + const plan = policy.resolve(request); + if (plan) return plan; + } + return null; + } +} diff --git a/projects/kit/offline/src/lib/offline-session.service.spec.ts b/projects/kit/offline/src/lib/offline-session.service.spec.ts new file mode 100644 index 0000000..b40b35b --- /dev/null +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -0,0 +1,67 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OFFLINE_REPOSITORY, type OfflineEntity, type OfflineRepository, type OfflineScope } from './offline-repository'; +import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service'; + +describe('OfflineSessionService shared-device boundary', () => { + let service: OfflineSessionService; + let lastUserId: number | null; + let entities: OfflineEntity[]; + let clearUser: ReturnType; + + beforeEach(() => { + lastUserId = 10; + entities = [ + { + userId: 10, + groupId: 0, + entityType: '__offline_session', + entityId: 'current', + value: { userId: 10, scopeIds: [1], authSubject: 'uid-A', updatedAt: 1 } satisfies OfflineSessionManifest, + serverRevision: null, + fetchedAt: 1, + }, + ]; + clearUser = vi.fn(async (userId: number) => { + entities = entities.filter((entity) => entity.userId !== userId); + if (lastUserId === userId) lastUserId = null; + }); + const repository = { + initialize: vi.fn(async () => undefined), + getLastUserId: vi.fn(async () => lastUserId), + setLastUserId: vi.fn(async (userId: number) => { + lastUserId = userId; + }), + getEntity: vi.fn( + async (scope: OfflineScope, type: string, id: string) => + entities.find( + (entity) => + entity.userId === scope.userId && entity.groupId === scope.groupId && entity.entityType === type && entity.entityId === id, + ) ?? null, + ), + putEntity: vi.fn(async (entity: OfflineEntity) => { + entities.push(entity); + }), + clearUser, + clearGroup: vi.fn(async () => undefined), + } as unknown as OfflineRepository; + TestBed.configureTestingModule({ + providers: [OfflineSessionService, { provide: OFFLINE_REPOSITORY, useValue: repository }], + }); + service = TestBed.inject(OfflineSessionService); + }); + + it('起動時に旧manifestを復元しても認証後activateまではsync contextへ公開しない', async () => { + await service.initialize(); + await expect(service.getSession()).resolves.toBeNull(); + }); + + it('AからBへ認証主体が変わるとA全scopeを削除してからBを有効化する', async () => { + await service.initialize(); + await service.activateSession(20, [2], 'uid-B'); + expect(clearUser).toHaveBeenCalledWith(10); + expect(lastUserId).toBe(20); + await expect(service.getSession()).resolves.toEqual({ userId: 20, scopes: [{ userId: 20, groupId: 2 }] }); + expect(service.activeManifest()).toMatchObject({ userId: 20, authSubject: 'uid-B' }); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts new file mode 100644 index 0000000..9dc1fa2 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -0,0 +1,94 @@ +import { inject, Injectable, signal } from '@angular/core'; +import type { OfflineScope } from './offline-repository'; +import { OFFLINE_REPOSITORY } from './offline-repository'; + +const SESSION_ENTITY_TYPE = '__offline_session'; +const SESSION_ENTITY_ID = 'current'; +const SESSION_GROUP_ID = 0; + +export interface OfflineSessionManifest { + userId: number; + scopeIds: number[]; + /** Authentication-provider subject used to distinguish users on a shared device. */ + authSubject: string | null; + updatedAt: number; +} + +@Injectable({ providedIn: 'root' }) +export class OfflineSessionService { + readonly #repository = inject(OFFLINE_REPOSITORY); + readonly #activeManifest = signal(null); + #initialized = false; + #activatedThisRun = false; + + readonly activeManifest = this.#activeManifest.asReadonly(); + + async initialize(): Promise { + if (this.#initialized) return; + await this.#repository.initialize(); + const userId = await this.#repository.getLastUserId(); + if (userId !== null) { + const entity = await this.#repository.getEntity( + { userId, groupId: SESSION_GROUP_ID }, + SESSION_ENTITY_TYPE, + SESSION_ENTITY_ID, + ); + this.#activeManifest.set(entity?.value ?? { userId, scopeIds: [], authSubject: null, updatedAt: 0 }); + } + this.#initialized = true; + } + + async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise { + await this.initialize(); + const normalizedScopeIds = [...new Set(scopeIds)].filter((id) => id !== SESSION_GROUP_ID).sort((a, b) => a - b); + const previousUserId = await this.#repository.getLastUserId(); + if (previousUserId !== null && previousUserId !== userId) await this.#repository.clearUser(previousUserId); + + const sessionScope = { userId, groupId: SESSION_GROUP_ID }; + const previous = + previousUserId === userId + ? ((await this.#repository.getEntity(sessionScope, SESSION_ENTITY_TYPE, SESSION_ENTITY_ID))?.value ?? null) + : null; + const active = new Set(normalizedScopeIds); + await Promise.all( + (previous?.scopeIds ?? []) + .filter((groupId) => !active.has(groupId)) + .map((groupId) => this.#repository.clearGroup({ userId, groupId })), + ); + + const manifest: OfflineSessionManifest = { + userId, + scopeIds: normalizedScopeIds, + authSubject, + updatedAt: Date.now(), + }; + await this.#repository.setLastUserId(userId); + await this.#repository.putEntity({ + ...sessionScope, + entityType: SESSION_ENTITY_TYPE, + entityId: SESSION_ENTITY_ID, + value: manifest, + serverRevision: null, + fetchedAt: manifest.updatedAt, + }); + this.#activeManifest.set(manifest); + this.#activatedThisRun = true; + } + + async clearActiveSession(): Promise { + await this.initialize(); + const userId = await this.#repository.getLastUserId(); + if (userId !== null) await this.#repository.clearUser(userId); + this.#activeManifest.set(null); + this.#activatedThisRun = false; + } + + async getSession(): Promise<{ userId: number; scopes: OfflineScope[] } | null> { + await this.initialize(); + if (!this.#activatedThisRun) return null; + const manifest = this.#activeManifest(); + return manifest + ? { userId: manifest.userId, scopes: manifest.scopeIds.map((groupId) => ({ userId: manifest.userId, groupId })) } + : null; + } +} diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts new file mode 100644 index 0000000..4112dcc --- /dev/null +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -0,0 +1,109 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor'; +import { OfflineNetworkService } from './offline-network.service'; +import { + OFFLINE_REPOSITORY, + type OfflineCommand, + type OfflineEntity, + type OfflineRepository, + type OfflineScope, +} from './offline-repository'; +import { OfflineSyncService } from './offline-sync.service'; + +describe('OfflineSyncService', () => { + let service: OfflineSyncService; + let commands: OfflineCommand[]; + let entities: OfflineEntity[]; + let connected: ReturnType>; + const execute = vi.fn(async (_command: OfflineCommand) => ({ response: null })); + + beforeEach(() => { + commands = []; + entities = []; + connected = signal(false); + execute.mockReset(); + execute.mockResolvedValue({ response: null }); + const repository = { + initialize: vi.fn(async () => undefined), + getCommands: vi.fn(async (scope: OfflineScope) => + commands.filter((item) => item.userId === scope.userId && item.groupId === scope.groupId), + ), + putCommand: vi.fn(async (command: OfflineCommand) => { + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + commands.sort((left, right) => left.createdAt - right.createdAt); + }), + replaceCommand: vi.fn(async (command: OfflineCommand) => { + commands = commands.map((item) => (item.commandId === command.commandId ? structuredClone(command) : item)); + }), + removeCommand: vi.fn(async (commandId: string) => { + commands = commands.filter((item) => item.commandId !== commandId); + }), + getEntity: vi.fn( + async (scope: OfflineScope, type: string, id: string) => + entities.find( + (item) => item.userId === scope.userId && item.groupId === scope.groupId && item.entityType === type && item.entityId === id, + ) ?? null, + ), + putEntity: vi.fn(async (entity: OfflineEntity) => { + entities = entities.filter( + (item) => + item.userId !== entity.userId || + item.groupId !== entity.groupId || + item.entityType !== entity.entityType || + item.entityId !== entity.entityId, + ); + entities.push(entity); + }), + } as unknown as OfflineRepository; + TestBed.configureTestingModule({ + providers: [ + OfflineSyncService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: { connected } }, + { + provide: OFFLINE_SYNC_CONTEXT, + useValue: { getSession: vi.fn(async () => ({ userId: 1, scopes: [{ userId: 1, groupId: 10 }] })) }, + }, + { + provide: OFFLINE_COMMAND_EXECUTOR, + useValue: { execute, withServerRevision: (command: OfflineCommand) => command }, + }, + ], + }); + service = TestBed.inject(OfflineSyncService); + }); + + it('同じaggregateの操作を作成順に送り、成功後だけoutboxから除く', async () => { + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { flush: false }, + ); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 2 } }, + { flush: false }, + ); + connected.set(true); + await service.flush(); + expect(execute.mock.calls.map(([command]) => (command as OfflineCommand<{ seq: number }>).payload.seq)).toEqual([1, 2]); + expect(service.pendingCount()).toBe(0); + }); + + it.each([ + [401, 'blocked_auth'], + [409, 'conflict'], + [422, 'rejected'], + [500, 'retry_wait'], + ] as const)('HTTP %sを%sへ分類して操作を保持する', async (status, state) => { + execute.mockRejectedValueOnce({ status }); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: {} }, + { flush: false }, + ); + connected.set(true); + await service.flush(); + expect(service.pendingCommands()[0]).toMatchObject({ state, lastErrorCode: String(status) }); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts new file mode 100644 index 0000000..e632415 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -0,0 +1,337 @@ +import { computed, effect, inject, Injectable, signal } from '@angular/core'; +import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { OfflineNetworkService } from './offline-network.service'; +import type { OfflineCommand, OfflineScope } from './offline-repository'; +import { OFFLINE_REPOSITORY } from './offline-repository'; + +export type OfflineSyncState = 'idle' | 'pending' | 'syncing' | 'attention'; + +export interface EnqueueOfflineCommand { + groupId: number; + aggregateType: string; + aggregateId: string; + operation: string; + payload: T; + baseRevision?: string | number | null; +} + +const MAX_PARALLEL_AGGREGATES = 3; +const MAX_BACKOFF_MS = 60 * 60 * 1000; + +@Injectable({ providedIn: 'root' }) +export class OfflineSyncService { + readonly #network = inject(OfflineNetworkService); + readonly #repository = inject(OFFLINE_REPOSITORY); + readonly #executor = inject(OFFLINE_COMMAND_EXECUTOR); + readonly #context = inject(OFFLINE_SYNC_CONTEXT); + readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); + readonly #commands = signal([]); + readonly #knownScopes = new Map(); + #activeUserId: number | null = null; + #flushPromise: Promise | null = null; + #retryTimer: ReturnType | null = null; + #initialized = false; + + readonly pendingCommands = this.#commands.asReadonly(); + readonly pendingCount = computed(() => this.pendingCommands().length); + readonly conflicts = computed(() => this.#commands().filter((command) => command.state === 'conflict')); + readonly syncState = computed(() => { + const commands = this.#commands(); + if (commands.some((command) => ['blocked_auth', 'rejected', 'conflict'].includes(command.state))) return 'attention'; + if (commands.some((command) => command.state === 'sending')) return 'syncing'; + return commands.length > 0 ? 'pending' : 'idle'; + }); + + constructor() { + effect(() => { + if (this.#initialized && this.#network.connected()) void this.flush(); + }); + } + + async initialize(): Promise { + if (this.#initialized) return; + await this.#repository.initialize(); + await this.#discoverScopes(); + const commands = await this.#readKnownCommands(); + await Promise.all( + commands + .filter((command) => command.state === 'sending') + .map((command) => this.#repository.putCommand({ ...command, state: 'pending' })), + ); + this.#initialized = true; + await this.#refreshState(); + if (this.#network.connected()) void this.flush(); + } + + noteScope(scope: OfflineScope): void { + this.#knownScopes.set(this.#scopeKey(scope), scope); + } + + async reloadPendingCommands(): Promise { + await this.initialize(); + await this.#refreshState(); + } + + async refreshSession(): Promise { + await this.initialize(); + await this.#discoverScopes(); + await this.#refreshState(); + if (this.#network.connected()) void this.flush(); + } + + async resetSession(): Promise { + this.#activeUserId = null; + this.#knownScopes.clear(); + this.#commands.set([]); + this.#scheduleRetry(null); + } + + async enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean } = {}): Promise { + await this.initialize(); + const session = await this.#context.getSession(); + if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); + const userId = session.userId; + this.#setActiveUser(userId); + const scope = { userId, groupId: request.groupId }; + this.noteScope(scope); + const normalized = await this.#normalizeEnqueueRequest(scope, request); + const commandId = crypto.randomUUID(); + await this.#repository.putCommand({ + ...scope, + commandId, + aggregateType: request.aggregateType, + aggregateId: request.aggregateId, + operation: request.operation, + payload: normalized.payload, + payloadHash: await this.#payloadHash(normalized.payload), + baseRevision: normalized.baseRevision, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: Date.now(), + lastErrorCode: null, + }); + await this.#refreshState(); + if (options.flush !== false && this.#network.connected()) void this.flush(); + return commandId; + } + + async discard(commandId: string, options: { flush?: boolean } = {}): Promise { + await this.initialize(); + const command = (await this.#readKnownCommands()).find((item) => item.commandId === commandId); + if (!command) return; + await this.#repository.removeCommand(command.commandId); + await this.#hooks.onCommandRemoved?.(command); + await this.#refreshState(); + if (options.flush !== false && this.#network.connected()) void this.flush(); + } + + async discardAllPending(): Promise { + await this.initialize(); + await Promise.all( + this.pendingCommands().map(async (command) => { + await this.#repository.removeCommand(command.commandId); + await this.#hooks.onCommandRemoved?.(command); + }), + ); + await this.#refreshState(); + } + + flush(): Promise { + this.#flushPromise ??= this.#runFlush().finally(() => (this.#flushPromise = null)); + return this.#flushPromise; + } + + async #runFlush(): Promise { + if (!this.#network.connected()) { + await this.#refreshState(); + return; + } + await this.#discoverScopes(); + while (this.#network.connected()) { + const groups = this.#eligibleAggregateGroups(await this.#readKnownCommands()); + if (groups.length === 0) break; + let cursor = 0; + const workers = Array.from({ length: Math.min(MAX_PARALLEL_AGGREGATES, groups.length) }, async () => { + while (cursor < groups.length) { + const group = groups[cursor++]; + if (group) await this.#sendAggregate(group); + } + }); + await Promise.all(workers); + } + await this.#refreshState(); + } + + #eligibleAggregateGroups(commands: OfflineCommand[]): OfflineCommand[][] { + const now = Date.now(); + const groups = new Map(); + for (const command of commands) { + const key = `${command.userId}:${command.groupId}:${command.aggregateType}:${command.aggregateId}`; + const group = groups.get(key) ?? []; + group.push(command); + groups.set(key, group); + } + return [...groups.values()].filter((group) => { + const head = group[0]; + return head?.state === 'pending' || (head?.state === 'retry_wait' && (head.retryAt ?? 0) <= now); + }); + } + + async #sendAggregate(commands: OfflineCommand[]): Promise { + for (let index = 0; index < commands.length; index++) { + const command = commands[index]!; + if (command.state === 'retry_wait' && (command.retryAt ?? 0) > Date.now()) break; + if (!['pending', 'retry_wait'].includes(command.state)) break; + const sending: OfflineCommand = { + ...command, + state: 'sending', + attempts: command.attempts + 1, + retryAt: null, + lastErrorCode: null, + }; + await this.#repository.putCommand(sending); + await this.#refreshState(); + try { + const result = await this.#executor.execute(sending); + const revision = result.serverRevision; + if (this.#hooks.shouldUpdateCache(sending, result)) { + await this.#updateCachedEntity(sending, result); + if (revision !== undefined) await this.#rebaseFollowingCommands(commands, index + 1, revision); + } + await this.#repository.removeCommand(sending.commandId); + } catch (error) { + const failed = this.#failedCommand(sending, error); + await this.#repository.putCommand(failed); + if (failed.state === 'retry_wait') this.#scheduleRetry(failed.retryAt); + break; + } + } + } + + async #normalizeEnqueueRequest( + scope: OfflineScope, + request: EnqueueOfflineCommand, + ): Promise<{ payload: T; baseRevision: string | number | null }> { + let baseRevision = request.baseRevision ?? null; + let payload = request.payload; + const entity = await this.#repository.getEntity(scope, this.#hooks.cacheEntityType(request), request.aggregateId); + if (entity?.serverRevision != null && entity.serverRevision !== baseRevision) { + const rebased = this.#executor.withServerRevision( + { ...scope, ...request, payload, baseRevision } as OfflineCommand, + entity.serverRevision, + ); + baseRevision = entity.serverRevision; + payload = rebased.payload as T; + } + return { payload, baseRevision }; + } + + async #updateCachedEntity(command: OfflineCommand, result: { serverRevision?: string | number; response?: unknown }) { + const scope = { userId: command.userId, groupId: command.groupId }; + const entityType = this.#hooks.cacheEntityType(command); + const cached = await this.#repository.getEntity(scope, entityType, command.aggregateId); + if (!cached) return; + const projected = this.#executor.projectEntity?.(command, cached, result); + if (!projected && result.serverRevision === undefined) return; + const updated = projected ?? { ...cached, fetchedAt: Date.now() }; + await this.#repository.putEntity({ + ...updated, + entityType, + serverRevision: result.serverRevision ?? updated.serverRevision, + }); + } + + async #rebaseFollowingCommands(commands: OfflineCommand[], start: number, revision: string | number) { + for (let index = start; index < commands.length; index++) { + const rebased = this.#executor.withServerRevision(commands[index]!, revision); + commands[index] = rebased; + await this.#repository.replaceCommand(rebased); + } + } + + #failedCommand(command: OfflineCommand, error: unknown): OfflineCommand { + const status = this.#errorStatus(error); + if (status === 401 || status === 403) return { ...command, state: 'blocked_auth', lastErrorCode: String(status) }; + if (status === 409 || status === 412) return { ...command, state: 'conflict', lastErrorCode: String(status) }; + if (status >= 400 && status < 500 && status !== 429) { + return { ...command, state: 'rejected', lastErrorCode: String(status) }; + } + const retryAt = Date.now() + Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.max(0, command.attempts - 1)); + return { ...command, state: 'retry_wait', retryAt, lastErrorCode: status > 0 ? String(status) : 'network' }; + } + + #errorStatus(error: unknown): number { + if (typeof error !== 'object' || error === null) return 0; + const status = (error as { status?: unknown }).status; + return typeof status === 'number' ? status : 0; + } + + async #discoverScopes(): Promise { + const session = await this.#context.getSession(); + if (!session) { + this.#activeUserId = null; + this.#knownScopes.clear(); + return; + } + this.#setActiveUser(session.userId); + this.#knownScopes.clear(); + for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); + } + + #setActiveUser(userId: number): void { + if (this.#activeUserId === userId) return; + this.#knownScopes.clear(); + this.#activeUserId = userId; + } + + async #readKnownCommands(): Promise { + return (await Promise.all([...this.#knownScopes.values()].map((scope) => this.#repository.getCommands(scope)))) + .flat() + .sort((left, right) => left.createdAt - right.createdAt); + } + + async #refreshState(): Promise { + const commands = await this.#readKnownCommands(); + this.#commands.set(commands); + const nextRetry = commands + .filter((command) => command.state === 'retry_wait' && command.retryAt !== null) + .reduce((earliest, command) => Math.min(earliest ?? command.retryAt!, command.retryAt!), null); + this.#scheduleRetry(nextRetry); + } + + #scheduleRetry(retryAt: number | null): void { + if (this.#retryTimer) clearTimeout(this.#retryTimer); + this.#retryTimer = null; + if (retryAt === null) return; + this.#retryTimer = setTimeout( + () => { + this.#retryTimer = null; + if (this.#network.connected()) void this.flush(); + }, + Math.max(0, retryAt - Date.now()), + ); + } + + async #payloadHash(payload: unknown): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this.#canonicalJson(payload))); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + } + + #canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map((item) => this.#canonicalJson(item)).join(',')}]`; + if (value !== null && typeof value === 'object') { + return `{${Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${this.#canonicalJson(item)}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; + } + + #scopeKey(scope: OfflineScope): string { + return `${scope.userId}:${scope.groupId}`; + } +} diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts new file mode 100644 index 0000000..466cbed --- /dev/null +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -0,0 +1,69 @@ +import { HttpContext, HttpErrorResponse, HttpRequest, HttpResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { firstValueFrom, of, throwError } from 'rxjs'; +import { offlineInterceptor } from './offline.interceptor'; +import { OFFLINE_BYPASS, OFFLINE_RESPONSE_HEADER, type OfflineRequestPlan, OfflineRequestPolicyRegistry } from './offline-request-policy'; + +describe('offlineInterceptor', () => { + let resolve: ReturnType) => OfflineRequestPlan | null>>; + + beforeEach(() => { + resolve = vi.fn(() => null); + TestBed.configureTestingModule({ + providers: [{ provide: OfflineRequestPolicyRegistry, useValue: { resolve } }], + }); + }); + + it('再送requestはpolicyを迂回してtransportへ渡す', async () => { + const request = new HttpRequest('POST', '/resource', null, { + context: new HttpContext().set(OFFLINE_BYPASS, true), + }); + const response = new HttpResponse({ status: 201 }); + const next = vi.fn(() => of(response)); + await expect(firstValueFrom(run(request, next))).resolves.toBe(response); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('GET成功を保存してから返す', async () => { + const storeFresh = vi.fn(async (): Promise => undefined); + resolve.mockReturnValue({ kind: 'read', storeFresh, readCached: vi.fn() }); + const response = new HttpResponse({ body: { userId: 1 }, status: 200 }); + await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => of(response)))).resolves.toBe(response); + expect(storeFresh).toHaveBeenCalledWith(response); + }); + + it('status=0だけcached responseへfallbackする', async () => { + const cached = new HttpResponse({ body: { dataFrom: 'api' }, status: 200 }); + const readCached = vi.fn(async () => cached); + resolve.mockReturnValue({ kind: 'read', storeFresh: vi.fn(), readCached }); + const error = new HttpErrorResponse({ status: 0, error: new Error('offline') }); + const response = await firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => error))); + expect(response instanceof HttpResponse && response.headers.get(OFFLINE_RESPONSE_HEADER)).toBe('cache'); + expect(readCached).toHaveBeenCalledOnce(); + }); + + it('403/500はcacheで隠さない', async () => { + const readCached = vi.fn(); + resolve.mockReturnValue({ kind: 'read', storeFresh: vi.fn(), readCached }); + for (const status of [403, 500]) { + const error = new HttpErrorResponse({ status }); + await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => error)))).rejects.toBe(error); + } + expect(readCached).not.toHaveBeenCalled(); + }); + + it('mutationはtransportより先にoutboxへ保存しsynthetic responseを返す', async () => { + const enqueue = vi.fn(async () => ({ commandId: 'command-1' })); + resolve.mockReturnValue({ kind: 'mutation', enqueue }); + const next = vi.fn(); + const response = await firstValueFrom(run(new HttpRequest('POST', '/groups/1/documents', {}), next)); + expect(next).not.toHaveBeenCalled(); + expect(response).toEqual(expect.objectContaining({ status: 202, body: { queued: true, commandId: 'command-1' } })); + expect(response instanceof HttpResponse && response.headers.get(OFFLINE_RESPONSE_HEADER)).toBe('queued'); + }); +}); + +function run(request: HttpRequest, next: Parameters[1]) { + return TestBed.runInInjectionContext(() => offlineInterceptor(request, next)); +} diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts new file mode 100644 index 0000000..8921e2a --- /dev/null +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -0,0 +1,76 @@ +import type { HttpEvent, HttpInterceptorFn, HttpRequest, HttpResponse } from '@angular/common/http'; +import { HttpHeaders, HttpResponse as AngularHttpResponse } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { catchError, concatMap, defer, from, map, of, throwError } from 'rxjs'; +import { isOfflineFallbackError } from './offline-network.service'; +import { + OFFLINE_BYPASS, + OFFLINE_RESPONSE_HEADER, + type OfflineMutationRequestPlan, + OfflineRequestPolicyRegistry, +} from './offline-request-policy'; + +export const offlineInterceptor: HttpInterceptorFn = (request, next) => { + if (request.context.get(OFFLINE_BYPASS)) return next(request); + const registry = inject(OfflineRequestPolicyRegistry); + const fallback = inject(OfflineRequestFallbackService); + const plan = registry.resolve(request); + if (!plan) return next(request); + if (plan.kind === 'mutation') { + if (plan.enqueue) return enqueueMutation(plan.enqueue, request.urlWithParams); + if (!plan.storeFresh) return next(request); + return observeRemoteResponse(next(request), plan.storeFresh); + } + if (request.method !== 'GET') return next(request); + return observeRemoteResponse( + defer(() => next(request)), + plan.storeFresh, + ).pipe(catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error))); +}; + +@Injectable({ providedIn: 'root' }) +export class OfflineRequestFallbackService { + readonly #registry = inject(OfflineRequestPolicyRegistry); + + handle( + request: HttpRequest, + error: unknown, + resolvedPlan?: ReturnType, + ): Observable> | null { + if (request.context.get(OFFLINE_BYPASS) || request.method !== 'GET' || !isOfflineFallbackError(error)) return null; + const plan = resolvedPlan ?? this.#registry.resolve(request); + if (!plan || plan.kind !== 'read') return null; + return defer(() => from(plan.readCached())).pipe( + concatMap((cached) => + cached ? of(cached.clone({ headers: cached.headers.set(OFFLINE_RESPONSE_HEADER, 'cache') })) : throwError(() => error), + ), + catchError(() => throwError(() => error)), + ); + } +} + +function observeRemoteResponse( + source: Observable>, + storeFresh: (response: HttpResponse) => Promise, +): Observable> { + return source.pipe( + concatMap((event) => { + if (!(event instanceof AngularHttpResponse) || event.headers.has(OFFLINE_RESPONSE_HEADER)) return of(event); + return defer(() => from(storeFresh(event))).pipe( + map(() => event), + catchError(() => of(event)), + ); + }), + ); +} + +function enqueueMutation(enqueue: NonNullable, url: string): Observable> { + return defer(() => from(enqueue())).pipe( + map(({ commandId, response }) => + (response ?? new AngularHttpResponse({ body: { queued: true, commandId }, status: 202, statusText: 'Accepted', url })).clone({ + headers: (response?.headers ?? new HttpHeaders()).set(OFFLINE_RESPONSE_HEADER, 'queued'), + }), + ), + ); +} diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts new file mode 100644 index 0000000..718fe11 --- /dev/null +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -0,0 +1,366 @@ +import { inject, Injectable, InjectionToken } from '@angular/core'; +import { CapacitorSQLite, SQLiteConnection, type SQLiteDBConnection } from '@capacitor-community/sqlite'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { + OFFLINE_SCHEMA_VERSION, + type OfflineCommand, + type OfflineEntity, + type OfflineQuery, + type OfflineRepository, + type OfflineScope, +} from './offline-repository'; + +export const OFFLINE_SQLITE_CONNECTION = new InjectionToken('OFFLINE_SQLITE_CONNECTION', { + factory: () => new SQLiteConnection(CapacitorSQLite), +}); + +type SQLiteValue = string | number | null; +type SQLiteRow = Record; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS offline_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + schema_version INTEGER NOT NULL, + last_user_id INTEGER, + next_local_id INTEGER NOT NULL DEFAULT 2000000000000 +); +CREATE TABLE IF NOT EXISTS offline_entities ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + value_json TEXT NOT NULL, + server_revision_json TEXT, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (user_id, group_id, entity_type, entity_id) +); +CREATE TABLE IF NOT EXISTS offline_queries ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + query_key TEXT NOT NULL, + value_json TEXT NOT NULL, + ordered_ids_json TEXT NOT NULL, + cursor TEXT, + etag TEXT, + fetched_at INTEGER NOT NULL, + is_complete INTEGER NOT NULL, + PRIMARY KEY (user_id, group_id, query_key) +); +CREATE TABLE IF NOT EXISTS offline_sync_commands ( + command_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + operation TEXT NOT NULL, + payload_json TEXT NOT NULL, + payload_hash TEXT NOT NULL, + base_revision_json TEXT, + state TEXT NOT NULL, + attempts INTEGER NOT NULL, + retry_at INTEGER, + created_at INTEGER NOT NULL, + last_error_code TEXT +); +CREATE INDEX IF NOT EXISTS offline_sync_commands_scope_created + ON offline_sync_commands (user_id, group_id, created_at); +`; + +/** Native iOS/AndroidはSQLCipher対応Capacitor SQLiteを利用する。 */ +@Injectable({ providedIn: 'root' }) +export class SqliteOfflineRepository implements OfflineRepository { + readonly #sqlite = inject(OFFLINE_SQLITE_CONNECTION); + readonly #options = inject(OFFLINE_KIT_OPTIONS); + #database: SQLiteDBConnection | null = null; + #initialization: Promise | null = null; + #writes: Promise = Promise.resolve(); + + initialize(): Promise { + this.#initialization ??= this.#open(); + return this.#initialization; + } + + async getLastUserId(): Promise { + const rows = await this.#query('SELECT last_user_id FROM offline_metadata WHERE id = 1'); + return this.#numberOrNull(rows[0]?.['last_user_id']); + } + + async setLastUserId(userId: number): Promise { + await this.#write( + `INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version, last_user_id = excluded.last_user_id`, + [OFFLINE_SCHEMA_VERSION, userId], + ); + } + + async allocateLocalId(): Promise { + let allocated = 0; + await this.#transaction(async (database) => { + await database.run('UPDATE offline_metadata SET next_local_id = next_local_id + 1 WHERE id = 1', [], false); + const result = await database.query('SELECT next_local_id FROM offline_metadata WHERE id = 1'); + allocated = this.#number((result.values?.[0] as SQLiteRow | undefined)?.['next_local_id']); + }); + return allocated; + } + + async getEntity(scope: OfflineScope, entityType: string, entityId: string): Promise | null> { + const rows = await this.#query( + `SELECT value_json, server_revision_json, fetched_at FROM offline_entities + WHERE user_id = ? AND group_id = ? AND entity_type = ? AND entity_id = ?`, + [scope.userId, scope.groupId, entityType, entityId], + ); + const row = rows[0]; + if (!row) return null; + return { + ...scope, + entityType, + entityId, + value: this.#parse(row['value_json']), + serverRevision: this.#parseNullable(row['server_revision_json']), + fetchedAt: this.#number(row['fetched_at']), + }; + } + + async putEntity(entity: OfflineEntity): Promise { + await this.#write( + `INSERT INTO offline_entities + (user_id, group_id, entity_type, entity_id, value_json, server_revision_json, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, group_id, entity_type, entity_id) DO UPDATE SET + value_json = excluded.value_json, server_revision_json = excluded.server_revision_json, + fetched_at = excluded.fetched_at`, + [ + entity.userId, + entity.groupId, + entity.entityType, + entity.entityId, + JSON.stringify(entity.value), + this.#stringifyNullable(entity.serverRevision), + entity.fetchedAt, + ], + ); + } + + async getQuery(scope: OfflineScope, queryKey: string): Promise | null> { + const rows = await this.#query( + `SELECT value_json, ordered_ids_json, cursor, etag, fetched_at, is_complete FROM offline_queries + WHERE user_id = ? AND group_id = ? AND query_key = ?`, + [scope.userId, scope.groupId, queryKey], + ); + const row = rows[0]; + if (!row) return null; + return { + ...scope, + queryKey, + value: this.#parse(row['value_json']), + orderedIds: this.#parse(row['ordered_ids_json']), + cursor: this.#stringOrNull(row['cursor']), + etag: this.#stringOrNull(row['etag']), + fetchedAt: this.#number(row['fetched_at']), + isComplete: this.#number(row['is_complete']) === 1, + }; + } + + async putQuery(query: OfflineQuery): Promise { + await this.#write( + `INSERT INTO offline_queries + (user_id, group_id, query_key, value_json, ordered_ids_json, cursor, etag, fetched_at, is_complete) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, group_id, query_key) DO UPDATE SET + value_json = excluded.value_json, ordered_ids_json = excluded.ordered_ids_json, + cursor = excluded.cursor, etag = excluded.etag, fetched_at = excluded.fetched_at, + is_complete = excluded.is_complete`, + [ + query.userId, + query.groupId, + query.queryKey, + JSON.stringify(query.value), + JSON.stringify(query.orderedIds), + query.cursor, + query.etag, + query.fetchedAt, + query.isComplete ? 1 : 0, + ], + ); + } + + async getCommands(scope: OfflineScope): Promise { + const rows = await this.#query('SELECT * FROM offline_sync_commands WHERE user_id = ? AND group_id = ? ORDER BY created_at ASC', [ + scope.userId, + scope.groupId, + ]); + return rows.map((row) => this.#command(row)); + } + + async putCommand(command: OfflineCommand): Promise { + await this.#write( + `INSERT INTO offline_sync_commands + (command_id, user_id, group_id, aggregate_type, aggregate_id, operation, payload_json, payload_hash, + base_revision_json, state, attempts, retry_at, created_at, last_error_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(command_id) DO UPDATE SET + user_id = excluded.user_id, group_id = excluded.group_id, aggregate_type = excluded.aggregate_type, + aggregate_id = excluded.aggregate_id, operation = excluded.operation, payload_json = excluded.payload_json, + payload_hash = excluded.payload_hash, base_revision_json = excluded.base_revision_json, + state = excluded.state, attempts = excluded.attempts, retry_at = excluded.retry_at, + created_at = excluded.created_at, last_error_code = excluded.last_error_code`, + [ + command.commandId, + command.userId, + command.groupId, + command.aggregateType, + command.aggregateId, + command.operation, + JSON.stringify(command.payload), + command.payloadHash, + this.#stringifyNullable(command.baseRevision), + command.state, + command.attempts, + command.retryAt, + command.createdAt, + command.lastErrorCode, + ], + ); + } + + replaceCommand(command: OfflineCommand): Promise { + return this.putCommand(command); + } + + async removeCommand(commandId: string): Promise { + await this.#write('DELETE FROM offline_sync_commands WHERE command_id = ?', [commandId]); + } + + async clearUser(userId: number): Promise { + await this.#transaction(async (database) => { + await database.run('DELETE FROM offline_entities WHERE user_id = ?', [userId], false); + await database.run('DELETE FROM offline_queries WHERE user_id = ?', [userId], false); + await database.run('DELETE FROM offline_sync_commands WHERE user_id = ?', [userId], false); + await database.run('UPDATE offline_metadata SET last_user_id = NULL WHERE id = 1 AND last_user_id = ?', [userId], false); + }); + } + + async clearGroup(scope: OfflineScope): Promise { + await this.#transaction(async (database) => { + const values = [scope.userId, scope.groupId]; + await database.run('DELETE FROM offline_entities WHERE user_id = ? AND group_id = ?', values, false); + await database.run('DELETE FROM offline_queries WHERE user_id = ? AND group_id = ?', values, false); + await database.run('DELETE FROM offline_sync_commands WHERE user_id = ? AND group_id = ?', values, false); + }); + } + + async #open(): Promise { + const encryptionConfigured = await this.#sqlite.isInConfigEncryption(); + if (!encryptionConfigured.result) { + throw new Error('Encrypted offline storage requires CapacitorSQLite native encryption configuration'); + } + const secretStored = await this.#sqlite.isSecretStored(); + if (!secretStored.result) await this.#sqlite.setEncryptionSecret(this.#createSecret()); + + const databaseName = this.#options.databaseName; + const connectionExists = await this.#sqlite.isConnection(databaseName, false); + this.#database = connectionExists.result + ? await this.#sqlite.retrieveConnection(databaseName, false) + : await this.#sqlite.createConnection(databaseName, true, 'secret', OFFLINE_SCHEMA_VERSION, false); + if (!(await this.#database.isDBOpen()).result) await this.#database.open(); + await this.#database.execute(SCHEMA, true); + const metadataColumns = await this.#database.query('PRAGMA table_info(offline_metadata)'); + if (!(metadataColumns.values ?? []).some((column) => column['name'] === 'next_local_id')) { + await this.#database.execute('ALTER TABLE offline_metadata ADD COLUMN next_local_id INTEGER NOT NULL DEFAULT 2000000000000', true); + } + await this.#database.run( + `INSERT INTO offline_metadata (id, schema_version, last_user_id, next_local_id) VALUES (1, ?, NULL, 2000000000000) + ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version`, + [OFFLINE_SCHEMA_VERSION], + ); + } + + async #databaseConnection(): Promise { + await this.initialize(); + if (!this.#database) throw new Error('Offline SQLite database is not initialized'); + return this.#database; + } + + async #query(statement: string, values: SQLiteValue[] = []): Promise { + const result = await (await this.#databaseConnection()).query(statement, values); + return (result.values ?? []) as SQLiteRow[]; + } + + #write(statement: string, values: SQLiteValue[]): Promise { + const write = this.#writes.then(async (): Promise => { + await (await this.#databaseConnection()).run(statement, values); + }); + this.#writes = write.catch((): void => undefined); + return write; + } + + #transaction(run: (database: SQLiteDBConnection) => Promise): Promise { + const write = this.#writes.then(async (): Promise => { + const database = await this.#databaseConnection(); + await database.beginTransaction(); + try { + await run(database); + await database.commitTransaction(); + } catch (error) { + await database.rollbackTransaction(); + throw error; + } + }); + this.#writes = write.catch((): void => undefined); + return write; + } + + #command(row: SQLiteRow): OfflineCommand { + return { + commandId: this.#string(row['command_id']), + userId: this.#number(row['user_id']), + groupId: this.#number(row['group_id']), + aggregateType: this.#string(row['aggregate_type']), + aggregateId: this.#string(row['aggregate_id']), + operation: this.#string(row['operation']), + payload: this.#parse(row['payload_json']), + payloadHash: this.#string(row['payload_hash']), + baseRevision: this.#parseNullable(row['base_revision_json']), + state: this.#string(row['state']) as OfflineCommand['state'], + attempts: this.#number(row['attempts']), + retryAt: this.#numberOrNull(row['retry_at']), + createdAt: this.#number(row['created_at']), + lastErrorCode: this.#stringOrNull(row['last_error_code']), + }; + } + + #createSecret(): string { + return Array.from(crypto.getRandomValues(new Uint8Array(32)), (value) => value.toString(16).padStart(2, '0')).join(''); + } + + #parse(value: unknown): T { + if (typeof value !== 'string') throw new Error('Invalid JSON value in offline SQLite database'); + return JSON.parse(value) as T; + } + + #parseNullable(value: unknown): T | null { + return value == null ? null : this.#parse(value); + } + + #stringifyNullable(value: unknown): string | null { + return value == null ? null : JSON.stringify(value); + } + + #number(value: unknown): number { + if (typeof value !== 'number') throw new Error('Invalid numeric value in offline SQLite database'); + return value; + } + + #numberOrNull(value: unknown): number | null { + return value == null ? null : this.#number(value); + } + + #string(value: unknown): string { + if (typeof value !== 'string') throw new Error('Invalid string value in offline SQLite database'); + return value; + } + + #stringOrNull(value: unknown): string | null { + return value == null ? null : this.#string(value); + } +} diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts new file mode 100644 index 0000000..24a58ec --- /dev/null +++ b/projects/kit/offline/src/public-api.ts @@ -0,0 +1,13 @@ +/** Standard scoped cache and outbox runtime for offline-capable Ionic applications. */ +export * from './lib/offline-command-executor'; +export * from './lib/offline-command-hooks'; +export * from './lib/offline-coordinator.service'; +export * from './lib/offline-kit-options'; +export * from './lib/offline-network.service'; +export * from './lib/offline-provider'; +export * from './lib/offline-repository'; +export * from './lib/offline-request-policy'; +export * from './lib/offline-session.service'; +export * from './lib/offline-sync.service'; +export * from './lib/offline.interceptor'; +export * from './lib/sqlite-offline-repository'; diff --git a/projects/kit/package.json b/projects/kit/package.json index 2e5e77e..e786f89 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -18,6 +18,7 @@ "@capacitor/haptics": ">=6.0.0 <9.0.0", "@capacitor/keyboard": ">=6.0.0 <9.0.0", "@capacitor/network": ">=6.0.0 <9.0.0", + "@capacitor-community/sqlite": ">=6.0.0 <9.0.0", "@capacitor/preferences": ">=6.0.0 <9.0.0", "@capacitor/status-bar": ">=6.0.0 <9.0.0", "@capacitor-community/in-app-review": ">=6.0.0 <9.0.0", diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index a51dc5d..d73ac4b 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -26,7 +26,8 @@ export * from './lib/directives/auth-input.directive'; // Keyboard: native keyboard reposition listeners. export * from './lib/keyboard/kit-keyboard'; -// Theme (`@rdlabo/ionic-angular-kit/theme`), Review (`.../review`), Printer (`.../printer`) and +// Theme (`@rdlabo/ionic-angular-kit/theme`), Review (`.../review`), Printer (`.../printer`), +// Offline (`.../offline`) and // Firebase auth (`.../auth-firebase`) are separate secondary entry points so their heavy native peers // (status-bar / in-app-review+preferences / brotherprint+dom-to-image / firebase) are // only pulled in by apps that import those subpaths. diff --git a/projects/kit/tsconfig.spec.json b/projects/kit/tsconfig.spec.json index 15b261b..7954594 100644 --- a/projects/kit/tsconfig.spec.json +++ b/projects/kit/tsconfig.spec.json @@ -13,6 +13,7 @@ "theme/src/**/*.spec.ts", "review/src/**/*.spec.ts", "live-update/src/**/*.spec.ts", + "offline/src/**/*.spec.ts", "auth-firebase/src/**/*.spec.ts", "auth-firebase/social/src/**/*.spec.ts" ] From ee00093dd3e3a8fd18784443ea909101d2d98df6 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 20:09:21 +0900 Subject: [PATCH 2/7] fix: harden offline session boundaries --- package-lock.json | 132 ++--------------- package.json | 1 - projects/kit/README.md | 20 ++- .../src/lib/offline-coordinator.service.ts | 3 +- .../offline/src/lib/offline-error-reporter.ts | 19 +++ .../offline/src/lib/offline-kit-options.ts | 2 + .../kit/offline/src/lib/offline-provider.ts | 17 ++- .../src/lib/offline-session.service.spec.ts | 15 ++ .../src/lib/offline-session.service.ts | 10 +- .../src/lib/offline-sync.service.spec.ts | 78 +++++++++- .../offline/src/lib/offline-sync.service.ts | 102 ++++++++++--- .../src/lib/offline.interceptor.spec.ts | 17 ++- .../offline/src/lib/offline.interceptor.ts | 13 +- .../src/lib/sqlite-offline-repository.spec.ts | 89 ++++++++++++ .../src/lib/sqlite-offline-repository.ts | 135 ++++++++++-------- projects/kit/offline/src/public-api.ts | 1 + projects/kit/package.json | 5 +- 17 files changed, 441 insertions(+), 218 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-error-reporter.ts create mode 100644 projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts diff --git a/package-lock.json b/package-lock.json index bf3c05b..81c826c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,6 @@ "@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build", "@capacitor-community/facebook-login": "^8.0.0", "@capacitor-community/in-app-review": "^8.0.0", - "@capacitor-community/sqlite": "^8.1.0", "@capacitor/cli": ">=6.0.0 <9.0.0", "@capacitor/haptics": "^8.0.2", "@capacitor/keyboard": "^8.0.5", @@ -77,14 +76,14 @@ }, "dist/photo-editor": { "name": "@rdlabo/ionic-angular-photo-editor", - "version": "22.0.0", + "version": "21.4.0", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "^22.0.0", - "@angular/common": "^22.0.0", - "@angular/core": "^22.0.0", + "@angular/cdk": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", "@capacitor/camera": ">=6.0.0 <9.0.0", "@capacitor/core": ">=6.0.0 <9.0.0", "@ionic/angular": "^8.0.0", @@ -94,26 +93,26 @@ }, "dist/scroll-header": { "name": "@rdlabo/ionic-angular-scroll-header", - "version": "22.0.0", + "version": "21.4.0", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "^22.0.0", - "@angular/common": "^22.0.0", - "@angular/core": "^22.0.0" + "@angular/cdk": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0" } }, "dist/scroll-strategies": { "name": "@rdlabo/ngx-cdk-scroll-strategies", - "version": "22.0.0", + "version": "21.4.0", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "^22.0.0", - "@angular/common": "^22.0.0", - "@angular/core": "^22.0.0" + "@angular/cdk": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0" } }, "node_modules/@algolia/abtesting": { @@ -3513,22 +3512,6 @@ "@capacitor/core": ">=8.0.0" } }, - "node_modules/@capacitor-community/sqlite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-8.1.0.tgz", - "integrity": "sha512-yhKZDAVPDPcM3QE6UGB3LXyV25a6Rve1SjZ1aUpTE0E2isnYTVM0PG9+JOI241f+NdsHzPTE7ESJiYSqKsKnuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jeep-sqlite": "^2.7.2" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@capacitor/core": ">=8.0.0" - } - }, "node_modules/@capacitor/android": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz", @@ -11338,13 +11321,6 @@ "node": ">=8" } }, - "node_modules/browser-fs-access": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz", - "integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -16881,20 +16857,6 @@ "node": ">=10" } }, - "node_modules/jeep-sqlite": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/jeep-sqlite/-/jeep-sqlite-2.8.0.tgz", - "integrity": "sha512-FWNUP6OAmrUHwiW7H1xH5YUQ8tN2O4l4psT1sLd7DQtHd5PfrA1nvNdeKPNj+wQBtu7elJa8WoUibTytNTaaCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@stencil/core": "^4.20.0", - "browser-fs-access": "^0.35.0", - "jszip": "^3.10.1", - "localforage": "^1.10.0", - "sql.js": "^1.11.0" - } - }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -17176,62 +17138,6 @@ "node": ">=0.6.0" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "dev": true, - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/jszip/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/jszip/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/jszip/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", @@ -23312,13 +23218,6 @@ "optional": true, "peer": true }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "dev": true, - "license": "MIT" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -23782,13 +23681,6 @@ "node": ">= 10.x" } }, - "node_modules/sql.js": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", - "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", - "dev": true, - "license": "MIT" - }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", diff --git a/package.json b/package.json index 030f375..f967b41 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,6 @@ "@capacitor-community/apple-sign-in": "github:rdlabo/apple-sign-in#cap8_build", "@capacitor-community/facebook-login": "^8.0.0", "@capacitor-community/in-app-review": "^8.0.0", - "@capacitor-community/sqlite": "^8.1.0", "@capacitor/cli": ">=6.0.0 <9.0.0", "@capacitor/haptics": "^8.0.2", "@capacitor/keyboard": "^8.0.5", diff --git a/projects/kit/README.md b/projects/kit/README.md index 54620ab..8abbf69 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -336,8 +336,24 @@ A fleet-canonical HTTP interceptor with: The optional `offline` entry point provides a user/group-scoped cache, durable outbox, authenticated session boundary, aggregate-ordered replay, retry classification, and request-policy interceptor. Applications provide only their URL/DTO policy and command executor through `provideOffline(...)`. -Web storage uses Ionic Storage; iOS and Android use encrypted Capacitor SQLite. Importing the primary -kit entry point does not pull in the SQLite peer. +Web storage uses Ionic Storage; iOS and Android use encrypted Capawesome SQLite. Importing either the +primary entry point or `/offline` does not pull the private plugin into existing applications. + +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: + +```bash +npm install @capawesome-team/capacitor-sqlite +``` + +```ts +provideOffline({ + // ...product policies and executor + sqlitePlugin: Sqlite, + encryptionKey: () => securePreferences.get({ key: 'offline-database-key' }).then(({ value }) => value), +}); +``` + - **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. - **Optional `treatAsError(response)`** — reject a 2xx (e.g. `204`/`206`) as an error when a backend uses it to signal a condition. The one genuinely app-specific bit (some apps receive a normal `204`), kept optional so class interceptors with a 2xx-as-error convention can migrate to `provideKitHttp`. diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index ce587f3..fcf1904 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -25,13 +25,14 @@ export class OfflineCoordinatorService { } async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise { + await this.#sync.resetSession(); await this.#session.activateSession(userId, scopeIds, authSubject); await this.#sync.refreshSession(); } async clearActiveSession(): Promise { - await this.#session.clearActiveSession(); await this.#sync.resetSession(); + await this.#session.clearActiveSession(); } async prepareLogout(action: OfflineLogoutAction): Promise { diff --git a/projects/kit/offline/src/lib/offline-error-reporter.ts b/projects/kit/offline/src/lib/offline-error-reporter.ts new file mode 100644 index 0000000..d2006bc --- /dev/null +++ b/projects/kit/offline/src/lib/offline-error-reporter.ts @@ -0,0 +1,19 @@ +import { InjectionToken } from '@angular/core'; + +export interface OfflinePersistenceErrorContext { + operation: 'storeFresh'; + method: string; + url: string; +} + +/** Reports background persistence failures without replacing a successful HTTP response. */ +export interface OfflineErrorReporter { + report(error: unknown, context: OfflinePersistenceErrorContext): void; +} + +export const OFFLINE_ERROR_REPORTER = new InjectionToken('OFFLINE_ERROR_REPORTER', { + providedIn: 'root', + factory: () => ({ + report: (error, context) => console.error('[offline] persistence failed', context, error), + }), +}); diff --git a/projects/kit/offline/src/lib/offline-kit-options.ts b/projects/kit/offline/src/lib/offline-kit-options.ts index e41f271..7b79b86 100644 --- a/projects/kit/offline/src/lib/offline-kit-options.ts +++ b/projects/kit/offline/src/lib/offline-kit-options.ts @@ -4,6 +4,8 @@ import { InjectionToken } from '@angular/core'; export interface OfflineKitOptions { /** Encrypted SQLite database name used on iOS and Android. */ databaseName: string; + /** Resolves the native database key from secure device storage. Required on iOS and Android. */ + encryptionKey?: () => Promise; } /** DI token for product-independent offline persistence settings. */ diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 29b25b0..2561542 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -8,11 +8,13 @@ import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import type { OfflineKitOptions } from './offline-kit-options'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineCoordinatorService } from './offline-coordinator.service'; +import type { OfflineErrorReporter } from './offline-error-reporter'; +import { OFFLINE_ERROR_REPORTER } from './offline-error-reporter'; import { IonicOfflineRepository, OFFLINE_REPOSITORY, selectOfflineRepository } from './offline-repository'; import type { OfflineRequestPolicy } from './offline-request-policy'; import { provideOfflineRequestPolicy } from './offline-request-policy'; import { OfflineSessionService } from './offline-session.service'; -import { SqliteOfflineRepository } from './sqlite-offline-repository'; +import { CAPAWESOME_SQLITE, type CapawesomeSqlitePlugin, SqliteOfflineRepository } from './sqlite-offline-repository'; /** Configuration for the standard offline repository, outbox, and request-policy runtime. */ export interface ProvideOfflineOptions extends OfflineKitOptions { @@ -22,22 +24,30 @@ export interface ProvideOfflineOptions extends OfflineKitOptions { requestPolicies: readonly Type[]; /** Optional product hooks for cache projection and command cleanup. */ commandHooks?: Type; + /** Optional reporter for background cache persistence failures. Defaults to console.error. */ + errorReporter?: Type; /** Optional additional providers required by product adapters. */ providers?: readonly Provider[]; + /** Capawesome `Sqlite` plugin. Required only when this runtime is selected on iOS or Android. */ + sqlitePlugin?: CapawesomeSqlitePlugin; } /** * Provide the standard scoped offline runtime. * * @remarks - * Web uses Ionic Storage. Native iOS/Android uses encrypted Capacitor SQLite. The application owns + * Web uses Ionic Storage. Native iOS/Android uses encrypted Capawesome SQLite. The application owns * URL/DTO policy and command execution; the kit owns persistence, ordering, retries, and session * isolation. */ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProviders { return makeEnvironmentProviders([ options.commandExecutor, - { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: options.databaseName } }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { databaseName: options.databaseName, encryptionKey: options.encryptionKey }, + }, + { provide: CAPAWESOME_SQLITE, useValue: options.sqlitePlugin ?? null }, { provide: OFFLINE_REPOSITORY, useFactory: () => selectOfflineRepository(Capacitor.getPlatform(), inject(IonicOfflineRepository), inject(SqliteOfflineRepository)), @@ -45,6 +55,7 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi { provide: OFFLINE_SYNC_CONTEXT, useExisting: OfflineSessionService }, { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.commandExecutor }, ...(options.commandHooks ? [options.commandHooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: options.commandHooks }] : []), + ...(options.errorReporter ? [options.errorReporter, { provide: OFFLINE_ERROR_REPORTER, useExisting: options.errorReporter }] : []), ...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), ...(options.providers ?? []), provideAppInitializer(() => inject(OfflineCoordinatorService).initialize()), diff --git a/projects/kit/offline/src/lib/offline-session.service.spec.ts b/projects/kit/offline/src/lib/offline-session.service.spec.ts index b40b35b..44de16b 100644 --- a/projects/kit/offline/src/lib/offline-session.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -64,4 +64,19 @@ describe('OfflineSessionService shared-device boundary', () => { await expect(service.getSession()).resolves.toEqual({ userId: 20, scopes: [{ userId: 20, groupId: 2 }] }); expect(service.activeManifest()).toMatchObject({ userId: 20, authSubject: 'uid-B' }); }); + + it('同じuserIdでもauthSubjectが変わると旧主体の全scopeを継承しない', async () => { + await service.initialize(); + await service.activateSession(10, [2], 'uid-B'); + expect(clearUser).toHaveBeenCalledWith(10); + expect(entities.some((entity) => entity.entityType === '__offline_session' && entity.value)).toBe(true); + expect(service.activeManifest()).toMatchObject({ userId: 10, scopeIds: [2], authSubject: 'uid-B' }); + }); + + it('legacy null subjectから既知subjectへの移行時も旧cacheを削除する', async () => { + (entities[0]!.value as OfflineSessionManifest).authSubject = null; + await service.initialize(); + await service.activateSession(10, [1], 'uid-A'); + expect(clearUser).toHaveBeenCalledWith(10); + }); }); diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index 9dc1fa2..44b16b4 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -42,13 +42,17 @@ export class OfflineSessionService { await this.initialize(); const normalizedScopeIds = [...new Set(scopeIds)].filter((id) => id !== SESSION_GROUP_ID).sort((a, b) => a - b); const previousUserId = await this.#repository.getLastUserId(); - if (previousUserId !== null && previousUserId !== userId) await this.#repository.clearUser(previousUserId); - const sessionScope = { userId, groupId: SESSION_GROUP_ID }; - const previous = + let previous = previousUserId === userId ? ((await this.#repository.getEntity(sessionScope, SESSION_ENTITY_TYPE, SESSION_ENTITY_ID))?.value ?? null) : null; + // A changed provider subject is a different person even when the product reuses its numeric id. + // This deliberately also clears legacy null -> known subject and known subject -> null transitions. + if (previousUserId !== null && (previousUserId !== userId || previous?.authSubject !== authSubject)) { + await this.#repository.clearUser(previousUserId); + previous = null; + } const active = new Set(normalizedScopeIds); await Promise.all( (previous?.scopeIds ?? []) 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 4112dcc..85793a0 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -10,19 +10,21 @@ import { type OfflineRepository, type OfflineScope, } from './offline-repository'; -import { OfflineSyncService } from './offline-sync.service'; +import { OfflinePayloadValidationError, OfflineSyncService } from './offline-sync.service'; describe('OfflineSyncService', () => { let service: OfflineSyncService; let commands: OfflineCommand[]; let entities: OfflineEntity[]; let connected: ReturnType>; + let session: { userId: number; scopes: OfflineScope[] } | null; const execute = vi.fn(async (_command: OfflineCommand) => ({ response: null })); beforeEach(() => { commands = []; entities = []; connected = signal(false); + session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; execute.mockReset(); execute.mockResolvedValue({ response: null }); const repository = { @@ -36,7 +38,9 @@ describe('OfflineSyncService', () => { commands.sort((left, right) => left.createdAt - right.createdAt); }), replaceCommand: vi.fn(async (command: OfflineCommand) => { - commands = commands.map((item) => (item.commandId === command.commandId ? structuredClone(command) : item)); + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + commands.sort((left, right) => left.createdAt - right.createdAt); }), removeCommand: vi.fn(async (commandId: string) => { commands = commands.filter((item) => item.commandId !== commandId); @@ -65,7 +69,7 @@ describe('OfflineSyncService', () => { { provide: OfflineNetworkService, useValue: { connected } }, { provide: OFFLINE_SYNC_CONTEXT, - useValue: { getSession: vi.fn(async () => ({ userId: 1, scopes: [{ userId: 1, groupId: 10 }] })) }, + useValue: { getSession: vi.fn(async () => session) }, }, { provide: OFFLINE_COMMAND_EXECUTOR, @@ -91,6 +95,27 @@ describe('OfflineSyncService', () => { expect(service.pendingCount()).toBe(0); }); + it('locale非依存のkey順で同じJSON payloadを同一hashにする', async () => { + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { あ: 3, z: 1, ä: 2 } }, + { flush: false }, + ); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '2', operation: 'documents.upsert', payload: { ä: 2, あ: 3, z: 1 } }, + { flush: false }, + ); + expect(service.pendingCommands()[0]?.payloadHash).toBe(service.pendingCommands()[1]?.payloadHash); + }); + + it('JSON外payloadを衝突するhashへ変換せずrejectする', async () => { + await expect( + service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { value: undefined } }, + { flush: false }, + ), + ).rejects.toBeInstanceOf(OfflinePayloadValidationError); + }); + it.each([ [401, 'blocked_auth'], [409, 'conflict'], @@ -106,4 +131,51 @@ describe('OfflineSyncService', () => { await service.flush(); expect(service.pendingCommands()[0]).toMatchObject({ state, lastErrorCode: String(status) }); }); + + it('flush中の一括discard後に旧commandを送信・復活させない', async () => { + let resolveExecute!: (value: { response: null; serverRevision?: number }) => void; + execute.mockImplementationOnce(() => new Promise((resolve) => (resolveExecute = resolve))); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { flush: false }, + ); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 2 } }, + { flush: false }, + ); + connected.set(true); + const flush = service.flush(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + await service.discardAllPending(); + resolveExecute({ response: null, serverRevision: 2 }); + await flush; + expect(execute).toHaveBeenCalledOnce(); + expect(commands).toEqual([]); + expect(service.pendingCount()).toBe(0); + }); + + it('flush中のsession切替後に旧user commandを新sessionへ復活させない', async () => { + let resolveExecute!: (value: { response: null; serverRevision?: number }) => void; + execute.mockImplementationOnce(() => new Promise((resolve) => (resolveExecute = resolve))); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { flush: false }, + ); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 2 } }, + { flush: false }, + ); + connected.set(true); + const oldFlush = service.flush(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + await service.resetSession(); + commands = commands.filter((command) => command.userId !== 1); + connected.set(false); + session = { userId: 2, scopes: [{ userId: 2, groupId: 20 }] }; + await service.refreshSession(); + resolveExecute({ response: null, serverRevision: 2 }); + await oldFlush; + expect(execute).toHaveBeenCalledOnce(); + expect(commands.some((command) => command.userId === 1)).toBe(false); + }); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index e632415..4be77a1 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -16,6 +16,13 @@ export interface EnqueueOfflineCommand { baseRevision?: string | number | null; } +export class OfflinePayloadValidationError extends Error { + constructor(message = 'Offline command payload must contain only JSON values') { + super(message); + this.name = 'OfflinePayloadValidationError'; + } +} + const MAX_PARALLEL_AGGREGATES = 3; const MAX_BACKOFF_MS = 60 * 60 * 1000; @@ -30,6 +37,7 @@ export class OfflineSyncService { readonly #knownScopes = new Map(); #activeUserId: number | null = null; #flushPromise: Promise | null = null; + #generation = 0; #retryTimer: ReturnType | null = null; #initialized = false; @@ -81,6 +89,7 @@ export class OfflineSyncService { } async resetSession(): Promise { + this.#invalidateFlush(); this.#activeUserId = null; this.#knownScopes.clear(); this.#commands.set([]); @@ -121,16 +130,20 @@ export class OfflineSyncService { await this.initialize(); const command = (await this.#readKnownCommands()).find((item) => item.commandId === commandId); if (!command) return; + this.#invalidateFlush(); await this.#repository.removeCommand(command.commandId); await this.#hooks.onCommandRemoved?.(command); + await this.#restoreInterruptedCommands(); await this.#refreshState(); if (options.flush !== false && this.#network.connected()) void this.flush(); } async discardAllPending(): Promise { await this.initialize(); + this.#invalidateFlush(); + const commands = await this.#readKnownCommands(); await Promise.all( - this.pendingCommands().map(async (command) => { + commands.map(async (command) => { await this.#repository.removeCommand(command.commandId); await this.#hooks.onCommandRemoved?.(command); }), @@ -139,29 +152,36 @@ export class OfflineSyncService { } flush(): Promise { - this.#flushPromise ??= this.#runFlush().finally(() => (this.#flushPromise = null)); - return this.#flushPromise; + if (this.#flushPromise) return this.#flushPromise; + const generation = this.#generation; + const promise = this.#runFlush(generation).finally(() => { + if (this.#flushPromise === promise) this.#flushPromise = null; + }); + this.#flushPromise = promise; + return promise; } - async #runFlush(): Promise { + async #runFlush(generation: number): Promise { + if (!this.#isCurrent(generation)) return; if (!this.#network.connected()) { - await this.#refreshState(); + await this.#refreshState(generation); return; } - await this.#discoverScopes(); - while (this.#network.connected()) { + if (!(await this.#discoverScopes(generation))) return; + while (this.#network.connected() && this.#isCurrent(generation)) { const groups = this.#eligibleAggregateGroups(await this.#readKnownCommands()); + if (!this.#isCurrent(generation)) return; if (groups.length === 0) break; let cursor = 0; const workers = Array.from({ length: Math.min(MAX_PARALLEL_AGGREGATES, groups.length) }, async () => { while (cursor < groups.length) { const group = groups[cursor++]; - if (group) await this.#sendAggregate(group); + if (group && this.#isCurrent(generation)) await this.#sendAggregate(group, generation); } }); await Promise.all(workers); } - await this.#refreshState(); + await this.#refreshState(generation); } #eligibleAggregateGroups(commands: OfflineCommand[]): OfflineCommand[][] { @@ -179,8 +199,9 @@ export class OfflineSyncService { }); } - async #sendAggregate(commands: OfflineCommand[]): Promise { + async #sendAggregate(commands: OfflineCommand[], generation: number): Promise { for (let index = 0; index < commands.length; index++) { + if (!this.#isCurrent(generation)) return; const command = commands[index]!; if (command.state === 'retry_wait' && (command.retryAt ?? 0) > Date.now()) break; if (!['pending', 'retry_wait'].includes(command.state)) break; @@ -192,16 +213,22 @@ export class OfflineSyncService { lastErrorCode: null, }; await this.#repository.putCommand(sending); - await this.#refreshState(); + if (!this.#isCurrent(generation)) return; + await this.#refreshState(generation); + if (!this.#isCurrent(generation)) return; try { const result = await this.#executor.execute(sending); + if (!this.#isCurrent(generation)) return; const revision = result.serverRevision; if (this.#hooks.shouldUpdateCache(sending, result)) { - await this.#updateCachedEntity(sending, result); - if (revision !== undefined) await this.#rebaseFollowingCommands(commands, index + 1, revision); + await this.#updateCachedEntity(sending, result, generation); + if (!this.#isCurrent(generation)) return; + if (revision !== undefined) await this.#rebaseFollowingCommands(commands, index + 1, revision, generation); } + if (!this.#isCurrent(generation)) return; await this.#repository.removeCommand(sending.commandId); } catch (error) { + if (!this.#isCurrent(generation)) return; const failed = this.#failedCommand(sending, error); await this.#repository.putCommand(failed); if (failed.state === 'retry_wait') this.#scheduleRetry(failed.retryAt); @@ -228,10 +255,11 @@ export class OfflineSyncService { return { payload, baseRevision }; } - async #updateCachedEntity(command: OfflineCommand, result: { serverRevision?: string | number; response?: unknown }) { + async #updateCachedEntity(command: OfflineCommand, result: { serverRevision?: string | number; response?: unknown }, generation: number) { const scope = { userId: command.userId, groupId: command.groupId }; const entityType = this.#hooks.cacheEntityType(command); const cached = await this.#repository.getEntity(scope, entityType, command.aggregateId); + if (!this.#isCurrent(generation)) return; if (!cached) return; const projected = this.#executor.projectEntity?.(command, cached, result); if (!projected && result.serverRevision === undefined) return; @@ -243,8 +271,9 @@ export class OfflineSyncService { }); } - async #rebaseFollowingCommands(commands: OfflineCommand[], start: number, revision: string | number) { + async #rebaseFollowingCommands(commands: OfflineCommand[], start: number, revision: string | number, generation: number) { for (let index = start; index < commands.length; index++) { + if (!this.#isCurrent(generation)) return; const rebased = this.#executor.withServerRevision(commands[index]!, revision); commands[index] = rebased; await this.#repository.replaceCommand(rebased); @@ -268,16 +297,18 @@ export class OfflineSyncService { return typeof status === 'number' ? status : 0; } - async #discoverScopes(): Promise { + async #discoverScopes(generation = this.#generation): Promise { const session = await this.#context.getSession(); + if (!this.#isCurrent(generation)) return false; if (!session) { this.#activeUserId = null; this.#knownScopes.clear(); - return; + return true; } this.#setActiveUser(session.userId); this.#knownScopes.clear(); for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); + return true; } #setActiveUser(userId: number): void { @@ -292,8 +323,9 @@ export class OfflineSyncService { .sort((left, right) => left.createdAt - right.createdAt); } - async #refreshState(): Promise { + async #refreshState(generation = this.#generation): Promise { const commands = await this.#readKnownCommands(); + if (!this.#isCurrent(generation)) return; this.#commands.set(commands); const nextRetry = commands .filter((command) => command.state === 'retry_wait' && command.retryAt !== null) @@ -314,6 +346,25 @@ export class OfflineSyncService { ); } + #invalidateFlush(): void { + this.#generation += 1; + this.#flushPromise = null; + this.#scheduleRetry(null); + } + + #isCurrent(generation: number): boolean { + return generation === this.#generation; + } + + async #restoreInterruptedCommands(): Promise { + const commands = await this.#readKnownCommands(); + await Promise.all( + commands + .filter((command) => command.state === 'sending') + .map((command) => this.#repository.putCommand({ ...command, state: 'pending' })), + ); + } + async #payloadHash(payload: unknown): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this.#canonicalJson(payload))); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); @@ -322,13 +373,22 @@ export class OfflineSyncService { #canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map((item) => this.#canonicalJson(item)).join(',')}]`; if (value !== null && typeof value === 'object') { + if (Object.getPrototypeOf(value) !== Object.prototype) { + throw new OfflinePayloadValidationError(); + } return `{${Object.entries(value) - .filter(([, item]) => item !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([key, item]) => `${JSON.stringify(key)}:${this.#canonicalJson(item)}`) .join(',')}}`; } - return JSON.stringify(value) ?? 'null'; + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new OfflinePayloadValidationError(); + } + const serialized = JSON.stringify(value); + if (typeof serialized !== 'string') { + throw new OfflinePayloadValidationError(); + } + return serialized; } #scopeKey(scope: OfflineScope): string { diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index 466cbed..955261f 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -2,19 +2,34 @@ import { HttpContext, HttpErrorResponse, HttpRequest, HttpResponse } from '@angu import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { firstValueFrom, of, throwError } from 'rxjs'; +import { OFFLINE_ERROR_REPORTER } from './offline-error-reporter'; import { offlineInterceptor } from './offline.interceptor'; import { OFFLINE_BYPASS, OFFLINE_RESPONSE_HEADER, type OfflineRequestPlan, OfflineRequestPolicyRegistry } from './offline-request-policy'; describe('offlineInterceptor', () => { let resolve: ReturnType) => OfflineRequestPlan | null>>; + let report: ReturnType; beforeEach(() => { resolve = vi.fn(() => null); + report = vi.fn(); TestBed.configureTestingModule({ - providers: [{ provide: OfflineRequestPolicyRegistry, useValue: { resolve } }], + providers: [ + { provide: OfflineRequestPolicyRegistry, useValue: { resolve } }, + { provide: OFFLINE_ERROR_REPORTER, useValue: { report } }, + ], }); }); + it('保存失敗をreportしつつ成功responseは呼び出し元へ返す', async () => { + const error = new Error('storage unavailable'); + resolve.mockReturnValue({ kind: 'read', storeFresh: vi.fn(async () => Promise.reject(error)), readCached: vi.fn() }); + const request = new HttpRequest('GET', '/bootstrap?group=1'); + const response = new HttpResponse({ status: 200 }); + await expect(firstValueFrom(run(request, () => of(response)))).resolves.toBe(response); + expect(report).toHaveBeenCalledWith(error, { operation: 'storeFresh', method: 'GET', url: '/bootstrap?group=1' }); + }); + it('再送requestはpolicyを迂回してtransportへ渡す', async () => { const request = new HttpRequest('POST', '/resource', null, { context: new HttpContext().set(OFFLINE_BYPASS, true), diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts index 8921e2a..913e8a2 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -3,6 +3,7 @@ import { HttpHeaders, HttpResponse as AngularHttpResponse } from '@angular/commo import { inject, Injectable } from '@angular/core'; import type { Observable } from 'rxjs'; import { catchError, concatMap, defer, from, map, of, throwError } from 'rxjs'; +import { OFFLINE_ERROR_REPORTER, type OfflineErrorReporter } from './offline-error-reporter'; import { isOfflineFallbackError } from './offline-network.service'; import { OFFLINE_BYPASS, @@ -15,17 +16,20 @@ export const offlineInterceptor: HttpInterceptorFn = (request, next) => { if (request.context.get(OFFLINE_BYPASS)) return next(request); const registry = inject(OfflineRequestPolicyRegistry); const fallback = inject(OfflineRequestFallbackService); + const errorReporter = inject(OFFLINE_ERROR_REPORTER); const plan = registry.resolve(request); if (!plan) return next(request); if (plan.kind === 'mutation') { if (plan.enqueue) return enqueueMutation(plan.enqueue, request.urlWithParams); if (!plan.storeFresh) return next(request); - return observeRemoteResponse(next(request), plan.storeFresh); + return observeRemoteResponse(next(request), plan.storeFresh, errorReporter, request); } if (request.method !== 'GET') return next(request); return observeRemoteResponse( defer(() => next(request)), plan.storeFresh, + errorReporter, + request, ).pipe(catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error))); }; @@ -53,13 +57,18 @@ export class OfflineRequestFallbackService { function observeRemoteResponse( source: Observable>, storeFresh: (response: HttpResponse) => Promise, + errorReporter: OfflineErrorReporter, + request: HttpRequest, ): Observable> { return source.pipe( concatMap((event) => { if (!(event instanceof AngularHttpResponse) || event.headers.has(OFFLINE_RESPONSE_HEADER)) return of(event); return defer(() => from(storeFresh(event))).pipe( map(() => event), - catchError(() => of(event)), + catchError((error: unknown) => { + errorReporter.report(error, { operation: 'storeFresh', method: request.method, url: request.urlWithParams }); + return of(event); + }), ); }), ); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts new file mode 100644 index 0000000..5aca300 --- /dev/null +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { CAPAWESOME_SQLITE, type CapawesomeSqlitePlugin, SqliteOfflineRepository } from './sqlite-offline-repository'; + +describe('SqliteOfflineRepository Capawesome adapter', () => { + let nextLocalId: number; + let plugin: { + [K in keyof CapawesomeSqlitePlugin]: ReturnType; + }; + + beforeEach(() => { + nextLocalId = 2_000_000_000_000; + plugin = { + open: vi.fn(async () => ({ databaseId: 'offline-db' })), + execute: vi.fn(async ({ statement }: { statement: string }) => { + if (statement.startsWith('UPDATE offline_metadata SET next_local_id')) nextLocalId += 1; + return {}; + }), + query: vi.fn(async ({ statement }: { statement: string }) => { + if (statement.startsWith('PRAGMA table_info')) return { rows: [{ name: 'next_local_id' }] }; + if (statement.startsWith('SELECT next_local_id')) return { rows: [{ next_local_id: nextLocalId }] }; + return { rows: [] }; + }), + beginTransaction: vi.fn(async () => undefined), + commitTransaction: vi.fn(async () => undefined), + rollbackTransaction: vi.fn(async () => undefined), + }; + }); + + it('暗号化databaseのopen失敗を呼び出し元へ伝播する', async () => { + const error = new Error('SQLCipher is not configured'); + plugin.open.mockRejectedValueOnce(error); + const repository = createRepository(); + await expect(repository.initialize()).rejects.toBe(error); + expect(plugin.open).toHaveBeenCalledWith({ path: 'test-offline.sqlite3', encryptionKey: 'secret', readOnly: false }); + }); + + it('暗号鍵が無い場合はdatabaseを開かない', async () => { + const repository = createRepository(async () => ''); + await expect(repository.initialize()).rejects.toThrow('non-empty encryption key'); + expect(plugin.open).not.toHaveBeenCalled(); + }); + + it('group scopeのentity/query/outboxを単一transactionで削除する', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.clearGroup({ userId: 7, groupId: 8 }); + const deletes = plugin.execute.mock.calls + .map(([options]) => options as { statement: string; values?: unknown[] }) + .filter(({ statement }) => statement.startsWith('DELETE FROM')); + expect(deletes).toHaveLength(3); + expect(deletes.every(({ values }) => JSON.stringify(values) === '[7,8]')).toBe(true); + expect(plugin.beginTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).toHaveBeenCalledOnce(); + expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); + }); + + it('並列採番を直列transactionにして永続counterを重複させない', async () => { + const repository = createRepository(); + await repository.initialize(); + await expect(Promise.all([repository.allocateLocalId(), repository.allocateLocalId()])).resolves.toEqual([ + 2_000_000_000_001, 2_000_000_000_002, + ]); + expect(plugin.beginTransaction).toHaveBeenCalledTimes(2); + expect(plugin.commitTransaction).toHaveBeenCalledTimes(2); + }); + + it('transaction中の書き込み失敗をrollbackして握りつぶさない', async () => { + const error = new Error('disk full'); + const repository = createRepository(); + await repository.initialize(); + plugin.execute.mockImplementationOnce(async () => Promise.reject(error)); + await expect(repository.clearUser(7)).rejects.toBe(error); + expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).not.toHaveBeenCalled(); + }); + + function createRepository(encryptionKey: () => Promise = async () => 'secret'): SqliteOfflineRepository { + TestBed.configureTestingModule({ + providers: [ + SqliteOfflineRepository, + { provide: CAPAWESOME_SQLITE, useValue: plugin }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', encryptionKey } }, + ], + }); + return TestBed.inject(SqliteOfflineRepository); + } +}); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 718fe11..aa7fd65 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -1,5 +1,4 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; -import { CapacitorSQLite, SQLiteConnection, type SQLiteDBConnection } from '@capacitor-community/sqlite'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OFFLINE_SCHEMA_VERSION, @@ -10,21 +9,33 @@ import { type OfflineScope, } from './offline-repository'; -export const OFFLINE_SQLITE_CONNECTION = new InjectionToken('OFFLINE_SQLITE_CONNECTION', { - factory: () => new SQLiteConnection(CapacitorSQLite), +export interface CapawesomeSqlitePlugin { + open(options: { path: string; encryptionKey: string; readOnly: false }): Promise<{ databaseId: string }>; + execute(options: { databaseId: string; statement: string; values?: SQLiteValue[] }): Promise; + query(options: { databaseId: string; statement: string; values?: SQLiteValue[] }): Promise<{ + columns?: string[]; + rows?: unknown[]; + }>; + beginTransaction(options: { databaseId: string }): Promise; + commitTransaction(options: { databaseId: string }): Promise; + rollbackTransaction(options: { databaseId: string }): Promise; +} + +export const CAPAWESOME_SQLITE = new InjectionToken('CAPAWESOME_SQLITE', { + factory: () => null, }); type SQLiteValue = string | number | null; type SQLiteRow = Record; -const SCHEMA = ` -CREATE TABLE IF NOT EXISTS offline_metadata ( +const SCHEMA = [ + `CREATE TABLE IF NOT EXISTS offline_metadata ( id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL, last_user_id INTEGER, next_local_id INTEGER NOT NULL DEFAULT 2000000000000 -); -CREATE TABLE IF NOT EXISTS offline_entities ( +)`, + `CREATE TABLE IF NOT EXISTS offline_entities ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, entity_type TEXT NOT NULL, @@ -33,8 +44,8 @@ CREATE TABLE IF NOT EXISTS offline_entities ( server_revision_json TEXT, fetched_at INTEGER NOT NULL, PRIMARY KEY (user_id, group_id, entity_type, entity_id) -); -CREATE TABLE IF NOT EXISTS offline_queries ( +)`, + `CREATE TABLE IF NOT EXISTS offline_queries ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, query_key TEXT NOT NULL, @@ -45,8 +56,8 @@ CREATE TABLE IF NOT EXISTS offline_queries ( fetched_at INTEGER NOT NULL, is_complete INTEGER NOT NULL, PRIMARY KEY (user_id, group_id, query_key) -); -CREATE TABLE IF NOT EXISTS offline_sync_commands ( +)`, + `CREATE TABLE IF NOT EXISTS offline_sync_commands ( command_id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, @@ -61,17 +72,17 @@ CREATE TABLE IF NOT EXISTS offline_sync_commands ( retry_at INTEGER, created_at INTEGER NOT NULL, last_error_code TEXT -); -CREATE INDEX IF NOT EXISTS offline_sync_commands_scope_created - ON offline_sync_commands (user_id, group_id, created_at); -`; +)`, + `CREATE INDEX IF NOT EXISTS offline_sync_commands_scope_created + ON offline_sync_commands (user_id, group_id, created_at)`, +]; -/** Native iOS/AndroidはSQLCipher対応Capacitor SQLiteを利用する。 */ +/** Native iOS/Android uses the encrypted Capawesome SQLite plugin supplied by the application. */ @Injectable({ providedIn: 'root' }) export class SqliteOfflineRepository implements OfflineRepository { - readonly #sqlite = inject(OFFLINE_SQLITE_CONNECTION); + readonly #sqlite = inject(CAPAWESOME_SQLITE); readonly #options = inject(OFFLINE_KIT_OPTIONS); - #database: SQLiteDBConnection | null = null; + #databaseId: string | null = null; #initialization: Promise | null = null; #writes: Promise = Promise.resolve(); @@ -96,9 +107,9 @@ export class SqliteOfflineRepository implements OfflineRepository { async allocateLocalId(): Promise { let allocated = 0; await this.#transaction(async (database) => { - await database.run('UPDATE offline_metadata SET next_local_id = next_local_id + 1 WHERE id = 1', [], false); - const result = await database.query('SELECT next_local_id FROM offline_metadata WHERE id = 1'); - allocated = this.#number((result.values?.[0] as SQLiteRow | undefined)?.['next_local_id']); + await this.#execute(database, 'UPDATE offline_metadata SET next_local_id = next_local_id + 1 WHERE id = 1'); + const rows = await this.#queryDatabase(database, 'SELECT next_local_id FROM offline_metadata WHERE id = 1'); + allocated = this.#number(rows[0]?.['next_local_id']); }); return allocated; } @@ -233,76 +244,72 @@ export class SqliteOfflineRepository implements OfflineRepository { async clearUser(userId: number): Promise { await this.#transaction(async (database) => { - await database.run('DELETE FROM offline_entities WHERE user_id = ?', [userId], false); - await database.run('DELETE FROM offline_queries WHERE user_id = ?', [userId], false); - await database.run('DELETE FROM offline_sync_commands WHERE user_id = ?', [userId], false); - await database.run('UPDATE offline_metadata SET last_user_id = NULL WHERE id = 1 AND last_user_id = ?', [userId], false); + await this.#execute(database, 'DELETE FROM offline_entities WHERE user_id = ?', [userId]); + await this.#execute(database, 'DELETE FROM offline_queries WHERE user_id = ?', [userId]); + await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ?', [userId]); + await this.#execute(database, 'UPDATE offline_metadata SET last_user_id = NULL WHERE id = 1 AND last_user_id = ?', [userId]); }); } async clearGroup(scope: OfflineScope): Promise { await this.#transaction(async (database) => { const values = [scope.userId, scope.groupId]; - await database.run('DELETE FROM offline_entities WHERE user_id = ? AND group_id = ?', values, false); - await database.run('DELETE FROM offline_queries WHERE user_id = ? AND group_id = ?', values, false); - await database.run('DELETE FROM offline_sync_commands WHERE user_id = ? AND group_id = ?', values, false); + await this.#execute(database, 'DELETE FROM offline_entities WHERE user_id = ? AND group_id = ?', values); + await this.#execute(database, 'DELETE FROM offline_queries WHERE user_id = ? AND group_id = ?', values); + await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ? AND group_id = ?', values); }); } async #open(): Promise { - const encryptionConfigured = await this.#sqlite.isInConfigEncryption(); - if (!encryptionConfigured.result) { - throw new Error('Encrypted offline storage requires CapacitorSQLite native encryption configuration'); - } - const secretStored = await this.#sqlite.isSecretStored(); - if (!secretStored.result) await this.#sqlite.setEncryptionSecret(this.#createSecret()); - - const databaseName = this.#options.databaseName; - const connectionExists = await this.#sqlite.isConnection(databaseName, false); - this.#database = connectionExists.result - ? await this.#sqlite.retrieveConnection(databaseName, false) - : await this.#sqlite.createConnection(databaseName, true, 'secret', OFFLINE_SCHEMA_VERSION, false); - if (!(await this.#database.isDBOpen()).result) await this.#database.open(); - await this.#database.execute(SCHEMA, true); - const metadataColumns = await this.#database.query('PRAGMA table_info(offline_metadata)'); - if (!(metadataColumns.values ?? []).some((column) => column['name'] === 'next_local_id')) { - await this.#database.execute('ALTER TABLE offline_metadata ADD COLUMN next_local_id INTEGER NOT NULL DEFAULT 2000000000000', true); + if (!this.#sqlite) throw new Error('Native offline storage requires the Capawesome Sqlite plugin'); + const encryptionKey = await this.#options.encryptionKey?.(); + if (!encryptionKey) throw new Error('Native offline storage requires a non-empty encryption key'); + const { databaseId } = await this.#sqlite.open({ + path: `${this.#options.databaseName}.sqlite3`, + encryptionKey, + readOnly: false, + }); + this.#databaseId = databaseId; + for (const statement of SCHEMA) await this.#execute(databaseId, statement); + const metadataColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_metadata)'); + if (!metadataColumns.some((column) => column['name'] === 'next_local_id')) { + await this.#execute(databaseId, 'ALTER TABLE offline_metadata ADD COLUMN next_local_id INTEGER NOT NULL DEFAULT 2000000000000'); } - await this.#database.run( + await this.#execute( + databaseId, `INSERT INTO offline_metadata (id, schema_version, last_user_id, next_local_id) VALUES (1, ?, NULL, 2000000000000) ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version`, [OFFLINE_SCHEMA_VERSION], ); } - async #databaseConnection(): Promise { + async #databaseConnection(): Promise { await this.initialize(); - if (!this.#database) throw new Error('Offline SQLite database is not initialized'); - return this.#database; + if (!this.#databaseId) throw new Error('Offline SQLite database is not initialized'); + return this.#databaseId; } async #query(statement: string, values: SQLiteValue[] = []): Promise { - const result = await (await this.#databaseConnection()).query(statement, values); - return (result.values ?? []) as SQLiteRow[]; + return this.#queryDatabase(await this.#databaseConnection(), statement, values); } #write(statement: string, values: SQLiteValue[]): Promise { const write = this.#writes.then(async (): Promise => { - await (await this.#databaseConnection()).run(statement, values); + await this.#execute(await this.#databaseConnection(), statement, values); }); this.#writes = write.catch((): void => undefined); return write; } - #transaction(run: (database: SQLiteDBConnection) => Promise): Promise { + #transaction(run: (databaseId: string) => Promise): Promise { const write = this.#writes.then(async (): Promise => { - const database = await this.#databaseConnection(); - await database.beginTransaction(); + const databaseId = await this.#databaseConnection(); + await this.#sqlite!.beginTransaction({ databaseId }); try { - await run(database); - await database.commitTransaction(); + await run(databaseId); + await this.#sqlite!.commitTransaction({ databaseId }); } catch (error) { - await database.rollbackTransaction(); + await this.#sqlite!.rollbackTransaction({ databaseId }); throw error; } }); @@ -329,8 +336,16 @@ export class SqliteOfflineRepository implements OfflineRepository { }; } - #createSecret(): string { - return Array.from(crypto.getRandomValues(new Uint8Array(32)), (value) => value.toString(16).padStart(2, '0')).join(''); + async #execute(databaseId: string, statement: string, values: SQLiteValue[] = []): Promise { + await this.#sqlite!.execute({ databaseId, statement, values }); + } + + async #queryDatabase(databaseId: string, statement: string, values: SQLiteValue[] = []): Promise { + const result = await this.#sqlite!.query({ databaseId, statement, values }); + return (result.rows ?? []).map((row) => { + if (!Array.isArray(row)) return row as SQLiteRow; + return Object.fromEntries(row.map((value, index) => [result.columns?.[index] ?? String(index), value])); + }); } #parse(value: unknown): T { diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 24a58ec..01cc09d 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -2,6 +2,7 @@ export * from './lib/offline-command-executor'; export * from './lib/offline-command-hooks'; export * from './lib/offline-coordinator.service'; +export * from './lib/offline-error-reporter'; export * from './lib/offline-kit-options'; export * from './lib/offline-network.service'; export * from './lib/offline-provider'; diff --git a/projects/kit/package.json b/projects/kit/package.json index e786f89..ea0f4f8 100644 --- a/projects/kit/package.json +++ b/projects/kit/package.json @@ -15,10 +15,10 @@ "@capacitor/core": ">=6.0.0 <9.0.0", "@capacitor/app": ">=6.0.0 <9.0.0", "@capawesome/capacitor-live-update": ">=6.0.0 <9.0.0", + "@capawesome-team/capacitor-sqlite": ">=0.3.0 <1.0.0", "@capacitor/haptics": ">=6.0.0 <9.0.0", "@capacitor/keyboard": ">=6.0.0 <9.0.0", "@capacitor/network": ">=6.0.0 <9.0.0", - "@capacitor-community/sqlite": ">=6.0.0 <9.0.0", "@capacitor/preferences": ">=6.0.0 <9.0.0", "@capacitor/status-bar": ">=6.0.0 <9.0.0", "@capacitor-community/in-app-review": ">=6.0.0 <9.0.0", @@ -37,6 +37,9 @@ "@capawesome/capacitor-live-update": { "optional": true }, + "@capawesome-team/capacitor-sqlite": { + "optional": true + }, "@capacitor/preferences": { "optional": true }, From a67cff164cf6ced71929003a9ddcf880188c3abb Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 20:13:08 +0900 Subject: [PATCH 3/7] fix: recover interrupted offline sessions --- projects/kit/README.md | 6 +++++ .../src/lib/offline-sync.service.spec.ts | 22 +++++++++++++++++++ .../offline/src/lib/offline-sync.service.ts | 1 + .../src/lib/offline.interceptor.spec.ts | 16 ++++++++++++++ .../offline/src/lib/offline.interceptor.ts | 19 ++++++++++++++-- 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/projects/kit/README.md b/projects/kit/README.md index 8abbf69..1217ddc 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -354,6 +354,12 @@ provideOffline({ }); ``` +Encrypted native builds also require the plugin's SQLCipher platform setup: enable +`capawesomeCapacitorSqliteIncludeSqlcipher = true` on Android; select the `SQLCipher` pod when using +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. + - **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. - **Optional `treatAsError(response)`** — reject a 2xx (e.g. `204`/`206`) as an error when a backend uses it to signal a condition. The one genuinely app-specific bit (some apps receive a normal `204`), kept optional so class interceptors with a 2xx-as-error convention can migrate to `provideKitHttp`. 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 85793a0..cf491ad 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -178,4 +178,26 @@ describe('OfflineSyncService', () => { expect(execute).toHaveBeenCalledOnce(); expect(commands.some((command) => command.userId === 1)).toBe(false); }); + + it('flush中に同一sessionを再activateしてもsending commandをpendingへ戻す', async () => { + let resolveFirst!: (value: { response: null }) => void; + execute.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))); + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { flush: false }, + ); + connected.set(true); + const oldFlush = service.flush(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + connected.set(false); + await service.resetSession(); + await service.refreshSession(); + expect(service.pendingCommands()[0]?.state).toBe('pending'); + resolveFirst({ response: null }); + await oldFlush; + connected.set(true); + await service.flush(); + expect(execute).toHaveBeenCalledTimes(2); + expect(service.pendingCount()).toBe(0); + }); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 4be77a1..3acc76a 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -90,6 +90,7 @@ export class OfflineSyncService { async resetSession(): Promise { this.#invalidateFlush(); + await this.#restoreInterruptedCommands(); this.#activeUserId = null; this.#knownScopes.clear(); this.#commands.set([]); diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index 955261f..f4e57dd 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -30,6 +30,22 @@ describe('offlineInterceptor', () => { expect(report).toHaveBeenCalledWith(error, { operation: 'storeFresh', method: 'GET', url: '/bootstrap?group=1' }); }); + it('reporter自身がthrowしても成功responseを置き換えない', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + report.mockImplementationOnce(() => { + throw new Error('reporter unavailable'); + }); + resolve.mockReturnValue({ + kind: 'read', + storeFresh: vi.fn(async () => Promise.reject(new Error('storage unavailable'))), + readCached: vi.fn(), + }); + const response = new HttpResponse({ status: 200 }); + await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => of(response)))).resolves.toBe(response); + expect(consoleError).toHaveBeenCalledOnce(); + consoleError.mockRestore(); + }); + it('再送requestはpolicyを迂回してtransportへ渡す', async () => { const request = new HttpRequest('POST', '/resource', null, { context: new HttpContext().set(OFFLINE_BYPASS, true), diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts index 913e8a2..c015f02 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -66,14 +66,29 @@ function observeRemoteResponse( return defer(() => from(storeFresh(event))).pipe( map(() => event), catchError((error: unknown) => { - errorReporter.report(error, { operation: 'storeFresh', method: request.method, url: request.urlWithParams }); - return of(event); + return from( + reportPersistenceError(errorReporter, error, { + operation: 'storeFresh', + method: request.method, + url: request.urlWithParams, + }), + ).pipe(map(() => event)); }), ); }), ); } +function reportPersistenceError( + reporter: OfflineErrorReporter, + error: unknown, + context: Parameters[1], +): Promise { + return Promise.resolve() + .then(() => reporter.report(error, context)) + .catch((reporterError: unknown) => console.error('[offline] error reporter failed', context, reporterError)); +} + function enqueueMutation(enqueue: NonNullable, url: string): Observable> { return defer(() => from(enqueue())).pipe( map(({ commandId, response }) => From dab633eef3809b5cbeecac550b818789b7217589 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 20:17:05 +0900 Subject: [PATCH 4/7] fix: await outbox state transitions --- .../src/lib/offline-sync.service.spec.ts | 35 +++++++++++++++++++ .../offline/src/lib/offline-sync.service.ts | 20 ++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) 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 cf491ad..1999c60 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -18,6 +18,7 @@ describe('OfflineSyncService', () => { let entities: OfflineEntity[]; let connected: ReturnType>; let session: { userId: number; scopes: OfflineScope[] } | null; + let beforePutCommand: ((command: OfflineCommand) => Promise) | null; const execute = vi.fn(async (_command: OfflineCommand) => ({ response: null })); beforeEach(() => { @@ -25,6 +26,7 @@ describe('OfflineSyncService', () => { entities = []; connected = signal(false); session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; + beforePutCommand = null; execute.mockReset(); execute.mockResolvedValue({ response: null }); const repository = { @@ -33,6 +35,7 @@ describe('OfflineSyncService', () => { commands.filter((item) => item.userId === scope.userId && item.groupId === scope.groupId), ), putCommand: vi.fn(async (command: OfflineCommand) => { + await beforePutCommand?.(command); commands = commands.filter((item) => item.commandId !== command.commandId); commands.push(structuredClone(command)); commands.sort((left, right) => left.createdAt - right.createdAt); @@ -200,4 +203,36 @@ describe('OfflineSyncService', () => { expect(execute).toHaveBeenCalledTimes(2); expect(service.pendingCount()).toBe(0); }); + + it('sending書き込み中の同一session resetでも完了後にpendingへ復旧する', async () => { + let notifySendingStarted!: () => void; + const sendingStarted = new Promise((resolve) => (notifySendingStarted = resolve)); + let releaseSendingWrite!: () => void; + const sendingWrite = new Promise((resolve) => (releaseSendingWrite = resolve)); + beforePutCommand = async (command) => { + if (command.state !== 'sending') return; + notifySendingStarted(); + await sendingWrite; + }; + await service.enqueue( + { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { flush: false }, + ); + connected.set(true); + const oldFlush = service.flush(); + await sendingStarted; + connected.set(false); + const reset = service.resetSession(); + releaseSendingWrite(); + await reset; + beforePutCommand = null; + await service.refreshSession(); + expect(service.pendingCommands()[0]?.state).toBe('pending'); + await oldFlush; + expect(execute).not.toHaveBeenCalled(); + connected.set(true); + await service.flush(); + expect(execute).toHaveBeenCalledOnce(); + expect(service.pendingCount()).toBe(0); + }); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 3acc76a..62f5e5c 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -38,6 +38,7 @@ export class OfflineSyncService { #activeUserId: number | null = null; #flushPromise: Promise | null = null; #generation = 0; + readonly #sendingTransitions = new Set>(); #retryTimer: ReturnType | null = null; #initialized = false; @@ -90,6 +91,7 @@ export class OfflineSyncService { async resetSession(): Promise { this.#invalidateFlush(); + await this.#waitForSendingTransitions(); await this.#restoreInterruptedCommands(); this.#activeUserId = null; this.#knownScopes.clear(); @@ -132,6 +134,7 @@ export class OfflineSyncService { const command = (await this.#readKnownCommands()).find((item) => item.commandId === commandId); if (!command) return; this.#invalidateFlush(); + await this.#waitForSendingTransitions(); await this.#repository.removeCommand(command.commandId); await this.#hooks.onCommandRemoved?.(command); await this.#restoreInterruptedCommands(); @@ -142,6 +145,7 @@ export class OfflineSyncService { async discardAllPending(): Promise { await this.initialize(); this.#invalidateFlush(); + await this.#waitForSendingTransitions(); const commands = await this.#readKnownCommands(); await Promise.all( commands.map(async (command) => { @@ -213,7 +217,7 @@ export class OfflineSyncService { retryAt: null, lastErrorCode: null, }; - await this.#repository.putCommand(sending); + await this.#putSendingCommand(sending); if (!this.#isCurrent(generation)) return; await this.#refreshState(generation); if (!this.#isCurrent(generation)) return; @@ -366,6 +370,20 @@ export class OfflineSyncService { ); } + #putSendingCommand(command: OfflineCommand): Promise { + const transition = this.#repository.putCommand(command); + this.#sendingTransitions.add(transition); + void transition.then( + () => this.#sendingTransitions.delete(transition), + () => this.#sendingTransitions.delete(transition), + ); + return transition; + } + + async #waitForSendingTransitions(): Promise { + await Promise.allSettled([...this.#sendingTransitions]); + } + async #payloadHash(payload: unknown): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(this.#canonicalJson(payload))); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); From 390522c393c7229bf731885b1e4e3073bd41e4e5 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 22:01:57 +0900 Subject: [PATCH 5/7] feat: standardize offline replica sync --- projects/kit/README.md | 98 +- .../src/lib/offline-command-executor.ts | 25 +- .../offline/src/lib/offline-command-hooks.ts | 8 +- .../src/lib/offline-coordinator.service.ts | 2 + .../offline/src/lib/offline-error-reporter.ts | 19 - .../offline/src/lib/offline-kit-options.ts | 3 + .../src/lib/offline-network.service.ts | 3 +- .../kit/offline/src/lib/offline-provider.ts | 21 +- .../lib/offline-replica-pull.service.spec.ts | 603 +++++++++++++ .../src/lib/offline-replica-pull.service.ts | 210 +++++ .../offline/src/lib/offline-replica-puller.ts | 36 + .../src/lib/offline-replica-schema.spec.ts | 763 ++++++++++++++++ .../offline/src/lib/offline-replica-schema.ts | 846 ++++++++++++++++++ .../src/lib/offline-repository.spec.ts | 748 +++++++++++++++- .../kit/offline/src/lib/offline-repository.ts | 490 ++++++++-- .../offline/src/lib/offline-request-policy.ts | 27 +- .../src/lib/offline-session.service.spec.ts | 41 +- .../src/lib/offline-session.service.ts | 28 +- .../src/lib/offline-sync.service.spec.ts | 365 +++++++- .../offline/src/lib/offline-sync.service.ts | 208 +++-- .../src/lib/offline.interceptor.spec.ts | 89 +- .../offline/src/lib/offline.interceptor.ts | 103 +-- .../src/lib/sqlite-offline-repository.spec.ts | 826 ++++++++++++++++- .../src/lib/sqlite-offline-repository.ts | 514 ++++++++--- projects/kit/offline/src/public-api.ts | 6 +- 25 files changed, 5504 insertions(+), 578 deletions(-) delete mode 100644 projects/kit/offline/src/lib/offline-error-reporter.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-pull.service.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-puller.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-schema.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-replica-schema.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index 1217ddc..60a04ac 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -331,14 +331,21 @@ A fleet-canonical HTTP interceptor with: - **Safe retry**: only idempotent methods (`GET`/`HEAD`/`OPTIONS`, or a request with an `Idempotency-Key`) are retried, and only on a transient status `[0, 408, 429, 502, 503, 504]`, up to 2 times with a short jittered backoff (honoring `Retry-After`). **Writes are never auto-retried** (no duplicate saves). - **Offline fast-fail**: when the device is offline the interceptor stops retrying immediately and hands off to `offlineFallback` instead of waiting out the backoff. -### Scoped cache and outbox (`@rdlabo/ionic-angular-kit/offline`) +### Scoped local replica and outbox (`@rdlabo/ionic-angular-kit/offline`) -The optional `offline` entry point provides a user/group-scoped cache, durable outbox, authenticated -session boundary, aggregate-ordered replay, retry classification, and request-policy interceptor. -Applications provide only their URL/DTO policy and command executor through `provideOffline(...)`. +The optional `offline` entry point provides a user/group-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(...)`. +Mutations are queued explicitly with `OfflineSyncService.enqueue`, not through HTTP interceptor policy. Web storage uses Ionic Storage; iOS and Android use encrypted Capawesome SQLite. Importing either the primary entry point or `/offline` does not pull the private plugin into existing applications. +The offline interceptor observes real transport responses to update API reachability. For matched `GET` +requests only, a transport failure with `status=0` may return a local replica response tagged +`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: @@ -348,12 +355,91 @@ npm install @capawesome-team/capacitor-sqlite ```ts provideOffline({ - // ...product policies and executor + // ...product policies, puller, and executor sqlitePlugin: Sqlite, - encryptionKey: () => securePreferences.get({ key: 'offline-database-key' }).then(({ value }) => value), + encryptionKey: async () => { + const { value } = await securePreferences.get({ key: 'offline-database-key' }); + if (!value) throw new Error('offline-database-key is missing'); + return value; + }, }); ``` +SQLite entities use an immutable client-generated UUID as `localId` and keep the server's +`AUTO_INCREMENT` id separately as nullable `serverId`. The outbox references only `aggregateLocalId`. +Immediately before each send, the executor receives the latest `{ localId, serverId }` resolved from +SQLite; a successful create adds `serverId` without replacing `localId`. Entity projection and outbox +append/removal are committed in one local transaction. + +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 +revision changed while a local command is pending, the optimistic row remains visible and both row and command move +to `conflict`; the new server value is retained as the confirmed baseline. + +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, +`syncState`) stay outside the callback. The bundle fingerprint hashes `version`, entity layouts, and +migration `fromVersion`/`statements` — never function bodies. + +```typescript +import { + defineOfflineReplicaSchema, + defineReplicaEntity, + provideOffline, + serverId, + text, +} from '@rdlabo/ionic-angular-kit/offline'; + +// This is the Hono package's existing `typeof items.$inferSelect` export. +import type { Items as ItemSelect } from '@product/hono/db/schema'; + +const itemEntityV2 = defineReplicaEntity()({ + table: 'items', + sourceKey: 'items', + scope: 'group', + fields: { + id: serverId(), + title: text(), + subtitle: text(), + }, +}); + +const replicaSchema = defineOfflineReplicaSchema({ + version: 2, + entities: [itemEntityV2], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE items ADD COLUMN subtitle TEXT NOT NULL DEFAULT ""'], + migrateWebRow: (row) => ({ + sourceKey: row.sourceKey, + values: { ...row.values, subtitle: '' }, + confirmedValues: + row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: '' }, + }), + }, + ], +}); + +provideOffline({ + replicaSchema, + replicaPuller: ProductReplicaPuller, + commandExecutor: ProductCommandExecutor, + // ...request policies, sqlitePlugin, encryptionKey +}); +``` + +The schema definition must map every `ItemSelect` key exactly once as a SQLite column, `serverId()`, or +`ignored(reason)`. 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. + Encrypted native builds also require the plugin's SQLCipher platform setup: enable `capawesomeCapacitorSqliteIncludeSqlcipher = true` on Android; select the `SQLCipher` pod when using CocoaPods, or enable the `SQLCipher` package trait when using Swift Package Manager on iOS. Follow diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts index 38a5329..62717b8 100644 --- a/projects/kit/offline/src/lib/offline-command-executor.ts +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -1,27 +1,44 @@ import { InjectionToken } from '@angular/core'; -import type { OfflineCommand, OfflineEntity, OfflineScope } from './offline-repository'; +import type { OfflineCommand, OfflineScope } from './offline-repository'; +/** Server acknowledgement used to reconcile one optimistic local mutation. */ export interface OfflineCommandResult { + /** AUTO_INCREMENT id returned by a successful create. */ + serverId?: number; 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; response?: unknown; } -/** 不透明なoperationを製品APIへ送信し、cacheへ投影するadapter。 */ +/** Target ids resolved from the local replica immediately before transport. */ +export interface OfflineCommandTarget { + localId: string; + serverId: number | null; +} + +/** 不透明なoperationを製品APIへ送信し、local replicaへ投影するadapter。 */ +/** Product adapter that sends commands and projects acknowledgements into entities. */ export interface OfflineCommandExecutor { - execute(command: OfflineCommand): Promise; + execute(command: OfflineCommand, target: OfflineCommandTarget): Promise; withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand; - projectEntity?(command: OfflineCommand, entity: OfflineEntity, result: OfflineCommandResult): OfflineEntity; } +/** DI token for the product-specific command transport adapter. */ export const OFFLINE_COMMAND_EXECUTOR = new InjectionToken('OFFLINE_COMMAND_EXECUTOR'); +/** Authenticated user and group scopes currently eligible for synchronization. */ export interface OfflineSyncSession { userId: number; scopes: OfflineScope[]; } +/** Product adapter that exposes the currently authenticated synchronization session. */ export interface OfflineSyncContext { getSession(): Promise; } +/** DI token for authenticated synchronization context. */ export const OFFLINE_SYNC_CONTEXT = new InjectionToken('OFFLINE_SYNC_CONTEXT'); diff --git a/projects/kit/offline/src/lib/offline-command-hooks.ts b/projects/kit/offline/src/lib/offline-command-hooks.ts index 82a3134..c9928eb 100644 --- a/projects/kit/offline/src/lib/offline-command-hooks.ts +++ b/projects/kit/offline/src/lib/offline-command-hooks.ts @@ -1,17 +1,17 @@ import { InjectionToken } from '@angular/core'; import type { OfflineCommand } from './offline-repository'; +/** Optional product hooks for entity projection and command cleanup. */ export interface OfflineCommandHooks { - cacheEntityType(command: Pick): string; - shouldUpdateCache(command: OfflineCommand, result: { serverRevision?: string | number; response?: unknown }): boolean; + entityType(command: Pick): string; onCommandRemoved?(command: OfflineCommand): Promise; } export const DEFAULT_OFFLINE_COMMAND_HOOKS: OfflineCommandHooks = { - cacheEntityType: (command) => command.aggregateType, - shouldUpdateCache: () => true, + entityType: (command) => command.aggregateType, }; +/** DI token for optional product-specific synchronization hooks. */ export const OFFLINE_COMMAND_HOOKS = new InjectionToken('OFFLINE_COMMAND_HOOKS', { providedIn: 'root', factory: () => DEFAULT_OFFLINE_COMMAND_HOOKS, diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index fcf1904..8e75946 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -4,8 +4,10 @@ import { OFFLINE_REPOSITORY } from './offline-repository'; import { OfflineSessionService } from './offline-session.service'; import { OfflineSyncService } from './offline-sync.service'; +/** User choice when logout encounters unconfirmed local mutations. */ export type OfflineLogoutAction = 'sync' | 'discard' | 'cancel'; +/** Coordinates local persistence, session boundaries, network state, and outbox synchronization. */ @Injectable({ providedIn: 'root' }) export class OfflineCoordinatorService { readonly #repository = inject(OFFLINE_REPOSITORY); diff --git a/projects/kit/offline/src/lib/offline-error-reporter.ts b/projects/kit/offline/src/lib/offline-error-reporter.ts deleted file mode 100644 index d2006bc..0000000 --- a/projects/kit/offline/src/lib/offline-error-reporter.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { InjectionToken } from '@angular/core'; - -export interface OfflinePersistenceErrorContext { - operation: 'storeFresh'; - method: string; - url: string; -} - -/** Reports background persistence failures without replacing a successful HTTP response. */ -export interface OfflineErrorReporter { - report(error: unknown, context: OfflinePersistenceErrorContext): void; -} - -export const OFFLINE_ERROR_REPORTER = new InjectionToken('OFFLINE_ERROR_REPORTER', { - providedIn: 'root', - factory: () => ({ - report: (error, context) => console.error('[offline] persistence failed', context, error), - }), -}); diff --git a/projects/kit/offline/src/lib/offline-kit-options.ts b/projects/kit/offline/src/lib/offline-kit-options.ts index 7b79b86..b7484f5 100644 --- a/projects/kit/offline/src/lib/offline-kit-options.ts +++ b/projects/kit/offline/src/lib/offline-kit-options.ts @@ -1,4 +1,5 @@ import { InjectionToken } from '@angular/core'; +import type { OfflineReplicaSchemaBundle } from './offline-replica-schema'; /** Product-independent native offline persistence settings. */ export interface OfflineKitOptions { @@ -6,6 +7,8 @@ export interface OfflineKitOptions { databaseName: string; /** Resolves the native database key from secure device storage. Required on iOS and Android. */ encryptionKey?: () => Promise; + /** Versioned product replica schema applied to native SQLite during initialization. */ + replicaSchema: OfflineReplicaSchemaBundle; } /** DI token for product-independent offline persistence settings. */ diff --git a/projects/kit/offline/src/lib/offline-network.service.ts b/projects/kit/offline/src/lib/offline-network.service.ts index d4fb072..6238fd6 100644 --- a/projects/kit/offline/src/lib/offline-network.service.ts +++ b/projects/kit/offline/src/lib/offline-network.service.ts @@ -5,11 +5,12 @@ import { Network } from '@capacitor/network'; export type OfflineNetworkState = 'online' | 'offline' | 'unverified'; -/** transport不能(status=0)だけをcache fallback対象にし、HTTPエラーは隠さない。 */ +/** transport不能(status=0)だけをlocal replica fallback対象にし、HTTPエラーは隠さない。 */ export function isOfflineFallbackError(error: unknown): boolean { return typeof error === 'object' && error !== null && (error as { status?: unknown }).status === 0; } +/** Combines operating-system connectivity with observed API reachability. */ @Injectable({ providedIn: 'root' }) export class OfflineNetworkService { readonly #osConnected = signal(null); diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 2561542..10ea7b9 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -8,11 +8,11 @@ import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import type { OfflineKitOptions } from './offline-kit-options'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineCoordinatorService } from './offline-coordinator.service'; -import type { OfflineErrorReporter } from './offline-error-reporter'; -import { OFFLINE_ERROR_REPORTER } from './offline-error-reporter'; import { IonicOfflineRepository, OFFLINE_REPOSITORY, selectOfflineRepository } from './offline-repository'; import type { OfflineRequestPolicy } from './offline-request-policy'; import { provideOfflineRequestPolicy } from './offline-request-policy'; +import type { OfflineReplicaPuller } from './offline-replica-puller'; +import { OFFLINE_REPLICA_PULLER } from './offline-replica-puller'; import { OfflineSessionService } from './offline-session.service'; import { CAPAWESOME_SQLITE, type CapawesomeSqlitePlugin, SqliteOfflineRepository } from './sqlite-offline-repository'; @@ -20,12 +20,12 @@ import { CAPAWESOME_SQLITE, type CapawesomeSqlitePlugin, SqliteOfflineRepository export interface ProvideOfflineOptions extends OfflineKitOptions { /** Product adapter that sends opaque commands to its API. */ commandExecutor: Type; - /** Product policies that map URLs and DTOs to generic cache/outbox operations. */ + /** Product transport for explicit cursor-based server delta pulls. */ + replicaPuller: Type; + /** Product policies that map URLs and DTOs to generic replica/outbox operations. */ requestPolicies: readonly Type[]; - /** Optional product hooks for cache projection and command cleanup. */ + /** Optional product hooks for entity projection and command cleanup. */ commandHooks?: Type; - /** Optional reporter for background cache persistence failures. Defaults to console.error. */ - errorReporter?: Type; /** Optional additional providers required by product adapters. */ providers?: readonly Provider[]; /** Capawesome `Sqlite` plugin. Required only when this runtime is selected on iOS or Android. */ @@ -43,9 +43,14 @@ export interface ProvideOfflineOptions extends OfflineKitOptions { export function provideOffline(options: ProvideOfflineOptions): EnvironmentProviders { return makeEnvironmentProviders([ options.commandExecutor, + options.replicaPuller, { provide: OFFLINE_KIT_OPTIONS, - useValue: { databaseName: options.databaseName, encryptionKey: options.encryptionKey }, + useValue: { + databaseName: options.databaseName, + encryptionKey: options.encryptionKey, + replicaSchema: options.replicaSchema, + }, }, { provide: CAPAWESOME_SQLITE, useValue: options.sqlitePlugin ?? null }, { @@ -54,8 +59,8 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi }, { provide: OFFLINE_SYNC_CONTEXT, useExisting: OfflineSessionService }, { provide: OFFLINE_COMMAND_EXECUTOR, useExisting: options.commandExecutor }, + { provide: OFFLINE_REPLICA_PULLER, useExisting: options.replicaPuller }, ...(options.commandHooks ? [options.commandHooks, { provide: OFFLINE_COMMAND_HOOKS, useExisting: options.commandHooks }] : []), - ...(options.errorReporter ? [options.errorReporter, { provide: OFFLINE_ERROR_REPORTER, useExisting: options.errorReporter }] : []), ...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), ...(options.providers ?? []), provideAppInitializer(() => inject(OfflineCoordinatorService).initialize()), diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts new file mode 100644 index 0000000..3250b80 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts @@ -0,0 +1,603 @@ +import { TestBed } from '@angular/core/testing'; +import { KitStorageService } from '@rdlabo/ionic-angular-kit'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { + OFFLINE_REPLICA_PULLER, + type OfflineReplicaChange, + type OfflineReplicaPullPage, + type OfflineReplicaPullRequest, +} from './offline-replica-puller'; +import { OfflineReplicaPullService } from './offline-replica-pull.service'; +import { + defineOfflineReplicaSchema, + defineReplicaEntity, + serverId, + sha256OfflineReplicaSchema, + text, +} from './offline-replica-schema'; +import { + IonicOfflineRepository, + OFFLINE_REPOSITORY, + OFFLINE_SCHEMA_VERSION, + type OfflineCommand, + type OfflineReplicaRow, + type OfflineRepository, + type OfflineScope, +} from './offline-repository'; + +type TestItemSelect = { id: number; title: string }; + +const testItemEntity = defineReplicaEntity()({ + table: 'test_items', + sourceKey: 'test_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, +}); + +const replicaSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [testItemEntity], + migrations: [], +}); + +const scope: OfflineScope = { userId: 1, groupId: 10 }; + +class MemoryStorage { + readonly values = new Map(); + get(key: string): Promise { + return Promise.resolve((this.values.get(key) as T | undefined) ?? null); + } + set(key: string, value: T): Promise { + this.values.set(key, structuredClone(value)); + return Promise.resolve(value); + } + remove(key: string): Promise { + this.values.delete(key); + return Promise.resolve(); + } + keys(): Promise { + return Promise.resolve([...this.values.keys()]); + } +} + +function itemChange( + serverIdValue: number, + title: string, + options: Partial> = {}, +): OfflineReplicaChange { + return { + sourceKey: 'test_items', + serverId: serverIdValue, + serverRevision: options.serverRevision ?? 1, + values: options.deleted ? null : (options.values ?? { id: serverIdValue, title }), + deleted: options.deleted ?? false, + }; +} + +describe('OfflineReplicaPullService', () => { + let service: OfflineReplicaPullService; + let repository: OfflineRepository; + let storage: MemoryStorage; + let schemaHash: string; + let pull: ReturnType Promise>>; + + function page( + changes: readonly OfflineReplicaChange[], + options: { + nextCursor?: string; + hasMore?: boolean; + schemaVersion?: number; + schemaHash?: string; + } = {}, + ): OfflineReplicaPullPage { + return { + schemaVersion: options.schemaVersion ?? replicaSchema.version, + schemaHash: options.schemaHash ?? schemaHash, + changes, + nextCursor: options.nextCursor ?? 'cursor-v1', + hasMore: options.hasMore ?? false, + }; + } + + async function expectPullRejectsPreservingCursor(setup: () => void, message: string | RegExp): Promise { + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + setup(); + await expect(service.pull(scope)).rejects.toThrow(message); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + expect(await repository.getReplicaRows(scope, 'test_items')).toEqual([]); + } + + async function seedReplicaMetadata(): Promise { + schemaHash = await sha256OfflineReplicaSchema(replicaSchema); + storage.values.set('offline:metadata', { + schemaVersion: OFFLINE_SCHEMA_VERSION, + lastUserId: null, + replicaSchemaVersion: replicaSchema.version, + replicaSchemaHash: schemaHash, + }); + storage.values.set('offline:replica:rows', {}); + storage.values.set('offline:outbox:commands', {}); + storage.values.set('offline:replica:cursors', {}); + } + + function configureTestBed(): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + OfflineReplicaPullService, + IonicOfflineRepository, + { provide: KitStorageService, useValue: storage }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema } }, + { provide: OFFLINE_REPOSITORY, useExisting: IonicOfflineRepository }, + { provide: OFFLINE_REPLICA_PULLER, useValue: { pull } }, + { + provide: OFFLINE_COMMAND_HOOKS, + useValue: { entityType: (command: Pick) => command.aggregateType }, + }, + ], + }); + repository = TestBed.inject(OFFLINE_REPOSITORY); + service = TestBed.inject(OfflineReplicaPullService); + } + + beforeEach(async () => { + storage = new MemoryStorage(); + pull = vi.fn(async () => page([])); + await seedReplicaMetadata(); + configureTestBed(); + await repository.initialize(); + }); + + it('initial empty cursor requestをpullerへ送る', async () => { + pull.mockResolvedValueOnce(page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' })); + + await service.pull(scope); + + expect(pull).toHaveBeenCalledOnce(); + expect(pull.mock.calls[0]?.[0]).toEqual({ + scope, + cursor: '', + schemaVersion: replicaSchema.version, + schemaHash, + }); + }); + + it('exact schema version/hash handshakeを要求し、一致ページだけ受理する', async () => { + pull.mockResolvedValueOnce(page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' })); + + await service.pull(scope); + + expect(pull.mock.calls[0]?.[0].schemaVersion).toBe(1); + expect(pull.mock.calls[0]?.[0].schemaHash).toBe(schemaHash); + }); + + it('multi-page cursor progressionでstored cursorをページングする', async () => { + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull + .mockResolvedValueOnce(page([itemChange(42, 'Page 1')], { nextCursor: 'cursor-v1', hasMore: true })) + .mockResolvedValueOnce(page([itemChange(43, 'Page 2')], { nextCursor: 'cursor-v2', hasMore: false })); + + await service.pull(scope); + + expect(pull.mock.calls.map(([request]) => request.cursor)).toEqual(['cursor-v0', 'cursor-v1']); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v2' }); + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + confirmedValues: { title: 'Page 1' }, + }); + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 43)).resolves.toMatchObject({ + confirmedValues: { title: 'Page 2' }, + }); + }); + + it('row更新とcursor更新を同一transactReplica呼び出しで原子的に書く', async () => { + const transactReplica = vi.spyOn(repository, 'transactReplica'); + pull.mockResolvedValueOnce(page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' })); + + await service.pull(scope); + + expect(transactReplica).toHaveBeenCalledOnce(); + expect(transactReplica.mock.calls[0]?.[0]).toMatchObject({ + putRows: [expect.objectContaining({ serverId: 42, confirmedValues: { title: 'Created' } })], + putCursors: [{ ...scope, cursor: 'cursor-v1' }], + }); + }); + + it('new remote rowにlocal UUIDとserver IDを割り当てる', async () => { + const randomUuid = vi.spyOn(crypto, 'randomUUID').mockReturnValue('019d0000-0000-7000-8000-000000000001'); + pull.mockResolvedValueOnce(page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' })); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d0000-0000-7000-8000-000000000001')).resolves.toMatchObject({ + localId: '019d0000-0000-7000-8000-000000000001', + serverId: 42, + sourceKey: 'test_items', + syncState: 'confirmed', + values: { title: 'Created' }, + confirmedValues: { title: 'Created' }, + }); + randomUuid.mockRestore(); + }); + + it('existing remote rowをupdateする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-existing', + serverId: 42, + values: { id: 42, title: 'Old' }, + confirmedValues: { id: 42, title: 'Old' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Updated', { serverRevision: 2 })], { nextCursor: 'cursor-v1' }), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-existing')).resolves.toMatchObject({ + localId: '019d-existing', + serverId: 42, + serverRevision: 2, + values: { title: 'Updated' }, + confirmedValues: { title: 'Updated' }, + syncState: 'confirmed', + }); + }); + + it('pending commandが無いdeleteはreplica rowを削除する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-delete', + serverId: 42, + values: { id: 42, title: 'Gone' }, + confirmedValues: { id: 42, title: 'Gone' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Gone', { deleted: true, serverRevision: 2 })], { nextCursor: 'cursor-v1' }), + ); + + await service.pull(scope); + + expect(await repository.getReplicaRow(scope, 'test_items', '019d-delete')).toBeNull(); + expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).toBeNull(); + }); + + it('duplicate changeはlast-winsでcollapseする', async () => { + pull.mockResolvedValueOnce( + page( + [ + itemChange(42, 'First', { serverRevision: 1 }), + itemChange(42, 'Second', { serverRevision: 2 }), + itemChange(42, 'Third', { serverRevision: 3 }), + ], + { nextCursor: 'cursor-v1' }, + ), + ); + + await service.pull(scope); + + const rows = await repository.getReplicaRows(scope, 'test_items'); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + confirmedValues: { title: 'Third' }, + serverRevision: 3, + }); + }); + + it('invalid valuesはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce( + page([itemChange(42, 'Broken', { values: { id: 42 } })], { nextCursor: 'cursor-v1' }), + ), + 'Replica row is missing required source key "title".', + ); + }); + + describe('pull page boundary validation', () => { + it('malformed nextCursorはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce({ + ...page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' }), + nextCursor: 1 as unknown as string, + }), + 'Offline replica pull page nextCursor must be a string.', + ); + }); + + it('malformed hasMoreはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce({ + ...page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' }), + hasMore: 'yes' as unknown as boolean, + }), + 'Offline replica pull page hasMore must be a boolean.', + ); + }); + + it('malformed changesはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce({ + ...page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1' }), + changes: null as unknown as OfflineReplicaChange[], + }), + 'Offline replica pull page changes must be an array.', + ); + }); + + it('non-positive serverIdはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce( + page([{ ...itemChange(42, 'Created'), serverId: 0 }], { nextCursor: 'cursor-v1' }), + ), + 'Offline replica pull page changes[0].serverId must be a positive integer.', + ); + }); + + it('non-integer serverIdはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce( + page([{ ...itemChange(42, 'Created'), serverId: 42.5 }], { nextCursor: 'cursor-v1' }), + ), + 'Offline replica pull page changes[0].serverId must be a positive integer.', + ); + }); + + it('invalid serverRevision typeはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce( + page([{ ...itemChange(42, 'Created'), serverRevision: true as unknown as number }], { + nextCursor: 'cursor-v1', + }), + ), + 'Offline replica pull page changes[0].serverRevision must be a string or number.', + ); + }); + + it('deleted change with non-null valuesはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => + pull.mockResolvedValueOnce( + page( + [{ ...itemChange(42, 'Gone', { deleted: true, serverRevision: 2 }), values: { id: 42, title: 'Gone' } }], + { nextCursor: 'cursor-v1' }, + ), + ), + 'Offline replica pull page changes[0] with deleted=true must have null values.', + ); + }); + }); + + it('unknown source keyはrejectしcursorを進めない', async () => { + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull.mockResolvedValueOnce( + page( + [{ sourceKey: 'unknown_items', serverId: 42, serverRevision: 1, values: { id: 42, title: 'X' }, deleted: false }], + { nextCursor: 'cursor-v1' }, + ), + ); + + await expect(service.pull(scope)).rejects.toThrow('Unknown offline replica source key "unknown_items".'); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + }); + + it('missing valuesはrejectしcursorを進めない', async () => { + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull.mockResolvedValueOnce( + page([{ sourceKey: 'test_items', serverId: 42, serverRevision: 1, values: null, deleted: false }], { + nextCursor: 'cursor-v1', + }), + ); + + await expect(service.pull(scope)).rejects.toThrow('Offline replica change "test_items"/42 is missing values.'); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + }); + + it('schema mismatchはrejectしcursorを進めない', async () => { + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1', schemaVersion: 99, schemaHash: 'deadbeef' }), + ); + + await expect(service.pull(scope)).rejects.toThrow('Offline replica schema mismatch'); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + expect(await repository.getReplicaRows(scope, 'test_items')).toEqual([]); + }); + + it('non-advancing cursorはrejectしcursorを進めない', async () => { + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull.mockResolvedValueOnce(page([itemChange(42, 'Created')], { nextCursor: 'cursor-v0', hasMore: true })); + + await expect(service.pull(scope)).rejects.toThrow('Offline replica pull cursor did not advance'); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + expect(await repository.getReplicaRows(scope, 'test_items')).toEqual([]); + }); + + it('pending optimistic rowはconfirmed baselineだけ更新しoptimistic valuesを保持する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-pending', + serverId: 42, + values: { id: 42, title: 'Optimistic draft' }, + confirmedValues: { id: 42, title: 'Confirmed baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-pending', + aggregateType: 'test_items', + aggregateLocalId: '019d-pending', + operation: 'test_items.update', + payload: { title: 'Optimistic draft' }, + optimisticValue: { id: 42, title: 'Optimistic draft' }, + payloadHash: 'hash', + baseRevision: 2, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Server truth', { serverRevision: 2 })], { nextCursor: 'cursor-v1' }), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-pending')).resolves.toMatchObject({ + values: { title: 'Optimistic draft' }, + confirmedValues: { title: 'Server truth' }, + serverRevision: 2, + syncState: 'pending', + }); + await expect(repository.getCommands(scope)).resolves.toEqual([ + expect.objectContaining({ commandId: 'cmd-pending', state: 'pending' }), + ]); + }); + + it('revision conflictはreplicaとcommandをconflictへ遷移する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-conflict', + serverId: 42, + values: { id: 42, title: 'Local edit' }, + confirmedValues: { id: 42, title: 'Old confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-conflict', + aggregateType: 'test_items', + aggregateLocalId: '019d-conflict', + operation: 'test_items.update', + payload: { title: 'Local edit' }, + optimisticValue: { id: 42, title: 'Local edit' }, + payloadHash: 'hash', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Remote truth', { serverRevision: 9 })], { nextCursor: 'cursor-v1' }), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-conflict')).resolves.toMatchObject({ + syncState: 'conflict', + confirmedValues: { title: 'Remote truth' }, + serverRevision: 9, + }); + await expect(repository.getCommands(scope)).resolves.toEqual([ + expect.objectContaining({ + commandId: 'cmd-conflict', + state: 'conflict', + lastErrorCode: 'remote_revision', + retryAt: null, + }), + ]); + }); + + it('remote tombstone conflictはpending commandをremote_deleted conflictへ遷移する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-tombstone', + serverId: 42, + values: { id: 42, title: 'Pending delete' }, + confirmedValues: { id: 42, title: 'Confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-tombstone', + aggregateType: 'test_items', + aggregateLocalId: '019d-tombstone', + operation: 'test_items.delete', + payload: {}, + optimisticValue: { id: 42, title: 'Pending delete' }, + payloadHash: 'hash', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Confirmed', { deleted: true, serverRevision: 2 })], { nextCursor: 'cursor-v1' }), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-tombstone')).resolves.toMatchObject({ + localId: '019d-tombstone', + syncState: 'conflict', + serverRevision: 2, + }); + await expect(repository.getCommands(scope)).resolves.toEqual([ + expect.objectContaining({ + commandId: 'cmd-tombstone', + state: 'conflict', + lastErrorCode: 'remote_deleted', + retryAt: null, + }), + ]); + expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).not.toBeNull(); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.ts new file mode 100644 index 0000000..9ed7199 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -0,0 +1,210 @@ +import { inject, Injectable } from '@angular/core'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { + OFFLINE_REPLICA_PULLER, + type OfflineReplicaChange, + type OfflineReplicaPullPage, +} from './offline-replica-puller'; +import { + projectOfflineReplicaValues, + sha256OfflineReplicaSchema, + type OfflineReplicaEntitySchema, +} from './offline-replica-schema'; +import { + OFFLINE_REPOSITORY, + type OfflineCommand, + type OfflineReplicaRow, + type OfflineReplicaRowKey, + type OfflineScope, +} from './offline-repository'; + +/** Pulls authoritative server deltas into one durable local replica partition. */ +@Injectable({ providedIn: 'root' }) +export class OfflineReplicaPullService { + readonly #repository = inject(OFFLINE_REPOSITORY); + readonly #options = inject(OFFLINE_KIT_OPTIONS); + readonly #puller = inject(OFFLINE_REPLICA_PULLER); + readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); + #schemaHash: Promise | null = null; + + async pull(scope: OfflineScope): Promise { + const schemaHash = await (this.#schemaHash ??= sha256OfflineReplicaSchema(this.#options.replicaSchema)); + let cursor = (await this.#repository.getReplicaCursor(scope))?.cursor ?? ''; + + for (;;) { + const page = await this.#puller.pull({ + scope, + cursor, + schemaVersion: this.#options.replicaSchema.version, + schemaHash, + }); + this.#assertPullPage(page); + this.#assertHandshake(page.schemaVersion, page.schemaHash, schemaHash); + if (page.hasMore && page.nextCursor === cursor) { + throw new Error(`Offline replica pull cursor did not advance for scope ${scope.userId}:${scope.groupId}.`); + } + + const commands = await this.#repository.getCommands(scope); + const changes = this.#collapseChanges(page.changes); + const putRows: OfflineReplicaRow[] = []; + const removeRows: OfflineReplicaRowKey[] = []; + const putCommands = new Map(); + + for (const change of changes) { + const schema = this.#entitySchema(change.sourceKey); + const existing = await this.#repository.getReplicaRowByServerId(scope, change.sourceKey, change.serverId); + const related = existing + ? commands.filter( + (command) => + this.#hooks.entityType(command) === change.sourceKey && command.aggregateLocalId === existing.localId, + ) + : []; + const hasPending = related.length > 0; + + if (change.deleted) { + if (!existing) continue; + if (!hasPending) { + removeRows.push(existing); + continue; + } + putRows.push({ ...existing, serverRevision: change.serverRevision, syncState: 'conflict', fetchedAt: Date.now() }); + for (const command of related) { + putCommands.set(command.commandId, { ...command, state: 'conflict', retryAt: null, lastErrorCode: 'remote_deleted' }); + } + continue; + } + + const confirmedValues = this.#validatedValues(schema, change); + if (!existing) { + putRows.push({ + ...scope, + sourceKey: change.sourceKey, + localId: crypto.randomUUID(), + serverId: change.serverId, + values: confirmedValues, + confirmedValues, + serverRevision: change.serverRevision, + fetchedAt: Date.now(), + syncState: 'confirmed', + }); + continue; + } + + const conflicted = related.some((command) => command.baseRevision !== change.serverRevision); + putRows.push({ + ...existing, + values: hasPending ? existing.values : confirmedValues, + confirmedValues, + serverRevision: change.serverRevision, + fetchedAt: Date.now(), + syncState: conflicted ? 'conflict' : hasPending ? 'pending' : 'confirmed', + }); + if (conflicted) { + for (const command of related) { + putCommands.set(command.commandId, { + ...command, + state: 'conflict', + retryAt: null, + lastErrorCode: 'remote_revision', + }); + } + } + } + + await this.#repository.transactReplica({ + putRows, + removeRows, + putCommands: [...putCommands.values()], + putCursors: [{ ...scope, cursor: page.nextCursor }], + }); + cursor = page.nextCursor; + if (!page.hasMore) return; + } + } + + #assertPullPage(page: OfflineReplicaPullPage): void { + if (typeof page.nextCursor !== 'string') { + throw new Error('Offline replica pull page nextCursor must be a string.'); + } + if (typeof page.hasMore !== 'boolean') { + throw new Error('Offline replica pull page hasMore must be a boolean.'); + } + if (!Array.isArray(page.changes)) { + throw new Error('Offline replica pull page changes must be an array.'); + } + for (const [index, change] of page.changes.entries()) { + this.#assertPullChange(change, index); + } + } + + #assertPullChange(change: unknown, index: number): void { + const label = `Offline replica pull page changes[${index}]`; + if (!isPlainObject(change)) { + throw new Error(`${label} must be a plain object.`); + } + if (typeof change['sourceKey'] !== 'string') { + throw new Error(`${label}.sourceKey must be a string.`); + } + if (typeof change['deleted'] !== 'boolean') { + throw new Error(`${label}.deleted must be a boolean.`); + } + const serverId = change['serverId']; + if ( + typeof serverId !== 'number' || + !Number.isFinite(serverId) || + !Number.isSafeInteger(serverId) || + serverId <= 0 + ) { + throw new Error(`${label}.serverId must be a positive integer.`); + } + const revision = change['serverRevision']; + if (typeof revision !== 'string' && typeof revision !== 'number') { + throw new Error(`${label}.serverRevision must be a string or number.`); + } + if (change['deleted']) { + if (change['values'] !== null) { + throw new Error(`${label} with deleted=true must have null values.`); + } + } + } + + #assertHandshake(version: number, hash: string, expectedHash: string): void { + if (version !== this.#options.replicaSchema.version || hash !== expectedHash) { + throw new Error( + `Offline replica schema mismatch: client=${this.#options.replicaSchema.version}/${expectedHash}, server=${version}/${hash}.`, + ); + } + } + + #entitySchema(sourceKey: string): OfflineReplicaEntitySchema> { + const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); + if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); + return schema; + } + + #validatedValues( + schema: OfflineReplicaEntitySchema>, + change: OfflineReplicaChange, + ): unknown { + if (change.values === null) { + throw new Error(`Offline replica change "${change.sourceKey}"/${change.serverId} is missing values.`); + } + return projectOfflineReplicaValues(schema, change.values); + } + + #collapseChanges(changes: readonly OfflineReplicaChange[]): OfflineReplicaChange[] { + const collapsed = new Map(); + for (const change of changes) collapsed.set(`${change.sourceKey}:${change.serverId}`, change); + return [...collapsed.values()]; + } +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/projects/kit/offline/src/lib/offline-replica-puller.ts b/projects/kit/offline/src/lib/offline-replica-puller.ts new file mode 100644 index 0000000..9a8ed13 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-puller.ts @@ -0,0 +1,36 @@ +import { InjectionToken } from '@angular/core'; +import type { OfflineScope } from './offline-repository'; + +/** Server pull request for one user/group replica partition. */ +export interface OfflineReplicaPullRequest { + scope: OfflineScope; + cursor: string; + schemaVersion: number; + schemaHash: string; +} + +/** One server-side replica mutation returned by an explicit pull page. */ +export interface OfflineReplicaChange { + sourceKey: string; + serverId: number; + serverRevision: string | number; + values: unknown | null; + deleted: boolean; +} + +/** One explicit replica pull response page from the application backend. */ +export interface OfflineReplicaPullPage { + schemaVersion: number; + schemaHash: string; + changes: readonly OfflineReplicaChange[]; + nextCursor: string; + hasMore: boolean; +} + +/** Application-provided transport that fetches explicit replica pull pages from the server. */ +export interface OfflineReplicaPuller { + pull(request: OfflineReplicaPullRequest): Promise; +} + +/** DI token for the application-provided explicit replica pull transport. */ +export const OFFLINE_REPLICA_PULLER = new InjectionToken('OFFLINE_REPLICA_PULLER'); diff --git a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts new file mode 100644 index 0000000..a08355c --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts @@ -0,0 +1,763 @@ +import { describe, expect, it } from 'vitest'; +import { + booleanColumn, + datetime, + decodeOfflineReplicaValues, + defineOfflineReplicaSchema, + defineReplicaEntity, + encodeOfflineReplicaValues, + ignored, + integer, + json, + nullable, + projectOfflineReplicaValues, + real, + serverId, + sha256OfflineReplicaSchema, + text, +} from './offline-replica-schema'; + +type SampleSelect = { + id: number; + title: string; + notes: string | null; + amount: number; + active: boolean; + payload: { version: number }; + updatedAt: string | Date; + transientFlag: boolean; +}; + +const sampleSchema = defineReplicaEntity()({ + table: 'sample_items', + sourceKey: 'sample_items', + scope: 'group', + fields: { + id: serverId(), + title: text(), + notes: nullable(text()), + amount: real(), + active: booleanColumn(), + payload: json<{ version: number }>(), + updatedAt: datetime(), + transientFlag: ignored('server-only cache flag'), + }, +}); + +describe('offline-replica-schema runtime', () => { + it('materializes ordered field descriptors with table metadata', () => { + expect(sampleSchema.tableName).toBe('sample_items'); + expect(sampleSchema.sourceKey).toBe('sample_items'); + expect(sampleSchema.scope).toBe('group'); + expect(sampleSchema.fields.map((field) => field.sourceKey)).toEqual([ + 'active', + 'amount', + 'id', + 'notes', + 'payload', + 'title', + 'transientFlag', + 'updatedAt', + ]); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'id')).toEqual({ + sourceKey: 'id', + policy: 'serverId', + sqliteColumnName: 'server_id', + affinity: 'INTEGER', + storageKind: null, + nullable: true, + ignoredReason: null, + }); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'notes')).toMatchObject({ + policy: 'column', + sqliteColumnName: 'notes', + affinity: 'TEXT', + storageKind: 'text', + nullable: true, + }); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'updatedAt')).toMatchObject({ + policy: 'column', + sqliteColumnName: 'updated_at', + affinity: 'TEXT', + storageKind: 'datetime', + nullable: false, + }); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'active')).toMatchObject({ + storageKind: 'booleanColumn', + affinity: 'INTEGER', + }); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'amount')).toMatchObject({ + storageKind: 'real', + affinity: 'REAL', + }); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'payload')).toMatchObject({ + storageKind: 'json', + affinity: 'TEXT', + }); + expect(sampleSchema.fields.find((field) => field.sourceKey === 'transientFlag')).toMatchObject({ + policy: 'ignored', + sqliteColumnName: null, + ignoredReason: 'server-only cache flag', + }); + }); + + it('generates deterministic CREATE TABLE SQL and scoped server_id index', () => { + expect(sampleSchema.createTableSql).toEqual([ + `CREATE TABLE IF NOT EXISTS sample_items ( + local_id TEXT NOT NULL, + _offline_user_id INTEGER NOT NULL, + _offline_group_id INTEGER NOT NULL, + server_id INTEGER, + _offline_confirmed_json TEXT, + _offline_server_revision_json TEXT, + _offline_sync_state TEXT NOT NULL, + _offline_fetched_at INTEGER NOT NULL, + active INTEGER NOT NULL, + amount REAL NOT NULL, + notes TEXT, + payload TEXT NOT NULL, + title TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (local_id) +)`, + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_sample_items_server_id ON sample_items (_offline_user_id, _offline_group_id, server_id) WHERE server_id IS NOT NULL', + ]); + }); + + it('omits _offline_group_id and scopes the partial index to _offline_user_id for user scope', () => { + type UserScopedSelect = { id: number; title: string }; + const userScopedSchema = defineReplicaEntity()({ + table: 'user_notes', + sourceKey: 'user_notes', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, + }); + + expect(userScopedSchema.createTableSql).toEqual([ + `CREATE TABLE IF NOT EXISTS user_notes ( + local_id TEXT NOT NULL, + _offline_user_id INTEGER NOT NULL, + server_id INTEGER, + _offline_confirmed_json TEXT, + _offline_server_revision_json TEXT, + _offline_sync_state TEXT NOT NULL, + _offline_fetched_at INTEGER NOT NULL, + title TEXT NOT NULL, + PRIMARY KEY (local_id) +)`, + 'CREATE UNIQUE INDEX IF NOT EXISTS uq_user_notes_server_id ON user_notes (_offline_user_id, server_id) WHERE server_id IS NOT NULL', + ]); + }); + + it('exposes a deterministic schema fingerprint input', () => { + expect(sampleSchema.schemaFingerprintInput).toBe( + 'table=sample_items|source=sample_items|scope=group|hasServerId=1|fields=active:column:active:INTEGER:booleanColumn:required;amount:column:amount:REAL:real:required;id:serverId:server_id:INTEGER:nullable;notes:column:notes:TEXT:text:nullable;payload:column:payload:TEXT:json:required;title:column:title:TEXT:text:required;transientFlag:ignored:server-only cache flag;updatedAt:column:updated_at:TEXT:datetime:required', + ); + }); + + it('rejects invalid table and reserved column identifiers', () => { + type MinimalSelect = { title: string }; + expect(() => + defineReplicaEntity()({ + table: 'Bad-Table', + sourceKey: 'items', + scope: 'user', + fields: { title: text() }, + }), + ).toThrow('Replica table "Bad-Table" must match ^[a-z][a-z0-9_]*$.'); + + expect(() => + defineReplicaEntity()({ + table: 'items', + sourceKey: 'items', + scope: 'user', + fields: { title: { kind: 'column', affinity: 'TEXT', storageKind: 'text', columnName: 'local_id', nullable: false } }, + }), + ).toThrow('Replica column "local_id" is reserved.'); + }); + + it('rejects ignored fields with an empty reason', () => { + type Select = { flag: boolean }; + expect(() => + defineReplicaEntity()({ + table: 'items', + sourceKey: 'items', + scope: 'user', + fields: { id: serverId(), altId: serverId() }, + }), + ).toThrow('Replica entity cannot define more than one serverId field.'); + }); +}); + +const sampleRowValues = { + id: 42, + title: 'Sample', + notes: null, + amount: 12.5, + active: true, + payload: { version: 3 }, + updatedAt: '2026-07-22T12:00:00.000Z', + transientFlag: true, +}; + +describe('encodeOfflineReplicaValues', () => { + it('encodes column fields in deterministic descriptor order and ignores serverId/ignored source keys', () => { + const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); + + expect(Object.keys(encoded)).toEqual([ + 'active', + 'amount', + 'notes', + 'payload', + 'title', + 'updated_at', + ]); + expect(encoded).toEqual({ + active: 1, + amount: 12.5, + notes: null, + payload: '{"version":3}', + title: 'Sample', + updated_at: '2026-07-22T12:00:00.000Z', + }); + }); + + it('accepts nullable null, Date datetimes, and JSON-serializable payloads', () => { + const encoded = encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + notes: 'memo', + updatedAt: new Date('2026-07-22T12:00:00.000Z'), + active: false, + }); + + expect(encoded['notes']).toBe('memo'); + expect(encoded['updated_at']).toBe('2026-07-22T12:00:00.000Z'); + expect(encoded['active']).toBe(0); + }); + + it('rejects non-plain objects, unknown keys, and missing column fields', () => { + expect(() => encodeOfflineReplicaValues(sampleSchema, null)).toThrow( + 'Replica row values must be a plain object.', + ); + expect(() => encodeOfflineReplicaValues(sampleSchema, new Map())).toThrow( + 'Replica row values must be a plain object.', + ); + + expect(() => + encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + surprise: 'schema drift', + }), + ).toThrow('Replica row contains unknown source key "surprise".'); + + const { title: _title, ...missingTitle } = sampleRowValues; + expect(() => encodeOfflineReplicaValues(sampleSchema, missingTitle)).toThrow( + 'Replica row is missing required source key "title".', + ); + }); + + it('rejects null for required columns and invalid typed values', () => { + expect(() => + encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + title: null, + }), + ).toThrow('Replica row source key "title" must not be null.'); + + expect(() => + encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + amount: Number.NaN, + }), + ).toThrow('Replica row source key "amount" must be a finite number.'); + + expect(() => + encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + active: 1, + }), + ).toThrow('Replica row source key "active" must be a boolean.'); + + expect(() => + encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + payload: { version: undefined }, + }), + ).toThrow('Replica row source key "payload" must be JSON-serializable without undefined.'); + + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => + encodeOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + payload: cyclic, + }), + ).toThrow('Replica row source key "payload" must be JSON-serializable without cycles.'); + }); +}); + +const sampleColumnValues = { + title: 'Sample', + notes: null, + amount: 12.5, + active: true, + payload: { version: 3 }, + updatedAt: '2026-07-22T12:00:00.000Z', +}; + +describe('decodeOfflineReplicaValues', () => { + it('decodes column fields keyed by source property names and ignores serverId/ignored columns', () => { + const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); + const decoded = decodeOfflineReplicaValues(sampleSchema, { + ...encoded, + server_id: 42, + local_id: 'local-1', + _offline_user_id: 7, + _offline_group_id: 3, + _offline_confirmed_json: null, + _offline_server_revision_json: null, + _offline_sync_state: 'synced', + _offline_fetched_at: 1_720_000_000_000, + }); + + expect(Object.keys(decoded)).toEqual([ + 'active', + 'amount', + 'notes', + 'payload', + 'title', + 'updatedAt', + ]); + expect(decoded).toEqual(sampleColumnValues); + }); + + it('rejects non-plain objects, missing columns, and null for required columns', () => { + const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); + + expect(() => decodeOfflineReplicaValues(sampleSchema, null)).toThrow( + 'Replica SQLite row must be a plain object.', + ); + expect(() => decodeOfflineReplicaValues(sampleSchema, new Map())).toThrow( + 'Replica SQLite row must be a plain object.', + ); + + const { title: _title, ...missingTitle } = encoded; + expect(() => decodeOfflineReplicaValues(sampleSchema, missingTitle)).toThrow( + 'Replica SQLite row is missing required column "title".', + ); + + expect(() => + decodeOfflineReplicaValues(sampleSchema, { + ...encoded, + title: null, + }), + ).toThrow('Replica SQLite column "title" must not be null.'); + }); + + it('rejects invalid SQLite column values', () => { + const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); + + expect(() => + decodeOfflineReplicaValues(sampleSchema, { + ...encoded, + amount: Number.NaN, + }), + ).toThrow('Replica row source key "amount" must be a finite number.'); + + expect(() => + decodeOfflineReplicaValues(sampleSchema, { + ...encoded, + active: true, + }), + ).toThrow('Replica row source key "active" must be boolean integer 0 or 1.'); + + expect(() => + decodeOfflineReplicaValues(sampleSchema, { + ...encoded, + payload: '{invalid', + }), + ).toThrow('Replica row source key "payload" must be valid JSON.'); + }); +}); + +describe('encodeOfflineReplicaValues round-trip', () => { + it('round-trips column values through encode and decode', () => { + const sourceRows = [ + sampleRowValues, + { + ...sampleRowValues, + notes: 'memo', + updatedAt: new Date('2026-07-22T12:00:00.000Z'), + active: false, + }, + ]; + + for (const sourceRow of sourceRows) { + const encoded = encodeOfflineReplicaValues(sampleSchema, sourceRow); + const decoded = decodeOfflineReplicaValues(sampleSchema, encoded); + + expect(decoded).toEqual({ + title: sourceRow.title, + notes: sourceRow.notes, + amount: sourceRow.amount, + active: sourceRow.active, + payload: sourceRow.payload, + updatedAt: + sourceRow.updatedAt instanceof Date + ? sourceRow.updatedAt.toISOString() + : sourceRow.updatedAt, + }); + } + }); +}); + +describe('projectOfflineReplicaValues', () => { + it('projects column fields and omits serverId and ignored source keys', () => { + expect(projectOfflineReplicaValues(sampleSchema, sampleRowValues)).toEqual(sampleColumnValues); + expect(Object.keys(projectOfflineReplicaValues(sampleSchema, sampleRowValues))).not.toContain('id'); + expect(Object.keys(projectOfflineReplicaValues(sampleSchema, sampleRowValues))).not.toContain('transientFlag'); + }); + + it('validates values like encodeOfflineReplicaValues before projecting', () => { + expect(() => projectOfflineReplicaValues(sampleSchema, null)).toThrow( + 'Replica row values must be a plain object.', + ); + expect(() => + projectOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + surprise: 'schema drift', + }), + ).toThrow('Replica row contains unknown source key "surprise".'); + expect(() => + projectOfflineReplicaValues(sampleSchema, { + ...sampleRowValues, + title: null, + }), + ).toThrow('Replica row source key "title" must not be null.'); + }); +}); + +type UserNotesSelect = { id: number; title: string }; +const userNotesSchema = defineReplicaEntity()({ + table: 'user_notes', + sourceKey: 'user_notes', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, +}); + +const schemaBundle = defineOfflineReplicaSchema({ + version: 2, + entities: [userNotesSchema, sampleSchema], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE sample_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0'], + migrateWebRow: (row) => row, + }, + ], +}); + +describe('offline-replica-schema bundle runtime', () => { + it('orders entities and migrations deterministically regardless of input order', () => { + const reversed = defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema, userNotesSchema], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE sample_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0'], + migrateWebRow: (row) => row, + }, + ], + }); + + expect(schemaBundle.entities.map((entity) => entity.sourceKey)).toEqual(['sample_items', 'user_notes']); + expect(schemaBundle.migrations.map((migration) => migration.fromVersion)).toEqual([1]); + expect(reversed.schemaFingerprintInput).toBe(schemaBundle.schemaFingerprintInput); + expect(reversed.entities).toEqual(schemaBundle.entities); + expect(reversed.migrations.map((migration) => migration.statements)).toEqual( + schemaBundle.migrations.map((migration) => migration.statements), + ); + }); + + it('exposes a canonical bundle schema fingerprint input', () => { + expect(schemaBundle.schemaFingerprintInput).toBe( + `version=2|entities=${sampleSchema.schemaFingerprintInput};${userNotesSchema.schemaFingerprintInput}|migrations=from=1|statements=ALTER TABLE sample_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0`, + ); + }); + + it('computes a stable 64-character lowercase sha256 digest', async () => { + const digest = await sha256OfflineReplicaSchema(schemaBundle); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + expect(digest).toBe('0c276e4507eff8937278ac5126fe66aceab802ef780efc459d80de70dba050f5'); + }); + + it('changes the bundle fingerprint when storageKind changes from integer to booleanColumn while affinity stays INTEGER', async () => { + type IntegerSelect = { id: number; flag: number }; + type BooleanSelect = { id: number; flag: boolean }; + + const integerEntity = defineReplicaEntity()({ + table: 'flag_items', + sourceKey: 'flag_items', + scope: 'user', + fields: { + id: serverId(), + flag: integer(), + }, + }); + const booleanEntity = defineReplicaEntity()({ + table: 'flag_items', + sourceKey: 'flag_items', + scope: 'user', + fields: { + id: serverId(), + flag: booleanColumn(), + }, + }); + + expect(integerEntity.fields.find((field) => field.sourceKey === 'flag')).toMatchObject({ + affinity: 'INTEGER', + storageKind: 'integer', + }); + expect(booleanEntity.fields.find((field) => field.sourceKey === 'flag')).toMatchObject({ + affinity: 'INTEGER', + storageKind: 'booleanColumn', + }); + expect(integerEntity.schemaFingerprintInput).not.toBe(booleanEntity.schemaFingerprintInput); + + const integerBundle = defineOfflineReplicaSchema({ + version: 1, + entities: [integerEntity], + migrations: [], + }); + const booleanBundle = defineOfflineReplicaSchema({ + version: 1, + entities: [booleanEntity], + migrations: [], + }); + + expect(integerBundle.schemaFingerprintInput).not.toBe(booleanBundle.schemaFingerprintInput); + expect(await sha256OfflineReplicaSchema(integerBundle)).not.toBe( + await sha256OfflineReplicaSchema(booleanBundle), + ); + }); + + it('rejects invalid bundle and migration versions', () => { + expect(() => + defineOfflineReplicaSchema({ + version: 0, + entities: [sampleSchema], + migrations: [], + }), + ).toThrow('Replica schema version must be a positive integer.'); + + expect(() => + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema], + migrations: [{ fromVersion: 0, statements: ['SELECT 1'], migrateWebRow: (row) => row }], + }), + ).toThrow('Replica migration fromVersion must be a positive integer.'); + + expect(() => + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema], + migrations: [{ fromVersion: 2, statements: ['SELECT 1'], migrateWebRow: (row) => row }], + }), + ).toThrow('Replica migration from version 2 targets version 3, which exceeds bundle version 2.'); + }); + + it('rejects duplicate entity identifiers and migration fromVersion values', () => { + expect(() => + defineOfflineReplicaSchema({ + version: 1, + entities: [sampleSchema, sampleSchema], + migrations: [], + }), + ).toThrow('Duplicate replica entity table "sample_items".'); + + type AltSelect = { id: number; title: string }; + const altTableSchema = defineReplicaEntity()({ + table: 'alt_items', + sourceKey: 'sample_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, + }); + + expect(() => + defineOfflineReplicaSchema({ + version: 1, + entities: [sampleSchema, altTableSchema], + migrations: [], + }), + ).toThrow('Duplicate replica entity source key "sample_items".'); + + expect(() => + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema], + migrations: [ + { fromVersion: 1, statements: ['SELECT 1'], migrateWebRow: (row) => row }, + { fromVersion: 1, statements: ['SELECT 2'], migrateWebRow: (row) => row }, + ], + }), + ).toThrow('Duplicate replica migration from version 1.'); + }); + + it('rejects non-consecutive migrations and empty SQL statements', () => { + expect(() => + defineOfflineReplicaSchema({ + version: 4, + entities: [sampleSchema], + migrations: [ + { fromVersion: 1, statements: ['SELECT 1'], migrateWebRow: (row) => row }, + { fromVersion: 3, statements: ['SELECT 2'], migrateWebRow: (row) => row }, + ], + }), + ).toThrow( + 'Replica migrations must advance consecutive schema versions; expected fromVersion 2, received 3.', + ); + + expect(() => + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema], + migrations: [{ fromVersion: 1, statements: ['SELECT 1', ' '], migrateWebRow: (row) => row }], + }), + ).toThrow('Replica migration from version 1 statement 2 must not be empty.'); + + expect(() => + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema], + migrations: [{ fromVersion: 1, statements: [], migrateWebRow: (row) => row }], + }), + ).toThrow('Replica migration from version 1 requires at least one SQL statement.'); + + expect(() => + defineOfflineReplicaSchema({ + version: 2, + entities: [sampleSchema], + migrations: [{ fromVersion: 1, statements: ['SELECT 1'] } as never], + }), + ).toThrow('Replica migration from version 1 requires migrateWebRow.'); + }); +}); + +describe('offline-replica-schema types', () => { + type CompileSelect = { + id: number; + title: string; + notes: string | null; + }; + + it('type: every select key must appear exactly once', () => { + defineReplicaEntity()({ + table: 'compile_items', + sourceKey: 'compile_items', + scope: 'group', + fields: { + id: serverId(), + title: text(), + notes: nullable(text()), + }, + }); + + defineReplicaEntity()({ + table: 'compile_items', + sourceKey: 'compile_items', + scope: 'group', + // @ts-expect-error — `notes` is missing from the field map. + fields: { + id: serverId(), + title: text(), + }, + }); + + defineReplicaEntity()({ + table: 'compile_items', + sourceKey: 'compile_items', + scope: 'group', + // @ts-expect-error — `extra` is not part of the select shape. + fields: { + id: serverId(), + title: text(), + notes: nullable(text()), + extra: text(), + }, + }); + }); + + it('type: column nullability must match the select property', () => { + defineReplicaEntity()({ + table: 'compile_items', + sourceKey: 'compile_items', + scope: 'group', + fields: { + id: serverId(), + title: text(), + // @ts-expect-error — nullable select properties require nullable(...). + notes: text(), + }, + }); + + defineReplicaEntity()({ + table: 'compile_items', + sourceKey: 'compile_items', + scope: 'group', + fields: { + id: serverId(), + // @ts-expect-error — non-null select properties reject nullable(...). + title: nullable(text()), + notes: nullable(text()), + }, + }); + + defineReplicaEntity()({ + table: 'compile_items', + sourceKey: 'compile_items', + scope: 'group', + fields: { + id: serverId(), + title: text(), + notes: nullable(text()), + }, + }); + }); + + it('type: serverId() applies only to numeric source properties', () => { + type StringIdSelect = { id: string; title: string }; + defineReplicaEntity()({ + table: 'items', + sourceKey: 'items', + scope: 'user', + fields: { + // @ts-expect-error — serverId() cannot map a string property. + id: serverId(), + title: text(), + }, + }); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-replica-schema.ts b/projects/kit/offline/src/lib/offline-replica-schema.ts new file mode 100644 index 0000000..ce4c396 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-replica-schema.ts @@ -0,0 +1,846 @@ +/** SQLite storage affinity for a replicated entity column. */ +export type OfflineReplicaSqliteAffinity = 'TEXT' | 'INTEGER' | 'REAL'; + +/** + * Logical storage kind for a replicated column, independent of SQLite affinity. + * + * For example, {@link booleanColumn} and {@link datetime} both use INTEGER/TEXT affinity + * but remain distinguishable at runtime through this marker. + */ +export type OfflineReplicaStorageKind = + | 'text' + | 'integer' + | 'real' + | 'booleanColumn' + | 'json' + | 'datetime'; + +/** Scope partition persisted on every replica table row. */ +export type OfflineReplicaEntityScope = 'user' | 'group'; + +/** Field projection policy for a source-model property. */ +export type OfflineReplicaFieldPolicy = 'column' | 'serverId' | 'ignored'; + +/** Runtime descriptor for one mapped source property. */ +export interface OfflineReplicaFieldDescriptor { + /** Source-model property name from the select shape. */ + readonly sourceKey: string; + /** How the property is projected into SQLite. */ + readonly policy: OfflineReplicaFieldPolicy; + /** Physical SQLite column name, or null when {@link policy} is `ignored`. */ + readonly sqliteColumnName: string | null; + /** SQLite affinity for column policies, otherwise null. */ + readonly affinity: OfflineReplicaSqliteAffinity | null; + /** Logical storage kind for column policies, otherwise null. */ + readonly storageKind: OfflineReplicaStorageKind | null; + /** Whether the SQLite column accepts NULL. */ + readonly nullable: boolean; + /** Human-readable reason when {@link policy} is `ignored`. */ + readonly ignoredReason: string | null; +} + +/** Materialized replica entity schema used to create and fingerprint SQLite tables. */ +export interface OfflineReplicaEntitySchema> { + /** Validated SQLite table name. */ + readonly tableName: string; + /** Stable source key used by sync and repository layers. */ + readonly sourceKey: string; + /** Whether rows are partitioned by user only or user plus group. */ + readonly scope: OfflineReplicaEntityScope; + /** Ordered field descriptors derived from the DSL definition. */ + readonly fields: readonly OfflineReplicaFieldDescriptor[]; + /** Deterministic DDL statements for the replica table and indexes. */ + readonly createTableSql: readonly string[]; + /** Canonical string input for schema fingerprinting; hashing is left to callers. */ + readonly schemaFingerprintInput: string; + /** Phantom marker tying the schema to its select shape. */ + readonly __select?: TSelect; +} + +declare const replicaNullableBrand: unique symbol; + +/** Phantom nullability brand carried by column builders. */ +type ReplicaNullableBrand = { readonly [replicaNullableBrand]: 'nullable' | 'required' }; + +/** Column builder definition with compile-time nullability tracking. */ +export interface OfflineReplicaColumnDef< + TValue = unknown, + TNullable extends ReplicaNullableBrand = { readonly [replicaNullableBrand]: 'required' }, +> { + readonly kind: 'column'; + readonly affinity: OfflineReplicaSqliteAffinity; + readonly storageKind: OfflineReplicaStorageKind; + readonly columnName?: string; + readonly nullable?: boolean; + readonly __types?: { readonly value: TValue; readonly nullable: TNullable[typeof replicaNullableBrand] }; +} + +/** Builder marking a source property as the server-assigned identifier. */ +export interface OfflineReplicaServerIdDef { + readonly kind: 'serverId'; + readonly __types?: { readonly value: number }; +} + +/** Builder excluding a source property from SQLite projection. */ +export interface OfflineReplicaIgnoredDef { + readonly kind: 'ignored'; + readonly reason: string; +} + +type OfflineReplicaFieldDef = OfflineReplicaColumnDef | OfflineReplicaServerIdDef | OfflineReplicaIgnoredDef; + +type StripNullish = Exclude; + +type IsNullableSelectValue = null extends T ? true : undefined extends T ? true : false; + +type OfflineReplicaFieldDefForKey, K extends keyof TSelect> = + | (StripNullish extends number ? OfflineReplicaServerIdDef : never) + | OfflineReplicaIgnoredDef + | (IsNullableSelectValue extends true + ? OfflineReplicaColumnDef, { readonly [replicaNullableBrand]: 'nullable' }> + : OfflineReplicaColumnDef); + +type ExactSelectKeys, TFields> = + Exclude extends never + ? Exclude extends never + ? TFields + : never + : never; + +/** Configuration accepted by {@link defineReplicaEntity}. */ +export interface OfflineReplicaEntityDefinition< + TSelect extends Record, + TFields extends { readonly [K in keyof TSelect]: OfflineReplicaFieldDefForKey }, +> { + readonly table: string; + readonly sourceKey: string; + readonly scope: OfflineReplicaEntityScope; + readonly fields: ExactSelectKeys & TFields; +} + +const IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/; +const RESERVED_COLUMN_NAMES = new Set([ + 'local_id', + '_offline_user_id', + '_offline_group_id', + 'server_id', + '_offline_confirmed_json', + '_offline_server_revision_json', + '_offline_sync_state', + '_offline_fetched_at', +]); + +/** + * Begins a type-safe replica entity schema definition for the given select shape. + * + * Every key in `TSelect` must appear exactly once in `fields`, and column nullability + * must match the select property nullability. + */ +export function defineReplicaEntity>() { + return function defineReplicaEntityConfig< + const TFields extends { readonly [K in keyof TSelect]: OfflineReplicaFieldDefForKey }, + >(definition: OfflineReplicaEntityDefinition): OfflineReplicaEntitySchema { + return buildOfflineReplicaEntitySchema({ + table: definition.table, + sourceKey: definition.sourceKey, + scope: definition.scope, + fields: definition.fields as Record, + }); + }; +} + +function requiredColumn( + affinity: OfflineReplicaSqliteAffinity, + storageKind: OfflineReplicaStorageKind, +): OfflineReplicaColumnDef { + return { kind: 'column', affinity, storageKind, nullable: false }; +} + +/** Maps a non-null string source property to a TEXT column. */ +export function text(): OfflineReplicaColumnDef { + return requiredColumn('TEXT', 'text'); +} + +/** Maps a non-null integer source property to an INTEGER column. */ +export function integer(): OfflineReplicaColumnDef { + return requiredColumn('INTEGER', 'integer'); +} + +/** Maps a non-null floating-point source property to a REAL column. */ +export function real(): OfflineReplicaColumnDef { + return requiredColumn('REAL', 'real'); +} + +/** Maps a non-null boolean source property to an INTEGER column. */ +export function booleanColumn(): OfflineReplicaColumnDef { + return requiredColumn('INTEGER', 'booleanColumn'); +} + +/** Maps a non-null JSON-serializable source property to a TEXT column. */ +export function json(): OfflineReplicaColumnDef { + return requiredColumn('TEXT', 'json'); +} + +/** Maps a non-null datetime source property to an INTEGER epoch-millis column. */ +export function datetime(): OfflineReplicaColumnDef { + return requiredColumn('TEXT', 'datetime'); +} + +/** Marks a column builder as nullable for nullable select properties. */ +export function nullable( + column: OfflineReplicaColumnDef, +): OfflineReplicaColumnDef { + return { + kind: 'column', + affinity: column.affinity, + storageKind: column.storageKind, + nullable: true, + ...(column.columnName !== undefined ? { columnName: column.columnName } : {}), + }; +} + +/** Maps a source property to the shared nullable `server_id` column. */ +export function serverId(): OfflineReplicaServerIdDef { + return { kind: 'serverId' }; +} + +/** Excludes a source property from SQLite while retaining it in the select shape. */ +export function ignored(reason: string): OfflineReplicaIgnoredDef { + return { kind: 'ignored', reason }; +} + +function buildOfflineReplicaEntitySchema>(definition: { + readonly table: string; + readonly sourceKey: string; + readonly scope: OfflineReplicaEntityScope; + readonly fields: Record; +}): OfflineReplicaEntitySchema { + validateIdentifier(definition.table, 'table'); + validateIdentifier(definition.sourceKey, 'source key'); + + const sourceKeys = Object.keys(definition.fields).sort(); + const serverIdCount = sourceKeys.filter((sourceKey) => definition.fields[sourceKey]?.kind === 'serverId').length; + if (serverIdCount > 1) throw new Error('Replica entity cannot define more than one serverId field.'); + const hasServerId = serverIdCount === 1; + const fields: OfflineReplicaFieldDescriptor[] = sourceKeys.map((sourceKey) => { + const fieldDef = definition.fields[sourceKey as keyof TSelect] as OfflineReplicaFieldDef; + return materializeFieldDescriptor(sourceKey, fieldDef); + }); + + const createTableSql = buildCreateTableSql(definition.table, definition.scope, fields, hasServerId); + const schemaFingerprintInput = buildSchemaFingerprintInput( + definition.table, + definition.sourceKey, + definition.scope, + fields, + hasServerId, + ); + + return { + tableName: definition.table, + sourceKey: definition.sourceKey, + scope: definition.scope, + fields, + createTableSql, + schemaFingerprintInput, + }; +} + +function materializeFieldDescriptor( + sourceKey: string, + fieldDef: OfflineReplicaFieldDef, +): OfflineReplicaFieldDescriptor { + if (fieldDef.kind === 'ignored') { + if (!fieldDef.reason.trim()) throw new Error(`Replica ignored field "${sourceKey}" requires a reason.`); + return { + sourceKey, + policy: 'ignored', + sqliteColumnName: null, + affinity: null, + storageKind: null, + nullable: false, + ignoredReason: fieldDef.reason, + }; + } + + if (fieldDef.kind === 'serverId') { + return { + sourceKey, + policy: 'serverId', + sqliteColumnName: 'server_id', + affinity: 'INTEGER', + storageKind: null, + nullable: true, + ignoredReason: null, + }; + } + + const sqliteColumnName = fieldDef.columnName ?? toSnakeCase(sourceKey); + validateIdentifier(sqliteColumnName, 'column'); + if (RESERVED_COLUMN_NAMES.has(sqliteColumnName)) { + throw new Error(`Replica column "${sqliteColumnName}" is reserved.`); + } + + return { + sourceKey, + policy: 'column', + sqliteColumnName, + affinity: fieldDef.affinity, + storageKind: fieldDef.storageKind, + nullable: fieldDef.nullable === true, + ignoredReason: null, + }; +} + +function buildCreateTableSql( + tableName: string, + scope: OfflineReplicaEntityScope, + fields: readonly OfflineReplicaFieldDescriptor[], + hasServerId: boolean, +): readonly string[] { + const columnLines = ['local_id TEXT NOT NULL', '_offline_user_id INTEGER NOT NULL']; + if (scope === 'group') { + columnLines.push('_offline_group_id INTEGER NOT NULL'); + } + if (hasServerId) { + columnLines.push('server_id INTEGER'); + } + columnLines.push( + '_offline_confirmed_json TEXT', + '_offline_server_revision_json TEXT', + '_offline_sync_state TEXT NOT NULL', + '_offline_fetched_at INTEGER NOT NULL', + ); + + for (const field of fields) { + if (field.policy !== 'column') { + continue; + } + const nullability = field.nullable ? '' : ' NOT NULL'; + columnLines.push(`${field.sqliteColumnName} ${field.affinity}${nullability}`); + } + + const statements = [`CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnLines.join(',\n ')},\n PRIMARY KEY (local_id)\n)`]; + + if (hasServerId) { + const indexColumns = + scope === 'group' ? '_offline_user_id, _offline_group_id, server_id' : '_offline_user_id, server_id'; + statements.push( + `CREATE UNIQUE INDEX IF NOT EXISTS uq_${tableName}_server_id ON ${tableName} (${indexColumns}) WHERE server_id IS NOT NULL`, + ); + } + + return statements; +} + +function buildSchemaFingerprintInput( + tableName: string, + sourceKey: string, + scope: OfflineReplicaEntityScope, + fields: readonly OfflineReplicaFieldDescriptor[], + hasServerId: boolean, +): string { + const fieldParts = fields.map((field) => { + if (field.policy === 'ignored') { + return `${field.sourceKey}:ignored:${field.ignoredReason ?? ''}`; + } + if (field.policy === 'serverId') { + return `${field.sourceKey}:serverId:server_id:INTEGER:nullable`; + } + return `${field.sourceKey}:column:${field.sqliteColumnName}:${field.affinity}:${field.storageKind}:${field.nullable ? 'nullable' : 'required'}`; + }); + + return [ + `table=${tableName}`, + `source=${sourceKey}`, + `scope=${scope}`, + `hasServerId=${hasServerId ? '1' : '0'}`, + `fields=${fieldParts.join(';')}`, + ].join('|'); +} + +function validateIdentifier(value: string, kind: 'table' | 'column' | 'source key'): void { + if (!IDENTIFIER_PATTERN.test(value)) { + throw new Error(`Replica ${kind} "${value}" must match ${IDENTIFIER_PATTERN.source}.`); + } +} + +function toSnakeCase(value: string): string { + return value + .replace(/([A-Z])/g, '_$1') + .replace(/^_/, '') + .toLowerCase(); +} + +/** + * Validates and encodes a source-model row into SQLite-bound column values. + * + * Only {@link OfflineReplicaFieldPolicy} `column` fields are emitted, keyed by + * {@link OfflineReplicaFieldDescriptor.sqliteColumnName} in descriptor order. + * Source properties mapped as `serverId` or `ignored` may be present but are not encoded. + */ +export function encodeOfflineReplicaValues( + schema: OfflineReplicaEntitySchema>, + values: unknown, +): Readonly> { + if (!isPlainObject(values)) { + throw new Error('Replica row values must be a plain object.'); + } + + const sourceValues = values; + const allowedSourceKeys = new Set(schema.fields.map((field) => field.sourceKey)); + + for (const sourceKey of Object.keys(sourceValues)) { + if (!allowedSourceKeys.has(sourceKey)) { + throw new Error(`Replica row contains unknown source key "${sourceKey}".`); + } + } + + const encoded: Record = {}; + + for (const field of schema.fields) { + if (field.policy !== 'column') { + continue; + } + + const sqliteColumnName = field.sqliteColumnName; + if (sqliteColumnName === null) { + continue; + } + + if (!Object.prototype.hasOwnProperty.call(sourceValues, field.sourceKey)) { + throw new Error(`Replica row is missing required source key "${field.sourceKey}".`); + } + + const rawValue = sourceValues[field.sourceKey]; + if (rawValue === null) { + if (!field.nullable) { + throw new Error(`Replica row source key "${field.sourceKey}" must not be null.`); + } + encoded[sqliteColumnName] = null; + continue; + } + + if (rawValue === undefined) { + throw new Error(`Replica row source key "${field.sourceKey}" must not be undefined.`); + } + + encoded[sqliteColumnName] = encodeReplicaColumnValue(field, rawValue); + } + + return encoded; +} + +/** + * Validates and decodes SQLite column values into a source-model row. + * + * Only {@link OfflineReplicaFieldPolicy} `column` fields are emitted, keyed by + * {@link OfflineReplicaFieldDescriptor.sourceKey} in descriptor order. + * Physical columns such as `server_id`, sync metadata, and scope keys may be + * present in `row` but are not decoded. + */ +export function decodeOfflineReplicaValues( + schema: OfflineReplicaEntitySchema>, + row: unknown, +): Readonly> { + if (!isPlainObject(row)) { + throw new Error('Replica SQLite row must be a plain object.'); + } + + const decoded: Record = {}; + + for (const field of schema.fields) { + if (field.policy !== 'column') { + continue; + } + + const sqliteColumnName = field.sqliteColumnName; + if (sqliteColumnName === null) { + continue; + } + + if (!Object.prototype.hasOwnProperty.call(row, sqliteColumnName)) { + throw new Error(`Replica SQLite row is missing required column "${sqliteColumnName}".`); + } + + const rawValue = row[sqliteColumnName]; + if (rawValue === null) { + if (!field.nullable) { + throw new Error(`Replica SQLite column "${sqliteColumnName}" must not be null.`); + } + decoded[field.sourceKey] = null; + continue; + } + + if (rawValue === undefined) { + throw new Error(`Replica SQLite column "${sqliteColumnName}" must not be undefined.`); + } + + decoded[field.sourceKey] = decodeReplicaColumnValue(field, rawValue); + } + + return decoded; +} + +/** + * Validates a source-model row and returns the canonical domain projection persisted by every repository. + * + * Fields mapped as `serverId` or `ignored` are intentionally omitted. This keeps web and native row shapes + * identical; the server identifier remains available through the replica row's dedicated `serverId` field. + */ +export function projectOfflineReplicaValues( + schema: OfflineReplicaEntitySchema>, + values: unknown, +): Readonly> { + return decodeOfflineReplicaValues(schema, encodeOfflineReplicaValues(schema, values)); +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object') { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function encodeReplicaColumnValue( + field: OfflineReplicaFieldDescriptor, + value: unknown, +): string | number { + switch (field.storageKind) { + case 'text': + return encodeReplicaTextValue(field.sourceKey, value); + case 'integer': + return encodeReplicaIntegerValue(field.sourceKey, value); + case 'real': + return encodeReplicaRealValue(field.sourceKey, value); + case 'booleanColumn': + return encodeReplicaBooleanValue(field.sourceKey, value); + case 'json': + return encodeReplicaJsonValue(field.sourceKey, value); + case 'datetime': + return encodeReplicaDatetimeValue(field.sourceKey, value); + default: + throw new Error(`Replica row source key "${field.sourceKey}" has no storage kind.`); + } +} + +function decodeReplicaColumnValue(field: OfflineReplicaFieldDescriptor, value: unknown): unknown { + switch (field.storageKind) { + case 'text': + return decodeReplicaTextValue(field.sourceKey, value); + case 'integer': + return decodeReplicaIntegerValue(field.sourceKey, value); + case 'real': + return decodeReplicaRealValue(field.sourceKey, value); + case 'booleanColumn': + return decodeReplicaBooleanValue(field.sourceKey, value); + case 'json': + return decodeReplicaJsonValue(field.sourceKey, value); + case 'datetime': + return decodeReplicaDatetimeValue(field.sourceKey, value); + default: + throw new Error(`Replica row source key "${field.sourceKey}" has no storage kind.`); + } +} + +function encodeReplicaTextValue(sourceKey: string, value: unknown): string { + if (typeof value !== 'string') { + throw new Error(`Replica row source key "${sourceKey}" must be a string.`); + } + return value; +} + +function encodeReplicaIntegerValue(sourceKey: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isSafeInteger(value)) { + throw new Error(`Replica row source key "${sourceKey}" must be a finite safe integer.`); + } + return value; +} + +function encodeReplicaRealValue(sourceKey: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Replica row source key "${sourceKey}" must be a finite number.`); + } + return value; +} + +function encodeReplicaBooleanValue(sourceKey: string, value: unknown): 0 | 1 { + if (typeof value !== 'boolean') { + throw new Error(`Replica row source key "${sourceKey}" must be a boolean.`); + } + return value ? 1 : 0; +} + +function encodeReplicaJsonValue(sourceKey: string, value: unknown): string { + assertJsonSerializable(sourceKey, value); + return JSON.stringify(value); +} + +function encodeReplicaDatetimeValue(sourceKey: string, value: unknown): string { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) { + throw new Error(`Replica row source key "${sourceKey}" must be a valid Date.`); + } + return value.toISOString(); + } + + if (typeof value === 'string') { + return value; + } + + throw new Error(`Replica row source key "${sourceKey}" must be a Date or string.`); +} + +function decodeReplicaTextValue(sourceKey: string, value: unknown): string { + if (typeof value !== 'string') { + throw new Error(`Replica row source key "${sourceKey}" must be a string.`); + } + return value; +} + +function decodeReplicaIntegerValue(sourceKey: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isSafeInteger(value)) { + throw new Error(`Replica row source key "${sourceKey}" must be a finite safe integer.`); + } + return value; +} + +function decodeReplicaRealValue(sourceKey: string, value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new Error(`Replica row source key "${sourceKey}" must be a finite number.`); + } + return value; +} + +function decodeReplicaBooleanValue(sourceKey: string, value: unknown): boolean { + if (value !== 0 && value !== 1) { + throw new Error(`Replica row source key "${sourceKey}" must be boolean integer 0 or 1.`); + } + return value === 1; +} + +function decodeReplicaJsonValue(sourceKey: string, value: unknown): unknown { + if (typeof value !== 'string') { + throw new Error(`Replica row source key "${sourceKey}" must be a JSON string.`); + } + + try { + return JSON.parse(value); + } catch { + throw new Error(`Replica row source key "${sourceKey}" must be valid JSON.`); + } +} + +function decodeReplicaDatetimeValue(sourceKey: string, value: unknown): string { + if (typeof value !== 'string') { + throw new Error(`Replica row source key "${sourceKey}" must be a string.`); + } + return value; +} + +function assertJsonSerializable(sourceKey: string, value: unknown, seen = new WeakSet()): void { + if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') { + return; + } + + if (typeof value === 'undefined') { + throw new Error(`Replica row source key "${sourceKey}" must be JSON-serializable without undefined.`); + } + + if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') { + throw new Error(`Replica row source key "${sourceKey}" must be JSON-serializable without functions.`); + } + + if (Array.isArray(value)) { + for (const item of value) { + assertJsonSerializable(sourceKey, item, seen); + } + return; + } + + if (typeof value === 'object') { + if (seen.has(value)) { + throw new Error(`Replica row source key "${sourceKey}" must be JSON-serializable without cycles.`); + } + seen.add(value); + for (const key of Object.keys(value)) { + assertJsonSerializable(sourceKey, (value as Record)[key], seen); + } + } +} + +/** Domain payload transformed during web replica schema migration. Identity and sync metadata stay outside this shape. */ +export interface OfflineReplicaWebMigrationRow { + readonly sourceKey: string; + readonly values: Readonly>; + readonly confirmedValues: Readonly> | null; +} + +/** Result of {@link OfflineReplicaMigration.migrateWebRow}; `null` deletes the stored row. */ +export type OfflineReplicaWebMigrationResult = OfflineReplicaWebMigrationRow | null; + +/** Transforms one replica row's domain projection during a web schema migration step. */ +export type OfflineReplicaWebMigrationCallback = ( + row: OfflineReplicaWebMigrationRow, +) => OfflineReplicaWebMigrationResult | Promise; + +/** One explicit schema-version step applied during offline SQLite and web replica migrations. */ +export interface OfflineReplicaMigration { + /** Source schema version; the migration advances to {@link fromVersion} + 1. */ + readonly fromVersion: number; + /** Non-empty DDL/DML statements executed in order on native SQLite for this step. Web ignores these. */ + readonly statements: readonly string[]; + /** + * Transforms one row's domain projection for web storage during this step. + * The callback receives only {@link OfflineReplicaWebMigrationRow}; it must not read or mutate + * `localId`, `serverId`, scope, revision, or `syncState`. Return `null` to delete the row. + */ + readonly migrateWebRow: OfflineReplicaWebMigrationCallback; +} + +/** Versioned product replica schema bundle with entities and migration path. */ +export interface OfflineReplicaSchemaBundle { + /** Current bundle schema version. */ + readonly version: number; + /** Entity schemas sorted by {@link OfflineReplicaEntitySchema.sourceKey}. */ + readonly entities: readonly OfflineReplicaEntitySchema>[]; + /** Migrations sorted by {@link OfflineReplicaMigration.fromVersion}. */ + readonly migrations: readonly OfflineReplicaMigration[]; + /** Canonical fingerprint input spanning version and every entity fingerprint. */ + readonly schemaFingerprintInput: string; +} + +/** Input accepted by {@link defineOfflineReplicaSchema}. */ +export interface OfflineReplicaSchemaDefinition { + /** Current bundle schema version. */ + readonly version: number; + /** Entity schemas for the bundle version. */ + readonly entities: readonly OfflineReplicaEntitySchema>[]; + /** Ordered one-step migrations from older bundle versions. */ + readonly migrations: readonly OfflineReplicaMigration[]; +} + +/** + * Validates and materializes a versioned offline replica schema bundle. + * + * Entity `tableName` and `sourceKey` values must each be unique within the bundle. + * Migrations must advance one version at a time without duplicates or gaps. + */ +export function defineOfflineReplicaSchema( + definition: OfflineReplicaSchemaDefinition, +): OfflineReplicaSchemaBundle { + assertPositiveInteger(definition.version, 'schema version'); + + const entities = [...definition.entities].sort((left, right) => + left.sourceKey.localeCompare(right.sourceKey), + ); + const tableNames = new Set(); + const sourceKeys = new Set(); + for (const entity of entities) { + if (tableNames.has(entity.tableName)) { + throw new Error(`Duplicate replica entity table "${entity.tableName}".`); + } + tableNames.add(entity.tableName); + + if (sourceKeys.has(entity.sourceKey)) { + throw new Error(`Duplicate replica entity source key "${entity.sourceKey}".`); + } + sourceKeys.add(entity.sourceKey); + } + + const migrations = normalizeOfflineReplicaMigrations(definition.migrations, definition.version); + const schemaFingerprintInput = buildBundleSchemaFingerprintInput(definition.version, entities, migrations); + + return { + version: definition.version, + entities, + migrations, + schemaFingerprintInput, + }; +} + +/** + * Computes a lowercase SHA-256 hex digest of {@link OfflineReplicaSchemaBundle.schemaFingerprintInput}. + */ +export async function sha256OfflineReplicaSchema(bundle: OfflineReplicaSchemaBundle): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(bundle.schemaFingerprintInput)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function normalizeOfflineReplicaMigrations( + migrations: readonly OfflineReplicaMigration[], + bundleVersion: number, +): readonly OfflineReplicaMigration[] { + const sorted = [...migrations].sort((left, right) => left.fromVersion - right.fromVersion); + const seenFromVersions = new Set(); + + for (const migration of sorted) { + assertPositiveInteger(migration.fromVersion, 'migration fromVersion'); + + const targetVersion = migration.fromVersion + 1; + if (targetVersion > bundleVersion) { + throw new Error( + `Replica migration from version ${migration.fromVersion} targets version ${targetVersion}, which exceeds bundle version ${bundleVersion}.`, + ); + } + + if (seenFromVersions.has(migration.fromVersion)) { + throw new Error(`Duplicate replica migration from version ${migration.fromVersion}.`); + } + seenFromVersions.add(migration.fromVersion); + + if (typeof migration.migrateWebRow !== 'function') { + throw new Error(`Replica migration from version ${migration.fromVersion} requires migrateWebRow.`); + } + + if (migration.statements.length === 0) { + throw new Error(`Replica migration from version ${migration.fromVersion} requires at least one SQL statement.`); + } + + for (const [index, statement] of migration.statements.entries()) { + if (!statement.trim()) { + throw new Error( + `Replica migration from version ${migration.fromVersion} statement ${index + 1} must not be empty.`, + ); + } + } + } + + for (let index = 1; index < sorted.length; index++) { + const previous = sorted[index - 1]!; + const current = sorted[index]!; + if (current.fromVersion !== previous.fromVersion + 1) { + throw new Error( + `Replica migrations must advance consecutive schema versions; expected fromVersion ${previous.fromVersion + 1}, received ${current.fromVersion}.`, + ); + } + } + + if (sorted.length > 0 && sorted[0]!.fromVersion !== 1) { + throw new Error( + `Replica migrations must advance consecutive schema versions; expected fromVersion 1, received ${sorted[0]!.fromVersion}.`, + ); + } + + return sorted; +} + +function buildBundleSchemaFingerprintInput( + version: number, + entities: readonly OfflineReplicaEntitySchema>[], + migrations: readonly OfflineReplicaMigration[], +): string { + const entityFingerprints = entities.map((entity) => entity.schemaFingerprintInput).join(';'); + const migrationFingerprints = migrations + .map((migration) => `from=${migration.fromVersion}|statements=${migration.statements.join('\n')}`) + .join(';'); + return `version=${version}|entities=${entityFingerprints}|migrations=${migrationFingerprints}`; +} + +function assertPositiveInteger(value: number, label: string): void { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`Replica ${label} must be a positive integer.`); + } +} diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index bd12c01..1f01c12 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -1,15 +1,121 @@ import { TestBed } from '@angular/core/testing'; import { KitStorageService } from '@rdlabo/ionic-angular-kit'; import { describe, beforeEach, expect, it, vi } from 'vitest'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { isOfflineFallbackError } from './offline-network.service'; +import { + defineOfflineReplicaSchema, + defineReplicaEntity, + serverId, + sha256OfflineReplicaSchema, + text, + type OfflineReplicaSchemaBundle, +} from './offline-replica-schema'; import { IonicOfflineRepository, OFFLINE_REPOSITORY, + OFFLINE_SCHEMA_VERSION, selectOfflineRepository, type OfflineCommand, + type OfflineReplicaRow, type OfflineRepository, } from './offline-repository'; +type TestItemSelect = { id: number; title: string }; +type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; + +const testItemEntity = defineReplicaEntity()({ + table: 'test_items', + sourceKey: 'test_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, +}); + +const testItemWithSubtitleEntity = defineReplicaEntity()({ + table: 'test_items', + sourceKey: 'test_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + subtitle: text(), + }, +}); + +const testGroupItemEntity = defineReplicaEntity<{ id: number; name: string }>()({ + table: 'test_group_items', + sourceKey: 'test_group_items', + scope: 'group', + fields: { + id: serverId(), + name: text(), + }, +}); + +const replicaSchemaV1 = defineOfflineReplicaSchema({ + version: 1, + entities: [testItemEntity, testGroupItemEntity], + migrations: [], +}); + +const replicaSchemaV2 = defineOfflineReplicaSchema({ + version: 2, + entities: [testItemWithSubtitleEntity, testGroupItemEntity], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE test_items ADD COLUMN subtitle TEXT NOT NULL DEFAULT ""'], + migrateWebRow: (row) => ({ + sourceKey: row.sourceKey, + values: { ...row.values, subtitle: 'migrated' }, + confirmedValues: + row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: 'migrated-confirmed' }, + }), + }, + ], +}); + +const replicaSchemaV2DeleteRow = defineOfflineReplicaSchema({ + version: 2, + entities: [testItemWithSubtitleEntity, testGroupItemEntity], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE test_items ADD COLUMN subtitle TEXT NOT NULL DEFAULT ""'], + migrateWebRow: (row) => (row.values['title'] === 'drop-me' ? null : { + sourceKey: row.sourceKey, + values: { ...row.values, subtitle: 'kept' }, + confirmedValues: row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: 'kept' }, + }), + }, + ], +}); + +const replicaSchemaV2InvalidOutput = defineOfflineReplicaSchema({ + version: 2, + entities: [testItemWithSubtitleEntity, testGroupItemEntity], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE test_items ADD COLUMN subtitle TEXT NOT NULL DEFAULT ""'], + migrateWebRow: (row) => ({ + sourceKey: row.sourceKey, + values: row.values, + confirmedValues: row.confirmedValues, + }), + }, + ], +}); + +const replicaSchemaV1HashDrift = defineOfflineReplicaSchema({ + version: 1, + entities: [testItemWithSubtitleEntity, testGroupItemEntity], + migrations: [], +}); + class MemoryStorage { readonly values = new Map(); get(key: string): Promise { @@ -32,44 +138,285 @@ describe('IonicOfflineRepository', () => { let repository: OfflineRepository; let storage: MemoryStorage; - beforeEach(() => { - storage = new MemoryStorage(); + function createRepository( + replicaSchema: OfflineReplicaSchemaBundle = replicaSchemaV1, + options: { preserveStorage?: boolean } = {}, + ): OfflineRepository { + TestBed.resetTestingModule(); + if (!options.preserveStorage) { + storage = new MemoryStorage(); + } TestBed.configureTestingModule({ providers: [ IonicOfflineRepository, { provide: KitStorageService, useValue: storage }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema } }, { provide: OFFLINE_REPOSITORY, useExisting: IonicOfflineRepository }, ], }); - repository = TestBed.inject(OFFLINE_REPOSITORY); + return TestBed.inject(OFFLINE_REPOSITORY); + } + + async function createSeededRepository( + replicaSchema: OfflineReplicaSchemaBundle, + seed: () => Promise, + ): Promise { + storage = new MemoryStorage(); + await seed(); + return createRepository(replicaSchema, { preserveStorage: true }); + } + + beforeEach(() => { + repository = createRepository(); }); - it('cacheを user/group scope ごとに分離する', async () => { - await repository.putQuery({ + async function seedReplicaMetadata( + replicaSchema: OfflineReplicaSchemaBundle, + rows: Record = {}, + commands: Record = {}, + ): Promise<{ version: number; schemaHash: string }> { + const schemaHash = await sha256OfflineReplicaSchema(replicaSchema); + storage.values.set('offline:metadata', { + schemaVersion: OFFLINE_SCHEMA_VERSION, + lastUserId: null, + replicaSchemaVersion: replicaSchema.version, + replicaSchemaHash: schemaHash, + }); + storage.values.set('offline:replica:rows', structuredClone(rows)); + storage.values.set('offline:outbox:commands', structuredClone(commands)); + return { version: replicaSchema.version, schemaHash }; + } + + describe('web replica schema migration', () => { + const baseRow: OfflineReplicaRow = { userId: 1, groupId: 10, - queryKey: 'snapshot', - value: { records: [1] }, - orderedIds: [], - cursor: null, - etag: null, - fetchedAt: 1, - isComplete: true, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: 42, + values: { id: 42, title: 'Local item' }, + confirmedValues: { id: 42, title: 'Confirmed item' }, + serverRevision: 7, + fetchedAt: 99, + syncState: 'confirmed', + }; + + it('add-field transformを成功させidentity/sync metadataとoutboxを保持する', async () => { + const command: OfflineCommand = { + userId: 1, + groupId: 10, + commandId: 'update-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.update', + payload: { title: 'Local item' }, + optimisticValue: { id: 42, title: 'Local item' }, + payloadHash: 'hash', + baseRevision: 7, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + repository = await createSeededRepository(replicaSchemaV2, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }, { 'update-1': command }); + }); + await repository.initialize(); + + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + localId: '019d-aaaa', + serverId: 42, + serverRevision: 7, + fetchedAt: 99, + syncState: 'confirmed', + values: { title: 'Local item', subtitle: 'migrated' }, + confirmedValues: { title: 'Confirmed item', subtitle: 'migrated-confirmed' }, + }); + expect(await repository.getCommands({ userId: 1, groupId: 10 })).toHaveLength(1); + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); + expect(storage.values.get('offline:metadata')).toMatchObject({ + replicaSchemaVersion: 2, + replicaSchemaHash: await sha256OfflineReplicaSchema(replicaSchemaV2), + }); + }); + + it('delete transformで行だけ削除しoutboxは保持する', async () => { + const keepRow: OfflineReplicaRow = { + ...baseRow, + localId: '019d-bbbb', + values: { id: 43, title: 'Keep me' }, + confirmedValues: null, + }; + const dropRow: OfflineReplicaRow = { + ...baseRow, + localId: '019d-cccc', + values: { id: 44, title: 'drop-me' }, + confirmedValues: null, + }; + const command: OfflineCommand = { + userId: 1, + groupId: 10, + commandId: 'delete-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-cccc', + operation: 'test_items.delete', + payload: {}, + optimisticValue: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + repository = await createSeededRepository(replicaSchemaV2DeleteRow, async () => { + await seedReplicaMetadata( + replicaSchemaV1, + { + '1:10:test_items:019d-bbbb': keepRow, + '1:10:test_items:019d-cccc': dropRow, + }, + { 'delete-1': command }, + ); + }); + await repository.initialize(); + + expect(await repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-cccc')).toBeNull(); + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-bbbb')).resolves.toMatchObject({ + values: { title: 'Keep me', subtitle: 'kept' }, + }); + expect(await repository.getCommands({ userId: 1, groupId: 10 })).toHaveLength(1); + expect(storage.values.get('offline:metadata')).toMatchObject({ replicaSchemaVersion: 2 }); + }); + + it('invalid outputはmetadataを進めずoutboxを失わない', async () => { + const command: OfflineCommand = { + userId: 1, + groupId: 10, + commandId: 'update-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.update', + payload: {}, + optimisticValue: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + repository = await createSeededRepository(replicaSchemaV2InvalidOutput, async () => { + await seedReplicaMetadata( + replicaSchemaV1, + { '1:10:test_items:019d-aaaa': baseRow }, + { 'update-1': command }, + ); + }); + + await expect(repository.initialize()).rejects.toThrow('Replica row is missing required source key "subtitle".'); + const schemaHash = await sha256OfflineReplicaSchema(replicaSchemaV1); + expect(storage.values.get('offline:metadata')).toMatchObject({ + replicaSchemaVersion: 1, + replicaSchemaHash: schemaHash, + }); + expect(Object.keys(storage.values.get('offline:outbox:commands') as object)).toEqual(['update-1']); + expect(storage.values.get('offline:replica:rows')).toMatchObject({ + '1:10:test_items:019d-aaaa': expect.objectContaining({ + values: { id: 42, title: 'Local item' }, + }), + }); + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); }); - expect(await repository.getQuery({ userId: 1, groupId: 10 }, 'snapshot')).toMatchObject({ - value: { records: [1] }, + + it('same-version hash driftはrow/outbox mutation前に拒否する', async () => { + repository = await createSeededRepository(replicaSchemaV1HashDrift, async () => { + await seedReplicaMetadata(replicaSchemaV1); + }); + + await expect(repository.initialize()).rejects.toThrow('Offline replica schema hash mismatch at version 1'); + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-new', + serverId: null, + values: { id: 0, title: 'New', subtitle: 'added' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }), + ).rejects.toThrow('Offline replica schema hash mismatch at version 1'); + }); + + it('中断されたmigration journalをrollbackして再実行する', async () => { + repository = await createSeededRepository(replicaSchemaV2, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }); + storage.values.set('offline:replica:schema-migration', { + originalRows: { '1:10:test_items:019d-aaaa': structuredClone(baseRow) }, + fromVersion: 1, + targetVersion: 2, + targetHash: await sha256OfflineReplicaSchema(replicaSchemaV2), + }); + storage.values.set('offline:replica:rows', { + '1:10:test_items:019d-aaaa': { + ...baseRow, + values: { id: 42, title: 'Corrupted partial migration' }, + }, + }); + }); + await repository.initialize(); + + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + values: { title: 'Local item', subtitle: 'migrated' }, + }); + expect(storage.values.get('offline:metadata')).toMatchObject({ + replicaSchemaVersion: 2, + replicaSchemaHash: await sha256OfflineReplicaSchema(replicaSchemaV2), + }); + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); + }); + + it('committed metadataと残存journalだけならjournalを掃除する', async () => { + const targetHash = await sha256OfflineReplicaSchema(replicaSchemaV2); + repository = await createSeededRepository(replicaSchemaV2, async () => { + storage.values.set('offline:metadata', { + schemaVersion: OFFLINE_SCHEMA_VERSION, + lastUserId: null, + replicaSchemaVersion: 2, + replicaSchemaHash: targetHash, + }); + storage.values.set('offline:replica:schema-migration', { + originalRows: { '1:10:test_items:019d-aaaa': baseRow }, + fromVersion: 1, + targetVersion: 2, + targetHash, + }); + }); + + await repository.initialize(); + + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); }); - expect(await repository.getQuery({ userId: 1, groupId: 11 }, 'snapshot')).toBeNull(); - expect(await repository.getQuery({ userId: 2, groupId: 10 }, 'snapshot')).toBeNull(); }); it('outboxを作成順で保持し、group削除時はそのscopeだけを消す', async () => { const base: Omit = { userId: 1, aggregateType: 'documents', - aggregateId: '1', + aggregateLocalId: '019d-aaaa', operation: 'documents.upsert', payload: {}, + optimisticValue: {}, payloadHash: 'hash', baseRevision: null, state: 'pending' as const, @@ -87,11 +434,69 @@ describe('IonicOfflineRepository', () => { expect(await repository.getCommands({ userId: 1, groupId: 11 })).toHaveLength(1); }); - it('local resource idを並行要求でも永続的に単調採番する', async () => { - await expect(Promise.all([repository.allocateLocalId(), repository.allocateLocalId()])).resolves.toEqual([ - 2_000_000_000_001, 2_000_000_000_002, - ]); - expect((storage.values.get('offline:metadata') as { nextLocalId: number }).nextLocalId).toBe(2_000_000_000_002); + it('session manifestをuserIdごとに保持しclearUserで削除する', async () => { + const manifest = { userId: 1, scopeIds: [10], authSubject: 'uid-a', updatedAt: 1 }; + await repository.putSessionManifest(1, manifest); + await expect(repository.getSessionManifest(1)).resolves.toEqual(manifest); + await expect(repository.getSessionManifest(2)).resolves.toBeNull(); + + await repository.clearUser(1); + await expect(repository.getSessionManifest(1)).resolves.toBeNull(); + }); + + it('clearGroupはsession manifestを削除しない', async () => { + const manifest = { userId: 1, scopeIds: [10], authSubject: 'uid-a', updatedAt: 1 }; + await repository.putSessionManifest(1, manifest); + await repository.clearGroup({ userId: 1, groupId: 10 }); + await expect(repository.getSessionManifest(1)).resolves.toEqual(manifest); + }); + + it('local UUIDを主キーとしてserver id未採番のreplica rowを保持する', async () => { + const scope = { userId: 1, groupId: 10 }; + const row = { + ...scope, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: null, + values: { id: 0, title: 'local' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + } as const; + const command: OfflineCommand = { + userId: 1, + groupId: 10, + commandId: 'create-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.create', + payload: { title: 'local' }, + optimisticValue: { id: 0, title: 'local' }, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + await repository.transactReplica({ putRows: [row], putCommands: [command] }); + await expect(repository.getReplicaRow(scope, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + localId: '019d-aaaa', + serverId: null, + }); + expect(await repository.getCommands(scope)).toHaveLength(1); + + await repository.transactReplica({ + putRows: [{ ...row, serverId: 38142, values: { id: 38142, title: 'local' }, syncState: 'confirmed' }], + removeCommandIds: ['create-1'], + }); + await expect(repository.getReplicaRow(scope, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + localId: '019d-aaaa', + serverId: 38142, + }); + expect(await repository.getCommands(scope)).toEqual([]); }); it('未知schemaではoffline領域だけ初期化し他のstorage keyを保持する', async () => { @@ -103,11 +508,308 @@ describe('IonicOfflineRepository', () => { expect(storage.values.get('firebaseToken')).toEqual({ token: 'keep' }); }); - it('cache fallbackは通信不能だけを対象にする', () => { + it('local replica fallbackは通信不能だけを対象にする', () => { expect(isOfflineFallbackError({ status: 0 })).toBe(true); expect(isOfflineFallbackError({ status: 403 })).toBe(false); expect(isOfflineFallbackError({ status: 500 })).toBe(false); }); + + it('replica rowとoutbox commandを単一transactionで同時に読める', async () => { + const scope = { userId: 1, groupId: 10 }; + const row = { + ...scope, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: null, + values: { id: 0, title: 'Local item' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + } as const; + const command: OfflineCommand = { + ...scope, + commandId: 'create-row-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-bbbb', + operation: 'test_items.create', + payload: { title: 'Local item' }, + optimisticValue: { id: 0, title: 'Local item' }, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + await repository.transactReplica({ putRows: [row], putCommands: [command] }); + await expect(repository.getReplicaRow(scope, 'test_items', '019d-bbbb')).resolves.toMatchObject({ + localId: '019d-bbbb', + values: { title: 'Local item' }, + }); + expect(await repository.getCommands(scope)).toHaveLength(1); + }); + + it('putRowsはvaluesとconfirmedValuesからserverId列を投影で除去する', async () => { + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-projected', + serverId: 42, + values: { id: 42, title: 'Optimistic' }, + confirmedValues: { id: 42, title: 'Confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }); + + await expect( + repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-projected'), + ).resolves.toMatchObject({ + serverId: 42, + values: { title: 'Optimistic' }, + confirmedValues: { title: 'Confirmed' }, + }); + }); + + it('replica row保存前にschema driftを拒否する', async () => { + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: null, + values: { id: 0 }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }), + ).rejects.toThrow('Replica row is missing required source key "title".'); + expect(await repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-bbbb')).toBeNull(); + }); + + it('schema reset時にreplica rowsも消す', async () => { + storage.values.set('offline:replica:rows', { + '1:10:test_items:019d-bbbb': { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: null, + values: { id: 0, title: 'Local item' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + }); + storage.values.set('offline:metadata', { schemaVersion: 999, lastUserId: 1 }); + await repository.initialize(); + expect(storage.values.has('offline:replica:rows')).toBe(false); + }); + + describe('replica pull persistence', () => { + const scope = { userId: 1, groupId: 10 }; + const baseRow = { + sourceKey: 'test_items', + serverId: 42, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed' as const, + }; + + it('getReplicaRowByServerIdはuser scopeでgroupIdを無視してlookupする', async () => { + await repository.transactReplica({ + putRows: [ + { ...baseRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 42, title: 'G10' } }, + { ...baseRow, userId: 1, groupId: 11, localId: '019d-bbbb', serverId: 43, values: { id: 43, title: 'G11' } }, + { ...baseRow, userId: 2, groupId: 10, localId: '019d-cccc', serverId: 42, values: { id: 42, title: 'Other user' } }, + ], + }); + + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + localId: '019d-aaaa', + values: { title: 'G10' }, + }); + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 43)).resolves.toMatchObject({ + localId: '019d-bbbb', + }); + expect(await repository.getReplicaRowByServerId(scope, 'test_items', 99)).toBeNull(); + }); + + it('getReplicaRowByServerIdはgroup scopeでgroupId一致のみ返す', async () => { + const groupRow = { + sourceKey: 'test_group_items', + serverId: 55, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed' as const, + }; + 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', serverId: 56, values: { id: 56, name: 'G11' } }, + ], + }); + + await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({ + localId: '019d-aaaa', + }); + expect(await repository.getReplicaRowByServerId(scope, 'test_group_items', 56)).toBeNull(); + }); + + it('putCursorsはrow更新と同一transactionで原子的に永続化する', async () => { + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: 42, + values: { id: 42, title: 'Pulled' }, + confirmedValues: { id: 42, title: 'Pulled' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + putCursors: [{ userId: 1, groupId: 10, cursor: 'cursor-v1' }], + }); + + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ userId: 1, groupId: 10, cursor: 'cursor-v1' }); + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + values: { title: 'Pulled' }, + }); + }); + + it('row validation失敗時はcursorも永続化しない', async () => { + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: 42, + values: { id: 42 }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + putCursors: [{ userId: 1, groupId: 10, cursor: 'cursor-v1' }], + }), + ).rejects.toThrow('Replica row is missing required source key "title".'); + expect(await repository.getReplicaCursor(scope)).toBeNull(); + }); + + it('clearGroupはcursorを削除する', async () => { + await repository.transactReplica({ + putCursors: [ + { userId: 1, groupId: 10, cursor: 'cursor-g10' }, + { userId: 1, groupId: 11, cursor: 'cursor-g11' }, + ], + }); + await repository.clearGroup(scope); + expect(await repository.getReplicaCursor(scope)).toBeNull(); + await expect(repository.getReplicaCursor({ userId: 1, groupId: 11 })).resolves.toEqual({ + userId: 1, + groupId: 11, + cursor: 'cursor-g11', + }); + }); + + it('clearUserはcursorを削除する', async () => { + await repository.transactReplica({ + putCursors: [ + { userId: 1, groupId: 10, cursor: 'cursor-u1' }, + { userId: 2, groupId: 10, cursor: 'cursor-u2' }, + ], + }); + await repository.clearUser(1); + expect(await repository.getReplicaCursor({ userId: 1, groupId: 10 })).toBeNull(); + await expect(repository.getReplicaCursor({ userId: 2, groupId: 10 })).resolves.toEqual({ + userId: 2, + groupId: 10, + cursor: 'cursor-u2', + }); + }); + }); + + describe('getReplicaRows', () => { + const baseRow = { + sourceKey: 'test_items', + serverId: null, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }; + + it('localId昇順で決定的に返す', async () => { + await repository.transactReplica({ + putRows: [ + { ...baseRow, userId: 1, groupId: 10, localId: '019d-cccc', values: { id: 0, title: 'C' } }, + { ...baseRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 0, title: 'A' } }, + { ...baseRow, userId: 1, groupId: 10, localId: '019d-bbbb', values: { id: 0, title: 'B' } }, + ], + }); + + const rows = await repository.getReplicaRows({ userId: 1, groupId: 10 }, 'test_items'); + expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb', '019d-cccc']); + }); + + it('user-scoped sourceはgroupIdを無視して同一userの行を返す', async () => { + await repository.transactReplica({ + putRows: [ + { ...baseRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 0, title: 'G10' } }, + { ...baseRow, userId: 1, groupId: 11, localId: '019d-bbbb', values: { id: 0, title: 'G11' } }, + { ...baseRow, userId: 2, groupId: 10, localId: '019d-cccc', values: { id: 0, title: 'Other user' } }, + ], + }); + + const rows = await repository.getReplicaRows({ userId: 1, groupId: 10 }, 'test_items'); + expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb']); + }); + + it('group-scoped sourceはgroupId一致の行だけを返す', async () => { + const groupRow = { + sourceKey: 'test_group_items', + serverId: null, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }; + await repository.transactReplica({ + putRows: [ + { ...groupRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 0, name: 'G10' } }, + { ...groupRow, userId: 1, groupId: 11, localId: '019d-bbbb', values: { id: 0, name: 'G11' } }, + ], + }); + + const rows = await repository.getReplicaRows({ userId: 1, groupId: 10 }, 'test_group_items'); + expect(rows).toHaveLength(1); + expect(rows[0]?.localId).toBe('019d-aaaa'); + }); + }); }); describe('selectOfflineRepository', () => { diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 3c1a9ae..59a7305 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -1,40 +1,39 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; import { KitStorageService } from '@rdlabo/ionic-angular-kit'; - -export const OFFLINE_SCHEMA_VERSION = 2; -const FIRST_LOCAL_RESOURCE_ID = 2_000_000_000_000; - +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { + encodeOfflineReplicaValues, + projectOfflineReplicaValues, + sha256OfflineReplicaSchema, + type OfflineReplicaEntitySchema, + type OfflineReplicaWebMigrationRow, +} from './offline-replica-schema'; + +/** Current durable storage schema used by both web and native repositories. */ +export const OFFLINE_SCHEMA_VERSION = 4; + +/** User and group partition of all local offline data. */ export interface OfflineScope { userId: number; groupId: number; } -export interface OfflineEntity extends OfflineScope { - entityType: string; - entityId: string; - value: T; - serverRevision: string | number | null; - fetchedAt: number; -} - -export interface OfflineQuery extends OfflineScope { - queryKey: string; - value: T; - orderedIds: string[]; - cursor: string | null; - etag: string | null; - fetchedAt: number; - isComplete: boolean; -} +/** Synchronization state of a locally materialized product replica row. */ +export type OfflineReplicaSyncState = 'confirmed' | 'pending' | 'blocked_auth' | 'rejected' | 'conflict'; +/** Durable processing state of an outbox command. */ export type OfflineCommandState = 'pending' | 'sending' | 'retry_wait' | 'blocked_auth' | 'rejected' | 'conflict'; +/** Product-agnostic mutation persisted in the outbox by local id. */ export interface OfflineCommand extends OfflineScope { commandId: string; aggregateType: string; - aggregateId: string; + /** Immutable local id of the target. The outbox never persists a server id. */ + aggregateLocalId: string; operation: string; payload: T; + /** Full optimistic entity value displayed while this command is pending. */ + optimisticValue: unknown; payloadHash: string; baseRevision: string | number | null; state: OfflineCommandState; @@ -44,25 +43,75 @@ export interface OfflineCommand extends OfflineScope { lastErrorCode: string | null; } +/** Product replica row materialized from a versioned schema entity. */ +export interface OfflineReplicaRow extends OfflineScope { + /** Stable source key matching {@link OfflineReplicaEntitySchema.sourceKey}. */ + sourceKey: string; + /** Immutable client-generated UUID used as the SQLite primary key. */ + localId: string; + /** Server-assigned identifier after a successful create, otherwise null. */ + serverId: number | null; + /** Current optimistic domain values displayed locally. */ + values: TValues; + /** Last server-confirmed domain values, or null while pending. */ + confirmedValues: TValues | null; + serverRevision: string | number | null; + fetchedAt: number; + syncState: OfflineReplicaSyncState; +} + +/** Stable address of a product replica row inside a user and group replica. */ +export interface OfflineReplicaRowKey extends OfflineScope { + sourceKey: string; + localId: string; +} + +/** Scope partition plus the durable replica pull cursor for that partition. */ +export interface OfflineReplicaCursor extends OfflineScope { + cursor: string; +} + +/** Atomic changes applied to the local replica and durable outbox together. */ +export interface OfflineReplicaTransaction { + putRows?: readonly OfflineReplicaRow[]; + removeRows?: readonly OfflineReplicaRowKey[]; + putCommands?: readonly OfflineCommand[]; + removeCommandIds?: readonly string[]; + putCursors?: readonly OfflineReplicaCursor[]; +} + +/** Durable local replica and outbox persistence contract. */ export interface OfflineRepository { initialize(): Promise; getLastUserId(): Promise; setLastUserId(userId: number): Promise; - allocateLocalId(): Promise; - getEntity(scope: OfflineScope, entityType: string, entityId: string): Promise | null>; - putEntity(entity: OfflineEntity): Promise; - getQuery(scope: OfflineScope, queryKey: string): Promise | null>; - putQuery(query: OfflineQuery): Promise; + getSessionManifest(userId: number): Promise; + putSessionManifest(userId: number, value: T): Promise; + getReplicaRow( + scope: OfflineScope, + sourceKey: string, + localId: string, + ): Promise | null>; + getReplicaRows(scope: OfflineScope, sourceKey: string): Promise[]>; + getReplicaRowByServerId( + scope: OfflineScope, + sourceKey: string, + serverId: number, + ): Promise | null>; + getReplicaCursor(scope: OfflineScope): Promise; getCommands(scope: OfflineScope): Promise; putCommand(command: OfflineCommand): Promise; replaceCommand(command: OfflineCommand): Promise; removeCommand(commandId: string): Promise; clearUser(userId: number): Promise; clearGroup(scope: OfflineScope): Promise; + transactReplica(transaction: OfflineReplicaTransaction): Promise; } +/** DI token for the selected platform repository. */ export const OFFLINE_REPOSITORY = new InjectionToken('OFFLINE_REPOSITORY'); +/** Selects encrypted SQLite on native platforms and Ionic Storage on web. */ export function selectOfflineRepository( platform: string, webRepository: OfflineRepository, @@ -74,23 +123,43 @@ export function selectOfflineRepository( interface OfflineMetadata { schemaVersion: number; lastUserId: number | null; - nextLocalId: number; + replicaSchemaVersion: number | null; + replicaSchemaHash: string | null; +} + +/** Crash-recoverable journal for an in-flight web replica schema migration. */ +interface OfflineReplicaSchemaMigrationJournal { + originalRows: Record; + fromVersion: number; + targetVersion: number; + targetHash: string; } const METADATA_KEY = 'offline:metadata'; -const ENTITIES_KEY = 'offline:business_cache:entities'; -const QUERIES_KEY = 'offline:business_cache:queries'; +const SESSION_MANIFESTS_KEY = 'offline:session:manifests'; +const ROWS_KEY = 'offline:replica:rows'; +const CURSORS_KEY = 'offline:replica:cursors'; +const LEGACY_ENTITIES_KEY = 'offline:business_cache:entities'; +const LEGACY_QUERIES_KEY = 'offline:business_cache:queries'; const OUTBOX_KEY = 'offline:outbox:commands'; +const REPLICA_TRANSACTION_KEY = 'offline:replica:transaction'; +const REPLICA_SCHEMA_MIGRATION_KEY = 'offline:replica:schema-migration'; /** WebはIonic StorageのIndexedDB driverを利用する。 */ @Injectable({ providedIn: 'root' }) export class IonicOfflineRepository implements OfflineRepository { readonly #storage = inject(KitStorageService); + readonly #options = inject(OFFLINE_KIT_OPTIONS); #initialization: Promise | null = null; #writes: Promise = Promise.resolve(); initialize(): Promise { - this.#initialization ??= this.#migrate(); + if (!this.#initialization) { + this.#initialization = this.#migrate().catch((error: unknown) => { + this.#initialization = null; + throw error; + }); + } return this.#initialization; } @@ -104,49 +173,77 @@ export class IonicOfflineRepository implements OfflineRepository { await this.#storage.set(METADATA_KEY, { ...(await this.#metadata()), lastUserId: userId }); } - async allocateLocalId(): Promise { + async getSessionManifest(userId: number): Promise { await this.initialize(); - let allocated = FIRST_LOCAL_RESOURCE_ID; - const write = this.#writes.then(async (): Promise => { - const metadata = await this.#metadata(); - allocated = Math.max(metadata.nextLocalId, FIRST_LOCAL_RESOURCE_ID) + 1; - await this.#storage.set(METADATA_KEY, { ...metadata, nextLocalId: allocated }); + await this.#writes; + const manifests = await this.#readRecord(SESSION_MANIFESTS_KEY); + return manifests[String(userId)] ?? null; + } + + async putSessionManifest(userId: number, value: T): Promise { + await this.initialize(); + await this.#mutateRecord(SESSION_MANIFESTS_KEY, (manifests) => { + manifests[String(userId)] = value; + return manifests; }); - this.#writes = write.catch((): void => undefined); - await write; - return allocated; } - async getEntity(scope: OfflineScope, entityType: string, entityId: string): Promise | null> { + async getReplicaRow( + scope: OfflineScope, + sourceKey: string, + localId: string, + ): Promise | null> { await this.initialize(); - const entities = await this.#readRecord>(ENTITIES_KEY); - return entities[this.#entityKey(scope, entityType, entityId)] ?? null; + await this.#writes; + const rows = await this.#readRecord>(ROWS_KEY); + return rows[this.#rowKey(scope, sourceKey, localId)] ?? null; } - async putEntity(entity: OfflineEntity): Promise { + async getReplicaRows( + scope: OfflineScope, + sourceKey: string, + ): Promise[]> { await this.initialize(); - await this.#mutateRecord>(ENTITIES_KEY, (entities) => { - entities[this.#entityKey(entity, entity.entityType, entity.entityId)] = entity; - return entities; - }); + await this.#writes; + const schema = this.#resolveReplicaEntitySchema(sourceKey); + const rows = await this.#readRecord>(ROWS_KEY); + return Object.values(rows) + .filter((row) => { + if (row.sourceKey !== sourceKey || row.userId !== scope.userId) return false; + return schema.scope === 'group' ? row.groupId === scope.groupId : true; + }) + .sort((left, right) => left.localId.localeCompare(right.localId)); } - async getQuery(scope: OfflineScope, queryKey: string): Promise | null> { + async getReplicaRowByServerId( + scope: OfflineScope, + sourceKey: string, + serverId: number, + ): Promise | null> { await this.initialize(); - const queries = await this.#readRecord>(QUERIES_KEY); - return queries[this.#queryKey(scope, queryKey)] ?? null; + await this.#writes; + const schema = this.#resolveReplicaEntitySchema(sourceKey); + if (!this.#schemaHasServerId(schema)) return null; + const rows = await this.#readRecord>(ROWS_KEY); + return ( + Object.values(rows).find((row) => { + if (row.sourceKey !== sourceKey || row.userId !== scope.userId || row.serverId !== serverId) return false; + return schema.scope === 'group' ? row.groupId === scope.groupId : true; + }) ?? null + ); } - async putQuery(query: OfflineQuery): Promise { + async getReplicaCursor(scope: OfflineScope): Promise { await this.initialize(); - await this.#mutateRecord>(QUERIES_KEY, (queries) => { - queries[this.#queryKey(query, query.queryKey)] = query; - return queries; - }); + await this.#writes; + const cursors = await this.#readRecord(CURSORS_KEY); + const cursor = cursors[this.#cursorKey(scope)]; + return cursor === undefined ? null : { ...scope, cursor }; } async getCommands(scope: OfflineScope): Promise { await this.initialize(); + await this.#writes; const commands = await this.#readRecord(OUTBOX_KEY); return Object.values(commands) .filter((command) => command.userId === scope.userId && command.groupId === scope.groupId) @@ -155,6 +252,7 @@ export class IonicOfflineRepository implements OfflineRepository { async putCommand(command: OfflineCommand): Promise { await this.initialize(); + await this.#assertReplicaSchemaLocked(); await this.#mutateRecord(OUTBOX_KEY, (commands) => { commands[command.commandId] = command; return commands; @@ -176,9 +274,13 @@ export class IonicOfflineRepository implements OfflineRepository { async clearUser(userId: number): Promise { await this.initialize(); await Promise.all([ - this.#filterRecord(ENTITIES_KEY, (value) => value.userId !== userId), - this.#filterRecord(QUERIES_KEY, (value) => value.userId !== userId), + this.#mutateRecord(SESSION_MANIFESTS_KEY, (manifests) => { + delete manifests[String(userId)]; + return manifests; + }), + this.#filterRecord(ROWS_KEY, (value) => value.userId !== userId), this.#filterRecord(OUTBOX_KEY, (value) => value.userId !== userId), + this.#filterRecord(CURSORS_KEY, (_value, key) => !key.startsWith(`${userId}:`)), ]); const metadata = await this.#metadata(); if (metadata.lastUserId === userId) { @@ -190,21 +292,242 @@ export class IonicOfflineRepository implements OfflineRepository { await this.initialize(); const belongsToGroup = (value: OfflineScope) => value.userId === scope.userId && value.groupId === scope.groupId; await Promise.all([ - this.#filterRecord(ENTITIES_KEY, (value) => !belongsToGroup(value)), - this.#filterRecord(QUERIES_KEY, (value) => !belongsToGroup(value)), + this.#filterRecord(ROWS_KEY, (value) => !belongsToGroup(value)), this.#filterRecord(OUTBOX_KEY, (value) => !belongsToGroup(value)), + this.#filterRecord(CURSORS_KEY, (_value, key) => key !== this.#cursorKey(scope)), ]); } + async transactReplica(transaction: OfflineReplicaTransaction): Promise { + await this.initialize(); + const write = this.#writes.then(() => this.#applyReplicaTransaction(transaction, true)); + this.#writes = write.catch((): void => undefined); + return write; + } + async #migrate(): Promise { - const metadata = await this.#storage.get(METADATA_KEY); - if (metadata?.schemaVersion === OFFLINE_SCHEMA_VERSION) return; - await Promise.all([this.#storage.remove(ENTITIES_KEY), this.#storage.remove(QUERIES_KEY), this.#storage.remove(OUTBOX_KEY)]); - await this.#storage.set(METADATA_KEY, { - schemaVersion: OFFLINE_SCHEMA_VERSION, - lastUserId: metadata?.lastUserId ?? null, - nextLocalId: metadata?.nextLocalId ?? FIRST_LOCAL_RESOURCE_ID, + const metadata = await this.#storage.get>(METADATA_KEY); + if (metadata?.schemaVersion !== undefined && metadata.schemaVersion !== OFFLINE_SCHEMA_VERSION) { + await Promise.all([ + this.#storage.remove(SESSION_MANIFESTS_KEY), + this.#storage.remove(ROWS_KEY), + this.#storage.remove(CURSORS_KEY), + this.#storage.remove(LEGACY_ENTITIES_KEY), + this.#storage.remove(LEGACY_QUERIES_KEY), + this.#storage.remove(OUTBOX_KEY), + this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY), + ]); + await this.#storage.set(METADATA_KEY, { + schemaVersion: OFFLINE_SCHEMA_VERSION, + lastUserId: metadata.lastUserId ?? null, + replicaSchemaVersion: null, + replicaSchemaHash: null, + }); + await this.#storage.remove(REPLICA_TRANSACTION_KEY); + await this.#initializeReplicaSchema(null, null); + return; + } + + const interruptedSchemaMigration = await this.#storage.get( + REPLICA_SCHEMA_MIGRATION_KEY, + ); + if (interruptedSchemaMigration) { + await this.#recoverReplicaSchemaMigration(interruptedSchemaMigration); + } + + const currentMetadata = await this.#metadata(); + await this.#initializeReplicaSchema( + currentMetadata.replicaSchemaVersion, + currentMetadata.replicaSchemaHash, + ); + + const interrupted = await this.#storage.get(REPLICA_TRANSACTION_KEY); + if (interrupted) await this.#applyReplicaTransaction(interrupted, false); + } + + async #initializeReplicaSchema(storedVersion: number | null, storedHash: string | null): Promise { + const bundle = this.#options.replicaSchema; + const targetVersion = bundle.version; + const targetHash = await sha256OfflineReplicaSchema(bundle); + + if (storedVersion === null) { + const metadata = await this.#metadata(); + await this.#storage.set(METADATA_KEY, { + ...metadata, + replicaSchemaVersion: targetVersion, + replicaSchemaHash: targetHash, + }); + return; + } + + if (storedVersion === targetVersion && storedHash === targetHash) { + return; + } + + if (storedVersion === targetVersion) { + throw new Error( + `Offline replica schema hash mismatch at version ${targetVersion}. Reinstall the application or bump replicaSchema.version after intentional schema changes.`, + ); + } + + if (storedVersion > targetVersion) { + throw new Error( + `Offline replica schema version ${storedVersion} is newer than application version ${targetVersion}. Upgrade the application before opening this database.`, + ); + } + + await this.#runReplicaSchemaMigration(storedVersion, targetVersion, targetHash); + } + + async #recoverReplicaSchemaMigration(journal: OfflineReplicaSchemaMigrationJournal): Promise { + const metadata = await this.#metadata(); + if ( + metadata.replicaSchemaVersion === journal.targetVersion && + metadata.replicaSchemaHash === journal.targetHash + ) { + await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); + return; + } + + await this.#storage.set(ROWS_KEY, journal.originalRows); + await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); + } + + async #runReplicaSchemaMigration( + fromVersion: number, + targetVersion: number, + targetHash: string, + ): Promise { + const bundle = this.#options.replicaSchema; + for (let version = fromVersion; version < targetVersion; version++) { + if (!bundle.migrations.some((migration) => migration.fromVersion === version)) { + throw new Error(`Missing offline replica schema migration from version ${version} to ${version + 1}.`); + } + } + + const write = this.#writes.then(async (): Promise => { + const rows = await this.#readRecord(ROWS_KEY); + const originalRows = structuredClone(rows); + await this.#storage.set(REPLICA_SCHEMA_MIGRATION_KEY, { + originalRows, + fromVersion, + targetVersion, + targetHash, + }); + + try { + const transformedRows: Record = {}; + for (const [key, row] of Object.entries(rows)) { + let current: OfflineReplicaWebMigrationRow | null = this.#toWebMigrationRow(row); + for (let version = fromVersion; version < targetVersion; version++) { + if (current === null) break; + const migration = bundle.migrations.find((candidate) => candidate.fromVersion === version); + if (!migration) { + throw new Error(`Missing offline replica schema migration from version ${version} to ${version + 1}.`); + } + current = await migration.migrateWebRow(current); + } + if (current === null) continue; + + const entitySchema = bundle.entities.find((entity) => entity.sourceKey === current!.sourceKey); + if (!entitySchema) { + throw new Error(`Unknown offline replica source key "${current!.sourceKey}" after schema migration.`); + } + encodeOfflineReplicaValues(entitySchema, current!.values); + if (current!.confirmedValues !== null) { + encodeOfflineReplicaValues(entitySchema, current!.confirmedValues); + } + + transformedRows[key] = { + ...row, + sourceKey: current!.sourceKey, + values: projectOfflineReplicaValues(entitySchema, current!.values), + confirmedValues: + current!.confirmedValues === null + ? null + : projectOfflineReplicaValues(entitySchema, current!.confirmedValues), + }; + } + + const metadata = await this.#metadata(); + await Promise.all([ + this.#storage.set(ROWS_KEY, transformedRows), + this.#storage.set(METADATA_KEY, { + ...metadata, + replicaSchemaVersion: targetVersion, + replicaSchemaHash: targetHash, + }), + ]); + await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); + } catch (error) { + await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); + throw error; + } }); + this.#writes = write.catch((): void => undefined); + return write; + } + + #toWebMigrationRow(row: OfflineReplicaRow): OfflineReplicaWebMigrationRow { + return { + sourceKey: row.sourceKey, + values: structuredClone(row.values as Record), + confirmedValues: + row.confirmedValues === null ? null : structuredClone(row.confirmedValues as Record), + }; + } + + async #assertReplicaSchemaLocked(): Promise { + const bundle = this.#options.replicaSchema; + const metadata = await this.#metadata(); + const targetHash = await sha256OfflineReplicaSchema(bundle); + if (metadata.replicaSchemaVersion === null) { + throw new Error('Offline replica schema metadata is not initialized.'); + } + if (metadata.replicaSchemaVersion !== bundle.version) { + throw new Error( + `Offline replica schema version ${metadata.replicaSchemaVersion} does not match application version ${bundle.version}.`, + ); + } + if (metadata.replicaSchemaHash !== targetHash) { + throw new Error( + `Offline replica schema hash mismatch at version ${bundle.version}. Reinstall the application or bump replicaSchema.version after intentional schema changes.`, + ); + } + } + + async #applyReplicaTransaction(transaction: OfflineReplicaTransaction, journal: boolean): Promise { + await this.#assertReplicaSchemaLocked(); + for (const row of transaction.putRows ?? []) this.#validateReplicaRow(row); + if (journal) await this.#storage.set(REPLICA_TRANSACTION_KEY, transaction); + const [rows, commands, cursors] = await Promise.all([ + this.#readRecord(ROWS_KEY), + this.#readRecord(OUTBOX_KEY), + this.#readRecord(CURSORS_KEY), + ]); + for (const row of transaction.putRows ?? []) { + const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + rows[this.#rowKey(row, row.sourceKey, row.localId)] = { + ...row, + values: projectOfflineReplicaValues(schema, row.values), + confirmedValues: + row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues), + }; + } + for (const row of transaction.removeRows ?? []) { + delete rows[this.#rowKey(row, row.sourceKey, row.localId)]; + } + for (const command of transaction.putCommands ?? []) commands[command.commandId] = command; + for (const commandId of transaction.removeCommandIds ?? []) delete commands[commandId]; + for (const cursor of transaction.putCursors ?? []) { + cursors[this.#cursorKey(cursor)] = cursor.cursor; + } + await Promise.all([ + this.#storage.set(ROWS_KEY, rows), + this.#storage.set(OUTBOX_KEY, commands), + this.#storage.set(CURSORS_KEY, cursors), + ]); + await this.#storage.remove(REPLICA_TRANSACTION_KEY); } async #metadata(): Promise { @@ -212,7 +535,8 @@ export class IonicOfflineRepository implements OfflineRepository { return { schemaVersion: metadata?.schemaVersion ?? OFFLINE_SCHEMA_VERSION, lastUserId: metadata?.lastUserId ?? null, - nextLocalId: metadata?.nextLocalId ?? FIRST_LOCAL_RESOURCE_ID, + replicaSchemaVersion: metadata?.replicaSchemaVersion ?? null, + replicaSchemaHash: metadata?.replicaSchemaHash ?? null, }; } @@ -220,8 +544,10 @@ export class IonicOfflineRepository implements OfflineRepository { return (await this.#storage.get>(key)) ?? {}; } - async #filterRecord(key: string, predicate: (value: T) => boolean): Promise { - await this.#mutateRecord(key, (record) => Object.fromEntries(Object.entries(record).filter(([, value]) => predicate(value)))); + async #filterRecord(key: string, predicate: (value: T, recordKey: string) => boolean): Promise { + await this.#mutateRecord(key, (record) => + Object.fromEntries(Object.entries(record).filter(([recordKey, value]) => predicate(value, recordKey))), + ); } #mutateRecord(key: string, mutate: (record: Record) => Record): Promise { @@ -233,11 +559,27 @@ export class IonicOfflineRepository implements OfflineRepository { return write; } - #entityKey(scope: OfflineScope, entityType: string, entityId: string): string { - return `${scope.userId}:${scope.groupId}:${entityType}:${entityId}`; + #rowKey(scope: OfflineScope, sourceKey: string, localId: string): string { + return `${scope.userId}:${scope.groupId}:${sourceKey}:${localId}`; + } + + #cursorKey(scope: OfflineScope): string { + return `${scope.userId}:${scope.groupId}`; + } + + #schemaHasServerId(schema: OfflineReplicaEntitySchema>): boolean { + return schema.fields.some((field) => field.policy === 'serverId'); + } + + #validateReplicaRow(row: OfflineReplicaRow): void { + const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + encodeOfflineReplicaValues(schema, row.values); + if (row.confirmedValues !== null) encodeOfflineReplicaValues(schema, row.confirmedValues); } - #queryKey(scope: OfflineScope, queryKey: string): string { - return `${scope.userId}:${scope.groupId}:${queryKey}`; + #resolveReplicaEntitySchema(sourceKey: string): OfflineReplicaEntitySchema> { + const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); + if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); + return schema; } } diff --git a/projects/kit/offline/src/lib/offline-request-policy.ts b/projects/kit/offline/src/lib/offline-request-policy.ts index a61865e..ee3ab17 100644 --- a/projects/kit/offline/src/lib/offline-request-policy.ts +++ b/projects/kit/offline/src/lib/offline-request-policy.ts @@ -5,41 +5,36 @@ import { HttpContextToken } from '@angular/common/http'; /** outbox再送時にoffline interceptorだけを迂回する。認証・retryは維持する。 */ export const OFFLINE_BYPASS = new HttpContextToken(() => false); +/** Header attached to responses served from the local replica fallback. */ export const OFFLINE_RESPONSE_HEADER = 'X-Offline-Response'; -export type OfflineResponseSource = 'cache' | 'queued'; +/** Origin of a synthetic offline response. */ +export type OfflineResponseSource = 'local'; +/** Product read policy backed by a local replica fallback for transport failures. */ export interface OfflineReadRequestPlan { kind: 'read'; - readCached(): Promise | null>; - storeFresh(response: HttpResponse): Promise; + readLocal(): Promise | null>; } -export interface OfflineQueuedMutation { - commandId: string; - response?: HttpResponse; -} - -export interface OfflineMutationRequestPlan { - kind: 'mutation'; - enqueue?: () => Promise; - storeFresh?: (response: HttpResponse) => Promise; -} - -export type OfflineRequestPlan = OfflineReadRequestPlan | OfflineMutationRequestPlan; +/** Resolved offline handling strategy for an HTTP request. */ +export type OfflineRequestPlan = OfflineReadRequestPlan; -/** URL・DTO・cache key・feature flagは製品Policyだけが知る。 */ +/** URL・DTO・replica key・feature flagは製品Policyだけが知る。 */ export interface OfflineRequestPolicy { resolve(request: HttpRequest): OfflineRequestPlan | null; } +/** Multi-provider token containing product request policies. */ export const OFFLINE_REQUEST_POLICIES = new InjectionToken('OFFLINE_REQUEST_POLICIES', { factory: () => [], }); +/** Registers one product request policy with the offline interceptor. */ export function provideOfflineRequestPolicy(policy: Type): Provider[] { return [policy, { provide: OFFLINE_REQUEST_POLICIES, useExisting: policy, multi: true }]; } +/** Resolves the first product policy matching an HTTP request. */ @Injectable({ providedIn: 'root' }) export class OfflineRequestPolicyRegistry { readonly #policies = inject(OFFLINE_REQUEST_POLICIES); diff --git a/projects/kit/offline/src/lib/offline-session.service.spec.ts b/projects/kit/offline/src/lib/offline-session.service.spec.ts index 44de16b..8531af5 100644 --- a/projects/kit/offline/src/lib/offline-session.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -1,29 +1,24 @@ import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OFFLINE_REPOSITORY, type OfflineEntity, type OfflineRepository, type OfflineScope } from './offline-repository'; +import { OFFLINE_REPOSITORY, type OfflineRepository } from './offline-repository'; import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service'; describe('OfflineSessionService shared-device boundary', () => { let service: OfflineSessionService; let lastUserId: number | null; - let entities: OfflineEntity[]; + let manifests: Map; let clearUser: ReturnType; beforeEach(() => { lastUserId = 10; - entities = [ - { - userId: 10, - groupId: 0, - entityType: '__offline_session', - entityId: 'current', - value: { userId: 10, scopeIds: [1], authSubject: 'uid-A', updatedAt: 1 } satisfies OfflineSessionManifest, - serverRevision: null, - fetchedAt: 1, - }, - ]; + manifests = new Map([ + [ + 10, + { userId: 10, scopeIds: [1], authSubject: 'uid-A', updatedAt: 1 }, + ], + ]); clearUser = vi.fn(async (userId: number) => { - entities = entities.filter((entity) => entity.userId !== userId); + manifests.delete(userId); if (lastUserId === userId) lastUserId = null; }); const repository = { @@ -32,15 +27,9 @@ describe('OfflineSessionService shared-device boundary', () => { setLastUserId: vi.fn(async (userId: number) => { lastUserId = userId; }), - getEntity: vi.fn( - async (scope: OfflineScope, type: string, id: string) => - entities.find( - (entity) => - entity.userId === scope.userId && entity.groupId === scope.groupId && entity.entityType === type && entity.entityId === id, - ) ?? null, - ), - putEntity: vi.fn(async (entity: OfflineEntity) => { - entities.push(entity); + getSessionManifest: vi.fn(async (userId: number) => manifests.get(userId) ?? null), + putSessionManifest: vi.fn(async (userId: number, manifest: OfflineSessionManifest) => { + manifests.set(userId, manifest); }), clearUser, clearGroup: vi.fn(async () => undefined), @@ -69,12 +58,12 @@ describe('OfflineSessionService shared-device boundary', () => { await service.initialize(); await service.activateSession(10, [2], 'uid-B'); expect(clearUser).toHaveBeenCalledWith(10); - expect(entities.some((entity) => entity.entityType === '__offline_session' && entity.value)).toBe(true); + expect(manifests.get(10)).toMatchObject({ userId: 10, scopeIds: [2], authSubject: 'uid-B' }); expect(service.activeManifest()).toMatchObject({ userId: 10, scopeIds: [2], authSubject: 'uid-B' }); }); - it('legacy null subjectから既知subjectへの移行時も旧cacheを削除する', async () => { - (entities[0]!.value as OfflineSessionManifest).authSubject = null; + it('legacy null subjectから既知subjectへの移行時も旧local replicaを削除する', async () => { + manifests.set(10, { userId: 10, scopeIds: [1], authSubject: null, updatedAt: 1 }); await service.initialize(); await service.activateSession(10, [1], 'uid-A'); expect(clearUser).toHaveBeenCalledWith(10); diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index 44b16b4..c938596 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -2,10 +2,7 @@ import { inject, Injectable, signal } from '@angular/core'; import type { OfflineScope } from './offline-repository'; import { OFFLINE_REPOSITORY } from './offline-repository'; -const SESSION_ENTITY_TYPE = '__offline_session'; -const SESSION_ENTITY_ID = 'current'; -const SESSION_GROUP_ID = 0; - +/** Persisted identity and group boundary for one authenticated local replica. */ export interface OfflineSessionManifest { userId: number; scopeIds: number[]; @@ -14,6 +11,7 @@ export interface OfflineSessionManifest { updatedAt: number; } +/** Owns activation and cleanup of the authenticated local-replica boundary. */ @Injectable({ providedIn: 'root' }) export class OfflineSessionService { readonly #repository = inject(OFFLINE_REPOSITORY); @@ -28,24 +26,19 @@ export class OfflineSessionService { await this.#repository.initialize(); const userId = await this.#repository.getLastUserId(); if (userId !== null) { - const entity = await this.#repository.getEntity( - { userId, groupId: SESSION_GROUP_ID }, - SESSION_ENTITY_TYPE, - SESSION_ENTITY_ID, - ); - this.#activeManifest.set(entity?.value ?? { userId, scopeIds: [], authSubject: null, updatedAt: 0 }); + const manifest = await this.#repository.getSessionManifest(userId); + this.#activeManifest.set(manifest ?? { userId, scopeIds: [], authSubject: null, updatedAt: 0 }); } this.#initialized = true; } async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise { await this.initialize(); - const normalizedScopeIds = [...new Set(scopeIds)].filter((id) => id !== SESSION_GROUP_ID).sort((a, b) => a - b); + const normalizedScopeIds = [...new Set(scopeIds)].filter((id) => id !== 0).sort((a, b) => a - b); const previousUserId = await this.#repository.getLastUserId(); - const sessionScope = { userId, groupId: SESSION_GROUP_ID }; let previous = previousUserId === userId - ? ((await this.#repository.getEntity(sessionScope, SESSION_ENTITY_TYPE, SESSION_ENTITY_ID))?.value ?? null) + ? ((await this.#repository.getSessionManifest(userId)) ?? null) : null; // A changed provider subject is a different person even when the product reuses its numeric id. // This deliberately also clears legacy null -> known subject and known subject -> null transitions. @@ -67,14 +60,7 @@ export class OfflineSessionService { updatedAt: Date.now(), }; await this.#repository.setLastUserId(userId); - await this.#repository.putEntity({ - ...sessionScope, - entityType: SESSION_ENTITY_TYPE, - entityId: SESSION_ENTITY_ID, - value: manifest, - serverRevision: null, - fetchedAt: manifest.updatedAt, - }); + await this.#repository.putSessionManifest(userId, manifest); this.#activeManifest.set(manifest); this.#activatedThisRun = true; } 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 1999c60..137689c 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -1,32 +1,62 @@ -import { signal } from '@angular/core'; +import { ErrorHandler, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor'; +import { + OFFLINE_COMMAND_EXECUTOR, + OFFLINE_SYNC_CONTEXT, + type OfflineCommandResult, + type OfflineCommandTarget, +} from './offline-command-executor'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; +import { OfflineReplicaPullService } from './offline-replica-pull.service'; +import { defineOfflineReplicaSchema, defineReplicaEntity, serverId, text } from './offline-replica-schema'; import { OFFLINE_REPOSITORY, type OfflineCommand, - type OfflineEntity, + type OfflineReplicaRow, type OfflineRepository, type OfflineScope, } from './offline-repository'; import { OfflinePayloadValidationError, OfflineSyncService } from './offline-sync.service'; +const replicaSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [ + defineReplicaEntity<{ id: number; title: string }>()({ + table: 'documents', + sourceKey: 'documents', + scope: 'group', + fields: { + id: serverId(), + title: text(), + }, + }), + ], + migrations: [], +}); + describe('OfflineSyncService', () => { let service: OfflineSyncService; let commands: OfflineCommand[]; - let entities: OfflineEntity[]; + let rows: OfflineReplicaRow[]; let connected: ReturnType>; let session: { userId: number; scopes: OfflineScope[] } | null; let beforePutCommand: ((command: OfflineCommand) => Promise) | null; - const execute = vi.fn(async (_command: OfflineCommand) => ({ response: null })); + let pull: ReturnType Promise>>; + let handleError: ReturnType void>>; + const execute = vi.fn( + async (_command: OfflineCommand, _target: OfflineCommandTarget): Promise => ({ response: null }), + ); beforeEach(() => { commands = []; - entities = []; + rows = []; connected = signal(false); session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; beforePutCommand = null; + pull = vi.fn(async () => undefined); + handleError = vi.fn(); execute.mockReset(); execute.mockResolvedValue({ response: null }); const repository = { @@ -48,21 +78,43 @@ describe('OfflineSyncService', () => { removeCommand: vi.fn(async (commandId: string) => { commands = commands.filter((item) => item.commandId !== commandId); }), - getEntity: vi.fn( - async (scope: OfflineScope, type: string, id: string) => - entities.find( - (item) => item.userId === scope.userId && item.groupId === scope.groupId && item.entityType === type && item.entityId === id, + getReplicaRow: vi.fn( + async (scope: OfflineScope, sourceKey: string, localId: string) => + rows.find( + (item) => + item.userId === scope.userId && + item.groupId === scope.groupId && + item.sourceKey === sourceKey && + item.localId === localId, ) ?? null, ), - putEntity: vi.fn(async (entity: OfflineEntity) => { - entities = entities.filter( - (item) => - item.userId !== entity.userId || - item.groupId !== entity.groupId || - item.entityType !== entity.entityType || - item.entityId !== entity.entityId, - ); - entities.push(entity); + getReplicaCursor: vi.fn(async () => null), + transactReplica: vi.fn(async (transaction) => { + for (const row of transaction.putRows ?? []) { + rows = rows.filter( + (item) => + item.userId !== row.userId || + item.groupId !== row.groupId || + item.sourceKey !== row.sourceKey || + item.localId !== row.localId, + ); + rows.push(structuredClone(row)); + } + for (const key of transaction.removeRows ?? []) { + rows = rows.filter( + (item) => + item.userId !== key.userId || + item.groupId !== key.groupId || + item.sourceKey !== key.sourceKey || + item.localId !== key.localId, + ); + } + for (const command of transaction.putCommands ?? []) { + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + } + commands = commands.filter((command) => !(transaction.removeCommandIds ?? []).includes(command.commandId)); + commands.sort((left, right) => left.createdAt - right.createdAt); }), } as unknown as OfflineRepository; TestBed.configureTestingModule({ @@ -70,6 +122,9 @@ describe('OfflineSyncService', () => { OfflineSyncService, { provide: OFFLINE_REPOSITORY, useValue: repository }, { provide: OfflineNetworkService, useValue: { connected } }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema } }, + { provide: OfflineReplicaPullService, useValue: { pull } }, + { provide: ErrorHandler, useValue: { handleError } }, { provide: OFFLINE_SYNC_CONTEXT, useValue: { getSession: vi.fn(async () => session) }, @@ -85,11 +140,25 @@ describe('OfflineSyncService', () => { it('同じaggregateの操作を作成順に送り、成功後だけoutboxから除く', async () => { await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 1 }, + optimisticValue: { seq: 1 }, + }, { flush: false }, ); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 2 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 2 }, + optimisticValue: { seq: 2 }, + }, { flush: false }, ); connected.set(true); @@ -98,13 +167,178 @@ describe('OfflineSyncService', () => { expect(service.pendingCount()).toBe(0); }); + it('local_idを不変主キーにして送信直前に最新server_idへ解決する', async () => { + execute.mockResolvedValueOnce({ + serverId: 38142, + serverRevision: 1, + confirmedValues: { name: 'draft' }, + response: { id: 38142 }, + }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-aaaa', + operation: 'documents.create', + payload: { name: 'draft' }, + optimisticValue: { name: 'draft' }, + }, + { flush: false }, + ); + expect(rows[0]).toMatchObject({ localId: '019d-aaaa', serverId: null, syncState: 'pending' }); + expect(commands[0]).toMatchObject({ aggregateLocalId: '019d-aaaa' }); + expect('serverId' in commands[0]!).toBe(false); + + connected.set(true); + await service.flush(); + expect(execute.mock.calls[0]?.[1]).toEqual({ localId: '019d-aaaa', serverId: null }); + expect(rows[0]).toMatchObject({ + localId: '019d-aaaa', + serverId: 38142, + serverRevision: 1, + syncState: 'confirmed', + confirmedValues: { name: 'draft' }, + }); + expect(commands.every((command) => !('serverId' in command))).toBe(true); + + execute.mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { name: 'edited' }, + response: { id: 38142, name: 'edited' }, + }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-aaaa', + operation: 'documents.update', + payload: { name: 'edited', revision: 1 }, + optimisticValue: { name: 'edited' }, + baseRevision: 1, + }, + { flush: false }, + ); + await service.flush(); + expect(execute.mock.calls[1]?.[1]).toEqual({ localId: '019d-aaaa', serverId: 38142 }); + expect(rows[0]).toMatchObject({ + localId: '019d-aaaa', + serverId: 38142, + serverRevision: 2, + confirmedValues: { name: 'edited' }, + }); + expect(commands.every((command) => !('serverId' in command))).toBe(true); + }); + + it('session scope発見後に前回起動のsending commandをpendingへ復旧する', async () => { + session = null; + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'documents', + localId: '019d-aaaa', + serverId: null, + values: {}, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }); + commands.push({ + userId: 1, + groupId: 10, + commandId: 'interrupted', + aggregateType: 'documents', + aggregateLocalId: '019d-aaaa', + operation: 'documents.create', + payload: {}, + optimisticValue: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'sending', + attempts: 1, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }); + await service.initialize(); + session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; + await service.refreshSession(); + expect(service.pendingCommands()[0]?.state).toBe('pending'); + }); + + it('未同期createを破棄するとoutboxと未確定replica rowを同時に除く', async () => { + const commandId = await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-new', + operation: 'documents.create', + payload: { name: 'draft' }, + optimisticValue: { name: 'draft' }, + }, + { flush: false }, + ); + await service.discard(commandId, { flush: false }); + expect(commands).toEqual([]); + expect(rows).toEqual([]); + }); + + it('既存replica rowの未同期updateを破棄するとserver確定値へ戻す', async () => { + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'documents', + localId: '019d-existing', + serverId: 38142, + values: { name: 'confirmed' }, + confirmedValues: { name: 'confirmed' }, + serverRevision: 4, + fetchedAt: 1, + syncState: 'confirmed', + }); + const commandId = await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-existing', + operation: 'documents.update', + payload: { name: 'draft', revision: 4 }, + optimisticValue: { name: 'draft' }, + baseRevision: 4, + }, + { flush: false }, + ); + expect(rows[0]?.values).toEqual({ name: 'draft' }); + await service.discard(commandId, { flush: false }); + expect(rows[0]).toMatchObject({ + values: { name: 'confirmed' }, + confirmedValues: { name: 'confirmed' }, + syncState: 'confirmed', + serverId: 38142, + }); + }); + it('locale非依存のkey順で同じJSON payloadを同一hashにする', async () => { await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { あ: 3, z: 1, ä: 2 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { あ: 3, z: 1, ä: 2 }, + optimisticValue: {}, + }, { flush: false }, ); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '2', operation: 'documents.upsert', payload: { ä: 2, あ: 3, z: 1 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '2', + operation: 'documents.upsert', + payload: { ä: 2, あ: 3, z: 1 }, + optimisticValue: {}, + }, { flush: false }, ); expect(service.pendingCommands()[0]?.payloadHash).toBe(service.pendingCommands()[1]?.payloadHash); @@ -113,37 +347,59 @@ describe('OfflineSyncService', () => { it('JSON外payloadを衝突するhashへ変換せずrejectする', async () => { await expect( service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { value: undefined } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { value: undefined }, + optimisticValue: {}, + }, { flush: false }, ), ).rejects.toBeInstanceOf(OfflinePayloadValidationError); }); it.each([ - [401, 'blocked_auth'], - [409, 'conflict'], - [422, 'rejected'], - [500, 'retry_wait'], - ] as const)('HTTP %sを%sへ分類して操作を保持する', async (status, state) => { + [401, 'blocked_auth', 'blocked_auth'], + [409, 'conflict', 'conflict'], + [422, 'rejected', 'rejected'], + [500, 'retry_wait', 'pending'], + ] as const)('HTTP %sを%sへ分類して操作を保持する', async (status, state, rowSyncState) => { execute.mockRejectedValueOnce({ status }); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: {} }, + { groupId: 10, aggregateType: 'documents', aggregateLocalId: '1', operation: 'documents.upsert', payload: {}, optimisticValue: {} }, { flush: false }, ); connected.set(true); await service.flush(); expect(service.pendingCommands()[0]).toMatchObject({ state, lastErrorCode: String(status) }); + expect(rows[0]?.syncState).toBe(rowSyncState); }); it('flush中の一括discard後に旧commandを送信・復活させない', async () => { let resolveExecute!: (value: { response: null; serverRevision?: number }) => void; execute.mockImplementationOnce(() => new Promise((resolve) => (resolveExecute = resolve))); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 1 }, + optimisticValue: { seq: 1 }, + }, { flush: false }, ); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 2 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 2 }, + optimisticValue: { seq: 2 }, + }, { flush: false }, ); connected.set(true); @@ -161,11 +417,25 @@ describe('OfflineSyncService', () => { let resolveExecute!: (value: { response: null; serverRevision?: number }) => void; execute.mockImplementationOnce(() => new Promise((resolve) => (resolveExecute = resolve))); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 1 }, + optimisticValue: { seq: 1 }, + }, { flush: false }, ); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 2 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 2 }, + optimisticValue: { seq: 2 }, + }, { flush: false }, ); connected.set(true); @@ -186,7 +456,14 @@ describe('OfflineSyncService', () => { let resolveFirst!: (value: { response: null }) => void; execute.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))); await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 1 }, + optimisticValue: { seq: 1 }, + }, { flush: false }, ); connected.set(true); @@ -204,6 +481,15 @@ describe('OfflineSyncService', () => { expect(service.pendingCount()).toBe(0); }); + it('background flush failureはErrorHandlerへ渡し、await flushはrejectする', async () => { + const pullError = new Error('pull failed'); + pull.mockRejectedValue(pullError); + connected.set(true); + await service.initialize(); + await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(pullError)); + await expect(service.flush()).rejects.toThrow('pull failed'); + }); + it('sending書き込み中の同一session resetでも完了後にpendingへ復旧する', async () => { let notifySendingStarted!: () => void; const sendingStarted = new Promise((resolve) => (notifySendingStarted = resolve)); @@ -215,7 +501,14 @@ describe('OfflineSyncService', () => { await sendingWrite; }; await service.enqueue( - { groupId: 10, aggregateType: 'documents', aggregateId: '1', operation: 'documents.upsert', payload: { seq: 1 } }, + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 1 }, + optimisticValue: { seq: 1 }, + }, { flush: false }, ); connected.set(true); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 62f5e5c..236c8ce 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -1,21 +1,28 @@ -import { computed, effect, inject, Injectable, signal } from '@angular/core'; -import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT } from './offline-command-executor'; +import { computed, effect, ErrorHandler, inject, Injectable, signal } from '@angular/core'; +import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT, type OfflineCommandResult } from './offline-command-executor'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import { OfflineNetworkService } from './offline-network.service'; -import type { OfflineCommand, OfflineScope } from './offline-repository'; +import { OfflineReplicaPullService } from './offline-replica-pull.service'; +import type { OfflineCommand, OfflineReplicaSyncState, OfflineReplicaRow, OfflineReplicaRowKey, OfflineScope } from './offline-repository'; import { OFFLINE_REPOSITORY } from './offline-repository'; +/** Aggregate synchronization state exposed to application UI. */ export type OfflineSyncState = 'idle' | 'pending' | 'syncing' | 'attention'; +/** Mutation and optimistic entity materialization appended atomically to the outbox. */ export interface EnqueueOfflineCommand { groupId: number; aggregateType: string; - aggregateId: string; + /** Stable UUID generated by the client before the entity is first written. */ + aggregateLocalId: string; operation: string; payload: T; + /** Full local entity value committed to the replica before the command is exposed to the UI. */ + optimisticValue: unknown; baseRevision?: string | number | null; } +/** Raised before persistence when an outbox payload is not losslessly JSON serializable. */ export class OfflinePayloadValidationError extends Error { constructor(message = 'Offline command payload must contain only JSON values') { super(message); @@ -26,6 +33,7 @@ export class OfflinePayloadValidationError extends Error { const MAX_PARALLEL_AGGREGATES = 3; const MAX_BACKOFF_MS = 60 * 60 * 1000; +/** Maintains the optimistic local replica and synchronizes its durable outbox. */ @Injectable({ providedIn: 'root' }) export class OfflineSyncService { readonly #network = inject(OfflineNetworkService); @@ -33,12 +41,15 @@ export class OfflineSyncService { readonly #executor = inject(OFFLINE_COMMAND_EXECUTOR); readonly #context = inject(OFFLINE_SYNC_CONTEXT); readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); + readonly #pull = inject(OfflineReplicaPullService); + readonly #errorHandler = inject(ErrorHandler); readonly #commands = signal([]); readonly #knownScopes = new Map(); #activeUserId: number | null = null; #flushPromise: Promise | null = null; #generation = 0; readonly #sendingTransitions = new Set>(); + #enqueueTail: Promise = Promise.resolve(); #retryTimer: ReturnType | null = null; #initialized = false; @@ -54,7 +65,7 @@ export class OfflineSyncService { constructor() { effect(() => { - if (this.#initialized && this.#network.connected()) void this.flush(); + if (this.#initialized && this.#network.connected()) this.#flushInBackground(); }); } @@ -70,7 +81,7 @@ export class OfflineSyncService { ); this.#initialized = true; await this.#refreshState(); - if (this.#network.connected()) void this.flush(); + if (this.#network.connected()) this.#flushInBackground(); } noteScope(scope: OfflineScope): void { @@ -85,8 +96,9 @@ export class OfflineSyncService { async refreshSession(): Promise { await this.initialize(); await this.#discoverScopes(); + await this.#restoreInterruptedCommands(); await this.#refreshState(); - if (this.#network.connected()) void this.flush(); + if (this.#network.connected()) this.#flushInBackground(); } async resetSession(): Promise { @@ -99,7 +111,16 @@ export class OfflineSyncService { this.#scheduleRetry(null); } - async enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean } = {}): Promise { + enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean } = {}): Promise { + const enqueue = this.#enqueueTail.then(() => this.#enqueue(request, options)); + this.#enqueueTail = enqueue.then( + () => undefined, + () => undefined, + ); + return enqueue; + } + + async #enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean }): Promise { await this.initialize(); const session = await this.#context.getSession(); if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); @@ -107,15 +128,18 @@ export class OfflineSyncService { this.#setActiveUser(userId); const scope = { userId, groupId: request.groupId }; this.noteScope(scope); + const aggregateLocalId = request.aggregateLocalId; const normalized = await this.#normalizeEnqueueRequest(scope, request); + const optimisticValue = request.optimisticValue; const commandId = crypto.randomUUID(); - await this.#repository.putCommand({ + const command: OfflineCommand = { ...scope, commandId, aggregateType: request.aggregateType, - aggregateId: request.aggregateId, + aggregateLocalId, operation: request.operation, payload: normalized.payload, + optimisticValue, payloadHash: await this.#payloadHash(normalized.payload), baseRevision: normalized.baseRevision, state: 'pending', @@ -123,9 +147,23 @@ export class OfflineSyncService { retryAt: null, createdAt: Date.now(), lastErrorCode: null, - }); + }; + const entityType = this.#entityType(command); + const existing = await this.#repository.getReplicaRow(scope, entityType, aggregateLocalId); + const optimisticRow: OfflineReplicaRow = { + ...scope, + sourceKey: entityType, + localId: aggregateLocalId, + serverId: existing?.serverId ?? null, + values: optimisticValue, + confirmedValues: existing?.confirmedValues ?? existing?.values ?? null, + serverRevision: existing?.serverRevision ?? normalized.baseRevision, + fetchedAt: Date.now(), + syncState: 'pending', + }; + await this.#repository.transactReplica({ putRows: [optimisticRow], putCommands: [command] }); await this.#refreshState(); - if (options.flush !== false && this.#network.connected()) void this.flush(); + if (options.flush !== false && this.#network.connected()) this.#flushInBackground(); return commandId; } @@ -135,11 +173,11 @@ export class OfflineSyncService { if (!command) return; this.#invalidateFlush(); await this.#waitForSendingTransitions(); - await this.#repository.removeCommand(command.commandId); + await this.#discardCommands([command]); await this.#hooks.onCommandRemoved?.(command); await this.#restoreInterruptedCommands(); await this.#refreshState(); - if (options.flush !== false && this.#network.connected()) void this.flush(); + if (options.flush !== false && this.#network.connected()) this.#flushInBackground(); } async discardAllPending(): Promise { @@ -147,15 +185,15 @@ export class OfflineSyncService { this.#invalidateFlush(); await this.#waitForSendingTransitions(); const commands = await this.#readKnownCommands(); - await Promise.all( - commands.map(async (command) => { - await this.#repository.removeCommand(command.commandId); - await this.#hooks.onCommandRemoved?.(command); - }), - ); + await this.#discardCommands(commands); + await Promise.all(commands.map((command) => this.#hooks.onCommandRemoved?.(command))); await this.#refreshState(); } + #flushInBackground(): void { + void this.flush().catch((error) => this.#errorHandler.handleError(error)); + } + flush(): Promise { if (this.#flushPromise) return this.#flushPromise; const generation = this.#generation; @@ -173,6 +211,10 @@ export class OfflineSyncService { return; } if (!(await this.#discoverScopes(generation))) return; + for (const scope of this.#knownScopes.values()) { + if (!this.#isCurrent(generation) || !this.#network.connected()) return; + await this.#pull.pull(scope); + } while (this.#network.connected() && this.#isCurrent(generation)) { const groups = this.#eligibleAggregateGroups(await this.#readKnownCommands()); if (!this.#isCurrent(generation)) return; @@ -193,7 +235,7 @@ export class OfflineSyncService { const now = Date.now(); const groups = new Map(); for (const command of commands) { - const key = `${command.userId}:${command.groupId}:${command.aggregateType}:${command.aggregateId}`; + const key = `${command.userId}:${command.groupId}:${command.aggregateType}:${command.aggregateLocalId}`; const group = groups.get(key) ?? []; group.push(command); groups.set(key, group); @@ -222,20 +264,19 @@ export class OfflineSyncService { await this.#refreshState(generation); if (!this.#isCurrent(generation)) return; try { - const result = await this.#executor.execute(sending); + const row = await this.#rowForCommand(sending); + if (!row) throw new Error(`Offline replica row not found: ${sending.aggregateType}/${sending.aggregateLocalId}`); + const result = await this.#executor.execute(sending, { localId: row.localId, serverId: row.serverId }); if (!this.#isCurrent(generation)) return; - const revision = result.serverRevision; - if (this.#hooks.shouldUpdateCache(sending, result)) { - await this.#updateCachedEntity(sending, result, generation); - if (!this.#isCurrent(generation)) return; - if (revision !== undefined) await this.#rebaseFollowingCommands(commands, index + 1, revision, generation); - } - if (!this.#isCurrent(generation)) return; - await this.#repository.removeCommand(sending.commandId); + await this.#completeCommand(commands, index, sending, result, generation); } catch (error) { if (!this.#isCurrent(generation)) return; const failed = this.#failedCommand(sending, error); - await this.#repository.putCommand(failed); + const row = await this.#rowForCommand(failed); + await this.#repository.transactReplica({ + putRows: row ? [{ ...row, syncState: this.#replicaState(failed.state) }] : undefined, + putCommands: [failed], + }); if (failed.state === 'retry_wait') this.#scheduleRetry(failed.retryAt); break; } @@ -248,41 +289,98 @@ export class OfflineSyncService { ): Promise<{ payload: T; baseRevision: string | number | null }> { let baseRevision = request.baseRevision ?? null; let payload = request.payload; - const entity = await this.#repository.getEntity(scope, this.#hooks.cacheEntityType(request), request.aggregateId); - if (entity?.serverRevision != null && entity.serverRevision !== baseRevision) { + const aggregateLocalId = request.aggregateLocalId; + const row = await this.#repository.getReplicaRow(scope, this.#entityType(request), aggregateLocalId); + if (row?.serverRevision != null && row.serverRevision !== baseRevision) { const rebased = this.#executor.withServerRevision( - { ...scope, ...request, payload, baseRevision } as OfflineCommand, - entity.serverRevision, + { ...scope, ...request, aggregateLocalId, payload, baseRevision } as OfflineCommand, + row.serverRevision, ); - baseRevision = entity.serverRevision; + baseRevision = row.serverRevision; payload = rebased.payload as T; } return { payload, baseRevision }; } - async #updateCachedEntity(command: OfflineCommand, result: { serverRevision?: string | number; response?: unknown }, generation: number) { - const scope = { userId: command.userId, groupId: command.groupId }; - const entityType = this.#hooks.cacheEntityType(command); - const cached = await this.#repository.getEntity(scope, entityType, command.aggregateId); + async #completeCommand( + commands: OfflineCommand[], + index: number, + command: OfflineCommand, + result: OfflineCommandResult, + generation: number, + ): Promise { + const revision = result.serverRevision; + const following = commands.slice(index + 1); + const rebased = revision === undefined ? following : following.map((item) => this.#executor.withServerRevision(item, revision)); + for (let offset = 0; offset < rebased.length; offset++) commands[index + 1 + offset] = rebased[offset]!; + const current = await this.#rowForCommand(command); if (!this.#isCurrent(generation)) return; - if (!cached) return; - const projected = this.#executor.projectEntity?.(command, cached, result); - if (!projected && result.serverRevision === undefined) return; - const updated = projected ?? { ...cached, fetchedAt: Date.now() }; - await this.#repository.putEntity({ - ...updated, - entityType, - serverRevision: result.serverRevision ?? updated.serverRevision, + const confirmedValues = result.confirmedValues ?? command.optimisticValue; + const row = current + ? { + ...current, + values: rebased.length > 0 ? rebased.at(-1)!.optimisticValue : confirmedValues, + confirmedValues, + serverId: result.serverId ?? current.serverId, + serverRevision: revision ?? current.serverRevision, + fetchedAt: Date.now(), + syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), + } + : undefined; + if (!this.#isCurrent(generation)) return; + await this.#repository.transactReplica({ + putRows: result.removeReplica || !row ? undefined : [row], + removeRows: result.removeReplica && current ? [current] : undefined, + putCommands: rebased, + removeCommandIds: [command.commandId], }); } - async #rebaseFollowingCommands(commands: OfflineCommand[], start: number, revision: string | number, generation: number) { - for (let index = start; index < commands.length; index++) { - if (!this.#isCurrent(generation)) return; - const rebased = this.#executor.withServerRevision(commands[index]!, revision); - commands[index] = rebased; - await this.#repository.replaceCommand(rebased); + async #rowForCommand(command: OfflineCommand): Promise { + const scope = { userId: command.userId, groupId: command.groupId }; + const entityType = this.#entityType(command); + return this.#repository.getReplicaRow(scope, entityType, command.aggregateLocalId); + } + + #replicaState(state: OfflineCommand['state']): OfflineReplicaSyncState { + if (state === 'blocked_auth' || state === 'rejected' || state === 'conflict') return state; + return 'pending'; + } + + #entityType(command: Pick): string { + return this.#hooks.entityType(command); + } + + async #discardCommands(discarded: readonly OfflineCommand[]): Promise { + const all = await this.#readKnownCommands(); + const discardedIds = new Set(discarded.map((command) => command.commandId)); + const affected = new Map(); + for (const command of discarded) affected.set(this.#aggregateKey(command), command); + const putRows: OfflineReplicaRow[] = []; + const removeRows: OfflineReplicaRowKey[] = []; + for (const [key, command] of affected) { + const row = await this.#rowForCommand(command); + if (!row) continue; + const remaining = all.filter((item) => !discardedIds.has(item.commandId) && this.#aggregateKey(item) === key); + if (remaining.length === 0 && row.confirmedValues === null && row.serverId === null) { + removeRows.push(row); + } else { + putRows.push({ + ...row, + values: remaining.length > 0 ? remaining.at(-1)!.optimisticValue : row.confirmedValues, + syncState: remaining.length > 0 ? 'pending' : 'confirmed', + }); + } } + await this.#repository.transactReplica({ + putRows, + removeRows, + removeCommandIds: [...discardedIds], + }); + } + + #aggregateKey(command: OfflineCommand): string { + return `${command.userId}:${command.groupId}:${command.aggregateType}:${command.aggregateLocalId}`; } #failedCommand(command: OfflineCommand, error: unknown): OfflineCommand { @@ -345,7 +443,7 @@ export class OfflineSyncService { this.#retryTimer = setTimeout( () => { this.#retryTimer = null; - if (this.#network.connected()) void this.flush(); + if (this.#network.connected()) this.#flushInBackground(); }, Math.max(0, retryAt - Date.now()), ); diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index f4e57dd..29c0a67 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -2,50 +2,27 @@ import { HttpContext, HttpErrorResponse, HttpRequest, HttpResponse } from '@angu import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { firstValueFrom, of, throwError } from 'rxjs'; -import { OFFLINE_ERROR_REPORTER } from './offline-error-reporter'; +import { OfflineNetworkService } from './offline-network.service'; import { offlineInterceptor } from './offline.interceptor'; import { OFFLINE_BYPASS, OFFLINE_RESPONSE_HEADER, type OfflineRequestPlan, OfflineRequestPolicyRegistry } from './offline-request-policy'; describe('offlineInterceptor', () => { let resolve: ReturnType) => OfflineRequestPlan | null>>; - let report: ReturnType; + let markApiSuccess: ReturnType; + let markApiFailure: ReturnType; beforeEach(() => { resolve = vi.fn(() => null); - report = vi.fn(); + markApiSuccess = vi.fn(); + markApiFailure = vi.fn(); TestBed.configureTestingModule({ providers: [ { provide: OfflineRequestPolicyRegistry, useValue: { resolve } }, - { provide: OFFLINE_ERROR_REPORTER, useValue: { report } }, + { provide: OfflineNetworkService, useValue: { markApiSuccess, markApiFailure } }, ], }); }); - it('保存失敗をreportしつつ成功responseは呼び出し元へ返す', async () => { - const error = new Error('storage unavailable'); - resolve.mockReturnValue({ kind: 'read', storeFresh: vi.fn(async () => Promise.reject(error)), readCached: vi.fn() }); - const request = new HttpRequest('GET', '/bootstrap?group=1'); - const response = new HttpResponse({ status: 200 }); - await expect(firstValueFrom(run(request, () => of(response)))).resolves.toBe(response); - expect(report).toHaveBeenCalledWith(error, { operation: 'storeFresh', method: 'GET', url: '/bootstrap?group=1' }); - }); - - it('reporter自身がthrowしても成功responseを置き換えない', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); - report.mockImplementationOnce(() => { - throw new Error('reporter unavailable'); - }); - resolve.mockReturnValue({ - kind: 'read', - storeFresh: vi.fn(async () => Promise.reject(new Error('storage unavailable'))), - readCached: vi.fn(), - }); - const response = new HttpResponse({ status: 200 }); - await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => of(response)))).resolves.toBe(response); - expect(consoleError).toHaveBeenCalledOnce(); - consoleError.mockRestore(); - }); - it('再送requestはpolicyを迂回してtransportへ渡す', async () => { const request = new HttpRequest('POST', '/resource', null, { context: new HttpContext().set(OFFLINE_BYPASS, true), @@ -56,42 +33,52 @@ describe('offlineInterceptor', () => { expect(resolve).not.toHaveBeenCalled(); }); - it('GET成功を保存してから返す', async () => { - const storeFresh = vi.fn(async (): Promise => undefined); - resolve.mockReturnValue({ kind: 'read', storeFresh, readCached: vi.fn() }); + it('GET成功はtransport responseをそのまま返しreachabilityを更新する', async () => { + resolve.mockReturnValue({ kind: 'read', readLocal: vi.fn() }); const response = new HttpResponse({ body: { userId: 1 }, status: 200 }); await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => of(response)))).resolves.toBe(response); - expect(storeFresh).toHaveBeenCalledWith(response); + expect(markApiSuccess).toHaveBeenCalledOnce(); }); - it('status=0だけcached responseへfallbackする', async () => { - const cached = new HttpResponse({ body: { dataFrom: 'api' }, status: 200 }); - const readCached = vi.fn(async () => cached); - resolve.mockReturnValue({ kind: 'read', storeFresh: vi.fn(), readCached }); + it('status=0だけlocal replica responseへfallbackする', async () => { + const local = new HttpResponse({ body: { dataFrom: 'api' }, status: 200 }); + const readLocal = vi.fn(async () => local); + resolve.mockReturnValue({ kind: 'read', readLocal }); const error = new HttpErrorResponse({ status: 0, error: new Error('offline') }); const response = await firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => error))); - expect(response instanceof HttpResponse && response.headers.get(OFFLINE_RESPONSE_HEADER)).toBe('cache'); - expect(readCached).toHaveBeenCalledOnce(); + expect(response instanceof HttpResponse && response.headers.get(OFFLINE_RESPONSE_HEADER)).toBe('local'); + expect(readLocal).toHaveBeenCalledOnce(); + expect(markApiFailure).toHaveBeenCalledOnce(); }); - it('403/500はcacheで隠さない', async () => { - const readCached = vi.fn(); - resolve.mockReturnValue({ kind: 'read', storeFresh: vi.fn(), readCached }); + it('403/500はlocal replicaで隠さない', async () => { + const readLocal = vi.fn(); + resolve.mockReturnValue({ kind: 'read', readLocal }); for (const status of [403, 500]) { const error = new HttpErrorResponse({ status }); await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => error)))).rejects.toBe(error); } - expect(readCached).not.toHaveBeenCalled(); + expect(readLocal).not.toHaveBeenCalled(); }); - it('mutationはtransportより先にoutboxへ保存しsynthetic responseを返す', async () => { - const enqueue = vi.fn(async () => ({ commandId: 'command-1' })); - resolve.mockReturnValue({ kind: 'mutation', enqueue }); - const next = vi.fn(); - const response = await firstValueFrom(run(new HttpRequest('POST', '/groups/1/documents', {}), next)); - expect(next).not.toHaveBeenCalled(); - expect(response).toEqual(expect.objectContaining({ status: 202, body: { queued: true, commandId: 'command-1' } })); - expect(response instanceof HttpResponse && response.headers.get(OFFLINE_RESPONSE_HEADER)).toBe('queued'); + it('POSTはpolicyを解決せずtransportへ渡しreachabilityを更新する', async () => { + resolve.mockReturnValue({ kind: 'read', readLocal: vi.fn() }); + const request = new HttpRequest('POST', '/groups/1/documents', {}); + const response = new HttpResponse({ status: 201 }); + const next = vi.fn(() => of(response)); + await expect(firstValueFrom(run(request, next))).resolves.toBe(response); + expect(resolve).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledOnce(); + expect(markApiSuccess).toHaveBeenCalledOnce(); + }); + + it('POST失敗もtransport経由でreachabilityを更新する', async () => { + const request = new HttpRequest('POST', '/groups/1/documents', {}); + const error = new HttpErrorResponse({ status: 0, error: new Error('offline') }); + const next = vi.fn(() => throwError(() => error)); + await expect(firstValueFrom(run(request, next))).rejects.toBe(error); + expect(resolve).not.toHaveBeenCalled(); + expect(markApiFailure).toHaveBeenCalledOnce(); }); }); diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts index c015f02..4b4b5d1 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -1,38 +1,40 @@ -import type { HttpEvent, HttpInterceptorFn, HttpRequest, HttpResponse } from '@angular/common/http'; -import { HttpHeaders, HttpResponse as AngularHttpResponse } from '@angular/common/http'; +import type { HttpEvent, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; +import { HttpResponse as AngularHttpResponse } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import type { Observable } from 'rxjs'; -import { catchError, concatMap, defer, from, map, of, throwError } from 'rxjs'; -import { OFFLINE_ERROR_REPORTER, type OfflineErrorReporter } from './offline-error-reporter'; -import { isOfflineFallbackError } from './offline-network.service'; -import { - OFFLINE_BYPASS, - OFFLINE_RESPONSE_HEADER, - type OfflineMutationRequestPlan, - OfflineRequestPolicyRegistry, -} from './offline-request-policy'; +import { catchError, concatMap, defer, from, of, tap, throwError } from 'rxjs'; +import { isOfflineFallbackError, OfflineNetworkService } from './offline-network.service'; +import { OFFLINE_BYPASS, OFFLINE_RESPONSE_HEADER, OfflineRequestPolicyRegistry } from './offline-request-policy'; +/** Applies product offline read policies while observing real API reachability. */ export const offlineInterceptor: HttpInterceptorFn = (request, next) => { - if (request.context.get(OFFLINE_BYPASS)) return next(request); + const network = inject(OfflineNetworkService); + const transport = () => observeTransport(next(request), network); + if (request.context.get(OFFLINE_BYPASS)) return transport(); + if (request.method !== 'GET') return transport(); const registry = inject(OfflineRequestPolicyRegistry); const fallback = inject(OfflineRequestFallbackService); - const errorReporter = inject(OFFLINE_ERROR_REPORTER); const plan = registry.resolve(request); - if (!plan) return next(request); - if (plan.kind === 'mutation') { - if (plan.enqueue) return enqueueMutation(plan.enqueue, request.urlWithParams); - if (!plan.storeFresh) return next(request); - return observeRemoteResponse(next(request), plan.storeFresh, errorReporter, request); - } - if (request.method !== 'GET') return next(request); - return observeRemoteResponse( - defer(() => next(request)), - plan.storeFresh, - errorReporter, - request, - ).pipe(catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error))); + if (!plan) return transport(); + return defer(transport).pipe( + catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error)), + ); }; +function observeTransport(source: Observable>, network: OfflineNetworkService): Observable> { + return source.pipe( + tap({ + next: (event) => { + if (event instanceof AngularHttpResponse) network.markApiSuccess(); + }, + error: (error: unknown) => { + if (isOfflineFallbackError(error)) network.markApiFailure(); + }, + }), + ); +} + +/** Resolves transport failures from the local replica without hiding HTTP errors. */ @Injectable({ providedIn: 'root' }) export class OfflineRequestFallbackService { readonly #registry = inject(OfflineRequestPolicyRegistry); @@ -45,56 +47,11 @@ export class OfflineRequestFallbackService { if (request.context.get(OFFLINE_BYPASS) || request.method !== 'GET' || !isOfflineFallbackError(error)) return null; const plan = resolvedPlan ?? this.#registry.resolve(request); if (!plan || plan.kind !== 'read') return null; - return defer(() => from(plan.readCached())).pipe( + return defer(() => from(plan.readLocal())).pipe( concatMap((cached) => - cached ? of(cached.clone({ headers: cached.headers.set(OFFLINE_RESPONSE_HEADER, 'cache') })) : throwError(() => error), + cached ? of(cached.clone({ headers: cached.headers.set(OFFLINE_RESPONSE_HEADER, 'local') })) : throwError(() => error), ), catchError(() => throwError(() => error)), ); } } - -function observeRemoteResponse( - source: Observable>, - storeFresh: (response: HttpResponse) => Promise, - errorReporter: OfflineErrorReporter, - request: HttpRequest, -): Observable> { - return source.pipe( - concatMap((event) => { - if (!(event instanceof AngularHttpResponse) || event.headers.has(OFFLINE_RESPONSE_HEADER)) return of(event); - return defer(() => from(storeFresh(event))).pipe( - map(() => event), - catchError((error: unknown) => { - return from( - reportPersistenceError(errorReporter, error, { - operation: 'storeFresh', - method: request.method, - url: request.urlWithParams, - }), - ).pipe(map(() => event)); - }), - ); - }), - ); -} - -function reportPersistenceError( - reporter: OfflineErrorReporter, - error: unknown, - context: Parameters[1], -): Promise { - return Promise.resolve() - .then(() => reporter.report(error, context)) - .catch((reporterError: unknown) => console.error('[offline] error reporter failed', context, reporterError)); -} - -function enqueueMutation(enqueue: NonNullable, url: string): Observable> { - return defer(() => from(enqueue())).pipe( - map(({ commandId, response }) => - (response ?? new AngularHttpResponse({ body: { queued: true, commandId }, status: 202, statusText: 'Accepted', url })).clone({ - headers: (response?.headers ?? new HttpHeaders()).set(OFFLINE_RESPONSE_HEADER, 'queued'), - }), - ), - ); -} diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index 5aca300..44945d1 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -1,25 +1,114 @@ import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { + defineOfflineReplicaSchema, + defineReplicaEntity, + serverId, + sha256OfflineReplicaSchema, + text, + type OfflineReplicaSchemaBundle, +} from './offline-replica-schema'; import { CAPAWESOME_SQLITE, type CapawesomeSqlitePlugin, SqliteOfflineRepository } from './sqlite-offline-repository'; +type TestItemSelect = { id: number; title: string }; +type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; + +const testItemEntity = defineReplicaEntity()({ + table: 'test_items', + sourceKey: 'test_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, +}); + +const testItemWithSubtitleEntity = defineReplicaEntity()({ + table: 'test_items', + sourceKey: 'test_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + subtitle: text(), + }, +}); + +const testGroupItemEntity = defineReplicaEntity<{ id: number; name: string }>()({ + table: 'test_group_items', + sourceKey: 'test_group_items', + scope: 'group', + fields: { + id: serverId(), + name: text(), + }, +}); + +const replicaSchemaV1 = defineOfflineReplicaSchema({ + version: 1, + entities: [testItemEntity], + migrations: [], +}); + +const replicaSchemaV1WithGroup = defineOfflineReplicaSchema({ + version: 1, + entities: [testItemEntity, testGroupItemEntity], + migrations: [], +}); + +const replicaSchemaV2 = defineOfflineReplicaSchema({ + version: 2, + entities: [testItemEntity], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE test_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0'], + migrateWebRow: (row) => row, + }, + ], +}); + +const replicaSchemaV3MissingMigration = defineOfflineReplicaSchema({ + version: 3, + entities: [testItemEntity], + migrations: [{ fromVersion: 1, statements: ['SELECT 1'], migrateWebRow: (row) => row }], +}); + +const replicaSchemaV1HashDrift = defineOfflineReplicaSchema({ + version: 1, + entities: [testItemWithSubtitleEntity], + migrations: [], +}); + describe('SqliteOfflineRepository Capawesome adapter', () => { - let nextLocalId: number; let plugin: { [K in keyof CapawesomeSqlitePlugin]: ReturnType; }; + let storedReplicaMetadata: { version: number; schemaHash: string } | null; + let replicaSchemaV1Hash: string; + + beforeAll(async () => { + replicaSchemaV1Hash = await sha256OfflineReplicaSchema(replicaSchemaV1); + }); beforeEach(() => { - nextLocalId = 2_000_000_000_000; + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: replicaSchemaV1Hash, + }; plugin = { open: vi.fn(async () => ({ databaseId: 'offline-db' })), - execute: vi.fn(async ({ statement }: { statement: string }) => { - if (statement.startsWith('UPDATE offline_metadata SET next_local_id')) nextLocalId += 1; - return {}; - }), + execute: vi.fn(async () => ({})), query: vi.fn(async ({ statement }: { statement: string }) => { + if (statement.includes('offline_replica_schema_metadata')) { + if (!storedReplicaMetadata) return { rows: [] }; + return { + columns: ['version', 'schema_hash'], + rows: [[storedReplicaMetadata.version, storedReplicaMetadata.schemaHash]], + }; + } if (statement.startsWith('PRAGMA table_info')) return { rows: [{ name: 'next_local_id' }] }; - if (statement.startsWith('SELECT next_local_id')) return { rows: [{ next_local_id: nextLocalId }] }; return { rows: [] }; }), beginTransaction: vi.fn(async () => undefined), @@ -42,28 +131,42 @@ describe('SqliteOfflineRepository Capawesome adapter', () => { expect(plugin.open).not.toHaveBeenCalled(); }); - it('group scopeのentity/query/outboxを単一transactionで削除する', async () => { + it('group scopeのoutboxを単一transactionで削除する', async () => { const repository = createRepository(); await repository.initialize(); await repository.clearGroup({ userId: 7, groupId: 8 }); const deletes = plugin.execute.mock.calls .map(([options]) => options as { statement: string; values?: unknown[] }) .filter(({ statement }) => statement.startsWith('DELETE FROM')); - expect(deletes).toHaveLength(3); - expect(deletes.every(({ values }) => JSON.stringify(values) === '[7,8]')).toBe(true); + expect(deletes).toHaveLength(2); + expect(deletes[0]?.values).toEqual([7, 8]); + expect(deletes[1]?.values).toEqual([7, 8]); expect(plugin.beginTransaction).toHaveBeenCalledOnce(); expect(plugin.commitTransaction).toHaveBeenCalledOnce(); expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); }); - it('並列採番を直列transactionにして永続counterを重複させない', async () => { + it('replicaとoutboxを単一transactionで更新する', async () => { const repository = createRepository(); await repository.initialize(); - await expect(Promise.all([repository.allocateLocalId(), repository.allocateLocalId()])).resolves.toEqual([ - 2_000_000_000_001, 2_000_000_000_002, - ]); - expect(plugin.beginTransaction).toHaveBeenCalledTimes(2); - expect(plugin.commitTransaction).toHaveBeenCalledTimes(2); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: null, + values: { id: 0, title: 'Local item' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }); + expect(plugin.beginTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).toHaveBeenCalledOnce(); }); it('transaction中の書き込み失敗をrollbackして握りつぶさない', async () => { @@ -76,12 +179,699 @@ describe('SqliteOfflineRepository Capawesome adapter', () => { expect(plugin.commitTransaction).not.toHaveBeenCalled(); }); - function createRepository(encryptionKey: () => Promise = async () => 'secret'): SqliteOfflineRepository { + describe('offline replica schema initialization', () => { + it('first install creates product tables and stores metadata in one transaction', async () => { + storedReplicaMetadata = null; + const repository = createRepository(); + await repository.initialize(); + + expect(plugin.beginTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).toHaveBeenCalledOnce(); + expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); + expect( + plugin.execute.mock.calls.some(([options]) => + (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), + ), + ).toBe(true); + expect( + plugin.execute.mock.calls.some(([options]) => { + const call = options as { statement: string; values?: unknown[] }; + return call.statement.includes('offline_replica_schema_metadata') && call.values?.[0] === 1; + }), + ).toBe(true); + }); + + it('unchanged schema is a no-op after metadata matches', async () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + }; + const repository = createRepository(); + await repository.initialize(); + + expect(plugin.beginTransaction).not.toHaveBeenCalled(); + expect( + plugin.execute.mock.calls.some(([options]) => + (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), + ), + ).toBe(false); + }); + + it('rejects hash drift without a version bump before product mutations', async () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + }; + const repository = createRepository(undefined, { replicaSchema: replicaSchemaV1HashDrift }); + + await expect(repository.initialize()).rejects.toThrow('Offline replica schema hash mismatch at version 1'); + expect( + plugin.execute.mock.calls.some(([options]) => + (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), + ), + ).toBe(false); + expect(plugin.beginTransaction).not.toHaveBeenCalled(); + }); + + it('migrates stored schema through a complete one-step chain and refreshes metadata', async () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + }; + const repository = createRepository(undefined, { replicaSchema: replicaSchemaV2 }); + await repository.initialize(); + + expect( + plugin.execute.mock.calls.some(([options]) => { + const call = options as { statement: string; values?: unknown[] }; + return call.statement === 'ALTER TABLE test_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0'; + }), + ).toBe(true); + expect( + plugin.execute.mock.calls.some(([options]) => { + const call = options as { statement: string; values?: unknown[] }; + return call.statement.includes('offline_replica_schema_metadata') && call.values?.[0] === 2; + }), + ).toBe(true); + expect(plugin.beginTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).toHaveBeenCalledOnce(); + }); + + it('rejects missing migrations before executing product DDL', async () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + }; + const repository = createRepository(undefined, { replicaSchema: replicaSchemaV3MissingMigration }); + + await expect(repository.initialize()).rejects.toThrow( + 'Missing offline replica schema migration from version 2 to 3.', + ); + expect( + plugin.execute.mock.calls.some(([options]) => + (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), + ), + ).toBe(false); + expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + }); + + it('rolls back replica schema migration failures without updating metadata', async () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + }; + const error = new Error('migration failed'); + plugin.execute.mockImplementation(async (options: { statement: string }) => { + if (options.statement === 'ALTER TABLE test_items ADD COLUMN legacy_flag INTEGER NOT NULL DEFAULT 0') { + throw error; + } + }); + const repository = createRepository(undefined, { replicaSchema: replicaSchemaV2 }); + + await expect(repository.initialize()).rejects.toBe(error); + expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + expect( + plugin.execute.mock.calls.some(([options]) => { + const call = options as { statement: string; values?: unknown[] }; + return call.statement.includes('offline_replica_schema_metadata') && call.values?.[0] === 2; + }), + ).toBe(false); + }); + }); + + function createRepository( + encryptionKey: () => Promise = async () => 'secret', + options: { replicaSchema?: OfflineReplicaSchemaBundle } = {}, + ): SqliteOfflineRepository { + TestBed.configureTestingModule({ + providers: [ + SqliteOfflineRepository, + { provide: CAPAWESOME_SQLITE, useValue: plugin }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { + databaseName: 'test-offline', + encryptionKey, + replicaSchema: options.replicaSchema ?? replicaSchemaV1, + }, + }, + ], + }); + return TestBed.inject(SqliteOfflineRepository); + } +}); + +describe('SqliteOfflineRepository replica rows', () => { + let plugin: { + [K in keyof CapawesomeSqlitePlugin]: ReturnType; + }; + let storedReplicaMetadata: { version: number; schemaHash: string } | null; + let replicaSchemaV1Hash: string; + let storedReplicaRows: Record; + let storedReplicaCursors: Record; + let transactionDepth: number; + + const testItemColumns = [ + 'local_id', + '_offline_user_id', + 'server_id', + '_offline_confirmed_json', + '_offline_server_revision_json', + '_offline_sync_state', + '_offline_fetched_at', + 'title', + ]; + + const testGroupItemColumns = [ + 'local_id', + '_offline_user_id', + '_offline_group_id', + 'server_id', + '_offline_confirmed_json', + '_offline_server_revision_json', + '_offline_sync_state', + '_offline_fetched_at', + 'name', + ]; + + function replicaRowMatrix(tableName: string, stored: { values: unknown[] }): unknown[] { + return tableName === 'test_group_items' ? [...stored.values] : [...stored.values]; + } + + function queryStoredReplicaRows(tableName: string, statement: string, values?: unknown[]) { + const columns = tableName === 'test_group_items' ? testGroupItemColumns : testItemColumns; + const entries = Object.entries(storedReplicaRows).filter(([, stored]) => stored.tableName === tableName); + if (statement.includes('server_id = ?')) { + const serverId = values?.[0]; + const userId = values?.[1]; + const groupId = tableName === 'test_group_items' ? values?.[2] : undefined; + const stored = Object.entries(storedReplicaRows).find(([, row]) => { + if (row.tableName !== tableName) return false; + const serverIdIndex = tableName === 'test_group_items' ? 3 : 2; + if (row.values[serverIdIndex] !== serverId || row.values[1] !== userId) return false; + if (groupId !== undefined && row.values[2] !== groupId) return false; + return true; + }); + if (!stored) return { rows: [] }; + return { columns, rows: [replicaRowMatrix(tableName, stored[1])] }; + } + if (statement.includes('local_id = ?')) { + const localId = values?.[0]; + const stored = typeof localId === 'string' ? storedReplicaRows[localId] : undefined; + if (!stored || stored.tableName !== tableName) return { rows: [] }; + return { columns, rows: [replicaRowMatrix(tableName, stored)] }; + } + const userId = values?.[0]; + const groupId = tableName === 'test_group_items' ? values?.[1] : undefined; + const rows = entries + .filter(([, stored]) => { + if (stored.values[1] !== userId) return false; + if (groupId !== undefined && stored.values[2] !== groupId) return false; + return true; + }) + .sort(([leftId], [rightId]) => leftId.localeCompare(rightId)) + .map(([, stored]) => replicaRowMatrix(tableName, stored)); + return { columns, rows }; + } + + beforeAll(async () => { + replicaSchemaV1Hash = await sha256OfflineReplicaSchema(replicaSchemaV1WithGroup); + }); + + beforeEach(() => { + storedReplicaRows = {}; + storedReplicaCursors = {}; + transactionDepth = 0; + storedReplicaMetadata = { + version: replicaSchemaV1WithGroup.version, + schemaHash: replicaSchemaV1Hash, + }; + plugin = { + open: vi.fn(async () => ({ databaseId: 'offline-db' })), + execute: vi.fn(async ({ statement, values }: { statement: string; values?: unknown[] }) => { + if (statement.startsWith('INSERT INTO offline_replica_cursors')) { + const userId = values?.[0]; + const groupId = values?.[1]; + const cursor = values?.[2]; + if (typeof userId === 'number' && typeof groupId === 'number' && typeof cursor === 'string') { + storedReplicaCursors[`${userId}:${groupId}`] = cursor; + } + } + if (statement.startsWith('DELETE FROM offline_replica_cursors')) { + const userId = values?.[0]; + const groupId = values?.[1]; + if (typeof userId === 'number' && groupId === undefined) { + for (const key of Object.keys(storedReplicaCursors)) { + if (key.startsWith(`${userId}:`)) delete storedReplicaCursors[key]; + } + } else if (typeof userId === 'number' && typeof groupId === 'number') { + delete storedReplicaCursors[`${userId}:${groupId}`]; + } + } + for (const tableName of ['test_items', 'test_group_items'] as const) { + if (statement.startsWith(`INSERT INTO ${tableName}`)) { + const localId = values?.[0]; + if (typeof localId === 'string') { + storedReplicaRows[localId] = { tableName, statement, values: [...(values ?? [])] }; + } + } + if (statement.startsWith(`DELETE FROM ${tableName}`)) { + const localId = values?.[0]; + if (typeof localId === 'string') delete storedReplicaRows[localId]; + } + } + }), + query: vi.fn(async ({ statement, values }: { statement: string; values?: unknown[] }) => { + if (statement.includes('offline_replica_schema_metadata')) { + if (!storedReplicaMetadata) return { rows: [] }; + return { + columns: ['version', 'schema_hash'], + rows: [[storedReplicaMetadata.version, storedReplicaMetadata.schemaHash]], + }; + } + if (statement.includes('offline_replica_cursors')) { + const userId = values?.[0]; + const groupId = values?.[1]; + const cursor = + typeof userId === 'number' && typeof groupId === 'number' + ? storedReplicaCursors[`${userId}:${groupId}`] + : undefined; + return cursor === undefined ? { rows: [] } : { columns: ['cursor'], rows: [[cursor]] }; + } + for (const tableName of ['test_items', 'test_group_items'] as const) { + if (statement.startsWith(`SELECT * FROM ${tableName}`)) { + return queryStoredReplicaRows(tableName, statement, values); + } + } + if (statement.startsWith('PRAGMA table_info')) return { rows: [{ name: 'next_local_id' }] }; + return { rows: [] }; + }), + beginTransaction: vi.fn(async () => { + transactionDepth += 1; + }), + commitTransaction: vi.fn(async () => { + transactionDepth -= 1; + }), + rollbackTransaction: vi.fn(async () => { + transactionDepth -= 1; + }), + }; + }); + + it('normalized column upsertとoutbox writeを単一transactionで実行する', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: null, + values: { id: 0, title: 'Local item' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + userId: 1, + groupId: 10, + commandId: 'create-row-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-bbbb', + operation: 'test_items.create', + payload: { title: 'Local item' }, + optimisticValue: { id: 0, title: 'Local item' }, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + + expect(plugin.beginTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).toHaveBeenCalledOnce(); + expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); + expect(transactionDepth).toBe(0); + const upsert = plugin.execute.mock.calls.find(([options]) => + (options as { statement: string }).statement.startsWith('INSERT INTO test_items'), + )?.[0] as { statement: string; values?: unknown[] }; + expect(upsert?.statement).toContain('title'); + expect(upsert?.statement).not.toContain('value_json'); + expect(upsert?.values).toEqual([ + '019d-bbbb', + 1, + null, + null, + null, + 'pending', + 1, + 'Local item', + ]); + expect( + plugin.execute.mock.calls.some(([options]) => + (options as { statement: string }).statement.startsWith('INSERT INTO offline_sync_commands'), + ), + ).toBe(true); + }); + + it('confirmed JSONはserverId列を投影したdomain valuesだけを永続化する', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-confirmed', + serverId: 42, + values: { id: 42, title: 'Optimistic' }, + confirmedValues: { id: 42, title: 'Confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }); + + const upsert = plugin.execute.mock.calls.find(([options]) => + (options as { statement: string }).statement.startsWith('INSERT INTO test_items'), + )?.[0] as { statement: string; values?: unknown[] }; + expect(upsert?.values?.[3]).toBe(JSON.stringify({ title: 'Confirmed' })); + await expect(repository.getReplicaRowByServerId({ userId: 1, groupId: 10 }, 'test_items', 42)).resolves.toMatchObject({ + values: { title: 'Optimistic' }, + confirmedValues: { title: 'Confirmed' }, + }); + }); + + it('server_idはnullから38142へ更新されlocal_idは不変', async () => { + const repository = createRepository(); + await repository.initialize(); + const scope = { userId: 1, groupId: 10 }; + const baseRow = { + ...scope, + sourceKey: 'test_items', + localId: '019d-bbbb', + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }; + await repository.transactReplica({ + putRows: [{ ...baseRow, serverId: null, values: { id: 0, title: 'Local item' } }], + }); + await repository.transactReplica({ + putRows: [{ ...baseRow, serverId: 38142, values: { id: 38142, title: 'Local item' }, syncState: 'confirmed' }], + }); + + const stored = storedReplicaRows['019d-bbbb']; + expect(stored?.values[0]).toBe('019d-bbbb'); + expect(stored?.values[2]).toBe(38142); + await expect(repository.getReplicaRow(scope, 'test_items', '019d-bbbb')).resolves.toMatchObject({ + localId: '019d-bbbb', + serverId: 38142, + values: { title: 'Local item' }, + }); + }); + + it('invalid/missing domain fieldsは拒否してrollbackする', async () => { + const repository = createRepository(); + await repository.initialize(); + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: null, + values: { id: 0 }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + }), + ).rejects.toThrow('Replica row is missing required source key "title".'); + expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).not.toHaveBeenCalled(); + expect(storedReplicaRows['019d-bbbb']).toBeUndefined(); + }); + + it('getReplicaRowはSQLite列をdecodeしてvaluesを返す', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: null, + values: { id: 0, title: 'Decoded title' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 99, + syncState: 'pending', + }, + ], + }); + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-bbbb')).resolves.toMatchObject({ + values: { title: 'Decoded title' }, + fetchedAt: 99, + syncState: 'pending', + }); + }); + + describe('getReplicaRows', () => { + const baseRow = { + sourceKey: 'test_items', + serverId: null, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }; + + it('local_id昇順で決定的に返す', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { ...baseRow, userId: 1, groupId: 10, localId: '019d-cccc', values: { id: 0, title: 'C' } }, + { ...baseRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 0, title: 'A' } }, + { ...baseRow, userId: 1, groupId: 10, localId: '019d-bbbb', values: { id: 0, title: 'B' } }, + ], + }); + + const rows = await repository.getReplicaRows({ userId: 1, groupId: 10 }, 'test_items'); + expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb', '019d-cccc']); + }); + + it('user-scoped sourceはgroupIdを無視して同一userの行を返す', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { ...baseRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 0, title: 'G10' } }, + { ...baseRow, userId: 1, groupId: 11, localId: '019d-bbbb', values: { id: 0, title: 'G11' } }, + { ...baseRow, userId: 2, groupId: 10, localId: '019d-cccc', values: { id: 0, title: 'Other user' } }, + ], + }); + + const rows = await repository.getReplicaRows({ userId: 1, groupId: 10 }, 'test_items'); + expect(rows.map((row) => row.localId)).toEqual(['019d-aaaa', '019d-bbbb']); + }); + + it('group-scoped sourceはgroupId一致の行だけを返す', async () => { + const repository = createRepository(); + await repository.initialize(); + const groupRow = { + sourceKey: 'test_group_items', + serverId: null, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending' as const, + }; + await repository.transactReplica({ + putRows: [ + { ...groupRow, userId: 1, groupId: 10, localId: '019d-aaaa', values: { id: 0, name: 'G10' } }, + { ...groupRow, userId: 1, groupId: 11, localId: '019d-bbbb', values: { id: 0, name: 'G11' } }, + ], + }); + + const rows = await repository.getReplicaRows({ userId: 1, groupId: 10 }, 'test_group_items'); + expect(rows).toHaveLength(1); + expect(rows[0]?.localId).toBe('019d-aaaa'); + }); + }); + + describe('replica pull persistence', () => { + const scope = { userId: 1, groupId: 10 }; + + it('getReplicaRowByServerIdはuser scopeでgroupIdを無視してlookupする', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: 42, + values: { id: 42, title: 'G10' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + { + userId: 1, + groupId: 11, + sourceKey: 'test_items', + localId: '019d-bbbb', + serverId: 43, + values: { id: 43, title: 'G11' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + localId: '019d-aaaa', + }); + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 43)).resolves.toMatchObject({ + localId: '019d-bbbb', + }); + expect(await repository.getReplicaRowByServerId(scope, 'test_items', 99)).toBeNull(); + }); + + it('getReplicaRowByServerIdはgroup scopeでgroupId一致のみ返す', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_group_items', + localId: '019d-aaaa', + serverId: 55, + values: { id: 55, name: 'G10' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + { + userId: 1, + groupId: 11, + sourceKey: 'test_group_items', + localId: '019d-bbbb', + serverId: 56, + values: { id: 56, name: 'G11' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + await expect(repository.getReplicaRowByServerId(scope, 'test_group_items', 55)).resolves.toMatchObject({ + localId: '019d-aaaa', + }); + expect(await repository.getReplicaRowByServerId(scope, 'test_group_items', 56)).toBeNull(); + }); + + it('putCursorsはrow更新と同一SQLite transactionで原子的に永続化する', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: 42, + values: { id: 42, title: 'Pulled' }, + confirmedValues: { id: 42, title: 'Pulled' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + putCursors: [{ userId: 1, groupId: 10, cursor: 'cursor-v1' }], + }); + + expect(plugin.beginTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).toHaveBeenCalledOnce(); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ userId: 1, groupId: 10, cursor: 'cursor-v1' }); + await expect(repository.getReplicaRowByServerId(scope, 'test_items', 42)).resolves.toMatchObject({ + values: { title: 'Pulled' }, + }); + }); + + it('row validation失敗時はcursorも永続化せずrollbackする', async () => { + const repository = createRepository(); + await repository.initialize(); + await expect( + repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-aaaa', + serverId: 42, + values: { id: 42 }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + putCursors: [{ userId: 1, groupId: 10, cursor: 'cursor-v1' }], + }), + ).rejects.toThrow('Replica row is missing required source key "title".'); + expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); + expect(plugin.commitTransaction).not.toHaveBeenCalled(); + expect(await repository.getReplicaCursor(scope)).toBeNull(); + expect(storedReplicaCursors['1:10']).toBeUndefined(); + }); + }); + + function createRepository(): SqliteOfflineRepository { TestBed.configureTestingModule({ providers: [ SqliteOfflineRepository, { provide: CAPAWESOME_SQLITE, useValue: plugin }, - { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', encryptionKey } }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { + databaseName: 'test-offline', + encryptionKey: async () => 'secret', + replicaSchema: replicaSchemaV1WithGroup, + }, + }, ], }); return TestBed.inject(SqliteOfflineRepository); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index aa7fd65..3b444b2 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -1,14 +1,25 @@ import { inject, Injectable, InjectionToken } from '@angular/core'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { + decodeOfflineReplicaValues, + encodeOfflineReplicaValues, + projectOfflineReplicaValues, + type OfflineReplicaEntitySchema, + type OfflineReplicaSchemaBundle, + sha256OfflineReplicaSchema, +} from './offline-replica-schema'; import { OFFLINE_SCHEMA_VERSION, type OfflineCommand, - type OfflineEntity, - type OfflineQuery, + type OfflineReplicaCursor, + type OfflineReplicaRow, + type OfflineReplicaRowKey, type OfflineRepository, + type OfflineReplicaTransaction, type OfflineScope, } from './offline-repository'; +/** Minimal Capawesome SQLite surface required by the offline repository. */ export interface CapawesomeSqlitePlugin { open(options: { path: string; encryptionKey: string; readOnly: false }): Promise<{ databaseId: string }>; execute(options: { databaseId: string; statement: string; values?: SQLiteValue[] }): Promise; @@ -21,6 +32,7 @@ export interface CapawesomeSqlitePlugin { rollbackTransaction(options: { databaseId: string }): Promise; } +/** DI token for the optional application-installed Capawesome SQLite plugin. */ export const CAPAWESOME_SQLITE = new InjectionToken('CAPAWESOME_SQLITE', { factory: () => null, }); @@ -32,39 +44,21 @@ const SCHEMA = [ `CREATE TABLE IF NOT EXISTS offline_metadata ( id INTEGER PRIMARY KEY CHECK (id = 1), schema_version INTEGER NOT NULL, - last_user_id INTEGER, - next_local_id INTEGER NOT NULL DEFAULT 2000000000000 + last_user_id INTEGER )`, - `CREATE TABLE IF NOT EXISTS offline_entities ( - user_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - entity_type TEXT NOT NULL, - entity_id TEXT NOT NULL, - value_json TEXT NOT NULL, - server_revision_json TEXT, - fetched_at INTEGER NOT NULL, - PRIMARY KEY (user_id, group_id, entity_type, entity_id) -)`, - `CREATE TABLE IF NOT EXISTS offline_queries ( - user_id INTEGER NOT NULL, - group_id INTEGER NOT NULL, - query_key TEXT NOT NULL, - value_json TEXT NOT NULL, - ordered_ids_json TEXT NOT NULL, - cursor TEXT, - etag TEXT, - fetched_at INTEGER NOT NULL, - is_complete INTEGER NOT NULL, - PRIMARY KEY (user_id, group_id, query_key) + `CREATE TABLE IF NOT EXISTS offline_session_manifests ( + user_id INTEGER PRIMARY KEY, + value_json TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS offline_sync_commands ( command_id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, aggregate_type TEXT NOT NULL, - aggregate_id TEXT NOT NULL, + aggregate_local_id TEXT NOT NULL, operation TEXT NOT NULL, payload_json TEXT NOT NULL, + optimistic_value_json TEXT NOT NULL, payload_hash TEXT NOT NULL, base_revision_json TEXT, state TEXT NOT NULL, @@ -75,6 +69,17 @@ const SCHEMA = [ )`, `CREATE INDEX IF NOT EXISTS offline_sync_commands_scope_created ON offline_sync_commands (user_id, group_id, created_at)`, + `CREATE TABLE IF NOT EXISTS offline_replica_schema_metadata ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL, + schema_hash TEXT NOT NULL CHECK (length(schema_hash) = 64) +)`, + `CREATE TABLE IF NOT EXISTS offline_replica_cursors ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + cursor TEXT NOT NULL, + PRIMARY KEY (user_id, group_id) +)`, ]; /** Native iOS/Android uses the encrypted Capawesome SQLite plugin supplied by the application. */ @@ -104,95 +109,91 @@ export class SqliteOfflineRepository implements OfflineRepository { ); } - async allocateLocalId(): Promise { - let allocated = 0; - await this.#transaction(async (database) => { - await this.#execute(database, 'UPDATE offline_metadata SET next_local_id = next_local_id + 1 WHERE id = 1'); - const rows = await this.#queryDatabase(database, 'SELECT next_local_id FROM offline_metadata WHERE id = 1'); - allocated = this.#number(rows[0]?.['next_local_id']); - }); - return allocated; + async getSessionManifest(userId: number): Promise { + const rows = await this.#query('SELECT value_json FROM offline_session_manifests WHERE user_id = ?', [userId]); + const row = rows[0]; + return row ? this.#parse(row['value_json']) : null; } - async getEntity(scope: OfflineScope, entityType: string, entityId: string): Promise | null> { - const rows = await this.#query( - `SELECT value_json, server_revision_json, fetched_at FROM offline_entities - WHERE user_id = ? AND group_id = ? AND entity_type = ? AND entity_id = ?`, - [scope.userId, scope.groupId, entityType, entityId], + async putSessionManifest(userId: number, value: T): Promise { + await this.#write( + `INSERT INTO offline_session_manifests (user_id, value_json) VALUES (?, ?) + ON CONFLICT(user_id) DO UPDATE SET value_json = excluded.value_json`, + [userId, JSON.stringify(value)], ); + } + + async getReplicaRow( + scope: OfflineScope, + sourceKey: string, + localId: string, + ): Promise | null> { + const schema = this.#resolveReplicaEntitySchema(sourceKey); + const predicates = ['local_id = ?', '_offline_user_id = ?']; + const values: SQLiteValue[] = [localId, scope.userId]; + if (schema.scope === 'group') { + predicates.push('_offline_group_id = ?'); + values.push(scope.groupId); + } + const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); const row = rows[0]; if (!row) return null; - return { - ...scope, - entityType, - entityId, - value: this.#parse(row['value_json']), - serverRevision: this.#parseNullable(row['server_revision_json']), - fetchedAt: this.#number(row['fetched_at']), - }; + return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, localId, row); } - async putEntity(entity: OfflineEntity): Promise { - await this.#write( - `INSERT INTO offline_entities - (user_id, group_id, entity_type, entity_id, value_json, server_revision_json, fetched_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, group_id, entity_type, entity_id) DO UPDATE SET - value_json = excluded.value_json, server_revision_json = excluded.server_revision_json, - fetched_at = excluded.fetched_at`, - [ - entity.userId, - entity.groupId, - entity.entityType, - entity.entityId, - JSON.stringify(entity.value), - this.#stringifyNullable(entity.serverRevision), - entity.fetchedAt, - ], + async getReplicaRows( + scope: OfflineScope, + sourceKey: string, + ): Promise[]> { + const schema = this.#resolveReplicaEntitySchema(sourceKey); + const predicates = ['_offline_user_id = ?']; + const values: SQLiteValue[] = [scope.userId]; + if (schema.scope === 'group') { + predicates.push('_offline_group_id = ?'); + values.push(scope.groupId); + } + const rows = await this.#query( + `SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')} ORDER BY local_id ASC`, + values, + ); + return rows.map((row) => + this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row), ); } - async getQuery(scope: OfflineScope, queryKey: string): Promise | null> { - const rows = await this.#query( - `SELECT value_json, ordered_ids_json, cursor, etag, fetched_at, is_complete FROM offline_queries - WHERE user_id = ? AND group_id = ? AND query_key = ?`, - [scope.userId, scope.groupId, queryKey], - ); + async getReplicaRowByServerId( + scope: OfflineScope, + sourceKey: string, + serverId: number, + ): Promise | null> { + const schema = this.#resolveReplicaEntitySchema(sourceKey); + if (!this.#schemaHasServerId(schema)) return null; + const predicates = ['server_id = ?', '_offline_user_id = ?']; + const values: SQLiteValue[] = [serverId, scope.userId]; + if (schema.scope === 'group') { + predicates.push('_offline_group_id = ?'); + values.push(scope.groupId); + } + const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); const row = rows[0]; if (!row) return null; - return { - ...scope, - queryKey, - value: this.#parse(row['value_json']), - orderedIds: this.#parse(row['ordered_ids_json']), - cursor: this.#stringOrNull(row['cursor']), - etag: this.#stringOrNull(row['etag']), - fetchedAt: this.#number(row['fetched_at']), - isComplete: this.#number(row['is_complete']) === 1, - }; + return this.#replicaRowFromSqliteRow( + schema, + scope, + sourceKey, + this.#string(row['local_id']), + row, + ); } - async putQuery(query: OfflineQuery): Promise { - await this.#write( - `INSERT INTO offline_queries - (user_id, group_id, query_key, value_json, ordered_ids_json, cursor, etag, fetched_at, is_complete) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(user_id, group_id, query_key) DO UPDATE SET - value_json = excluded.value_json, ordered_ids_json = excluded.ordered_ids_json, - cursor = excluded.cursor, etag = excluded.etag, fetched_at = excluded.fetched_at, - is_complete = excluded.is_complete`, - [ - query.userId, - query.groupId, - query.queryKey, - JSON.stringify(query.value), - JSON.stringify(query.orderedIds), - query.cursor, - query.etag, - query.fetchedAt, - query.isComplete ? 1 : 0, - ], + async getReplicaCursor(scope: OfflineScope): Promise { + const rows = await this.#query( + 'SELECT cursor FROM offline_replica_cursors WHERE user_id = ? AND group_id = ?', + [scope.userId, scope.groupId], ); + const row = rows[0]; + if (!row) return null; + return { ...scope, cursor: this.#string(row['cursor']) }; } async getCommands(scope: OfflineScope): Promise { @@ -204,34 +205,7 @@ export class SqliteOfflineRepository implements OfflineRepository { } async putCommand(command: OfflineCommand): Promise { - await this.#write( - `INSERT INTO offline_sync_commands - (command_id, user_id, group_id, aggregate_type, aggregate_id, operation, payload_json, payload_hash, - base_revision_json, state, attempts, retry_at, created_at, last_error_code) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(command_id) DO UPDATE SET - user_id = excluded.user_id, group_id = excluded.group_id, aggregate_type = excluded.aggregate_type, - aggregate_id = excluded.aggregate_id, operation = excluded.operation, payload_json = excluded.payload_json, - payload_hash = excluded.payload_hash, base_revision_json = excluded.base_revision_json, - state = excluded.state, attempts = excluded.attempts, retry_at = excluded.retry_at, - created_at = excluded.created_at, last_error_code = excluded.last_error_code`, - [ - command.commandId, - command.userId, - command.groupId, - command.aggregateType, - command.aggregateId, - command.operation, - JSON.stringify(command.payload), - command.payloadHash, - this.#stringifyNullable(command.baseRevision), - command.state, - command.attempts, - command.retryAt, - command.createdAt, - command.lastErrorCode, - ], - ); + await this.#queueWrite((databaseId) => this.#putCommand(databaseId, command)); } replaceCommand(command: OfflineCommand): Promise { @@ -244,9 +218,12 @@ export class SqliteOfflineRepository implements OfflineRepository { async clearUser(userId: number): Promise { await this.#transaction(async (database) => { - await this.#execute(database, 'DELETE FROM offline_entities WHERE user_id = ?', [userId]); - await this.#execute(database, 'DELETE FROM offline_queries WHERE user_id = ?', [userId]); + await this.#execute(database, 'DELETE FROM offline_session_manifests WHERE user_id = ?', [userId]); await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ?', [userId]); + await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ?', [userId]); + for (const entity of this.#options.replicaSchema.entities) { + await this.#execute(database, `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ?`, [userId]); + } await this.#execute(database, 'UPDATE offline_metadata SET last_user_id = NULL WHERE id = 1 AND last_user_id = ?', [userId]); }); } @@ -254,14 +231,37 @@ export class SqliteOfflineRepository implements OfflineRepository { async clearGroup(scope: OfflineScope): Promise { await this.#transaction(async (database) => { const values = [scope.userId, scope.groupId]; - await this.#execute(database, 'DELETE FROM offline_entities WHERE user_id = ? AND group_id = ?', values); - await this.#execute(database, 'DELETE FROM offline_queries WHERE user_id = ? AND group_id = ?', values); await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ? AND group_id = ?', values); + await this.#execute( + database, + 'DELETE FROM offline_replica_cursors WHERE user_id = ? AND group_id = ?', + values, + ); + for (const entity of this.#options.replicaSchema.entities) { + if (entity.scope !== 'group') continue; + await this.#execute( + database, + `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ? AND _offline_group_id = ?`, + values, + ); + } + }); + } + + async transactReplica(transaction: OfflineReplicaTransaction): Promise { + await this.#transaction(async (databaseId) => { + for (const row of transaction.putRows ?? []) await this.#putReplicaRow(databaseId, row); + for (const row of transaction.removeRows ?? []) await this.#removeReplicaRow(databaseId, row); + for (const command of transaction.putCommands ?? []) await this.#putCommand(databaseId, command); + for (const commandId of transaction.removeCommandIds ?? []) { + await this.#execute(databaseId, 'DELETE FROM offline_sync_commands WHERE command_id = ?', [commandId]); + } + for (const cursor of transaction.putCursors ?? []) await this.#putReplicaCursor(databaseId, cursor); }); } async #open(): Promise { - if (!this.#sqlite) throw new Error('Native offline storage requires the Capawesome Sqlite plugin'); + if (!this.#sqlite) throw new Error('Native offline storage requires the Capawesome SQLite plugin'); const encryptionKey = await this.#options.encryptionKey?.(); if (!encryptionKey) throw new Error('Native offline storage requires a non-empty encryption key'); const { databaseId } = await this.#sqlite.open({ @@ -271,16 +271,100 @@ export class SqliteOfflineRepository implements OfflineRepository { }); this.#databaseId = databaseId; for (const statement of SCHEMA) await this.#execute(databaseId, statement); - const metadataColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_metadata)'); - if (!metadataColumns.some((column) => column['name'] === 'next_local_id')) { - await this.#execute(databaseId, 'ALTER TABLE offline_metadata ADD COLUMN next_local_id INTEGER NOT NULL DEFAULT 2000000000000'); + let commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); + if (commandColumns.some((column) => column['name'] === 'aggregate_id')) { + await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands RENAME COLUMN aggregate_id TO aggregate_local_id'); + commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); + } + if (!commandColumns.some((column) => column['name'] === 'optimistic_value_json')) { + await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_value_json TEXT'); + await this.#execute(databaseId, 'UPDATE offline_sync_commands SET optimistic_value_json = payload_json'); } await this.#execute( databaseId, - `INSERT INTO offline_metadata (id, schema_version, last_user_id, next_local_id) VALUES (1, ?, NULL, 2000000000000) + `INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL) ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version`, [OFFLINE_SCHEMA_VERSION], ); + await this.#initializeReplicaSchema(databaseId); + } + + async #initializeReplicaSchema(databaseId: string): Promise { + const bundle = this.#options.replicaSchema; + const targetVersion = bundle.version; + const targetHash = await sha256OfflineReplicaSchema(bundle); + const rows = await this.#queryDatabase( + databaseId, + 'SELECT version, schema_hash FROM offline_replica_schema_metadata WHERE id = 1', + ); + const storedVersion = rows[0] ? this.#number(rows[0]['version']) : null; + const storedHash = rows[0] ? this.#string(rows[0]['schema_hash']) : null; + + if (storedVersion === null) { + await this.#nativeTransaction(databaseId, async () => { + await this.#executeReplicaCreateStatements(databaseId, bundle); + await this.#upsertReplicaSchemaMetadata(databaseId, targetVersion, targetHash); + }); + return; + } + + if (storedVersion === targetVersion && storedHash === targetHash) { + return; + } + + if (storedVersion === targetVersion) { + throw new Error( + `Offline replica schema hash mismatch at version ${targetVersion}. Reinstall the application or bump replicaSchema.version after intentional schema changes.`, + ); + } + + if (storedVersion > targetVersion) { + throw new Error( + `Offline replica schema version ${storedVersion} is newer than application version ${targetVersion}. Upgrade the application before opening this database.`, + ); + } + + await this.#nativeTransaction(databaseId, async () => { + for (let version = storedVersion; version < targetVersion; version++) { + const migration = bundle.migrations.find((candidate) => candidate.fromVersion === version); + if (!migration) { + throw new Error(`Missing offline replica schema migration from version ${version} to ${version + 1}.`); + } + for (const statement of migration.statements) { + await this.#execute(databaseId, statement); + } + } + await this.#executeReplicaCreateStatements(databaseId, bundle); + await this.#upsertReplicaSchemaMetadata(databaseId, targetVersion, targetHash); + }); + } + + async #executeReplicaCreateStatements(databaseId: string, bundle: OfflineReplicaSchemaBundle): Promise { + for (const entity of bundle.entities) { + for (const statement of entity.createTableSql) { + await this.#execute(databaseId, statement); + } + } + } + + async #upsertReplicaSchemaMetadata(databaseId: string, version: number, schemaHash: string): Promise { + await this.#execute( + databaseId, + `INSERT INTO offline_replica_schema_metadata (id, version, schema_hash) VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET version = excluded.version, schema_hash = excluded.schema_hash`, + [version, schemaHash], + ); + } + + async #nativeTransaction(databaseId: string, run: () => Promise): Promise { + await this.#sqlite!.beginTransaction({ databaseId }); + try { + await run(); + await this.#sqlite!.commitTransaction({ databaseId }); + } catch (error) { + await this.#sqlite!.rollbackTransaction({ databaseId }); + throw error; + } } async #databaseConnection(): Promise { @@ -294,9 +378,11 @@ export class SqliteOfflineRepository implements OfflineRepository { } #write(statement: string, values: SQLiteValue[]): Promise { - const write = this.#writes.then(async (): Promise => { - await this.#execute(await this.#databaseConnection(), statement, values); - }); + return this.#queueWrite((databaseId) => this.#execute(databaseId, statement, values)); + } + + #queueWrite(run: (databaseId: string) => Promise): Promise { + const write = this.#writes.then(async (): Promise => run(await this.#databaseConnection())); this.#writes = write.catch((): void => undefined); return write; } @@ -323,9 +409,10 @@ export class SqliteOfflineRepository implements OfflineRepository { userId: this.#number(row['user_id']), groupId: this.#number(row['group_id']), aggregateType: this.#string(row['aggregate_type']), - aggregateId: this.#string(row['aggregate_id']), + aggregateLocalId: this.#string(row['aggregate_local_id']), operation: this.#string(row['operation']), payload: this.#parse(row['payload_json']), + optimisticValue: this.#parse(row['optimistic_value_json']), payloadHash: this.#string(row['payload_hash']), baseRevision: this.#parseNullable(row['base_revision_json']), state: this.#string(row['state']) as OfflineCommand['state'], @@ -336,6 +423,151 @@ export class SqliteOfflineRepository implements OfflineRepository { }; } + #putCommand(databaseId: string, command: OfflineCommand): Promise { + return this.#execute( + databaseId, + `INSERT INTO offline_sync_commands + (command_id, user_id, group_id, aggregate_type, aggregate_local_id, operation, payload_json, optimistic_value_json, + payload_hash, base_revision_json, state, attempts, retry_at, created_at, last_error_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(command_id) DO UPDATE SET + user_id = excluded.user_id, group_id = excluded.group_id, aggregate_type = excluded.aggregate_type, + aggregate_local_id = excluded.aggregate_local_id, operation = excluded.operation, payload_json = excluded.payload_json, + optimistic_value_json = excluded.optimistic_value_json, payload_hash = excluded.payload_hash, + base_revision_json = excluded.base_revision_json, state = excluded.state, attempts = excluded.attempts, + retry_at = excluded.retry_at, created_at = excluded.created_at, last_error_code = excluded.last_error_code`, + [ + command.commandId, + command.userId, + command.groupId, + command.aggregateType, + command.aggregateLocalId, + command.operation, + JSON.stringify(command.payload), + JSON.stringify(command.optimisticValue), + command.payloadHash, + this.#stringifyNullable(command.baseRevision), + command.state, + command.attempts, + command.retryAt, + command.createdAt, + command.lastErrorCode, + ], + ); + } + + #putReplicaRow(databaseId: string, row: OfflineReplicaRow): Promise { + const schema = this.#resolveReplicaEntitySchema(row.sourceKey); + const encoded = encodeOfflineReplicaValues(schema, row.values); + const confirmedValues = + row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues); + const { sql, domainColumns } = this.#buildReplicaUpsertStatement(schema); + const values: SQLiteValue[] = [ + row.localId, + row.userId, + ...(schema.scope === 'group' ? [row.groupId] : []), + ...(this.#schemaHasServerId(schema) ? [row.serverId] : []), + confirmedValues === null ? null : JSON.stringify(confirmedValues), + row.serverRevision == null ? null : JSON.stringify(row.serverRevision), + row.syncState, + row.fetchedAt, + ...domainColumns.map((column) => encoded[column] ?? null), + ]; + return this.#execute(databaseId, sql, values); + } + + #removeReplicaRow(databaseId: string, key: OfflineReplicaRowKey): Promise { + const schema = this.#resolveReplicaEntitySchema(key.sourceKey); + const predicates = ['local_id = ?', '_offline_user_id = ?']; + const values: SQLiteValue[] = [key.localId, key.userId]; + if (schema.scope === 'group') { + predicates.push('_offline_group_id = ?'); + values.push(key.groupId); + } + return this.#execute(databaseId, `DELETE FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); + } + + #putReplicaCursor(databaseId: string, cursor: OfflineReplicaCursor): Promise { + return this.#execute( + databaseId, + `INSERT INTO offline_replica_cursors (user_id, group_id, cursor) VALUES (?, ?, ?) + ON CONFLICT(user_id, group_id) DO UPDATE SET cursor = excluded.cursor`, + [cursor.userId, cursor.groupId, cursor.cursor], + ); + } + + #buildReplicaUpsertStatement(schema: OfflineReplicaEntitySchema>): { + sql: string; + domainColumns: readonly string[]; + } { + const insertColumns = ['local_id', '_offline_user_id']; + const updateSets = ['_offline_user_id = excluded._offline_user_id']; + if (schema.scope === 'group') { + insertColumns.push('_offline_group_id'); + updateSets.push('_offline_group_id = excluded._offline_group_id'); + } + if (this.#schemaHasServerId(schema)) { + insertColumns.push('server_id'); + updateSets.push('server_id = excluded.server_id'); + } + insertColumns.push( + '_offline_confirmed_json', + '_offline_server_revision_json', + '_offline_sync_state', + '_offline_fetched_at', + ); + updateSets.push( + '_offline_confirmed_json = excluded._offline_confirmed_json', + '_offline_server_revision_json = excluded._offline_server_revision_json', + '_offline_sync_state = excluded._offline_sync_state', + '_offline_fetched_at = excluded._offline_fetched_at', + ); + const domainColumns: string[] = []; + for (const field of schema.fields) { + if (field.policy !== 'column' || field.sqliteColumnName === null) continue; + insertColumns.push(field.sqliteColumnName); + updateSets.push(`${field.sqliteColumnName} = excluded.${field.sqliteColumnName}`); + domainColumns.push(field.sqliteColumnName); + } + const placeholders = insertColumns.map(() => '?').join(', '); + return { + sql: `INSERT INTO ${schema.tableName} (${insertColumns.join(', ')}) + VALUES (${placeholders}) + ON CONFLICT(local_id) DO UPDATE SET ${updateSets.join(', ')}`, + domainColumns, + }; + } + + #replicaRowFromSqliteRow( + schema: OfflineReplicaEntitySchema>, + scope: OfflineScope, + sourceKey: string, + localId: string, + row: SQLiteRow, + ): OfflineReplicaRow { + return { + ...scope, + sourceKey, + localId, + serverId: this.#schemaHasServerId(schema) ? this.#numberOrNull(row['server_id']) : null, + values: decodeOfflineReplicaValues(schema, row) as TValues, + confirmedValues: this.#parseNullable(row['_offline_confirmed_json']), + serverRevision: this.#parseNullable(row['_offline_server_revision_json']), + fetchedAt: this.#number(row['_offline_fetched_at']), + syncState: this.#string(row['_offline_sync_state']) as OfflineReplicaRow['syncState'], + }; + } + + #resolveReplicaEntitySchema(sourceKey: string): OfflineReplicaEntitySchema> { + const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); + if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); + return schema; + } + + #schemaHasServerId(schema: OfflineReplicaEntitySchema>): boolean { + return schema.fields.some((field) => field.policy === 'serverId'); + } + async #execute(databaseId: string, statement: string, values: SQLiteValue[] = []): Promise { await this.#sqlite!.execute({ databaseId, statement, values }); } diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 01cc09d..1a3ea42 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -1,8 +1,10 @@ -/** Standard scoped cache and outbox runtime for offline-capable Ionic applications. */ +/** Standard scoped local replica and outbox runtime for offline-capable Ionic applications. */ +export * from './lib/offline-replica-schema'; +export * from './lib/offline-replica-puller'; +export * from './lib/offline-replica-pull.service'; export * from './lib/offline-command-executor'; export * from './lib/offline-command-hooks'; export * from './lib/offline-coordinator.service'; -export * from './lib/offline-error-reporter'; export * from './lib/offline-kit-options'; export * from './lib/offline-network.service'; export * from './lib/offline-provider'; From cabb5a420eecfc6d81af6fd8960dd74a113959a1 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 22:25:42 +0900 Subject: [PATCH 6/7] fix: harden offline replica recovery --- angular.json | 6 + projects/kit/README.md | 9 +- .../src/lib/offline-command-executor.ts | 1 + .../lib/offline-replica-pull.service.spec.ts | 403 ++++++++++++++++-- .../src/lib/offline-replica-pull.service.ts | 145 +++++-- .../offline/src/lib/offline-replica-puller.ts | 2 + .../src/lib/offline-replica-schema.spec.ts | 87 ++-- .../offline/src/lib/offline-replica-schema.ts | 55 +-- .../src/lib/offline-repository.spec.ts | 335 ++++++++++++++- .../kit/offline/src/lib/offline-repository.ts | 135 +++--- .../src/lib/offline-sync.service.spec.ts | 160 ++++++- .../offline/src/lib/offline-sync.service.ts | 67 ++- .../offline/src/lib/offline.interceptor.ts | 12 +- .../src/lib/sqlite-offline-repository.spec.ts | 109 ++++- .../src/lib/sqlite-offline-repository.ts | 62 +-- .../kit-maintenance.controller.spec.ts | 10 +- .../overlay/kit-overlay.controller.spec.ts | 2 +- .../src/lib/overlay/kit-overlay.controller.ts | 2 - projects/kit/src/lib/utils/array.spec.ts | 1 + 19 files changed, 1259 insertions(+), 344 deletions(-) diff --git a/angular.json b/angular.json index 0db4f61..bff15f1 100644 --- a/angular.json +++ b/angular.json @@ -272,6 +272,12 @@ "watch": false } } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": ["projects/kit/**/*.ts", "projects/kit/**/*.html"] + } } } } diff --git a/projects/kit/README.md b/projects/kit/README.md index 60a04ac..41268af 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -377,6 +377,12 @@ mismatch, malformed row, or non-advancing cursor rejects synchronization without revision changed while a local command is pending, the optimistic row remains visible and both row and command move to `conflict`; the new server value is retained as the confirmed baseline. +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. + 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 @@ -435,7 +441,8 @@ provideOffline({ ``` The schema definition must map every `ItemSelect` key exactly once as a SQLite column, `serverId()`, or -`ignored(reason)`. Nullable Hono columns require `nullable(...)`; non-null columns reject it. Therefore adding, +`ignored(reason)`, with exactly one `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. diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts index 62717b8..2683617 100644 --- a/projects/kit/offline/src/lib/offline-command-executor.ts +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -22,6 +22,7 @@ export interface OfflineCommandTarget { /** 不透明なoperationを製品APIへ送信し、local replicaへ投影するadapter。 */ /** Product adapter that sends commands and projects acknowledgements into entities. */ export interface OfflineCommandExecutor { + /** Sends the command using `command.commandId` as its durable server-side idempotency key. */ execute(command: OfflineCommand, target: OfflineCommandTarget): Promise; withServerRevision(command: OfflineCommand, revision: string | number): OfflineCommand; } diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts index 3250b80..b6d3175 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts @@ -1,6 +1,8 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ import { TestBed } from '@angular/core/testing'; import { KitStorageService } from '@rdlabo/ionic-angular-kit'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OFFLINE_COMMAND_EXECUTOR } from './offline-command-executor'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { @@ -10,13 +12,7 @@ import { type OfflineReplicaPullRequest, } from './offline-replica-puller'; import { OfflineReplicaPullService } from './offline-replica-pull.service'; -import { - defineOfflineReplicaSchema, - defineReplicaEntity, - serverId, - sha256OfflineReplicaSchema, - text, -} from './offline-replica-schema'; +import { defineOfflineReplicaSchema, defineReplicaEntity, serverId, sha256OfflineReplicaSchema, text } from './offline-replica-schema'; import { IonicOfflineRepository, OFFLINE_REPOSITORY, @@ -68,12 +64,13 @@ class MemoryStorage { function itemChange( serverIdValue: number, title: string, - options: Partial> = {}, + options: Partial> = {}, ): OfflineReplicaChange { return { sourceKey: 'test_items', serverId: serverIdValue, serverRevision: options.serverRevision ?? 1, + acknowledgedCommandIds: options.acknowledgedCommandIds ?? [], values: options.deleted ? null : (options.values ?? { id: serverIdValue, title }), deleted: options.deleted ?? false, }; @@ -139,6 +136,16 @@ describe('OfflineReplicaPullService', () => { provide: OFFLINE_COMMAND_HOOKS, useValue: { entityType: (command: Pick) => command.aggregateType }, }, + { + provide: OFFLINE_COMMAND_EXECUTOR, + useValue: { + execute: vi.fn(), + withServerRevision: (command: OfflineCommand, revision: string | number) => ({ + ...command, + baseRevision: revision, + }), + }, + }, ], }); repository = TestBed.inject(OFFLINE_REPOSITORY); @@ -240,9 +247,7 @@ describe('OfflineReplicaPullService', () => { }, ], }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Updated', { serverRevision: 2 })], { nextCursor: 'cursor-v1' }), - ); + pull.mockResolvedValueOnce(page([itemChange(42, 'Updated', { serverRevision: 2 })], { nextCursor: 'cursor-v1' })); await service.pull(scope); @@ -272,9 +277,7 @@ describe('OfflineReplicaPullService', () => { }, ], }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Gone', { deleted: true, serverRevision: 2 })], { nextCursor: 'cursor-v1' }), - ); + pull.mockResolvedValueOnce(page([itemChange(42, 'Gone', { deleted: true, serverRevision: 2 })], { nextCursor: 'cursor-v1' })); await service.pull(scope); @@ -306,10 +309,7 @@ describe('OfflineReplicaPullService', () => { it('invalid valuesはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( - () => - pull.mockResolvedValueOnce( - page([itemChange(42, 'Broken', { values: { id: 42 } })], { nextCursor: 'cursor-v1' }), - ), + () => pull.mockResolvedValueOnce(page([itemChange(42, 'Broken', { values: { id: 42 } })], { nextCursor: 'cursor-v1' })), 'Replica row is missing required source key "title".', ); }); @@ -350,20 +350,14 @@ describe('OfflineReplicaPullService', () => { it('non-positive serverIdはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( - () => - pull.mockResolvedValueOnce( - page([{ ...itemChange(42, 'Created'), serverId: 0 }], { nextCursor: 'cursor-v1' }), - ), + () => pull.mockResolvedValueOnce(page([{ ...itemChange(42, 'Created'), serverId: 0 }], { nextCursor: 'cursor-v1' })), 'Offline replica pull page changes[0].serverId must be a positive integer.', ); }); it('non-integer serverIdはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( - () => - pull.mockResolvedValueOnce( - page([{ ...itemChange(42, 'Created'), serverId: 42.5 }], { nextCursor: 'cursor-v1' }), - ), + () => pull.mockResolvedValueOnce(page([{ ...itemChange(42, 'Created'), serverId: 42.5 }], { nextCursor: 'cursor-v1' })), 'Offline replica pull page changes[0].serverId must be a positive integer.', ); }); @@ -384,10 +378,9 @@ describe('OfflineReplicaPullService', () => { await expectPullRejectsPreservingCursor( () => pull.mockResolvedValueOnce( - page( - [{ ...itemChange(42, 'Gone', { deleted: true, serverRevision: 2 }), values: { id: 42, title: 'Gone' } }], - { nextCursor: 'cursor-v1' }, - ), + page([{ ...itemChange(42, 'Gone', { deleted: true, serverRevision: 2 }), values: { id: 42, title: 'Gone' } }], { + nextCursor: 'cursor-v1', + }), ), 'Offline replica pull page changes[0] with deleted=true must have null values.', ); @@ -398,7 +391,16 @@ describe('OfflineReplicaPullService', () => { await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); pull.mockResolvedValueOnce( page( - [{ sourceKey: 'unknown_items', serverId: 42, serverRevision: 1, values: { id: 42, title: 'X' }, deleted: false }], + [ + { + sourceKey: 'unknown_items', + serverId: 42, + serverRevision: 1, + acknowledgedCommandIds: [], + values: { id: 42, title: 'X' }, + deleted: false, + }, + ], { nextCursor: 'cursor-v1' }, ), ); @@ -410,7 +412,7 @@ describe('OfflineReplicaPullService', () => { it('missing valuesはrejectしcursorを進めない', async () => { await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); pull.mockResolvedValueOnce( - page([{ sourceKey: 'test_items', serverId: 42, serverRevision: 1, values: null, deleted: false }], { + page([{ sourceKey: 'test_items', serverId: 42, serverRevision: 1, acknowledgedCommandIds: [], values: null, deleted: false }], { nextCursor: 'cursor-v1', }), ); @@ -421,9 +423,7 @@ describe('OfflineReplicaPullService', () => { it('schema mismatchはrejectしcursorを進めない', async () => { await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1', schemaVersion: 99, schemaHash: 'deadbeef' }), - ); + pull.mockResolvedValueOnce(page([itemChange(42, 'Created')], { nextCursor: 'cursor-v1', schemaVersion: 99, schemaHash: 'deadbeef' })); await expect(service.pull(scope)).rejects.toThrow('Offline replica schema mismatch'); await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); @@ -473,9 +473,7 @@ describe('OfflineReplicaPullService', () => { }, ], }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Server truth', { serverRevision: 2 })], { nextCursor: 'cursor-v1' }), - ); + pull.mockResolvedValueOnce(page([itemChange(42, 'Server truth', { serverRevision: 2 })], { nextCursor: 'cursor-v1' })); await service.pull(scope); @@ -485,9 +483,7 @@ describe('OfflineReplicaPullService', () => { serverRevision: 2, syncState: 'pending', }); - await expect(repository.getCommands(scope)).resolves.toEqual([ - expect.objectContaining({ commandId: 'cmd-pending', state: 'pending' }), - ]); + await expect(repository.getCommands(scope)).resolves.toEqual([expect.objectContaining({ commandId: 'cmd-pending', state: 'pending' })]); }); it('revision conflictはreplicaとcommandをconflictへ遷移する', async () => { @@ -524,9 +520,7 @@ describe('OfflineReplicaPullService', () => { }, ], }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Remote truth', { serverRevision: 9 })], { nextCursor: 'cursor-v1' }), - ); + pull.mockResolvedValueOnce(page([itemChange(42, 'Remote truth', { serverRevision: 9 })], { nextCursor: 'cursor-v1' })); await service.pull(scope); @@ -579,9 +573,7 @@ describe('OfflineReplicaPullService', () => { }, ], }); - pull.mockResolvedValueOnce( - page([itemChange(42, 'Confirmed', { deleted: true, serverRevision: 2 })], { nextCursor: 'cursor-v1' }), - ); + pull.mockResolvedValueOnce(page([itemChange(42, 'Confirmed', { deleted: true, serverRevision: 2 })], { nextCursor: 'cursor-v1' })); await service.pull(scope); @@ -600,4 +592,321 @@ describe('OfflineReplicaPullService', () => { ]); expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).not.toBeNull(); }); + + describe('lost ACK correlation', () => { + async function seedPendingCreate(localId = '019d-create'): Promise { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId, + serverId: null, + values: { id: 0, title: 'Draft create' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-create', + aggregateType: 'test_items', + aggregateLocalId: localId, + operation: 'test_items.create', + payload: { title: 'Draft create' }, + optimisticValue: { id: 0, title: 'Draft create' }, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + } + + it('create lost ACKは既存localId行をreconcileしserverIdを割り当ててcommandを除去する', async () => { + await seedPendingCreate(); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Created', { serverRevision: 1, acknowledgedCommandIds: ['cmd-create'] })], { nextCursor: 'cursor-v1' }), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-create')).resolves.toMatchObject({ + localId: '019d-create', + serverId: 42, + confirmedValues: { title: 'Created' }, + syncState: 'confirmed', + }); + expect(await repository.getCommands(scope)).toEqual([]); + expect(await repository.getReplicaRows(scope, 'test_items')).toHaveLength(1); + }); + + it('update lost ACKはprefix commandを除去しfollowing commandをrebaseする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-update', + serverId: 42, + values: { id: 42, title: 'Follow-up edit' }, + confirmedValues: { id: 42, title: 'Confirmed baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-update-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-update', + operation: 'test_items.update', + payload: { title: 'First edit' }, + optimisticValue: { id: 42, title: 'First edit' }, + payloadHash: 'hash-1', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + { + ...scope, + commandId: 'cmd-update-2', + aggregateType: 'test_items', + aggregateLocalId: '019d-update', + operation: 'test_items.update', + payload: { title: 'Follow-up edit' }, + optimisticValue: { id: 42, title: 'Follow-up edit' }, + payloadHash: 'hash-2', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 2, + lastErrorCode: null, + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'First edit applied', { serverRevision: 2, acknowledgedCommandIds: ['cmd-update-1'] })], { + nextCursor: 'cursor-v1', + }), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-update')).resolves.toMatchObject({ + localId: '019d-update', + serverId: 42, + values: { title: 'Follow-up edit' }, + confirmedValues: { title: 'First edit applied' }, + serverRevision: 2, + syncState: 'pending', + }); + expect(await repository.getCommands(scope)).toEqual([ + expect.objectContaining({ commandId: 'cmd-update-2', baseRevision: 2, state: 'pending' }), + ]); + }); + + it('delete lost ACKはfollowing commandが無ければ行を削除する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-delete-ack', + serverId: 42, + values: { id: 42, title: 'Pending delete' }, + confirmedValues: { id: 42, title: 'Confirmed' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-delete', + aggregateType: 'test_items', + aggregateLocalId: '019d-delete-ack', + operation: 'test_items.delete', + payload: {}, + optimisticValue: { id: 42, title: 'Pending delete' }, + payloadHash: 'hash', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Confirmed', { deleted: true, serverRevision: 2, acknowledgedCommandIds: ['cmd-delete'] })], { + nextCursor: 'cursor-v1', + }), + ); + + await service.pull(scope); + + expect(await repository.getReplicaRow(scope, 'test_items', '019d-delete-ack')).toBeNull(); + expect(await repository.getReplicaRowByServerId(scope, 'test_items', 42)).toBeNull(); + expect(await repository.getCommands(scope)).toEqual([]); + }); + + it('duplicate deltaはacknowledgedCommandIdsをマージする', async () => { + await seedPendingCreate(); + pull.mockResolvedValueOnce( + page( + [ + itemChange(42, 'Partial', { serverRevision: 1, acknowledgedCommandIds: ['cmd-create'] }), + itemChange(42, 'Final', { serverRevision: 2, acknowledgedCommandIds: ['cmd-create'] }), + ], + { nextCursor: 'cursor-v1' }, + ), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-create')).resolves.toMatchObject({ + confirmedValues: { title: 'Final' }, + serverRevision: 2, + syncState: 'confirmed', + }); + expect(await repository.getCommands(scope)).toEqual([]); + }); + + it('skipped-prefix acknowledgementはrejectする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-skip', + serverId: 42, + values: { id: 42, title: 'Second edit' }, + confirmedValues: { id: 42, title: 'Baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'pending', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-first', + aggregateType: 'test_items', + aggregateLocalId: '019d-skip', + operation: 'test_items.update', + payload: { title: 'First edit' }, + optimisticValue: { id: 42, title: 'First edit' }, + payloadHash: 'hash-1', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + { + ...scope, + commandId: 'cmd-second', + aggregateType: 'test_items', + aggregateLocalId: '019d-skip', + operation: 'test_items.update', + payload: { title: 'Second edit' }, + optimisticValue: { id: 42, title: 'Second edit' }, + payloadHash: 'hash-2', + baseRevision: 1, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 2, + lastErrorCode: null, + }, + ], + }); + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull.mockResolvedValueOnce( + page([itemChange(42, 'Only second', { serverRevision: 2, acknowledgedCommandIds: ['cmd-second'] })], { nextCursor: 'cursor-v1' }), + ); + + await expect(service.pull(scope)).rejects.toThrow('Replica acknowledgement skipped an earlier aggregate command.'); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + expect(await repository.getCommands(scope)).toHaveLength(2); + }); + + it('server id collisionはrejectする', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-local-a', + serverId: null, + values: { id: 0, title: 'Pending create A' }, + confirmedValues: null, + serverRevision: null, + fetchedAt: 1, + syncState: 'pending', + }, + { + ...scope, + sourceKey: 'test_items', + localId: '019d-local-b', + serverId: 99, + values: { id: 99, title: 'Existing remote' }, + confirmedValues: { id: 99, title: 'Existing remote' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + putCommands: [ + { + ...scope, + commandId: 'cmd-create-a', + aggregateType: 'test_items', + aggregateLocalId: '019d-local-a', + operation: 'test_items.create', + payload: { title: 'Pending create A' }, + optimisticValue: { id: 0, title: 'Pending create A' }, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }, + ], + }); + await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); + pull.mockResolvedValueOnce( + page([itemChange(99, 'Collision', { serverRevision: 2, acknowledgedCommandIds: ['cmd-create-a'] })], { nextCursor: 'cursor-v1' }), + ); + + await expect(service.pull(scope)).rejects.toThrow('Server id 99 is already mapped to another local replica row.'); + await expect(repository.getReplicaCursor(scope)).resolves.toEqual({ ...scope, cursor: 'cursor-v0' }); + }); + }); + + it('non-finite numeric serverRevisionはrejectしcursorを進めない', async () => { + await expectPullRejectsPreservingCursor( + () => pull.mockResolvedValueOnce(page([{ ...itemChange(42, 'Created'), serverRevision: Number.NaN }], { nextCursor: 'cursor-v1' })), + 'Offline replica pull page changes[0].serverRevision must be a string or number.', + ); + }); }); 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 9ed7199..934b4c1 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -1,16 +1,9 @@ import { inject, Injectable } from '@angular/core'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; -import { - OFFLINE_REPLICA_PULLER, - type OfflineReplicaChange, - type OfflineReplicaPullPage, -} from './offline-replica-puller'; -import { - projectOfflineReplicaValues, - sha256OfflineReplicaSchema, - type OfflineReplicaEntitySchema, -} from './offline-replica-schema'; +import { OFFLINE_COMMAND_EXECUTOR } from './offline-command-executor'; +import { OFFLINE_REPLICA_PULLER, type OfflineReplicaChange, type OfflineReplicaPullPage } from './offline-replica-puller'; +import { projectOfflineReplicaValues, sha256OfflineReplicaSchema, type OfflineReplicaEntitySchema } from './offline-replica-schema'; import { OFFLINE_REPOSITORY, type OfflineCommand, @@ -26,6 +19,7 @@ export class OfflineReplicaPullService { readonly #options = inject(OFFLINE_KIT_OPTIONS); readonly #puller = inject(OFFLINE_REPLICA_PULLER); readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); + readonly #executor = inject(OFFLINE_COMMAND_EXECUTOR); #schemaHash: Promise | null = null; async pull(scope: OfflineScope): Promise { @@ -45,23 +39,58 @@ export class OfflineReplicaPullService { throw new Error(`Offline replica pull cursor did not advance for scope ${scope.userId}:${scope.groupId}.`); } - const commands = await this.#repository.getCommands(scope); + const scopeCommands = await this.#repository.getCommands(scope); + const userCommands = await this.#repository.getCommandsForUser(scope.userId); const changes = this.#collapseChanges(page.changes); const putRows: OfflineReplicaRow[] = []; const removeRows: OfflineReplicaRowKey[] = []; const putCommands = new Map(); + const removeCommandIds = new Set(); for (const change of changes) { const schema = this.#entitySchema(change.sourceKey); - const existing = await this.#repository.getReplicaRowByServerId(scope, change.sourceKey, change.serverId); + const commands = schema.scope === 'user' ? userCommands : scopeCommands; + const acknowledged = change.acknowledgedCommandIds + .map((commandId) => { + const command = commands.find((candidate) => candidate.commandId === commandId); + if (!command) return null; + if (this.#hooks.entityType(command) !== change.sourceKey) { + throw new Error(`Acknowledged command "${commandId}" does not target "${change.sourceKey}".`); + } + return command; + }) + .filter((command): command is OfflineCommand => command !== null); + const acknowledgedLocalIds = new Set(acknowledged.map((command) => command.aggregateLocalId)); + if (acknowledgedLocalIds.size > 1) { + throw new Error(`Acknowledged commands for "${change.sourceKey}" target multiple local rows.`); + } + const acknowledgedCommand = acknowledged[0]; + const acknowledgedScope = acknowledgedCommand + ? { userId: acknowledgedCommand.userId, groupId: acknowledgedCommand.groupId } + : scope; + const acknowledgedRow = acknowledgedCommand + ? await this.#repository.getReplicaRow(acknowledgedScope, change.sourceKey, acknowledgedCommand.aggregateLocalId) + : null; + if (acknowledgedCommand && !acknowledgedRow) { + throw new Error(`Acknowledged command "${acknowledgedCommand.commandId}" has no local replica row.`); + } + const serverRow = await this.#repository.getReplicaRowByServerId(scope, change.sourceKey, change.serverId); + if (acknowledgedRow && serverRow && acknowledgedRow.localId !== serverRow.localId) { + throw new Error(`Server id ${change.serverId} is already mapped to another local replica row.`); + } + const existing = acknowledgedRow ?? serverRow; const related = existing ? commands.filter( - (command) => - this.#hooks.entityType(command) === change.sourceKey && command.aggregateLocalId === existing.localId, + (command) => this.#hooks.entityType(command) === change.sourceKey && command.aggregateLocalId === existing.localId, ) : []; const hasPending = related.length > 0; + if (acknowledgedCommand) { + this.#applyAcknowledgement(change, existing!, related, putRows, removeRows, putCommands, removeCommandIds); + continue; + } + if (change.deleted) { if (!existing) continue; if (!hasPending) { @@ -116,6 +145,7 @@ export class OfflineReplicaPullService { putRows, removeRows, putCommands: [...putCommands.values()], + removeCommandIds: [...removeCommandIds], putCursors: [{ ...scope, cursor: page.nextCursor }], }); cursor = page.nextCursor; @@ -150,18 +180,20 @@ export class OfflineReplicaPullService { throw new Error(`${label}.deleted must be a boolean.`); } const serverId = change['serverId']; - if ( - typeof serverId !== 'number' || - !Number.isFinite(serverId) || - !Number.isSafeInteger(serverId) || - serverId <= 0 - ) { + if (typeof serverId !== 'number' || !Number.isFinite(serverId) || !Number.isSafeInteger(serverId) || serverId <= 0) { throw new Error(`${label}.serverId must be a positive integer.`); } const revision = change['serverRevision']; - if (typeof revision !== 'string' && typeof revision !== 'number') { + if (typeof revision !== 'string' && (typeof revision !== 'number' || !Number.isFinite(revision))) { throw new Error(`${label}.serverRevision must be a string or number.`); } + const acknowledgedCommandIds = change['acknowledgedCommandIds']; + if ( + !Array.isArray(acknowledgedCommandIds) || + acknowledgedCommandIds.some((commandId) => typeof commandId !== 'string' || commandId.length === 0) + ) { + throw new Error(`${label}.acknowledgedCommandIds must be an array of non-empty strings.`); + } if (change['deleted']) { if (change['values'] !== null) { throw new Error(`${label} with deleted=true must have null values.`); @@ -183,10 +215,7 @@ export class OfflineReplicaPullService { return schema; } - #validatedValues( - schema: OfflineReplicaEntitySchema>, - change: OfflineReplicaChange, - ): unknown { + #validatedValues(schema: OfflineReplicaEntitySchema>, change: OfflineReplicaChange): unknown { if (change.values === null) { throw new Error(`Offline replica change "${change.sourceKey}"/${change.serverId} is missing values.`); } @@ -195,9 +224,73 @@ export class OfflineReplicaPullService { #collapseChanges(changes: readonly OfflineReplicaChange[]): OfflineReplicaChange[] { const collapsed = new Map(); - for (const change of changes) collapsed.set(`${change.sourceKey}:${change.serverId}`, change); + for (const change of changes) { + const key = `${change.sourceKey}:${change.serverId}`; + const previous = collapsed.get(key); + collapsed.set(key, { + ...change, + acknowledgedCommandIds: [...new Set([...(previous?.acknowledgedCommandIds ?? []), ...change.acknowledgedCommandIds])], + }); + } return [...collapsed.values()]; } + + #applyAcknowledgement( + change: OfflineReplicaChange, + row: OfflineReplicaRow, + related: readonly OfflineCommand[], + putRows: OfflineReplicaRow[], + removeRows: OfflineReplicaRowKey[], + putCommands: Map, + removeCommandIds: Set, + ): void { + const acknowledgedIds = new Set(change.acknowledgedCommandIds); + const lastAcknowledgedIndex = related.reduce((last, command, index) => (acknowledgedIds.has(command.commandId) ? index : last), -1); + if (lastAcknowledgedIndex < 0) { + throw new Error(`Replica acknowledgement does not match the local aggregate outbox.`); + } + if (related.slice(0, lastAcknowledgedIndex + 1).some((command) => !acknowledgedIds.has(command.commandId))) { + throw new Error(`Replica acknowledgement skipped an earlier aggregate command.`); + } + const following = related + .slice(lastAcknowledgedIndex + 1) + .map((command) => this.#executor.withServerRevision(command, change.serverRevision)); + for (const command of following) putCommands.set(command.commandId, command); + for (const command of related.slice(0, lastAcknowledgedIndex + 1)) { + removeCommandIds.add(command.commandId); + } + + if (change.deleted) { + if (following.length > 0) { + putRows.push({ ...row, serverRevision: change.serverRevision, syncState: 'conflict', fetchedAt: Date.now() }); + for (const command of following) { + putCommands.set(command.commandId, { ...command, state: 'conflict', lastErrorCode: 'remote_deleted' }); + } + } else { + removeRows.push(row); + } + return; + } + + const schema = this.#entitySchema(change.sourceKey); + const confirmedValues = this.#validatedValues(schema, change); + this.#assertServerIdAssignment(row.serverId, change.serverId); + putRows.push({ + ...row, + serverId: change.serverId, + values: following.length > 0 ? following.at(-1)!.optimisticValue : confirmedValues, + confirmedValues, + serverRevision: change.serverRevision, + fetchedAt: Date.now(), + syncState: following.length > 0 ? 'pending' : 'confirmed', + }); + } + + #assertServerIdAssignment(current: number | null, incoming: number): void { + if (current !== null && current !== incoming) { + throw new Error(`Replica serverId is immutable: current=${current}, incoming=${incoming}.`); + } + } } function isPlainObject(value: unknown): value is Record { diff --git a/projects/kit/offline/src/lib/offline-replica-puller.ts b/projects/kit/offline/src/lib/offline-replica-puller.ts index 9a8ed13..979c136 100644 --- a/projects/kit/offline/src/lib/offline-replica-puller.ts +++ b/projects/kit/offline/src/lib/offline-replica-puller.ts @@ -14,6 +14,8 @@ export interface OfflineReplicaChange { sourceKey: string; serverId: number; serverRevision: string | number; + /** Idempotency command ids durably recorded by the server and reflected in this final row state. */ + acknowledgedCommandIds: readonly string[]; values: unknown | null; deleted: boolean; } diff --git a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts index a08355c..30748ab 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ import { describe, expect, it } from 'vitest'; import { booleanColumn, @@ -159,13 +160,13 @@ describe('offline-replica-schema runtime', () => { }); it('rejects invalid table and reserved column identifiers', () => { - type MinimalSelect = { title: string }; + type MinimalSelect = { id: number; title: string }; expect(() => defineReplicaEntity()({ table: 'Bad-Table', sourceKey: 'items', scope: 'user', - fields: { title: text() }, + fields: { id: serverId(), title: text() }, }), ).toThrow('Replica table "Bad-Table" must match ^[a-z][a-z0-9_]*$.'); @@ -174,19 +175,22 @@ describe('offline-replica-schema runtime', () => { table: 'items', sourceKey: 'items', scope: 'user', - fields: { title: { kind: 'column', affinity: 'TEXT', storageKind: 'text', columnName: 'local_id', nullable: false } }, + fields: { + id: serverId(), + title: { kind: 'column', affinity: 'TEXT', storageKind: 'text', columnName: 'local_id', nullable: false }, + }, }), ).toThrow('Replica column "local_id" is reserved.'); }); it('rejects ignored fields with an empty reason', () => { - type Select = { flag: boolean }; + type Select = { id: number; flag: boolean }; expect(() => defineReplicaEntity()({ + table: 'items', + sourceKey: 'items', + scope: 'user', + fields: { title: text() }, + }), + ).toThrow('Replica entity must define exactly one serverId field.'); }); }); @@ -219,14 +235,7 @@ describe('encodeOfflineReplicaValues', () => { it('encodes column fields in deterministic descriptor order and ignores serverId/ignored source keys', () => { const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); - expect(Object.keys(encoded)).toEqual([ - 'active', - 'amount', - 'notes', - 'payload', - 'title', - 'updated_at', - ]); + expect(Object.keys(encoded)).toEqual(['active', 'amount', 'notes', 'payload', 'title', 'updated_at']); expect(encoded).toEqual({ active: 1, amount: 12.5, @@ -251,12 +260,8 @@ describe('encodeOfflineReplicaValues', () => { }); it('rejects non-plain objects, unknown keys, and missing column fields', () => { - expect(() => encodeOfflineReplicaValues(sampleSchema, null)).toThrow( - 'Replica row values must be a plain object.', - ); - expect(() => encodeOfflineReplicaValues(sampleSchema, new Map())).toThrow( - 'Replica row values must be a plain object.', - ); + expect(() => encodeOfflineReplicaValues(sampleSchema, null)).toThrow('Replica row values must be a plain object.'); + expect(() => encodeOfflineReplicaValues(sampleSchema, new Map())).toThrow('Replica row values must be a plain object.'); expect(() => encodeOfflineReplicaValues(sampleSchema, { @@ -266,9 +271,7 @@ describe('encodeOfflineReplicaValues', () => { ).toThrow('Replica row contains unknown source key "surprise".'); const { title: _title, ...missingTitle } = sampleRowValues; - expect(() => encodeOfflineReplicaValues(sampleSchema, missingTitle)).toThrow( - 'Replica row is missing required source key "title".', - ); + expect(() => encodeOfflineReplicaValues(sampleSchema, missingTitle)).toThrow('Replica row is missing required source key "title".'); }); it('rejects null for required columns and invalid typed values', () => { @@ -335,31 +338,18 @@ describe('decodeOfflineReplicaValues', () => { _offline_fetched_at: 1_720_000_000_000, }); - expect(Object.keys(decoded)).toEqual([ - 'active', - 'amount', - 'notes', - 'payload', - 'title', - 'updatedAt', - ]); + expect(Object.keys(decoded)).toEqual(['active', 'amount', 'notes', 'payload', 'title', 'updatedAt']); expect(decoded).toEqual(sampleColumnValues); }); it('rejects non-plain objects, missing columns, and null for required columns', () => { const encoded = encodeOfflineReplicaValues(sampleSchema, sampleRowValues); - expect(() => decodeOfflineReplicaValues(sampleSchema, null)).toThrow( - 'Replica SQLite row must be a plain object.', - ); - expect(() => decodeOfflineReplicaValues(sampleSchema, new Map())).toThrow( - 'Replica SQLite row must be a plain object.', - ); + expect(() => decodeOfflineReplicaValues(sampleSchema, null)).toThrow('Replica SQLite row must be a plain object.'); + expect(() => decodeOfflineReplicaValues(sampleSchema, new Map())).toThrow('Replica SQLite row must be a plain object.'); const { title: _title, ...missingTitle } = encoded; - expect(() => decodeOfflineReplicaValues(sampleSchema, missingTitle)).toThrow( - 'Replica SQLite row is missing required column "title".', - ); + expect(() => decodeOfflineReplicaValues(sampleSchema, missingTitle)).toThrow('Replica SQLite row is missing required column "title".'); expect(() => decodeOfflineReplicaValues(sampleSchema, { @@ -417,10 +407,7 @@ describe('encodeOfflineReplicaValues round-trip', () => { amount: sourceRow.amount, active: sourceRow.active, payload: sourceRow.payload, - updatedAt: - sourceRow.updatedAt instanceof Date - ? sourceRow.updatedAt.toISOString() - : sourceRow.updatedAt, + updatedAt: sourceRow.updatedAt instanceof Date ? sourceRow.updatedAt.toISOString() : sourceRow.updatedAt, }); } }); @@ -434,9 +421,7 @@ describe('projectOfflineReplicaValues', () => { }); it('validates values like encodeOfflineReplicaValues before projecting', () => { - expect(() => projectOfflineReplicaValues(sampleSchema, null)).toThrow( - 'Replica row values must be a plain object.', - ); + expect(() => projectOfflineReplicaValues(sampleSchema, null)).toThrow('Replica row values must be a plain object.'); expect(() => projectOfflineReplicaValues(sampleSchema, { ...sampleRowValues, @@ -555,9 +540,7 @@ describe('offline-replica-schema bundle runtime', () => { }); expect(integerBundle.schemaFingerprintInput).not.toBe(booleanBundle.schemaFingerprintInput); - expect(await sha256OfflineReplicaSchema(integerBundle)).not.toBe( - await sha256OfflineReplicaSchema(booleanBundle), - ); + expect(await sha256OfflineReplicaSchema(integerBundle)).not.toBe(await sha256OfflineReplicaSchema(booleanBundle)); }); it('rejects invalid bundle and migration versions', () => { @@ -636,9 +619,7 @@ describe('offline-replica-schema bundle runtime', () => { { fromVersion: 3, statements: ['SELECT 2'], migrateWebRow: (row) => row }, ], }), - ).toThrow( - 'Replica migrations must advance consecutive schema versions; expected fromVersion 2, received 3.', - ); + ).toThrow('Replica migrations must advance consecutive schema versions; expected fromVersion 2, received 3.'); expect(() => defineOfflineReplicaSchema({ diff --git a/projects/kit/offline/src/lib/offline-replica-schema.ts b/projects/kit/offline/src/lib/offline-replica-schema.ts index ce4c396..c3a2bbd 100644 --- a/projects/kit/offline/src/lib/offline-replica-schema.ts +++ b/projects/kit/offline/src/lib/offline-replica-schema.ts @@ -7,13 +7,7 @@ export type OfflineReplicaSqliteAffinity = 'TEXT' | 'INTEGER' | 'REAL'; * For example, {@link booleanColumn} and {@link datetime} both use INTEGER/TEXT affinity * but remain distinguishable at runtime through this marker. */ -export type OfflineReplicaStorageKind = - | 'text' - | 'integer' - | 'real' - | 'booleanColumn' - | 'json' - | 'datetime'; +export type OfflineReplicaStorageKind = 'text' | 'integer' | 'real' | 'booleanColumn' | 'json' | 'datetime'; /** Scope partition persisted on every replica table row. */ export type OfflineReplicaEntityScope = 'user' | 'group'; @@ -60,7 +54,9 @@ export interface OfflineReplicaEntitySchema, K ext : OfflineReplicaColumnDef); type ExactSelectKeys, TFields> = - Exclude extends never - ? Exclude extends never - ? TFields - : never - : never; + Exclude extends never ? (Exclude extends never ? TFields : never) : never; /** Configuration accepted by {@link defineReplicaEntity}. */ export interface OfflineReplicaEntityDefinition< @@ -181,7 +173,7 @@ export function json(): OfflineReplicaColumnDef { return requiredColumn('TEXT', 'json'); } -/** Maps a non-null datetime source property to an INTEGER epoch-millis column. */ +/** Maps a non-null datetime source property to a canonical ISO-8601 TEXT column. */ export function datetime(): OfflineReplicaColumnDef { return requiredColumn('TEXT', 'datetime'); } @@ -220,21 +212,15 @@ function buildOfflineReplicaEntitySchema const sourceKeys = Object.keys(definition.fields).sort(); const serverIdCount = sourceKeys.filter((sourceKey) => definition.fields[sourceKey]?.kind === 'serverId').length; - if (serverIdCount > 1) throw new Error('Replica entity cannot define more than one serverId field.'); - const hasServerId = serverIdCount === 1; + if (serverIdCount !== 1) throw new Error('Replica entity must define exactly one serverId field.'); + const hasServerId = true; const fields: OfflineReplicaFieldDescriptor[] = sourceKeys.map((sourceKey) => { const fieldDef = definition.fields[sourceKey as keyof TSelect] as OfflineReplicaFieldDef; return materializeFieldDescriptor(sourceKey, fieldDef); }); const createTableSql = buildCreateTableSql(definition.table, definition.scope, fields, hasServerId); - const schemaFingerprintInput = buildSchemaFingerprintInput( - definition.table, - definition.sourceKey, - definition.scope, - fields, - hasServerId, - ); + const schemaFingerprintInput = buildSchemaFingerprintInput(definition.table, definition.sourceKey, definition.scope, fields, hasServerId); return { tableName: definition.table, @@ -246,10 +232,7 @@ function buildOfflineReplicaEntitySchema }; } -function materializeFieldDescriptor( - sourceKey: string, - fieldDef: OfflineReplicaFieldDef, -): OfflineReplicaFieldDescriptor { +function materializeFieldDescriptor(sourceKey: string, fieldDef: OfflineReplicaFieldDef): OfflineReplicaFieldDescriptor { if (fieldDef.kind === 'ignored') { if (!fieldDef.reason.trim()) throw new Error(`Replica ignored field "${sourceKey}" requires a reason.`); return { @@ -323,8 +306,7 @@ function buildCreateTableSql( const statements = [`CREATE TABLE IF NOT EXISTS ${tableName} (\n ${columnLines.join(',\n ')},\n PRIMARY KEY (local_id)\n)`]; if (hasServerId) { - const indexColumns = - scope === 'group' ? '_offline_user_id, _offline_group_id, server_id' : '_offline_user_id, server_id'; + const indexColumns = scope === 'group' ? '_offline_user_id, _offline_group_id, server_id' : '_offline_user_id, server_id'; statements.push( `CREATE UNIQUE INDEX IF NOT EXISTS uq_${tableName}_server_id ON ${tableName} (${indexColumns}) WHERE server_id IS NOT NULL`, ); @@ -504,10 +486,7 @@ function isPlainObject(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } -function encodeReplicaColumnValue( - field: OfflineReplicaFieldDescriptor, - value: unknown, -): string | number { +function encodeReplicaColumnValue(field: OfflineReplicaFieldDescriptor, value: unknown): string | number { switch (field.storageKind) { case 'text': return encodeReplicaTextValue(field.sourceKey, value); @@ -728,13 +707,11 @@ export interface OfflineReplicaSchemaDefinition { * Entity `tableName` and `sourceKey` values must each be unique within the bundle. * Migrations must advance one version at a time without duplicates or gaps. */ -export function defineOfflineReplicaSchema( - definition: OfflineReplicaSchemaDefinition, -): OfflineReplicaSchemaBundle { +export function defineOfflineReplicaSchema(definition: OfflineReplicaSchemaDefinition): OfflineReplicaSchemaBundle { assertPositiveInteger(definition.version, 'schema version'); const entities = [...definition.entities].sort((left, right) => - left.sourceKey.localeCompare(right.sourceKey), + left.sourceKey < right.sourceKey ? -1 : left.sourceKey > right.sourceKey ? 1 : 0, ); const tableNames = new Set(); const sourceKeys = new Set(); @@ -801,9 +778,7 @@ function normalizeOfflineReplicaMigrations( for (const [index, statement] of migration.statements.entries()) { if (!statement.trim()) { - throw new Error( - `Replica migration from version ${migration.fromVersion} statement ${index + 1} must not be empty.`, - ); + throw new Error(`Replica migration from version ${migration.fromVersion} statement ${index + 1} must not be empty.`); } } } diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index 1f01c12..bb3678d 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ import { TestBed } from '@angular/core/testing'; import { KitStorageService } from '@rdlabo/ionic-angular-kit'; import { describe, beforeEach, expect, it, vi } from 'vitest'; @@ -71,8 +72,7 @@ const replicaSchemaV2 = defineOfflineReplicaSchema({ migrateWebRow: (row) => ({ sourceKey: row.sourceKey, values: { ...row.values, subtitle: 'migrated' }, - confirmedValues: - row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: 'migrated-confirmed' }, + confirmedValues: row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: 'migrated-confirmed' }, }), }, ], @@ -85,11 +85,14 @@ const replicaSchemaV2DeleteRow = defineOfflineReplicaSchema({ { fromVersion: 1, statements: ['ALTER TABLE test_items ADD COLUMN subtitle TEXT NOT NULL DEFAULT ""'], - migrateWebRow: (row) => (row.values['title'] === 'drop-me' ? null : { - sourceKey: row.sourceKey, - values: { ...row.values, subtitle: 'kept' }, - confirmedValues: row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: 'kept' }, - }), + migrateWebRow: (row) => + row.values['title'] === 'drop-me' + ? null + : { + sourceKey: row.sourceKey, + values: { ...row.values, subtitle: 'kept' }, + confirmedValues: row.confirmedValues === null ? null : { ...row.confirmedValues, subtitle: 'kept' }, + }, }, ], }); @@ -109,6 +112,59 @@ const replicaSchemaV2InvalidOutput = defineOfflineReplicaSchema({ }, ], }); +const replicaSchemaV2Rekey = defineOfflineReplicaSchema({ + version: 2, + entities: [ + defineReplicaEntity<{ id: number; title: string }>()({ + table: 'renamed_items', + sourceKey: 'renamed_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, + }), + testGroupItemEntity, + ], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE test_items RENAME TO renamed_items'], + migrateWebRow: (row) => ({ + sourceKey: 'renamed_items', + values: row.values, + confirmedValues: row.confirmedValues, + }), + }, + ], +}); + +const replicaSchemaV2RekeyCollision = defineOfflineReplicaSchema({ + version: 2, + entities: [ + defineReplicaEntity<{ id: number; title: string }>()({ + table: 'renamed_items', + sourceKey: 'renamed_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, + }), + testGroupItemEntity, + ], + migrations: [ + { + fromVersion: 1, + statements: ['ALTER TABLE test_items RENAME TO renamed_items'], + migrateWebRow: (row) => ({ + sourceKey: 'renamed_items', + values: row.values, + confirmedValues: row.confirmedValues, + }), + }, + ], +}); const replicaSchemaV1HashDrift = defineOfflineReplicaSchema({ version: 1, @@ -157,10 +213,7 @@ describe('IonicOfflineRepository', () => { return TestBed.inject(OFFLINE_REPOSITORY); } - async function createSeededRepository( - replicaSchema: OfflineReplicaSchemaBundle, - seed: () => Promise, - ): Promise { + async function createSeededRepository(replicaSchema: OfflineReplicaSchemaBundle, seed: () => Promise): Promise { storage = new MemoryStorage(); await seed(); return createRepository(replicaSchema, { preserveStorage: true }); @@ -310,11 +363,7 @@ describe('IonicOfflineRepository', () => { lastErrorCode: null, }; repository = await createSeededRepository(replicaSchemaV2InvalidOutput, async () => { - await seedReplicaMetadata( - replicaSchemaV1, - { '1:10:test_items:019d-aaaa': baseRow }, - { 'update-1': command }, - ); + await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }, { 'update-1': command }); }); await expect(repository.initialize()).rejects.toThrow('Replica row is missing required source key "subtitle".'); @@ -358,12 +407,163 @@ describe('IonicOfflineRepository', () => { ).rejects.toThrow('Offline replica schema hash mismatch at version 1'); }); + it('ROWS成功後のmetadata失敗は旧状態へrollbackする', async () => { + const command: OfflineCommand = { + userId: 1, + groupId: 10, + commandId: 'update-1', + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.update', + payload: { title: 'Local item' }, + optimisticValue: { id: 42, title: 'Local item' }, + payloadHash: 'hash', + baseRevision: 7, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 1, + lastErrorCode: null, + }; + repository = await createSeededRepository(replicaSchemaV2, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }, { 'update-1': command }); + }); + const kitStorage = TestBed.inject(KitStorageService) as MemoryStorage & KitStorageService; + const originalSet = kitStorage.set.bind(kitStorage); + vi.spyOn(kitStorage, 'set').mockImplementation(async (key, value) => { + if (key === 'offline:metadata' && (value as { replicaSchemaVersion?: number }).replicaSchemaVersion === replicaSchemaV2.version) { + throw new Error('metadata write failed'); + } + return originalSet(key, value) as Promise; + }); + + await expect(repository.initialize()).rejects.toThrow('metadata write failed'); + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); + expect(storage.values.get('offline:metadata')).toMatchObject({ + replicaSchemaVersion: 1, + replicaSchemaHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + }); + expect(storage.values.get('offline:replica:rows')).toMatchObject({ + '1:10:test_items:019d-aaaa': expect.objectContaining({ + values: { id: 42, title: 'Local item' }, + }), + }); + expect(Object.keys(storage.values.get('offline:outbox:commands') as object)).toEqual(['update-1']); + }); + + it('target metadataとjournalからrecovery後にmigrationを再実行する', async () => { + repository = await createSeededRepository(replicaSchemaV2, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:user:test_items:019d-aaaa': baseRow }); + storage.values.set('offline:replica:schema-migration', { + originalRows: { '1:user:test_items:019d-aaaa': structuredClone(baseRow) }, + fromVersion: 1, + fromHash: await sha256OfflineReplicaSchema(replicaSchemaV1), + targetVersion: 2, + targetHash: await sha256OfflineReplicaSchema(replicaSchemaV2), + }); + storage.values.set('offline:replica:rows', { + '1:user:test_items:019d-aaaa': { + ...baseRow, + values: { id: 42, title: 'Corrupted partial migration' }, + }, + }); + }); + await repository.initialize(); + + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + values: { title: 'Local item', subtitle: 'migrated' }, + }); + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); + }); + + it('recovery失敗時はjournalを保持する', async () => { + repository = await createSeededRepository(replicaSchemaV2, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }); + }); + const kitStorage = TestBed.inject(KitStorageService) as MemoryStorage & KitStorageService; + const originalSet = kitStorage.set.bind(kitStorage); + const originalRemove = kitStorage.remove.bind(kitStorage); + vi.spyOn(kitStorage, 'set').mockImplementation(async (key, value) => { + if (key === 'offline:metadata' && (value as { replicaSchemaVersion?: number }).replicaSchemaVersion === replicaSchemaV2.version) { + throw new Error('metadata write failed'); + } + return originalSet(key, value) as Promise; + }); + vi.spyOn(kitStorage, 'remove').mockImplementation(async (key) => { + if (key === 'offline:replica:schema-migration') { + throw new Error('journal remove failed'); + } + return originalRemove(key); + }); + + await expect(repository.initialize()).rejects.toThrow('journal remove failed'); + expect(storage.values.get('offline:replica:schema-migration')).toBeDefined(); + expect(storage.values.get('offline:metadata')).toMatchObject({ replicaSchemaVersion: 1 }); + }); + + it('失敗後の再initializeで旧状態から新schemaへ収束する', async () => { + repository = await createSeededRepository(replicaSchemaV2, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:user:test_items:019d-aaaa': baseRow }); + }); + const kitStorage = TestBed.inject(KitStorageService) as MemoryStorage & KitStorageService; + const originalSet = kitStorage.set.bind(kitStorage); + let failMetadataOnce = true; + vi.spyOn(kitStorage, 'set').mockImplementation(async (key, value) => { + if (failMetadataOnce && key === 'offline:metadata' && (value as { replicaSchemaVersion?: number }).replicaSchemaVersion === 2) { + failMetadataOnce = false; + throw new Error('metadata write failed once'); + } + return originalSet(key, value) as Promise; + }); + + await expect(repository.initialize()).rejects.toThrow('metadata write failed once'); + vi.restoreAllMocks(); + repository = createRepository(replicaSchemaV2, { preserveStorage: true }); + await repository.initialize(); + + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-aaaa')).resolves.toMatchObject({ + values: { title: 'Local item', subtitle: 'migrated' }, + }); + expect(storage.values.get('offline:metadata')).toMatchObject({ replicaSchemaVersion: 2 }); + expect(storage.values.get('offline:replica:schema-migration')).toBeUndefined(); + }); + + it('sourceKey re-key collisionはmigrationを拒否する', async () => { + const rowA: OfflineReplicaRow = { ...baseRow, localId: '019d-aaaa', serverId: 42, values: { id: 42, title: 'A' } }; + const rowB: OfflineReplicaRow = { ...baseRow, localId: '019d-aaaa', serverId: 43, values: { id: 43, title: 'B' } }; + repository = await createSeededRepository(replicaSchemaV2RekeyCollision, async () => { + await seedReplicaMetadata(replicaSchemaV1, { + '1:10:test_items:019d-aaaa': rowA, + '1:user:test_items:019d-aaaa': rowB, + }); + }); + + await expect(repository.initialize()).rejects.toThrow('Replica schema migration produced duplicate row key'); + expect(storage.values.get('offline:metadata')).toMatchObject({ replicaSchemaVersion: 1 }); + }); + + it('sourceKey re-keyはstorage keyを更新してlookupできる', async () => { + repository = await createSeededRepository(replicaSchemaV2Rekey, async () => { + await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }); + }); + await repository.initialize(); + + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-aaaa')).rejects.toThrow( + 'Unknown offline replica source key "test_items".', + ); + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'renamed_items', '019d-aaaa')).resolves.toMatchObject({ + sourceKey: 'renamed_items', + values: { title: 'Local item' }, + }); + }); + it('中断されたmigration journalをrollbackして再実行する', async () => { repository = await createSeededRepository(replicaSchemaV2, async () => { await seedReplicaMetadata(replicaSchemaV1, { '1:10:test_items:019d-aaaa': baseRow }); storage.values.set('offline:replica:schema-migration', { originalRows: { '1:10:test_items:019d-aaaa': structuredClone(baseRow) }, fromVersion: 1, + fromHash: await sha256OfflineReplicaSchema(replicaSchemaV1), targetVersion: 2, targetHash: await sha256OfflineReplicaSchema(replicaSchemaV2), }); @@ -398,6 +598,7 @@ describe('IonicOfflineRepository', () => { storage.values.set('offline:replica:schema-migration', { originalRows: { '1:10:test_items:019d-aaaa': baseRow }, fromVersion: 1, + fromHash: await sha256OfflineReplicaSchema(replicaSchemaV1), targetVersion: 2, targetHash, }); @@ -409,6 +610,104 @@ describe('IonicOfflineRepository', () => { }); }); + it('clearGroupはuser-scoped replica rowを保持しgroup-scoped rowだけ削除する', async () => { + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-user', + serverId: 42, + values: { id: 42, title: 'User scoped' }, + confirmedValues: { id: 42, title: 'User scoped' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + { + userId: 1, + groupId: 10, + sourceKey: 'test_group_items', + localId: '019d-group', + serverId: 55, + values: { id: 55, name: 'Group scoped' }, + confirmedValues: { id: 55, name: 'Group scoped' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + await repository.clearGroup({ userId: 1, groupId: 10 }); + + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-user')).resolves.toMatchObject({ + localId: '019d-user', + }); + expect(await repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_group_items', '019d-group')).toBeNull(); + }); + + describe('user-scope cross-group parity', () => { + const scopeG10 = { userId: 1, groupId: 10 }; + const scopeG11 = { userId: 1, groupId: 11 }; + const userRow = { + sourceKey: 'test_items', + localId: '019d-cross', + serverId: 42, + confirmedValues: { id: 42, title: 'Shared user row' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed' as const, + }; + + beforeEach(async () => { + await repository.transactReplica({ + putRows: [ + { + ...userRow, + userId: 1, + groupId: 10, + values: { id: 42, title: 'Shared user row' }, + }, + ], + }); + }); + + it('getReplicaRowは別groupIdでも同一user rowを返す', async () => { + await expect(repository.getReplicaRow(scopeG11, 'test_items', '019d-cross')).resolves.toMatchObject({ + localId: '019d-cross', + groupId: 11, + values: { title: 'Shared user row' }, + }); + }); + + it('getReplicaRowByServerIdは別groupIdでも同一user rowを返す', async () => { + await expect(repository.getReplicaRowByServerId(scopeG11, 'test_items', 42)).resolves.toMatchObject({ + localId: '019d-cross', + }); + }); + + it('transactReplica更新は別group scopeからでも同一rowへ投影する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...userRow, + userId: 1, + groupId: 11, + values: { id: 42, title: 'Updated from G11' }, + confirmedValues: { id: 42, title: 'Updated from G11' }, + }, + ], + }); + + await expect(repository.getReplicaRow(scopeG10, 'test_items', '019d-cross')).resolves.toMatchObject({ + values: { title: 'Updated from G11' }, + groupId: 10, + }); + }); + }); + it('outboxを作成順で保持し、group削除時はそのscopeだけを消す', async () => { const base: Omit = { userId: 1, @@ -569,9 +868,7 @@ describe('IonicOfflineRepository', () => { ], }); - await expect( - repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-projected'), - ).resolves.toMatchObject({ + await expect(repository.getReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-projected')).resolves.toMatchObject({ serverId: 42, values: { title: 'Optimistic' }, confirmedValues: { title: 'Confirmed' }, diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 59a7305..d6b72a0 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -87,11 +87,7 @@ export interface OfflineRepository { setLastUserId(userId: number): Promise; getSessionManifest(userId: number): Promise; putSessionManifest(userId: number, value: T): Promise; - getReplicaRow( - scope: OfflineScope, - sourceKey: string, - localId: string, - ): Promise | null>; + getReplicaRow(scope: OfflineScope, sourceKey: string, localId: string): Promise | null>; getReplicaRows(scope: OfflineScope, sourceKey: string): Promise[]>; getReplicaRowByServerId( scope: OfflineScope, @@ -100,6 +96,7 @@ export interface OfflineRepository { ): Promise | null>; getReplicaCursor(scope: OfflineScope): Promise; getCommands(scope: OfflineScope): Promise; + getCommandsForUser(userId: number): Promise; putCommand(command: OfflineCommand): Promise; replaceCommand(command: OfflineCommand): Promise; removeCommand(commandId: string): Promise; @@ -131,6 +128,7 @@ interface OfflineMetadata { interface OfflineReplicaSchemaMigrationJournal { originalRows: Record; fromVersion: number; + fromHash: string; targetVersion: number; targetHash: string; } @@ -195,14 +193,13 @@ export class IonicOfflineRepository implements OfflineRepository { ): Promise | null> { await this.initialize(); await this.#writes; + const schema = this.#resolveReplicaEntitySchema(sourceKey); const rows = await this.#readRecord>(ROWS_KEY); - return rows[this.#rowKey(scope, sourceKey, localId)] ?? null; + const row = rows[this.#rowKey(scope, sourceKey, localId)]; + return row ? (this.#rowForScope(row, schema, scope) as OfflineReplicaRow) : null; } - async getReplicaRows( - scope: OfflineScope, - sourceKey: string, - ): Promise[]> { + async getReplicaRows(scope: OfflineScope, sourceKey: string): Promise[]> { await this.initialize(); await this.#writes; const schema = this.#resolveReplicaEntitySchema(sourceKey); @@ -212,6 +209,7 @@ export class IonicOfflineRepository implements OfflineRepository { if (row.sourceKey !== sourceKey || row.userId !== scope.userId) return false; return schema.scope === 'group' ? row.groupId === scope.groupId : true; }) + .map((row) => this.#rowForScope(row, schema, scope)) .sort((left, right) => left.localId.localeCompare(right.localId)); } @@ -225,12 +223,11 @@ export class IonicOfflineRepository implements OfflineRepository { const schema = this.#resolveReplicaEntitySchema(sourceKey); if (!this.#schemaHasServerId(schema)) return null; const rows = await this.#readRecord>(ROWS_KEY); - return ( - Object.values(rows).find((row) => { - if (row.sourceKey !== sourceKey || row.userId !== scope.userId || row.serverId !== serverId) return false; - return schema.scope === 'group' ? row.groupId === scope.groupId : true; - }) ?? null - ); + const row = Object.values(rows).find((row) => { + if (row.sourceKey !== sourceKey || row.userId !== scope.userId || row.serverId !== serverId) return false; + return schema.scope === 'group' ? row.groupId === scope.groupId : true; + }); + return row ? (this.#rowForScope(row, schema, scope) as OfflineReplicaRow) : null; } async getReplicaCursor(scope: OfflineScope): Promise { @@ -250,6 +247,15 @@ export class IonicOfflineRepository implements OfflineRepository { .sort((left, right) => left.createdAt - right.createdAt); } + async getCommandsForUser(userId: number): Promise { + await this.initialize(); + await this.#writes; + const commands = await this.#readRecord(OUTBOX_KEY); + return Object.values(commands) + .filter((command) => command.userId === userId) + .sort((left, right) => left.createdAt - right.createdAt); + } + async putCommand(command: OfflineCommand): Promise { await this.initialize(); await this.#assertReplicaSchemaLocked(); @@ -292,7 +298,10 @@ export class IonicOfflineRepository implements OfflineRepository { await this.initialize(); const belongsToGroup = (value: OfflineScope) => value.userId === scope.userId && value.groupId === scope.groupId; await Promise.all([ - this.#filterRecord(ROWS_KEY, (value) => !belongsToGroup(value)), + this.#filterRecord(ROWS_KEY, (value) => { + const schema = this.#resolveReplicaEntitySchema(value.sourceKey); + return schema.scope === 'user' || !belongsToGroup(value); + }), this.#filterRecord(OUTBOX_KEY, (value) => !belongsToGroup(value)), this.#filterRecord(CURSORS_KEY, (_value, key) => key !== this.#cursorKey(scope)), ]); @@ -328,18 +337,13 @@ export class IonicOfflineRepository implements OfflineRepository { return; } - const interruptedSchemaMigration = await this.#storage.get( - REPLICA_SCHEMA_MIGRATION_KEY, - ); + const interruptedSchemaMigration = await this.#storage.get(REPLICA_SCHEMA_MIGRATION_KEY); if (interruptedSchemaMigration) { await this.#recoverReplicaSchemaMigration(interruptedSchemaMigration); } const currentMetadata = await this.#metadata(); - await this.#initializeReplicaSchema( - currentMetadata.replicaSchemaVersion, - currentMetadata.replicaSchemaHash, - ); + await this.#initializeReplicaSchema(currentMetadata.replicaSchemaVersion, currentMetadata.replicaSchemaHash); const interrupted = await this.#storage.get(REPLICA_TRANSACTION_KEY); if (interrupted) await this.#applyReplicaTransaction(interrupted, false); @@ -376,28 +380,25 @@ export class IonicOfflineRepository implements OfflineRepository { ); } - await this.#runReplicaSchemaMigration(storedVersion, targetVersion, targetHash); + if (storedHash === null) { + throw new Error(`Offline replica schema metadata at version ${storedVersion} is missing its schema hash.`); + } + + await this.#runReplicaSchemaMigration(storedVersion, storedHash, targetVersion, targetHash); } async #recoverReplicaSchemaMigration(journal: OfflineReplicaSchemaMigrationJournal): Promise { - const metadata = await this.#metadata(); - if ( - metadata.replicaSchemaVersion === journal.targetVersion && - metadata.replicaSchemaHash === journal.targetHash - ) { - await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); - return; - } - await this.#storage.set(ROWS_KEY, journal.originalRows); + const metadata = await this.#metadata(); + await this.#storage.set(METADATA_KEY, { + ...metadata, + replicaSchemaVersion: journal.fromVersion, + replicaSchemaHash: journal.fromHash, + }); await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); } - async #runReplicaSchemaMigration( - fromVersion: number, - targetVersion: number, - targetHash: string, - ): Promise { + async #runReplicaSchemaMigration(fromVersion: number, fromHash: string, targetVersion: number, targetHash: string): Promise { const bundle = this.#options.replicaSchema; for (let version = fromVersion; version < targetVersion; version++) { if (!bundle.migrations.some((migration) => migration.fromVersion === version)) { @@ -411,13 +412,14 @@ export class IonicOfflineRepository implements OfflineRepository { await this.#storage.set(REPLICA_SCHEMA_MIGRATION_KEY, { originalRows, fromVersion, + fromHash, targetVersion, targetHash, }); try { const transformedRows: Record = {}; - for (const [key, row] of Object.entries(rows)) { + for (const row of Object.values(rows)) { let current: OfflineReplicaWebMigrationRow | null = this.#toWebMigrationRow(row); for (let version = fromVersion; version < targetVersion; version++) { if (current === null) break; @@ -438,29 +440,35 @@ export class IonicOfflineRepository implements OfflineRepository { encodeOfflineReplicaValues(entitySchema, current!.confirmedValues); } - transformedRows[key] = { + const transformedRow: OfflineReplicaRow = { ...row, sourceKey: current!.sourceKey, values: projectOfflineReplicaValues(entitySchema, current!.values), - confirmedValues: - current!.confirmedValues === null - ? null - : projectOfflineReplicaValues(entitySchema, current!.confirmedValues), + confirmedValues: current!.confirmedValues === null ? null : projectOfflineReplicaValues(entitySchema, current!.confirmedValues), }; + const transformedKey = this.#rowKey(transformedRow, transformedRow.sourceKey, transformedRow.localId); + if (transformedRows[transformedKey]) { + throw new Error(`Replica schema migration produced duplicate row key "${transformedKey}".`); + } + transformedRows[transformedKey] = transformedRow; } const metadata = await this.#metadata(); - await Promise.all([ - this.#storage.set(ROWS_KEY, transformedRows), - this.#storage.set(METADATA_KEY, { - ...metadata, - replicaSchemaVersion: targetVersion, - replicaSchemaHash: targetHash, - }), - ]); + await this.#storage.set(ROWS_KEY, transformedRows); + await this.#storage.set(METADATA_KEY, { + ...metadata, + replicaSchemaVersion: targetVersion, + replicaSchemaHash: targetHash, + }); await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); } catch (error) { - await this.#storage.remove(REPLICA_SCHEMA_MIGRATION_KEY); + await this.#recoverReplicaSchemaMigration({ + originalRows, + fromVersion, + fromHash, + targetVersion, + targetHash, + }); throw error; } }); @@ -472,8 +480,7 @@ export class IonicOfflineRepository implements OfflineRepository { return { sourceKey: row.sourceKey, values: structuredClone(row.values as Record), - confirmedValues: - row.confirmedValues === null ? null : structuredClone(row.confirmedValues as Record), + confirmedValues: row.confirmedValues === null ? null : structuredClone(row.confirmedValues as Record), }; } @@ -509,9 +516,9 @@ export class IonicOfflineRepository implements OfflineRepository { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); rows[this.#rowKey(row, row.sourceKey, row.localId)] = { ...row, + groupId: schema.scope === 'user' ? 0 : row.groupId, values: projectOfflineReplicaValues(schema, row.values), - confirmedValues: - row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues), + confirmedValues: row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues), }; } for (const row of transaction.removeRows ?? []) { @@ -560,7 +567,17 @@ export class IonicOfflineRepository implements OfflineRepository { } #rowKey(scope: OfflineScope, sourceKey: string, localId: string): string { - return `${scope.userId}:${scope.groupId}:${sourceKey}:${localId}`; + const schema = this.#resolveReplicaEntitySchema(sourceKey); + const partition = schema.scope === 'user' ? 'user' : String(scope.groupId); + return `${scope.userId}:${partition}:${sourceKey}:${localId}`; + } + + #rowForScope( + row: OfflineReplicaRow, + schema: OfflineReplicaEntitySchema>, + scope: OfflineScope, + ): OfflineReplicaRow { + return schema.scope === 'user' ? { ...row, groupId: scope.groupId } : row; } #cursorKey(scope: OfflineScope): string { 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 137689c..2e677d4 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -64,6 +64,7 @@ describe('OfflineSyncService', () => { getCommands: vi.fn(async (scope: OfflineScope) => commands.filter((item) => item.userId === scope.userId && item.groupId === scope.groupId), ), + getCommandsForUser: vi.fn(async (userId: number) => commands.filter((item) => item.userId === userId)), putCommand: vi.fn(async (command: OfflineCommand) => { await beforePutCommand?.(command); commands = commands.filter((item) => item.commandId !== command.commandId); @@ -82,10 +83,7 @@ describe('OfflineSyncService', () => { async (scope: OfflineScope, sourceKey: string, localId: string) => rows.find( (item) => - item.userId === scope.userId && - item.groupId === scope.groupId && - item.sourceKey === sourceKey && - item.localId === localId, + item.userId === scope.userId && item.groupId === scope.groupId && item.sourceKey === sourceKey && item.localId === localId, ) ?? null, ), getReplicaCursor: vi.fn(async () => null), @@ -528,4 +526,158 @@ describe('OfflineSyncService', () => { expect(execute).toHaveBeenCalledOnce(); expect(service.pendingCount()).toBe(0); }); + + it('local replica row lookup failureはrejectしbackground flushはErrorHandlerへ渡す', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository; + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + vi.mocked(repository.getReplicaRow).mockResolvedValue(null); + connected.set(true); + await service.refreshSession(); + await vi.waitFor(() => + expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Offline replica row not found: documents/1' })), + ); + + await service.refreshSession(); + await expect(service.flush()).rejects.toThrow('Offline replica row not found'); + expect(execute).not.toHaveBeenCalled(); + }); + + it('transactReplica failureはrejectしbackground flushはErrorHandlerへ渡す', async () => { + const repository = TestBed.inject(OFFLINE_REPOSITORY) as OfflineRepository; + const originalTransact = vi.mocked(repository.transactReplica).getMockImplementation()!; + vi.mocked(repository.transactReplica).mockImplementation(async (transaction) => { + if ((transaction.removeCommandIds?.length ?? 0) > 0) { + throw new Error('transaction failed'); + } + return originalTransact(transaction); + }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + connected.set(true); + await service.refreshSession(); + await vi.waitFor(() => expect(handleError).toHaveBeenCalledWith(expect.objectContaining({ message: 'transaction failed' }))); + + await service.refreshSession(); + await expect(service.flush()).rejects.toThrow('transaction failed'); + }); + + it('executor error without integer statusはclassifyせずrejectする', async () => { + execute.mockRejectedValueOnce(new Error('programming failure')); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toThrow('programming failure'); + expect(service.pendingCommands()[0]?.state).toBe('sending'); + expect(handleError).not.toHaveBeenCalled(); + }); + + it('executor error with negative statusはclassifyせずrejectする', async () => { + execute.mockRejectedValueOnce({ status: -1 }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toThrow(); + expect(service.pendingCommands()[0]?.state).toBe('sending'); + }); + + it('invalid serverIdはhard failする', async () => { + execute.mockResolvedValueOnce({ serverId: 0, serverRevision: 1, confirmedValues: {}, response: null }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-invalid-id', + operation: 'documents.create', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toThrow('Offline command returned invalid serverId 0.'); + }); + + it('reassigned serverIdはhard failする', async () => { + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'documents', + localId: '019d-existing', + serverId: 38142, + values: {}, + confirmedValues: {}, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }); + execute.mockResolvedValueOnce({ serverId: 99999, serverRevision: 2, confirmedValues: {}, response: null }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '019d-existing', + operation: 'documents.update', + payload: {}, + optimisticValue: {}, + baseRevision: 1, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toThrow('Offline replica serverId is immutable'); + }); + + it('invalid serverRevisionはhard failする', async () => { + execute.mockResolvedValueOnce({ serverRevision: Number.NaN, confirmedValues: {}, response: null }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: {}, + optimisticValue: {}, + }, + { flush: false }, + ); + connected.set(true); + await expect(service.flush()).rejects.toThrow('Offline command returned invalid serverRevision NaN.'); + }); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 236c8ce..6df56d6 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -263,23 +263,24 @@ export class OfflineSyncService { if (!this.#isCurrent(generation)) return; await this.#refreshState(generation); if (!this.#isCurrent(generation)) return; + const row = await this.#rowForCommand(sending); + if (!row) throw new Error(`Offline replica row not found: ${sending.aggregateType}/${sending.aggregateLocalId}`); + let result: OfflineCommandResult; try { - const row = await this.#rowForCommand(sending); - if (!row) throw new Error(`Offline replica row not found: ${sending.aggregateType}/${sending.aggregateLocalId}`); - const result = await this.#executor.execute(sending, { localId: row.localId, serverId: row.serverId }); - if (!this.#isCurrent(generation)) return; - await this.#completeCommand(commands, index, sending, result, generation); + result = await this.#executor.execute(sending, { localId: row.localId, serverId: row.serverId }); } catch (error) { if (!this.#isCurrent(generation)) return; + if (!this.#isClassifiableTransportError(error)) throw error; const failed = this.#failedCommand(sending, error); - const row = await this.#rowForCommand(failed); await this.#repository.transactReplica({ - putRows: row ? [{ ...row, syncState: this.#replicaState(failed.state) }] : undefined, + putRows: [{ ...row, syncState: this.#replicaState(failed.state) }], putCommands: [failed], }); if (failed.state === 'retry_wait') this.#scheduleRetry(failed.retryAt); break; } + if (!this.#isCurrent(generation)) return; + await this.#completeCommand(commands, index, sending, result, generation); } } @@ -315,22 +316,25 @@ export class OfflineSyncService { for (let offset = 0; offset < rebased.length; offset++) commands[index + 1 + offset] = rebased[offset]!; const current = await this.#rowForCommand(command); if (!this.#isCurrent(generation)) return; + if (!current) { + throw new Error(`Offline replica row disappeared while completing command ${command.commandId}.`); + } + this.#assertServerRevision(result.serverRevision); const confirmedValues = result.confirmedValues ?? command.optimisticValue; - const row = current - ? { - ...current, - values: rebased.length > 0 ? rebased.at(-1)!.optimisticValue : confirmedValues, - confirmedValues, - serverId: result.serverId ?? current.serverId, - serverRevision: revision ?? current.serverRevision, - fetchedAt: Date.now(), - syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), - } - : undefined; + const serverId = this.#resolvedServerId(current.serverId, result.serverId); + const row = { + ...current, + values: rebased.length > 0 ? rebased.at(-1)!.optimisticValue : confirmedValues, + confirmedValues, + serverId, + serverRevision: revision ?? current.serverRevision, + fetchedAt: Date.now(), + syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), + }; if (!this.#isCurrent(generation)) return; await this.#repository.transactReplica({ - putRows: result.removeReplica || !row ? undefined : [row], - removeRows: result.removeReplica && current ? [current] : undefined, + putRows: result.removeReplica ? undefined : [row], + removeRows: result.removeReplica ? [current] : undefined, putCommands: rebased, removeCommandIds: [command.commandId], }); @@ -400,6 +404,29 @@ export class OfflineSyncService { return typeof status === 'number' ? status : 0; } + #isClassifiableTransportError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const status = (error as { status?: unknown }).status; + return typeof status === 'number' && Number.isInteger(status) && status >= 0; + } + + #assertServerRevision(revision: string | number | undefined): void { + if (typeof revision === 'number' && !Number.isFinite(revision)) { + throw new Error(`Offline command returned invalid serverRevision ${String(revision)}.`); + } + } + + #resolvedServerId(current: number | null, incoming: number | undefined): number | null { + if (incoming === undefined) return current; + if (!Number.isSafeInteger(incoming) || incoming <= 0) { + throw new Error(`Offline command returned invalid serverId ${String(incoming)}.`); + } + if (current !== null && current !== incoming) { + throw new Error(`Offline replica serverId is immutable: current=${current}, incoming=${incoming}.`); + } + return incoming; + } + async #discoverScopes(generation = this.#generation): Promise { const session = await this.#context.getSession(); if (!this.#isCurrent(generation)) return false; diff --git a/projects/kit/offline/src/lib/offline.interceptor.ts b/projects/kit/offline/src/lib/offline.interceptor.ts index 4b4b5d1..ae95a9c 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.ts @@ -1,6 +1,6 @@ import type { HttpEvent, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; import { HttpResponse as AngularHttpResponse } from '@angular/common/http'; -import { inject, Injectable } from '@angular/core'; +import { ErrorHandler, inject, Injectable } from '@angular/core'; import type { Observable } from 'rxjs'; import { catchError, concatMap, defer, from, of, tap, throwError } from 'rxjs'; import { isOfflineFallbackError, OfflineNetworkService } from './offline-network.service'; @@ -16,9 +16,7 @@ export const offlineInterceptor: HttpInterceptorFn = (request, next) => { const fallback = inject(OfflineRequestFallbackService); const plan = registry.resolve(request); if (!plan) return transport(); - return defer(transport).pipe( - catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error)), - ); + return defer(transport).pipe(catchError((error: unknown) => fallback.handle(request, error, plan) ?? throwError(() => error))); }; function observeTransport(source: Observable>, network: OfflineNetworkService): Observable> { @@ -38,6 +36,7 @@ function observeTransport(source: Observable>, network: Offli @Injectable({ providedIn: 'root' }) export class OfflineRequestFallbackService { readonly #registry = inject(OfflineRequestPolicyRegistry); + readonly #errorHandler = inject(ErrorHandler); handle( request: HttpRequest, @@ -48,10 +47,13 @@ export class OfflineRequestFallbackService { const plan = resolvedPlan ?? this.#registry.resolve(request); if (!plan || plan.kind !== 'read') return null; return defer(() => from(plan.readLocal())).pipe( + catchError((localError: unknown) => { + this.#errorHandler.handleError(localError); + return throwError(() => error); + }), concatMap((cached) => cached ? of(cached.clone({ headers: cached.headers.set(OFFLINE_RESPONSE_HEADER, 'local') })) : throwError(() => error), ), - catchError(() => throwError(() => error)), ); } } diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index 44945d1..e2bcfa4 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; @@ -264,9 +265,7 @@ describe('SqliteOfflineRepository Capawesome adapter', () => { }; const repository = createRepository(undefined, { replicaSchema: replicaSchemaV3MissingMigration }); - await expect(repository.initialize()).rejects.toThrow( - 'Missing offline replica schema migration from version 2 to 3.', - ); + await expect(repository.initialize()).rejects.toThrow('Missing offline replica schema migration from version 2 to 3.'); expect( plugin.execute.mock.calls.some(([options]) => (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), @@ -436,6 +435,21 @@ describe('SqliteOfflineRepository replica rows', () => { } } if (statement.startsWith(`DELETE FROM ${tableName}`)) { + const userId = values?.[0]; + const groupId = values?.[1]; + if ( + statement.includes('_offline_user_id = ? AND _offline_group_id = ?') && + typeof userId === 'number' && + typeof groupId === 'number' + ) { + for (const [localId, stored] of Object.entries(storedReplicaRows)) { + if (stored.tableName !== tableName) continue; + if (stored.values[1] === userId && stored.values[2] === groupId) { + delete storedReplicaRows[localId]; + } + } + continue; + } const localId = values?.[0]; if (typeof localId === 'string') delete storedReplicaRows[localId]; } @@ -453,9 +467,7 @@ describe('SqliteOfflineRepository replica rows', () => { const userId = values?.[0]; const groupId = values?.[1]; const cursor = - typeof userId === 'number' && typeof groupId === 'number' - ? storedReplicaCursors[`${userId}:${groupId}`] - : undefined; + typeof userId === 'number' && typeof groupId === 'number' ? storedReplicaCursors[`${userId}:${groupId}`] : undefined; return cursor === undefined ? { rows: [] } : { columns: ['cursor'], rows: [[cursor]] }; } for (const tableName of ['test_items', 'test_group_items'] as const) { @@ -526,16 +538,7 @@ describe('SqliteOfflineRepository replica rows', () => { )?.[0] as { statement: string; values?: unknown[] }; expect(upsert?.statement).toContain('title'); expect(upsert?.statement).not.toContain('value_json'); - expect(upsert?.values).toEqual([ - '019d-bbbb', - 1, - null, - null, - null, - 'pending', - 1, - 'Local item', - ]); + expect(upsert?.values).toEqual(['019d-bbbb', 1, null, null, null, 'pending', 1, 'Local item']); expect( plugin.execute.mock.calls.some(([options]) => (options as { statement: string }).statement.startsWith('INSERT INTO offline_sync_commands'), @@ -859,6 +862,80 @@ describe('SqliteOfflineRepository replica rows', () => { }); }); + describe('user-scope cross-group parity', () => { + const scopeG10 = { userId: 1, groupId: 10 }; + const scopeG11 = { userId: 1, groupId: 11 }; + + it('getReplicaRow/getReplicaRowByServerIdは別groupIdでも同一user rowを返す', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-cross', + serverId: 42, + values: { id: 42, title: 'Shared user row' }, + confirmedValues: { id: 42, title: 'Shared user row' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + await expect(repository.getReplicaRow(scopeG11, 'test_items', '019d-cross')).resolves.toMatchObject({ + localId: '019d-cross', + groupId: 11, + }); + await expect(repository.getReplicaRowByServerId(scopeG11, 'test_items', 42)).resolves.toMatchObject({ + localId: '019d-cross', + }); + }); + + it('clearGroupはuser-scoped rowを保持しgroup-scoped rowだけ削除する', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.transactReplica({ + putRows: [ + { + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-user', + serverId: 42, + values: { id: 42, title: 'User scoped' }, + confirmedValues: { id: 42, title: 'User scoped' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + { + userId: 1, + groupId: 10, + sourceKey: 'test_group_items', + localId: '019d-group', + serverId: 55, + values: { id: 55, name: 'Group scoped' }, + confirmedValues: { id: 55, name: 'Group scoped' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + + await repository.clearGroup(scopeG10); + + await expect(repository.getReplicaRow(scopeG10, 'test_items', '019d-user')).resolves.toMatchObject({ + localId: '019d-user', + }); + expect(await repository.getReplicaRow(scopeG10, 'test_group_items', '019d-group')).toBeNull(); + }); + }); + function createRepository(): SqliteOfflineRepository { TestBed.configureTestingModule({ providers: [ diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 3b444b2..7a50d13 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -141,10 +141,7 @@ export class SqliteOfflineRepository implements OfflineRepository { return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, localId, row); } - async getReplicaRows( - scope: OfflineScope, - sourceKey: string, - ): Promise[]> { + async getReplicaRows(scope: OfflineScope, sourceKey: string): Promise[]> { const schema = this.#resolveReplicaEntitySchema(sourceKey); const predicates = ['_offline_user_id = ?']; const values: SQLiteValue[] = [scope.userId]; @@ -152,13 +149,8 @@ export class SqliteOfflineRepository implements OfflineRepository { predicates.push('_offline_group_id = ?'); values.push(scope.groupId); } - const rows = await this.#query( - `SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')} ORDER BY local_id ASC`, - values, - ); - return rows.map((row) => - this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row), - ); + const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')} ORDER BY local_id ASC`, values); + return rows.map((row) => this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row)); } async getReplicaRowByServerId( @@ -177,20 +169,14 @@ export class SqliteOfflineRepository implements OfflineRepository { const rows = await this.#query(`SELECT * FROM ${schema.tableName} WHERE ${predicates.join(' AND ')}`, values); const row = rows[0]; if (!row) return null; - return this.#replicaRowFromSqliteRow( - schema, - scope, - sourceKey, - this.#string(row['local_id']), - row, - ); + return this.#replicaRowFromSqliteRow(schema, scope, sourceKey, this.#string(row['local_id']), row); } async getReplicaCursor(scope: OfflineScope): Promise { - const rows = await this.#query( - 'SELECT cursor FROM offline_replica_cursors WHERE user_id = ? AND group_id = ?', - [scope.userId, scope.groupId], - ); + const rows = await this.#query('SELECT cursor FROM offline_replica_cursors WHERE user_id = ? AND group_id = ?', [ + scope.userId, + scope.groupId, + ]); const row = rows[0]; if (!row) return null; return { ...scope, cursor: this.#string(row['cursor']) }; @@ -204,6 +190,11 @@ export class SqliteOfflineRepository implements OfflineRepository { return rows.map((row) => this.#command(row)); } + async getCommandsForUser(userId: number): Promise { + const rows = await this.#query('SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC', [userId]); + return rows.map((row) => this.#command(row)); + } + async putCommand(command: OfflineCommand): Promise { await this.#queueWrite((databaseId) => this.#putCommand(databaseId, command)); } @@ -232,18 +223,10 @@ export class SqliteOfflineRepository implements OfflineRepository { await this.#transaction(async (database) => { const values = [scope.userId, scope.groupId]; await this.#execute(database, 'DELETE FROM offline_sync_commands WHERE user_id = ? AND group_id = ?', values); - await this.#execute( - database, - 'DELETE FROM offline_replica_cursors WHERE user_id = ? AND group_id = ?', - values, - ); + await this.#execute(database, 'DELETE FROM offline_replica_cursors WHERE user_id = ? AND group_id = ?', values); for (const entity of this.#options.replicaSchema.entities) { if (entity.scope !== 'group') continue; - await this.#execute( - database, - `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ? AND _offline_group_id = ?`, - values, - ); + await this.#execute(database, `DELETE FROM ${entity.tableName} WHERE _offline_user_id = ? AND _offline_group_id = ?`, values); } }); } @@ -293,10 +276,7 @@ export class SqliteOfflineRepository implements OfflineRepository { const bundle = this.#options.replicaSchema; const targetVersion = bundle.version; const targetHash = await sha256OfflineReplicaSchema(bundle); - const rows = await this.#queryDatabase( - databaseId, - 'SELECT version, schema_hash FROM offline_replica_schema_metadata WHERE id = 1', - ); + const rows = await this.#queryDatabase(databaseId, 'SELECT version, schema_hash FROM offline_replica_schema_metadata WHERE id = 1'); const storedVersion = rows[0] ? this.#number(rows[0]['version']) : null; const storedHash = rows[0] ? this.#string(rows[0]['schema_hash']) : null; @@ -459,8 +439,7 @@ export class SqliteOfflineRepository implements OfflineRepository { #putReplicaRow(databaseId: string, row: OfflineReplicaRow): Promise { const schema = this.#resolveReplicaEntitySchema(row.sourceKey); const encoded = encodeOfflineReplicaValues(schema, row.values); - const confirmedValues = - row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues); + const confirmedValues = row.confirmedValues === null ? null : projectOfflineReplicaValues(schema, row.confirmedValues); const { sql, domainColumns } = this.#buildReplicaUpsertStatement(schema); const values: SQLiteValue[] = [ row.localId, @@ -510,12 +489,7 @@ export class SqliteOfflineRepository implements OfflineRepository { insertColumns.push('server_id'); updateSets.push('server_id = excluded.server_id'); } - insertColumns.push( - '_offline_confirmed_json', - '_offline_server_revision_json', - '_offline_sync_state', - '_offline_fetched_at', - ); + insertColumns.push('_offline_confirmed_json', '_offline_server_revision_json', '_offline_sync_state', '_offline_fetched_at'); updateSets.push( '_offline_confirmed_json = excluded._offline_confirmed_json', '_offline_server_revision_json = excluded._offline_server_revision_json', diff --git a/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts b/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts index c61094c..e193be6 100644 --- a/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-maintenance.controller.spec.ts @@ -10,13 +10,13 @@ const OPTS = { waitUrl: 'https://api.example/public/maintenance/wait', }; -type FakeSource = { +interface FakeSource { addEventListener: ReturnType; close: ReturnType; onerror: ((ev: Event) => void) | null; onopen: ((ev: Event) => void) | null; trigger: (type: string) => void; -}; +} function fakeAlert() { let dismissResolve: () => void = () => undefined; @@ -55,11 +55,7 @@ function setup(alert = fakeAlert(), source = fakeEventSource()) { vi.stubGlobal('EventSource', EventSourceMock); TestBed.configureTestingModule({ - providers: [ - provideZonelessChangeDetection(), - KitMaintenanceController, - { provide: AlertController, useValue: alertCtrl }, - ], + providers: [provideZonelessChangeDetection(), KitMaintenanceController, { provide: AlertController, useValue: alertCtrl }], }); return { controller: TestBed.inject(KitMaintenanceController), diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts index 5b3187e..70dea30 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.spec.ts @@ -369,7 +369,7 @@ describe('KitOverlayController', () => { const assertVoid: void = result; expect(assertVoid).toBeUndefined(); // @ts-expect-error — a void result carries no dismiss data. - result?.anything; + expect(result?.anything).toBeUndefined(); }); it('presents the modal', async () => { diff --git a/projects/kit/src/lib/overlay/kit-overlay.controller.ts b/projects/kit/src/lib/overlay/kit-overlay.controller.ts index 57b26e4..1d20823 100644 --- a/projects/kit/src/lib/overlay/kit-overlay.controller.ts +++ b/projects/kit/src/lib/overlay/kit-overlay.controller.ts @@ -60,14 +60,12 @@ export interface ModalMetadata { * Matched with `any` (not `unknown`): `InputSignalWithTransform`'s `TransformT` is contravariant, so * `InputSignal` is not assignable to `InputSignalWithTransform`. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any type InputWriteType = F extends InputSignalWithTransform ? W : never; /** Instance type of a component constructor; `never` for non-class components (string / HTMLElement refs). */ type InstanceOf = C extends abstract new (...args: never[]) => infer I ? I : never; /** Keys of the `input()` signal fields on a component instance. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any type InputFieldKeys = { [K in keyof I]-?: I[K] extends InputSignalWithTransform ? K : never }[keyof I]; /** diff --git a/projects/kit/src/lib/utils/array.spec.ts b/projects/kit/src/lib/utils/array.spec.ts index 19954e4..d4e778d 100644 --- a/projects/kit/src/lib/utils/array.spec.ts +++ b/projects/kit/src/lib/utils/array.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ import { arrayConcatById } from './array'; interface Row { From be1f7ff41460d908666b8b58deb378f38d00ad27 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 22 Jul 2026 22:41:04 +0900 Subject: [PATCH 7/7] fix: serialize user scoped offline commands --- .../lib/offline-replica-pull.service.spec.ts | 79 +++++ .../src/lib/offline-replica-pull.service.ts | 13 +- .../offline/src/lib/offline-replica-puller.ts | 2 +- .../src/lib/offline-repository.spec.ts | 22 ++ .../kit/offline/src/lib/offline-repository.ts | 10 +- .../src/lib/offline-sync.service.spec.ts | 331 ++++++++++++++++++ .../offline/src/lib/offline-sync.service.ts | 38 +- .../src/lib/sqlite-offline-repository.spec.ts | 58 +++ .../src/lib/sqlite-offline-repository.ts | 12 +- 9 files changed, 545 insertions(+), 20 deletions(-) diff --git a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts index b6d3175..01f358a 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.spec.ts @@ -903,6 +903,85 @@ describe('OfflineReplicaPullService', () => { }); }); + it('acknowledgedCommandIds欠落changeは外部変更として受理する', async () => { + await repository.transactReplica({ + putRows: [ + { + ...scope, + sourceKey: 'test_items', + localId: '019d-external', + serverId: 42, + values: { id: 42, title: 'Local baseline' }, + confirmedValues: { id: 42, title: 'Local baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }, + ], + }); + pull.mockResolvedValueOnce( + page( + [ + { + sourceKey: 'test_items', + serverId: 42, + serverRevision: 2, + values: { id: 42, title: 'Remote edit' }, + deleted: false, + }, + ], + { nextCursor: 'cursor-v1' }, + ), + ); + + await service.pull(scope); + + await expect(repository.getReplicaRow(scope, 'test_items', '019d-external')).resolves.toMatchObject({ + values: { title: 'Remote edit' }, + confirmedValues: { title: 'Remote edit' }, + serverRevision: 2, + syncState: 'confirmed', + }); + }); + + it('optional getCommandsForUser未実装repositoryでもpullがthrowしない', async () => { + TestBed.resetTestingModule(); + pull = vi.fn(async () => page([])); + TestBed.configureTestingModule({ + providers: [ + OfflineReplicaPullService, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema } }, + { provide: OFFLINE_REPLICA_PULLER, useValue: { pull } }, + { + provide: OFFLINE_COMMAND_HOOKS, + useValue: { entityType: (command: Pick) => command.aggregateType }, + }, + { + provide: OFFLINE_COMMAND_EXECUTOR, + useValue: { + execute: vi.fn(), + withServerRevision: (command: OfflineCommand, revision: string | number) => ({ + ...command, + baseRevision: revision, + }), + }, + }, + { + provide: OFFLINE_REPOSITORY, + useValue: { + getReplicaCursor: vi.fn(async () => null), + getCommands: vi.fn(async () => []), + transactReplica: vi.fn(async () => undefined), + getReplicaRow: vi.fn(async () => null), + getReplicaRowByServerId: vi.fn(async () => null), + }, + }, + ], + }); + service = TestBed.inject(OfflineReplicaPullService); + await expect(service.pull(scope)).resolves.toBeUndefined(); + }); + it('non-finite numeric serverRevisionはrejectしcursorを進めない', async () => { await expectPullRejectsPreservingCursor( () => pull.mockResolvedValueOnce(page([{ ...itemChange(42, 'Created'), serverRevision: Number.NaN }], { nextCursor: 'cursor-v1' })), 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 934b4c1..a317bf1 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -40,7 +40,7 @@ export class OfflineReplicaPullService { } const scopeCommands = await this.#repository.getCommands(scope); - const userCommands = await this.#repository.getCommandsForUser(scope.userId); + const userCommands = this.#repository.getCommandsForUser ? await this.#repository.getCommandsForUser(scope.userId) : scopeCommands; const changes = this.#collapseChanges(page.changes); const putRows: OfflineReplicaRow[] = []; const removeRows: OfflineReplicaRowKey[] = []; @@ -50,7 +50,7 @@ export class OfflineReplicaPullService { for (const change of changes) { const schema = this.#entitySchema(change.sourceKey); const commands = schema.scope === 'user' ? userCommands : scopeCommands; - const acknowledged = change.acknowledgedCommandIds + const acknowledged = (change.acknowledgedCommandIds ?? []) .map((commandId) => { const command = commands.find((candidate) => candidate.commandId === commandId); if (!command) return null; @@ -189,8 +189,9 @@ export class OfflineReplicaPullService { } const acknowledgedCommandIds = change['acknowledgedCommandIds']; if ( - !Array.isArray(acknowledgedCommandIds) || - acknowledgedCommandIds.some((commandId) => typeof commandId !== 'string' || commandId.length === 0) + (acknowledgedCommandIds !== undefined && !Array.isArray(acknowledgedCommandIds)) || + (Array.isArray(acknowledgedCommandIds) && + acknowledgedCommandIds.some((commandId) => typeof commandId !== 'string' || commandId.length === 0)) ) { throw new Error(`${label}.acknowledgedCommandIds must be an array of non-empty strings.`); } @@ -229,7 +230,7 @@ export class OfflineReplicaPullService { const previous = collapsed.get(key); collapsed.set(key, { ...change, - acknowledgedCommandIds: [...new Set([...(previous?.acknowledgedCommandIds ?? []), ...change.acknowledgedCommandIds])], + acknowledgedCommandIds: [...new Set([...(previous?.acknowledgedCommandIds ?? []), ...(change.acknowledgedCommandIds ?? [])])], }); } return [...collapsed.values()]; @@ -244,7 +245,7 @@ export class OfflineReplicaPullService { putCommands: Map, removeCommandIds: Set, ): void { - const acknowledgedIds = new Set(change.acknowledgedCommandIds); + const acknowledgedIds = new Set(change.acknowledgedCommandIds ?? []); const lastAcknowledgedIndex = related.reduce((last, command, index) => (acknowledgedIds.has(command.commandId) ? index : last), -1); if (lastAcknowledgedIndex < 0) { throw new Error(`Replica acknowledgement does not match the local aggregate outbox.`); diff --git a/projects/kit/offline/src/lib/offline-replica-puller.ts b/projects/kit/offline/src/lib/offline-replica-puller.ts index 979c136..e3c3732 100644 --- a/projects/kit/offline/src/lib/offline-replica-puller.ts +++ b/projects/kit/offline/src/lib/offline-replica-puller.ts @@ -15,7 +15,7 @@ export interface OfflineReplicaChange { serverId: number; serverRevision: string | number; /** Idempotency command ids durably recorded by the server and reflected in this final row state. */ - acknowledgedCommandIds: readonly string[]; + acknowledgedCommandIds?: readonly string[]; values: unknown | null; deleted: boolean; } diff --git a/projects/kit/offline/src/lib/offline-repository.spec.ts b/projects/kit/offline/src/lib/offline-repository.spec.ts index bb3678d..8f1214e 100644 --- a/projects/kit/offline/src/lib/offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/offline-repository.spec.ts @@ -708,6 +708,28 @@ describe('IonicOfflineRepository', () => { }); }); + it('同一createdAtはcommandId昇順で決定的に並べる', async () => { + const base: Omit = { + userId: 1, + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.update', + payload: {}, + optimisticValue: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'pending' as const, + attempts: 0, + retryAt: null, + lastErrorCode: null, + }; + await repository.putCommand({ ...base, groupId: 10, commandId: 'cmd-z', createdAt: 10 }); + await repository.putCommand({ ...base, groupId: 10, commandId: 'cmd-a', createdAt: 10 }); + await repository.putCommand({ ...base, groupId: 11, commandId: 'cmd-m', createdAt: 10 }); + expect((await repository.getCommands({ userId: 1, groupId: 10 })).map((item) => item.commandId)).toEqual(['cmd-a', 'cmd-z']); + expect((await repository.getCommandsForUser!(1)).map((item) => item.commandId)).toEqual(['cmd-a', 'cmd-m', 'cmd-z']); + }); + it('outboxを作成順で保持し、group削除時はそのscopeだけを消す', async () => { const base: Omit = { userId: 1, diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index d6b72a0..2d34cdc 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -96,7 +96,7 @@ export interface OfflineRepository { ): Promise | null>; getReplicaCursor(scope: OfflineScope): Promise; getCommands(scope: OfflineScope): Promise; - getCommandsForUser(userId: number): Promise; + getCommandsForUser?(userId: number): Promise; putCommand(command: OfflineCommand): Promise; replaceCommand(command: OfflineCommand): Promise; removeCommand(commandId: string): Promise; @@ -143,6 +143,10 @@ const OUTBOX_KEY = 'offline:outbox:commands'; const REPLICA_TRANSACTION_KEY = 'offline:replica:transaction'; const REPLICA_SCHEMA_MIGRATION_KEY = 'offline:replica:schema-migration'; +function compareOfflineCommands(left: OfflineCommand, right: OfflineCommand): number { + return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0); +} + /** WebはIonic StorageのIndexedDB driverを利用する。 */ @Injectable({ providedIn: 'root' }) export class IonicOfflineRepository implements OfflineRepository { @@ -244,7 +248,7 @@ export class IonicOfflineRepository implements OfflineRepository { const commands = await this.#readRecord(OUTBOX_KEY); return Object.values(commands) .filter((command) => command.userId === scope.userId && command.groupId === scope.groupId) - .sort((left, right) => left.createdAt - right.createdAt); + .sort(compareOfflineCommands); } async getCommandsForUser(userId: number): Promise { @@ -253,7 +257,7 @@ export class IonicOfflineRepository implements OfflineRepository { const commands = await this.#readRecord(OUTBOX_KEY); return Object.values(commands) .filter((command) => command.userId === userId) - .sort((left, right) => left.createdAt - right.createdAt); + .sort(compareOfflineCommands); } async putCommand(command: OfflineCommand): Promise { 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 2e677d4..35add84 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -316,6 +316,36 @@ describe('OfflineSyncService', () => { }); }); + it('同一ミリ秒のDate.nowでもcreatedAtは単調増加で保存する', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '1', + operation: 'documents.upsert', + payload: { seq: 1 }, + optimisticValue: { seq: 1 }, + }, + { flush: false }, + ); + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: '2', + operation: 'documents.upsert', + payload: { seq: 2 }, + optimisticValue: { seq: 2 }, + }, + { flush: false }, + ); + const createdAt = commands.map((command) => command.createdAt); + expect(createdAt[0]).toBeLessThan(createdAt[1]!); + expect(service.pendingCommands().map((command) => command.createdAt)).toEqual(createdAt); + nowSpy.mockRestore(); + }); + it('locale非依存のkey順で同じJSON payloadを同一hashにする', async () => { await service.enqueue( { @@ -680,4 +710,305 @@ describe('OfflineSyncService', () => { connected.set(true); await expect(service.flush()).rejects.toThrow('Offline command returned invalid serverRevision NaN.'); }); + + describe('user-scoped cross-group aggregate fixes', () => { + const userReplicaSchema = defineOfflineReplicaSchema({ + version: 1, + entities: [ + defineReplicaEntity<{ id: number; title: string }>()({ + table: 'test_items', + sourceKey: 'test_items', + scope: 'user', + fields: { + id: serverId(), + title: text(), + }, + }), + defineReplicaEntity<{ id: number; name: string }>()({ + table: 'test_group_items', + sourceKey: 'test_group_items', + scope: 'group', + fields: { + id: serverId(), + name: text(), + }, + }), + ], + migrations: [], + }); + const multiScopeSession = { userId: 1, scopes: [{ userId: 1, groupId: 10 }, { userId: 1, groupId: 11 }] as OfflineScope[] }; + const userScopedSourceKeys = new Set(['test_items']); + + function compareCommands(left: OfflineCommand, right: OfflineCommand): number { + return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0); + } + + function findReplicaRow(scope: OfflineScope, sourceKey: string, localId: string): OfflineReplicaRow | undefined { + return rows.find((item) => { + if (item.userId !== scope.userId || item.sourceKey !== sourceKey || item.localId !== localId) return false; + return userScopedSourceKeys.has(sourceKey) ? true : item.groupId === scope.groupId; + }); + } + + function projectReplicaRow(row: OfflineReplicaRow, scope: OfflineScope): OfflineReplicaRow { + return userScopedSourceKeys.has(row.sourceKey) ? { ...row, groupId: scope.groupId } : row; + } + + beforeEach(() => { + TestBed.resetTestingModule(); + commands = []; + rows = []; + connected = signal(false); + session = multiScopeSession; + beforePutCommand = null; + pull = vi.fn(async () => undefined); + handleError = vi.fn(); + execute.mockReset(); + execute.mockResolvedValue({ response: null }); + const repository = { + initialize: vi.fn(async () => undefined), + getCommands: vi.fn(async (scope: OfflineScope) => + commands + .filter((item) => item.userId === scope.userId && item.groupId === scope.groupId) + .sort(compareCommands), + ), + getCommandsForUser: vi.fn(async (userId: number) => commands.filter((item) => item.userId === userId).sort(compareCommands)), + putCommand: vi.fn(async (command: OfflineCommand) => { + await beforePutCommand?.(command); + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + commands.sort(compareCommands); + }), + replaceCommand: vi.fn(async (command: OfflineCommand) => { + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + commands.sort(compareCommands); + }), + removeCommand: vi.fn(async (commandId: string) => { + commands = commands.filter((item) => item.commandId !== commandId); + }), + getReplicaRow: vi.fn(async (scope: OfflineScope, sourceKey: string, localId: string) => { + const row = findReplicaRow(scope, sourceKey, localId); + return row ? projectReplicaRow(row, scope) : null; + }), + getReplicaCursor: vi.fn(async () => null), + transactReplica: vi.fn(async (transaction) => { + for (const row of transaction.putRows ?? []) { + const existing = findReplicaRow(row, row.sourceKey, row.localId); + rows = rows.filter( + (item) => + item.userId !== row.userId || + item.sourceKey !== row.sourceKey || + item.localId !== row.localId || + (!userScopedSourceKeys.has(row.sourceKey) && item.groupId !== row.groupId), + ); + rows.push(structuredClone(existing ? { ...existing, ...row } : row)); + } + for (const key of transaction.removeRows ?? []) { + rows = rows.filter( + (item) => + item.userId !== key.userId || + item.sourceKey !== key.sourceKey || + item.localId !== key.localId || + (!userScopedSourceKeys.has(key.sourceKey) && item.groupId !== key.groupId), + ); + } + for (const command of transaction.putCommands ?? []) { + commands = commands.filter((item) => item.commandId !== command.commandId); + commands.push(structuredClone(command)); + } + commands = commands.filter((command) => !(transaction.removeCommandIds ?? []).includes(command.commandId)); + commands.sort(compareCommands); + }), + } as unknown as OfflineRepository; + TestBed.configureTestingModule({ + providers: [ + OfflineSyncService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: { connected } }, + { provide: OFFLINE_KIT_OPTIONS, useValue: { databaseName: 'test-offline', replicaSchema: userReplicaSchema } }, + { provide: OfflineReplicaPullService, useValue: { pull } }, + { provide: ErrorHandler, useValue: { handleError } }, + { + provide: OFFLINE_SYNC_CONTEXT, + useValue: { getSession: vi.fn(async () => session) }, + }, + { + provide: OFFLINE_COMMAND_EXECUTOR, + useValue: { + execute, + withServerRevision: (command: OfflineCommand, revision: string | number) => ({ + ...command, + baseRevision: revision, + }), + }, + }, + ], + }); + service = TestBed.inject(OfflineSyncService); + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'test_items', + localId: '019d-user-item', + serverId: 42, + values: { id: 42, title: 'Baseline' }, + confirmedValues: { id: 42, title: 'Baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }); + }); + + it('同一user rowの別group commandは1 aggregateに直列化し、先頭完了後に後続をrebaseする', async () => { + let resolveFirst!: (value: OfflineCommandResult) => void; + let resolveSecond!: (value: OfflineCommandResult) => void; + execute + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))); + await service.enqueue( + { + groupId: 10, + aggregateType: 'test_items', + aggregateLocalId: '019d-user-item', + operation: 'test_items.update', + payload: { title: 'G10 edit' }, + optimisticValue: { id: 42, title: 'G10 edit' }, + baseRevision: 1, + }, + { flush: false }, + ); + const secondId = await service.enqueue( + { + groupId: 11, + aggregateType: 'test_items', + aggregateLocalId: '019d-user-item', + operation: 'test_items.update', + payload: { title: 'G11 edit' }, + optimisticValue: { id: 42, title: 'G11 edit' }, + baseRevision: 1, + }, + { flush: false }, + ); + connected.set(true); + const flush = service.flush(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce()); + resolveFirst({ serverRevision: 2, confirmedValues: { id: 42, title: 'G10 edit' }, response: null }); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + expect(execute.mock.calls[1]?.[0]).toMatchObject({ + commandId: secondId, + baseRevision: 2, + optimisticValue: { id: 42, title: 'G11 edit' }, + }); + expect(findReplicaRow({ userId: 1, groupId: 10 }, 'test_items', '019d-user-item')).toMatchObject({ + values: { title: 'G11 edit' }, + confirmedValues: { title: 'G10 edit' }, + serverRevision: 2, + syncState: 'pending', + }); + resolveSecond({ serverRevision: 3, confirmedValues: { id: 42, title: 'G11 edit' }, response: null }); + await flush; + expect(execute.mock.calls.map(([command]) => (command as OfflineCommand<{ title: string }>).payload)).toEqual([ + { title: 'G10 edit' }, + { title: 'G11 edit' }, + ]); + expect(service.pendingCount()).toBe(0); + }); + + it('cross-group commandの一方discardでも他方のoptimistic valueを保持する', async () => { + const firstId = await service.enqueue( + { + groupId: 10, + aggregateType: 'test_items', + aggregateLocalId: '019d-user-item', + operation: 'test_items.update', + payload: { title: 'G10 edit' }, + optimisticValue: { id: 42, title: 'G10 edit' }, + baseRevision: 1, + }, + { flush: false }, + ); + await service.enqueue( + { + groupId: 11, + aggregateType: 'test_items', + aggregateLocalId: '019d-user-item', + operation: 'test_items.update', + payload: { title: 'G11 edit' }, + optimisticValue: { id: 42, title: 'G11 edit' }, + baseRevision: 1, + }, + { flush: false }, + ); + await service.discard(firstId, { flush: false }); + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ groupId: 11, optimisticValue: { id: 42, title: 'G11 edit' } }); + expect(findReplicaRow({ userId: 1, groupId: 11 }, 'test_items', '019d-user-item')).toMatchObject({ + values: { title: 'G11 edit' }, + confirmedValues: { title: 'Baseline' }, + syncState: 'pending', + }); + }); + + it('group-scopedの同一localIdはgroupごとに独立aggregateのまま並列送信する', async () => { + let resolveFirst!: (value: OfflineCommandResult) => void; + execute.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))); + execute.mockResolvedValueOnce({ serverRevision: 2, confirmedValues: { id: 55, name: 'G11 name' }, response: null }); + rows.push({ + userId: 1, + groupId: 11, + sourceKey: 'test_group_items', + localId: '019d-group-same', + serverId: 55, + values: { id: 55, name: 'G11 baseline' }, + confirmedValues: { id: 55, name: 'G11 baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }); + rows.push({ + userId: 1, + groupId: 10, + sourceKey: 'test_group_items', + localId: '019d-group-same', + serverId: 56, + values: { id: 56, name: 'G10 baseline' }, + confirmedValues: { id: 56, name: 'G10 baseline' }, + serverRevision: 1, + fetchedAt: 1, + syncState: 'confirmed', + }); + await service.enqueue( + { + groupId: 10, + aggregateType: 'test_group_items', + aggregateLocalId: '019d-group-same', + operation: 'test_group_items.update', + payload: { name: 'G10 name' }, + optimisticValue: { id: 56, name: 'G10 name' }, + baseRevision: 1, + }, + { flush: false }, + ); + await service.enqueue( + { + groupId: 11, + aggregateType: 'test_group_items', + aggregateLocalId: '019d-group-same', + operation: 'test_group_items.update', + payload: { name: 'G11 name' }, + optimisticValue: { id: 55, name: 'G11 name' }, + baseRevision: 1, + }, + { flush: false }, + ); + connected.set(true); + const flush = service.flush(); + await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2)); + resolveFirst({ serverRevision: 2, confirmedValues: { id: 56, name: 'G10 name' }, response: null }); + await flush; + expect(execute.mock.calls.map(([command]) => command.groupId).sort()).toEqual([10, 11]); + expect(service.pendingCount()).toBe(0); + }); + }); }); diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 6df56d6..ca027a5 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -1,6 +1,7 @@ import { computed, effect, ErrorHandler, inject, Injectable, signal } from '@angular/core'; import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT, type OfflineCommandResult } from './offline-command-executor'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; import { OfflineReplicaPullService } from './offline-replica-pull.service'; import type { OfflineCommand, OfflineReplicaSyncState, OfflineReplicaRow, OfflineReplicaRowKey, OfflineScope } from './offline-repository'; @@ -41,6 +42,7 @@ export class OfflineSyncService { readonly #executor = inject(OFFLINE_COMMAND_EXECUTOR); readonly #context = inject(OFFLINE_SYNC_CONTEXT); readonly #hooks = inject(OFFLINE_COMMAND_HOOKS); + readonly #options = inject(OFFLINE_KIT_OPTIONS); readonly #pull = inject(OfflineReplicaPullService); readonly #errorHandler = inject(ErrorHandler); readonly #commands = signal([]); @@ -52,6 +54,7 @@ export class OfflineSyncService { #enqueueTail: Promise = Promise.resolve(); #retryTimer: ReturnType | null = null; #initialized = false; + #lastCommandCreatedAt = 0; readonly pendingCommands = this.#commands.asReadonly(); readonly pendingCount = computed(() => this.pendingCommands().length); @@ -145,7 +148,7 @@ export class OfflineSyncService { state: 'pending', attempts: 0, retryAt: null, - createdAt: Date.now(), + createdAt: await this.#nextCommandCreatedAt(userId), lastErrorCode: null, }; const entityType = this.#entityType(command); @@ -235,7 +238,7 @@ export class OfflineSyncService { const now = Date.now(); const groups = new Map(); for (const command of commands) { - const key = `${command.userId}:${command.groupId}:${command.aggregateType}:${command.aggregateLocalId}`; + const key = this.#aggregateKey(command); const group = groups.get(key) ?? []; group.push(command); groups.set(key, group); @@ -384,7 +387,11 @@ export class OfflineSyncService { } #aggregateKey(command: OfflineCommand): string { - return `${command.userId}:${command.groupId}:${command.aggregateType}:${command.aggregateLocalId}`; + const sourceKey = this.#entityType(command); + const schema = this.#options.replicaSchema.entities.find((entity) => entity.sourceKey === sourceKey); + if (!schema) throw new Error(`Unknown offline replica source key "${sourceKey}".`); + const partition = schema.scope === 'user' ? 'user' : `group:${command.groupId}`; + return `${command.userId}:${partition}:${sourceKey}:${command.aggregateLocalId}`; } #failedCommand(command: OfflineCommand, error: unknown): OfflineCommand { @@ -445,12 +452,29 @@ export class OfflineSyncService { if (this.#activeUserId === userId) return; this.#knownScopes.clear(); this.#activeUserId = userId; + this.#lastCommandCreatedAt = 0; } async #readKnownCommands(): Promise { - return (await Promise.all([...this.#knownScopes.values()].map((scope) => this.#repository.getCommands(scope)))) + const commands = (await Promise.all([...this.#knownScopes.values()].map((scope) => this.#repository.getCommands(scope)))) .flat() - .sort((left, right) => left.createdAt - right.createdAt); + .sort(compareOfflineCommands); + this.#rememberCreatedAt(commands); + return commands; + } + + async #nextCommandCreatedAt(userId: number): Promise { + const commands = this.#repository.getCommandsForUser + ? await this.#repository.getCommandsForUser(userId) + : await this.#readKnownCommands(); + this.#rememberCreatedAt(commands); + const createdAt = Math.max(Date.now(), this.#lastCommandCreatedAt + 1); + this.#lastCommandCreatedAt = createdAt; + return createdAt; + } + + #rememberCreatedAt(commands: readonly OfflineCommand[]): void { + for (const command of commands) this.#lastCommandCreatedAt = Math.max(this.#lastCommandCreatedAt, command.createdAt); } async #refreshState(generation = this.#generation): Promise { @@ -539,3 +563,7 @@ export class OfflineSyncService { return `${scope.userId}:${scope.groupId}`; } } + +function compareOfflineCommands(left: OfflineCommand, right: OfflineCommand): number { + return left.createdAt - right.createdAt || (left.commandId < right.commandId ? -1 : left.commandId > right.commandId ? 1 : 0); +} diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts index e2bcfa4..61d8e4d 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -147,6 +147,64 @@ describe('SqliteOfflineRepository Capawesome adapter', () => { expect(plugin.rollbackTransaction).not.toHaveBeenCalled(); }); + it('getCommandsはcreated_atとcommand_id昇順でSQL ORDER BYする', async () => { + const repository = createRepository(); + await repository.initialize(); + await repository.putCommand({ + userId: 1, + groupId: 10, + commandId: 'cmd-z', + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.update', + payload: {}, + optimisticValue: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 10, + lastErrorCode: null, + }); + await repository.putCommand({ + userId: 1, + groupId: 10, + commandId: 'cmd-a', + aggregateType: 'test_items', + aggregateLocalId: '019d-aaaa', + operation: 'test_items.update', + payload: {}, + optimisticValue: {}, + payloadHash: 'hash', + baseRevision: null, + state: 'pending', + attempts: 0, + retryAt: null, + createdAt: 10, + lastErrorCode: null, + }); + + await repository.getCommands({ userId: 1, groupId: 10 }); + await repository.getCommandsForUser(1); + + const scopeQuery = plugin.query.mock.calls.find(([options]) => { + const statement = (options as { statement: string }).statement; + return ( + statement === + 'SELECT * FROM offline_sync_commands WHERE user_id = ? AND group_id = ? ORDER BY created_at ASC, command_id ASC' + ); + })?.[0] as { statement: string } | undefined; + const userQuery = plugin.query.mock.calls.find(([options]) => { + const statement = (options as { statement: string }).statement; + return statement === 'SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC, command_id ASC'; + })?.[0] as { statement: string } | undefined; + expect(scopeQuery?.statement).toBe( + 'SELECT * FROM offline_sync_commands WHERE user_id = ? AND group_id = ? ORDER BY created_at ASC, command_id ASC', + ); + expect(userQuery?.statement).toBe('SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC, command_id ASC'); + }); + it('replicaとoutboxを単一transactionで更新する', async () => { const repository = createRepository(); await repository.initialize(); diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index 7a50d13..04ae86c 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -183,15 +183,17 @@ export class SqliteOfflineRepository implements OfflineRepository { } async getCommands(scope: OfflineScope): Promise { - const rows = await this.#query('SELECT * FROM offline_sync_commands WHERE user_id = ? AND group_id = ? ORDER BY created_at ASC', [ - scope.userId, - scope.groupId, - ]); + const rows = await this.#query( + 'SELECT * FROM offline_sync_commands WHERE user_id = ? AND group_id = ? ORDER BY created_at ASC, command_id ASC', + [scope.userId, scope.groupId], + ); return rows.map((row) => this.#command(row)); } async getCommandsForUser(userId: number): Promise { - const rows = await this.#query('SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC', [userId]); + const rows = await this.#query('SELECT * FROM offline_sync_commands WHERE user_id = ? ORDER BY created_at ASC, command_id ASC', [ + userId, + ]); return rows.map((row) => this.#command(row)); }