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
30 changes: 29 additions & 1 deletion projects/kit/offline/src/lib/offline-request-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { HttpRequest, HttpResponse } from '@angular/common/http';
import { HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { describe, expect, it, vi } from 'vitest';
import { of } from 'rxjs';
import { OfflineNetworkService } from './offline-network.service';
import {
OFFLINE_MUTATION_REQUEST_POLICIES,
OFFLINE_RESPONSE_COMPLETE_HEADER,
OFFLINE_RESPONSE_HEADER,
OfflineMutationPolicyConflictError,
type OfflineMutationRequestPlan,
type OfflineMutationRequestPolicy,
OfflineMutationRequestPolicyRegistry,
offlineReadEmission,
shouldCommitOfflineCollection,
} from './offline-request-policy';
import { offlineInterceptor } from './offline.interceptor';

Expand Down Expand Up @@ -55,6 +59,30 @@ describe('OfflineMutationRequestPolicyRegistry', () => {
});
});

describe('offline read settlement', () => {
it('keeps an incomplete empty local collection from replacing usable state', () => {
const response = new HttpResponse({ headers: new HttpHeaders().set(OFFLINE_RESPONSE_HEADER, 'local') });

expect(shouldCommitOfflineCollection(offlineReadEmission(response, []))).toBe(false);
});

it('commits complete local and remote empty collections', () => {
const local = new HttpResponse({
headers: new HttpHeaders().set(OFFLINE_RESPONSE_HEADER, 'local').set(OFFLINE_RESPONSE_COMPLETE_HEADER, 'true'),
});
const remote = new HttpResponse();

expect(shouldCommitOfflineCollection(offlineReadEmission(local, []))).toBe(true);
expect(shouldCommitOfflineCollection(offlineReadEmission(remote, []))).toBe(true);
});

it('commits a nonempty local collection before settlement', () => {
const response = new HttpResponse({ headers: new HttpHeaders().set(OFFLINE_RESPONSE_HEADER, 'local') });

expect(shouldCommitOfflineCollection(offlineReadEmission(response, ['cached']))).toBe(true);
});
});

function createRegistry(policies: readonly OfflineMutationRequestPolicy[]): OfflineMutationRequestPolicyRegistry {
TestBed.configureTestingModule({
providers: [{ provide: OFFLINE_MUTATION_REQUEST_POLICIES, useValue: policies }],
Expand Down
29 changes: 29 additions & 0 deletions projects/kit/offline/src/lib/offline-request-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,41 @@ import { HttpContextToken } from '@angular/common/http';
export const OFFLINE_BYPASS = new HttpContextToken<boolean>(() => false);
/** Header attached to synthetic local or optimistic responses. */
export const OFFLINE_RESPONSE_HEADER = 'X-Offline-Response';
/** Header marking a local response as a complete collection snapshot. */
export const OFFLINE_RESPONSE_COMPLETE_HEADER = 'X-Offline-Response-Complete';
/** Origin of a synthetic offline response. */
export type OfflineResponseSource = 'local' | 'optimistic';

/** Source passed to a read policy's shared response projector. */
export type OfflineReadResponseSource = 'remote' | 'local';

/** Value emitted by a local-first read together with its settlement metadata. */
export interface OfflineReadEmission<T> {
readonly value: T;
readonly source: OfflineReadResponseSource;
readonly complete?: boolean;
}

/** Reads the source attached by the offline interceptor. */
export function offlineReadSource(response: HttpResponse<unknown>): OfflineReadResponseSource {
return response.headers.get(OFFLINE_RESPONSE_HEADER) === 'local' ? 'local' : 'remote';
}

/** Converts an HTTP response into the shared local-first read emission contract. */
export function offlineReadEmission<T>(response: HttpResponse<unknown>, value: T): OfflineReadEmission<T> {
const source = offlineReadSource(response);
return {
value,
source,
complete: source === 'remote' || response.headers.get(OFFLINE_RESPONSE_COMPLETE_HEADER) === 'true',
};
}

/** Prevents an incomplete empty local collection from clearing usable UI state. */
export function shouldCommitOfflineCollection<T>(emission: OfflineReadEmission<readonly T[]>): boolean {
return emission.source === 'remote' || emission.complete || emission.value.length > 0;
}

/** Controls GET emission order between local replica and remote transport. */
export type OfflineReadStrategy = 'network-first' | 'local-first';

Expand Down