From ebbc50015b6264f199ec5345abbf7549224d55f9 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 15 Oct 2026 12:46:05 +0900 Subject: [PATCH 1/2] feat(offline): share disposable storage lifecycle and generation guards --- package.json | 1 + projects/kit/docs/offline-realtime.md | 47 ++++ .../offline/src/lib/offline-kit-options.ts | 3 + .../offline/src/lib/offline-local-reset.ts | 12 +- .../kit/offline/src/lib/offline-provider.ts | 2 + .../kit/offline/src/lib/offline-repository.ts | 15 +- .../src/lib/offline-storage-lifecycle.spec.ts | 259 ++++++++++++++++++ .../src/lib/offline-storage-lifecycle.ts | 171 ++++++++++++ .../src/lib/sqlite-offline-repository.ts | 4 +- projects/kit/offline/src/public-api.ts | 2 + .../storage-generation/ng-package.json | 4 + .../storage-generation/src/public-api.ts | 20 ++ ...offline-storage-lifecycle.browser.test.mjs | 84 ++++++ tsconfig.json | 1 + 14 files changed, 615 insertions(+), 10 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-storage-lifecycle.ts create mode 100644 projects/kit/offline/storage-generation/ng-package.json create mode 100644 projects/kit/offline/storage-generation/src/public-api.ts create mode 100644 tests/offline-storage-lifecycle.browser.test.mjs diff --git a/package.json b/package.json index 5ffe7d64..d0d8d87d 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "test:watch": "ng test", "test:actions": "node --test .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs && vitest run --config .github/actions/vitest.config.mjs", "test:offline-types:nonstrict": "npm run prebuild:kit && tsc -p projects/kit/offline/tsconfig.nonstrict-null.json", + "test:storage-lifecycle:browser": "node --test tests/offline-storage-lifecycle.browser.test.mjs", "test:sqlite": "npm run test:sqlite-concurrency && vitest run --config vitest.sqlite.config.ts", "test:sqlite-concurrency": "node --test projects/kit/offline/src/lib/sqlite-concurrency.node.test.mjs", "test:package-consumer": "node scripts/test-package-consumer.mjs", diff --git a/projects/kit/docs/offline-realtime.md b/projects/kit/docs/offline-realtime.md index 5911f80f..8460d651 100644 --- a/projects/kit/docs/offline-realtime.md +++ b/projects/kit/docs/offline-realtime.md @@ -221,3 +221,50 @@ credential exchange is still pending. Subclass `KitRealtimeConnection` to supply connection intent and `{ url, protocols }` targets. The kit owns foreground and network suspension, target-scoped reconnect, exponential backoff, ping/pong detection, self-echo annotation, and `reconnected$` resync signaling. Use `kitRealtimeProtocols()` to carry authentication and the stable `KIT_REALTIME_CLIENT_ID` in WebSocket subprotocols instead of URL parameters. Offline-capable authenticated clients set `requireRemoteAccess: true`; sockets then remain closed in `none` and `local` modes. + +## Disposable storage generations + +`OfflineStorageLifecycle` is an explicit alternative to retaining incremental local data migrations. +It does not change the wire protocol and never resets storage in response to a generic error. +Products must opt in with `discardPendingCommands: true`: the entire configured replica, cursors, +local snapshots and unsent Outbox are disposable. Authentication and unrelated preferences remain +outside the cleanup targets. Repopulation requires connectivity. + +Create one lifecycle instance for bootstrap and pass that same instance as `storageLifecycle` to +`provideOffline` / `provideRouteScopedOffline`. Await `prepare()` before constructing product +services that open the database. Kit also gates repository initialization through this instance. +`reload-required` is not permission to open the repository: render the product's reset/update UI, +call `requestRebuildAfterBootstrap(waitUntilReady, location)`, and resume only after a cold restart. +The `beforeCleanup` hook can acknowledge Live Update readiness before slow native file deletion. +Kit does not depend on the Live Update plugin or supply product messages/navigation. + +The lightweight `@rdlabo/ionic-angular-kit/offline/storage-generation` entry point exports +`inspectOfflineStorageGeneration` for startup guards without eagerly loading the offline runtime. +Generation values and marker keys are product-owned. SeatKeep keeps its +`OFFLINE_SCHEMA_VERSION:schemaHash` identity; Winecode keeps generation `4` and its existing keys. +Matching markers are accepted without deleting data or forcing a migration-only reset. Local +schema changes must change the configured generation; they must not change `wireProtocol` unless +the server contract itself is incompatible. Retaining an explicit numeric generation requires the +product to bump it when its local storage shape changes. + +Native targets list only databases compatible with the existing reset connection lifecycle; +`additionalCleanup` owns other databases/media files. A pending reset survives deletion failure, +completion-marker failure, and interrupted cleanup. Completion is recorded only after all cleanup +succeeds. Deferred mode can adopt a fresh installation without reloading when no target DB exists. +Existing manual reset and encryption-migration APIs remain available and retain their behavior. + +Web targets require Web Locks (secure context). All cooperating runtimes use one origin-wide lock +for the existing `offline:` namespace: preparation and writes are exclusive, reads are shared. +The repository validates the generation and a reset revision within the lock. A stale runtime throws +`OfflineStorageGenerationChangedError` before touching rebuilt data, including after a manual reset +of the same generation. The product should reload that runtime. Absence of Web Locks fails before +any deletion; there is no unsafe per-tab fallback. Already shipped clients without this protocol +cannot be fenced by the new library; replacing/reloading old tabs is necessary during rollout. + +Validation commands: + +- `npm test -- --project kit --include='../offline/src/lib/offline-storage-lifecycle.spec.ts'` +- `npm run test:storage-lifecycle:browser` (headless Chromium, two pages of the same origin) + +The browser test runs the actual lifecycle with Web Locks and a small localStorage adapter. Repository +integration tests separately cover the KitStorageService boundary and stale repository reads/writes. diff --git a/projects/kit/offline/src/lib/offline-kit-options.ts b/projects/kit/offline/src/lib/offline-kit-options.ts index 53e71b73..cacc7acb 100644 --- a/projects/kit/offline/src/lib/offline-kit-options.ts +++ b/projects/kit/offline/src/lib/offline-kit-options.ts @@ -1,3 +1,4 @@ +import type { OfflineStorageLifecycle } from './offline-storage-lifecycle'; import { InjectionToken, type Type } from '@angular/core'; import type { OfflineReplicaSchemaBundle } from './offline-replica-schema'; import type { OfflineStorageUnavailableError } from './offline-storage'; @@ -63,6 +64,8 @@ interface OfflineKitOptionsBase { * the replica schema fingerprint for backward compatibility. */ wireProtocol?: OfflineWireProtocolFingerprint; + /** Opt-in disposable storage lifecycle. Shared with the product bootstrap before opening a repository. */ + storageLifecycle?: OfflineStorageLifecycle; /** Optional durable Outbox backpressure policy. */ outboxLimits?: OfflineOutboxLimits; /** diff --git a/projects/kit/offline/src/lib/offline-local-reset.ts b/projects/kit/offline/src/lib/offline-local-reset.ts index 592312d3..f6892a8e 100644 --- a/projects/kit/offline/src/lib/offline-local-reset.ts +++ b/projects/kit/offline/src/lib/offline-local-reset.ts @@ -96,7 +96,7 @@ export async function recoverOfflineLocalReset(options: RecoverOfflineLocalReset const marker = await options.markerStore.get({ key: options.markerKey }); if (marker.value !== OFFLINE_LOCAL_RESET_REQUESTED) return false; - await resetOfflineDatabases({ + await clearOfflineLocalDatabases({ sqliteConnection: options.sqliteConnection, databaseNames: options.kitCompatibleDatabaseNames, databaseEncryption: options.databaseEncryption ?? COMMUNITY_SQLITE_ENCRYPTED, @@ -118,7 +118,7 @@ export async function migrateOfflineDatabaseEncryption(options: MigrateOfflineDa const marker = await options.markerStore.get({ key: options.markerKey }); if (marker.value === options.migrationVersion) return false; - await resetOfflineDatabases({ + await clearOfflineLocalDatabases({ sqliteConnection: options.sqliteConnection, databaseNames: options.kitCompatibleDatabaseNames, databaseEncryption: options.sourceDatabaseEncryption, @@ -134,12 +134,16 @@ export async function migrateOfflineDatabaseEncryption(options: MigrateOfflineDa return true; } -async function resetOfflineDatabases(options: { +/** Native databases and product files to remove before recording a completed reset. */ +export interface OfflineLocalDatabaseResetOptions { sqliteConnection: OfflineLocalResetSqliteConnection; databaseNames: readonly [string, ...string[]]; databaseEncryption: boolean | 'detect'; additionalCleanup?: () => Promise; -}): Promise { +} + +/** Deletes only explicitly configured databases and files; leaves markers and authentication untouched. */ +export async function clearOfflineLocalDatabases(options: OfflineLocalDatabaseResetOptions): Promise { await options.sqliteConnection.checkConnectionsConsistency(); for (const databaseName of new Set(options.databaseNames)) { const exists = await options.sqliteConnection.isDatabase(databaseName); diff --git a/projects/kit/offline/src/lib/offline-provider.ts b/projects/kit/offline/src/lib/offline-provider.ts index 73923160..0662ac66 100644 --- a/projects/kit/offline/src/lib/offline-provider.ts +++ b/projects/kit/offline/src/lib/offline-provider.ts @@ -240,6 +240,7 @@ function offlineConfigurationProviders(options: ProvideOfflineOptions): Provider createEncryptionKey: options.createEncryptionKey, replicaSchema: options.replicaSchema, wireProtocol: options.wireProtocol, + storageLifecycle: options.storageLifecycle, outboxLimits: options.outboxLimits, mutationPersistence: options.mutationPersistence, onStorageUnavailable: options.onStorageUnavailable, @@ -251,6 +252,7 @@ function offlineConfigurationProviders(options: ProvideOfflineOptions): Provider createEncryptionKey: options.createEncryptionKey, replicaSchema: options.replicaSchema, wireProtocol: options.wireProtocol, + storageLifecycle: options.storageLifecycle, outboxLimits: options.outboxLimits, mutationPersistence: options.mutationPersistence, onStorageUnavailable: options.onStorageUnavailable, diff --git a/projects/kit/offline/src/lib/offline-repository.ts b/projects/kit/offline/src/lib/offline-repository.ts index d4dcce32..935909d2 100644 --- a/projects/kit/offline/src/lib/offline-repository.ts +++ b/projects/kit/offline/src/lib/offline-repository.ts @@ -348,7 +348,7 @@ export class IonicOfflineRepository implements OfflineRepository { initialize(): Promise { if (!this.#initialization) { - this.#initialization = this.#migrate().catch((error: unknown) => { + this.#initialization = this.#withStorageLifecycle('exclusive', () => this.#migrate()).catch((error: unknown) => { this.#initialization = null; throw error; }); @@ -564,7 +564,7 @@ export class IonicOfflineRepository implements OfflineRepository { await this.#writes; this.#beginReaders(); const read = async (): Promise => operation(); - return read().finally(() => this.#endReaders()); + return this.#withStorageLifecycle('shared', read).finally(() => this.#endReaders()); } #beginReaders(): void { @@ -836,7 +836,8 @@ export class IonicOfflineRepository implements OfflineRepository { }); throw error; }); - }); + // Initialization already holds the lifecycle lock; Web Locks are not reentrant. + }, false); } #toWebMigrationRow(row: OfflineReplicaRow): OfflineReplicaWebMigrationRow { @@ -1059,10 +1060,14 @@ export class IonicOfflineRepository implements OfflineRepository { await this.#storage.set(key, mutate(record)); } - #enqueueWrite(operation: () => Promise): Promise { + #withStorageLifecycle(mode: 'shared' | 'exclusive', operation: () => Promise): Promise { + return this.#options.storageLifecycle ? this.#options.storageLifecycle.run(mode, operation) : operation(); + } + + #enqueueWrite(operation: () => Promise, guard = true): Promise { const write = this.#writes.then(async () => { if (this.#activeReaders > 0) await this.#readersIdle; - return operation(); + return guard ? this.#withStorageLifecycle('exclusive', operation) : operation(); }); this.#writes = write.then( () => undefined, diff --git a/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts b/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts new file mode 100644 index 00000000..9b375004 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts @@ -0,0 +1,259 @@ +import { Injector } from '@angular/core'; +import { KitStorageService } from '@rdlabo/ionic-angular-kit'; +import { IonicOfflineRepository } from './offline-repository'; +import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; +import { defineOfflineReplicaSchema } from './offline-replica-schema'; +import { describe, expect, it, vi } from 'vitest'; +import { + OfflineStorageLifecycle, + OfflineStorageGenerationChangedError, + type OfflineStorageLifecycleOptions, +} from './offline-storage-lifecycle'; + +function fixture() { + const markers = new Map(); + const rows = new Map([ + ['offline:outbox:commands', { pending: true }], + ['token', 'keep'], + ]); + const markerStore = { + get: vi.fn(async ({ key }: { key: string }) => ({ value: markers.get(key) ?? null })), + set: vi.fn(async ({ key, value }: { key: string; value: string }) => { + markers.set(key, value); + }), + remove: vi.fn(async ({ key }: { key: string }) => { + markers.delete(key); + }), + }; + const storage = { + get: async (key: string): Promise => structuredClone((rows.get(key) as T) ?? null), + set: async (key: string, value: T): Promise => { + rows.set(key, structuredClone(value)); + return value; + }, + keys: vi.fn(async () => [...rows.keys()]), + remove: vi.fn(async (key: string) => { + rows.delete(key); + }), + }; + // Deterministic exclusive queue, shared by simulated tabs. Real Web Locks are exercised in browser verification. + let tail: Promise | undefined; + const locks = { + request: (_name: string, _options: unknown, callback: () => Promise) => { + const result = tail ? tail.then(callback) : callback(); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + } as Pick; + const options: OfflineStorageLifecycleOptions = { + markerStore, + generationKey: 'generation', + markerKey: 'reset', + generation: 'current', + discardPendingCommands: true, + target: { kind: 'web', storage, locks }, + }; + return { markers, rows, markerStore, storage, options, lifecycle: () => new OfflineStorageLifecycle(options) }; +} + +describe('disposable storage lifecycle', () => { + it('adopts an existing matching marker without discarding pending commands or authentication', async () => { + const f = fixture(); + f.markers.set('generation', 'current'); + expect(await f.lifecycle().prepare()).toBe('ready'); + expect(f.storage.remove).not.toHaveBeenCalled(); + expect(f.rows.has('offline:outbox:commands')).toBe(true); + }); + + it('discards the whole replica once on first adoption but preserves other storage', async () => { + const f = fixture(); + expect(await f.lifecycle().prepare()).toBe('rebuilt'); + expect([...f.rows.entries()]).toEqual([['token', 'keep']]); + expect(f.markers.get('generation')).toBe('current'); + expect(f.markers.has('reset')).toBe(false); + expect(await f.lifecycle().prepare()).toBe('ready'); + expect(f.storage.remove).toHaveBeenCalledOnce(); + }); + + it('keeps the reset intent and retries a partially failed deletion before opening storage', async () => { + const f = fixture(); + f.markers.set('generation', 'old'); + f.storage.remove.mockRejectedValueOnce(new Error('locked')); + const lifecycle = f.lifecycle(); + const open = vi.fn(async () => undefined); + await expect(lifecycle.run('exclusive', open)).rejects.toThrow('locked'); + expect(open).not.toHaveBeenCalled(); + expect(f.markers.get('generation')).toBe('old'); + expect(f.markers.get('reset')).toBe('requested'); + await lifecycle.run('exclusive', open); + expect(open).toHaveBeenCalledOnce(); + }); + + it('retries cleanup if writing the generation or consuming the marker fails', async () => { + const f = fixture(); + f.markerStore.remove.mockRejectedValueOnce(new Error('marker failure')); + await expect(f.lifecycle().prepare()).rejects.toThrow('marker failure'); + expect(f.markers.get('reset')).toBe('requested'); + f.rows.set('offline:replica:rows', {}); + expect(await f.lifecycle().prepare()).toBe('rebuilt'); + expect(f.rows.has('offline:replica:rows')).toBe(false); + }); + + it('serializes competing tabs and rechecks the marker instead of deleting new cache twice', async () => { + const f = fixture(); + const a = f.lifecycle(); + const b = f.lifecycle(); + expect(await Promise.all([a.prepare(), b.prepare()])).toEqual(['rebuilt', 'ready']); + await a.run('exclusive', async () => { + f.rows.set('offline:replica:rows', 'new'); + }); + await b.run('shared', async () => expect(f.rows.get('offline:replica:rows')).toBe('new')); + expect(f.storage.remove).toHaveBeenCalledOnce(); + }); + + it.each(['new-generation', 'manual-reset'])('rejects stale readers and writers after %s', async (reason) => { + const f = fixture(); + const old = f.lifecycle(); + await old.prepare(); + if (reason === 'manual-reset') f.markers.set('reset', 'requested'); + const next = new OfflineStorageLifecycle({ ...f.options, generation: reason === 'manual-reset' ? 'current' : 'next' }); + await next.prepare(); + const write = vi.fn(async () => { + f.rows.set('offline:replica:rows', 'stale'); + }); + await expect(old.run('exclusive', write)).rejects.toBeInstanceOf(OfflineStorageGenerationChangedError); + await expect(old.run('shared', write)).rejects.toBeInstanceOf(OfflineStorageGenerationChangedError); + expect(write).not.toHaveBeenCalled(); + }); + + it('waits for an active operation to finish before rebuilding', async () => { + const f = fixture(); + const old = f.lifecycle(); + await old.prepare(); + let release!: () => void; + let started!: () => void; + const entered = new Promise((resolve) => { + started = resolve; + }); + const blocked = new Promise((resolve) => { + release = resolve; + }); + const write = old.run('exclusive', async () => { + started(); + await blocked; + f.rows.set('offline:replica:rows', 'old'); + }); + await entered; + const next = new OfflineStorageLifecycle({ ...f.options, generation: 'next' }); + const rebuild = next.prepare(); + expect(f.markers.get('generation')).toBe('current'); + release(); + await write; + await rebuild; + expect(f.rows.has('offline:replica:rows')).toBe(false); + }); + + it('computes the bundled generation once rather than hashing the schema on every read', async () => { + const f = fixture(); + const generation = vi.fn(async () => 'current'); + const lifecycle = new OfflineStorageLifecycle({ ...f.options, generation }); + await lifecycle.run('shared', async () => undefined); + await lifecycle.run('shared', async () => undefined); + expect(generation).toHaveBeenCalledOnce(); + }); + + it('keeps the old generation and reset intent if recording completion fails', async () => { + const f = fixture(); + f.markers.set('generation', 'old'); + const originalSet = f.markerStore.set.getMockImplementation()!; + f.markerStore.set.mockImplementation(async (entry) => { + if (entry.key === 'generation') throw new Error('generation write failed'); + await originalSet(entry); + }); + await expect(f.lifecycle().prepare()).rejects.toThrow('generation write failed'); + expect(f.markers.get('generation')).toBe('old'); + expect(f.markers.get('reset')).toBe('requested'); + f.markerStore.set.mockImplementation(originalSet); + expect(await f.lifecycle().prepare()).toBe('rebuilt'); + }); + + it('does not delete anything in response to an operation failure', async () => { + const f = fixture(); + const lifecycle = f.lifecycle(); + await lifecycle.prepare(); + f.rows.set('offline:replica:rows', 'keep'); + await expect( + lifecycle.run('shared', async () => { + throw new Error('wire mismatch'); + }), + ).rejects.toThrow('wire mismatch'); + expect(f.rows.get('offline:replica:rows')).toBe('keep'); + expect(f.markers.has('reset')).toBe(false); + }); + + it('guards real repository initialization, reads and queued writes across a rebuild', async () => { + const f = fixture(); + const createRepository = (generation: string) => + Injector.create({ + providers: [ + IonicOfflineRepository, + { provide: KitStorageService, useValue: f.storage }, + { + provide: OFFLINE_KIT_OPTIONS, + useValue: { + databaseName: 'test', + databaseEncryption: false, + replicaSchema: defineOfflineReplicaSchema({ version: 1, entities: [], migrations: [] }), + storageLifecycle: new OfflineStorageLifecycle({ ...f.options, generation }), + }, + }, + ], + }).get(IonicOfflineRepository); + const old = createRepository('current'); + await old.setLastUserId('user-a'); + expect(await old.getLastUserId()).toBe('user-a'); + const next = createRepository('next'); + await next.setLastUserId('user-b'); + await expect(old.setLastUserId('stale')).rejects.toBeInstanceOf(OfflineStorageGenerationChangedError); + await expect(old.getLastUserId()).rejects.toBeInstanceOf(OfflineStorageGenerationChangedError); + expect(await next.getLastUserId()).toBe('user-b'); + }); + + it('defers native deletion until readiness and a cold restart, retaining the existing generation format', async () => { + const f = fixture(); + const deleted = vi.fn(async () => undefined); + const ready = vi.fn(async () => undefined); + const options: OfflineStorageLifecycleOptions = { + ...f.options, + generation: '4', + rebuild: 'after-reload', + beforeCleanup: ready, + target: { + kind: 'sqlite', + databaseNames: ['replica', 'media'], + databaseEncryption: false, + sqliteConnection: { + checkConnectionsConsistency: vi.fn(async () => ({})), + isDatabase: vi.fn(async () => ({ result: true })), + createConnection: vi.fn(async () => ({ delete: deleted })), + closeConnection: vi.fn(async () => undefined), + }, + }, + }; + const first = new OfflineStorageLifecycle(options); + expect(await first.prepare()).toBe('reload-required'); + await expect(first.run('exclusive', vi.fn())).rejects.toThrow('cold reload'); + expect(deleted).not.toHaveBeenCalled(); + const reload = vi.fn(); + await first.requestRebuildAfterBootstrap(ready, { reload }); + expect(reload).toHaveBeenCalledOnce(); + expect(f.markers.get('reset')).toBe('requested'); + expect(await new OfflineStorageLifecycle(options).prepare()).toBe('rebuilt'); + expect(deleted).toHaveBeenCalledTimes(2); + expect(f.markers.get('generation')).toBe('4'); + expect(ready.mock.invocationCallOrder[1]).toBeLessThan(deleted.mock.invocationCallOrder[0]!); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-storage-lifecycle.ts b/projects/kit/offline/src/lib/offline-storage-lifecycle.ts new file mode 100644 index 00000000..22c24de8 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-storage-lifecycle.ts @@ -0,0 +1,171 @@ +import { + inspectOfflineStorageGeneration, + type OfflineStorageGenerationOptions, +} from '@rdlabo/ionic-angular-kit/offline/storage-generation'; +import type { KitStorageService } from '@rdlabo/ionic-angular-kit'; +import { + clearOfflineLocalDatabases, + type OfflineLocalDatabaseResetOptions, + type OfflineLocalResetMarkerStore, + type OfflineLocalResetReloadTarget, +} from './offline-local-reset'; + +/** Destructive storage preparation configured by the consuming product. */ +export interface OfflineStorageLifecycleOptions extends OfflineStorageGenerationOptions { + markerStore: OfflineLocalResetMarkerStore; + /** Explicit acknowledgement that the replica and all pending Outbox commands may be discarded. */ + discardPendingCommands: true; + target: + | ({ kind: 'sqlite' } & OfflineLocalDatabaseResetOptions) + | { kind: 'web'; storage: Pick; locks?: Pick }; + /** Defer existing storage to a cold reload after the product renders its update UI. */ + rebuild?: 'immediate' | 'after-reload'; + /** Product hook, e.g. Live Update readiness, awaited before any destructive operation. */ + beforeCleanup?: () => Promise; +} + +/** Preparation result; callers must not open a repository while a reload is required. */ +export type OfflineStoragePreparation = 'ready' | 'rebuilt' | 'reload-required'; + +/** A different runtime has rebuilt this storage. Reload before using the local replica again. */ +export class OfflineStorageGenerationChangedError extends Error { + constructor() { + super('Offline storage changed in another runtime. Reload the application before using the local replica.'); + this.name = 'OfflineStorageGenerationChangedError'; + } +} + +const nativeOperations = new WeakMap>>(); + +/** + * Opt-in lifecycle for disposable local replicas. Never infer a reset from a network, auth, + * wire-protocol or generic storage error. Use one instance for preparation and repository access. + */ +export class OfflineStorageLifecycle { + #preparation: Promise | null = null; + #generation: string | null = null; + #revision: string | null = null; + + constructor(private readonly options: OfflineStorageLifecycleOptions) { + if (options.discardPendingCommands !== true) + throw new Error('Storage rebuild requires explicit permission to discard pending commands.'); + } + + /** Prepares storage once, before the repository is opened; failed attempts remain retryable. */ + prepare(): Promise { + this.#preparation ??= this.#lock('exclusive', () => this.#prepare()).then( + (result) => { + if (result === 'reload-required') this.#preparation = null; + return result; + }, + (error: unknown) => { + this.#preparation = null; + throw error; + }, + ); + return this.#preparation; + } + + /** + * Holds the same Web Lock used for reset throughout one repository operation. + * A stale runtime fails before it can read or write the newly rebuilt storage. + */ + async run(mode: 'shared' | 'exclusive', operation: () => Promise): Promise { + if ((await this.prepare()) === 'reload-required') throw new Error('Offline storage requires a cold reload before opening.'); + return this.#lock(mode, async () => { + const state = await inspectOfflineStorageGeneration({ ...this.options, generation: this.#generation! }); + const revision = await this.options.markerStore.get({ key: this.#revisionKey }); + if (!state.matches || state.requested || state.generation !== this.#generation || revision.value !== this.#revision) { + throw new OfflineStorageGenerationChangedError(); + } + return operation(); + }); + } + + /** Waits for product readiness, rechecks the state, persists intent, then requests a cold reload. */ + async requestRebuildAfterBootstrap( + waitUntilReady: () => Promise, + reloadTarget: OfflineLocalResetReloadTarget, + ): Promise { + const state = await inspectOfflineStorageGeneration(this.options); + if (state.matches || state.requested) return false; + await waitUntilReady(); + const requested = await this.#lock('exclusive', async () => { + const current = await inspectOfflineStorageGeneration(this.options); + if (current.matches || current.requested) return false; + await this.options.markerStore.set({ key: this.options.markerKey, value: 'requested' }); + return true; + }); + if (requested) reloadTarget.reload(); + return requested; + } + + get #revisionKey(): string { + return `${this.options.generationKey}:revision`; + } + + async #prepare(): Promise { + const options = this.options; + const state = await inspectOfflineStorageGeneration(options); + this.#generation = state.generation; + if (state.matches && !state.requested) { + this.#revision = (await options.markerStore.get({ key: this.#revisionKey })).value; + return 'ready'; + } + if (!state.requested && options.rebuild === 'after-reload') { + if (await this.#hasStorage()) return 'reload-required'; + await options.markerStore.set({ key: options.generationKey, value: state.generation }); + this.#revision = (await options.markerStore.get({ key: this.#revisionKey })).value; + return 'ready'; + } + await options.beforeCleanup?.(); + if (!state.requested) await options.markerStore.set({ key: options.markerKey, value: 'requested' }); + if (options.target.kind === 'sqlite') { + await clearOfflineLocalDatabases(options.target); + } else { + const keys = (await options.target.storage.keys()).filter((key) => key.startsWith('offline:')); + for (const key of keys) await options.target.storage.remove(key); + } + // Keep the reset intent until *all* completion writes succeed. A crash retries cleanup. + const revision = options.target.kind === 'web' ? crypto.randomUUID() : null; + if (revision !== null) await options.markerStore.set({ key: this.#revisionKey, value: revision }); + await options.markerStore.set({ key: options.generationKey, value: state.generation }); + await options.markerStore.remove({ key: options.markerKey }); + this.#revision = revision; + return 'rebuilt'; + } + + async #hasStorage(): Promise { + const target = this.options.target; + if (target.kind === 'web') return (await target.storage.keys()).some((key) => key.startsWith('offline:')); + await target.sqliteConnection.checkConnectionsConsistency(); + const databases = await Promise.all(target.databaseNames.map((name) => target.sqliteConnection.isDatabase(name))); + return databases.some(({ result }) => result === true); + } + + #lock(mode: 'shared' | 'exclusive', operation: () => Promise): Promise { + if (this.options.target.kind === 'sqlite') { + let operations = nativeOperations.get(this.options.markerStore); + if (!operations) { + operations = new Map(); + nativeOperations.set(this.options.markerStore, operations); + } + const key = this.options.generationKey; + const previous = operations.get(key); + const result = previous ? previous.then(operation) : operation(); + const settled = result.then( + () => undefined, + () => undefined, + ); + operations.set(key, settled); + void settled.then(() => { + if (operations.get(key) === settled) operations.delete(key); + }); + return result; + } + const locks = this.options.target.locks ?? globalThis.navigator?.locks; + if (!locks) return Promise.reject(new Error('Web Locks are required to safely rebuild shared offline storage.')); + // One origin-wide lock matches the existing shared `offline:` namespace, even across products. + return locks.request('kit:offline:storage', { mode }, operation); + } +} diff --git a/projects/kit/offline/src/lib/sqlite-offline-repository.ts b/projects/kit/offline/src/lib/sqlite-offline-repository.ts index a7d3d5be..6722ee57 100644 --- a/projects/kit/offline/src/lib/sqlite-offline-repository.ts +++ b/projects/kit/offline/src/lib/sqlite-offline-repository.ts @@ -385,7 +385,9 @@ export class SqliteOfflineRepository implements OfflineRepository { #resolveAtomicIdle: (() => void) | null = null; initialize(): Promise { - this.#initialization ??= this.#open(); + this.#initialization ??= this.#options.storageLifecycle + ? this.#options.storageLifecycle.run('exclusive', () => this.#open()) + : this.#open(); return this.#initialization; } diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 6ebaa1a1..10ca4993 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -20,6 +20,8 @@ export * from './lib/offline-auth-bridge'; export * from './lib/offline-coordinator.service'; export * from './lib/offline-kit-options'; export * from './lib/offline-local-reset'; +export * from './lib/offline-storage-lifecycle'; +export * from '@rdlabo/ionic-angular-kit/offline/storage-generation'; export * from './lib/offline-mutation-envelope'; export { OfflineMutationPersistenceDisabledError } from './lib/offline-mutation-admission.service'; export { diff --git a/projects/kit/offline/storage-generation/ng-package.json b/projects/kit/offline/storage-generation/ng-package.json new file mode 100644 index 00000000..74df6265 --- /dev/null +++ b/projects/kit/offline/storage-generation/ng-package.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { "entryFile": "src/public-api.ts" } +} diff --git a/projects/kit/offline/storage-generation/src/public-api.ts b/projects/kit/offline/storage-generation/src/public-api.ts new file mode 100644 index 00000000..52f6f41f --- /dev/null +++ b/projects/kit/offline/storage-generation/src/public-api.ts @@ -0,0 +1,20 @@ +/** Lightweight storage marker inspection with no Angular, SQLite, or replica-runtime imports. @experimental */ +/** Persistent identity of an explicitly disposable local replica, independent of the wire protocol. */ +export interface OfflineStorageGenerationOptions { + markerStore: { get(options: { key: string }): Promise<{ value: string | null }> }; + generationKey: string; + markerKey: string; + generation: string | (() => Promise); +} + +/** Reads the generation and durable reset intent without opening or modifying any replica. */ +export async function inspectOfflineStorageGeneration( + options: OfflineStorageGenerationOptions, +): Promise<{ generation: string; matches: boolean; requested: boolean }> { + const generation = typeof options.generation === 'string' ? options.generation : await options.generation(); + const [stored, marker] = await Promise.all([ + options.markerStore.get({ key: options.generationKey }), + options.markerStore.get({ key: options.markerKey }), + ]); + return { generation, matches: stored.value === generation, requested: marker.value === 'requested' }; +} diff --git a/tests/offline-storage-lifecycle.browser.test.mjs b/tests/offline-storage-lifecycle.browser.test.mjs new file mode 100644 index 00000000..8a28d04c --- /dev/null +++ b/tests/offline-storage-lifecycle.browser.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { test } from 'node:test'; +import { build } from 'esbuild'; +import { chromium } from '@playwright/test'; + +test('Web Locks serialize resets across tabs and fence stale writers', { timeout: 60000 }, async (t) => { + const bundle = await build({ + entryPoints: ['projects/kit/offline/src/lib/offline-storage-lifecycle.ts'], + bundle: true, write: false, format: 'iife', globalName: 'kitLifecycle', platform: 'browser', + }); + const server = createServer((request, response) => { + response.setHeader('Content-Type', request.url === '/kit.js' ? 'text/javascript' : 'text/html'); + response.end(request.url === '/kit.js' ? bundle.outputFiles[0].text : ''); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + let browser; + t.after(async () => { + await browser?.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const a = await context.newPage(); + const b = await context.newPage(); + const origin = `http://127.0.0.1:${server.address().port}`; + await Promise.all([a.goto(origin), b.goto(origin)]); + const initialize = async (page, generation) => page.evaluate((generation) => { + const markerStore = { + get: async ({ key }) => ({ value: localStorage.getItem(key) }), + set: async ({ key, value }) => localStorage.setItem(key, value), + remove: async ({ key }) => localStorage.removeItem(key), + }; + const storage = { + keys: async () => Object.keys(localStorage), + remove: async (key) => { + localStorage.setItem('deletions', String(Number(localStorage.getItem('deletions') ?? 0) + 1)); + await new Promise((resolve) => setTimeout(resolve, 20)); + localStorage.removeItem(key); + }, + }; + window.lifecycle = new kitLifecycle.OfflineStorageLifecycle({ + markerStore, generationKey: 'generation', markerKey: 'reset', generation, + discardPendingCommands: true, target: { kind: 'web', storage }, + }); + }, generation); + await a.evaluate(() => { + localStorage.setItem('offline:rows', 'old'); + localStorage.setItem('token', 'preserved'); + }); + await Promise.all([initialize(a, 'one'), initialize(b, 'one')]); + const results = await Promise.all([a.evaluate(() => lifecycle.prepare()), b.evaluate(() => lifecycle.prepare())]); + assert.deepEqual(results.sort(), ['ready', 'rebuilt']); + assert.equal(await a.evaluate(() => localStorage.getItem('deletions')), '1'); + await a.evaluate(() => lifecycle.run('exclusive', async () => localStorage.setItem('offline:rows', 'current'))); + assert.equal(await b.evaluate(() => lifecycle.run('shared', async () => localStorage.getItem('offline:rows'))), 'current'); + + // Hold a real cross-tab operation open; a generation reset cannot pass it. + await a.evaluate(() => { + window.operationStarted = false; + window.operation = lifecycle.run('exclusive', async () => { + window.operationStarted = true; + await new Promise((resolve) => { window.releaseOperation = resolve; }); + localStorage.setItem('offline:rows', 'old-in-flight'); + }); + }); + await a.waitForFunction(() => window.operationStarted); + await initialize(b, 'two'); + await b.evaluate(() => { window.rebuilding = lifecycle.prepare(); }); + await b.waitForFunction(async () => (await navigator.locks.query()).pending.length === 1); + assert.equal(await a.evaluate(() => localStorage.getItem('generation')), 'one'); + await a.evaluate(async () => { releaseOperation(); await operation; }); + assert.equal(await b.evaluate(() => rebuilding), 'rebuilt'); + assert.equal(await b.evaluate(() => localStorage.getItem('offline:rows')), null); + assert.equal(await b.evaluate(() => localStorage.getItem('token')), 'preserved'); + assert.equal(await a.evaluate(async () => { + try { + await lifecycle.run('exclusive', async () => localStorage.setItem('offline:rows', 'stale')); + return 'unexpected-write'; + } catch (error) { return error.name; } + }), 'OfflineStorageGenerationChangedError'); + assert.equal(await b.evaluate(() => localStorage.getItem('offline:rows')), null); +}); diff --git a/tsconfig.json b/tsconfig.json index 1750193f..d9363116 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "paths": { "ionic-theme-ios26": ["./dist/ionic-theme-ios26"], "kit": ["./dist/kit"], + "@rdlabo/ionic-angular-kit/offline/storage-generation": ["./projects/kit/offline/storage-generation/src/public-api.ts"], "@rdlabo/ionic-angular-kit": ["./projects/kit/src/public-api.ts"], "@rdlabo/ionic-angular-kit/auth-firebase": ["./projects/kit/auth-firebase/src/public-api.ts"], "@rdlabo/ionic-angular-kit/auth-firebase/social": ["./projects/kit/auth-firebase/social/src/public-api.ts"], From d6811cd669bce5d8e727f3a460fd3f2d49f0d308 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Thu, 15 Oct 2026 13:00:04 +0900 Subject: [PATCH 2/2] fix(offline): preserve recovery retries and Angular 21 compatibility --- .../src/lib/offline-storage-lifecycle.spec.ts | 45 +++++++++++++++++++ .../src/lib/offline-storage-lifecycle.ts | 18 +++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts b/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts index 9b375004..86a4a289 100644 --- a/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts +++ b/projects/kit/offline/src/lib/offline-storage-lifecycle.spec.ts @@ -60,6 +60,51 @@ function fixture() { } describe('disposable storage lifecycle', () => { + it('retries a failed cold reload even when the reset intent is already persisted', async () => { + const f = fixture(); + const lifecycle = f.lifecycle(); + const ready = vi.fn(async () => undefined); + const reload = vi.fn().mockImplementationOnce(() => { + throw new Error('reload failed'); + }); + await expect(lifecycle.requestRebuildAfterBootstrap(ready, { reload })).rejects.toThrow('reload failed'); + expect(f.markers.get('reset')).toBe('requested'); + expect(await lifecycle.requestRebuildAfterBootstrap(ready, { reload })).toBe(true); + expect(ready).toHaveBeenCalledTimes(2); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it('cleans orphaned product files without a database and retries failure before completing adoption', async () => { + const f = fixture(); + const cleanup = vi.fn(async () => undefined).mockRejectedValueOnce(new Error('files locked')); + const ready = vi.fn(async () => undefined); + const lifecycle = new OfflineStorageLifecycle({ + ...f.options, + rebuild: 'after-reload', + beforeCleanup: ready, + target: { + kind: 'sqlite', + databaseNames: ['replica'], + databaseEncryption: false, + additionalCleanup: cleanup, + sqliteConnection: { + checkConnectionsConsistency: vi.fn(async () => ({})), + isDatabase: vi.fn(async () => ({ result: false })), + createConnection: vi.fn(), + closeConnection: vi.fn(), + }, + }, + }); + await expect(lifecycle.prepare()).rejects.toThrow('files locked'); + expect(f.markers.get('reset')).toBe('requested'); + expect(f.markers.has('generation')).toBe(false); + expect(ready.mock.invocationCallOrder[0]).toBeLessThan(cleanup.mock.invocationCallOrder[0]!); + expect(await lifecycle.prepare()).toBe('rebuilt'); + expect(cleanup).toHaveBeenCalledTimes(2); + expect(f.markers.get('generation')).toBe('current'); + expect(f.markers.has('reset')).toBe(false); + }); + it('adopts an existing matching marker without discarding pending commands or authentication', async () => { const f = fixture(); f.markers.set('generation', 'current'); diff --git a/projects/kit/offline/src/lib/offline-storage-lifecycle.ts b/projects/kit/offline/src/lib/offline-storage-lifecycle.ts index 22c24de8..55b6d13c 100644 --- a/projects/kit/offline/src/lib/offline-storage-lifecycle.ts +++ b/projects/kit/offline/src/lib/offline-storage-lifecycle.ts @@ -88,12 +88,12 @@ export class OfflineStorageLifecycle { reloadTarget: OfflineLocalResetReloadTarget, ): Promise { const state = await inspectOfflineStorageGeneration(this.options); - if (state.matches || state.requested) return false; + if (state.matches && !state.requested) return false; await waitUntilReady(); const requested = await this.#lock('exclusive', async () => { const current = await inspectOfflineStorageGeneration(this.options); - if (current.matches || current.requested) return false; - await this.options.markerStore.set({ key: this.options.markerKey, value: 'requested' }); + if (current.matches && !current.requested) return false; + if (!current.requested) await this.options.markerStore.set({ key: this.options.markerKey, value: 'requested' }); return true; }); if (requested) reloadTarget.reload(); @@ -114,7 +114,15 @@ export class OfflineStorageLifecycle { } if (!state.requested && options.rebuild === 'after-reload') { if (await this.#hasStorage()) return 'reload-required'; + // Database absence does not imply product-owned media is absent as well. + const cleanup = options.target.kind === 'sqlite' ? options.target.additionalCleanup : undefined; + if (cleanup) { + await options.beforeCleanup?.(); + await options.markerStore.set({ key: options.markerKey, value: 'requested' }); + await cleanup(); + } await options.markerStore.set({ key: options.generationKey, value: state.generation }); + if (cleanup) await options.markerStore.remove({ key: options.markerKey }); this.#revision = (await options.markerStore.get({ key: this.#revisionKey })).value; return 'ready'; } @@ -143,7 +151,7 @@ export class OfflineStorageLifecycle { return databases.some(({ result }) => result === true); } - #lock(mode: 'shared' | 'exclusive', operation: () => Promise): Promise { + async #lock(mode: 'shared' | 'exclusive', operation: () => Promise): Promise { if (this.options.target.kind === 'sqlite') { let operations = nativeOperations.get(this.options.markerStore); if (!operations) { @@ -166,6 +174,6 @@ export class OfflineStorageLifecycle { const locks = this.options.target.locks ?? globalThis.navigator?.locks; if (!locks) return Promise.reject(new Error('Web Locks are required to safely rebuild shared offline storage.')); // One origin-wide lock matches the existing shared `offline:` namespace, even across products. - return locks.request('kit:offline:storage', { mode }, operation); + return await locks.request('kit:offline:storage', { mode }, operation); } }