From b3c55ad8c3805a3dab03ba9fd3fd3ad3fc664edb Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 13 Aug 2026 09:25:57 +0900 Subject: [PATCH] Harden offline storage and acknowledgment boundaries --- .../src/lib/offline-command-executor.ts | 27 +- .../lib/offline-coordinator.service.spec.ts | 149 +++++- .../src/lib/offline-coordinator.service.ts | 62 ++- .../offline/src/lib/offline-kit-options.ts | 29 + .../kit/offline/src/lib/offline-provider.ts | 5 + .../lib/offline-replica-pull.service.spec.ts | 10 + .../src/lib/offline-replica-pull.service.ts | 22 +- .../kit/offline/src/lib/offline-storage.ts | 50 ++ .../src/lib/offline-sync.service.spec.ts | 502 ++++++++++++++++++ .../offline/src/lib/offline-sync.service.ts | 91 +++- .../src/lib/sqlite-offline-repository.spec.ts | 166 +++++- .../src/lib/sqlite-offline-repository.ts | 118 ++-- projects/kit/offline/src/public-api.ts | 1 + 13 files changed, 1173 insertions(+), 59 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-storage.ts diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts index bcd86bd..ee39988 100644 --- a/projects/kit/offline/src/lib/offline-command-executor.ts +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -1,5 +1,11 @@ import { InjectionToken } from '@angular/core'; -import type { OfflineCommand, OfflineOptimisticReplicaCompanion, OfflineReplicaRow, OfflineScope } from './offline-repository'; +import type { + OfflineCommand, + OfflineOptimisticReplicaCompanion, + OfflineReplicaRow, + OfflineReplicaRowKey, + OfflineScope, +} from './offline-repository'; import type { OfflineCommandIdentity, OfflinePrincipalId, OfflineReplicaIdentity } from './offline-identity'; import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema'; @@ -10,6 +16,14 @@ export interface OfflineCommandResult { serverRevision?: string | number; /** Full server-confirmed domain values after applying the mutation. */ confirmedValues?: unknown; + /** + * Server-confirmed local-only projection changes owned by this command. + * + * Kit validates that every row belongs to the command's declared optimistic + * companion footprint, then commits these changes atomically with the base + * row acknowledgement, command removal, and reconciliation marker. + */ + confirmedCompanions?: readonly OfflineConfirmedReplicaCompanion[]; /** Removes the local replica row after a confirmed server delete. */ removeReplica?: boolean; /** @@ -20,6 +34,17 @@ export interface OfflineCommandResult { response?: unknown; } +/** Local-only projection changes confirmed by one server acknowledgement. */ +export interface OfflineConfirmedReplicaCompanion { + /** Exact optimistic companion footprint entry owned by this command. */ + readonly key: OfflineReplicaRowKey; + /** + * Applies the acknowledged domain effect to the latest confirmed projection read inside Kit's + * ACK lane. Return `null` to remove the confirmed projection. + */ + readonly reduce: (latestConfirmedValues: unknown) => unknown | null; +} + /** Target identity resolved from the local replica immediately before transport. */ export type OfflineCommandTarget = | { readonly kind: 'generated'; readonly localId: string; readonly remoteId: OfflineGeneratedRemoteId | null } diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index c930693..ff6fb67 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -1,20 +1,31 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { OfflineCoordinatorService } from './offline-coordinator.service'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; import { OFFLINE_REPOSITORY, type OfflineScope } from './offline-repository'; import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service'; +import { OfflineStorageUnavailableError } from './offline-storage'; import { OfflineSyncService } from './offline-sync.service'; +import { defineOfflineReplicaSchema } from './offline-replica-schema'; describe('OfflineCoordinatorService', () => { afterEach(() => TestBed.resetTestingModule()); - function setup(manifest: OfflineSessionManifest | null = null) { + const emptyReplicaSchema = defineOfflineReplicaSchema({ version: 1, entities: [], migrations: [] }); + + function setup( + manifest: OfflineSessionManifest | null = null, + options: { + repositoryInitialize?: () => Promise; + onStorageUnavailable?: (error: OfflineStorageUnavailableError) => void | Promise; + } = {}, + ) { const order: string[] = []; const sessionState: { userId: number | null } = { userId: null }; const repository = { - initialize: vi.fn(async () => undefined), + initialize: vi.fn(options.repositoryInitialize ?? (async () => undefined)), }; const network = { state: signal('connected'), @@ -60,9 +71,25 @@ describe('OfflineCoordinatorService', () => { { provide: OfflineNetworkService, useValue: network }, { provide: OfflineSessionService, useValue: session }, { provide: OfflineSyncService, useValue: sync }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { + databaseName: 'test-offline', + replicaSchema: emptyReplicaSchema, + onStorageUnavailable: options.onStorageUnavailable, + }, + }, ], }); - return { coordinator: TestBed.inject(OfflineCoordinatorService), order, session, sessionState, sync }; + return { + coordinator: TestBed.inject(OfflineCoordinatorService), + order, + session, + sessionState, + sync, + network, + repository, + }; } it('restores local visibility without starting remote synchronization', async () => { @@ -207,6 +234,13 @@ describe('OfflineCoordinatorService', () => { { provide: OFFLINE_REPOSITORY, useValue: repository }, { provide: OfflineNetworkService, useValue: network }, { provide: OfflineSyncService, useValue: sync }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { + databaseName: 'test-offline', + replicaSchema: emptyReplicaSchema, + }, + }, ], }); const coordinator = TestBed.inject(OfflineCoordinatorService); @@ -225,4 +259,111 @@ describe('OfflineCoordinatorService', () => { expect(sync.refreshSession).toHaveBeenCalledWith(['2', '9']); }); + + describe('storage initialization failure', () => { + const storageError = new OfflineStorageUnavailableError( + 'core_schema_incompatible', + 'Unsupported offline storage schema version 999; expected 1.', + ); + + it('default fails closed and does not start session or sync', async () => { + const { coordinator, session, sync, network } = setup(null, { + repositoryInitialize: async () => { + throw storageError; + }, + }); + + await expect(coordinator.initialize()).rejects.toBe(storageError); + + expect(network.initialize).toHaveBeenCalledOnce(); + expect(session.initialize).not.toHaveBeenCalled(); + expect(sync.initialize).not.toHaveBeenCalled(); + expect(coordinator.storageState()).toEqual({ status: 'unavailable', error: storageError }); + expect(coordinator.isStorageReady()).toBe(false); + }); + + it('opt-in onStorageUnavailable completes initializer in unavailable state without session/sync', async () => { + const onStorageUnavailable = vi.fn(async () => undefined); + const { coordinator, session, sync, network } = setup(null, { + repositoryInitialize: async () => { + throw storageError; + }, + onStorageUnavailable, + }); + + await expect(coordinator.initialize()).resolves.toBeUndefined(); + + expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(storageError); + expect(network.initialize).toHaveBeenCalledOnce(); + expect(session.initialize).not.toHaveBeenCalled(); + expect(sync.initialize).not.toHaveBeenCalled(); + expect(coordinator.storageState()).toEqual({ status: 'unavailable', error: storageError }); + expect(coordinator.isStorageReady()).toBe(false); + }); + + it('short-circuits repository-backed public APIs after online-only degradation', async () => { + const { coordinator, session, sync } = setup(null, { + repositoryInitialize: async () => { + throw storageError; + }, + onStorageUnavailable: async () => undefined, + }); + await coordinator.initialize(); + + await expect(coordinator.prepareRemoteSession(7, ['10'], 'subject')).resolves.toBe(true); + await expect(coordinator.resumeRemoteSession({ foregroundScopeIds: ['10'] })).resolves.toBeUndefined(); + await expect(coordinator.activateOfflineSession('subject')).resolves.toBeNull(); + await expect(coordinator.flush()).resolves.toBeUndefined(); + await expect(coordinator.prepareLogout('sync')).resolves.toBe(true); + await expect(coordinator.clearActiveSession()).resolves.toBeUndefined(); + + expect(session.activateSession).not.toHaveBeenCalled(); + expect(session.activateOfflineSession).not.toHaveBeenCalled(); + expect(session.clearActiveSession).not.toHaveBeenCalled(); + expect(sync.resetSession).not.toHaveBeenCalled(); + expect(sync.refreshSession).not.toHaveBeenCalled(); + expect(sync.flush).not.toHaveBeenCalled(); + }); + + it('callback failure still fails initialization', async () => { + const callbackError = new Error('telemetry rejected'); + const onStorageUnavailable = vi.fn(async () => { + throw callbackError; + }); + const { coordinator, session, sync } = setup(null, { + repositoryInitialize: async () => { + throw storageError; + }, + onStorageUnavailable, + }); + + await expect(coordinator.initialize()).rejects.toBe(callbackError); + + expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(storageError); + expect(session.initialize).not.toHaveBeenCalled(); + expect(sync.initialize).not.toHaveBeenCalled(); + expect(coordinator.isStorageReady()).toBe(false); + }); + + it('wraps non-typed repository failures as storage_unavailable preserving cause', async () => { + const cause = new Error('disk full'); + const onStorageUnavailable = vi.fn(async () => undefined); + const { coordinator } = setup(null, { + repositoryInitialize: async () => { + throw cause; + }, + onStorageUnavailable, + }); + + await coordinator.initialize(); + + const state = coordinator.storageState(); + expect(state.status).toBe('unavailable'); + if (state.status !== 'unavailable') return; + expect(state.error).toBeInstanceOf(OfflineStorageUnavailableError); + expect(state.error.reason).toBe('storage_unavailable'); + expect(state.error.cause).toBe(cause); + expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(state.error); + }); + }); }); diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index 3c97625..b97e3d1 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -1,9 +1,14 @@ -import { inject, Injectable } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; import type { OfflinePrincipalId } from './offline-identity'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; import { OFFLINE_REPOSITORY } from './offline-repository'; import { OfflineSessionService } from './offline-session.service'; import type { OfflineSessionManifest, OfflineSessionTransitionLease } from './offline-session.service'; +import { + OfflineStorageUnavailableError, + type OfflineStorageState, +} from './offline-storage'; import { OfflineSyncService } from './offline-sync.service'; /** User choice when logout encounters unconfirmed local mutations. */ @@ -21,16 +26,51 @@ export class OfflineCoordinatorService { readonly #network = inject(OfflineNetworkService); readonly #sync = inject(OfflineSyncService); readonly #session = inject(OfflineSessionService); + readonly #options = inject(OFFLINE_KIT_OPTIONS); + readonly #storageState = signal({ status: 'initializing' }); #transitionRevision = 0; #transitionTail: Promise = Promise.resolve(); + /** + * Local storage readiness after {@link initialize}. + * + * When `unavailable`, the product opted into online-only startup; replica/outbox APIs must not be used. + */ + readonly storageState = this.#storageState.asReadonly(); + /** + * Product-policy guard: `true` only when encrypted local storage finished initialization successfully. + * + * Use this (or {@link storageState}) to disable offline mutations and replica reads when storage is unavailable. + */ + readonly isStorageReady = computed(() => this.#storageState().status === 'ready'); + readonly networkState = this.#network.state; readonly syncState = this.#sync.syncState; readonly pendingCount = this.#sync.pendingCount; readonly conflicts = this.#sync.conflicts; + /** + * Opens local storage, then session and sync. + * + * Repository failure without {@link OfflineKitOptions.onStorageUnavailable} throws + * {@link OfflineStorageUnavailableError}. When the callback is present and settles, this method + * resolves with {@link storageState} `unavailable` and skips session/sync initialization. + */ async initialize(): Promise { - await Promise.all([this.#repository.initialize(), this.#network.initialize()]); + const networkReady = this.#network.initialize(); + try { + await this.#repository.initialize(); + } catch (error) { + await networkReady; + const typed = this.#asStorageUnavailable(error); + this.#storageState.set({ status: 'unavailable', error: typed }); + const onUnavailable = this.#options.onStorageUnavailable; + if (!onUnavailable) throw typed; + await onUnavailable(typed); + return; + } + await networkReady; + this.#storageState.set({ status: 'ready' }); await this.#session.initialize(); await this.#sync.initialize(); } @@ -47,6 +87,7 @@ export class OfflineCoordinatorService { authSubject: string | null, authLease?: OfflineSessionTransitionLease, ): Promise { + if (this.#storageUnavailable()) return Promise.resolve(true); const revision = ++this.#transitionRevision; const lease = this.#lease(revision, authLease); return this.#enqueueTransition(async () => { @@ -59,6 +100,7 @@ export class OfflineCoordinatorService { /** Starts pull and outbox replay after the caller has published remote access. */ async resumeRemoteSession(options?: OfflineResumeRemoteSessionOptions): Promise { + if (this.#storageUnavailable()) return; await this.#sync.refreshSession(options?.foregroundScopeIds); } @@ -66,6 +108,7 @@ export class OfflineCoordinatorService { * Activates a restored identity for local replica/outbox use without enabling transport sync. */ activateOfflineSession(authSubject?: string | null, authLease?: OfflineSessionTransitionLease): Promise { + if (this.#storageUnavailable()) return Promise.resolve(null); const revision = ++this.#transitionRevision; const lease = this.#lease(revision, authLease); return this.#enqueueTransition(async () => { @@ -78,6 +121,7 @@ export class OfflineCoordinatorService { } clearActiveSession(): Promise { + if (this.#storageUnavailable()) return Promise.resolve(); this.#sync.revokeSession(); this.#session.revokeAccess(); ++this.#transitionRevision; @@ -88,6 +132,7 @@ export class OfflineCoordinatorService { } async prepareLogout(action: OfflineLogoutAction): Promise { + if (this.#storageUnavailable()) return action !== 'cancel'; if (action === 'cancel') return false; if (action === 'discard') { await this.#sync.discardAllPending(); @@ -98,9 +143,22 @@ export class OfflineCoordinatorService { } flush(): Promise { + if (this.#storageUnavailable()) return Promise.resolve(); return this.#sync.flush(); } + #asStorageUnavailable(error: unknown): OfflineStorageUnavailableError { + if (error instanceof OfflineStorageUnavailableError) return error; + const message = error instanceof Error ? error.message : 'Offline storage is unavailable.'; + return new OfflineStorageUnavailableError('storage_unavailable', message || 'Offline storage is unavailable.', { + cause: error, + }); + } + + #storageUnavailable(): boolean { + return this.#storageState().status === 'unavailable'; + } + #lease(revision: number, authLease?: OfflineSessionTransitionLease): OfflineSessionTransitionLease { return { isCurrent: () => revision === this.#transitionRevision && (authLease?.isCurrent() ?? true) }; } diff --git a/projects/kit/offline/src/lib/offline-kit-options.ts b/projects/kit/offline/src/lib/offline-kit-options.ts index b8bc744..1d6b309 100644 --- a/projects/kit/offline/src/lib/offline-kit-options.ts +++ b/projects/kit/offline/src/lib/offline-kit-options.ts @@ -1,5 +1,6 @@ import { InjectionToken } from '@angular/core'; import type { OfflineReplicaSchemaBundle } from './offline-replica-schema'; +import type { OfflineStorageUnavailableError } from './offline-storage'; /** Backpressure limits for durable commands. Pending commands are never evicted automatically. */ export interface OfflineOutboxLimits { @@ -19,8 +20,36 @@ export interface OfflineKitOptions { createEncryptionKey?: () => Promise; /** Versioned product replica schema applied to native SQLite during initialization. */ replicaSchema: OfflineReplicaSchemaBundle; + /** + * Product wire protocol fingerprint exchanged with the synchronization server. + * + * Keep this independent from {@link replicaSchema}: local-only tables and + * storage migrations must not force a server rollout. When omitted, Kit uses + * the replica schema fingerprint for backward compatibility. + */ + wireProtocol?: OfflineWireProtocolFingerprint; /** Optional durable Outbox backpressure policy. */ outboxLimits?: OfflineOutboxLimits; + /** + * Optional product callback invoked when local storage initialization fails. + * + * Providing this callback opts the application into online-only startup degradation: + * after the callback settles successfully, the app initializer completes with + * {@link OfflineCoordinatorService.storageState} `unavailable` and session/sync are not started. + * When omitted, initialization throws {@link OfflineStorageUnavailableError} as before. + * If the callback throws or rejects, initialization still fails. + * + * Kit never deletes Outbox or replica data in response to storage failure; any reset is product-owned. + */ + onStorageUnavailable?: (error: OfflineStorageUnavailableError) => void | Promise; +} + +/** Exact versioned wire contract exchanged by pull transport. */ +export interface OfflineWireProtocolFingerprint { + /** Monotonic product wire protocol version. */ + readonly version: number; + /** Deterministic fingerprint of the pull/push wire contract. */ + readonly hash: string; } /** 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 c3c998c..8f01ee4 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -89,6 +89,9 @@ const READ_CACHE_ONLY_REPLICA_PULLER: OfflineReplicaPuller = { * Web uses Ionic Storage. Native iOS/Android uses encrypted `@capacitor-community/sqlite`. The application owns * URL/DTO policy and command execution; the kit owns persistence, ordering, retries, and session * isolation. + * + * Optional {@link OfflineKitOptions.onStorageUnavailable} opts into online-only startup when + * local storage cannot be opened; without it, the app initializer still throws. */ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProviders { const synchronized = options.mode !== 'readCacheOnly'; @@ -101,7 +104,9 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi databaseName: options.databaseName, createEncryptionKey: options.createEncryptionKey, replicaSchema: options.replicaSchema, + wireProtocol: options.wireProtocol, outboxLimits: options.outboxLimits, + onStorageUnavailable: options.onStorageUnavailable, }, }, { 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 17cf942..2de9f85 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 @@ -504,6 +504,16 @@ describe('OfflineReplicaPullService', () => { expect(pull.mock.calls[0]?.[0].schemaHash).toBe(schemaHash); }); + it('wire protocol fingerprintをlocal replica schemaから独立して送受信検証する', async () => { + const options = TestBed.inject(OFFLINE_KIT_OPTIONS); + options.wireProtocol = { version: 7, hash: 'wire-v7' }; + pull.mockResolvedValueOnce(page([], { nextCursor: '', schemaVersion: 7, schemaHash: 'wire-v7' })); + + await service.pull(scope); + + expect(pull).toHaveBeenCalledWith(expect.objectContaining({ schemaVersion: 7, schemaHash: 'wire-v7' })); + }); + it('multi-page cursor progressionでstored cursorをページングする', async () => { await repository.transactReplica({ putCursors: [{ ...scope, cursor: 'cursor-v0' }] }); pull 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 ba36c4c..ee85151 100644 --- a/projects/kit/offline/src/lib/offline-replica-pull.service.ts +++ b/projects/kit/offline/src/lib/offline-replica-pull.service.ts @@ -75,7 +75,11 @@ export class OfflineReplicaPullService { async pull(scope: OfflineScope): Promise { if (this.#options.mode === 'readCacheOnly') return; - const schemaHash = await (this.#schemaHash ??= sha256OfflineReplicaSchema(this.#options.replicaSchema)); + const storageSchemaHash = await (this.#schemaHash ??= sha256OfflineReplicaSchema(this.#options.replicaSchema)); + const wireProtocol = this.#options.wireProtocol ?? { + version: this.#options.replicaSchema.version, + hash: storageSchemaHash, + }; let persistedCursor = (await this.#repository.getReplicaCursor(scope))?.cursor ?? ''; let requestCursor = persistedCursor; let rebaselinePending = false; @@ -84,11 +88,11 @@ export class OfflineReplicaPullService { const page = await this.#puller.pull({ scope, cursor: requestCursor, - schemaVersion: this.#options.replicaSchema.version, - schemaHash, + schemaVersion: wireProtocol.version, + schemaHash: wireProtocol.hash, }); this.#assertPullPage(page); - this.#assertHandshake(page.schemaVersion, page.schemaHash, schemaHash); + this.#assertHandshake(page.schemaVersion, page.schemaHash, wireProtocol); if (page.hasMore && page.nextCursor === requestCursor) { throw new Error(`Offline replica pull cursor did not advance for scope ${scope.userId}:${scope.scopeId}.`); } @@ -476,9 +480,13 @@ export class OfflineReplicaPullService { } } - #assertHandshake(version: number, hash: string, expectedHash: string): void { - if (version !== this.#options.replicaSchema.version || hash !== expectedHash) { - throw new OfflineReplicaSchemaMismatchError(this.#options.replicaSchema.version, expectedHash, version, hash); + #assertHandshake( + version: number, + hash: string, + expected: { readonly version: number; readonly hash: string }, + ): void { + if (version !== expected.version || hash !== expected.hash) { + throw new OfflineReplicaSchemaMismatchError(expected.version, expected.hash, version, hash); } } diff --git a/projects/kit/offline/src/lib/offline-storage.ts b/projects/kit/offline/src/lib/offline-storage.ts new file mode 100644 index 0000000..7ef87d2 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-storage.ts @@ -0,0 +1,50 @@ +/** + * Closed set of reasons local offline storage failed to become ready. + * + * Product policy should branch on this discriminant rather than English message text. + */ +export type OfflineStorageUnavailableReason = + | 'encryption_key_unavailable' + | 'core_schema_incompatible' + | 'replica_schema_mismatch' + | 'migration_missing' + | 'storage_unavailable'; + +/** + * Local offline storage could not be opened or migrated without risking data loss. + * + * Prefer `instanceof` / {@link OfflineStorageUnavailableError.reason} over message text. + * The original failure is preserved on {@link Error.cause}. Kit never deletes Outbox or + * replica rows in response to this error; recovery is product-owned and explicit. + */ +export class OfflineStorageUnavailableError extends Error { + /** Stable machine-readable discriminator for storage initialization failures. */ + static readonly code = 'OFFLINE_STORAGE_UNAVAILABLE' as const; + + readonly code = OfflineStorageUnavailableError.code; + + constructor( + readonly reason: OfflineStorageUnavailableReason, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'OfflineStorageUnavailableError'; + } +} + +/** + * Coordinator view of encrypted local storage after app initialization. + * + * `unavailable` is reached only when the product opted into online-only startup via + * `onStorageUnavailable` (or when inspecting state after a thrown failure). + */ +export type OfflineStorageState = + | { readonly status: 'initializing' } + | { readonly status: 'ready' } + | { readonly status: 'unavailable'; readonly error: OfflineStorageUnavailableError }; + +/** Narrows an unknown failure to {@link OfflineStorageUnavailableError}. */ +export function isOfflineStorageUnavailableError(error: unknown): error is OfflineStorageUnavailableError { + return error instanceof OfflineStorageUnavailableError; +} 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 23dfc76..91384e1 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -2817,6 +2817,508 @@ describe('OfflineSyncService', () => { expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); }); + it('confirmedCompanions putはACK・command削除・reconciliation markerと同一transactReplicaで確定する', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'confirmed-put-view' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'confirmed-put' }, + operation: 'documents.update', + payload: { title: 'optimistic' }, + optimisticValue: { id: 20, title: 'optimistic' }, + baseRevision: 1, + }, + replicaTransaction: { + putRows: [{ ...companion, values: { title: 'optimistic view' }, syncState: 'pending' }], + }, + }), + { flush: false }, + ); + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + const callsBeforeFlush = transactReplica.mock.calls.length; + execute.mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { id: 20, title: 'server' }, + confirmedCompanions: [ + { + key: companion, + reduce: () => ({ title: 'server view' }), + }, + ], + response: null, + }); + + connected.set(true); + await service.flush(); + + const ackCalls = transactReplica.mock.calls.slice(callsBeforeFlush).filter(([transaction]) => + (transaction.putReconciliationScopes ?? []).some((scope) => scope.userId === 1 && scope.scopeId === '10'), + ); + expect(ackCalls).toHaveLength(1); + expect(ackCalls[0]?.[0]).toMatchObject({ + putRows: expect.arrayContaining([ + expect.objectContaining({ + sourceKey: 'documents', + values: { id: 20, title: 'server' }, + confirmedValues: { id: 20, title: 'server' }, + syncState: 'confirmed', + }), + expect.objectContaining({ + sourceKey: 'document_views', + values: { title: 'server view' }, + confirmedValues: { title: 'server view' }, + syncState: 'confirmed', + visibility: 'present', + }), + ]), + removeCommandIds: [expect.any(String)], + putReconciliationScopes: [{ userId: 1, scopeId: '10' }], + }); + expect(commands).toEqual([]); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'server view' }, + confirmedValues: { title: 'server view' }, + syncState: 'confirmed', + visibility: 'present', + }); + }); + + it('confirmedCompanions removeはACKと同一transactionでcompanionを除く', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'confirmed-remove-view' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'confirmed-remove' }, + operation: 'documents.update', + payload: { title: 'drop view' }, + optimisticValue: { id: 21, title: 'drop view' }, + baseRevision: 1, + }, + replicaTransaction: { removeRows: [companion] }, + }), + { flush: false }, + ); + expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); + execute.mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { id: 21, title: 'drop view' }, + confirmedCompanions: [ + { + key: companion, + reduce: () => null, + }, + ], + response: null, + }); + + connected.set(true); + await service.flush(); + + expect(commands).toEqual([]); + expect(rows.find((row) => row.sourceKey === 'document_views')).toBeUndefined(); + expect(rows.find((row) => row.sourceKey === 'documents')).toMatchObject({ + values: { id: 21, title: 'drop view' }, + confirmedValues: { id: 21, title: 'drop view' }, + syncState: 'confirmed', + }); + }); + + it('後続optimistic companionはconfirmedCompanionsの上にoverlayしconfirmedValuesはサーバ確定値を残す', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'confirmed-overlay-view' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'confirmed-overlay' }, + operation: 'documents.update', + payload: { title: 'first' }, + optimisticValue: { id: 22, title: 'first' }, + baseRevision: 1, + }, + replicaTransaction: { + putRows: [{ ...companion, values: { title: 'first optimistic' }, syncState: 'pending' }], + }, + }), + { flush: false }, + ); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'confirmed-overlay' }, + operation: 'documents.update', + payload: { title: 'second' }, + optimisticValue: { id: 22, title: 'second' }, + baseRevision: 1, + }, + replicaTransaction: { + putRows: [{ ...companion, values: { title: 'second optimistic' }, syncState: 'pending' }], + }, + }), + { flush: false }, + ); + const firstCommandId = commands[0]!.commandId; + const secondCommandId = commands[1]!.commandId; + execute + .mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { id: 22, title: 'first server' }, + confirmedCompanions: [ + { + key: companion, + reduce: () => ({ title: 'server companion' }), + }, + ], + response: null, + }) + .mockRejectedValueOnce(Object.assign(new Error('hold later command'), { status: 0 })); + + connected.set(true); + await service.flush(); + + expect(commands).toHaveLength(1); + expect(commands[0]?.commandId).toBe(secondCommandId); + expect(commands[0]?.commandId).not.toBe(firstCommandId); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'second optimistic' }, + confirmedValues: { title: 'server companion' }, + syncState: 'pending', + }); + expect(rows.find((row) => row.sourceKey === 'documents')).toMatchObject({ + values: { id: 22, title: 'second' }, + confirmedValues: { id: 22, title: 'first server' }, + }); + }); + + it('footprint外のconfirmedCompanionsはACKせずtransaction mutationを起こさない', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'declared-view' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'undeclared-companion' }, + operation: 'documents.update', + payload: { title: 'optimistic' }, + optimisticValue: { id: 23, title: 'optimistic' }, + baseRevision: 1, + }, + replicaTransaction: { + putRows: [{ ...companion, values: { title: 'optimistic view' }, syncState: 'pending' }], + }, + }), + { flush: false }, + ); + const beforeCommandId = commands[0]!.commandId; + const beforeReconciliation = structuredClone(reconciliationScopes); + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + const callsBeforeFlush = transactReplica.mock.calls.length; + execute.mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { id: 23, title: 'server' }, + confirmedCompanions: [ + { + key: { ...companion, identity: { kind: 'local', localId: 'undeclared-view' } }, + reduce: () => ({ title: 'smuggled' }), + }, + ], + response: null, + }); + + connected.set(true); + await expect(service.flush()).rejects.toThrow('undeclared companion'); + + const ackMutations = transactReplica.mock.calls.slice(callsBeforeFlush).filter( + ([transaction]) => + (transaction.removeCommandIds ?? []).length > 0 || + (transaction.putReconciliationScopes ?? []).length > 0 || + (transaction.putRows ?? []).some( + (row) => + row.sourceKey === 'document_views' && + (row.values as { title?: string } | null)?.title === 'smuggled', + ), + ); + expect(ackMutations).toEqual([]); + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ + commandId: beforeCommandId, + serverCommitUnknown: true, + }); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + identity: { kind: 'local', localId: 'declared-view' }, + values: { title: 'optimistic view' }, + confirmedValues: { title: 'baseline' }, + }); + expect(rows.find((row) => row.sourceKey === 'documents')?.confirmedValues).toBeNull(); + expect(reconciliationScopes).toEqual(beforeReconciliation); + }); + + it('confirmedCompanionsはfootprintをすべて覆う必要がある', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'exact-coverage' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'exact-coverage-command' }, + operation: 'documents.update', + payload: { title: 'optimistic' }, + optimisticValue: { id: 24, title: 'optimistic' }, + baseRevision: 1, + }, + replicaTransaction: { putRows: [{ ...companion, syncState: 'pending' }] }, + }), + { flush: false }, + ); + const commandId = commands[0]!.commandId; + execute.mockResolvedValueOnce({ + confirmedCompanions: [], + response: null, + }); + + connected.set(true); + await expect(service.flush()).rejects.toThrow('must cover every optimistic companion exactly once'); + expect(commands).toContainEqual(expect.objectContaining({ commandId })); + + }); + + it('confirmedCompanions reducerは最新のconfirmedValuesを使う', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'latest-confirmed' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'latest-confirmed-command' }, + operation: 'documents.update', + payload: { title: 'optimistic' }, + optimisticValue: { id: 25, title: 'optimistic' }, + baseRevision: 1, + }, + replicaTransaction: { + putRows: [{ ...companion, values: { title: 'optimistic view' }, syncState: 'pending' }], + }, + }), + { flush: false }, + ); + const current = rows.find((row) => row.sourceKey === 'document_views')!; + current.confirmedValues = { title: 'mutated confirmed' }; + const seen: unknown[] = []; + execute.mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { id: 25, title: 'server' }, + confirmedCompanions: [ + { + key: companion, + reduce: (latestConfirmedValues) => { + seen.push(latestConfirmedValues); + return { title: 'reduced', from: (latestConfirmedValues as { title: string }).title }; + }, + }, + ], + response: null, + }); + + connected.set(true); + await service.flush(); + + expect(seen).toEqual([{ title: 'mutated confirmed' }]); + expect(rows.find((row) => row.sourceKey === 'document_views')).toMatchObject({ + values: { title: 'reduced', from: 'mutated confirmed' }, + confirmedValues: { title: 'reduced', from: 'mutated confirmed' }, + syncState: 'confirmed', + visibility: 'present', + }); + }); + + it('current companionの明示的なconfirmed absenceを旧beforeへfallbackしない', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'confirmed-absence' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'confirmed-absence-command' }, + operation: 'documents.update', + payload: {}, + optimisticValue: { id: 27 }, + }, + replicaTransaction: { putRows: [{ ...companion, syncState: 'pending' }] }, + }), + { flush: false }, + ); + rows.find((row) => row.sourceKey === 'document_views')!.confirmedValues = null; + const seen: unknown[] = []; + execute.mockResolvedValueOnce({ + confirmedCompanions: [ + { + key: companion, + reduce: (latest) => { + seen.push(latest); + throw new Error('authoritative companion is absent'); + }, + }, + ], + }); + + connected.set(true); + await expect(service.flush()).rejects.toThrow('authoritative companion is absent'); + expect(seen).toEqual([null]); + expect(commands).toHaveLength(1); + }); + + it('confirmedCompanions putはcanonicalなconfirmed syncStateとpresent visibilityを書く', async () => { + const companion: OfflineReplicaRow = { + userId: 1, + scopeId: '10', + sourceKey: 'document_views', + identity: { kind: 'local', localId: 'canonical-visibility' }, + values: { title: 'baseline' }, + confirmedValues: { title: 'baseline' }, + serverRevision: null, + fetchedAt: 1, + syncState: 'confirmed', + visibility: 'pending_delete', + }; + rows.push(companion); + await service.enqueuePrepared( + async () => ({ + request: { + scopeId: '10', + aggregateType: 'documents', + identity: { kind: 'generated', localId: 'canonical-visibility-command' }, + operation: 'documents.update', + payload: { title: 'optimistic' }, + optimisticValue: { id: 26, title: 'optimistic' }, + baseRevision: 1, + }, + replicaTransaction: { + putRows: [ + { + ...companion, + values: { title: 'optimistic view' }, + syncState: 'pending', + visibility: 'pending_delete', + }, + ], + }, + }), + { flush: false }, + ); + const repository = TestBed.inject(OFFLINE_REPOSITORY); + const transactReplica = vi.mocked(repository.transactReplica); + const callsBeforeFlush = transactReplica.mock.calls.length; + execute.mockResolvedValueOnce({ + serverRevision: 2, + confirmedValues: { id: 26, title: 'server' }, + confirmedCompanions: [{ key: companion, reduce: () => ({ title: 'canonical' }) }], + response: null, + }); + + connected.set(true); + await service.flush(); + + const ackCalls = transactReplica.mock.calls.slice(callsBeforeFlush).filter(([transaction]) => + (transaction.putReconciliationScopes ?? []).some((scope) => scope.userId === 1 && scope.scopeId === '10'), + ); + expect(ackCalls[0]?.[0]?.putRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceKey: 'document_views', + values: { title: 'canonical' }, + confirmedValues: { title: 'canonical' }, + syncState: 'confirmed', + visibility: 'present', + }), + ]), + ); + }); + it('同一ミリ秒のDate.nowでもcreatedAtは単調増加で保存する', async () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); await service.enqueue( diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 330842f..829e19f 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -1284,7 +1284,11 @@ export class OfflineSyncService { syncState: rebased.length > 0 ? ('pending' as const) : ('confirmed' as const), visibility: rebased.at(-1)?.replicaMutation === 'delete' ? ('pending_delete' as const) : ('present' as const), }; - const companionTransaction = this.#companionTransactionAfterAcknowledgement(rebased); + const companionTransaction = await this.#companionTransactionAfterAcknowledgement( + command, + rebased, + result.confirmedCompanions, + ); if (!this.#isCurrent(generation)) return; await this.#repository.transactReplica({ putRows: [...(removesReplica && rebased.length === 0 ? [] : [row]), ...(companionTransaction.putRows ?? [])], @@ -1411,19 +1415,92 @@ export class OfflineSyncService { ); } - #companionTransactionAfterAcknowledgement( + async #companionTransactionAfterAcknowledgement( + acknowledged: OfflineCommand, following: readonly OfflineCommand[], - ): Pick { + confirmed: OfflineCommandResult['confirmedCompanions'], + ): Promise> { const latest = new Map(); for (const command of following) { for (const companion of command.optimisticCompanions ?? []) { latest.set(this.#replicaRowKey(companion.key), companion); } } - return { - putRows: [...latest.values()].flatMap((companion) => (companion.after ? [companion.after] : [])), - removeRows: [...latest.values()].flatMap((companion) => (companion.after ? [] : [companion.key])), - }; + // Exact legacy path: absent confirmedCompanions only reapplies later overlays as stored. + if (confirmed === undefined) { + return { + putRows: [...latest.values()].flatMap((companion) => (companion.after ? [companion.after] : [])), + removeRows: [...latest.values()].flatMap((companion) => (companion.after ? [] : [companion.key])), + }; + } + const footprint = new Set( + (acknowledged.optimisticCompanions ?? []).map((companion) => this.#replicaRowKey(companion.key)), + ); + const confirmedRows = new Map(); + const confirmedRemovals = new Map(); + const acknowledgedCompanions = new Map( + (acknowledged.optimisticCompanions ?? []).map((companion) => [this.#replicaRowKey(companion.key), companion]), + ); + for (const mutation of confirmed) { + const key = this.#replicaRowKey(mutation.key); + this.#assertConfirmedCompanionKey(footprint, key); + if (confirmedRows.has(key) || confirmedRemovals.has(key)) { + throw new Error(`Offline command result contains duplicate companion mutation ${key}.`); + } + const declared = acknowledgedCompanions.get(key)!; + const current = await this.#getCompanionRow(mutation.key); + const row = current ?? declared.before ?? declared.after; + if (!row) { + throw new Error(`Offline command result cannot resolve companion ${key}.`); + } + const latestConfirmed = current ? current.confirmedValues : (declared.before?.confirmedValues ?? null); + const reduced = mutation.reduce(latestConfirmed); + if (reduced === null) { + confirmedRemovals.set(key, mutation.key); + } else { + confirmedRows.set(key, { + ...row, + values: reduced, + confirmedValues: reduced, + syncState: 'confirmed', + visibility: 'present', + }); + } + } + if (confirmedRows.size + confirmedRemovals.size !== footprint.size) { + throw new Error('Offline command result must cover every optimistic companion exactly once.'); + } + const keys = new Set([...confirmedRows.keys(), ...confirmedRemovals.keys(), ...latest.keys()]); + const putRows: OfflineReplicaRow[] = []; + const removeRows: OfflineReplicaRowKey[] = []; + for (const key of keys) { + const overlay = latest.get(key); + const confirmedRow = confirmedRows.get(key); + if (overlay) { + if (overlay.after) { + if (confirmedRow) { + putRows.push({ ...overlay.after, confirmedValues: confirmedRow.confirmedValues }); + } else if (confirmedRemovals.has(key)) { + putRows.push({ ...overlay.after, confirmedValues: null }); + } else { + putRows.push(overlay.after); + } + } else { + removeRows.push(overlay.key); + } + } else if (confirmedRow) { + putRows.push(confirmedRow); + } else { + removeRows.push(confirmedRemovals.get(key)!); + } + } + return { putRows, removeRows }; + } + + #assertConfirmedCompanionKey(footprint: ReadonlySet, key: string): void { + if (!footprint.has(key)) { + throw new Error(`Offline command result changed undeclared companion ${key}.`); + } } #companionsAfterDiscard(all: readonly OfflineCommand[], remaining: readonly OfflineCommand[]): OfflineOptimisticReplicaCompanion[] { 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 e6ee00f..4bc6e01 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.spec.ts @@ -24,6 +24,8 @@ import { createRandomOfflineEncryptionKey, SqliteOfflineRepository, } from './sqlite-offline-repository'; +import { OfflineStorageUnavailableError } from './offline-storage'; +import { OFFLINE_SCHEMA_VERSION } from './offline-repository'; type TestItemSelect = { id: number; title: string }; type TestItemWithSubtitleSelect = { id: number; title: string; subtitle: string }; @@ -250,11 +252,15 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }; }); - it('暗号化databaseのopen失敗を呼び出し元へ伝播する', async () => { + it('暗号化databaseのopen失敗をtyped storage_unavailableとしてcause付きで伝播する', async () => { const error = new Error('SQLCipher is not configured'); plugin.open.mockRejectedValueOnce(error); const repository = createRepository(); - await expect(repository.initialize()).rejects.toBe(error); + await expect(repository.initialize()).rejects.toSatisfy((thrown: unknown) => { + expect(thrown).toBeInstanceOf(OfflineStorageUnavailableError); + expect(thrown).toMatchObject({ reason: 'storage_unavailable', cause: error }); + return true; + }); expect(plugin.open).toHaveBeenCalledWith({ databaseName: 'test-offline', createEncryptionKey: expect.any(Function), @@ -617,7 +623,14 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }; const repository = createRepository(undefined, { replicaSchema: replicaSchemaV1HashDrift }); - await expect(repository.initialize()).rejects.toThrow('Offline replica schema hash mismatch at version 1'); + await expect(repository.initialize()).rejects.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(OfflineStorageUnavailableError); + expect(error).toMatchObject({ + reason: 'replica_schema_mismatch', + }); + expect((error as Error).message).toContain('Offline replica schema hash mismatch at version 1'); + return true; + }); expect( plugin.execute.mock.calls.some(([options]) => (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), @@ -657,7 +670,14 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }; 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.toSatisfy((error: unknown) => { + expect(error).toBeInstanceOf(OfflineStorageUnavailableError); + expect(error).toMatchObject({ + reason: 'migration_missing', + message: 'Missing offline replica schema migration from version 2 to 3.', + }); + return true; + }); expect( plugin.execute.mock.calls.some(([options]) => (options as { statement: string }).statement.startsWith('CREATE TABLE IF NOT EXISTS test_items'), @@ -679,7 +699,11 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }); const repository = createRepository(undefined, { replicaSchema: replicaSchemaV2 }); - await expect(repository.initialize()).rejects.toBe(error); + await expect(repository.initialize()).rejects.toSatisfy((thrown: unknown) => { + expect(thrown).toBeInstanceOf(OfflineStorageUnavailableError); + expect(thrown).toMatchObject({ reason: 'storage_unavailable', cause: error }); + return true; + }); expect(plugin.rollbackTransaction).toHaveBeenCalledOnce(); expect( plugin.execute.mock.calls.some(([options]) => { @@ -688,6 +712,138 @@ describe('SqliteOfflineRepository community sqlite driver', () => { }), ).toBe(false); }); + + it('maps typed initialization failure reasons without deleting storage', async () => { + const cases: { + name: string; + reason: OfflineStorageUnavailableError['reason']; + arrange: () => void; + options?: { replicaSchema?: OfflineReplicaSchemaBundle; createEncryptionKey?: () => Promise }; + messageIncludes: string; + }[] = [ + { + name: 'encryption_key_unavailable', + reason: 'encryption_key_unavailable', + arrange: () => { + plugin.open.mockImplementation(async (options: { createEncryptionKey?: () => Promise }) => { + await options.createEncryptionKey?.(); + return { databaseId: 'offline-db' }; + }); + }, + options: { + createEncryptionKey: async () => { + throw new Error('keychain denied'); + }, + }, + messageIncludes: 'keychain denied', + }, + { + name: 'core_schema_incompatible', + reason: 'core_schema_incompatible', + arrange: () => { + plugin.query.mockImplementation(async ({ statement }: { statement: string }) => { + if (statement.includes('offline_replica_schema_metadata')) { + return { + columns: ['version', 'schema_hash'], + rows: [[replicaSchemaV1.version, replicaSchemaV1Hash]], + }; + } + if (statement.includes('offline_metadata') && statement.includes('schema_version')) { + return { rows: [{ schema_version: OFFLINE_SCHEMA_VERSION + 99 }] }; + } + if (statement.startsWith('PRAGMA table_info')) return { rows: [{ name: 'next_local_id' }] }; + return { rows: [] }; + }); + }, + messageIncludes: 'Unsupported offline storage schema version', + }, + { + name: 'replica_schema_mismatch (hash drift)', + reason: 'replica_schema_mismatch', + arrange: () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: replicaSchemaV1Hash, + }; + }, + options: { replicaSchema: replicaSchemaV1HashDrift }, + messageIncludes: 'Offline replica schema hash mismatch', + }, + { + name: 'replica_schema_mismatch (newer stored version)', + reason: 'replica_schema_mismatch', + arrange: () => { + storedReplicaMetadata = { + version: 9, + schemaHash: replicaSchemaV1Hash, + }; + }, + messageIncludes: 'newer than application version', + }, + { + name: 'migration_missing', + reason: 'migration_missing', + arrange: () => { + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: replicaSchemaV1Hash, + }; + }, + options: { replicaSchema: replicaSchemaV3MissingMigration }, + messageIncludes: 'Missing offline replica schema migration', + }, + { + name: 'storage_unavailable', + reason: 'storage_unavailable', + arrange: () => { + plugin.open.mockRejectedValueOnce(new Error('native plugin missing')); + }, + messageIncludes: 'native plugin missing', + }, + ]; + + for (const testCase of cases) { + TestBed.resetTestingModule(); + storedReplicaMetadata = { + version: replicaSchemaV1.version, + schemaHash: replicaSchemaV1Hash, + }; + plugin.open.mockReset(); + plugin.open.mockImplementation(async () => ({ databaseId: 'offline-db' })); + plugin.execute.mockClear(); + plugin.beginTransaction.mockClear(); + plugin.commitTransaction.mockClear(); + plugin.rollbackTransaction.mockClear(); + plugin.query.mockImplementation(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' }] }; + return { rows: [] }; + }); + testCase.arrange(); + const repository = createRepository(testCase.options?.createEncryptionKey, { + replicaSchema: testCase.options?.replicaSchema, + }); + + await expect(repository.initialize(), testCase.name).rejects.toSatisfy((error: unknown) => { + expect(error, testCase.name).toBeInstanceOf(OfflineStorageUnavailableError); + expect(error, testCase.name).toMatchObject({ reason: testCase.reason }); + expect((error as Error).message, testCase.name).toContain(testCase.messageIncludes); + return true; + }); + expect( + plugin.execute.mock.calls.some(([options]) => + (options as { statement: string }).statement.startsWith('DELETE FROM offline_sync_commands'), + ), + testCase.name, + ).toBe(false); + } + }); }); function createRepository( diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index d573989..06e652c 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -45,6 +45,7 @@ import { type OfflineReplicaTransaction, type OfflineScope, } from './offline-repository'; +import { OfflineStorageUnavailableError } from './offline-storage'; /** Minimal native SQLite driver surface required by the offline repository. */ export interface CommunitySqliteDriver { @@ -445,41 +446,51 @@ export class SqliteOfflineRepository implements OfflineRepository { } async #open(): Promise { - if (!this.#sqlite) throw new Error('Native offline storage requires a community SQLite connection'); - const { databaseId } = await this.#sqlite.open({ - databaseName: this.#options.databaseName, - createEncryptionKey: this.#options.createEncryptionKey, - }); - this.#databaseId = databaseId; - for (const statement of SCHEMA) await this.#execute(databaseId, statement); - const commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); - if (!commandColumns.some((row) => row['name'] === 'optimistic_companions_json')) { - await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_companions_json TEXT'); - } - if (!commandColumns.some((row) => row['name'] === 'server_commit_unknown')) { - await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN server_commit_unknown INTEGER NOT NULL DEFAULT 0'); - await this.#execute( - databaseId, - `UPDATE offline_sync_commands SET server_commit_unknown = 1 + try { + if (!this.#sqlite) { + throw new OfflineStorageUnavailableError( + 'storage_unavailable', + 'Native offline storage requires a community SQLite connection', + ); + } + const { databaseId } = await this.#sqlite.open({ + databaseName: this.#options.databaseName, + createEncryptionKey: this.#wrapCreateEncryptionKey(this.#options.createEncryptionKey), + }); + this.#databaseId = databaseId; + for (const statement of SCHEMA) await this.#execute(databaseId, statement); + const commandColumns = await this.#queryDatabase(databaseId, 'PRAGMA table_info(offline_sync_commands)'); + if (!commandColumns.some((row) => row['name'] === 'optimistic_companions_json')) { + await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN optimistic_companions_json TEXT'); + } + if (!commandColumns.some((row) => row['name'] === 'server_commit_unknown')) { + await this.#execute(databaseId, 'ALTER TABLE offline_sync_commands ADD COLUMN server_commit_unknown INTEGER NOT NULL DEFAULT 0'); + await this.#execute( + databaseId, + `UPDATE offline_sync_commands SET server_commit_unknown = 1 WHERE state IN ('sending', 'retry_wait') OR (attempts >= 2 AND state IN ('blocked_auth', 'conflict', 'rejected'))`, - ); - } - const metadata = await this.#queryDatabase(databaseId, 'SELECT schema_version FROM offline_metadata WHERE id = 1'); - if (metadata.length === 0) { - await this.#execute(databaseId, 'INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL)', [ - OFFLINE_SCHEMA_VERSION, - ]); - } else { - const storedVersion = this.#number(metadata[0]!['schema_version']); - if (storedVersion !== OFFLINE_SCHEMA_VERSION) { - throw new Error( - `Unsupported offline storage schema version ${storedVersion}; expected ${OFFLINE_SCHEMA_VERSION}. ` + - 'A lossless core schema migration is required before this database can be opened.', ); } + const metadata = await this.#queryDatabase(databaseId, 'SELECT schema_version FROM offline_metadata WHERE id = 1'); + if (metadata.length === 0) { + await this.#execute(databaseId, 'INSERT INTO offline_metadata (id, schema_version, last_user_id) VALUES (1, ?, NULL)', [ + OFFLINE_SCHEMA_VERSION, + ]); + } else { + const storedVersion = this.#number(metadata[0]!['schema_version']); + if (storedVersion !== OFFLINE_SCHEMA_VERSION) { + throw new OfflineStorageUnavailableError( + 'core_schema_incompatible', + `Unsupported offline storage schema version ${storedVersion}; expected ${OFFLINE_SCHEMA_VERSION}. ` + + 'A lossless core schema migration is required before this database can be opened.', + ); + } + } + await this.#initializeReplicaSchema(databaseId); + } catch (error) { + throw this.#mapInitializationError(error); } - await this.#initializeReplicaSchema(databaseId); } async #initializeReplicaSchema(databaseId: string): Promise { @@ -503,13 +514,15 @@ export class SqliteOfflineRepository implements OfflineRepository { } if (storedVersion === targetVersion) { - throw new Error( + throw new OfflineStorageUnavailableError( + 'replica_schema_mismatch', `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( + throw new OfflineStorageUnavailableError( + 'replica_schema_mismatch', `Offline replica schema version ${storedVersion} is newer than application version ${targetVersion}. Upgrade the application before opening this database.`, ); } @@ -518,7 +531,10 @@ export class SqliteOfflineRepository implements OfflineRepository { 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}.`); + throw new OfflineStorageUnavailableError( + 'migration_missing', + `Missing offline replica schema migration from version ${version} to ${version + 1}.`, + ); } for (const statement of migration.statements) { await this.#execute(databaseId, statement); @@ -529,6 +545,42 @@ export class SqliteOfflineRepository implements OfflineRepository { }); } + #wrapCreateEncryptionKey( + createEncryptionKey: (() => Promise) | undefined, + ): (() => Promise) | undefined { + if (!createEncryptionKey) return undefined; + return async () => { + try { + const encryptionKey = await createEncryptionKey(); + if (!encryptionKey) { + throw new OfflineStorageUnavailableError( + 'encryption_key_unavailable', + 'Native offline storage requires a non-empty encryption key on first open', + ); + } + return encryptionKey; + } catch (error) { + if (error instanceof OfflineStorageUnavailableError) throw error; + throw new OfflineStorageUnavailableError( + 'encryption_key_unavailable', + error instanceof Error ? error.message : 'Offline encryption key is unavailable.', + { cause: error }, + ); + } + }; + } + + #mapInitializationError(error: unknown): OfflineStorageUnavailableError { + if (error instanceof OfflineStorageUnavailableError) return error; + const message = error instanceof Error ? error.message : 'Offline storage is unavailable.'; + if (message.includes('non-empty encryption key on first open')) { + return new OfflineStorageUnavailableError('encryption_key_unavailable', message, { cause: error }); + } + return new OfflineStorageUnavailableError('storage_unavailable', message || 'Offline storage is unavailable.', { + cause: error, + }); + } + async #executeReplicaCreateStatements(databaseId: string, bundle: OfflineReplicaSchemaBundle): Promise { for (const entity of bundle.entities) { for (const statement of entity.createTableSql) { diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 32e9946..f055791 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -15,6 +15,7 @@ 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-storage'; export * from './lib/offline-sync.service'; export * from './lib/offline.interceptor'; export * from './lib/sqlite-offline-repository';