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
65 changes: 65 additions & 0 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,43 @@ provideOffline({
});
```

Products may make durable mutation saving device-configurable without disabling replica reads. Supply only the
durable preference adapter; Kit owns the concurrency-sensitive transition. It starts admission closed while loading,
gates every `enqueue` / `enqueuePrepared` / `enqueuePreparedBatch`, and routes matched HTTP writes to normal transport
while disabled. Disabling closes admission synchronously, waits for already accepted commits, flushes pending
commands, verifies an empty Outbox, and then persists `false`. A failed transition restores the last successfully
persisted state. Replacement and discard APIs remain available because they resolve existing commands rather than
create additional pending work.

```ts
@Injectable({ providedIn: 'root' })
class ProductMutationPersistenceAdapter implements OfflineMutationPersistenceAdapter {
readonly #settings = inject(ProductSettingsService);

loadEnabled(): Promise<boolean | null> {
return this.#settings.get('offlineMutationPersistence');
}

saveEnabled(enabled: boolean): Promise<void> {
return this.#settings.set('offlineMutationPersistence', enabled);
}
}

provideOffline({
// repository/schema/executor/puller options omitted
mutationPersistence: {
adapter: ProductMutationPersistenceAdapter,
defaultEnabled: true,
},
});

const offline = inject(OfflineCoordinatorService);
await offline.mutationPersistence.setEnabled(false);
```

The product owns settings UI, labels, confirmation copy, and the storage key. Omitting `mutationPersistence` keeps
the historical always-enabled behavior. `readCacheOnly` applications do not expose this setting.

The native offline runtime uses `@capacitor-community/sqlite` on iOS and Android. Install the plugin in the app and
sync native projects:

Expand Down Expand Up @@ -551,6 +588,34 @@ provideOffline({
});
```

An explicit user-requested local reset must run before Angular and Kit initialize SQLite. Use the cold-start helpers
instead of reproducing the community plugin's encrypted connection lifecycle in every application. Kit never invokes
reset automatically after a storage error. The product must show destructive confirmation first and owns the marker key.
Only databases that use Kit's same encrypted secret-mode, connection-version-1, read/write lifecycle belong in
`kitCompatibleDatabaseNames`; delete differently configured databases and files in `additionalCleanup`. The marker is
removed only after every database and product cleanup succeeds, so a partial failure is retried on the next cold launch.

```ts
// Settings action
await requestOfflineLocalReset({
markerStore: Preferences,
markerKey: 'product:offline:reset',
});

// main.ts, before bootstrapApplication(...). Report reset failure but always continue startup.
await recoverOfflineLocalReset({
markerStore: Preferences,
markerKey: 'product:offline:reset',
sqliteConnection,
kitCompatibleDatabaseNames: ['product-offline', 'product-offline-media'],
additionalCleanup: removeOfflineMediaFiles,
}).catch((error: unknown) => {
console.error('Offline local reset failed; it will retry on the next launch.', error);
});

await bootstrapApplication(AppComponent, appConfig);
```

Replica identity follows the product database:

| Identity declaration | SQLite primary key | Row / executor identity |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const COMMUNITY_SQLITE_ENCRYPTED = true;
export const COMMUNITY_SQLITE_MODE = 'secret';
export const COMMUNITY_SQLITE_VERSION = 1;
export const COMMUNITY_SQLITE_READONLY = false;
32 changes: 31 additions & 1 deletion projects/kit/offline/src/lib/offline-coordinator.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { signal } from '@angular/core';
import { ErrorHandler, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OfflineCoordinatorService } from './offline-coordinator.service';
import { OFFLINE_KIT_OPTIONS } from './offline-kit-options';
import { OfflineNetworkService } from './offline-network.service';
import { OFFLINE_MUTATION_PERSISTENCE_ADAPTER } from './offline-mutation-persistence.service';
import { OFFLINE_REPOSITORY, OFFLINE_SCHEMA_VERSION, type OfflineScope } from './offline-repository';
import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service';
import { OfflineStorageUnavailableError } from './offline-storage';
Expand All @@ -20,9 +21,11 @@ describe('OfflineCoordinatorService', () => {
options: {
repositoryInitialize?: () => Promise<void>;
onStorageUnavailable?: (error: OfflineStorageUnavailableError) => void | Promise<void>;
preferenceLoad?: () => Promise<boolean | null | undefined>;
} = {},
) {
const order: string[] = [];
const handleError = vi.fn();
const sessionState: { userId: number | null } = { userId: null };
const repository = {
initialize: vi.fn(options.repositoryInitialize ?? (async () => undefined)),
Expand Down Expand Up @@ -64,19 +67,29 @@ describe('OfflineCoordinatorService', () => {
discardAllPending: vi.fn(async () => undefined),
flush: vi.fn(async () => undefined),
};
class TestMutationPersistenceAdapter {
loadEnabled = options.preferenceLoad ?? (async () => true);
saveEnabled = vi.fn(async () => undefined);
}
const mutationPersistence = options.preferenceLoad ? { adapter: TestMutationPersistenceAdapter } : undefined;
TestBed.configureTestingModule({
providers: [
OfflineCoordinatorService,
{ provide: OFFLINE_REPOSITORY, useValue: repository },
{ provide: OfflineNetworkService, useValue: network },
{ provide: OfflineSessionService, useValue: session },
{ provide: OfflineSyncService, useValue: sync },
{ provide: ErrorHandler, useValue: { handleError } },
...(mutationPersistence
? [TestMutationPersistenceAdapter, { provide: OFFLINE_MUTATION_PERSISTENCE_ADAPTER, useExisting: TestMutationPersistenceAdapter }]
: []),
{
provide: OFFLINE_KIT_OPTIONS,
useValue: {
databaseName: 'test-offline',
replicaSchema: emptyReplicaSchema,
onStorageUnavailable: options.onStorageUnavailable,
mutationPersistence,
},
},
],
Expand All @@ -89,9 +102,26 @@ describe('OfflineCoordinatorService', () => {
sync,
network,
repository,
handleError,
};
}

it('continues repository, network, session, and sync startup with mutation admission fail-closed after preference read failure', async () => {
const failure = new Error('preference unavailable');
const { coordinator, repository, network, session, sync, handleError } = setup(null, {
preferenceLoad: async () => Promise.reject(failure),
});

await expect(coordinator.initialize()).resolves.toBeUndefined();

expect(coordinator.mutationPersistence.enabled()).toBe(false);
expect(handleError).toHaveBeenCalledExactlyOnceWith(failure);
expect(repository.initialize).toHaveBeenCalledOnce();
expect(network.initialize).toHaveBeenCalledOnce();
expect(session.initialize).toHaveBeenCalledOnce();
expect(sync.initialize).toHaveBeenCalledOnce();
});

it('restores local visibility without starting remote synchronization', async () => {
const manifest = { userId: 1, scopeIds: ['2'], authSubject: 'subject', updatedAt: 1 };
const { coordinator, order, sync } = setup(manifest);
Expand Down
10 changes: 9 additions & 1 deletion projects/kit/offline/src/lib/offline-coordinator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { computed, inject, Injectable, signal } from '@angular/core';
import type { OfflinePrincipalId } from './offline-identity';
import { OFFLINE_KIT_OPTIONS } from './offline-kit-options';
import { OfflineNetworkService } from './offline-network.service';
import { OfflineMutationPersistenceService } from './offline-mutation-persistence.service';
import { OFFLINE_REPOSITORY } from './offline-repository';
import { OfflineSessionService } from './offline-session.service';
import type { OfflineSessionManifest, OfflineSessionTransitionLease } from './offline-session.service';
Expand All @@ -23,6 +24,7 @@ export interface OfflineResumeRemoteSessionOptions {
export class OfflineCoordinatorService {
readonly #repository = inject(OFFLINE_REPOSITORY);
readonly #network = inject(OfflineNetworkService);
readonly #mutationPersistence = inject(OfflineMutationPersistenceService);
readonly #sync = inject(OfflineSyncService);
readonly #session = inject(OfflineSessionService);
readonly #options = inject(OFFLINE_KIT_OPTIONS);
Expand All @@ -47,6 +49,8 @@ export class OfflineCoordinatorService {
readonly syncState = this.#sync.syncState;
readonly pendingCount = this.#sync.pendingCount;
readonly conflicts = this.#sync.conflicts;
/** Device-local control for accepting new durable Outbox mutations. */
readonly mutationPersistence = this.#mutationPersistence;

/**
* Opens local storage, then session and sync.
Expand All @@ -56,6 +60,7 @@ export class OfflineCoordinatorService {
* resolves with {@link storageState} `unavailable` and skips session/sync initialization.
*/
async initialize(): Promise<void> {
await this.#mutationPersistence.initialize();
const networkReady = this.#network.initialize();
const initializeRepository = async (): Promise<void> => this.#repository.initialize();
const repositoryReady = await initializeRepository().then(
Expand Down Expand Up @@ -109,7 +114,10 @@ export class OfflineCoordinatorService {
/**
* Activates a restored identity for local replica/outbox use without enabling transport sync.
*/
async activateOfflineSession(authSubject?: string | null, authLease?: OfflineSessionTransitionLease): Promise<OfflineSessionManifest | null> {
async activateOfflineSession(
authSubject?: string | null,
authLease?: OfflineSessionTransitionLease,
): Promise<OfflineSessionManifest | null> {
if (this.#storageUnavailable()) return null;
const revision = ++this.#transitionRevision;
const lease = this.#lease(revision, authLease);
Expand Down
28 changes: 26 additions & 2 deletions projects/kit/offline/src/lib/offline-kit-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InjectionToken } from '@angular/core';
import { InjectionToken, type Type } from '@angular/core';
import type { OfflineReplicaSchemaBundle } from './offline-replica-schema';
import type { OfflineStorageUnavailableError } from './offline-storage';

Expand All @@ -10,6 +10,22 @@ export interface OfflineOutboxLimits {
maxBytesPerUser?: number;
}

/** Product persistence adapter for the device-local offline mutation preference. */
export interface OfflineMutationPersistenceAdapter {
/** Loads the last durable preference. `null` or `undefined` uses the configured default. */
loadEnabled(): Promise<boolean | null | undefined>;
/** Persists a completed enable or disable transition. */
saveEnabled(enabled: boolean): Promise<void>;
}

/** Configuration for device-local mutation persistence control. */
export interface OfflineMutationPersistenceOptions {
/** Injectable product adapter for the durable preference store. */
adapter: Type<OfflineMutationPersistenceAdapter>;
/** Initial value when no durable preference exists. Defaults to `true`. */
defaultEnabled?: boolean;
}

/** Product-independent native offline persistence settings. */
export interface OfflineKitOptions {
/** Runtime transport mode. Defaults to full synchronized replica/outbox behavior. */
Expand All @@ -30,6 +46,13 @@ export interface OfflineKitOptions {
wireProtocol?: OfflineWireProtocolFingerprint;
/** Optional durable Outbox backpressure policy. */
outboxLimits?: OfflineOutboxLimits;
/**
* Optional device-local control for accepting new durable Outbox mutations.
*
* Replica reads remain enabled while mutation persistence is disabled. When omitted,
* Kit preserves the historical always-enabled mutation behavior.
*/
mutationPersistence?: OfflineMutationPersistenceOptions;
/**
* Optional product callback invoked when local storage initialization fails.
*
Expand All @@ -39,7 +62,8 @@ export interface OfflineKitOptions {
* When omitted, initialization throws {@link OfflineStorageUnavailableError} as before.
* If the callback throws or rejects, initialization still fails.
*
* Kit never deletes Outbox or replica data in response to storage failure; any reset is product-owned.
* Kit never deletes Outbox or replica data in response to storage failure. Products may invoke
* `recoverOfflineLocalReset` only after an explicit destructive reset request.
*/
onStorageUnavailable?: (error: OfflineStorageUnavailableError) => void | Promise<void>;
}
Expand Down
Loading