@forgeroute/ui 0.4.78 → 0.4.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,887 @@
1
+ import * as i0 from "@angular/core";
2
+ import { EnvironmentProviders, InjectionToken } from "@angular/core";
3
+ import { Subject } from "rxjs";
4
+ import { HttpContextToken, HttpInterceptorFn } from "@angular/common/http";
5
+ import { UiLabelsService } from "@forgeroute/ui";
6
+ export type MutationStatus = 'pending' | 'syncing' | 'failed' | 'conflict';
7
+ export type MutationErrorKind = 'conflict' | 'rejected' | 'auth' | 'server' | 'network' | 'timeout' | 'dependency';
8
+ export interface MutationError {
9
+ kind: MutationErrorKind;
10
+ status?: number;
11
+ message: string;
12
+ }
13
+ export interface OfflineMutation {
14
+ id: string;
15
+ seq: number;
16
+ userId: string;
17
+ /** The Idempotency-Key header of the original request; re-sent unchanged on every replay. */
18
+ key: string;
19
+ method: string;
20
+ url: string;
21
+ body: unknown;
22
+ headers?: Record<string, string>;
23
+ label: string;
24
+ entityType?: string;
25
+ entityId?: string;
26
+ createdAt: number;
27
+ status: MutationStatus;
28
+ claimOwner?: string;
29
+ claimAt?: number;
30
+ attempts: number;
31
+ nextAttemptAt?: number;
32
+ lastError?: MutationError;
33
+ /**
34
+ * Set on a queued POST to a collection whose body had no id of its own: the client-made id handed out in the synthetic 202 (equals `id`).
35
+ * Later saved changes may refer to it; after this row is delivered it is mapped to the server's real id (store `idmap`).
36
+ */
37
+ tempId?: string;
38
+ /** Ids of the queued create rows whose temp id this change refers to (URL, query or body), set when the change was saved. */
39
+ dependsOn?: string[];
40
+ /** The temp ids behind `dependsOn`, same order (needed after the create row is gone to find its `idmap` entry). */
41
+ dependsOnTempIds?: string[];
42
+ }
43
+ export type NewMutation = Omit<OfflineMutation, 'seq'>;
44
+ export interface CachedEntry {
45
+ /** Composite primary key: `<userId>::<url key>`. */
46
+ key: string;
47
+ urlKey: string;
48
+ userId: string;
49
+ data: unknown;
50
+ cachedAt: number;
51
+ expiresAt: number;
52
+ }
53
+ /** Maps a temp id to the real id the server gave the record. Key `<userId>:<tempId>`; kept 7 days. */
54
+ export interface IdMapEntry {
55
+ key: string;
56
+ userId: string;
57
+ tempId: string;
58
+ /** Always text; `numeric` tells that the server's id was a number (so a JSON body gets a number back). */
59
+ realId: string;
60
+ numeric?: boolean;
61
+ createdAt: number;
62
+ expiresAt: number;
63
+ }
64
+ export declare const IDMAP_TTL_MS: number;
65
+ export interface MutationCounts {
66
+ pending: number;
67
+ syncing: number;
68
+ failed: number;
69
+ conflict: number;
70
+ }
71
+ /** A claim older than this in status `syncing` belongs to a tab that died mid-send and may be taken over. */
72
+ export declare const STALE_CLAIM_MS = 60000;
73
+ /** IndexedDB, or null when the browser has none / refuses access (some privacy modes throw on the property read). */
74
+ export declare const OFFLINE_IDB_FACTORY: InjectionToken<IDBFactory | null>;
75
+ /** IndexedDB is missing, blocked or failed to open: nothing can be saved on this device. */
76
+ export declare class StorageUnavailableError extends Error {
77
+ constructor(message?: string);
78
+ }
79
+ /** The user already has `maxQueue` saved changes. */
80
+ export declare class QueueFullError extends Error {
81
+ readonly limit: number;
82
+ constructor(limit: number);
83
+ }
84
+ export declare function isQuotaError(e: unknown): boolean;
85
+ /** True when `claim` may take the row: pending, a failed row whose failure was transient, or a `syncing` row whose claim went stale. */
86
+ export declare function isClaimable(row: OfflineMutation, now: number): boolean;
87
+ /**
88
+ * Thin promise wrapper over IndexedDB: the queued writes (`mutations`) and the per-user device copy of GET answers (`responses`).
89
+ * Every row and cache entry carries its `userId`; reads and deletes are always scoped to a user. If IndexedDB cannot be used,
90
+ * `available()` is false and callers must refuse to queue (never a fake success).
91
+ */
92
+ export declare class OfflineStore {
93
+ private readonly cfg;
94
+ private readonly factory;
95
+ /** Emits after every write to the saved changes (add, claim, update, delete, clear); other tabs are told by OfflineSupportService. */
96
+ readonly mutated$: Subject<void>;
97
+ private db;
98
+ private opening;
99
+ private initPromise;
100
+ private ready;
101
+ available(): boolean;
102
+ /** Opens the database. Never rejects: on failure `available()` stays false (and a later `init()` tries again). */
103
+ init(): Promise<void>;
104
+ private doInit;
105
+ /**
106
+ * Saves a new change. If this user already has a row with the same idempotency `key` that row is returned instead (a double click that
107
+ * re-used the key must not queue twice). Rejects with QueueFullError, StorageUnavailableError or the storage error (e.g. QuotaExceededError).
108
+ */
109
+ addMutation(row: NewMutation): Promise<OfflineMutation>;
110
+ /**
111
+ * Atomically takes a row for sending: only if it is pending, failed with a transient error, or `syncing` with a claim older than 60 s.
112
+ * Returns the claimed row, or null when it is gone or somebody else holds a live claim.
113
+ */
114
+ claim(id: string, owner: string, now?: number): Promise<OfflineMutation | null>;
115
+ /** Merges `patch` into the row (an `undefined` value removes the field). With `ifOwner` it only applies while that owner still holds the claim. */
116
+ update(id: string, patch: Partial<OfflineMutation>, opts?: {
117
+ ifOwner?: string;
118
+ }): Promise<OfflineMutation | null>;
119
+ /**
120
+ * The user's "Retry": puts one row (any status) back to pending with 0 attempts, in ONE transaction so a claim taken in between cannot be overwritten.
121
+ * Returns the reset row, or null when the row is missing, belongs to another user, or is being sent right now (live claim).
122
+ */
123
+ resetForRetry(id: string, userId: string, now?: number): Promise<OfflineMutation | null>;
124
+ get(id: string): Promise<OfflineMutation | null>;
125
+ /** Deletes the row; with `ifOwner` only while that owner still holds the claim. Returns whether a row was deleted. */
126
+ delete(id: string, opts?: {
127
+ ifOwner?: string;
128
+ }): Promise<boolean>;
129
+ /** The user's rows in send order (seq, then createdAt). */
130
+ list(userId: string): Promise<OfflineMutation[]>;
131
+ count(userId: string): Promise<number>;
132
+ countsByStatus(userId: string): Promise<MutationCounts>;
133
+ /** Deletes only the saved changes of this user (their device copy of GET answers stays). */
134
+ deleteMutations(userId: string): Promise<number>;
135
+ /** Deletes this user's saved changes AND device copy. Never touches another user's data. */
136
+ clearUser(userId: string): Promise<void>;
137
+ /** Deletes everything for every user (rows, device copy). Only for an explicit, confirmed full reset. */
138
+ clearAll(): Promise<void>;
139
+ /** Saves (or overwrites: a replayed create gives the same answer) the real id for a temp id. Rejects when the write fails. */
140
+ putIdMapping(userId: string, tempId: string, realId: string | number, now?: number): Promise<IdMapEntry>;
141
+ /** The user's real id for `tempId`, or null (missing, expired, or somebody else's). */
142
+ getIdMapping(userId: string, tempId: string, now?: number): Promise<IdMapEntry | null>;
143
+ /** All of the user's unexpired temp id mappings. */
144
+ listIdMap(userId: string, now?: number): Promise<IdMapEntry[]>;
145
+ /** Deletes this user's temp id mappings only. */
146
+ deleteIdMap(userId: string): Promise<number>;
147
+ /** Removes mappings whose 7 days are over (every user). Returns how many. */
148
+ purgeExpiredIdMap(now?: number): Promise<number>;
149
+ /** Stores a GET answer for this user. Returns false when it could not be stored (never throws: the copy is optional). */
150
+ cacheResponse(key: string, userId: string, data: unknown, ttlMs: number, now?: number): Promise<boolean>;
151
+ /** The user's stored answer for `key`, or null (missing, expired or belonging to somebody else). */
152
+ getCached(key: string, userId: string, now?: number): Promise<CachedEntry | null>;
153
+ /** Removes this user's stored answers whose URL key starts with `prefix`. */
154
+ invalidatePrefix(prefix: string, userId: string): Promise<number>;
155
+ /** Deletes the device copy of one user, or of everybody when `userId` is omitted. Saved changes are never touched. */
156
+ clearCache(userId?: string): Promise<void>;
157
+ purgeExpired(now?: number): Promise<number>;
158
+ private evictOldest;
159
+ private enforceCacheLimit;
160
+ private ensureReady;
161
+ private getDb;
162
+ private openWithRetry;
163
+ private openOnce;
164
+ private runTx;
165
+ static ɵfac: i0.ɵɵFactoryDeclaration<OfflineStore, never>;
166
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
167
+ }
168
+ /** string = path prefix or substring after the origin is stripped; RegExp is tested on path + query. */
169
+ export type UrlMatcher = string | RegExp;
170
+ export interface QueueRule {
171
+ pattern: RegExp;
172
+ methods: readonly string[];
173
+ /** Plain-words name of the change shown in the pending-changes list (e.g. "Add lot 12"). */
174
+ label?: (method: string, url: string, body: unknown) => string;
175
+ entityType?: (url: string) => string | undefined;
176
+ }
177
+ export interface OfflineSupportConfig {
178
+ /** Only URLs containing this are intercepted. Default '/api/'. */
179
+ apiPrefix?: string;
180
+ /** Never touched (auth, public, uploads...). */
181
+ skip?: readonly UrlMatcher[];
182
+ /** The signed-in user's id; null = nobody (nothing is queued/cached). Scopes the store. */
183
+ getUserId: () => string | null;
184
+ /** ALLOW-LIST mode: only requests matching a rule may be queued offline. Omit for BLOCK-LIST mode (everything may be queued except `blockOffline`). */
185
+ allowQueue?: readonly QueueRule[];
186
+ /** Writes that must NEVER be queued (payments, sends...). While offline/unreachable they fail with OfflineBlockedError. */
187
+ blockOffline?: readonly {
188
+ pattern: UrlMatcher;
189
+ message?: string;
190
+ }[];
191
+ /** GET endpoints that must never be stored or served from the device copy. */
192
+ neverCache?: readonly UrlMatcher[];
193
+ /** Safe extra headers copied onto the queued row and re-sent on replay (never Authorization). */
194
+ replayHeaders?: readonly string[];
195
+ /** Cheap authenticated/unauthenticated GET used ONLY while the connection is suspect. null/omitted = no probe. */
196
+ probe?: {
197
+ url: string;
198
+ intervalMs?: number;
199
+ } | null;
200
+ /** Client timeouts in ms. Defaults: read 30000, write 60000, long 180000. `long` applies to blob/arraybuffer, FormData, and matchers in `longRequests`. */
201
+ timeouts?: {
202
+ read?: number;
203
+ write?: number;
204
+ long?: number;
205
+ };
206
+ longRequests?: readonly UrlMatcher[];
207
+ /** Slow detection thresholds. Defaults: slowMs 5000, slowCount 2 within windowMs 60000; single in-flight > stuckMs 8000; recover after quietMs 30000 of fast requests. */
208
+ slow?: {
209
+ slowMs?: number;
210
+ slowCount?: number;
211
+ windowMs?: number;
212
+ stuckMs?: number;
213
+ quietMs?: number;
214
+ };
215
+ /** IndexedDB database name. Default 'forgeroute-offline'. */
216
+ dbName?: string;
217
+ /** Cache TTLs in ms (default 10 min lists / 30 min details); `ttl(url)` overrides. */
218
+ cacheTtl?: (url: string) => number;
219
+ maxQueue?: number;
220
+ maxAttempts?: number;
221
+ /**
222
+ * Reads the id the server gave a record that was created offline, from the 2xx answer of the replayed POST (`body` is the parsed JSON answer,
223
+ * `undefined` when the answer was empty / not JSON). Used to rewrite later saved changes that point at the client-made temporary id.
224
+ * Default: `body.id`, else `body.data.id`; a string or a finite number. Return `undefined` when the answer has no id (dependent changes then fail
225
+ * with kind `dependency` instead of being sent with a wrong id).
226
+ */
227
+ createdId?: (body: unknown, row: OfflineMutation) => string | number | undefined;
228
+ }
229
+ export declare const OFFLINE_SUPPORT_CONFIG: InjectionToken<OfflineSupportConfig>;
230
+ /** Header the replayer sets on every replayed request; the idempotency and offline interceptors let such requests through untouched. */
231
+ export declare const OFFLINE_REPLAY_HEADER = "X-Offline-Replay";
232
+ export declare const IDEMPOTENCY_HEADER = "Idempotency-Key";
233
+ /** Response header on an answer served from the device copy; the value is the `cachedAt` time in ms. */
234
+ export declare const FROM_CACHE_HEADER = "X-From-Offline-Cache";
235
+ export interface ResolvedSlowConfig {
236
+ slowMs: number;
237
+ slowCount: number;
238
+ windowMs: number;
239
+ stuckMs: number;
240
+ quietMs: number;
241
+ }
242
+ /** The config with every default filled in. */
243
+ export interface ResolvedOfflineConfig {
244
+ apiPrefix: string;
245
+ skip: readonly UrlMatcher[];
246
+ getUserId: () => string | null;
247
+ allowQueue: readonly QueueRule[] | null;
248
+ blockOffline: readonly {
249
+ pattern: UrlMatcher;
250
+ message?: string;
251
+ }[];
252
+ neverCache: readonly UrlMatcher[];
253
+ replayHeaders: readonly string[];
254
+ probe: {
255
+ url: string;
256
+ intervalMs: number;
257
+ } | null;
258
+ timeouts: {
259
+ read: number;
260
+ write: number;
261
+ long: number;
262
+ };
263
+ longRequests: readonly UrlMatcher[];
264
+ slow: ResolvedSlowConfig;
265
+ dbName: string;
266
+ cacheTtl: ((url: string) => number) | null;
267
+ maxQueue: number;
268
+ maxAttempts: number;
269
+ createdId: (body: unknown, row: OfflineMutation) => string | number | undefined;
270
+ }
271
+ /** Default for `createdId`: `body.id`, else `body.data.id` (string or finite number), otherwise undefined. */
272
+ export declare function defaultCreatedId(body: unknown): string | number | undefined;
273
+ export declare const DEFAULT_LIST_TTL_MS: number;
274
+ export declare const DEFAULT_DETAIL_TTL_MS: number;
275
+ export declare function resolveConfig(cfg: OfflineSupportConfig): ResolvedOfflineConfig;
276
+ /** Path + query of a URL with the origin removed ('https://x.test/api/a?b=1' -> '/api/a?b=1'). Relative URLs are returned as they are. */
277
+ export declare function pathAndQuery(url: string): string;
278
+ /** Path only (no query) of a URL with the origin removed. */
279
+ export declare function pathOnly(url: string): string;
280
+ /** True when `matcher` matches `url`: a string anywhere in the path + query, a RegExp tested on path + query (a global/sticky RegExp is not made stateful). */
281
+ export declare function matchesUrl(url: string, matcher: UrlMatcher): boolean;
282
+ export declare function matchesAny(url: string, matchers: readonly UrlMatcher[]): boolean;
283
+ export declare function isMutationMethod(method: string): boolean;
284
+ /** First id-looking path segment (digits or UUID) with the segment before it as the entity type: '/api/lots/42/notes' -> { entityType: 'lots', entityId: '42' }. */
285
+ export declare function deriveEntity(url: string): {
286
+ entityType?: string;
287
+ entityId?: string;
288
+ };
289
+ /** The path prefix whose stored GET copies a successful write may have made stale: the path without trailing id segments ('/api/contacts/12' -> '/api/contacts'). */
290
+ export declare function invalidationPrefix(url: string): string;
291
+ /** `resolveConfig` memoised per config object (interceptors run on every request). */
292
+ export declare function resolveConfigCached(cfg: OfflineSupportConfig): ResolvedOfflineConfig;
293
+ /** True when the request is one the offline layer looks at: under `apiPrefix` and not matched by `skip`. */
294
+ export declare function isInScope(url: string, cfg: ResolvedOfflineConfig): boolean;
295
+ export declare function lastSegmentIsId(url: string): boolean;
296
+ /**
297
+ * A v4 UUID. Uses `crypto.randomUUID` when the browser has it (Safari < 15.4 and insecure contexts do not),
298
+ * then `crypto.getRandomValues`, then `Math.random` as the last resort. Always 36 characters.
299
+ */
300
+ export declare function generateUuid(): string;
301
+ export type ConnectionState = 'online' | 'slow' | 'unreachable' | 'offline';
302
+ export interface RecordRequestInfo {
303
+ durationMs: number;
304
+ ok: boolean;
305
+ status: number;
306
+ timedOut?: boolean;
307
+ method: string;
308
+ /** Long requests (blob / FormData / `longRequests`) never count as slow. Additive to the documented shape. */
309
+ long?: boolean;
310
+ }
311
+ /** What made the connection usable again: the browser `online` event / a normal request that got an answer, or the probe. */
312
+ export type RecoveryTrigger = 'online' | 'probe';
313
+ /** Debounce for the browser `online` event (flapping wifi). `offline` is applied at once: queueing is always the safe side. */
314
+ export declare const ONLINE_DEBOUNCE_MS = 300;
315
+ /**
316
+ * The one answer to "can this device reach the server?": online | slow | unreachable | offline.
317
+ * Measured from real request timings (works in Safari/Firefox, which have no `navigator.connection`), the browser online/offline events and an
318
+ * optional probe GET that runs only while the connection is suspect.
319
+ */
320
+ export declare class ConnectionStateService {
321
+ private readonly cfg;
322
+ private readonly win;
323
+ private readonly browserOnline;
324
+ private readonly unreachable;
325
+ private readonly slowFlag;
326
+ private readonly _slowRequestActive;
327
+ private readonly _inFlightCount;
328
+ private readonly _state;
329
+ private readonly _lastChangeAt;
330
+ readonly state: import("@angular/core").Signal<ConnectionState>;
331
+ readonly isOnline: import("@angular/core").Signal<boolean>;
332
+ readonly isOffline: import("@angular/core").Signal<boolean>;
333
+ /** Some request has been in flight longer than `slow.stuckMs`. */
334
+ readonly slowRequestActive: import("@angular/core").Signal<boolean>;
335
+ readonly inFlightCount: import("@angular/core").Signal<number>;
336
+ readonly lastChangeAt: import("@angular/core").Signal<number>;
337
+ /** True while the user should be told "this is taking a while" (a request is stuck or the connection is slow). */
338
+ readonly slowRequestHint: import("@angular/core").Signal<boolean>;
339
+ /** Emits when the connection became usable again; the replayer listens to it. */
340
+ readonly recovered$: Subject<RecoveryTrigger>;
341
+ private failures;
342
+ private slowTimes;
343
+ private quietTimer;
344
+ private onlineTimer;
345
+ private probeTimer;
346
+ private optimisticTimer;
347
+ private probeInFlight;
348
+ private readonly inFlight;
349
+ private nextRequestId;
350
+ private readonly onWindowOnline;
351
+ private readonly onWindowOffline;
352
+ private readonly onFocus;
353
+ constructor();
354
+ /** Called by `connectionInterceptor` when a request finished (or failed / timed out). */
355
+ recordRequest(info: RecordRequestInfo): void;
356
+ /**
357
+ * Starts tracking one request; call the returned function when it ends (success, error, timeout or cancel; calling it twice is harmless).
358
+ * A non-long request still in flight after `slow.stuckMs` turns `slowRequestActive` and the state to `slow`.
359
+ */
360
+ beginRequest(options?: {
361
+ long?: boolean;
362
+ }): () => void;
363
+ /**
364
+ * Asks the probe URL once. Resolves true when it answered with ANY HTTP status (a 401 still proves the server is reachable).
365
+ * Without a configured probe there is nothing to ask: the answer is what the browser says.
366
+ */
367
+ probeNow(): Promise<boolean>;
368
+ private runProbe;
369
+ private onProbeSuccess;
370
+ private handleFailure;
371
+ private setUnreachable;
372
+ private clearUnreachable;
373
+ private stopRecoveryTimers;
374
+ private markSlow;
375
+ private scheduleOnline;
376
+ private cancelOnlineTimer;
377
+ private setBrowserOnline;
378
+ private syncFromNavigator;
379
+ private readNavigatorOnline;
380
+ private recompute;
381
+ private teardown;
382
+ static ɵfac: i0.ɵɵFactoryDeclaration<ConnectionStateService, never>;
383
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
384
+ }
385
+ /** Tells the UI when what the user sees came from the device copy instead of the server. Tracked per request key (the cache key of the GET). */
386
+ export declare class StaleDataService {
387
+ private readonly _staleAt;
388
+ private readonly entries;
389
+ /** `cachedAt` of the most recent cache-served GET that has not been replaced by a live answer for the same request; null = everything shown is live. */
390
+ readonly staleAt: import("@angular/core").Signal<number | null>;
391
+ markStale(cachedAt: number, key?: string): void;
392
+ /** A live answer arrived for this request: only its own stale mark goes away. */
393
+ clearKey(key: string): void;
394
+ /** Forgets every stale mark. */
395
+ clear(): void;
396
+ private publish;
397
+ static ɵfac: i0.ɵɵFactoryDeclaration<StaleDataService, never>;
398
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
399
+ }
400
+ export type ReplayTrigger = 'start' | 'online' | 'probe' | 'manual';
401
+ export interface ReplayResult {
402
+ synced: number;
403
+ failed: number;
404
+ conflicts: number;
405
+ blockedByAuth: boolean;
406
+ }
407
+ /** `navigator.locks`, or null when the browser has none (older Safari) - the replayer then falls back to an in-tab mutex plus a localStorage lease. */
408
+ export declare const OFFLINE_LOCK_MANAGER: InjectionToken<LockManager | null>;
409
+ export type LeaseStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
410
+ /** Storage for the lock-less fallback lease: ONE small key holding `{ owner, until }`, never any data. */
411
+ export declare const OFFLINE_LEASE_STORAGE: InjectionToken<LeaseStorage | null>;
412
+ export declare const LEASE_TTL_MS = 30000;
413
+ /** Wait before the next automatic try after `attempts` failed sends: 2 s, 4 s, 8 s ... capped at 5 minutes. */
414
+ export declare function backoffMs(attempts: number): number;
415
+ type Outcome = {
416
+ action: 'ok';
417
+ text?: string;
418
+ } | {
419
+ action: 'dependency';
420
+ } | {
421
+ action: 'conflict';
422
+ status: number;
423
+ } | {
424
+ action: 'rejected';
425
+ status: number;
426
+ } | {
427
+ action: 'auth';
428
+ status: number;
429
+ } | {
430
+ action: 'retry';
431
+ kind: Extract<MutationErrorKind, 'server' | 'network' | 'timeout'>;
432
+ status: number;
433
+ };
434
+ /** Maps what a replayed request ended with to what happens to its row. Anything unknown stays retryable: only a real 4xx answer is final. */
435
+ export declare function classifyReplay(err: unknown): Outcome;
436
+ /**
437
+ * Sends the signed-in user's saved changes, oldest first, when the connection is back. Safe by construction:
438
+ * - one run at a time per browser (navigator.locks, or in-tab mutex + localStorage lease), and every row is `claim`ed atomically in IndexedDB;
439
+ * - the row's own Idempotency-Key is re-sent unchanged, so even a crash after the server answered cannot create a second record;
440
+ * - 401/403 never touch a row, a permanent 4xx marks the row failed (only the user's Retry sends it again), nothing is ever deleted except
441
+ * after a 2xx or the user's own Discard.
442
+ */
443
+ export declare class OfflineReplayer {
444
+ private readonly cfg;
445
+ private readonly store;
446
+ private readonly http;
447
+ private readonly connection;
448
+ private readonly toast;
449
+ private readonly labels;
450
+ private readonly locks;
451
+ private readonly leaseStorage;
452
+ private readonly owner;
453
+ private running;
454
+ private readonly _syncing;
455
+ private readonly _pendingCount;
456
+ private readonly _failedCount;
457
+ private readonly _conflictCount;
458
+ private readonly _lastSyncAt;
459
+ private readonly _syncTick;
460
+ private readonly _authBlocked;
461
+ private readonly _lastResult;
462
+ readonly syncing: import("@angular/core").Signal<boolean>;
463
+ /** Rows waiting to be sent (pending + being sent right now). */
464
+ readonly pendingCount: import("@angular/core").Signal<number>;
465
+ readonly failedCount: import("@angular/core").Signal<number>;
466
+ readonly conflictCount: import("@angular/core").Signal<number>;
467
+ readonly lastSyncAt: import("@angular/core").Signal<number | null>;
468
+ /** Bumped when a run changed server data (a row was delivered): open pages refresh on it. */
469
+ readonly syncTick: import("@angular/core").Signal<number>;
470
+ /** True after a 401/403 stopped a run: the rows are safe and wait for the user to sign in again. */
471
+ readonly authBlocked: import("@angular/core").Signal<boolean>;
472
+ readonly lastResult: import("@angular/core").Signal<ReplayResult | null>;
473
+ /** Emits at the end of every run that did something. */
474
+ readonly runCompleted$: Subject<{
475
+ trigger: ReplayTrigger;
476
+ result: ReplayResult;
477
+ changed: boolean;
478
+ }>;
479
+ /** Sends everything that is due. Concurrent calls in this tab share one run; another tab's run makes this resolve null. */
480
+ replayAll(trigger: ReplayTrigger): Promise<ReplayResult | null>;
481
+ /** Puts one row (any status, conflict and failed included) back to pending with 0 attempts and sends only that row. */
482
+ retryOne(id: string): Promise<ReplayResult | null>;
483
+ /** Deletes one row of the signed-in user (the user chose to discard it). */
484
+ discard(id: string): Promise<void>;
485
+ /** Deletes all rows of the signed-in user (the user chose to discard everything). */
486
+ discardAll(): Promise<void>;
487
+ /** Re-reads the counts for the signed-in user (zeros when nobody is signed in or storage is unavailable). */
488
+ refreshCounts(): Promise<void>;
489
+ clearAuthBlocked(): void;
490
+ /** Another tab delivered rows: open pages in this tab must refresh too. */
491
+ noteRemoteSync(): void;
492
+ /** Forgets everything about the previous user (counts, last result); the stored rows are not touched. */
493
+ resetView(): void;
494
+ private start;
495
+ private exclusive;
496
+ private leaseKey;
497
+ private readLease;
498
+ private tryAcquireLease;
499
+ private releaseLease;
500
+ private withLease;
501
+ private run;
502
+ /**
503
+ * 'hold': a create this change points at is still pending / being sent (or waiting out a backoff): do not send it yet.
504
+ * 'fail': a create it points at failed / conflicted, was discarded, or answered without a readable id: it can never be sent correctly.
505
+ * Reads the store fresh for every create that has no mapping yet, so a run in another tab that just finished the create is seen.
506
+ */
507
+ private checkDependencies;
508
+ private send;
509
+ /** Stores the outcome on the row (only while we still own the claim). Returns true when the run must stop. */
510
+ private apply;
511
+ /** The server's id for a created record: the answer text parsed as JSON (undefined when empty / not JSON) run through `config.createdId`. */
512
+ private readCreatedId;
513
+ private announce;
514
+ static ɵfac: i0.ɵɵFactoryDeclaration<OfflineReplayer, never>;
515
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
516
+ }
517
+ /** Why an offline write was refused. */
518
+ export type OfflineBlockedReason = 'blocked' | 'upload' | 'storage' | 'not-allowed';
519
+ /**
520
+ * A write that cannot be saved on the device while offline. `message` is plain words for the user (from UiLabelsService or the app's
521
+ * `blockOffline[].message`). Nothing was queued and nothing was sent.
522
+ */
523
+ export declare class OfflineBlockedError extends Error {
524
+ readonly offlineBlocked = true;
525
+ readonly status = 0;
526
+ readonly reason: OfflineBlockedReason;
527
+ constructor(message: string, reason?: OfflineBlockedReason);
528
+ }
529
+ /** The client gave up waiting. For a write `outcomeUnknown` is true: the server may still have handled it. */
530
+ export declare class RequestTimeoutError extends Error {
531
+ readonly timeout = true;
532
+ readonly status = 0;
533
+ readonly method: string;
534
+ readonly url: string;
535
+ readonly outcomeUnknown: boolean;
536
+ constructor(message: string, method: string, url: string, outcomeUnknown: boolean);
537
+ }
538
+ export declare function isOfflineBlockedError(e: unknown): e is OfflineBlockedError;
539
+ export declare function isRequestTimeoutError(e: unknown): e is RequestTimeoutError;
540
+ /**
541
+ * Per-request client timeout in ms, set through `HttpContext`: `http.get(url, { context: new HttpContext().set(REQUEST_TIMEOUT_MS, 120000) })`.
542
+ * `null` (default) = the configured read / write / long timeout. A value of 0 or less switches the timeout off for that request.
543
+ */
544
+ export declare const REQUEST_TIMEOUT_MS: HttpContextToken<number | null>;
545
+ /**
546
+ * Gives every write under `apiPrefix` an `Idempotency-Key: <uuid>` unless the caller already set one (callers that must keep ONE key across
547
+ * user retries set it themselves). The same key goes on the queued row and on every replay, so the server can recognise a repeat.
548
+ * Left alone: reads, skipped URLs, FormData bodies, and replays (`X-Offline-Replay`, which already carry the row's key).
549
+ */
550
+ export declare const idempotencyInterceptor: HttpInterceptorFn;
551
+ /** Pause before the single automatic retry of a read that failed without an answer. */
552
+ declare const READ_RETRY_DELAY_MS = 1000;
553
+ declare function isNetworkFailure(err: unknown): boolean;
554
+ /**
555
+ * Measures every API request (feeds ConnectionStateService), applies the client timeout and retries a failed READ once.
556
+ * A write is never retried here: after a timeout or status 0 its outcome is unknown, so the user decides (the same Idempotency-Key makes
557
+ * a retry safe). Sits closest to the network so the offline interceptor sees timeouts and failures as errors.
558
+ */
559
+ export declare const connectionInterceptor: HttpInterceptorFn;
560
+ /**
561
+ * Reads: network first; the answer is also stored (per user) unless excluded; while offline/unreachable, or when the request fails with no
562
+ * answer, the stored copy is served (header `X-From-Offline-Cache: <cachedAt>`, StaleDataService told) and otherwise the error is rethrown.
563
+ * Writes while offline/unreachable: blocked (OfflineBlockedError) or saved on the device with the request's Idempotency-Key and answered with a
564
+ * typed synthetic 202. Writes while online pass through; a write with an unknown outcome (timeout / status 0 while the browser is online) is
565
+ * never queued automatically.
566
+ */
567
+ export declare const offlineInterceptor: HttpInterceptorFn;
568
+ /**
569
+ * Register LAST in `withInterceptors([... ,...offlineHttpInterceptors])` (after errorContext and the app's own auth interceptor; replays carry
570
+ * `X-Offline-Replay`, so an app whose auth interceptor sits after these still gets the live token on replays).
571
+ *
572
+ * Array order is outermost first: idempotency (adds the key) -> offline (queue / device copy) -> connection (timeout, timing, GET retry, closest
573
+ * to the network). The offline interceptor must be OUTSIDE the connection one: it has to see timeouts and status-0 failures as errors to fall back
574
+ * to the device copy, and answers it makes itself (queued 202, device copy) must not be measured as network successes.
575
+ */
576
+ export declare const offlineHttpInterceptors: readonly HttpInterceptorFn[];
577
+ /** What tabs tell each other: `changed` = the saved changes changed (re-read them), `synced` = rows were delivered (refresh open pages). */
578
+ export type CrossTabMessage = {
579
+ type: 'changed';
580
+ } | {
581
+ type: 'synced';
582
+ };
583
+ /** Creates the cross-tab channel; null when the browser has no BroadcastChannel (the focus / visibility poll still keeps tabs in step). */
584
+ export declare function createChannel(name: string): BroadcastChannel | null;
585
+ /**
586
+ * What the app talks to: counts, the list for the pending-changes drawer, Sync now / Retry / Discard, and the session helpers.
587
+ * Everything is scoped to `config.getUserId()`; nothing here ever deletes saved changes unless the user chose it
588
+ * (`discard`, `discardAll`, `endSession({ discardPending: true })` after the app asked the user).
589
+ */
590
+ export declare class OfflineSupportService {
591
+ private readonly cfg;
592
+ private readonly store;
593
+ private readonly replayer;
594
+ private readonly connection;
595
+ private readonly stale;
596
+ private readonly toast;
597
+ private readonly labels;
598
+ private readonly doc;
599
+ private readonly destroyRef;
600
+ private readonly _rows;
601
+ private readonly _lastQueued;
602
+ private readonly _storageAvailable;
603
+ readonly pendingCount: import("@angular/core").Signal<number>;
604
+ readonly failedCount: import("@angular/core").Signal<number>;
605
+ readonly conflictCount: import("@angular/core").Signal<number>;
606
+ readonly syncing: import("@angular/core").Signal<boolean>;
607
+ readonly lastSyncAt: import("@angular/core").Signal<number | null>;
608
+ readonly syncTick: import("@angular/core").Signal<number>;
609
+ readonly authBlocked: import("@angular/core").Signal<boolean>;
610
+ readonly lastResult: import("@angular/core").Signal<ReplayResult | null>;
611
+ /** The signed-in user's saved changes, oldest first, for the drawer. Updates when another tab changes them. */
612
+ readonly rows: import("@angular/core").Signal<readonly OfflineMutation[]>;
613
+ /** The most recent change saved on this device while offline ({ label, at }). */
614
+ readonly lastQueued: import("@angular/core").Signal<{
615
+ label: string;
616
+ at: number;
617
+ } | null>;
618
+ /** False when IndexedDB could not be opened on this device (set after the store initialised): the drawer then must not say "all synced". */
619
+ readonly storageAvailable: import("@angular/core").Signal<boolean>;
620
+ private started;
621
+ private channel;
622
+ private refreshing;
623
+ private dirty;
624
+ private lastKnownUserId;
625
+ private cleanups;
626
+ private retryTimer;
627
+ private destroyed;
628
+ /** Wires the triggers (called by `provideOfflineSupport`'s initializer; calling it again does nothing). Resolves when the first read is done. */
629
+ start(): Promise<void>;
630
+ /** Starts a replay run without ever leaving an unhandled rejection behind. */
631
+ private replay;
632
+ private clearRetryTimer;
633
+ /**
634
+ * Rows that failed with a transient error wait for `nextAttemptAt`. Nothing else triggers a run while the connection stays `online`, so a timer
635
+ * fires one replay when the earliest of them is due.
636
+ */
637
+ private scheduleRetry;
638
+ private post;
639
+ private onRemote;
640
+ /** Called by the offline interceptor after a write was saved on the device. */
641
+ onQueued(row: OfflineMutation): void;
642
+ /** Re-reads the rows and counts. Calls that overlap are merged into one extra read. */
643
+ refresh(): Promise<void>;
644
+ private doRefresh;
645
+ /** "Sync now": sends everything due, even rows waiting out a backoff. */
646
+ syncNow(): Promise<ReplayResult | null>;
647
+ retry(id: string): Promise<void>;
648
+ discard(id: string): Promise<void>;
649
+ discardAll(): Promise<void>;
650
+ /**
651
+ * Call in logout BEFORE signing out. `pending` = changes not yet delivered (waiting or being sent), `failed` = changes that need attention
652
+ * (failed or conflict). When `pending + failed > 0` show a ConfirmService dialog with `connection.logoutPending.one` / `connection.logoutPending.other`; then call
653
+ * `endSession({ discardPending })` with the user's choice. Nothing is deleted here.
654
+ */
655
+ beforeSessionEnd(): Promise<{
656
+ pending: number;
657
+ failed: number;
658
+ }>;
659
+ /** The signed-in user changed (sign-in, switch): re-scope the view to them and send what they have waiting. */
660
+ onSessionStart(userId: string): Promise<void>;
661
+ /**
662
+ * Called by the offline interceptor for every in-scope request: when the signed-in user is not the one the view was built for (the app never
663
+ * called `onSessionStart`), forget the previous user's rows and counts and re-read for the new one.
664
+ */
665
+ ensureScope(): void;
666
+ /**
667
+ * The user signed out. By default NOTHING is deleted: the saved changes and the device copy stay for that user and are sent when they sign in again.
668
+ * Only `discardPending: true` (the app asked the user first) deletes that one user's changes and device copy. Other users' data is never touched.
669
+ * `userId` names the user who is leaving; pass it when the app already switched to somebody else (impersonation, portal logout), otherwise the
670
+ * user the view was last built for is used.
671
+ */
672
+ endSession(options: {
673
+ discardPending: boolean;
674
+ userId?: string;
675
+ }): Promise<void>;
676
+ /**
677
+ * Empties the device copy of every user's GET answers and the in-memory view. Saved changes are kept unless `discardPending` is true, which wipes
678
+ * them for EVERY user: only for a deliberate full reset the user confirmed, never at logout.
679
+ */
680
+ clearAll(options?: {
681
+ discardPending?: boolean;
682
+ }): Promise<void>;
683
+ static ɵfac: i0.ɵɵFactoryDeclaration<OfflineSupportService, never>;
684
+ static ɵprov: i0.ɵɵInjectableDeclaration<any>;
685
+ }
686
+ /**
687
+ * Provides the config and starts the offline layer with the app: opens the store, then wires the replay triggers (app start when online, browser
688
+ * `online`, connection back to `online`, probe success) and the cross-tab sync. Pair it with
689
+ * `provideHttpClient(withInterceptors([..., ...offlineHttpInterceptors]))`.
690
+ */
691
+ export declare function provideOfflineSupport(config: OfflineSupportConfig): EnvironmentProviders;
692
+ /** How long the green "All changes synced" bar stays after a sync. */
693
+ export declare const SYNCED_FLASH_MS = 3000;
694
+ export type ConnectionBannerKind = 'auth' | 'offline' | 'unreachable' | 'syncing' | 'attention' | 'slow' | 'synced';
695
+ interface BannerView {
696
+ kind: ConnectionBannerKind;
697
+ title: string;
698
+ message: string;
699
+ detail: string;
700
+ /** Spoken text: exactly what the sighted user reads. */
701
+ announcement: string;
702
+ }
703
+ /**
704
+ * THE connection banner. Put ONE near the root of the app, above the routed content (inside the scrolling page, not fixed):
705
+ *
706
+ * <ui-connection-banner (viewChanges)="drawerOpen.set(true)" />
707
+ *
708
+ * One plain-words message per situation, by priority: sign-in needed for saved changes > offline > server unreachable >
709
+ * sending saved changes > changes need attention > slow connection > "All changes synced" (3 s, also after automatic syncs) > nothing.
710
+ *
711
+ * Accessibility: two live regions (polite status, assertive alert for the sign-in message) exist from the first render and only their text
712
+ * changes, so screen readers announce every change; the visible bar is ordinary content that mirrors the same words. The bar is
713
+ * `position: sticky; top: 0` inside the page flow, so it never covers content, and sits below modals / side panels.
714
+ *
715
+ * Test ids: connection-banner, connection-banner-live, connection-banner-live-alert, connection-banner-title, connection-banner-message,
716
+ * connection-banner-detail, connection-banner-sync-now, connection-banner-view, connection-banner-info-toggle, connection-banner-info-panel,
717
+ * connection-banner-info-hide.
718
+ */
719
+ export declare class ConnectionBannerComponent {
720
+ protected readonly labels: UiLabelsService;
721
+ private readonly connection;
722
+ private readonly support;
723
+ private readonly cdr;
724
+ /** What the user can still do while offline (app specific). Default: a generic list. */
725
+ readonly infoOffline: import("@angular/core").InputSignal<readonly string[] | null>;
726
+ /** What needs a connection (app specific, should match what the app really blocks). Default: a generic list. */
727
+ readonly infoNeedsConnection: import("@angular/core").InputSignal<readonly string[] | null>;
728
+ /** The user pressed "View changes" - the app opens `<ui-pending-changes-drawer>`. */
729
+ readonly viewChanges: import("@angular/core").OutputEmitterRef<void>;
730
+ protected readonly panelId: string;
731
+ protected readonly infoOpen: import("@angular/core").WritableSignal<boolean>;
732
+ private readonly infoToggleRef;
733
+ private readonly infoHideRef;
734
+ private readonly syncTotal;
735
+ private readonly showSynced;
736
+ private syncedTimer;
737
+ private seenTick;
738
+ private flashPending;
739
+ private wasSyncing;
740
+ protected readonly canDoItems: import("@angular/core").Signal<readonly string[]>;
741
+ protected readonly needsItems: import("@angular/core").Signal<readonly string[]>;
742
+ private count;
743
+ protected readonly view: import("@angular/core").Signal<BannerView | null>;
744
+ /** Set when a "Sync now" call was rejected: said politely in the status region (nothing is lost, the changes stay saved). */
745
+ private readonly actionFailure;
746
+ protected readonly politeText: import("@angular/core").Signal<string>;
747
+ protected readonly alertText: import("@angular/core").Signal<string>;
748
+ protected readonly infoAllowed: import("@angular/core").Signal<boolean>;
749
+ protected readonly showSyncNow: import("@angular/core").Signal<boolean>;
750
+ protected readonly showView: import("@angular/core").Signal<boolean>;
751
+ private readonly rowsTotal;
752
+ constructor();
753
+ private flashSynced;
754
+ protected syncNow(): void;
755
+ protected toggleInfo(): void;
756
+ protected closeInfo(returnFocus: boolean): void;
757
+ protected onEscape(event: Event): void;
758
+ static ɵfac: i0.ɵɵFactoryDeclaration<ConnectionBannerComponent, never>;
759
+ static ɵcmp: i0.ɵɵComponentDeclaration<ConnectionBannerComponent, "ui-connection-banner", never, {
760
+ "infoOffline": {
761
+ "alias": "infoOffline";
762
+ "required": false;
763
+ "isSignal": true;
764
+ };
765
+ "infoNeedsConnection": {
766
+ "alias": "infoNeedsConnection";
767
+ "required": false;
768
+ "isSignal": true;
769
+ };
770
+ }, {
771
+ "viewChanges": "viewChanges";
772
+ }, never, never, true, never>;
773
+ }
774
+ /** One saved change as the drawer shows it (derived from `OfflineSupportService.rows`, i.e. the OfflineStore row shape). */
775
+ type PendingRow = ReturnType<OfflineSupportService['rows']>[number];
776
+ /**
777
+ * Side panel that lists every change saved on this device and not yet accepted by the server, with plain-words reasons and
778
+ * the actions Retry / Discard per change, Sync all and Discard all. Open it from `(viewChanges)` of `<ui-connection-banner>`:
779
+ *
780
+ * <ui-pending-changes-drawer [open]="drawerOpen()" (closed)="drawerOpen.set(false)" />
781
+ *
782
+ * Raw server text is never shown: the wording comes from `lastError.kind`. Discards ask first (ConfirmService). Results are announced
783
+ * in a polite live region that exists from the first render. Test ids: pending-drawer-live, pending-drawer-auth, pending-drawer-list,
784
+ * pending-drawer-empty, pending-row-<id>, pending-row-status-<id>, pending-row-error-<id>, pending-row-time-<id>, pending-row-retry-<id>,
785
+ * pending-row-discard-<id>, pending-drawer-sync-all, pending-drawer-discard-all.
786
+ */
787
+ export declare class PendingChangesDrawerComponent {
788
+ protected readonly labels: UiLabelsService;
789
+ protected readonly support: OfflineSupportService;
790
+ private readonly confirmService;
791
+ private readonly cdr;
792
+ private readonly host;
793
+ private readonly doc;
794
+ readonly open: import("@angular/core").InputSignal<boolean>;
795
+ readonly closed: import("@angular/core").OutputEmitterRef<void>;
796
+ protected readonly rows: import("@angular/core").Signal<readonly import("@forgeroute/ui/offline").OfflineMutation[]>;
797
+ protected readonly announcement: import("@angular/core").WritableSignal<string>;
798
+ private readonly now;
799
+ constructor();
800
+ protected statusText(row: PendingRow): string;
801
+ /** Plain words from the error KIND; the server's own text (`lastError.message`) is never shown. */
802
+ protected errorText(row: PendingRow): string;
803
+ protected canRetry(row: PendingRow): boolean;
804
+ protected iso(row: PendingRow): string;
805
+ protected absolute(row: PendingRow): string;
806
+ protected savedText(row: PendingRow): string;
807
+ protected retry(row: PendingRow): Promise<void>;
808
+ protected syncAll(): Promise<void>;
809
+ protected discard(row: PendingRow): Promise<void>;
810
+ protected discardAll(): Promise<void>;
811
+ /** A rejected service call must not become an unhandled rejection; tell the user politely (nothing was lost). */
812
+ private announceFailure;
813
+ /** The row the user acted on is gone: move focus to the row now at that position (or the previous one, or the empty message). */
814
+ private focusAfterRemoval;
815
+ static ɵfac: i0.ɵɵFactoryDeclaration<PendingChangesDrawerComponent, never>;
816
+ static ɵcmp: i0.ɵɵComponentDeclaration<PendingChangesDrawerComponent, "ui-pending-changes-drawer", never, {
817
+ "open": {
818
+ "alias": "open";
819
+ "required": false;
820
+ "isSignal": true;
821
+ };
822
+ }, {
823
+ "closed": "closed";
824
+ }, never, never, true, never>;
825
+ }
826
+ /**
827
+ * "Showing saved data from 5 minutes ago [Refresh]" while `StaleDataService.staleAt()` is set (a GET was answered from the
828
+ * device copy). Put it at the top of the page area that shows the data; the app reloads the data on `(refresh)`:
829
+ *
830
+ * <ui-stale-data-chip (refresh)="reload()" />
831
+ *
832
+ * A polite live region exists from the first render (visually hidden) and mirrors the sentence. Test ids: stale-data-chip,
833
+ * stale-data-chip-live, stale-data-chip-time, stale-data-chip-refresh.
834
+ */
835
+ export declare class StaleDataChipComponent {
836
+ protected readonly labels: UiLabelsService;
837
+ private readonly stale;
838
+ private readonly doc;
839
+ /** The user pressed Refresh - reload the data from the server. */
840
+ readonly refresh: import("@angular/core").OutputEmitterRef<void>;
841
+ private readonly now;
842
+ protected readonly parts: import("@angular/core").Signal<{
843
+ before: string;
844
+ after: string;
845
+ relative: string;
846
+ iso: string;
847
+ absolute: string;
848
+ } | null>;
849
+ protected readonly announcement: import("@angular/core").Signal<string>;
850
+ constructor();
851
+ static ɵfac: i0.ɵɵFactoryDeclaration<StaleDataChipComponent, never>;
852
+ static ɵcmp: i0.ɵɵComponentDeclaration<StaleDataChipComponent, "ui-stale-data-chip", never, {}, {
853
+ "refresh": "refresh";
854
+ }, never, never, true, never>;
855
+ }
856
+ /**
857
+ * "Still working… please don't tap again." for a button or panel whose request has been running for a while.
858
+ *
859
+ * <ui-still-working [active]="saving()" />
860
+ *
861
+ * The host keeps its own button disabled while `active`. The text appears only after `delayMs` (default 4 s) of continuous
862
+ * `active`, disappears at once when `active` turns false, and sits in a polite live region that exists from the first render
863
+ * (so screen readers announce the text when it appears). Test id: still-working.
864
+ */
865
+ export declare class StillWorkingComponent {
866
+ private readonly labels;
867
+ /** True while the request runs. */
868
+ readonly active: import("@angular/core").InputSignal<boolean>;
869
+ /** Milliseconds of continuous `active` before the text appears. */
870
+ readonly delayMs: import("@angular/core").InputSignal<number>;
871
+ private readonly elapsed;
872
+ protected readonly text: import("@angular/core").Signal<string>;
873
+ constructor();
874
+ static ɵfac: i0.ɵɵFactoryDeclaration<StillWorkingComponent, never>;
875
+ static ɵcmp: i0.ɵɵComponentDeclaration<StillWorkingComponent, "ui-still-working", never, {
876
+ "active": {
877
+ "alias": "active";
878
+ "required": false;
879
+ "isSignal": true;
880
+ };
881
+ "delayMs": {
882
+ "alias": "delayMs";
883
+ "required": false;
884
+ "isSignal": true;
885
+ };
886
+ }, {}, never, never, true, never>;
887
+ }