Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions projects/kit/docs/offline-realtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions projects/kit/offline/src/lib/offline-kit-options.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
/**
Expand Down
12 changes: 8 additions & 4 deletions projects/kit/offline/src/lib/offline-local-reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<void>;
}): Promise<void> {
}

/** Deletes only explicitly configured databases and files; leaves markers and authentication untouched. */
export async function clearOfflineLocalDatabases(options: OfflineLocalDatabaseResetOptions): Promise<void> {
await options.sqliteConnection.checkConnectionsConsistency();
for (const databaseName of new Set(options.databaseNames)) {
const exists = await options.sqliteConnection.isDatabase(databaseName);
Expand Down
2 changes: 2 additions & 0 deletions projects/kit/offline/src/lib/offline-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions projects/kit/offline/src/lib/offline-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export class IonicOfflineRepository implements OfflineRepository {

initialize(): Promise<void> {
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;
});
Expand Down Expand Up @@ -564,7 +564,7 @@ export class IonicOfflineRepository implements OfflineRepository {
await this.#writes;
this.#beginReaders();
const read = async (): Promise<T> => operation();
return read().finally(() => this.#endReaders());
return this.#withStorageLifecycle('shared', read).finally(() => this.#endReaders());
}

#beginReaders(): void {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1059,10 +1060,14 @@ export class IonicOfflineRepository implements OfflineRepository {
await this.#storage.set(key, mutate(record));
}

#enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
#withStorageLifecycle<T>(mode: 'shared' | 'exclusive', operation: () => Promise<T>): Promise<T> {
return this.#options.storageLifecycle ? this.#options.storageLifecycle.run(mode, operation) : operation();
}

#enqueueWrite<T>(operation: () => Promise<T>, guard = true): Promise<T> {
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,
Expand Down
Loading
Loading