@sweidos/eidos 2.3.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.
package/dist/resource.js CHANGED
@@ -1,17 +1,17 @@
1
1
  import { useEidosStore as h } from "./store.js";
2
- import { sendToWorker as g } from "./sw-bridge.js";
3
- var d = /* @__PURE__ */ new Map(), l = /* @__PURE__ */ new Map(), v = null;
2
+ import { sendToWorker as m } from "./sw-bridge.js";
3
+ var d = /* @__PURE__ */ new Map(), l = /* @__PURE__ */ new Map(), w = null;
4
4
  function q(e) {
5
- v = e;
5
+ w = e;
6
6
  }
7
- function u(e) {
7
+ function f(e) {
8
8
  return e.includes("*") || /:[^/]+/.test(e);
9
9
  }
10
10
  function R(e) {
11
11
  return "^" + e.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".+").replace(/\*/g, "[^/]+").replace(/:[^/]+/g, "[^/]+") + "$";
12
12
  }
13
- function w(e, a) {
14
- const s = k(a), t = u(e) ? R(e) : void 0, r = {
13
+ function v(e, a) {
14
+ const s = S(a), t = f(e) ? R(e) : void 0, r = {
15
15
  url: e,
16
16
  config: a,
17
17
  strategy: s,
@@ -19,14 +19,15 @@ function w(e, a) {
19
19
  cacheHits: 0,
20
20
  cacheMisses: 0
21
21
  };
22
- return h.getState().registerResource(e, r), g({
22
+ return h.getState().registerResource(e, r), m({
23
23
  type: "EIDOS_REGISTER_RESOURCE",
24
24
  url: e,
25
25
  strategy: s.swStrategy,
26
26
  cacheName: s.cacheName,
27
27
  ...t !== void 0 && { pattern: t },
28
28
  ...a.maxAge !== void 0 && { maxAge: a.maxAge },
29
- ...a.maxEntries !== void 0 && { maxEntries: a.maxEntries }
29
+ ...a.maxEntries !== void 0 && { maxEntries: a.maxEntries },
30
+ ...a.networkTimeoutMs !== void 0 && { networkTimeoutMs: a.networkTimeoutMs }
30
31
  }), {
31
32
  strategy: s,
32
33
  regexStr: t
@@ -34,7 +35,7 @@ function w(e, a) {
34
35
  }
35
36
  function y(e, a, s) {
36
37
  return async () => {
37
- g({
38
+ m({
38
39
  type: "EIDOS_CLEAR_CACHE",
39
40
  url: e
40
41
  });
@@ -42,38 +43,38 @@ function y(e, a, s) {
42
43
  if (t) {
43
44
  const r = await t.keys(), n = s ? new RegExp(s) : null, i = e.startsWith("http");
44
45
  await Promise.all(r.filter((c) => {
45
- const o = c.url, f = new URL(o).pathname;
46
- return n ? n.test(i ? o : f) : i ? o === e : o === e || f === e;
46
+ const o = c.url, u = new URL(o).pathname;
47
+ return n ? n.test(i ? o : u) : i ? o === e : o === e || u === e;
47
48
  }).map((c) => t.delete(c)));
48
49
  }
49
- u(e) || h.getState().updateResource(e, {
50
+ f(e) || h.getState().updateResource(e, {
50
51
  status: "stale",
51
52
  cachedAt: void 0,
52
53
  lastEvent: "cache-cleared",
53
54
  cacheHits: 0,
54
55
  cacheMisses: 0
55
- }), v?.(["eidos", e]);
56
+ }), w?.(["eidos", e]);
56
57
  };
57
58
  }
58
59
  function E(e) {
59
60
  return () => {
60
- d.delete(e), g({
61
+ d.delete(e), m({
61
62
  type: "EIDOS_UNREGISTER_RESOURCE",
62
63
  url: e
63
64
  }), h.getState().unregisterResource(e);
64
65
  };
65
66
  }
66
67
  function _(e, a) {
67
- if (u(e)) throw new Error(`[eidos] resource('${e}') is a URL pattern — use resourcePattern('${e}', config) instead. Pattern handles only support invalidate()/unregister(); the SW intercepts matching requests automatically.`);
68
+ if (f(e)) throw new Error(`[eidos] resource('${e}') is a URL pattern — use resourcePattern('${e}', config) instead. Pattern handles only support invalidate()/unregister(); the SW intercepts matching requests automatically.`);
68
69
  if (d.has(e)) return d.get(e);
69
- const { strategy: s } = w(e, a), t = {
70
+ const { strategy: s } = v(e, a), t = {
70
71
  url: e,
71
72
  config: a,
72
73
  strategy: s,
73
74
  fetch: async () => {
74
75
  const r = l.get(e);
75
76
  if (r) return r.then((i) => i.clone());
76
- const n = S(e, a, s);
77
+ const n = k(e, a, s);
77
78
  return l.set(e, n), n.finally(() => l.delete(e)).catch(() => {
78
79
  }), n.then((i) => i.clone());
79
80
  },
@@ -90,10 +91,10 @@ function _(e, a) {
90
91
  };
91
92
  return d.set(e, t), t;
92
93
  }
93
- function $(e, a) {
94
- if (!u(e)) throw new Error(`[eidos] resourcePattern('${e}') is not a URL pattern — use resource('${e}', config) instead.`);
94
+ function T(e, a) {
95
+ if (!f(e)) throw new Error(`[eidos] resourcePattern('${e}') is not a URL pattern — use resource('${e}', config) instead.`);
95
96
  if (d.has(e)) return d.get(e);
96
- const { strategy: s, regexStr: t } = w(e, a), r = {
97
+ const { strategy: s, regexStr: t } = v(e, a), r = {
97
98
  url: e,
98
99
  config: a,
99
100
  strategy: s,
@@ -102,7 +103,7 @@ function $(e, a) {
102
103
  };
103
104
  return d.set(e, r), r;
104
105
  }
105
- async function S(e, a, s) {
106
+ async function k(e, a, s) {
106
107
  const t = h.getState();
107
108
  t.updateResource(e, {
108
109
  status: "fetching",
@@ -111,14 +112,14 @@ async function S(e, a, s) {
111
112
  const r = await caches.open(s.cacheName).catch(() => null);
112
113
  try {
113
114
  if (s.swStrategy !== "network-first") {
114
- const c = r ? await r.match(e).catch(() => null) : null, o = h.getState().resources[e], f = a.maxAge !== void 0 && o?.cachedAt !== void 0 && Date.now() - o.cachedAt > a.maxAge;
115
- if (c && !f)
115
+ const c = r ? await r.match(e).catch(() => null) : null, o = h.getState().resources[e], u = a.maxAge !== void 0 && o?.cachedAt !== void 0 && Date.now() - o.cachedAt > a.maxAge;
116
+ if (c && !u)
116
117
  return t.updateResource(e, {
117
118
  status: "fresh",
118
119
  lastEvent: "cache-hit",
119
120
  cacheHits: (o?.cacheHits ?? 0) + 1
120
- }), s.swStrategy === "stale-while-revalidate" && fetch(e, { signal: AbortSignal.timeout(5e3) }).then(async (m) => {
121
- m.ok && r && (await r.put(e, m.clone()), h.getState().updateResource(e, {
121
+ }), s.swStrategy === "stale-while-revalidate" && fetch(e, { signal: AbortSignal.timeout(a.networkTimeoutMs ?? 3e3) }).then(async (g) => {
122
+ g.ok && r && (await r.put(e, g.clone()), h.getState().updateResource(e, {
122
123
  cachedAt: Date.now(),
123
124
  lastEvent: "cache-updated"
124
125
  }));
@@ -150,7 +151,7 @@ async function S(e, a, s) {
150
151
  throw t.updateResource(e, { status: "error" }), n;
151
152
  }
152
153
  }
153
- function k(e) {
154
+ function S(e) {
154
155
  const a = e.strategy;
155
156
  return e.offline ? p(a ?? "stale-while-revalidate", e.cacheName, e.version) : p(a ?? "network-first", e.cacheName, e.version);
156
157
  }
@@ -198,7 +199,7 @@ new CacheFirst({
198
199
  equivalentCode: `// Workbox equivalent
199
200
  new NetworkFirst({
200
201
  cacheName: 'eidos-resources-v1',
201
- networkTimeoutSeconds: 3,
202
+ networkTimeoutSeconds: 3, // controlled via ResourceConfig.networkTimeoutMs (default 3000)
202
203
  })`
203
204
  }
204
205
  };
@@ -213,7 +214,7 @@ function p(e, a, s) {
213
214
  equivalentCode: ""
214
215
  };
215
216
  }
216
- async function O(e) {
217
+ async function M(e) {
217
218
  const a = await Promise.allSettled(e.map((t) => t.prefetch())), s = a.filter((t) => t.status === "rejected").map((t) => t.reason);
218
219
  return {
219
220
  warmed: a.filter((t) => t.status === "fulfilled").length,
@@ -223,9 +224,9 @@ async function O(e) {
223
224
  }
224
225
  export {
225
226
  _ as resource,
226
- $ as resourcePattern,
227
+ T as resourcePattern,
227
228
  q as setQueryInvalidator,
228
- O as warmCache
229
+ M as warmCache
229
230
  };
230
231
 
231
232
  //# sourceMappingURL=resource.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"resource.js","names":[],"sources":["../src/resource.ts"],"sourcesContent":["import { useEidosStore } from './store';\nimport { sendToWorker } from './sw-bridge';\nimport type {\n ResourceConfig,\n ResourceHandle,\n PatternResourceHandle,\n ResourceEntry,\n GeneratedStrategy,\n CacheStrategy,\n WarmCacheResult,\n} from './types';\n\nconst _registry = new Map<string, ResourceHandle | PatternResourceHandle>();\n\n// ── Request deduplication ─────────────────────────────────────────────────────\n// If multiple callers invoke handle.fetch() simultaneously for the same URL,\n// only one network request is made. Each caller gets its own cloned Response.\n// Keyed by URL; entry is deleted when the request settles.\nconst _inflightRequests = /* @__PURE__ */ new Map<string, Promise<Response>>();\n\n// ── TanStack Query bridge (optional) ─────────────────────────────────────────\n// Set by @sweidos/eidos/query when withEidosQueryClient() is called.\n// Lets handle.invalidate() also invalidate the matching TQ cache entry.\ntype QueryInvalidator = (queryKey: [string, string]) => void;\nlet _queryInvalidator: QueryInvalidator | null = null;\n\n/** @internal Called by @sweidos/eidos/query. */\nexport function setQueryInvalidator(fn: QueryInvalidator): void {\n _queryInvalidator = fn;\n}\n\n// ── URL pattern helpers ───────────────────────────────────────────────────────\n\n/** Returns true if `url` contains wildcard or :param segments. */\nfunction isPattern(url: string): boolean {\n return url.includes('*') || /:[^/]+/.test(url);\n}\n\n/**\n * Converts a URL pattern to a regex source string for SW fetch matching.\n * `**` → multi-segment wildcard (`.+`)\n * `*` → single-segment wildcard (`[^/]+`)\n * `:param` → named single segment (`[^/]+`)\n *\n * Special regex characters in the pattern (e.g. `.`) are escaped first so\n * they match literally.\n *\n * @example\n * patternToRegexStr('/api/products/*') // '^/api/products/[^/]+$'\n * patternToRegexStr('/api/products/**') // '^/api/products/.+$'\n * patternToRegexStr('/api/users/:id') // '^/api/users/[^/]+$'\n */\nfunction patternToRegexStr(pattern: string): string {\n // Escape all regex-special chars except `*`, `/`, `:` (handled below)\n const escaped = pattern.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return (\n '^' +\n escaped\n .replace(/\\*\\*/g, '.+') // ** → multi-segment wildcard\n .replace(/\\*/g, '[^/]+') // * → single-segment wildcard\n .replace(/:[^/]+/g, '[^/]+') + // :param → single-segment wildcard\n '$'\n );\n}\n\n/** Shared setup for resource()/resourcePattern(): strategy derivation, store + SW registration. */\nfunction _register(\n url: string,\n config: ResourceConfig,\n): { strategy: GeneratedStrategy; regexStr: string | undefined } {\n const strategy = deriveStrategy(config);\n const regexStr = isPattern(url) ? patternToRegexStr(url) : undefined;\n\n const entry: ResourceEntry = {\n url,\n config,\n strategy,\n status: 'idle',\n cacheHits: 0,\n cacheMisses: 0,\n };\n\n useEidosStore.getState().registerResource(url, entry);\n\n sendToWorker({\n type: 'EIDOS_REGISTER_RESOURCE',\n url,\n strategy: strategy.swStrategy,\n cacheName: strategy.cacheName,\n ...(regexStr !== undefined && { pattern: regexStr }),\n ...(config.maxAge !== undefined && { maxAge: config.maxAge }),\n ...(config.maxEntries !== undefined && { maxEntries: config.maxEntries }),\n });\n\n return { strategy, regexStr };\n}\n\nfunction _invalidate(\n url: string,\n strategy: GeneratedStrategy,\n regexStr: string | undefined,\n): () => Promise<void> {\n return async () => {\n sendToWorker({ type: 'EIDOS_CLEAR_CACHE', url });\n const cache = await caches.open(strategy.cacheName).catch(() => null);\n if (cache) {\n const keys = await cache.keys();\n const patternRe = regexStr ? new RegExp(regexStr) : null;\n const isCrossOrigin = url.startsWith('http');\n await Promise.all(\n keys\n .filter((r) => {\n const rUrl = r.url;\n const p = new URL(rUrl).pathname;\n if (patternRe) {\n // Cross-origin patterns were compiled from absolute URLs; test full URL.\n return patternRe.test(isCrossOrigin ? rUrl : p);\n }\n return isCrossOrigin ? rUrl === url : rUrl === url || p === url;\n })\n .map((r) => cache.delete(r)),\n );\n }\n // For exact-URL resources update the store entry; patterns don't have a\n // single entry to update (individual URLs are not tracked per-pattern).\n if (!isPattern(url)) {\n useEidosStore.getState().updateResource(url, {\n status: 'stale',\n cachedAt: undefined,\n lastEvent: 'cache-cleared',\n cacheHits: 0,\n cacheMisses: 0,\n });\n }\n // Notify TanStack Query bridge if registered.\n _queryInvalidator?.(['eidos', url]);\n };\n}\n\nfunction _unregister(url: string): () => void {\n return () => {\n _registry.delete(url);\n sendToWorker({ type: 'EIDOS_UNREGISTER_RESOURCE', url });\n useEidosStore.getState().unregisterResource(url);\n };\n}\n\nfunction _warnIfReregisteredWithDifferentConfig(\n url: string,\n existing: ResourceHandle | PatternResourceHandle,\n config: ResourceConfig,\n factoryName: string,\n): void {\n if (!import.meta.env.DEV) return;\n const existingCfg = existing.config;\n if (\n existingCfg.offline !== config.offline ||\n existingCfg.strategy !== config.strategy ||\n existingCfg.cacheName !== config.cacheName ||\n existingCfg.version !== config.version\n ) {\n console.warn(\n `[eidos] ${factoryName}('${url}') already registered with a different config — returning cached handle. Call handle.unregister() first to re-register.`,\n { registered: existingCfg, ignored: config },\n );\n }\n}\n\n// ── resource() ────────────────────────────────────────────────────────────────\n\n/**\n * Registers a concrete-URL resource. For URL patterns (`/api/products/*`,\n * `/api/users/:id`, `**`), use `resourcePattern()` instead.\n */\nexport function resource<T = unknown>(url: string, config: ResourceConfig): ResourceHandle<T> {\n if (isPattern(url)) {\n throw new Error(\n `[eidos] resource('${url}') is a URL pattern — use resourcePattern('${url}', config) instead. ` +\n `Pattern handles only support invalidate()/unregister(); the SW intercepts matching requests automatically.`,\n );\n }\n\n if (_registry.has(url)) {\n const existing = _registry.get(url)!;\n _warnIfReregisteredWithDifferentConfig(url, existing, config, 'resource');\n return existing as ResourceHandle<T>;\n }\n\n const { strategy } = _register(url, config);\n\n const handle: ResourceHandle<T> = {\n url,\n config,\n strategy,\n\n fetch: async () => {\n // ── Deduplication: coalesce concurrent fetches for the same URL ─────\n // If a request is already in-flight, piggyback on it and return a clone\n // so each caller gets an independent readable Response body.\n const existing = _inflightRequests.get(url);\n if (existing) return existing.then((r) => r.clone());\n\n // Store the raw-response promise. All callers (including the primary)\n // receive a clone — the raw response stays unconsumed in the map so\n // any caller arriving while the promise is still pending can clone it.\n const task = _fetchResource(url, config, strategy);\n _inflightRequests.set(url, task);\n // .catch() silences the unhandled-rejection on the cleanup promise;\n // the error still propagates to callers via task.then() below.\n task.finally(() => _inflightRequests.delete(url)).catch(() => {});\n return task.then((r) => r.clone());\n },\n\n json: async () => {\n const res = await handle.fetch();\n return res.json() as Promise<T>;\n },\n\n query: () => ({\n queryKey: ['eidos', url] as [string, string],\n queryFn: () => handle.json(),\n }),\n\n prefetch: async () => {\n await handle.fetch();\n },\n\n invalidate: _invalidate(url, strategy, undefined),\n unregister: _unregister(url),\n };\n\n _registry.set(url, handle);\n return handle;\n}\n\n// ── resourcePattern() ────────────────────────────────────────────────────────\n\n/**\n * Registers a URL pattern (`/api/products/*`, `/api/users/:id`, `**`). The SW\n * intercepts all matching requests automatically — there is no single URL to\n * fetch/cache directly, so the returned handle only supports cache management\n * (`invalidate`/`unregister`). For a fetchable resource, use `resource()`.\n */\nexport function resourcePattern(url: string, config: ResourceConfig): PatternResourceHandle {\n if (!isPattern(url)) {\n throw new Error(\n `[eidos] resourcePattern('${url}') is not a URL pattern — use resource('${url}', config) instead.`,\n );\n }\n\n if (_registry.has(url)) {\n const existing = _registry.get(url)!;\n _warnIfReregisteredWithDifferentConfig(url, existing, config, 'resourcePattern');\n return existing as PatternResourceHandle;\n }\n\n const { strategy, regexStr } = _register(url, config);\n\n const handle: PatternResourceHandle = {\n url,\n config,\n strategy,\n invalidate: _invalidate(url, strategy, regexStr),\n unregister: _unregister(url),\n };\n\n _registry.set(url, handle);\n return handle;\n}\n\n// ── _fetchResource ─────────────────────────────────────────────────────────────\n// The actual network/cache implementation. Separated from handle.fetch() so the\n// deduplication wrapper can store the Promise and share it across concurrent callers.\n// Returns the raw (unconsumed) Response — callers MUST .clone() before reading body.\nasync function _fetchResource(\n url: string,\n config: ResourceConfig,\n strategy: GeneratedStrategy,\n): Promise<Response> {\n const store = useEidosStore.getState();\n store.updateResource(url, { status: 'fetching', fetchedAt: Date.now() });\n\n // Open cache once and reuse across try/catch — avoids a redundant\n // caches.open() call in the error fallback path.\n const cache = await caches.open(strategy.cacheName).catch(() => null);\n\n try {\n // ── network-first: skip cache check, go straight to network ─────────\n // For cache-first / SWR the cache check below is correct. For\n // network-first, reading cache first and returning early would\n // contradict the strategy — fresh data is the priority.\n if (strategy.swStrategy !== 'network-first') {\n // ── Direct Cache API check ─────────────────────────────────────────\n // We read the cache in the main thread rather than waiting for\n // an async SW postMessage. This gives instant, reliable status\n // updates regardless of SW message timing.\n const cached = cache ? await cache.match(url).catch(() => null) : null;\n\n // Treat cache as miss if maxAge exceeded\n const current = useEidosStore.getState().resources[url];\n const expired =\n config.maxAge !== undefined &&\n current?.cachedAt !== undefined &&\n Date.now() - current.cachedAt > config.maxAge;\n\n if (cached && !expired) {\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-hit',\n cacheHits: (current?.cacheHits ?? 0) + 1,\n });\n\n // Background revalidation for SWR (stale-while-revalidate)\n if (strategy.swStrategy === 'stale-while-revalidate') {\n fetch(url, { signal: AbortSignal.timeout(5000) })\n .then(async (resp) => {\n if (resp.ok && cache) {\n await cache.put(url, resp.clone());\n useEidosStore.getState().updateResource(url, {\n cachedAt: Date.now(),\n lastEvent: 'cache-updated',\n });\n }\n })\n .catch(() => {\n /* offline or timed out — cached version stays valid */\n });\n }\n\n return cached;\n }\n\n // Cache miss (or expired)\n const storeEntry = useEidosStore.getState().resources[url];\n store.updateResource(url, {\n cacheMisses: (storeEntry?.cacheMisses ?? 0) + 1,\n });\n }\n\n const response = await fetch(url);\n\n if (response.ok) {\n if (cache) await cache.put(url, response.clone());\n store.updateResource(url, {\n status: 'fresh',\n cachedAt: Date.now(),\n lastEvent: 'cache-updated',\n });\n return response;\n }\n\n // Non-2xx response (e.g. 503 from offline SW) — update status and throw\n // so callers get a proper error instead of a plain-object body they can't use.\n store.updateResource(url, { status: response.status === 503 ? 'offline' : 'error' });\n\n // Check if the SW tagged this as an offline response\n const isOffline = response.headers.get('X-Eidos-Offline') === 'true';\n throw new Error(\n isOffline\n ? `offline: no cached response for ${url}`\n : `${response.status} ${response.statusText}`,\n );\n } catch (err) {\n // Network failure — try cache one more time as fallback\n const fallback = cache ? await cache.match(url).catch(() => null) : null;\n\n if (fallback) {\n const current = useEidosStore.getState().resources[url];\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-hit',\n cacheHits: (current?.cacheHits ?? 0) + 1,\n });\n return fallback;\n }\n\n store.updateResource(url, { status: 'error' });\n throw err;\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Strategy derivation — intent → deterministic caching strategy\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction deriveStrategy(config: ResourceConfig): GeneratedStrategy {\n const explicit = config.strategy;\n if (config.offline) {\n return buildStrategy(explicit ?? 'stale-while-revalidate', config.cacheName, config.version);\n }\n return buildStrategy(explicit ?? 'network-first', config.cacheName, config.version);\n}\n\n// Strategy display names — always included (tiny, used by devtools).\nconst STRATEGY_NAMES: Record<CacheStrategy, string> = {\n 'stale-while-revalidate': 'StaleWhileRevalidate',\n 'cache-first': 'CacheFirst',\n 'network-first': 'NetworkFirst',\n};\n\n// Heavy descriptive strings — stripped from production bundles by Vite's\n// import.meta.env.DEV dead-code elimination. Only the names above ship in prod.\ntype StrategyDevInfo = Pick<GeneratedStrategy, 'reasoning' | 'behavior' | 'equivalentCode'>;\nconst _STRATEGY_DEV_META: Record<CacheStrategy, StrategyDevInfo> = {\n 'stale-while-revalidate': {\n reasoning:\n 'offline: true signals resilience. SWR returns cached data instantly while revalidating in the background — the best tradeoff between speed and freshness for offline-capable resources.',\n behavior: [\n 'Cache hit → return immediately, kick off background revalidation',\n 'Cache miss → fetch from network, cache the response, return it',\n 'Offline → return cached version if available, 503 if not',\n 'Reconnect → next request triggers a background refresh',\n ],\n equivalentCode: `// Workbox equivalent (maxEntries/maxAge are configured via ResourceConfig)\nnew StaleWhileRevalidate({\n cacheName: 'eidos-resources-v1',\n plugins: [new ExpirationPlugin({ maxEntries: config.maxEntries, maxAgeSeconds: config.maxAge && config.maxAge / 1000 })],\n})`,\n },\n 'cache-first': {\n reasoning:\n 'cache-first maximises speed and offline availability. Network is consulted only on cache miss. Best for static or infrequently-updated data.',\n behavior: [\n 'Cache hit → return immediately, no network request made',\n 'Cache miss → fetch from network, cache the response, return it',\n 'Offline → return cached version, 503 if cache is empty',\n 'Cache never expires unless maxAge is set or explicitly invalidated',\n ],\n equivalentCode: `// Workbox equivalent (maxEntries/maxAge are configured via ResourceConfig)\nnew CacheFirst({\n cacheName: 'eidos-resources-v1',\n plugins: [new ExpirationPlugin({ maxEntries: config.maxEntries, maxAgeSeconds: config.maxAge && config.maxAge / 1000 })],\n})`,\n },\n 'network-first': {\n reasoning:\n 'network-first prioritises fresh data. Cache acts as a safety net when offline. Best for frequently-updated resources where stale data causes problems.',\n behavior: [\n 'Always try network first',\n 'Network success → update cache, return fresh response',\n 'Network failure → fall back to cached version',\n 'Offline with empty cache → return 503 error response',\n ],\n equivalentCode: `// Workbox equivalent\nnew NetworkFirst({\n cacheName: 'eidos-resources-v1',\n networkTimeoutSeconds: 3,\n})`,\n },\n};\n\nfunction buildStrategy(\n swStrategy: CacheStrategy,\n cacheName?: string,\n version?: string | number,\n): GeneratedStrategy {\n const meta = _STRATEGY_DEV_META[swStrategy];\n const baseName = cacheName ?? 'eidos-resources-v1';\n return {\n name: STRATEGY_NAMES[swStrategy],\n swStrategy,\n cacheName: version !== undefined ? `${baseName}-v${version}` : baseName,\n // reasoning + behavior are rendered by the playground UI from live ResourceEntry objects —\n // keep them in all builds. equivalentCode is a static code block only used in DEV tools.\n reasoning: meta.reasoning,\n behavior: meta.behavior,\n equivalentCode: import.meta.env.DEV ? meta.equivalentCode : '',\n };\n}\n\n// ── warmCache ─────────────────────────────────────────────────────────────────\n\n/**\n * Bulk-prefetch an array of resource handles concurrently, warming the cache\n * for each one. Useful on login / app init when you know which resources the\n * user will need offline.\n *\n * Pattern handles (containing `*`, `**`, or `:param`) are silently skipped —\n * they match multiple URLs so there is no single URL to prefetch.\n *\n * @example\n * import { warmCache } from '@sweidos/eidos'\n *\n * // In EidosProvider's onReady, or after login:\n * const { warmed, failed } = await warmCache([products, userProfile, settings])\n */\nexport async function warmCache(handles: ResourceHandle[]): Promise<WarmCacheResult> {\n const results = await Promise.allSettled(handles.map((h) => h.prefetch()));\n const errors = results\n .filter((r): r is PromiseRejectedResult => r.status === 'rejected')\n .map((r) => r.reason);\n\n if (import.meta.env.DEV && errors.length > 0) {\n console.warn(`[eidos] warmCache: ${errors.length} handle(s) failed to prefetch`, errors);\n }\n\n return {\n warmed: results.filter((r) => r.status === 'fulfilled').length,\n failed: errors.length,\n errors,\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"resource.js","names":[],"sources":["../src/resource.ts"],"sourcesContent":["import { useEidosStore } from './store';\nimport { sendToWorker } from './sw-bridge';\nimport type {\n ResourceConfig,\n ResourceHandle,\n PatternResourceHandle,\n ResourceEntry,\n GeneratedStrategy,\n CacheStrategy,\n WarmCacheResult,\n} from './types';\n\nconst _registry = new Map<string, ResourceHandle | PatternResourceHandle>();\n\n// ── Request deduplication ─────────────────────────────────────────────────────\n// If multiple callers invoke handle.fetch() simultaneously for the same URL,\n// only one network request is made. Each caller gets its own cloned Response.\n// Keyed by URL; entry is deleted when the request settles.\nconst _inflightRequests = /* @__PURE__ */ new Map<string, Promise<Response>>();\n\n// ── TanStack Query bridge (optional) ─────────────────────────────────────────\n// Set by @sweidos/eidos/query when withEidosQueryClient() is called.\n// Lets handle.invalidate() also invalidate the matching TQ cache entry.\ntype QueryInvalidator = (queryKey: [string, string]) => void;\nlet _queryInvalidator: QueryInvalidator | null = null;\n\n/** @internal Called by @sweidos/eidos/query. */\nexport function setQueryInvalidator(fn: QueryInvalidator): void {\n _queryInvalidator = fn;\n}\n\n// ── URL pattern helpers ───────────────────────────────────────────────────────\n\n/** Returns true if `url` contains wildcard or :param segments. */\nfunction isPattern(url: string): boolean {\n return url.includes('*') || /:[^/]+/.test(url);\n}\n\n/**\n * Converts a URL pattern to a regex source string for SW fetch matching.\n * `**` → multi-segment wildcard (`.+`)\n * `*` → single-segment wildcard (`[^/]+`)\n * `:param` → named single segment (`[^/]+`)\n *\n * Special regex characters in the pattern (e.g. `.`) are escaped first so\n * they match literally.\n *\n * @example\n * patternToRegexStr('/api/products/*') // '^/api/products/[^/]+$'\n * patternToRegexStr('/api/products/**') // '^/api/products/.+$'\n * patternToRegexStr('/api/users/:id') // '^/api/users/[^/]+$'\n */\nfunction patternToRegexStr(pattern: string): string {\n // Escape all regex-special chars except `*`, `/`, `:` (handled below)\n const escaped = pattern.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return (\n '^' +\n escaped\n .replace(/\\*\\*/g, '.+') // ** → multi-segment wildcard\n .replace(/\\*/g, '[^/]+') // * → single-segment wildcard\n .replace(/:[^/]+/g, '[^/]+') + // :param → single-segment wildcard\n '$'\n );\n}\n\n/** Shared setup for resource()/resourcePattern(): strategy derivation, store + SW registration. */\nfunction _register(\n url: string,\n config: ResourceConfig,\n): { strategy: GeneratedStrategy; regexStr: string | undefined } {\n const strategy = deriveStrategy(config);\n const regexStr = isPattern(url) ? patternToRegexStr(url) : undefined;\n\n const entry: ResourceEntry = {\n url,\n config,\n strategy,\n status: 'idle',\n cacheHits: 0,\n cacheMisses: 0,\n };\n\n useEidosStore.getState().registerResource(url, entry);\n\n sendToWorker({\n type: 'EIDOS_REGISTER_RESOURCE',\n url,\n strategy: strategy.swStrategy,\n cacheName: strategy.cacheName,\n ...(regexStr !== undefined && { pattern: regexStr }),\n ...(config.maxAge !== undefined && { maxAge: config.maxAge }),\n ...(config.maxEntries !== undefined && { maxEntries: config.maxEntries }),\n ...(config.networkTimeoutMs !== undefined && { networkTimeoutMs: config.networkTimeoutMs }),\n });\n\n return { strategy, regexStr };\n}\n\nfunction _invalidate(\n url: string,\n strategy: GeneratedStrategy,\n regexStr: string | undefined,\n): () => Promise<void> {\n return async () => {\n sendToWorker({ type: 'EIDOS_CLEAR_CACHE', url });\n const cache = await caches.open(strategy.cacheName).catch(() => null);\n if (cache) {\n const keys = await cache.keys();\n const patternRe = regexStr ? new RegExp(regexStr) : null;\n const isCrossOrigin = url.startsWith('http');\n await Promise.all(\n keys\n .filter((r) => {\n const rUrl = r.url;\n const p = new URL(rUrl).pathname;\n if (patternRe) {\n // Cross-origin patterns were compiled from absolute URLs; test full URL.\n return patternRe.test(isCrossOrigin ? rUrl : p);\n }\n return isCrossOrigin ? rUrl === url : rUrl === url || p === url;\n })\n .map((r) => cache.delete(r)),\n );\n }\n // For exact-URL resources update the store entry; patterns don't have a\n // single entry to update (individual URLs are not tracked per-pattern).\n if (!isPattern(url)) {\n useEidosStore.getState().updateResource(url, {\n status: 'stale',\n cachedAt: undefined,\n lastEvent: 'cache-cleared',\n cacheHits: 0,\n cacheMisses: 0,\n });\n }\n // Notify TanStack Query bridge if registered.\n _queryInvalidator?.(['eidos', url]);\n };\n}\n\nfunction _unregister(url: string): () => void {\n return () => {\n _registry.delete(url);\n sendToWorker({ type: 'EIDOS_UNREGISTER_RESOURCE', url });\n useEidosStore.getState().unregisterResource(url);\n };\n}\n\nfunction _warnIfReregisteredWithDifferentConfig(\n url: string,\n existing: ResourceHandle | PatternResourceHandle,\n config: ResourceConfig,\n factoryName: string,\n): void {\n if (!import.meta.env.DEV) return;\n const existingCfg = existing.config;\n if (\n existingCfg.offline !== config.offline ||\n existingCfg.strategy !== config.strategy ||\n existingCfg.cacheName !== config.cacheName ||\n existingCfg.version !== config.version\n ) {\n console.warn(\n `[eidos] ${factoryName}('${url}') already registered with a different config — returning cached handle. Call handle.unregister() first to re-register.`,\n { registered: existingCfg, ignored: config },\n );\n }\n}\n\n// ── resource() ────────────────────────────────────────────────────────────────\n\n/**\n * Registers a concrete-URL resource. For URL patterns (`/api/products/*`,\n * `/api/users/:id`, `**`), use `resourcePattern()` instead.\n */\nexport function resource<T = unknown>(url: string, config: ResourceConfig): ResourceHandle<T> {\n if (isPattern(url)) {\n throw new Error(\n `[eidos] resource('${url}') is a URL pattern — use resourcePattern('${url}', config) instead. ` +\n `Pattern handles only support invalidate()/unregister(); the SW intercepts matching requests automatically.`,\n );\n }\n\n if (_registry.has(url)) {\n const existing = _registry.get(url)!;\n _warnIfReregisteredWithDifferentConfig(url, existing, config, 'resource');\n return existing as ResourceHandle<T>;\n }\n\n const { strategy } = _register(url, config);\n\n const handle: ResourceHandle<T> = {\n url,\n config,\n strategy,\n\n fetch: async () => {\n // ── Deduplication: coalesce concurrent fetches for the same URL ─────\n // If a request is already in-flight, piggyback on it and return a clone\n // so each caller gets an independent readable Response body.\n const existing = _inflightRequests.get(url);\n if (existing) return existing.then((r) => r.clone());\n\n // Store the raw-response promise. All callers (including the primary)\n // receive a clone — the raw response stays unconsumed in the map so\n // any caller arriving while the promise is still pending can clone it.\n const task = _fetchResource(url, config, strategy);\n _inflightRequests.set(url, task);\n // .catch() silences the unhandled-rejection on the cleanup promise;\n // the error still propagates to callers via task.then() below.\n task.finally(() => _inflightRequests.delete(url)).catch(() => {});\n return task.then((r) => r.clone());\n },\n\n json: async () => {\n const res = await handle.fetch();\n return res.json() as Promise<T>;\n },\n\n query: () => ({\n queryKey: ['eidos', url] as [string, string],\n queryFn: () => handle.json(),\n }),\n\n prefetch: async () => {\n await handle.fetch();\n },\n\n invalidate: _invalidate(url, strategy, undefined),\n unregister: _unregister(url),\n };\n\n _registry.set(url, handle);\n return handle;\n}\n\n// ── resourcePattern() ────────────────────────────────────────────────────────\n\n/**\n * Registers a URL pattern (`/api/products/*`, `/api/users/:id`, `**`). The SW\n * intercepts all matching requests automatically — there is no single URL to\n * fetch/cache directly, so the returned handle only supports cache management\n * (`invalidate`/`unregister`). For a fetchable resource, use `resource()`.\n */\nexport function resourcePattern(url: string, config: ResourceConfig): PatternResourceHandle {\n if (!isPattern(url)) {\n throw new Error(\n `[eidos] resourcePattern('${url}') is not a URL pattern — use resource('${url}', config) instead.`,\n );\n }\n\n if (_registry.has(url)) {\n const existing = _registry.get(url)!;\n _warnIfReregisteredWithDifferentConfig(url, existing, config, 'resourcePattern');\n return existing as PatternResourceHandle;\n }\n\n const { strategy, regexStr } = _register(url, config);\n\n const handle: PatternResourceHandle = {\n url,\n config,\n strategy,\n invalidate: _invalidate(url, strategy, regexStr),\n unregister: _unregister(url),\n };\n\n _registry.set(url, handle);\n return handle;\n}\n\n// ── _fetchResource ─────────────────────────────────────────────────────────────\n// The actual network/cache implementation. Separated from handle.fetch() so the\n// deduplication wrapper can store the Promise and share it across concurrent callers.\n// Returns the raw (unconsumed) Response — callers MUST .clone() before reading body.\nasync function _fetchResource(\n url: string,\n config: ResourceConfig,\n strategy: GeneratedStrategy,\n): Promise<Response> {\n const store = useEidosStore.getState();\n store.updateResource(url, { status: 'fetching', fetchedAt: Date.now() });\n\n // Open cache once and reuse across try/catch — avoids a redundant\n // caches.open() call in the error fallback path.\n const cache = await caches.open(strategy.cacheName).catch(() => null);\n\n try {\n // ── network-first: skip cache check, go straight to network ─────────\n // For cache-first / SWR the cache check below is correct. For\n // network-first, reading cache first and returning early would\n // contradict the strategy — fresh data is the priority.\n if (strategy.swStrategy !== 'network-first') {\n // ── Direct Cache API check ─────────────────────────────────────────\n // We read the cache in the main thread rather than waiting for\n // an async SW postMessage. This gives instant, reliable status\n // updates regardless of SW message timing.\n const cached = cache ? await cache.match(url).catch(() => null) : null;\n\n // Treat cache as miss if maxAge exceeded\n const current = useEidosStore.getState().resources[url];\n const expired =\n config.maxAge !== undefined &&\n current?.cachedAt !== undefined &&\n Date.now() - current.cachedAt > config.maxAge;\n\n if (cached && !expired) {\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-hit',\n cacheHits: (current?.cacheHits ?? 0) + 1,\n });\n\n // Background revalidation for SWR (stale-while-revalidate)\n if (strategy.swStrategy === 'stale-while-revalidate') {\n fetch(url, { signal: AbortSignal.timeout(config.networkTimeoutMs ?? 3000) })\n .then(async (resp) => {\n if (resp.ok && cache) {\n await cache.put(url, resp.clone());\n useEidosStore.getState().updateResource(url, {\n cachedAt: Date.now(),\n lastEvent: 'cache-updated',\n });\n }\n })\n .catch(() => {\n /* offline or timed out — cached version stays valid */\n });\n }\n\n return cached;\n }\n\n // Cache miss (or expired)\n const storeEntry = useEidosStore.getState().resources[url];\n store.updateResource(url, {\n cacheMisses: (storeEntry?.cacheMisses ?? 0) + 1,\n });\n }\n\n const response = await fetch(url);\n\n if (response.ok) {\n if (cache) await cache.put(url, response.clone());\n store.updateResource(url, {\n status: 'fresh',\n cachedAt: Date.now(),\n lastEvent: 'cache-updated',\n });\n return response;\n }\n\n // Non-2xx response (e.g. 503 from offline SW) — update status and throw\n // so callers get a proper error instead of a plain-object body they can't use.\n store.updateResource(url, { status: response.status === 503 ? 'offline' : 'error' });\n\n // Check if the SW tagged this as an offline response\n const isOffline = response.headers.get('X-Eidos-Offline') === 'true';\n throw new Error(\n isOffline\n ? `offline: no cached response for ${url}`\n : `${response.status} ${response.statusText}`,\n );\n } catch (err) {\n // Network failure — try cache one more time as fallback\n const fallback = cache ? await cache.match(url).catch(() => null) : null;\n\n if (fallback) {\n const current = useEidosStore.getState().resources[url];\n store.updateResource(url, {\n status: 'fresh',\n lastEvent: 'cache-hit',\n cacheHits: (current?.cacheHits ?? 0) + 1,\n });\n return fallback;\n }\n\n store.updateResource(url, { status: 'error' });\n throw err;\n }\n}\n\n// ─────────────────────────────────────────────────────────────────────────────\n// Strategy derivation — intent → deterministic caching strategy\n// ─────────────────────────────────────────────────────────────────────────────\n\nfunction deriveStrategy(config: ResourceConfig): GeneratedStrategy {\n const explicit = config.strategy;\n if (config.offline) {\n return buildStrategy(explicit ?? 'stale-while-revalidate', config.cacheName, config.version);\n }\n return buildStrategy(explicit ?? 'network-first', config.cacheName, config.version);\n}\n\n// Strategy display names — always included (tiny, used by devtools).\nconst STRATEGY_NAMES: Record<CacheStrategy, string> = {\n 'stale-while-revalidate': 'StaleWhileRevalidate',\n 'cache-first': 'CacheFirst',\n 'network-first': 'NetworkFirst',\n};\n\n// Heavy descriptive strings — stripped from production bundles by Vite's\n// import.meta.env.DEV dead-code elimination. Only the names above ship in prod.\ntype StrategyDevInfo = Pick<GeneratedStrategy, 'reasoning' | 'behavior' | 'equivalentCode'>;\nconst _STRATEGY_DEV_META: Record<CacheStrategy, StrategyDevInfo> = {\n 'stale-while-revalidate': {\n reasoning:\n 'offline: true signals resilience. SWR returns cached data instantly while revalidating in the background — the best tradeoff between speed and freshness for offline-capable resources.',\n behavior: [\n 'Cache hit → return immediately, kick off background revalidation',\n 'Cache miss → fetch from network, cache the response, return it',\n 'Offline → return cached version if available, 503 if not',\n 'Reconnect → next request triggers a background refresh',\n ],\n equivalentCode: `// Workbox equivalent (maxEntries/maxAge are configured via ResourceConfig)\nnew StaleWhileRevalidate({\n cacheName: 'eidos-resources-v1',\n plugins: [new ExpirationPlugin({ maxEntries: config.maxEntries, maxAgeSeconds: config.maxAge && config.maxAge / 1000 })],\n})`,\n },\n 'cache-first': {\n reasoning:\n 'cache-first maximises speed and offline availability. Network is consulted only on cache miss. Best for static or infrequently-updated data.',\n behavior: [\n 'Cache hit → return immediately, no network request made',\n 'Cache miss → fetch from network, cache the response, return it',\n 'Offline → return cached version, 503 if cache is empty',\n 'Cache never expires unless maxAge is set or explicitly invalidated',\n ],\n equivalentCode: `// Workbox equivalent (maxEntries/maxAge are configured via ResourceConfig)\nnew CacheFirst({\n cacheName: 'eidos-resources-v1',\n plugins: [new ExpirationPlugin({ maxEntries: config.maxEntries, maxAgeSeconds: config.maxAge && config.maxAge / 1000 })],\n})`,\n },\n 'network-first': {\n reasoning:\n 'network-first prioritises fresh data. Cache acts as a safety net when offline. Best for frequently-updated resources where stale data causes problems.',\n behavior: [\n 'Always try network first',\n 'Network success → update cache, return fresh response',\n 'Network failure → fall back to cached version',\n 'Offline with empty cache → return 503 error response',\n ],\n equivalentCode: `// Workbox equivalent\nnew NetworkFirst({\n cacheName: 'eidos-resources-v1',\n networkTimeoutSeconds: 3, // controlled via ResourceConfig.networkTimeoutMs (default 3000)\n})`,\n },\n};\n\nfunction buildStrategy(\n swStrategy: CacheStrategy,\n cacheName?: string,\n version?: string | number,\n): GeneratedStrategy {\n const meta = _STRATEGY_DEV_META[swStrategy];\n const baseName = cacheName ?? 'eidos-resources-v1';\n return {\n name: STRATEGY_NAMES[swStrategy],\n swStrategy,\n cacheName: version !== undefined ? `${baseName}-v${version}` : baseName,\n // reasoning + behavior are rendered by the playground UI from live ResourceEntry objects —\n // keep them in all builds. equivalentCode is a static code block only used in DEV tools.\n reasoning: meta.reasoning,\n behavior: meta.behavior,\n equivalentCode: import.meta.env.DEV ? meta.equivalentCode : '',\n };\n}\n\n// ── warmCache ─────────────────────────────────────────────────────────────────\n\n/**\n * Bulk-prefetch an array of resource handles concurrently, warming the cache\n * for each one. Useful on login / app init when you know which resources the\n * user will need offline.\n *\n * Pattern handles (containing `*`, `**`, or `:param`) are silently skipped —\n * they match multiple URLs so there is no single URL to prefetch.\n *\n * @example\n * import { warmCache } from '@sweidos/eidos'\n *\n * // In EidosProvider's onReady, or after login:\n * const { warmed, failed } = await warmCache([products, userProfile, settings])\n */\nexport async function warmCache(handles: ResourceHandle[]): Promise<WarmCacheResult> {\n const results = await Promise.allSettled(handles.map((h) => h.prefetch()));\n const errors = results\n .filter((r): r is PromiseRejectedResult => r.status === 'rejected')\n .map((r) => r.reason);\n\n if (import.meta.env.DEV && errors.length > 0) {\n console.warn(`[eidos] warmCache: ${errors.length} handle(s) failed to prefetch`, errors);\n }\n\n return {\n warmed: results.filter((r) => r.status === 'fulfilled').length,\n failed: errors.length,\n errors,\n };\n}\n"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface ResourceConfig {
22
22
  * Useful for list/image endpoints that grow unbounded.
23
23
  */
24
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;
25
31
  }
26
32
  export interface GeneratedStrategy {
27
33
  name: string;
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 * 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\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":"AA2UA,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"}
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "2.3.0";
1
+ export declare const VERSION = "2.3.1";
package/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
- var r = "2.3.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.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sweidos/eidos",
3
- "version": "2.3.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": {