diff --git a/projects/kit/README.md b/projects/kit/README.md index c6ea1a8..9f356c6 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -398,7 +398,7 @@ declare dummy adapters. Mutations may still call `OfflineSyncService.enqueue` explicitly. A product that must keep ordinary HTTP services offline-unaware can instead register `mutationPolicies`; a matched `POST` / `PUT` / `PATCH` / `DELETE` is prepared locally before transport and returns an optimistic response while the Outbox owns remote replay. -Web storage uses Ionic Storage; iOS and Android use encrypted `@capacitor-community/sqlite`. Importing either the +Web storage uses Ionic Storage and supports `readCacheOnly` only. `synchronized` fails fast on Web because the current repository has no cross-tab lock; iOS and Android use encrypted `@capacitor-community/sqlite`. Importing either the primary entry point or `/offline` does not pull the optional native SQLite plugin into web-only applications. For cold-start offline route access, `OfflineCoordinatorService.activateOfflineSession()` restores only a manifest diff --git a/projects/kit/offline/src/lib/offline-contract.spec.ts b/projects/kit/offline/src/lib/offline-contract.spec.ts index 20cff07..80fde12 100644 --- a/projects/kit/offline/src/lib/offline-contract.spec.ts +++ b/projects/kit/offline/src/lib/offline-contract.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { requirePositiveOfflineInteger } from './offline-identity'; -import { provideOffline } from './offline-provider'; +import { assertSupportedOfflineMode, provideOffline } from './offline-provider'; import { normalizeOfflineReplicaPullPage } from './offline-replica-puller'; import { defineOfflineReplicaSchema } from './offline-replica-schema'; import { offlineSessionManifestAllows } from './offline-session.service'; @@ -75,6 +75,18 @@ describe('shared offline boundary contracts', () => { void compileOnly; }); + it('fails closed before enabling synchronized Outbox writes on Web', () => { + expect(() => assertSupportedOfflineMode('web', 'synchronized')).toThrow( + 'Offline synchronized mode is supported only by native repositories.', + ); + expect(() => assertSupportedOfflineMode('web', 'readCacheOnly')).not.toThrow(); + expect(() => assertSupportedOfflineMode('electron', 'synchronized')).toThrow( + 'Offline synchronized mode is supported only by native repositories.', + ); + expect(() => assertSupportedOfflineMode('ios', 'synchronized')).not.toThrow(); + expect(() => assertSupportedOfflineMode('android', 'synchronized')).not.toThrow(); + }); + it('requires an encryption key factory for synchronized providers at compile time', () => { type Options = import('./offline-provider').ProvideSynchronizedOfflineOptions; type IsRequired = Record extends Pick ? false : true; diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index fb04112..c3c998c 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -8,7 +8,12 @@ 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 { + IonicOfflineRepository, + OFFLINE_REPOSITORY, + selectOfflineRepository, + supportsSynchronizedOfflineRepository, +} from './offline-repository'; import type { OfflineMutationRequestPolicy, OfflineRequestPolicy } from './offline-request-policy'; import { provideOfflineMutationRequestPolicy, provideOfflineRequestPolicy } from './offline-request-policy'; import type { OfflineReplicaProjector, OfflineReplicaPuller } from './offline-replica-puller'; @@ -121,6 +126,21 @@ export function provideOffline(options: ProvideOfflineOptions): EnvironmentProvi ...options.requestPolicies.flatMap((policy) => provideOfflineRequestPolicy(policy)), ...(options.mutationPolicies ?? []).flatMap((policy) => provideOfflineMutationRequestPolicy(policy)), ...(options.providers ?? []), - provideAppInitializer(() => inject(OfflineCoordinatorService).initialize()), + provideAppInitializer(() => { + assertSupportedOfflineMode(Capacitor.getPlatform(), options.mode ?? 'synchronized'); + return inject(OfflineCoordinatorService).initialize(); + }), ]); } + +/** Prevents unsupported multi-tab Web writes from silently losing Outbox state. */ +export function assertSupportedOfflineMode( + platform: string, + mode: 'synchronized' | 'readCacheOnly', +): void { + if (!supportsSynchronizedOfflineRepository(platform) && mode === 'synchronized') { + throw new Error( + 'Offline synchronized mode is supported only by native repositories. Use readCacheOnly until the selected repository provides cross-context locking.', + ); + } +} diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index 8634059..71d4762 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -202,13 +202,18 @@ export interface OfflineRepository { /** 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. */ +/** Returns whether the platform uses the cross-process-safe native repository. */ +export function supportsSynchronizedOfflineRepository(platform: string): boolean { + return platform === 'ios' || platform === 'android'; +} + +/** Selects encrypted SQLite on native platforms and Ionic Storage elsewhere. */ export function selectOfflineRepository( platform: string, webRepository: OfflineRepository, nativeRepository: OfflineRepository, ): OfflineRepository { - return platform === 'ios' || platform === 'android' ? nativeRepository : webRepository; + return supportsSynchronizedOfflineRepository(platform) ? nativeRepository : webRepository; } interface OfflineMetadata {