Skip to content

Commit 60bb699

Browse files
authored
feat(offline): 共通のストレージ世代判定・破棄ライフサイクルを追加 (#108)
* feat(offline): share disposable storage lifecycle and generation guards * fix(offline): preserve recovery retries and Angular 21 compatibility
1 parent 5351400 commit 60bb699

14 files changed

Lines changed: 668 additions & 10 deletions

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"test:watch": "ng test",
3636
"test:actions": "node --test .github/actions/classify-mobile-release/classify-mobile-release.spec.mjs && vitest run --config .github/actions/vitest.config.mjs",
3737
"test:offline-types:nonstrict": "npm run prebuild:kit && tsc -p projects/kit/offline/tsconfig.nonstrict-null.json",
38+
"test:storage-lifecycle:browser": "node --test tests/offline-storage-lifecycle.browser.test.mjs",
3839
"test:sqlite": "npm run test:sqlite-concurrency && vitest run --config vitest.sqlite.config.ts",
3940
"test:sqlite-concurrency": "node --test projects/kit/offline/src/lib/sqlite-concurrency.node.test.mjs",
4041
"test:package-consumer": "node scripts/test-package-consumer.mjs",

‎projects/kit/docs/offline-realtime.md‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,50 @@ credential exchange is still pending.
221221
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.
222222

223223
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.
224+
225+
## Disposable storage generations
226+
227+
`OfflineStorageLifecycle` is an explicit alternative to retaining incremental local data migrations.
228+
It does not change the wire protocol and never resets storage in response to a generic error.
229+
Products must opt in with `discardPendingCommands: true`: the entire configured replica, cursors,
230+
local snapshots and unsent Outbox are disposable. Authentication and unrelated preferences remain
231+
outside the cleanup targets. Repopulation requires connectivity.
232+
233+
Create one lifecycle instance for bootstrap and pass that same instance as `storageLifecycle` to
234+
`provideOffline` / `provideRouteScopedOffline`. Await `prepare()` before constructing product
235+
services that open the database. Kit also gates repository initialization through this instance.
236+
`reload-required` is not permission to open the repository: render the product's reset/update UI,
237+
call `requestRebuildAfterBootstrap(waitUntilReady, location)`, and resume only after a cold restart.
238+
The `beforeCleanup` hook can acknowledge Live Update readiness before slow native file deletion.
239+
Kit does not depend on the Live Update plugin or supply product messages/navigation.
240+
241+
The lightweight `@rdlabo/ionic-angular-kit/offline/storage-generation` entry point exports
242+
`inspectOfflineStorageGeneration` for startup guards without eagerly loading the offline runtime.
243+
Generation values and marker keys are product-owned. SeatKeep keeps its
244+
`OFFLINE_SCHEMA_VERSION:schemaHash` identity; Winecode keeps generation `4` and its existing keys.
245+
Matching markers are accepted without deleting data or forcing a migration-only reset. Local
246+
schema changes must change the configured generation; they must not change `wireProtocol` unless
247+
the server contract itself is incompatible. Retaining an explicit numeric generation requires the
248+
product to bump it when its local storage shape changes.
249+
250+
Native targets list only databases compatible with the existing reset connection lifecycle;
251+
`additionalCleanup` owns other databases/media files. A pending reset survives deletion failure,
252+
completion-marker failure, and interrupted cleanup. Completion is recorded only after all cleanup
253+
succeeds. Deferred mode can adopt a fresh installation without reloading when no target DB exists.
254+
Existing manual reset and encryption-migration APIs remain available and retain their behavior.
255+
256+
Web targets require Web Locks (secure context). All cooperating runtimes use one origin-wide lock
257+
for the existing `offline:` namespace: preparation and writes are exclusive, reads are shared.
258+
The repository validates the generation and a reset revision within the lock. A stale runtime throws
259+
`OfflineStorageGenerationChangedError` before touching rebuilt data, including after a manual reset
260+
of the same generation. The product should reload that runtime. Absence of Web Locks fails before
261+
any deletion; there is no unsafe per-tab fallback. Already shipped clients without this protocol
262+
cannot be fenced by the new library; replacing/reloading old tabs is necessary during rollout.
263+
264+
Validation commands:
265+
266+
- `npm test -- --project kit --include='../offline/src/lib/offline-storage-lifecycle.spec.ts'`
267+
- `npm run test:storage-lifecycle:browser` (headless Chromium, two pages of the same origin)
268+
269+
The browser test runs the actual lifecycle with Web Locks and a small localStorage adapter. Repository
270+
integration tests separately cover the KitStorageService boundary and stale repository reads/writes.

‎projects/kit/offline/src/lib/offline-kit-options.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { OfflineStorageLifecycle } from './offline-storage-lifecycle';
12
import { InjectionToken, type Type } from '@angular/core';
23
import type { OfflineReplicaSchemaBundle } from './offline-replica-schema';
34
import type { OfflineStorageUnavailableError } from './offline-storage';
@@ -63,6 +64,8 @@ interface OfflineKitOptionsBase {
6364
* the replica schema fingerprint for backward compatibility.
6465
*/
6566
wireProtocol?: OfflineWireProtocolFingerprint;
67+
/** Opt-in disposable storage lifecycle. Shared with the product bootstrap before opening a repository. */
68+
storageLifecycle?: OfflineStorageLifecycle;
6669
/** Optional durable Outbox backpressure policy. */
6770
outboxLimits?: OfflineOutboxLimits;
6871
/**

‎projects/kit/offline/src/lib/offline-local-reset.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export async function recoverOfflineLocalReset(options: RecoverOfflineLocalReset
9696
const marker = await options.markerStore.get({ key: options.markerKey });
9797
if (marker.value !== OFFLINE_LOCAL_RESET_REQUESTED) return false;
9898

99-
await resetOfflineDatabases({
99+
await clearOfflineLocalDatabases({
100100
sqliteConnection: options.sqliteConnection,
101101
databaseNames: options.kitCompatibleDatabaseNames,
102102
databaseEncryption: options.databaseEncryption ?? COMMUNITY_SQLITE_ENCRYPTED,
@@ -118,7 +118,7 @@ export async function migrateOfflineDatabaseEncryption(options: MigrateOfflineDa
118118
const marker = await options.markerStore.get({ key: options.markerKey });
119119
if (marker.value === options.migrationVersion) return false;
120120

121-
await resetOfflineDatabases({
121+
await clearOfflineLocalDatabases({
122122
sqliteConnection: options.sqliteConnection,
123123
databaseNames: options.kitCompatibleDatabaseNames,
124124
databaseEncryption: options.sourceDatabaseEncryption,
@@ -134,12 +134,16 @@ export async function migrateOfflineDatabaseEncryption(options: MigrateOfflineDa
134134
return true;
135135
}
136136

137-
async function resetOfflineDatabases(options: {
137+
/** Native databases and product files to remove before recording a completed reset. */
138+
export interface OfflineLocalDatabaseResetOptions {
138139
sqliteConnection: OfflineLocalResetSqliteConnection;
139140
databaseNames: readonly [string, ...string[]];
140141
databaseEncryption: boolean | 'detect';
141142
additionalCleanup?: () => Promise<void>;
142-
}): Promise<void> {
143+
}
144+
145+
/** Deletes only explicitly configured databases and files; leaves markers and authentication untouched. */
146+
export async function clearOfflineLocalDatabases(options: OfflineLocalDatabaseResetOptions): Promise<void> {
143147
await options.sqliteConnection.checkConnectionsConsistency();
144148
for (const databaseName of new Set(options.databaseNames)) {
145149
const exists = await options.sqliteConnection.isDatabase(databaseName);

‎projects/kit/offline/src/lib/offline-provider.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ function offlineConfigurationProviders(options: ProvideOfflineOptions): Provider
240240
createEncryptionKey: options.createEncryptionKey,
241241
replicaSchema: options.replicaSchema,
242242
wireProtocol: options.wireProtocol,
243+
storageLifecycle: options.storageLifecycle,
243244
outboxLimits: options.outboxLimits,
244245
mutationPersistence: options.mutationPersistence,
245246
onStorageUnavailable: options.onStorageUnavailable,
@@ -251,6 +252,7 @@ function offlineConfigurationProviders(options: ProvideOfflineOptions): Provider
251252
createEncryptionKey: options.createEncryptionKey,
252253
replicaSchema: options.replicaSchema,
253254
wireProtocol: options.wireProtocol,
255+
storageLifecycle: options.storageLifecycle,
254256
outboxLimits: options.outboxLimits,
255257
mutationPersistence: options.mutationPersistence,
256258
onStorageUnavailable: options.onStorageUnavailable,

‎projects/kit/offline/src/lib/offline-repository.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export class IonicOfflineRepository implements OfflineRepository {
348348

349349
initialize(): Promise<void> {
350350
if (!this.#initialization) {
351-
this.#initialization = this.#migrate().catch((error: unknown) => {
351+
this.#initialization = this.#withStorageLifecycle('exclusive', () => this.#migrate()).catch((error: unknown) => {
352352
this.#initialization = null;
353353
throw error;
354354
});
@@ -564,7 +564,7 @@ export class IonicOfflineRepository implements OfflineRepository {
564564
await this.#writes;
565565
this.#beginReaders();
566566
const read = async (): Promise<T> => operation();
567-
return read().finally(() => this.#endReaders());
567+
return this.#withStorageLifecycle('shared', read).finally(() => this.#endReaders());
568568
}
569569

570570
#beginReaders(): void {
@@ -836,7 +836,8 @@ export class IonicOfflineRepository implements OfflineRepository {
836836
});
837837
throw error;
838838
});
839-
});
839+
// Initialization already holds the lifecycle lock; Web Locks are not reentrant.
840+
}, false);
840841
}
841842

842843
#toWebMigrationRow(row: OfflineReplicaRow): OfflineReplicaWebMigrationRow {
@@ -1059,10 +1060,14 @@ export class IonicOfflineRepository implements OfflineRepository {
10591060
await this.#storage.set(key, mutate(record));
10601061
}
10611062

1062-
#enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
1063+
#withStorageLifecycle<T>(mode: 'shared' | 'exclusive', operation: () => Promise<T>): Promise<T> {
1064+
return this.#options.storageLifecycle ? this.#options.storageLifecycle.run(mode, operation) : operation();
1065+
}
1066+
1067+
#enqueueWrite<T>(operation: () => Promise<T>, guard = true): Promise<T> {
10631068
const write = this.#writes.then(async () => {
10641069
if (this.#activeReaders > 0) await this.#readersIdle;
1065-
return operation();
1070+
return guard ? this.#withStorageLifecycle('exclusive', operation) : operation();
10661071
});
10671072
this.#writes = write.then(
10681073
() => undefined,

0 commit comments

Comments
 (0)