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
27 changes: 26 additions & 1 deletion projects/kit/offline/src/lib/offline-command-executor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { InjectionToken } from '@angular/core';
import type { OfflineCommand, OfflineOptimisticReplicaCompanion, OfflineReplicaRow, OfflineScope } from './offline-repository';
import type {
OfflineCommand,
OfflineOptimisticReplicaCompanion,
OfflineReplicaRow,
OfflineReplicaRowKey,
OfflineScope,
} from './offline-repository';
import type { OfflineCommandIdentity, OfflinePrincipalId, OfflineReplicaIdentity } from './offline-identity';
import type { OfflineGeneratedRemoteId, OfflineNaturalKey } from './offline-replica-schema';

Expand All @@ -10,6 +16,14 @@ export interface OfflineCommandResult {
serverRevision?: string | number;
/** Full server-confirmed domain values after applying the mutation. */
confirmedValues?: unknown;
/**
* Server-confirmed local-only projection changes owned by this command.
*
* Kit validates that every row belongs to the command's declared optimistic
* companion footprint, then commits these changes atomically with the base
* row acknowledgement, command removal, and reconciliation marker.
*/
confirmedCompanions?: readonly OfflineConfirmedReplicaCompanion[];
/** Removes the local replica row after a confirmed server delete. */
removeReplica?: boolean;
/**
Expand All @@ -20,6 +34,17 @@ export interface OfflineCommandResult {
response?: unknown;
}

/** Local-only projection changes confirmed by one server acknowledgement. */
export interface OfflineConfirmedReplicaCompanion {
/** Exact optimistic companion footprint entry owned by this command. */
readonly key: OfflineReplicaRowKey;
/**
* Applies the acknowledged domain effect to the latest confirmed projection read inside Kit's
* ACK lane. Return `null` to remove the confirmed projection.
*/
readonly reduce: (latestConfirmedValues: unknown) => unknown | null;
}

/** Target identity resolved from the local replica immediately before transport. */
export type OfflineCommandTarget =
| { readonly kind: 'generated'; readonly localId: string; readonly remoteId: OfflineGeneratedRemoteId | null }
Expand Down
149 changes: 145 additions & 4 deletions projects/kit/offline/src/lib/offline-coordinator.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
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_REPOSITORY, type OfflineScope } from './offline-repository';
import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service';
import { OfflineStorageUnavailableError } from './offline-storage';
import { OfflineSyncService } from './offline-sync.service';
import { defineOfflineReplicaSchema } from './offline-replica-schema';

describe('OfflineCoordinatorService', () => {
afterEach(() => TestBed.resetTestingModule());

function setup(manifest: OfflineSessionManifest | null = null) {
const emptyReplicaSchema = defineOfflineReplicaSchema({ version: 1, entities: [], migrations: [] });

function setup(
manifest: OfflineSessionManifest | null = null,
options: {
repositoryInitialize?: () => Promise<void>;
onStorageUnavailable?: (error: OfflineStorageUnavailableError) => void | Promise<void>;
} = {},
) {
const order: string[] = [];
const sessionState: { userId: number | null } = { userId: null };
const repository = {
initialize: vi.fn(async () => undefined),
initialize: vi.fn(options.repositoryInitialize ?? (async () => undefined)),
};
const network = {
state: signal('connected'),
Expand Down Expand Up @@ -60,9 +71,25 @@ describe('OfflineCoordinatorService', () => {
{ provide: OfflineNetworkService, useValue: network },
{ provide: OfflineSessionService, useValue: session },
{ provide: OfflineSyncService, useValue: sync },
{
provide: OFFLINE_KIT_OPTIONS,
useValue: {
databaseName: 'test-offline',
replicaSchema: emptyReplicaSchema,
onStorageUnavailable: options.onStorageUnavailable,
},
},
],
});
return { coordinator: TestBed.inject(OfflineCoordinatorService), order, session, sessionState, sync };
return {
coordinator: TestBed.inject(OfflineCoordinatorService),
order,
session,
sessionState,
sync,
network,
repository,
};
}

it('restores local visibility without starting remote synchronization', async () => {
Expand Down Expand Up @@ -207,6 +234,13 @@ describe('OfflineCoordinatorService', () => {
{ provide: OFFLINE_REPOSITORY, useValue: repository },
{ provide: OfflineNetworkService, useValue: network },
{ provide: OfflineSyncService, useValue: sync },
{
provide: OFFLINE_KIT_OPTIONS,
useValue: {
databaseName: 'test-offline',
replicaSchema: emptyReplicaSchema,
},
},
],
});
const coordinator = TestBed.inject(OfflineCoordinatorService);
Expand All @@ -225,4 +259,111 @@ describe('OfflineCoordinatorService', () => {

expect(sync.refreshSession).toHaveBeenCalledWith(['2', '9']);
});

describe('storage initialization failure', () => {
const storageError = new OfflineStorageUnavailableError(
'core_schema_incompatible',
'Unsupported offline storage schema version 999; expected 1.',
);

it('default fails closed and does not start session or sync', async () => {
const { coordinator, session, sync, network } = setup(null, {
repositoryInitialize: async () => {
throw storageError;
},
});

await expect(coordinator.initialize()).rejects.toBe(storageError);

expect(network.initialize).toHaveBeenCalledOnce();
expect(session.initialize).not.toHaveBeenCalled();
expect(sync.initialize).not.toHaveBeenCalled();
expect(coordinator.storageState()).toEqual({ status: 'unavailable', error: storageError });
expect(coordinator.isStorageReady()).toBe(false);
});

it('opt-in onStorageUnavailable completes initializer in unavailable state without session/sync', async () => {
const onStorageUnavailable = vi.fn(async () => undefined);
const { coordinator, session, sync, network } = setup(null, {
repositoryInitialize: async () => {
throw storageError;
},
onStorageUnavailable,
});

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

expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(storageError);
expect(network.initialize).toHaveBeenCalledOnce();
expect(session.initialize).not.toHaveBeenCalled();
expect(sync.initialize).not.toHaveBeenCalled();
expect(coordinator.storageState()).toEqual({ status: 'unavailable', error: storageError });
expect(coordinator.isStorageReady()).toBe(false);
});

it('short-circuits repository-backed public APIs after online-only degradation', async () => {
const { coordinator, session, sync } = setup(null, {
repositoryInitialize: async () => {
throw storageError;
},
onStorageUnavailable: async () => undefined,
});
await coordinator.initialize();

await expect(coordinator.prepareRemoteSession(7, ['10'], 'subject')).resolves.toBe(true);
await expect(coordinator.resumeRemoteSession({ foregroundScopeIds: ['10'] })).resolves.toBeUndefined();
await expect(coordinator.activateOfflineSession('subject')).resolves.toBeNull();
await expect(coordinator.flush()).resolves.toBeUndefined();
await expect(coordinator.prepareLogout('sync')).resolves.toBe(true);
await expect(coordinator.clearActiveSession()).resolves.toBeUndefined();

expect(session.activateSession).not.toHaveBeenCalled();
expect(session.activateOfflineSession).not.toHaveBeenCalled();
expect(session.clearActiveSession).not.toHaveBeenCalled();
expect(sync.resetSession).not.toHaveBeenCalled();
expect(sync.refreshSession).not.toHaveBeenCalled();
expect(sync.flush).not.toHaveBeenCalled();
});

it('callback failure still fails initialization', async () => {
const callbackError = new Error('telemetry rejected');
const onStorageUnavailable = vi.fn(async () => {
throw callbackError;
});
const { coordinator, session, sync } = setup(null, {
repositoryInitialize: async () => {
throw storageError;
},
onStorageUnavailable,
});

await expect(coordinator.initialize()).rejects.toBe(callbackError);

expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(storageError);
expect(session.initialize).not.toHaveBeenCalled();
expect(sync.initialize).not.toHaveBeenCalled();
expect(coordinator.isStorageReady()).toBe(false);
});

it('wraps non-typed repository failures as storage_unavailable preserving cause', async () => {
const cause = new Error('disk full');
const onStorageUnavailable = vi.fn(async () => undefined);
const { coordinator } = setup(null, {
repositoryInitialize: async () => {
throw cause;
},
onStorageUnavailable,
});

await coordinator.initialize();

const state = coordinator.storageState();
expect(state.status).toBe('unavailable');
if (state.status !== 'unavailable') return;
expect(state.error).toBeInstanceOf(OfflineStorageUnavailableError);
expect(state.error.reason).toBe('storage_unavailable');
expect(state.error.cause).toBe(cause);
expect(onStorageUnavailable).toHaveBeenCalledExactlyOnceWith(state.error);
});
});
});
62 changes: 60 additions & 2 deletions projects/kit/offline/src/lib/offline-coordinator.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { inject, Injectable } from '@angular/core';
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 { OFFLINE_REPOSITORY } from './offline-repository';
import { OfflineSessionService } from './offline-session.service';
import type { OfflineSessionManifest, OfflineSessionTransitionLease } from './offline-session.service';
import {
OfflineStorageUnavailableError,
type OfflineStorageState,
} from './offline-storage';
import { OfflineSyncService } from './offline-sync.service';

/** User choice when logout encounters unconfirmed local mutations. */
Expand All @@ -21,16 +26,51 @@ export class OfflineCoordinatorService {
readonly #network = inject(OfflineNetworkService);
readonly #sync = inject(OfflineSyncService);
readonly #session = inject(OfflineSessionService);
readonly #options = inject(OFFLINE_KIT_OPTIONS);
readonly #storageState = signal<OfflineStorageState>({ status: 'initializing' });
#transitionRevision = 0;
#transitionTail: Promise<void> = Promise.resolve();

/**
* Local storage readiness after {@link initialize}.
*
* When `unavailable`, the product opted into online-only startup; replica/outbox APIs must not be used.
*/
readonly storageState = this.#storageState.asReadonly();
/**
* Product-policy guard: `true` only when encrypted local storage finished initialization successfully.
*
* Use this (or {@link storageState}) to disable offline mutations and replica reads when storage is unavailable.
*/
readonly isStorageReady = computed(() => this.#storageState().status === 'ready');

readonly networkState = this.#network.state;
readonly syncState = this.#sync.syncState;
readonly pendingCount = this.#sync.pendingCount;
readonly conflicts = this.#sync.conflicts;

/**
* Opens local storage, then session and sync.
*
* Repository failure without {@link OfflineKitOptions.onStorageUnavailable} throws
* {@link OfflineStorageUnavailableError}. When the callback is present and settles, this method
* resolves with {@link storageState} `unavailable` and skips session/sync initialization.
*/
async initialize(): Promise<void> {
await Promise.all([this.#repository.initialize(), this.#network.initialize()]);
const networkReady = this.#network.initialize();
try {
await this.#repository.initialize();
} catch (error) {
await networkReady;
const typed = this.#asStorageUnavailable(error);
this.#storageState.set({ status: 'unavailable', error: typed });
const onUnavailable = this.#options.onStorageUnavailable;
if (!onUnavailable) throw typed;
await onUnavailable(typed);
return;
}
await networkReady;
this.#storageState.set({ status: 'ready' });
await this.#session.initialize();
await this.#sync.initialize();
}
Expand All @@ -47,6 +87,7 @@ export class OfflineCoordinatorService {
authSubject: string | null,
authLease?: OfflineSessionTransitionLease,
): Promise<boolean> {
if (this.#storageUnavailable()) return Promise.resolve(true);
const revision = ++this.#transitionRevision;
const lease = this.#lease(revision, authLease);
return this.#enqueueTransition(async () => {
Expand All @@ -59,13 +100,15 @@ export class OfflineCoordinatorService {

/** Starts pull and outbox replay after the caller has published remote access. */
async resumeRemoteSession(options?: OfflineResumeRemoteSessionOptions): Promise<void> {
if (this.#storageUnavailable()) return;
await this.#sync.refreshSession(options?.foregroundScopeIds);
}

/**
* Activates a restored identity for local replica/outbox use without enabling transport sync.
*/
activateOfflineSession(authSubject?: string | null, authLease?: OfflineSessionTransitionLease): Promise<OfflineSessionManifest | null> {
if (this.#storageUnavailable()) return Promise.resolve(null);
const revision = ++this.#transitionRevision;
const lease = this.#lease(revision, authLease);
return this.#enqueueTransition(async () => {
Expand All @@ -78,6 +121,7 @@ export class OfflineCoordinatorService {
}

clearActiveSession(): Promise<void> {
if (this.#storageUnavailable()) return Promise.resolve();
this.#sync.revokeSession();
this.#session.revokeAccess();
++this.#transitionRevision;
Expand All @@ -88,6 +132,7 @@ export class OfflineCoordinatorService {
}

async prepareLogout(action: OfflineLogoutAction): Promise<boolean> {
if (this.#storageUnavailable()) return action !== 'cancel';
if (action === 'cancel') return false;
if (action === 'discard') {
await this.#sync.discardAllPending();
Expand All @@ -98,9 +143,22 @@ export class OfflineCoordinatorService {
}

flush(): Promise<void> {
if (this.#storageUnavailable()) return Promise.resolve();
return this.#sync.flush();
}

#asStorageUnavailable(error: unknown): OfflineStorageUnavailableError {
if (error instanceof OfflineStorageUnavailableError) return error;
const message = error instanceof Error ? error.message : 'Offline storage is unavailable.';
return new OfflineStorageUnavailableError('storage_unavailable', message || 'Offline storage is unavailable.', {
cause: error,
});
}

#storageUnavailable(): boolean {
return this.#storageState().status === 'unavailable';
}

#lease(revision: number, authLease?: OfflineSessionTransitionLease): OfflineSessionTransitionLease {
return { isCurrent: () => revision === this.#transitionRevision && (authLease?.isCurrent() ?? true) };
}
Expand Down
Loading