diff --git a/.changeset/open-pillows-beg.md b/.changeset/open-pillows-beg.md new file mode 100644 index 000000000..09dbcdcfc --- /dev/null +++ b/.changeset/open-pillows-beg.md @@ -0,0 +1,5 @@ +--- +'@tanstack/offline-transactions': minor +--- + +Add support for capacitor on offline-transactions diff --git a/packages/offline-transactions/README.md b/packages/offline-transactions/README.md index 351480258..b5e8c2b1f 100644 --- a/packages/offline-transactions/README.md +++ b/packages/offline-transactions/README.md @@ -27,12 +27,21 @@ npm install @tanstack/offline-transactions @react-native-community/netinfo The React Native implementation requires the `@react-native-community/netinfo` peer dependency for network connectivity detection. +### Capacitor + +```bash +npm install @tanstack/offline-transactions @capacitor/network @capacitor/preferences +``` + +The Capacitor implementation requires the `@capacitor/network` and `@capacitor/preferences` peer dependency for network connectivity detection. + ## Platform Support This package provides platform-specific implementations for web and React Native environments: - **Web**: Uses browser APIs (`window.online/offline` events, `document.visibilitychange`) - **React Native**: Uses React Native primitives (`@react-native-community/netinfo` for network status, `AppState` for foreground/background detection) +- **Capacitor**: Uses Capacitor primitives (`@capacitor/network` and `@capacitor/preferences`) ## Quick Start @@ -50,6 +59,12 @@ import { startOfflineExecutor } from '@tanstack/offline-transactions' import { startOfflineExecutor } from '@tanstack/offline-transactions/react-native' ``` +**Capacitor** + +```typescript +import { startOfflineExecutor } from '@tanstack/offline-transactions/capacitor' +``` + **Usage (same for both platforms):** ```typescript @@ -254,6 +269,10 @@ await tx.commit() // Works offline! - **Required peer dependency**: `@react-native-community/netinfo` for network connectivity detection - **Storage**: Uses AsyncStorage or custom storage adapters +## Capacitor +- **Capacitor**: 8.0.0+ (tested with latest versions) +- **Required peer dependency**: `@capacitor/network` and `@capacitor/preferences` for network connectivity detection + ## License MIT diff --git a/packages/offline-transactions/package.json b/packages/offline-transactions/package.json index cad895b99..480c23eea 100644 --- a/packages/offline-transactions/package.json +++ b/packages/offline-transactions/package.json @@ -50,6 +50,16 @@ "default": "./dist/cjs/react-native/index.cjs" } }, + "./capacitor": { + "import": { + "types": "./dist/esm/capacitor/index.d.ts", + "default": "./dist/esm/capacitor/index.js" + }, + "require": { + "types": "./dist/cjs/capacitor/index.d.cts", + "default": "./dist/cjs/capacitor/index.cjs" + } + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -62,7 +72,9 @@ }, "peerDependencies": { "@react-native-community/netinfo": ">=11.0.0", - "react-native": ">=0.70.0" + "react-native": ">=0.70.0", + "@capacitor/preferences": ">=8.0.0", + "@capacitor/network": ">=8.0.0" }, "peerDependenciesMeta": { "@react-native-community/netinfo": { @@ -70,10 +82,18 @@ }, "react-native": { "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@capacitor/network": { + "optional": true } }, "devDependencies": { "@react-native-community/netinfo": "11.4.1", + "@capacitor/preferences": "8.0.0", + "@capacitor/network": "8.0.0", "@types/node": "^25.2.2", "eslint": "^9.39.2", "react-native": "0.79.6", diff --git a/packages/offline-transactions/src/capacitor/OfflineExecutor.ts b/packages/offline-transactions/src/capacitor/OfflineExecutor.ts new file mode 100644 index 000000000..4e222591a --- /dev/null +++ b/packages/offline-transactions/src/capacitor/OfflineExecutor.ts @@ -0,0 +1,18 @@ +import { OfflineExecutor as BaseOfflineExecutor } from '../OfflineExecutor' +import { CapacitorOnlineDetector } from '../connectivity/CapacitorOnlineDetector' +import { CapacitorStorageAdapter } from '../storage/CapacitorStorageAdapter' +import type { OfflineConfig } from '../types' + +export class OfflineExecutor extends BaseOfflineExecutor { + constructor(config: OfflineConfig) { + super({ + ...config, + storage: config.storage ?? new CapacitorStorageAdapter(), + onlineDetector: config.onlineDetector ?? new CapacitorOnlineDetector(), + }) + } +} + +export function startOfflineExecutor(config: OfflineConfig): OfflineExecutor { + return new OfflineExecutor(config) +} diff --git a/packages/offline-transactions/src/capacitor/index.ts b/packages/offline-transactions/src/capacitor/index.ts new file mode 100644 index 000000000..7b68dd55e --- /dev/null +++ b/packages/offline-transactions/src/capacitor/index.ts @@ -0,0 +1,45 @@ +// Re-export from main entry (types, utilities, etc.) +export { + // Types + type OfflineTransaction, + type OfflineConfig, + type OfflineMode, + type StorageAdapter, + type StorageDiagnostic, + type StorageDiagnosticCode, + type RetryPolicy, + type LeaderElection, + type OnlineDetector, + type CreateOfflineTransactionOptions, + type CreateOfflineActionOptions, + type SerializedError, + type SerializedMutation, + NonRetriableError, + // Storage adapters + IndexedDBAdapter, + LocalStorageAdapter, + // Retry policies + DefaultRetryPolicy, + BackoffCalculator, + // Coordination + WebLocksLeader, + BroadcastChannelLeader, + // Connectivity - export web detector too for flexibility + WebOnlineDetector, + DefaultOnlineDetector, + // API components + OfflineTransactionAPI, + createOfflineAction, + // Outbox management + OutboxManager, + TransactionSerializer, + // Execution engine + KeyScheduler, + TransactionExecutor, +} from '../index' + +// Export Capacitor-specific detector +export { CapacitorOnlineDetector } from '../connectivity/CapacitorOnlineDetector' + +// Export Capacitor-configured executor +export { OfflineExecutor, startOfflineExecutor } from './OfflineExecutor' diff --git a/packages/offline-transactions/src/connectivity/CapacitorOnlineDetector.ts b/packages/offline-transactions/src/connectivity/CapacitorOnlineDetector.ts new file mode 100644 index 000000000..fe4df4d9e --- /dev/null +++ b/packages/offline-transactions/src/connectivity/CapacitorOnlineDetector.ts @@ -0,0 +1,98 @@ +import { Network } from '@capacitor/network' +import type { OnlineDetector } from '../types' + +interface ListenerHandle { + remove: () => Promise +} + +export class CapacitorOnlineDetector implements OnlineDetector { + private listeners: Set<() => void> = new Set() + private networkListenerHandle: ListenerHandle | null = null + private isListening = false + private wasConnected = true + + constructor() { + this.startListening() + } + + private startListening(): void { + if (this.isListening) { + return + } + + this.isListening = true + + Network.addListener(`networkStatusChange`, (status) => { + const isConnected = status.connected + + if (isConnected && !this.wasConnected) { + this.notifyListeners() + } + + this.wasConnected = isConnected + }).then((handle) => { + this.networkListenerHandle = handle + }) + + if (typeof document !== `undefined`) { + document.addEventListener(`visibilitychange`, this.handleVisibilityChange) + } + } + + private handleVisibilityChange = (): void => { + if (document.visibilityState === `visible`) { + this.notifyListeners() + } + } + + private stopListening(): void { + if (!this.isListening) { + return + } + + this.isListening = false + + if (this.networkListenerHandle) { + this.networkListenerHandle.remove() + this.networkListenerHandle = null + } + + if (typeof document !== `undefined`) { + document.removeEventListener( + `visibilitychange`, + this.handleVisibilityChange, + ) + } + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + try { + listener() + } catch (error) { + console.warn(`CapacitorOnlineDetector listener error:`, error) + } + } + } + + subscribe(callback: () => void): () => void { + this.listeners.add(callback) + + return () => { + this.listeners.delete(callback) + + if (this.listeners.size === 0) { + this.stopListening() + } + } + } + + notifyOnline(): void { + this.notifyListeners() + } + + dispose(): void { + this.stopListening() + this.listeners.clear() + } +} diff --git a/packages/offline-transactions/src/storage/CapacitorStorageAdapter.ts b/packages/offline-transactions/src/storage/CapacitorStorageAdapter.ts new file mode 100644 index 000000000..970414a28 --- /dev/null +++ b/packages/offline-transactions/src/storage/CapacitorStorageAdapter.ts @@ -0,0 +1,85 @@ +import { Preferences } from '@capacitor/preferences' +import { BaseStorageAdapter } from './StorageAdapter' + +export class CapacitorStorageAdapter extends BaseStorageAdapter { + private prefix: string + + constructor(prefix = `offline-tx:`) { + super() + this.prefix = prefix + } + + static async probe(): Promise<{ available: boolean; error?: Error }> { + try { + const testKey = `__offline-tx-probe__` + const testValue = `test` + + await Preferences.set({ key: testKey, value: testValue }) + const { value: retrieved } = await Preferences.get({ key: testKey }) + await Preferences.remove({ key: testKey }) + + if (retrieved !== testValue) { + return { + available: false, + error: new Error(`Capacitor Preferences read/write verification failed`), + } + } + + return { available: true } + } catch (error) { + return { + available: false, + error: error instanceof Error ? error : new Error(String(error)), + } + } + } + + private getKey(key: string): string { + return `${this.prefix}${key}` + } + + async get(key: string): Promise { + try { + const { value } = await Preferences.get({ key: this.getKey(key) }) + return value + } catch (error) { + console.warn(`Capacitor Preferences get failed:`, error) + return null + } + } + + async set(key: string, value: string): Promise { + await Preferences.set({ key: this.getKey(key), value }) + } + + async delete(key: string): Promise { + try { + await Preferences.remove({ key: this.getKey(key) }) + } catch (error) { + console.warn(`Capacitor Preferences delete failed:`, error) + } + } + + async keys(): Promise> { + try { + const { keys } = await Preferences.keys() + return keys + .filter((key) => key.startsWith(this.prefix)) + .map((key) => key.slice(this.prefix.length)) + } catch (error) { + console.warn(`Capacitor Preferences keys failed:`, error) + return [] + } + } + + async clear(): Promise { + try { + const prefixedKeys = await this.keys() + await Promise.all( + prefixedKeys.map((key) => Preferences.remove({ key: this.getKey(key) })) + ) + } catch (error) { + console.warn(`Capacitor Preferences clear failed:`, error) + } + } +} diff --git a/packages/offline-transactions/tests/CapacitorOnlineDetector.test.ts b/packages/offline-transactions/tests/CapacitorOnlineDetector.test.ts new file mode 100644 index 000000000..feb3b4928 --- /dev/null +++ b/packages/offline-transactions/tests/CapacitorOnlineDetector.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { Network } from '@capacitor/network' +import { CapacitorOnlineDetector } from '../src/connectivity/CapacitorOnlineDetector' + +type NetworkStatusCallback = (status: { connected: boolean; connectionType: string }) => void + +const networkListeners: Array = [] +const mockRemove = vi.fn() + +vi.mock(`@capacitor/network`, () => ({ + Network: { + addListener: vi.fn( + (_event: string, callback: NetworkStatusCallback) => { + networkListeners.push(callback) + const handle = { + remove: vi.fn(() => { + const index = networkListeners.indexOf(callback) + if (index > -1) { + networkListeners.splice(index, 1) + } + mockRemove() + return Promise.resolve() + }), + } + return Promise.resolve(handle) + }, + ), + removeAllListeners: vi.fn(() => Promise.resolve()), + }, +})) + +function triggerNetworkChange(status: { connected: boolean; connectionType: string }): void { + for (const listener of networkListeners) { + listener(status) + } +} + +describe(`CapacitorOnlineDetector`, () => { + let originalDocument: typeof globalThis.document + let documentEventListeners: Map> + + beforeEach(() => { + vi.clearAllMocks() + networkListeners.length = 0 + + originalDocument = globalThis.document + documentEventListeners = new Map() + + Object.defineProperty(globalThis, `document`, { + value: { + visibilityState: `hidden`, + addEventListener: vi.fn((event: string, handler: EventListener) => { + if (!documentEventListeners.has(event)) { + documentEventListeners.set(event, new Set()) + } + documentEventListeners.get(event)!.add(handler) + }), + removeEventListener: vi.fn( + (event: string, handler: EventListener) => { + documentEventListeners.get(event)?.delete(handler) + }, + ), + }, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + Object.defineProperty(globalThis, `document`, { + value: originalDocument, + writable: true, + configurable: true, + }) + }) + + describe(`initialization`, () => { + it(`should subscribe to Network on construction`, () => { + const detector = new CapacitorOnlineDetector() + + expect(Network.addListener).toHaveBeenCalledTimes(1) + expect(Network.addListener).toHaveBeenCalledWith( + `networkStatusChange`, + expect.any(Function), + ) + + detector.dispose() + }) + + it(`should subscribe to visibilitychange on construction`, () => { + const detector = new CapacitorOnlineDetector() + + expect(document.addEventListener).toHaveBeenCalledWith( + `visibilitychange`, + expect.any(Function), + ) + + detector.dispose() + }) + }) + + describe(`network connectivity changes`, () => { + it(`should notify subscribers when transitioning from offline to online`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + triggerNetworkChange({ connected: false, connectionType: `none` }) + expect(callback).not.toHaveBeenCalled() + + triggerNetworkChange({ connected: true, connectionType: `wifi` }) + expect(callback).toHaveBeenCalledTimes(1) + + detector.dispose() + }) + + it(`should not notify when already online and staying online`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + triggerNetworkChange({ connected: true, connectionType: `wifi` }) + + expect(callback).not.toHaveBeenCalled() + + detector.dispose() + }) + + it(`should not notify when going from online to offline`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + triggerNetworkChange({ connected: false, connectionType: `none` }) + + expect(callback).not.toHaveBeenCalled() + + detector.dispose() + }) + + it(`should notify again on repeated offline-to-online transitions`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + triggerNetworkChange({ connected: false, connectionType: `none` }) + triggerNetworkChange({ connected: true, connectionType: `cellular` }) + expect(callback).toHaveBeenCalledTimes(1) + + triggerNetworkChange({ connected: false, connectionType: `none` }) + triggerNetworkChange({ connected: true, connectionType: `wifi` }) + expect(callback).toHaveBeenCalledTimes(2) + + detector.dispose() + }) + }) + + describe(`visibility changes`, () => { + it(`should notify subscribers when visibility changes to visible`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + ;(globalThis.document as any).visibilityState = `visible` + const handlers = documentEventListeners.get(`visibilitychange`) + for (const handler of handlers!) { + handler(new Event(`visibilitychange`)) + } + + expect(callback).toHaveBeenCalledTimes(1) + + detector.dispose() + }) + + it(`should not notify when visibility changes to hidden`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + ;(globalThis.document as any).visibilityState = `hidden` + const handlers = documentEventListeners.get(`visibilitychange`) + for (const handler of handlers!) { + handler(new Event(`visibilitychange`)) + } + + expect(callback).not.toHaveBeenCalled() + + detector.dispose() + }) + }) + + describe(`subscription management`, () => { + it(`should support multiple subscribers`, () => { + const detector = new CapacitorOnlineDetector() + const callback1 = vi.fn() + const callback2 = vi.fn() + + detector.subscribe(callback1) + detector.subscribe(callback2) + + detector.notifyOnline() + + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).toHaveBeenCalledTimes(1) + + detector.dispose() + }) + + it(`should allow unsubscribing`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + const unsubscribe = detector.subscribe(callback) + unsubscribe() + + detector.notifyOnline() + + expect(callback).not.toHaveBeenCalled() + + detector.dispose() + }) + + it(`should handle notifyOnline() manual trigger`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + detector.notifyOnline() + detector.notifyOnline() + + expect(callback).toHaveBeenCalledTimes(2) + + detector.dispose() + }) + + it(`should stop listening when all subscribers unsubscribe`, async () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + const unsubscribe = detector.subscribe(callback) + + await vi.waitFor(() => { + expect(networkListeners.length).toBe(1) + }) + + unsubscribe() + + await vi.waitFor(() => { + expect(mockRemove).toHaveBeenCalled() + }) + + expect(document.removeEventListener).toHaveBeenCalledWith( + `visibilitychange`, + expect.any(Function), + ) + }) + }) + + describe(`disposal`, () => { + it(`should remove network listener on dispose`, async () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + + await vi.waitFor(() => { + expect(networkListeners.length).toBe(1) + }) + + detector.dispose() + + await vi.waitFor(() => { + expect(mockRemove).toHaveBeenCalled() + }) + }) + + it(`should remove visibilitychange listener on dispose`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + detector.dispose() + + expect(document.removeEventListener).toHaveBeenCalledWith( + `visibilitychange`, + expect.any(Function), + ) + }) + + it(`should not notify after dispose`, () => { + const detector = new CapacitorOnlineDetector() + const callback = vi.fn() + + detector.subscribe(callback) + detector.dispose() + + detector.notifyOnline() + + expect(callback).not.toHaveBeenCalled() + }) + }) + + describe(`error handling`, () => { + it(`should catch and warn on listener errors without stopping other listeners`, () => { + const detector = new CapacitorOnlineDetector() + const consoleWarnSpy = vi + .spyOn(console, `warn`) + .mockImplementation(() => {}) + const errorCallback = vi.fn(() => { + throw new Error(`Test error`) + }) + const successCallback = vi.fn() + + detector.subscribe(errorCallback) + detector.subscribe(successCallback) + + detector.notifyOnline() + + expect(errorCallback).toHaveBeenCalledTimes(1) + expect(successCallback).toHaveBeenCalledTimes(1) + expect(consoleWarnSpy).toHaveBeenCalledWith( + `CapacitorOnlineDetector listener error:`, + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + detector.dispose() + }) + }) +}) diff --git a/packages/offline-transactions/tests/CapacitorStorageAdapter.test.ts b/packages/offline-transactions/tests/CapacitorStorageAdapter.test.ts new file mode 100644 index 000000000..ac7644577 --- /dev/null +++ b/packages/offline-transactions/tests/CapacitorStorageAdapter.test.ts @@ -0,0 +1,335 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { Preferences } from '@capacitor/preferences' +import { CapacitorStorageAdapter } from '../src/storage/CapacitorStorageAdapter' + +const store = new Map() + +vi.mock(`@capacitor/preferences`, () => ({ + Preferences: { + get: vi.fn(({ key }: { key: string }) => + Promise.resolve({ value: store.get(key) ?? null }), + ), + set: vi.fn(({ key, value }: { key: string; value: string }) => { + store.set(key, value) + return Promise.resolve() + }), + remove: vi.fn(({ key }: { key: string }) => { + store.delete(key) + return Promise.resolve() + }), + keys: vi.fn(() => Promise.resolve({ keys: [...store.keys()] })), + clear: vi.fn(() => { + store.clear() + return Promise.resolve() + }), + }, +})) + +describe(`CapacitorStorageAdapter`, () => { + beforeEach(() => { + store.clear() + vi.clearAllMocks() + }) + + describe(`basic CRUD operations`, () => { + it(`should store and retrieve a value`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + const result = await adapter.get(`key1`) + + expect(result).toBe(`value1`) + }) + + it(`should return null for non-existent keys`, async () => { + const adapter = new CapacitorStorageAdapter() + + const result = await adapter.get(`missing`) + + expect(result).toBeNull() + }) + + it(`should delete a value`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + await adapter.delete(`key1`) + const result = await adapter.get(`key1`) + + expect(result).toBeNull() + }) + + it(`should overwrite existing values`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `original`) + await adapter.set(`key1`, `updated`) + const result = await adapter.get(`key1`) + + expect(result).toBe(`updated`) + }) + }) + + describe(`prefix handling`, () => { + it(`should use default prefix for storage keys`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + + expect(Preferences.set).toHaveBeenCalledWith({ + key: `offline-tx:key1`, + value: `value1`, + }) + }) + + it(`should use custom prefix when provided`, async () => { + const adapter = new CapacitorStorageAdapter(`custom:`) + + await adapter.set(`key1`, `value1`) + + expect(Preferences.set).toHaveBeenCalledWith({ + key: `custom:key1`, + value: `value1`, + }) + }) + + it(`should pass prefixed key to get`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.get(`key1`) + + expect(Preferences.get).toHaveBeenCalledWith({ + key: `offline-tx:key1`, + }) + }) + + it(`should pass prefixed key to remove`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + await adapter.delete(`key1`) + + expect(Preferences.remove).toHaveBeenCalledWith({ + key: `offline-tx:key1`, + }) + }) + }) + + describe(`keys`, () => { + it(`should return only keys matching the prefix`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + await adapter.set(`key2`, `value2`) + store.set(`other-prefix:key3`, `value3`) + + const keys = await adapter.keys() + + expect(keys).toEqual(expect.arrayContaining([`key1`, `key2`])) + expect(keys).not.toContain(`other-prefix:key3`) + expect(keys).toHaveLength(2) + }) + + it(`should return empty array when no keys match`, async () => { + store.set(`other:key`, `value`) + + const adapter = new CapacitorStorageAdapter() + const keys = await adapter.keys() + + expect(keys).toEqual([]) + }) + + it(`should strip prefix from returned keys`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`my-key`, `value`) + const keys = await adapter.keys() + + expect(keys).toEqual([`my-key`]) + }) + }) + + describe(`clear`, () => { + it(`should remove only prefixed keys`, async () => { + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + await adapter.set(`key2`, `value2`) + store.set(`unrelated:key`, `should-survive`) + + await adapter.clear() + + expect(store.has(`offline-tx:key1`)).toBe(false) + expect(store.has(`offline-tx:key2`)).toBe(false) + expect(store.get(`unrelated:key`)).toBe(`should-survive`) + }) + + it(`should handle clearing when no keys exist`, async () => { + const adapter = new CapacitorStorageAdapter() + + await expect(adapter.clear()).resolves.toBeUndefined() + }) + }) + + describe(`probe`, () => { + it(`should return available true when Preferences works`, async () => { + const result = await CapacitorStorageAdapter.probe() + + expect(result).toEqual({ available: true }) + }) + + it(`should return available false when set fails`, async () => { + vi.mocked(Preferences.set).mockRejectedValueOnce( + new Error(`Preferences unavailable`), + ) + + const result = await CapacitorStorageAdapter.probe() + + expect(result.available).toBe(false) + expect(result.error).toBeInstanceOf(Error) + expect(result.error!.message).toBe(`Preferences unavailable`) + }) + + it(`should return available false when read verification fails`, async () => { + vi.mocked(Preferences.get).mockResolvedValueOnce({ value: `wrong` }) + + const result = await CapacitorStorageAdapter.probe() + + expect(result.available).toBe(false) + expect(result.error!.message).toContain(`verification failed`) + }) + + it(`should clean up the probe key after testing`, async () => { + await CapacitorStorageAdapter.probe() + + expect(Preferences.remove).toHaveBeenCalledWith({ + key: `__offline-tx-probe__`, + }) + }) + + it(`should wrap non-Error exceptions`, async () => { + vi.mocked(Preferences.set).mockRejectedValueOnce(`string error`) + + const result = await CapacitorStorageAdapter.probe() + + expect(result.available).toBe(false) + expect(result.error).toBeInstanceOf(Error) + expect(result.error!.message).toBe(`string error`) + }) + }) + + describe(`error handling`, () => { + it(`should return null on get failure`, async () => { + const consoleWarnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + vi.mocked(Preferences.get).mockRejectedValueOnce(new Error(`read error`)) + + const adapter = new CapacitorStorageAdapter() + const result = await adapter.get(`key1`) + + expect(result).toBeNull() + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Capacitor Preferences get failed:`, + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + + it(`should propagate set failures`, async () => { + vi.mocked(Preferences.set).mockRejectedValueOnce(new Error(`write error`)) + + const adapter = new CapacitorStorageAdapter() + + await expect(adapter.set(`key1`, `value1`)).rejects.toThrow(`write error`) + }) + + it(`should swallow delete failures silently`, async () => { + const consoleWarnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + vi.mocked(Preferences.remove).mockRejectedValueOnce(new Error(`delete error`)) + + const adapter = new CapacitorStorageAdapter() + + await expect(adapter.delete(`key1`)).resolves.toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Capacitor Preferences delete failed:`, + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + + it(`should return empty array on keys failure`, async () => { + const consoleWarnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + vi.mocked(Preferences.keys).mockRejectedValueOnce(new Error(`keys error`)) + + const adapter = new CapacitorStorageAdapter() + const keys = await adapter.keys() + + expect(keys).toEqual([]) + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Capacitor Preferences keys failed:`, + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + + it(`should swallow clear failures when keys fails`, async () => { + const consoleWarnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + vi.mocked(Preferences.keys).mockRejectedValueOnce(new Error(`keys error`)) + + const adapter = new CapacitorStorageAdapter() + + await expect(adapter.clear()).resolves.toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Capacitor Preferences keys failed:`, + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + + it(`should swallow clear failures when remove fails`, async () => { + const consoleWarnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const adapter = new CapacitorStorageAdapter() + + await adapter.set(`key1`, `value1`) + vi.mocked(Preferences.remove).mockRejectedValueOnce(new Error(`remove error`)) + + await expect(adapter.clear()).resolves.toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Capacitor Preferences clear failed:`, + expect.any(Error), + ) + + consoleWarnSpy.mockRestore() + }) + }) + + describe(`isolation between adapters`, () => { + it(`should isolate data between adapters with different prefixes`, async () => { + const adapter1 = new CapacitorStorageAdapter(`app1:`) + const adapter2 = new CapacitorStorageAdapter(`app2:`) + + await adapter1.set(`shared-key`, `value-from-app1`) + await adapter2.set(`shared-key`, `value-from-app2`) + + expect(await adapter1.get(`shared-key`)).toBe(`value-from-app1`) + expect(await adapter2.get(`shared-key`)).toBe(`value-from-app2`) + }) + + it(`should only clear keys with its own prefix`, async () => { + const adapter1 = new CapacitorStorageAdapter(`app1:`) + const adapter2 = new CapacitorStorageAdapter(`app2:`) + + await adapter1.set(`key`, `value1`) + await adapter2.set(`key`, `value2`) + + await adapter1.clear() + + expect(await adapter1.get(`key`)).toBeNull() + expect(await adapter2.get(`key`)).toBe(`value2`) + }) + }) +}) diff --git a/packages/offline-transactions/vite.config.ts b/packages/offline-transactions/vite.config.ts index 87883e4ce..f3b6dec05 100644 --- a/packages/offline-transactions/vite.config.ts +++ b/packages/offline-transactions/vite.config.ts @@ -15,8 +15,8 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: [`./src/index.ts`, `./src/react-native/index.ts`], + entry: [`./src/index.ts`, `./src/react-native/index.ts`, `./src/capacitor/index.ts`], srcDir: `./src`, - externalDeps: [`react-native`, `@react-native-community/netinfo`], + externalDeps: [`react-native`, `@react-native-community/netinfo`, `@capacitor/preferences`, `@capacitor/network`], }), ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 183f92a59..5ca10a43f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -896,6 +896,12 @@ importers: specifier: workspace:* version: link:../db devDependencies: + '@capacitor/network': + specifier: 8.0.0 + version: 8.0.0(@capacitor/core@8.1.0) + '@capacitor/preferences': + specifier: 8.0.0 + version: 8.0.0(@capacitor/core@8.1.0) '@react-native-community/netinfo': specifier: 11.4.1 version: 11.4.1(react-native@0.79.6(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.2.4)) @@ -1905,6 +1911,19 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@capacitor/core@8.1.0': + resolution: {integrity: sha512-UfMBMWc1v7J+14AhH03QmeNwV3HZx3qnOWhpwnHfzALEwAwlV/itQOQqcasMQYhOHWL0tiymc5ByaLTn7KKQxw==} + + '@capacitor/network@8.0.0': + resolution: {integrity: sha512-fgvB7pNKn8pKavuzys218j4YuA5euNfavp7nS3NuwWKWNupZAlbucfnl75lazxCyVF/ZRjzYVTb4vtTEfFrK1A==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + + '@capacitor/preferences@8.0.0': + resolution: {integrity: sha512-NsE7Srk9Zr0SxiVelHGiAJR7M238eyCD6dI/sDhu3ckKwFrXn8/GRyGr+SZcnGLlQKy948li8Pfcfr0dqxNf1g==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@changesets/apply-release-plan@7.0.14': resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} @@ -11799,6 +11818,18 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@capacitor/core@8.1.0': + dependencies: + tslib: 2.8.1 + + '@capacitor/network@8.0.0(@capacitor/core@8.1.0)': + dependencies: + '@capacitor/core': 8.1.0 + + '@capacitor/preferences@8.0.0(@capacitor/core@8.1.0)': + dependencies: + '@capacitor/core': 8.1.0 + '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2