diff --git a/projects/kit/offline/src/lib/offline-request-policy.spec.ts b/projects/kit/offline/src/lib/offline-request-policy.spec.ts index 706fce8..6b1842e 100644 --- a/projects/kit/offline/src/lib/offline-request-policy.spec.ts +++ b/projects/kit/offline/src/lib/offline-request-policy.spec.ts @@ -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'; @@ -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 }], diff --git a/projects/kit/offline/src/lib/offline-request-policy.ts b/projects/kit/offline/src/lib/offline-request-policy.ts index 27114ad..a020c47 100644 --- a/projects/kit/offline/src/lib/offline-request-policy.ts +++ b/projects/kit/offline/src/lib/offline-request-policy.ts @@ -7,12 +7,41 @@ import { HttpContextToken } from '@angular/common/http'; export const OFFLINE_BYPASS = new HttpContextToken(() => 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 { + readonly value: T; + readonly source: OfflineReadResponseSource; + readonly complete?: boolean; +} + +/** Reads the source attached by the offline interceptor. */ +export function offlineReadSource(response: HttpResponse): 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(response: HttpResponse, value: T): OfflineReadEmission { + 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(emission: OfflineReadEmission): 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';