@sweidos/eidos 2.2.0 → 2.3.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"sw-bridge.js","names":[],"sources":["../src/sw-bridge.ts"],"sourcesContent":["import { useEidosStore } from './store';\n\nlet _registration: ServiceWorkerRegistration | null = null;\n// Messages sent before the SW activates are buffered here and flushed once\n// the SW is ready. Covers resource registrations, cache clears, offline\n// simulation — anything sent at module scope before EidosProvider mounts.\nlet _pendingMessages: Record<string, unknown>[] = [];\n\nexport function getSwRegistration() {\n return _registration;\n}\n\nexport async function registerServiceWorker(swPath: string): Promise<void> {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n useEidosStore.getState().setSwStatus('unsupported');\n return;\n }\n\n const store = useEidosStore.getState();\n store.setSwStatus('registering');\n\n try {\n _registration = await navigator.serviceWorker.register(swPath, { scope: '/' });\n\n await waitForActivation(_registration);\n\n store.setSwStatus('active');\n\n // Receive messages from SW\n navigator.serviceWorker.addEventListener('message', onSwMessage);\n\n // Track online/offline\n window.addEventListener('online', () => store.setOnline(true));\n window.addEventListener('offline', () => store.setOnline(false));\n\n flushPendingMessages();\n } catch (err) {\n store.setSwStatus('error', String(err));\n }\n}\n\nfunction waitForActivation(reg: ServiceWorkerRegistration): Promise<void> {\n return new Promise((resolve) => {\n if (reg.active) {\n resolve();\n return;\n }\n const sw = reg.installing ?? reg.waiting;\n if (!sw) {\n resolve();\n return;\n }\n\n // Resolve after 10s regardless — another tab may be blocking activation\n const timer = setTimeout(resolve, 10_000);\n\n sw.addEventListener('statechange', function handler() {\n if (sw.state === 'activated') {\n clearTimeout(timer);\n sw.removeEventListener('statechange', handler);\n resolve();\n }\n });\n });\n}\n\nexport function sendToWorker(message: Record<string, unknown>): void {\n const sw = _registration?.active;\n if (sw) {\n sw.postMessage(message);\n } else {\n _pendingMessages.push(message);\n }\n}\n\nlet _bgSyncHandler: (() => void) | null = null;\n\nexport function registerBgSyncHandler(fn: () => void): void {\n _bgSyncHandler = fn;\n}\n\nexport function isBgSyncSupported(): boolean {\n try {\n return (\n typeof navigator !== 'undefined' &&\n 'serviceWorker' in navigator &&\n _registration !== null &&\n 'sync' in _registration\n );\n } catch {\n return false;\n }\n}\n\ninterface PushHandlers {\n onNotificationClick?: (data: unknown) => void;\n onSubscriptionExpired?: (sub: PushSubscriptionJSON) => void;\n}\n\nlet _pushHandlers: PushHandlers = {};\n\nexport function registerPushCallbacks(handlers: PushHandlers): void {\n _pushHandlers = handlers;\n}\n\nfunction onSwMessage(event: MessageEvent): void {\n const data = event.data as {\n type: string;\n url?: string;\n strategy?: string;\n data?: unknown;\n subscription?: unknown;\n };\n if (!data?.type) return;\n\n const store = useEidosStore.getState();\n const { type, url } = data;\n\n if (type === 'EIDOS_BACKGROUND_SYNC') {\n _bgSyncHandler?.();\n return;\n }\n\n if (type === 'EIDOS_NOTIFICATION_CLICK') {\n _pushHandlers.onNotificationClick?.(data.data);\n return;\n }\n\n if (type === 'EIDOS_SUBSCRIPTION_EXPIRED') {\n _pushHandlers.onSubscriptionExpired?.(data.subscription as PushSubscriptionJSON);\n return;\n }\n\n if (!url) return;\n\n switch (type) {\n case 'EIDOS_CACHE_HIT': {\n const current = store.resources[url];\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-hit',\n cacheHits: (current?.cacheHits ?? 0) + 1,\n });\n break;\n }\n case 'EIDOS_CACHE_UPDATED': {\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-updated',\n cachedAt: Date.now(),\n });\n break;\n }\n case 'EIDOS_NETWORK_ERROR': {\n store.updateResource(url, {\n status: 'error',\n lastEvent: 'network-error',\n });\n break;\n }\n }\n}\n\nexport function setOfflineSimulation(enabled: boolean): void {\n sendToWorker({ type: 'EIDOS_SIMULATE_OFFLINE', enabled });\n useEidosStore.getState().setOnline(!enabled);\n}\n\nfunction flushPendingMessages(): void {\n const sw = _registration?.active;\n if (!sw) return;\n for (const msg of _pendingMessages) sw.postMessage(msg);\n _pendingMessages = [];\n}\n\n/** Test-only: resets module-level state between test cases. */\nexport function _resetSwBridgeForTests(): void {\n _registration = null;\n _pendingMessages = [];\n _bgSyncHandler = null;\n _pushHandlers = {};\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sw-bridge.js","names":[],"sources":["../src/sw-bridge.ts"],"sourcesContent":["import { useEidosStore } from './store';\n\nlet _registration: ServiceWorkerRegistration | null = null;\n// Messages sent before the SW activates are buffered here and flushed once\n// the SW is ready. Covers resource registrations, cache clears, offline\n// simulation — anything sent at module scope before EidosProvider mounts.\nlet _pendingMessages: Record<string, unknown>[] = [];\n\nexport function getSwRegistration() {\n return _registration;\n}\n\ninterface SwRegistrationOptions {\n skipWaiting: boolean;\n onUpdateAvailable?: (registration: ServiceWorkerRegistration) => void;\n}\n\nexport async function registerServiceWorker(\n swPath: string,\n options: SwRegistrationOptions = { skipWaiting: true },\n): Promise<void> {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n useEidosStore.getState().setSwStatus('unsupported');\n if (import.meta.env.DEV) {\n console.warn(\n '[eidos] Service workers are not supported in this context. ' +\n 'Offline support and SW-side caching are disabled. ' +\n 'Service workers require a modern browser with a secure context (HTTPS or localhost).',\n );\n }\n return;\n }\n\n // Warn early when the browser will reject registration regardless — saves a round-trip\n // and gives a clearer message than the browser's generic SecurityError.\n if (import.meta.env.DEV && typeof window !== 'undefined' && !window.isSecureContext) {\n console.warn(\n `[eidos] Service workers require a secure context (HTTPS or localhost). ` +\n `initEidos() was called on \"${window.location.origin}\" — ` +\n `the browser will silently disable offline support. ` +\n `Switch to localhost for development or deploy to HTTPS.`,\n );\n }\n\n const store = useEidosStore.getState();\n store.setSwStatus('registering');\n\n try {\n _registration = await navigator.serviceWorker.register(swPath, { scope: '/' });\n\n await waitForActivation(_registration);\n\n store.setSwStatus('active');\n\n // Receive messages from SW\n navigator.serviceWorker.addEventListener('message', onSwMessage);\n\n // Track online/offline\n window.addEventListener('online', () => store.setOnline(true));\n window.addEventListener('offline', () => store.setOnline(false));\n\n flushPendingMessages();\n\n // Handle SW updates — the new SW waits for EIDOS_SKIP_WAITING from the page.\n _watchForUpdate(_registration, options);\n } catch (err) {\n store.setSwStatus('error', String(err));\n if (import.meta.env.DEV) {\n const errMsg = String(err).toLowerCase();\n const isNotFound =\n errMsg.includes('404') ||\n errMsg.includes('bad http response') ||\n errMsg.includes('not found') ||\n errMsg.includes('failed to load');\n if (isNotFound) {\n console.warn(\n `[eidos] Service worker file not found at \"${swPath}\". ` +\n `Did you add the eidos() plugin to your vite.config.ts? ` +\n `If you're not using Vite, copy the file manually: ` +\n `node_modules/@sweidos/eidos/dist/eidos-sw.js → public/eidos-sw.js`,\n );\n } else {\n console.warn(`[eidos] Service worker registration failed: ${err}`);\n }\n }\n }\n}\n\nfunction waitForActivation(reg: ServiceWorkerRegistration): Promise<void> {\n return new Promise((resolve) => {\n if (reg.active) {\n resolve();\n return;\n }\n const sw = reg.installing ?? reg.waiting;\n if (!sw) {\n resolve();\n return;\n }\n\n // Resolve after 10s regardless — another tab may be blocking activation\n const timer = setTimeout(resolve, 10_000);\n\n sw.addEventListener('statechange', function handler() {\n if (sw.state === 'activated') {\n clearTimeout(timer);\n sw.removeEventListener('statechange', handler);\n resolve();\n }\n });\n });\n}\n\nexport function sendToWorker(message: Record<string, unknown>): void {\n const sw = _registration?.active;\n if (sw) {\n sw.postMessage(message);\n } else {\n _pendingMessages.push(message);\n }\n}\n\nlet _bgSyncHandler: (() => void) | null = null;\n\nexport function registerBgSyncHandler(fn: () => void): void {\n _bgSyncHandler = fn;\n}\n\nexport function isBgSyncSupported(): boolean {\n try {\n return (\n typeof navigator !== 'undefined' &&\n 'serviceWorker' in navigator &&\n _registration !== null &&\n 'sync' in _registration\n );\n } catch {\n return false;\n }\n}\n\ninterface PushHandlers {\n onNotificationClick?: (data: unknown) => void;\n onSubscriptionExpired?: (sub: PushSubscriptionJSON) => void;\n}\n\nlet _pushHandlers: PushHandlers = {};\n\nexport function registerPushCallbacks(handlers: PushHandlers): void {\n _pushHandlers = handlers;\n}\n\nfunction onSwMessage(event: MessageEvent): void {\n const data = event.data as {\n type: string;\n url?: string;\n strategy?: string;\n data?: unknown;\n subscription?: unknown;\n };\n if (!data?.type) return;\n\n const store = useEidosStore.getState();\n const { type, url } = data;\n\n if (type === 'EIDOS_BACKGROUND_SYNC') {\n _bgSyncHandler?.();\n return;\n }\n\n if (type === 'EIDOS_NOTIFICATION_CLICK') {\n _pushHandlers.onNotificationClick?.(data.data);\n return;\n }\n\n if (type === 'EIDOS_SUBSCRIPTION_EXPIRED') {\n _pushHandlers.onSubscriptionExpired?.(data.subscription as PushSubscriptionJSON);\n return;\n }\n\n if (!url) return;\n\n switch (type) {\n case 'EIDOS_CACHE_HIT': {\n const current = store.resources[url];\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-hit',\n cacheHits: (current?.cacheHits ?? 0) + 1,\n });\n break;\n }\n case 'EIDOS_CACHE_UPDATED': {\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-updated',\n cachedAt: Date.now(),\n });\n break;\n }\n case 'EIDOS_NETWORK_ERROR': {\n store.updateResource(url, {\n status: 'error',\n lastEvent: 'network-error',\n });\n break;\n }\n }\n}\n\nexport function setOfflineSimulation(enabled: boolean): void {\n sendToWorker({ type: 'EIDOS_SIMULATE_OFFLINE', enabled });\n useEidosStore.getState().setOnline(!enabled);\n}\n\nfunction flushPendingMessages(): void {\n const sw = _registration?.active;\n if (!sw) return;\n for (const msg of _pendingMessages) sw.postMessage(msg);\n _pendingMessages = [];\n}\n\nfunction _watchForUpdate(reg: ServiceWorkerRegistration, options: SwRegistrationOptions): void {\n const notify = (r: ServiceWorkerRegistration) => {\n if (options.skipWaiting) {\n r.waiting?.postMessage({ type: 'EIDOS_SKIP_WAITING' });\n } else {\n options.onUpdateAvailable?.(r);\n }\n };\n\n // A SW may already be waiting on startup (installed across a previous page load\n // but blocked because another tab held the old SW active).\n if (reg.waiting && navigator.serviceWorker.controller) {\n notify(reg);\n }\n\n reg.addEventListener('updatefound', () => {\n const newSw = reg.installing;\n if (!newSw) return;\n newSw.addEventListener('statechange', () => {\n if (newSw.state === 'installed' && navigator.serviceWorker.controller) {\n notify(reg);\n }\n });\n });\n}\n\n/**\n * Tells the waiting service worker to activate immediately, then reloads the page.\n * Only relevant when `skipWaiting: false` — call this after the user confirms\n * a \"reload to update\" toast shown via `onUpdateAvailable`.\n */\nexport function triggerSwUpdate(): void {\n _registration?.waiting?.postMessage({ type: 'EIDOS_SKIP_WAITING' });\n}\n\n/** Test-only: resets module-level state between test cases. */\nexport function _resetSwBridgeForTests(): void {\n _registration = null;\n _pendingMessages = [];\n _bgSyncHandler = null;\n _pushHandlers = {};\n}\n"],"mappings":"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"}
package/dist/testing.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { EidosState, ReplayResult } from '@sweidos/eidos';
2
-
1
+ import { EidosState, ReplayResult } from './index.ts';
3
2
  export interface MockOfflineOptions {
4
3
  /**
5
4
  * Also stub `globalThis.fetch` to throw a TypeError (simulates a hard
@@ -0,0 +1,311 @@
1
+ export type CacheStrategy = 'cache-first' | 'stale-while-revalidate' | 'network-first';
2
+ export interface ResourceConfig {
3
+ /** Make this resource available when the device is offline. */
4
+ offline: boolean;
5
+ /** Override the auto-selected caching strategy. */
6
+ strategy?: CacheStrategy;
7
+ /** Custom cache bucket name. Defaults to 'eidos-resources-v1'. */
8
+ cacheName?: string;
9
+ /**
10
+ * Cache schema version. Bump when the response shape changes so old
11
+ * cached entries (a different shape) aren't served from a stale bucket.
12
+ * Appended to `cacheName` as a suffix (e.g. `eidos-resources-v1-v2`).
13
+ * Old-versioned buckets are left in Cache Storage — clear them via
14
+ * `caches.delete()` if needed.
15
+ */
16
+ version?: string | number;
17
+ /** Max age of cached response in milliseconds. Expired entries trigger a network fetch. */
18
+ maxAge?: number;
19
+ /**
20
+ * Maximum number of entries to keep in this resource's cache bucket.
21
+ * When the limit is exceeded after a `cache.put()`, the oldest entry is evicted (FIFO).
22
+ * Useful for list/image endpoints that grow unbounded.
23
+ */
24
+ maxEntries?: number;
25
+ /**
26
+ * How long (ms) to wait for a network response before falling back to the cache.
27
+ * Applies to `network-first` SW interception and the SWR background revalidation fetch.
28
+ * Default: 3000.
29
+ */
30
+ networkTimeoutMs?: number;
31
+ }
32
+ export interface GeneratedStrategy {
33
+ name: string;
34
+ swStrategy: CacheStrategy;
35
+ cacheName: string;
36
+ /** One-line rationale for why this strategy was chosen. */
37
+ reasoning: string;
38
+ /** Human-readable description of each behavioral step. */
39
+ behavior: string[];
40
+ /** Pseudocode showing the equivalent Workbox config. */
41
+ equivalentCode: string;
42
+ }
43
+ export interface ResourceEntry {
44
+ url: string;
45
+ config: ResourceConfig;
46
+ strategy: GeneratedStrategy;
47
+ status: 'idle' | 'fetching' | 'fresh' | 'stale' | 'error' | 'offline';
48
+ cachedAt?: number;
49
+ fetchedAt?: number;
50
+ cacheHits: number;
51
+ cacheMisses: number;
52
+ lastEvent?: 'cache-hit' | 'cache-updated' | 'network-error' | 'cache-cleared';
53
+ }
54
+ export interface ResourceHandle<T = unknown> {
55
+ readonly url: string;
56
+ readonly config: ResourceConfig;
57
+ readonly strategy: GeneratedStrategy;
58
+ fetch: () => Promise<Response>;
59
+ json: () => Promise<T>;
60
+ /** Returns a TanStack Query-compatible options object. */
61
+ query: () => {
62
+ queryKey: [string, string];
63
+ queryFn: () => Promise<T>;
64
+ };
65
+ prefetch: () => Promise<void>;
66
+ invalidate: () => Promise<void>;
67
+ /** Remove from registry and SW. Required before re-registering the same URL with different config. */
68
+ unregister: () => void;
69
+ }
70
+ /**
71
+ * Handle for a URL pattern (`/api/products/*`, `/api/users/:id`, `**`).
72
+ * The SW intercepts all matching requests automatically — there is no single
73
+ * URL to fetch/cache directly, so only cache-management methods are exposed.
74
+ * Returned by `resourcePattern()`.
75
+ */
76
+ export interface PatternResourceHandle {
77
+ readonly url: string;
78
+ readonly config: ResourceConfig;
79
+ readonly strategy: GeneratedStrategy;
80
+ /** Clears all cache entries matching this pattern. */
81
+ invalidate: () => Promise<void>;
82
+ /** Remove from registry and SW. Required before re-registering the same pattern with different config. */
83
+ unregister: () => void;
84
+ }
85
+ /** A handle returned by either `resource()` or `resourcePattern()`. */
86
+ export type AnyResourceHandle = ResourceHandle<any> | PatternResourceHandle;
87
+ /** Summary returned by warmCache(). */
88
+ export interface WarmCacheResult {
89
+ /** Resources that were prefetched successfully. */
90
+ warmed: number;
91
+ /** Resources whose prefetch threw (network error, offline, etc.). */
92
+ failed: number;
93
+ /** The raw errors, in input order, for failed handles. */
94
+ errors: unknown[];
95
+ }
96
+ interface ActionConfigBase<TArgs extends any[] = any[]> {
97
+ /** Max retry attempts before marking as failed. Default: 3. */
98
+ maxRetries?: number;
99
+ /** Human-readable name for the action (used in devtools). */
100
+ name?: string;
101
+ /**
102
+ * Prefixes the registered action id (`namespace::name`). Use to avoid
103
+ * collisions when two actions share a name (e.g. across micro-frontends,
104
+ * or two `createOrder` actions in different modules) — without a
105
+ * namespace, the second registration silently overwrites the first.
106
+ */
107
+ namespace?: string;
108
+ /**
109
+ * Replay order when multiple queued actions are pending.
110
+ * `'high'` items replay before `'normal'`, which replay before `'low'`.
111
+ * Each tier completes fully before the next tier begins.
112
+ * Default: `'normal'`.
113
+ */
114
+ priority?: 'high' | 'normal' | 'low';
115
+ /**
116
+ * Called immediately before the async function executes, with the same args
117
+ * plus a trailing `ActionContext`. Use to apply an optimistic UI update (add
118
+ * item, mark as pending, etc.) and to capture `idempotencyKey` for later
119
+ * `handle.cancel(idempotencyKey)` calls. Called on every invocation —
120
+ * online, offline, and during queue replay.
121
+ */
122
+ onOptimistic?: (...args: [...TArgs, ActionContext]) => void;
123
+ /**
124
+ * Called when the action permanently fails and will not be retried.
125
+ * - `best-effort`: called on first throw.
126
+ * - `neverLose`: called when `maxRetries` is exhausted (status → `'failed'`).
127
+ * Use to revert the optimistic update.
128
+ */
129
+ onRollback?: (...args: [...TArgs, ActionContext]) => void;
130
+ /**
131
+ * Declarative conflict-resolution strategy used during queue replay when
132
+ * the server responds with a 4xx status (conflict, gone, unprocessable,
133
+ * etc.). If not provided, 4xx errors are treated identically to other
134
+ * errors (retried until `maxRetries` is exhausted, then `onRollback` is
135
+ * called). See `ConflictConfig`.
136
+ */
137
+ conflict?: ConflictConfig;
138
+ /**
139
+ * When `true`, each invocation gets an `AbortController` whose `signal` is
140
+ * passed via `ActionContext.signal`. Forward it to `fetch`/etc. so
141
+ * `handle.cancel(idempotencyKey)` can abort an in-flight call, or remove a
142
+ * not-yet-replayed queued item.
143
+ */
144
+ cancellable?: boolean;
145
+ }
146
+ export type ActionConfig<TArgs extends any[] = any[]> = (ActionConfigBase<TArgs> & {
147
+ /** Call directly, no persistence on failure. */
148
+ reliability: 'best-effort';
149
+ }) | (ActionConfigBase<TArgs> & {
150
+ /** Persist to IndexedDB before executing; replay on reconnect. */
151
+ reliability: 'neverLose';
152
+ /**
153
+ * Required for `neverLose` — queued items must survive a page reload
154
+ * and be matched back to this action on replay. `fn.name` is not
155
+ * reliable (minifiers rename it, arrow functions may be anonymous).
156
+ */
157
+ name: string;
158
+ });
159
+ /**
160
+ * Passed to `ConflictConfig.resolve` (for `'merge'`/`'custom'` strategies)
161
+ * when a queued action's replay receives a 4xx response.
162
+ */
163
+ export interface ConflictContext {
164
+ /** Whatever `fn` threw — typically a `Response` or an error with `.status`. */
165
+ error: unknown;
166
+ /** The original arguments the action was queued with. */
167
+ args: any[];
168
+ /** Number of replay attempts so far (0 on first replay). */
169
+ attempt: number;
170
+ idempotencyKey: string;
171
+ }
172
+ /**
173
+ * Outcome of `ConflictConfig.resolve`:
174
+ * - `'retry'`: keep the item queued, retry per normal backoff.
175
+ * - `'skip'`: silently remove the item (no `onRollback`).
176
+ * - `{ resolved: args }`: replace the queued args and retry immediately
177
+ * on the next replay pass.
178
+ */
179
+ export type ConflictResolution = 'retry' | 'skip' | {
180
+ resolved: any[];
181
+ };
182
+ export interface ConflictConfig {
183
+ /**
184
+ * - `'serverWins'`: drop the queued item, keeping the server's state.
185
+ * - `'clientWins'`: keep retrying — the client's write should eventually
186
+ * be accepted (e.g. once the server-side conflict is cleared).
187
+ * - `'merge'` / `'custom'`: call `resolve` to decide.
188
+ */
189
+ strategy: 'serverWins' | 'clientWins' | 'merge' | 'custom';
190
+ /** Required for `'merge'` and `'custom'`. */
191
+ resolve?: (ctx: ConflictContext) => ConflictResolution;
192
+ }
193
+ /** Bump when ActionQueueItem's shape changes. Used to migrate items persisted by older versions. */
194
+ export declare const CURRENT_QUEUE_SCHEMA_VERSION = 2;
195
+ export interface ActionQueueItem {
196
+ /** Shape version this item was persisted with. Items from before v2 are migrated on load. */
197
+ schemaVersion: number;
198
+ id: string;
199
+ /** ID of the registered action (maps to the function in the registry). */
200
+ actionId: string;
201
+ actionName: string;
202
+ /**
203
+ * Stable per-invocation key, generated once and reused across every retry/replay
204
+ * of this item. Pass to your server as an idempotency key so retries that reach
205
+ * the server after a dropped response don't double-execute.
206
+ */
207
+ idempotencyKey: string;
208
+ args: unknown[];
209
+ queuedAt: number;
210
+ retryCount: number;
211
+ maxRetries: number;
212
+ status: 'pending' | 'replaying' | 'succeeded' | 'failed';
213
+ /** Replay priority. High items replay before normal, normal before low. Default: 'normal'. */
214
+ priority?: 'high' | 'normal' | 'low';
215
+ error?: string;
216
+ completedAt?: number;
217
+ /** Earliest timestamp at which this item should be retried (exponential backoff). */
218
+ nextRetryAt?: number;
219
+ }
220
+ export interface QueuedResult {
221
+ readonly queued: true;
222
+ readonly id: string;
223
+ readonly message: string;
224
+ }
225
+ /** Summary returned by replayQueue(). */
226
+ export interface ReplayResult {
227
+ /** Items where the registered function was found and called. */
228
+ attempted: number;
229
+ /** Items that resolved successfully. */
230
+ succeeded: number;
231
+ /** Items that failed and have no retries remaining (status: 'failed'). */
232
+ failed: number;
233
+ /** Items that failed but will be retried later (nextRetryAt set). */
234
+ retrying: number;
235
+ /** Items whose actionId had no registered function — likely not yet imported. */
236
+ skipped: number;
237
+ /** Items that received a 4xx response and were dropped via `conflict: { strategy: 'serverWins' }` (or `resolve()` returning `'skip'`). */
238
+ conflicted: number;
239
+ /** Items removed via `handle.cancel(idempotencyKey)` before/during replay. */
240
+ cancelled: number;
241
+ }
242
+ /**
243
+ * Passed as an extra argument after the declared params to `neverLose` actions,
244
+ * on every invocation (initial call, offline queue, and replay). The same
245
+ * `idempotencyKey` is reused across all retries of one logical invocation —
246
+ * forward it to your server (e.g. as an `Idempotency-Key` header) so a retry
247
+ * that reaches the server after a dropped response doesn't double-execute.
248
+ */
249
+ export interface ActionContext {
250
+ idempotencyKey: string;
251
+ /** 0 on the first attempt, incremented on each replay retry. */
252
+ attempt: number;
253
+ /** Set when `config.cancellable` is true. Forward to `fetch`/etc. for cancellation support. */
254
+ signal?: AbortSignal;
255
+ }
256
+ /**
257
+ * Every action function receives its declared args plus a trailing
258
+ * `ActionContext` — on every invocation (online, offline, and replay).
259
+ */
260
+ export type ActionFn<TArgs extends unknown[], TReturn> = (...args: [...TArgs, ActionContext]) => Promise<TReturn>;
261
+ export interface ActionHandle<TArgs extends any[], TReturn> {
262
+ (...args: TArgs): Promise<TReturn | QueuedResult>;
263
+ readonly id: string;
264
+ readonly config: ActionConfig;
265
+ /**
266
+ * Cancel an invocation by its `idempotencyKey` (from `ActionContext` /
267
+ * `onOptimistic`). Aborts the in-flight call if `cancellable: true` and
268
+ * still running, otherwise removes a not-yet-replayed queued item.
269
+ * Returns `true` if something was cancelled/removed.
270
+ */
271
+ cancel: (idempotencyKey: string) => Promise<boolean>;
272
+ }
273
+ export interface EidosState {
274
+ isOnline: boolean;
275
+ swStatus: 'idle' | 'registering' | 'active' | 'error' | 'unsupported';
276
+ swError?: string;
277
+ resources: Record<string, ResourceEntry>;
278
+ queue: ActionQueueItem[];
279
+ reliability: ReliabilityStats;
280
+ }
281
+ /**
282
+ * Cumulative, session-scoped counters for `neverLose` queue outcomes — opt-in
283
+ * telemetry surfaced via `eidosReliabilityStats` / `useEidosReliabilityStats()`
284
+ * and `EidosConfig.onReliabilityReport`. Reset on page reload (not persisted).
285
+ */
286
+ export interface ReliabilityStats {
287
+ [key: string]: number;
288
+ /** Actions persisted to the queue (offline, or online call that threw). */
289
+ queued: number;
290
+ /** Queue items that executed successfully (first attempt or a retry). */
291
+ succeeded: number;
292
+ /** Queue items that exhausted `maxRetries` and moved to `'failed'`. */
293
+ failed: number;
294
+ /** Replay attempts that failed but will retry (haven't exhausted `maxRetries`). */
295
+ retried: number;
296
+ /** Queue items dropped by a `serverWins`/`merge`/`custom` conflict resolution. */
297
+ conflicted: number;
298
+ /** Queue items removed via `handle.cancel(idempotencyKey)` before replay completed. */
299
+ cancelled: number;
300
+ }
301
+ export declare function emptyReliabilityStats(): ReliabilityStats;
302
+ export interface QueueStatusCounts {
303
+ [key: string]: number;
304
+ pending: number;
305
+ failed: number;
306
+ replaying: number;
307
+ total: number;
308
+ }
309
+ /** Single pass over the queue — avoids separate .filter() calls per status. */
310
+ export declare function countQueueByStatus(queue: ActionQueueItem[]): QueueStatusCounts;
311
+ export {};
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["// ─────────────────────────────────────────────────────────────────────────────\n// Eidos Core Types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type CacheStrategy = 'cache-first' | 'stale-while-revalidate' | 'network-first';\n\n// ── Resource ─────────────────────────────────────────────────────────────────\n\nexport interface ResourceConfig {\n /** Make this resource available when the device is offline. */\n offline: boolean;\n /** Override the auto-selected caching strategy. */\n strategy?: CacheStrategy;\n /** Custom cache bucket name. Defaults to 'eidos-resources-v1'. */\n cacheName?: string;\n /**\n * Cache schema version. Bump when the response shape changes so old\n * cached entries (a different shape) aren't served from a stale bucket.\n * Appended to `cacheName` as a suffix (e.g. `eidos-resources-v1-v2`).\n * Old-versioned buckets are left in Cache Storage — clear them via\n * `caches.delete()` if needed.\n */\n version?: string | number;\n /** Max age of cached response in milliseconds. Expired entries trigger a network fetch. */\n maxAge?: number;\n}\n\nexport interface GeneratedStrategy {\n name: string;\n swStrategy: CacheStrategy;\n cacheName: string;\n /** One-line rationale for why this strategy was chosen. */\n reasoning: string;\n /** Human-readable description of each behavioral step. */\n behavior: string[];\n /** Pseudocode showing the equivalent Workbox config. */\n equivalentCode: string;\n}\n\nexport interface ResourceEntry {\n url: string;\n config: ResourceConfig;\n strategy: GeneratedStrategy;\n status: 'idle' | 'fetching' | 'fresh' | 'stale' | 'error' | 'offline';\n cachedAt?: number;\n fetchedAt?: number;\n cacheHits: number;\n cacheMisses: number;\n lastEvent?: 'cache-hit' | 'cache-updated' | 'network-error' | 'cache-cleared';\n}\n\nexport interface ResourceHandle<T = unknown> {\n readonly url: string;\n readonly config: ResourceConfig;\n readonly strategy: GeneratedStrategy;\n fetch: () => Promise<Response>;\n json: () => Promise<T>;\n /** Returns a TanStack Query-compatible options object. */\n query: () => { queryKey: [string, string]; queryFn: () => Promise<T> };\n prefetch: () => Promise<void>;\n invalidate: () => Promise<void>;\n /** Remove from registry and SW. Required before re-registering the same URL with different config. */\n unregister: () => void;\n}\n\n/**\n * Handle for a URL pattern (`/api/products/*`, `/api/users/:id`, `**`).\n * The SW intercepts all matching requests automatically — there is no single\n * URL to fetch/cache directly, so only cache-management methods are exposed.\n * Returned by `resourcePattern()`.\n */\nexport interface PatternResourceHandle {\n readonly url: string;\n readonly config: ResourceConfig;\n readonly strategy: GeneratedStrategy;\n /** Clears all cache entries matching this pattern. */\n invalidate: () => Promise<void>;\n /** Remove from registry and SW. Required before re-registering the same pattern with different config. */\n unregister: () => void;\n}\n\n/** A handle returned by either `resource()` or `resourcePattern()`. */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type AnyResourceHandle = ResourceHandle<any> | PatternResourceHandle;\n\n/** Summary returned by warmCache(). */\nexport interface WarmCacheResult {\n /** Resources that were prefetched successfully. */\n warmed: number;\n /** Resources whose prefetch threw (network error, offline, etc.). */\n failed: number;\n /** The raw errors, in input order, for failed handles. */\n errors: unknown[];\n}\n\n// ── Action ───────────────────────────────────────────────────────────────────\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ninterface ActionConfigBase<TArgs extends any[] = any[]> {\n /** Max retry attempts before marking as failed. Default: 3. */\n maxRetries?: number;\n /** Human-readable name for the action (used in devtools). */\n name?: string;\n /**\n * Prefixes the registered action id (`namespace::name`). Use to avoid\n * collisions when two actions share a name (e.g. across micro-frontends,\n * or two `createOrder` actions in different modules) — without a\n * namespace, the second registration silently overwrites the first.\n */\n namespace?: string;\n /**\n * Replay order when multiple queued actions are pending.\n * `'high'` items replay before `'normal'`, which replay before `'low'`.\n * Each tier completes fully before the next tier begins.\n * Default: `'normal'`.\n */\n priority?: 'high' | 'normal' | 'low';\n /**\n * Called immediately before the async function executes, with the same args\n * plus a trailing `ActionContext`. Use to apply an optimistic UI update (add\n * item, mark as pending, etc.) and to capture `idempotencyKey` for later\n * `handle.cancel(idempotencyKey)` calls. Called on every invocation —\n * online, offline, and during queue replay.\n */\n onOptimistic?: (...args: [...TArgs, ActionContext]) => void;\n /**\n * Called when the action permanently fails and will not be retried.\n * - `best-effort`: called on first throw.\n * - `neverLose`: called when `maxRetries` is exhausted (status → `'failed'`).\n * Use to revert the optimistic update.\n */\n onRollback?: (...args: [...TArgs, ActionContext]) => void;\n /**\n * Declarative conflict-resolution strategy used during queue replay when\n * the server responds with a 4xx status (conflict, gone, unprocessable,\n * etc.). If not provided, 4xx errors are treated identically to other\n * errors (retried until `maxRetries` is exhausted, then `onRollback` is\n * called). See `ConflictConfig`.\n */\n conflict?: ConflictConfig;\n /**\n * When `true`, each invocation gets an `AbortController` whose `signal` is\n * passed via `ActionContext.signal`. Forward it to `fetch`/etc. so\n * `handle.cancel(idempotencyKey)` can abort an in-flight call, or remove a\n * not-yet-replayed queued item.\n */\n cancellable?: boolean;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ActionConfig<TArgs extends any[] = any[]> =\n | (ActionConfigBase<TArgs> & {\n /** Call directly, no persistence on failure. */\n reliability: 'best-effort';\n })\n | (ActionConfigBase<TArgs> & {\n /** Persist to IndexedDB before executing; replay on reconnect. */\n reliability: 'neverLose';\n /**\n * Required for `neverLose` — queued items must survive a page reload\n * and be matched back to this action on replay. `fn.name` is not\n * reliable (minifiers rename it, arrow functions may be anonymous).\n */\n name: string;\n });\n\n/**\n * Passed to `ConflictConfig.resolve` (for `'merge'`/`'custom'` strategies)\n * when a queued action's replay receives a 4xx response.\n */\nexport interface ConflictContext {\n /** Whatever `fn` threw — typically a `Response` or an error with `.status`. */\n error: unknown;\n /** The original arguments the action was queued with. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n args: any[];\n /** Number of replay attempts so far (0 on first replay). */\n attempt: number;\n idempotencyKey: string;\n}\n\n/**\n * Outcome of `ConflictConfig.resolve`:\n * - `'retry'`: keep the item queued, retry per normal backoff.\n * - `'skip'`: silently remove the item (no `onRollback`).\n * - `{ resolved: args }`: replace the queued args and retry immediately\n * on the next replay pass.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ConflictResolution = 'retry' | 'skip' | { resolved: any[] };\n\nexport interface ConflictConfig {\n /**\n * - `'serverWins'`: drop the queued item, keeping the server's state.\n * - `'clientWins'`: keep retrying — the client's write should eventually\n * be accepted (e.g. once the server-side conflict is cleared).\n * - `'merge'` / `'custom'`: call `resolve` to decide.\n */\n strategy: 'serverWins' | 'clientWins' | 'merge' | 'custom';\n /** Required for `'merge'` and `'custom'`. */\n resolve?: (ctx: ConflictContext) => ConflictResolution;\n}\n\n/** Bump when ActionQueueItem's shape changes. Used to migrate items persisted by older versions. */\nexport const CURRENT_QUEUE_SCHEMA_VERSION = 2;\n\nexport interface ActionQueueItem {\n /** Shape version this item was persisted with. Items from before v2 are migrated on load. */\n schemaVersion: number;\n id: string;\n /** ID of the registered action (maps to the function in the registry). */\n actionId: string;\n actionName: string;\n /**\n * Stable per-invocation key, generated once and reused across every retry/replay\n * of this item. Pass to your server as an idempotency key so retries that reach\n * the server after a dropped response don't double-execute.\n */\n idempotencyKey: string;\n args: unknown[];\n queuedAt: number;\n retryCount: number;\n maxRetries: number;\n status: 'pending' | 'replaying' | 'succeeded' | 'failed';\n /** Replay priority. High items replay before normal, normal before low. Default: 'normal'. */\n priority?: 'high' | 'normal' | 'low';\n error?: string;\n completedAt?: number;\n /** Earliest timestamp at which this item should be retried (exponential backoff). */\n nextRetryAt?: number;\n}\n\nexport interface QueuedResult {\n readonly queued: true;\n readonly id: string;\n readonly message: string;\n}\n\n/** Summary returned by replayQueue(). */\nexport interface ReplayResult {\n /** Items where the registered function was found and called. */\n attempted: number;\n /** Items that resolved successfully. */\n succeeded: number;\n /** Items that failed and have no retries remaining (status: 'failed'). */\n failed: number;\n /** Items that failed but will be retried later (nextRetryAt set). */\n retrying: number;\n /** Items whose actionId had no registered function — likely not yet imported. */\n skipped: number;\n /** Items that received a 4xx response and were dropped via `conflict: { strategy: 'serverWins' }` (or `resolve()` returning `'skip'`). */\n conflicted: number;\n /** Items removed via `handle.cancel(idempotencyKey)` before/during replay. */\n cancelled: number;\n}\n\n/**\n * Passed as an extra argument after the declared params to `neverLose` actions,\n * on every invocation (initial call, offline queue, and replay). The same\n * `idempotencyKey` is reused across all retries of one logical invocation —\n * forward it to your server (e.g. as an `Idempotency-Key` header) so a retry\n * that reaches the server after a dropped response doesn't double-execute.\n */\nexport interface ActionContext {\n idempotencyKey: string;\n /** 0 on the first attempt, incremented on each replay retry. */\n attempt: number;\n /** Set when `config.cancellable` is true. Forward to `fetch`/etc. for cancellation support. */\n signal?: AbortSignal;\n}\n\n/**\n * Every action function receives its declared args plus a trailing\n * `ActionContext` — on every invocation (online, offline, and replay).\n */\nexport type ActionFn<TArgs extends unknown[], TReturn> = (\n ...args: [...TArgs, ActionContext]\n) => Promise<TReturn>;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface ActionHandle<TArgs extends any[], TReturn> {\n (...args: TArgs): Promise<TReturn | QueuedResult>;\n readonly id: string;\n readonly config: ActionConfig;\n /**\n * Cancel an invocation by its `idempotencyKey` (from `ActionContext` /\n * `onOptimistic`). Aborts the in-flight call if `cancellable: true` and\n * still running, otherwise removes a not-yet-replayed queued item.\n * Returns `true` if something was cancelled/removed.\n */\n cancel: (idempotencyKey: string) => Promise<boolean>;\n}\n\n// ── Global State ─────────────────────────────────────────────────────────────\n\nexport interface EidosState {\n isOnline: boolean;\n swStatus: 'idle' | 'registering' | 'active' | 'error' | 'unsupported';\n swError?: string;\n resources: Record<string, ResourceEntry>;\n queue: ActionQueueItem[];\n reliability: ReliabilityStats;\n}\n\n/**\n * Cumulative, session-scoped counters for `neverLose` queue outcomes — opt-in\n * telemetry surfaced via `eidosReliabilityStats` / `useEidosReliabilityStats()`\n * and `EidosConfig.onReliabilityReport`. Reset on page reload (not persisted).\n */\nexport interface ReliabilityStats {\n [key: string]: number;\n /** Actions persisted to the queue (offline, or online call that threw). */\n queued: number;\n /** Queue items that executed successfully (first attempt or a retry). */\n succeeded: number;\n /** Queue items that exhausted `maxRetries` and moved to `'failed'`. */\n failed: number;\n /** Replay attempts that failed but will retry (haven't exhausted `maxRetries`). */\n retried: number;\n /** Queue items dropped by a `serverWins`/`merge`/`custom` conflict resolution. */\n conflicted: number;\n /** Queue items removed via `handle.cancel(idempotencyKey)` before replay completed. */\n cancelled: number;\n}\n\nexport function emptyReliabilityStats(): ReliabilityStats {\n return { queued: 0, succeeded: 0, failed: 0, retried: 0, conflicted: 0, cancelled: 0 };\n}\n\nexport interface QueueStatusCounts {\n [key: string]: number;\n pending: number;\n failed: number;\n replaying: number;\n total: number;\n}\n\n/** Single pass over the queue — avoids separate .filter() calls per status. */\nexport function countQueueByStatus(queue: ActionQueueItem[]): QueueStatusCounts {\n let pending = 0,\n failed = 0,\n replaying = 0;\n for (const q of queue) {\n if (q.status === 'pending') pending++;\n else if (q.status === 'failed') failed++;\n else if (q.status === 'replaying') replaying++;\n }\n return { pending, failed, replaying, total: queue.length };\n}\n"],"mappings":"AAqUA,SAAgB,IAA0C;AACxD,SAAO;AAAA,IAAE,QAAQ;AAAA,IAAG,WAAW;AAAA,IAAG,QAAQ;AAAA,IAAG,SAAS;AAAA,IAAG,YAAY;AAAA,IAAG,WAAW;AAAA,EAAE;AACvF;AAWA,SAAgB,EAAmB,GAA6C;AAC9E,MAAI,IAAU,GACZ,IAAS,GACT,IAAY;AACd,aAAW,KAAK,EACd,CAAI,EAAE,WAAW,YAAW,MACnB,EAAE,WAAW,WAAU,MACvB,EAAE,WAAW,eAAa;AAErC,SAAO;AAAA,IAAE,SAAA;AAAA,IAAS,QAAA;AAAA,IAAQ,WAAA;AAAA,IAAW,OAAO,EAAM;AAAA,EAAO;AAC3D"}
1
+ {"version":3,"file":"types.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["// ─────────────────────────────────────────────────────────────────────────────\n// Eidos Core Types\n// ─────────────────────────────────────────────────────────────────────────────\n\nexport type CacheStrategy = 'cache-first' | 'stale-while-revalidate' | 'network-first';\n\n// ── Resource ─────────────────────────────────────────────────────────────────\n\nexport interface ResourceConfig {\n /** Make this resource available when the device is offline. */\n offline: boolean;\n /** Override the auto-selected caching strategy. */\n strategy?: CacheStrategy;\n /** Custom cache bucket name. Defaults to 'eidos-resources-v1'. */\n cacheName?: string;\n /**\n * Cache schema version. Bump when the response shape changes so old\n * cached entries (a different shape) aren't served from a stale bucket.\n * Appended to `cacheName` as a suffix (e.g. `eidos-resources-v1-v2`).\n * Old-versioned buckets are left in Cache Storage — clear them via\n * `caches.delete()` if needed.\n */\n version?: string | number;\n /** Max age of cached response in milliseconds. Expired entries trigger a network fetch. */\n maxAge?: number;\n /**\n * Maximum number of entries to keep in this resource's cache bucket.\n * When the limit is exceeded after a `cache.put()`, the oldest entry is evicted (FIFO).\n * Useful for list/image endpoints that grow unbounded.\n */\n maxEntries?: number;\n /**\n * How long (ms) to wait for a network response before falling back to the cache.\n * Applies to `network-first` SW interception and the SWR background revalidation fetch.\n * Default: 3000.\n */\n networkTimeoutMs?: number;\n}\n\nexport interface GeneratedStrategy {\n name: string;\n swStrategy: CacheStrategy;\n cacheName: string;\n /** One-line rationale for why this strategy was chosen. */\n reasoning: string;\n /** Human-readable description of each behavioral step. */\n behavior: string[];\n /** Pseudocode showing the equivalent Workbox config. */\n equivalentCode: string;\n}\n\nexport interface ResourceEntry {\n url: string;\n config: ResourceConfig;\n strategy: GeneratedStrategy;\n status: 'idle' | 'fetching' | 'fresh' | 'stale' | 'error' | 'offline';\n cachedAt?: number;\n fetchedAt?: number;\n cacheHits: number;\n cacheMisses: number;\n lastEvent?: 'cache-hit' | 'cache-updated' | 'network-error' | 'cache-cleared';\n}\n\nexport interface ResourceHandle<T = unknown> {\n readonly url: string;\n readonly config: ResourceConfig;\n readonly strategy: GeneratedStrategy;\n fetch: () => Promise<Response>;\n json: () => Promise<T>;\n /** Returns a TanStack Query-compatible options object. */\n query: () => { queryKey: [string, string]; queryFn: () => Promise<T> };\n prefetch: () => Promise<void>;\n invalidate: () => Promise<void>;\n /** Remove from registry and SW. Required before re-registering the same URL with different config. */\n unregister: () => void;\n}\n\n/**\n * Handle for a URL pattern (`/api/products/*`, `/api/users/:id`, `**`).\n * The SW intercepts all matching requests automatically — there is no single\n * URL to fetch/cache directly, so only cache-management methods are exposed.\n * Returned by `resourcePattern()`.\n */\nexport interface PatternResourceHandle {\n readonly url: string;\n readonly config: ResourceConfig;\n readonly strategy: GeneratedStrategy;\n /** Clears all cache entries matching this pattern. */\n invalidate: () => Promise<void>;\n /** Remove from registry and SW. Required before re-registering the same pattern with different config. */\n unregister: () => void;\n}\n\n/** A handle returned by either `resource()` or `resourcePattern()`. */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type AnyResourceHandle = ResourceHandle<any> | PatternResourceHandle;\n\n/** Summary returned by warmCache(). */\nexport interface WarmCacheResult {\n /** Resources that were prefetched successfully. */\n warmed: number;\n /** Resources whose prefetch threw (network error, offline, etc.). */\n failed: number;\n /** The raw errors, in input order, for failed handles. */\n errors: unknown[];\n}\n\n// ── Action ───────────────────────────────────────────────────────────────────\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ninterface ActionConfigBase<TArgs extends any[] = any[]> {\n /** Max retry attempts before marking as failed. Default: 3. */\n maxRetries?: number;\n /** Human-readable name for the action (used in devtools). */\n name?: string;\n /**\n * Prefixes the registered action id (`namespace::name`). Use to avoid\n * collisions when two actions share a name (e.g. across micro-frontends,\n * or two `createOrder` actions in different modules) — without a\n * namespace, the second registration silently overwrites the first.\n */\n namespace?: string;\n /**\n * Replay order when multiple queued actions are pending.\n * `'high'` items replay before `'normal'`, which replay before `'low'`.\n * Each tier completes fully before the next tier begins.\n * Default: `'normal'`.\n */\n priority?: 'high' | 'normal' | 'low';\n /**\n * Called immediately before the async function executes, with the same args\n * plus a trailing `ActionContext`. Use to apply an optimistic UI update (add\n * item, mark as pending, etc.) and to capture `idempotencyKey` for later\n * `handle.cancel(idempotencyKey)` calls. Called on every invocation —\n * online, offline, and during queue replay.\n */\n onOptimistic?: (...args: [...TArgs, ActionContext]) => void;\n /**\n * Called when the action permanently fails and will not be retried.\n * - `best-effort`: called on first throw.\n * - `neverLose`: called when `maxRetries` is exhausted (status → `'failed'`).\n * Use to revert the optimistic update.\n */\n onRollback?: (...args: [...TArgs, ActionContext]) => void;\n /**\n * Declarative conflict-resolution strategy used during queue replay when\n * the server responds with a 4xx status (conflict, gone, unprocessable,\n * etc.). If not provided, 4xx errors are treated identically to other\n * errors (retried until `maxRetries` is exhausted, then `onRollback` is\n * called). See `ConflictConfig`.\n */\n conflict?: ConflictConfig;\n /**\n * When `true`, each invocation gets an `AbortController` whose `signal` is\n * passed via `ActionContext.signal`. Forward it to `fetch`/etc. so\n * `handle.cancel(idempotencyKey)` can abort an in-flight call, or remove a\n * not-yet-replayed queued item.\n */\n cancellable?: boolean;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ActionConfig<TArgs extends any[] = any[]> =\n | (ActionConfigBase<TArgs> & {\n /** Call directly, no persistence on failure. */\n reliability: 'best-effort';\n })\n | (ActionConfigBase<TArgs> & {\n /** Persist to IndexedDB before executing; replay on reconnect. */\n reliability: 'neverLose';\n /**\n * Required for `neverLose` — queued items must survive a page reload\n * and be matched back to this action on replay. `fn.name` is not\n * reliable (minifiers rename it, arrow functions may be anonymous).\n */\n name: string;\n });\n\n/**\n * Passed to `ConflictConfig.resolve` (for `'merge'`/`'custom'` strategies)\n * when a queued action's replay receives a 4xx response.\n */\nexport interface ConflictContext {\n /** Whatever `fn` threw — typically a `Response` or an error with `.status`. */\n error: unknown;\n /** The original arguments the action was queued with. */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n args: any[];\n /** Number of replay attempts so far (0 on first replay). */\n attempt: number;\n idempotencyKey: string;\n}\n\n/**\n * Outcome of `ConflictConfig.resolve`:\n * - `'retry'`: keep the item queued, retry per normal backoff.\n * - `'skip'`: silently remove the item (no `onRollback`).\n * - `{ resolved: args }`: replace the queued args and retry immediately\n * on the next replay pass.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type ConflictResolution = 'retry' | 'skip' | { resolved: any[] };\n\nexport interface ConflictConfig {\n /**\n * - `'serverWins'`: drop the queued item, keeping the server's state.\n * - `'clientWins'`: keep retrying — the client's write should eventually\n * be accepted (e.g. once the server-side conflict is cleared).\n * - `'merge'` / `'custom'`: call `resolve` to decide.\n */\n strategy: 'serverWins' | 'clientWins' | 'merge' | 'custom';\n /** Required for `'merge'` and `'custom'`. */\n resolve?: (ctx: ConflictContext) => ConflictResolution;\n}\n\n/** Bump when ActionQueueItem's shape changes. Used to migrate items persisted by older versions. */\nexport const CURRENT_QUEUE_SCHEMA_VERSION = 2;\n\nexport interface ActionQueueItem {\n /** Shape version this item was persisted with. Items from before v2 are migrated on load. */\n schemaVersion: number;\n id: string;\n /** ID of the registered action (maps to the function in the registry). */\n actionId: string;\n actionName: string;\n /**\n * Stable per-invocation key, generated once and reused across every retry/replay\n * of this item. Pass to your server as an idempotency key so retries that reach\n * the server after a dropped response don't double-execute.\n */\n idempotencyKey: string;\n args: unknown[];\n queuedAt: number;\n retryCount: number;\n maxRetries: number;\n status: 'pending' | 'replaying' | 'succeeded' | 'failed';\n /** Replay priority. High items replay before normal, normal before low. Default: 'normal'. */\n priority?: 'high' | 'normal' | 'low';\n error?: string;\n completedAt?: number;\n /** Earliest timestamp at which this item should be retried (exponential backoff). */\n nextRetryAt?: number;\n}\n\nexport interface QueuedResult {\n readonly queued: true;\n readonly id: string;\n readonly message: string;\n}\n\n/** Summary returned by replayQueue(). */\nexport interface ReplayResult {\n /** Items where the registered function was found and called. */\n attempted: number;\n /** Items that resolved successfully. */\n succeeded: number;\n /** Items that failed and have no retries remaining (status: 'failed'). */\n failed: number;\n /** Items that failed but will be retried later (nextRetryAt set). */\n retrying: number;\n /** Items whose actionId had no registered function — likely not yet imported. */\n skipped: number;\n /** Items that received a 4xx response and were dropped via `conflict: { strategy: 'serverWins' }` (or `resolve()` returning `'skip'`). */\n conflicted: number;\n /** Items removed via `handle.cancel(idempotencyKey)` before/during replay. */\n cancelled: number;\n}\n\n/**\n * Passed as an extra argument after the declared params to `neverLose` actions,\n * on every invocation (initial call, offline queue, and replay). The same\n * `idempotencyKey` is reused across all retries of one logical invocation —\n * forward it to your server (e.g. as an `Idempotency-Key` header) so a retry\n * that reaches the server after a dropped response doesn't double-execute.\n */\nexport interface ActionContext {\n idempotencyKey: string;\n /** 0 on the first attempt, incremented on each replay retry. */\n attempt: number;\n /** Set when `config.cancellable` is true. Forward to `fetch`/etc. for cancellation support. */\n signal?: AbortSignal;\n}\n\n/**\n * Every action function receives its declared args plus a trailing\n * `ActionContext` — on every invocation (online, offline, and replay).\n */\nexport type ActionFn<TArgs extends unknown[], TReturn> = (\n ...args: [...TArgs, ActionContext]\n) => Promise<TReturn>;\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport interface ActionHandle<TArgs extends any[], TReturn> {\n (...args: TArgs): Promise<TReturn | QueuedResult>;\n readonly id: string;\n readonly config: ActionConfig;\n /**\n * Cancel an invocation by its `idempotencyKey` (from `ActionContext` /\n * `onOptimistic`). Aborts the in-flight call if `cancellable: true` and\n * still running, otherwise removes a not-yet-replayed queued item.\n * Returns `true` if something was cancelled/removed.\n */\n cancel: (idempotencyKey: string) => Promise<boolean>;\n}\n\n// ── Global State ─────────────────────────────────────────────────────────────\n\nexport interface EidosState {\n isOnline: boolean;\n swStatus: 'idle' | 'registering' | 'active' | 'error' | 'unsupported';\n swError?: string;\n resources: Record<string, ResourceEntry>;\n queue: ActionQueueItem[];\n reliability: ReliabilityStats;\n}\n\n/**\n * Cumulative, session-scoped counters for `neverLose` queue outcomes — opt-in\n * telemetry surfaced via `eidosReliabilityStats` / `useEidosReliabilityStats()`\n * and `EidosConfig.onReliabilityReport`. Reset on page reload (not persisted).\n */\nexport interface ReliabilityStats {\n [key: string]: number;\n /** Actions persisted to the queue (offline, or online call that threw). */\n queued: number;\n /** Queue items that executed successfully (first attempt or a retry). */\n succeeded: number;\n /** Queue items that exhausted `maxRetries` and moved to `'failed'`. */\n failed: number;\n /** Replay attempts that failed but will retry (haven't exhausted `maxRetries`). */\n retried: number;\n /** Queue items dropped by a `serverWins`/`merge`/`custom` conflict resolution. */\n conflicted: number;\n /** Queue items removed via `handle.cancel(idempotencyKey)` before replay completed. */\n cancelled: number;\n}\n\nexport function emptyReliabilityStats(): ReliabilityStats {\n return { queued: 0, succeeded: 0, failed: 0, retried: 0, conflicted: 0, cancelled: 0 };\n}\n\nexport interface QueueStatusCounts {\n [key: string]: number;\n pending: number;\n failed: number;\n replaying: number;\n total: number;\n}\n\n/** Single pass over the queue — avoids separate .filter() calls per status. */\nexport function countQueueByStatus(queue: ActionQueueItem[]): QueueStatusCounts {\n let pending = 0,\n failed = 0,\n replaying = 0;\n for (const q of queue) {\n if (q.status === 'pending') pending++;\n else if (q.status === 'failed') failed++;\n else if (q.status === 'replaying') replaying++;\n }\n return { pending, failed, replaying, total: queue.length };\n}\n"],"mappings":"AAiVA,SAAgB,IAA0C;AACxD,SAAO;AAAA,IAAE,QAAQ;AAAA,IAAG,WAAW;AAAA,IAAG,QAAQ;AAAA,IAAG,SAAS;AAAA,IAAG,YAAY;AAAA,IAAG,WAAW;AAAA,EAAE;AACvF;AAWA,SAAgB,EAAmB,GAA6C;AAC9E,MAAI,IAAU,GACZ,IAAS,GACT,IAAY;AACd,aAAW,KAAK,EACd,CAAI,EAAE,WAAW,YAAW,MACnB,EAAE,WAAW,WAAU,MACvB,EAAE,WAAW,eAAa;AAErC,SAAO;AAAA,IAAE,SAAA;AAAA,IAAS,QAAA;AAAA,IAAQ,WAAA;AAAA,IAAW,OAAO,EAAM;AAAA,EAAO;AAC3D"}
@@ -0,0 +1 @@
1
+ export declare const VERSION = "2.3.1";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
- var r = "2.2.0";
1
+ var r = "2.3.1";
2
2
  export {
3
3
  r as VERSION
4
4
  };
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","names":[],"sources":["../src/version.ts"],"sourcesContent":["export const VERSION = '2.2.0';\n"],"mappings":"AAAA,IAAa,IAAU"}
1
+ {"version":3,"file":"version.js","names":[],"sources":["../src/version.ts"],"sourcesContent":["export const VERSION = '2.3.1';\n"],"mappings":"AAAA,IAAa,IAAU"}
package/dist/vite.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Plugin } from 'vite';
2
-
3
2
  export interface EidosPluginOptions {
4
3
  /**
5
4
  * Destination path for the service worker, relative to the project root.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sweidos/eidos",
3
- "version": "2.2.0",
4
- "description": "Describe intent. The runtime figures out how. An abstraction layer for offline-first web apps.",
3
+ "version": "2.3.1",
4
+ "description": "Eidos — offline-first abstraction layer. resource() + action() auto-generate Service Workers, cache strategies, and an IndexedDB action queue with idempotency keys and cross-tab replay locks.",
5
5
  "author": "Aditya Raj",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -21,7 +21,9 @@
21
21
  "react-native",
22
22
  "queue",
23
23
  "optimistic-ui",
24
- "network-resilience"
24
+ "network-resilience",
25
+ "sweidos",
26
+ "eidos"
25
27
  ],
26
28
  "homepage": "https://sweidos.vercel.app/overview",
27
29
  "bugs": {
@@ -138,14 +140,14 @@
138
140
  "@types/react": "^19.2.17",
139
141
  "@types/react-dom": "^19.2.3",
140
142
  "@vitejs/plugin-react": "^6.0.2",
141
- "@vitest/coverage-v8": "^4.1.8",
143
+ "@vitest/coverage-v8": "^4.1.9",
142
144
  "fake-indexeddb": "^6.2.5",
143
145
  "jsdom": "^29.1.1",
144
146
  "size-limit": "^12.1.0",
145
147
  "typescript": "^6.0.3",
146
148
  "vite": "^8.0.16",
147
- "vite-plugin-dts": "^3.9.1",
148
- "vitest": "^4.1.8"
149
+ "vite-plugin-dts": "^5.0.2",
150
+ "vitest": "^4.1.9"
149
151
  },
150
152
  "scripts": {
151
153
  "build": "vite build && vite build --config vite.cjs.config.ts && vite build --config vite.plugin.config.ts && vite build --config vite.query.config.ts && vite build --config vite.push.config.ts && vite build --config vite.testing.config.ts && vite build --config vite.nextjs.config.ts && vite build --config vite.sveltekit.config.ts && vite build --config vite.devtools.config.ts && vite build --config vite.react-native.config.ts && vite build --config vite.cli.config.ts && node scripts/copy-sw.mjs",
@@ -153,7 +155,9 @@
153
155
  "type-check": "tsc --noEmit",
154
156
  "test": "vitest run",
155
157
  "test:watch": "vitest",
158
+ "bench": "vitest bench run",
156
159
  "test:coverage": "vitest run --coverage",
157
- "size": "size-limit"
160
+ "size": "size-limit",
161
+ "size:check-docs": "node scripts/check-bundle-size-doc.mjs"
158
162
  }
159
163
  }