@sweidos/eidos 1.1.0 → 2.0.0
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/README.md +127 -32
- package/dist/action.js +223 -112
- package/dist/action.js.map +1 -0
- package/dist/async-storage-adapter.js.map +1 -0
- package/dist/cli.js +102 -0
- package/dist/devtools.js +208 -71
- package/dist/eidos-sw.js +283 -188
- package/dist/eidos.cjs +2 -2
- package/dist/eidos.cjs.map +1 -0
- package/dist/idb.js.map +1 -0
- package/dist/index.d.ts +160 -26
- package/dist/index.js +45 -41
- package/dist/push.cjs +123 -0
- package/dist/push.d.ts +28 -0
- package/dist/push.js +116 -0
- package/dist/query.cjs +1 -1
- package/dist/query.d.ts +1 -2
- package/dist/query.js +1 -1
- package/dist/queue-storage.js.map +1 -0
- package/dist/queue-sync.js +34 -0
- package/dist/queue-sync.js.map +1 -0
- package/dist/react/Provider.js.map +1 -0
- package/dist/react/hooks.js +23 -23
- package/dist/react/hooks.js.map +1 -0
- package/dist/replay.js.map +1 -0
- package/dist/resource.js +121 -107
- package/dist/resource.js.map +1 -0
- package/dist/runtime.js +37 -19
- package/dist/runtime.js.map +1 -0
- package/dist/store-slices.js.map +1 -0
- package/dist/store.js.map +1 -0
- package/dist/stores.js +23 -31
- package/dist/stores.js.map +1 -0
- package/dist/sw-bridge.js +44 -31
- package/dist/sw-bridge.js.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -0
- package/package.json +11 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"action.js","names":[],"sources":["../src/action.ts"],"sourcesContent":["import { useEidosStore } from './store';\nimport { getSwRegistration } from './sw-bridge';\nimport { idbQueueStorage } from './idb';\nimport { _getQueueStorage } from './queue-storage';\nimport { broadcastQueueSync } from './queue-sync';\nimport type { QueueStorage } from './queue-storage';\nimport { CURRENT_QUEUE_SCHEMA_VERSION } from './types';\nimport type {\n ActionConfig,\n ActionContext,\n ActionHandle,\n ActionFn,\n ActionQueueItem,\n ConflictConfig,\n ConflictContext,\n ConflictResolution,\n QueuedResult,\n ReplayResult,\n} from './types';\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst _actionRegistry = new Map<string, ActionFn<any[], any>>();\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nconst _rollbackRegistry = new Map<string, (...args: any[]) => void>();\nconst _conflictConfigRegistry = new Map<string, ConflictConfig>();\nconst _configRegistry = new Map<string, ActionConfig>();\n\n// In-flight AbortControllers for `cancellable` actions, keyed by idempotencyKey.\n// Populated for direct calls and replays alike; removed once the call settles.\nconst _inflightControllers = new Map<string, AbortController>();\n\nfunction qs(): QueueStorage {\n // idbQueueStorage is the default browser fallback when no custom storage is set.\n return _getQueueStorage() ?? idbQueueStorage;\n}\n\nfunction uid() {\n return crypto.randomUUID();\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nfunction callWithContext<TArgs extends any[], TReturn>(\n fn: ActionFn<TArgs, TReturn>,\n args: TArgs,\n ctx: ActionContext,\n): Promise<TReturn> {\n return fn(...args, ctx);\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport function action<TArgs extends any[], TReturn>(\n fn: ActionFn<TArgs, TReturn>,\n config: ActionConfig<TArgs>,\n): ActionHandle<TArgs, TReturn> {\n // || not ?? — fn.name can be '' (anonymous arrow fn) which ?? treats as a\n // valid value, causing all anonymous actions to share actionId ''.\n const baseId = config.name || fn.name || uid();\n const actionId = config.namespace ? `${config.namespace}::${baseId}` : baseId;\n\n if (import.meta.env.DEV && config.reliability === 'neverLose' && !config.name && !fn.name) {\n console.warn(\n `[eidos] action() registered with neverLose but no stable name was found (fn.name=\"${fn.name}\"). Pass config.name so queued items survive a page reload and can be replayed.`,\n );\n }\n\n if (_actionRegistry.has(actionId)) {\n throw new Error(\n `[eidos] duplicate action id \"${actionId}\" — an action with this id is already registered. Pass a unique config.name or config.namespace.`,\n );\n }\n\n // Registering here means the function is available for replay after\n // the user refreshes the page (actions are defined at module scope).\n _actionRegistry.set(actionId, fn as ActionFn<unknown[], unknown>);\n _configRegistry.set(actionId, config as ActionConfig);\n\n if (config.onRollback) {\n _rollbackRegistry.set(actionId, config.onRollback);\n }\n\n if (config.conflict) {\n if (\n import.meta.env.DEV &&\n (config.conflict.strategy === 'merge' || config.conflict.strategy === 'custom') &&\n !config.conflict.resolve\n ) {\n console.error(\n `[eidos] action \"${actionId}\" has conflict.strategy \"${config.conflict.strategy}\" but no resolve() — items will retry indefinitely on 4xx.`,\n );\n }\n _conflictConfigRegistry.set(actionId, config.conflict);\n }\n\n const wrapped = async (...args: TArgs): Promise<TReturn | QueuedResult> => {\n const { isOnline } = useEidosStore.getState();\n\n // Generated for every invocation — reused across every retry/replay of a\n // neverLose item, and used to key handle.cancel() for in-flight cancellable calls.\n const idempotencyKey = uid();\n\n let signal: AbortSignal | undefined;\n if (config.cancellable) {\n const controller = new AbortController();\n _inflightControllers.set(idempotencyKey, controller);\n signal = controller.signal;\n }\n\n const ctx: ActionContext = { idempotencyKey, attempt: 0, signal };\n\n config.onOptimistic?.(...args, ctx);\n\n try {\n if (config.reliability === 'neverLose') {\n if (!isOnline) {\n return persistAndQueue(actionId, actionId, args, config, idempotencyKey);\n }\n // Online + neverLose: execute, queue on failure\n try {\n return await callWithContext(fn, args, ctx);\n } catch (err) {\n if (isAbortError(err)) throw err;\n return persistAndQueue(actionId, actionId, args, config, idempotencyKey);\n }\n }\n\n // best-effort: execute directly, rollback on failure\n try {\n return await callWithContext(fn, args, ctx);\n } catch (err) {\n config.onRollback?.(...args, ctx);\n throw err;\n }\n } finally {\n if (config.cancellable) _inflightControllers.delete(idempotencyKey);\n }\n };\n\n const cancel = async (idempotencyKey: string): Promise<boolean> => {\n const controller = _inflightControllers.get(idempotencyKey);\n if (controller) {\n controller.abort();\n return true;\n }\n\n // Not in flight — check for a not-yet-replayed queued item with this key.\n const items = await qs().getAll();\n const item = items.find((i) => i.idempotencyKey === idempotencyKey && i.status === 'pending');\n if (!item) return false;\n\n useEidosStore.getState().removeQueueItem(item.id);\n broadcastQueueSync({ type: 'remove', id: item.id });\n await qs().remove(item.id);\n return true;\n };\n\n Object.defineProperty(wrapped, 'id', { value: actionId, writable: false });\n Object.defineProperty(wrapped, 'config', { value: config, writable: false });\n Object.defineProperty(wrapped, 'cancel', { value: cancel, writable: false });\n\n return wrapped as unknown as ActionHandle<TArgs, TReturn>;\n}\n\nfunction isJsonSerializable(value: unknown): boolean {\n try {\n JSON.stringify(value);\n return true;\n } catch {\n return false;\n }\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nasync function persistAndQueue<TArgs extends any[]>(\n actionId: string,\n actionName: string,\n args: TArgs,\n config: ActionConfig<TArgs>,\n idempotencyKey: string,\n): Promise<QueuedResult> {\n if (import.meta.env.DEV && !isJsonSerializable(args)) {\n console.warn(\n `[eidos] action \"${actionName}\" queued with non-JSON-serializable args. These args will be lost after a page reload. Use plain JSON values for neverLose actions.`,\n args,\n );\n }\n\n const id = uid();\n const item: ActionQueueItem = {\n schemaVersion: CURRENT_QUEUE_SCHEMA_VERSION,\n id,\n actionId,\n actionName,\n idempotencyKey,\n args,\n queuedAt: Date.now(),\n retryCount: 0,\n maxRetries: config.maxRetries ?? 3,\n status: 'pending',\n priority: config.priority ?? 'normal',\n };\n\n await qs().add(item);\n useEidosStore.getState().addQueueItem(item);\n\n // Register Background Sync tag so the browser can wake up open clients\n // when connectivity returns, even if the user navigated away briefly.\n // Graceful no-op when Background Sync is unsupported.\n try {\n const reg = getSwRegistration();\n if (reg && 'sync' in reg) {\n await (reg as unknown as { sync: { register(tag: string): Promise<void> } }).sync.register(\n 'eidos-queue-replay',\n );\n }\n } catch {\n // Background Sync not available — online-event replay remains the fallback\n }\n\n return {\n queued: true,\n id,\n message: `\"${actionName}\" queued — will execute when online`,\n };\n}\n\nfunction isAbortError(err: unknown): boolean {\n return err instanceof DOMException && err.name === 'AbortError';\n}\n\nfunction isClientError(err: unknown): boolean {\n if (err instanceof Response) return err.status >= 400 && err.status < 500;\n if (typeof err === 'object' && err !== null) {\n const s = (err as Record<string, unknown>).status;\n if (typeof s === 'number') return s >= 400 && s < 500;\n }\n return false;\n}\n\n// Base delay 2s, doubles per retry, capped at 5 minutes, ±20% jitter\nfunction backoffMs(retryCount: number): number {\n const base = Math.min(2000 * 2 ** retryCount, 300_000);\n return base * (0.8 + Math.random() * 0.4);\n}\n\nfunction emptyReplayResult(): ReplayResult {\n return {\n attempted: 0,\n succeeded: 0,\n failed: 0,\n retrying: 0,\n skipped: 0,\n conflicted: 0,\n cancelled: 0,\n };\n}\n\nlet _replaying = false;\nconst REPLAY_LOCK_NAME = 'eidos-queue-replay';\n\nexport async function replayQueue(): Promise<ReplayResult> {\n const store = useEidosStore.getState();\n if (!store.isOnline) return emptyReplayResult();\n\n // Web Locks coordinate replay across tabs sharing the same IndexedDB queue —\n // only the lock holder replays; other tabs no-op rather than re-executing\n // the same queued actions in parallel.\n if (typeof navigator !== 'undefined' && navigator.locks) {\n return navigator.locks.request(REPLAY_LOCK_NAME, { ifAvailable: true }, async (lock) => {\n if (!lock) return emptyReplayResult();\n return _doReplayQueue(store);\n });\n }\n\n // Fallback for environments without the Web Locks API (older Safari, React\n // Native, test runners) — guards against concurrent replay within this tab only.\n if (_replaying) return emptyReplayResult();\n _replaying = true;\n try {\n return await _doReplayQueue(store);\n } finally {\n _replaying = false;\n }\n}\n\ntype ItemOutcome = 'succeeded' | 'failed' | 'retrying' | 'skipped' | 'conflicted' | 'cancelled';\n\nasync function _markSucceeded(\n item: ActionQueueItem,\n store: ReturnType<typeof useEidosStore.getState>,\n): Promise<void> {\n const completedAt = Date.now();\n store.updateQueueItem(item.id, { status: 'succeeded', completedAt });\n broadcastQueueSync({ type: 'update', id: item.id, update: { status: 'succeeded', completedAt } });\n await qs().update(item.id, { status: 'succeeded', completedAt });\n\n // Remove from queue after a short delay so UI can show the success state briefly\n setTimeout(() => {\n store.removeQueueItem(item.id);\n broadcastQueueSync({ type: 'remove', id: item.id });\n qs().remove(item.id);\n }, 3000);\n}\n\n/**\n * Resolves a 4xx error against the action's conflict strategy.\n * Returns 'conflicted' if the item was dropped, undefined if normal\n * retry/fail logic should run (possibly with `item.args` rewritten by `merge`).\n */\nasync function _resolveConflict(\n item: ActionQueueItem,\n store: ReturnType<typeof useEidosStore.getState>,\n err: unknown,\n): Promise<ItemOutcome | undefined> {\n const conflictConfig = _conflictConfigRegistry.get(item.actionId);\n let resolution: ConflictResolution | undefined;\n\n if (conflictConfig) {\n switch (conflictConfig.strategy) {\n case 'serverWins':\n resolution = 'skip';\n break;\n case 'clientWins':\n resolution = 'retry';\n break;\n case 'merge':\n case 'custom': {\n const ctx: ConflictContext = {\n error: err,\n args: item.args as unknown[],\n attempt: item.retryCount,\n idempotencyKey: item.idempotencyKey,\n };\n resolution = conflictConfig.resolve?.(ctx) ?? 'retry';\n break;\n }\n }\n }\n\n if (resolution === 'skip') {\n store.removeQueueItem(item.id);\n broadcastQueueSync({ type: 'remove', id: item.id });\n await qs().remove(item.id);\n return 'conflicted';\n }\n if (resolution && typeof resolution === 'object') {\n item.args = resolution.resolved;\n store.updateQueueItem(item.id, { args: resolution.resolved });\n broadcastQueueSync({ type: 'update', id: item.id, update: { args: resolution.resolved } });\n await qs().update(item.id, { args: resolution.resolved });\n }\n // 'retry' (or merged args) falls through to normal retry/fail logic\n return undefined;\n}\n\nasync function _scheduleRetryOrFail(\n item: ActionQueueItem,\n store: ReturnType<typeof useEidosStore.getState>,\n err: unknown,\n): Promise<ItemOutcome> {\n const retryCount = item.retryCount + 1;\n if (retryCount >= item.maxRetries) {\n const update = { status: 'failed' as const, error: String(err), retryCount };\n store.updateQueueItem(item.id, update);\n broadcastQueueSync({ type: 'update', id: item.id, update });\n await qs().update(item.id, update);\n const ctx: ActionContext = { idempotencyKey: item.idempotencyKey, attempt: retryCount };\n _rollbackRegistry.get(item.actionId)?.(...(item.args as unknown[]), ctx);\n return 'failed';\n }\n\n const nextRetryAt = Date.now() + backoffMs(retryCount);\n const update = { status: 'pending' as const, retryCount, nextRetryAt };\n store.updateQueueItem(item.id, update);\n broadcastQueueSync({ type: 'update', id: item.id, update });\n await qs().update(item.id, update);\n return 'retrying';\n}\n\nasync function _replayItem(\n item: ActionQueueItem,\n store: ReturnType<typeof useEidosStore.getState>,\n): Promise<ItemOutcome> {\n const fn = _actionRegistry.get(item.actionId);\n if (!fn) return 'skipped';\n\n const cancellable = _configRegistry.get(item.actionId)?.cancellable;\n let signal: AbortSignal | undefined;\n if (cancellable) {\n const controller = new AbortController();\n _inflightControllers.set(item.idempotencyKey, controller);\n signal = controller.signal;\n }\n\n const ctx: ActionContext = {\n idempotencyKey: item.idempotencyKey,\n attempt: item.retryCount,\n signal,\n };\n\n try {\n await callWithContext(fn, item.args as unknown[], ctx);\n await _markSucceeded(item, store);\n return 'succeeded';\n } catch (err) {\n // Cancelled via handle.cancel(idempotencyKey) — drop the item, no rollback/retry.\n if (isAbortError(err)) {\n store.removeQueueItem(item.id);\n broadcastQueueSync({ type: 'remove', id: item.id });\n await qs().remove(item.id);\n return 'cancelled';\n }\n\n // 4xx: give the conflict strategy a chance to decide before normal retry/fail logic\n if (isClientError(err)) {\n const outcome = await _resolveConflict(item, store, err);\n if (outcome) return outcome;\n }\n\n return _scheduleRetryOrFail(item, store, err);\n } finally {\n if (cancellable) _inflightControllers.delete(item.idempotencyKey);\n }\n}\n\nasync function _replayTier(\n items: ActionQueueItem[],\n store: ReturnType<typeof useEidosStore.getState>,\n result: ReplayResult,\n): Promise<void> {\n if (items.length === 0) return;\n\n // Batch 'replaying' status update — N items → 1 store notify.\n // IDB write is fire-and-forget: on reload items stay 'pending', safe to re-replay.\n const replayable = items.filter((item) => _actionRegistry.has(item.actionId));\n result.skipped += items.length - replayable.length;\n\n if (replayable.length > 0) {\n const updates = replayable.map((item) => ({\n id: item.id,\n update: { status: 'replaying' as const },\n }));\n store.batchUpdateQueueItems(updates);\n broadcastQueueSync({ type: 'batchUpdate', updates });\n for (const item of replayable) {\n qs().update(item.id, { status: 'replaying' });\n }\n }\n\n const outcomes = await Promise.allSettled(replayable.map((item) => _replayItem(item, store)));\n\n for (const o of outcomes) {\n const outcome = o.status === 'fulfilled' ? o.value : 'failed';\n if (outcome === 'skipped') {\n result.skipped++;\n } else if (outcome === 'conflicted') {\n result.conflicted++;\n } else if (outcome === 'cancelled') {\n result.cancelled++;\n } else {\n result.attempted++;\n result[outcome]++;\n }\n }\n}\n\nasync function _doReplayQueue(\n store: ReturnType<typeof useEidosStore.getState>,\n): Promise<ReplayResult> {\n const candidates = await qs().getPending();\n const now = Date.now();\n // getPending() includes 'failed' items (for UI/queue-stats visibility), but\n // items that already exhausted maxRetries must not be auto-replayed again —\n // otherwise every reconnect re-executes the action and re-fires onRollback.\n // Those items stay 'failed' until the host app explicitly clears/re-queues them.\n const pending = candidates.filter(\n (item) => item.retryCount < item.maxRetries && (!item.nextRetryAt || item.nextRetryAt <= now),\n );\n\n const result: ReplayResult = emptyReplayResult();\n\n // Process tiers sequentially: high items complete before normal, normal before low.\n // Within each tier items run in parallel via Promise.allSettled.\n for (const tier of ['high', 'normal', 'low'] as const) {\n const tierItems = pending.filter((item) => (item.priority ?? 'normal') === tier);\n await _replayTier(tierItems, store, result);\n }\n\n return result;\n}\n\n/** Remove all items from the action queue (storage + in-memory store). */\nexport async function clearQueue(): Promise<void> {\n await qs().clear();\n useEidosStore.getState().hydrateQueue([]);\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"async-storage-adapter.js","names":[],"sources":["../src/async-storage-adapter.ts"],"sourcesContent":["import type { ActionQueueItem } from './types';\nimport type { QueueStorage } from './queue-storage';\n\n/** Minimal subset of @react-native-async-storage/async-storage (or any compatible key-value store). */\nexport interface AsyncStorageLike {\n getItem(key: string): Promise<string | null>;\n setItem(key: string, value: string): Promise<void>;\n removeItem(key: string): Promise<void>;\n}\n\nconst QUEUE_KEY = '@eidos:queue';\n\n/**\n * QueueStorage implementation backed by any AsyncStorage-compatible API.\n * Pass the AsyncStorage singleton from @react-native-async-storage/async-storage\n * (or MMKV, SQLite, or any store that satisfies AsyncStorageLike).\n */\nexport class AsyncStorageQueueStorage implements QueueStorage {\n constructor(private readonly storage: AsyncStorageLike) {}\n\n private async readAll(): Promise<ActionQueueItem[]> {\n try {\n const raw = await this.storage.getItem(QUEUE_KEY);\n if (!raw) return [];\n return JSON.parse(raw) as ActionQueueItem[];\n } catch {\n return [];\n }\n }\n\n private async writeAll(items: ActionQueueItem[]): Promise<void> {\n await this.storage.setItem(QUEUE_KEY, JSON.stringify(items));\n }\n\n async add(item: ActionQueueItem): Promise<void> {\n const items = await this.readAll();\n items.push(item);\n await this.writeAll(items);\n }\n\n async getAll(): Promise<ActionQueueItem[]> {\n return this.readAll();\n }\n\n async getPending(): Promise<ActionQueueItem[]> {\n const items = await this.readAll();\n return items.filter((i) => i.status === 'pending' || i.status === 'failed');\n }\n\n async update(id: string, patch: Partial<ActionQueueItem>): Promise<void> {\n const items = await this.readAll();\n const idx = items.findIndex((i) => i.id === id);\n if (idx !== -1) items[idx] = { ...items[idx], ...patch };\n await this.writeAll(items);\n }\n\n async remove(id: string): Promise<void> {\n const items = await this.readAll();\n await this.writeAll(items.filter((i) => i.id !== id));\n }\n\n async clear(): Promise<void> {\n await this.storage.removeItem(QUEUE_KEY);\n }\n}\n"],"mappings":"AAUA,IAAM,IAAY,gBAOL,IAAb,MAA8D;AAAA,EAC5D,YAAY,GAA4C;AAA3B,SAAA,UAAA;AAAA,EAA4B;AAAA,EAEzD,MAAc,UAAsC;AAClD,QAAI;AACF,YAAM,IAAM,MAAM,KAAK,QAAQ,QAAQ,CAAS;AAChD,aAAK,IACE,KAAK,MAAM,CAAG,IADJ,CAAC;AAAA,IAEpB,QAAQ;AACN,aAAO,CAAC;AAAA,IACV;AAAA,EACF;AAAA,EAEA,MAAc,SAAS,GAAyC;AAC9D,UAAM,KAAK,QAAQ,QAAQ,GAAW,KAAK,UAAU,CAAK,CAAC;AAAA,EAC7D;AAAA,EAEA,MAAM,IAAI,GAAsC;AAC9C,UAAM,IAAQ,MAAM,KAAK,QAAQ;AACjC,IAAA,EAAM,KAAK,CAAI,GACf,MAAM,KAAK,SAAS,CAAK;AAAA,EAC3B;AAAA,EAEA,MAAM,SAAqC;AACzC,WAAO,KAAK,QAAQ;AAAA,EACtB;AAAA,EAEA,MAAM,aAAyC;AAE7C,YAAO,MADa,KAAK,QAAQ,GACpB,OAAA,CAAQ,MAAM,EAAE,WAAW,aAAa,EAAE,WAAW,QAAQ;AAAA,EAC5E;AAAA,EAEA,MAAM,OAAO,GAAY,GAAgD;AACvE,UAAM,IAAQ,MAAM,KAAK,QAAQ,GAC3B,IAAM,EAAM,UAAA,CAAW,MAAM,EAAE,OAAO,CAAE;AAC9C,IAAI,MAAQ,OAAI,EAAM,CAAA,IAAO;AAAA,MAAE,GAAG,EAAM,CAAA;AAAA,MAAM,GAAG;AAAA,IAAM,IACvD,MAAM,KAAK,SAAS,CAAK;AAAA,EAC3B;AAAA,EAEA,MAAM,OAAO,GAA2B;AACtC,UAAM,IAAQ,MAAM,KAAK,QAAQ;AACjC,UAAM,KAAK,SAAS,EAAM,OAAA,CAAQ,MAAM,EAAE,OAAO,CAAE,CAAC;AAAA,EACtD;AAAA,EAEA,MAAM,QAAuB;AAC3B,UAAM,KAAK,QAAQ,WAAW,CAAS;AAAA,EACzC;AACF"}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
3
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
//#region src/cli.ts
|
|
7
|
+
var PUBLIC_KEY_NAME = "EIDOS_VAPID_PUBLIC_KEY";
|
|
8
|
+
var PRIVATE_KEY_NAME = "EIDOS_VAPID_PRIVATE_KEY";
|
|
9
|
+
function base64UrlFromBuffer(buf) {
|
|
10
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
11
|
+
}
|
|
12
|
+
function base64UrlToBuffer(b64url) {
|
|
13
|
+
const b64 = (b64url + "=".repeat((4 - b64url.length % 4) % 4)).replace(/-/g, "+").replace(/_/g, "/");
|
|
14
|
+
return Buffer.from(b64, "base64");
|
|
15
|
+
}
|
|
16
|
+
/** Pads a base64url-encoded big-endian integer to `length` bytes (leading zeros). */
|
|
17
|
+
function padTo(b64url, length) {
|
|
18
|
+
const buf = base64UrlToBuffer(b64url);
|
|
19
|
+
if (buf.length === length) return buf;
|
|
20
|
+
const padded = Buffer.alloc(length);
|
|
21
|
+
buf.copy(padded, length - buf.length);
|
|
22
|
+
return padded;
|
|
23
|
+
}
|
|
24
|
+
function generateVapidKeys() {
|
|
25
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
26
|
+
const pubJwk = publicKey.export({ format: "jwk" });
|
|
27
|
+
const privJwk = privateKey.export({ format: "jwk" });
|
|
28
|
+
const x = padTo(pubJwk.x, 32);
|
|
29
|
+
const y = padTo(pubJwk.y, 32);
|
|
30
|
+
const point = Buffer.concat([
|
|
31
|
+
Buffer.from([4]),
|
|
32
|
+
x,
|
|
33
|
+
y
|
|
34
|
+
]);
|
|
35
|
+
const d = padTo(privJwk.d, 32);
|
|
36
|
+
return {
|
|
37
|
+
publicKey: base64UrlFromBuffer(point),
|
|
38
|
+
privateKey: base64UrlFromBuffer(d)
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function detectEnvPrefix(cwd) {
|
|
42
|
+
if (existsSync(resolve(cwd, "next.config.js")) || existsSync(resolve(cwd, "next.config.ts"))) return "NEXT_PUBLIC_";
|
|
43
|
+
if (existsSync(resolve(cwd, "vite.config.ts")) || existsSync(resolve(cwd, "vite.config.js"))) return "VITE_";
|
|
44
|
+
if (existsSync(resolve(cwd, "svelte.config.js"))) return "PUBLIC_";
|
|
45
|
+
if (existsSync(resolve(cwd, "nuxt.config.ts")) || existsSync(resolve(cwd, "nuxt.config.js"))) return "NUXT_PUBLIC_";
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
function pickEnvFile(cwd) {
|
|
49
|
+
if (existsSync(resolve(cwd, ".env.local"))) return resolve(cwd, ".env.local");
|
|
50
|
+
if (existsSync(resolve(cwd, ".env"))) return resolve(cwd, ".env");
|
|
51
|
+
return resolve(cwd, ".env.local");
|
|
52
|
+
}
|
|
53
|
+
async function confirm(message) {
|
|
54
|
+
const rl = createInterface({
|
|
55
|
+
input: process.stdin,
|
|
56
|
+
output: process.stdout
|
|
57
|
+
});
|
|
58
|
+
const answer = await rl.question(`${message} (type "yes" to continue): `);
|
|
59
|
+
rl.close();
|
|
60
|
+
return answer.trim().toLowerCase() === "yes";
|
|
61
|
+
}
|
|
62
|
+
async function generateVapidKeysCommand() {
|
|
63
|
+
const cwd = process.cwd();
|
|
64
|
+
const publicKeyName = `${detectEnvPrefix(cwd)}${PUBLIC_KEY_NAME}`;
|
|
65
|
+
const force = process.argv.includes("--force");
|
|
66
|
+
const envFile = pickEnvFile(cwd);
|
|
67
|
+
const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
|
68
|
+
const hasPublic = new RegExp(`^${publicKeyName}=`, "m").test(existing);
|
|
69
|
+
const hasPrivate = new RegExp(`^${PRIVATE_KEY_NAME}=`, "m").test(existing);
|
|
70
|
+
if (hasPublic && hasPrivate) {
|
|
71
|
+
if (!force) {
|
|
72
|
+
console.log(`VAPID keys already configured in ${envFile} — nothing to do.`);
|
|
73
|
+
console.log("Pass --force to regenerate (this invalidates ALL existing push subscriptions).");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!await confirm(`⚠ Regenerating VAPID keys will invalidate ALL existing push subscriptions in ${envFile}. Continue?`)) {
|
|
77
|
+
console.log("Aborted.");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const { publicKey, privateKey } = generateVapidKeys();
|
|
82
|
+
const lines = [`${publicKeyName}=${publicKey}`, `${PRIVATE_KEY_NAME}=${privateKey}`];
|
|
83
|
+
if (hasPublic && hasPrivate) writeFileSync(envFile, `${existing.split("\n").filter((line) => !line.startsWith(`${publicKeyName}=`) && !line.startsWith(`${PRIVATE_KEY_NAME}=`)).join("\n").replace(/\n+$/, "")}\n${lines.join("\n")}\n`);
|
|
84
|
+
else appendFileSync(envFile, `${existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""}${lines.join("\n")}\n`);
|
|
85
|
+
console.log(`✓ VAPID keys written to ${envFile}`);
|
|
86
|
+
console.log("");
|
|
87
|
+
console.log(` ${publicKeyName}=${publicKey}`);
|
|
88
|
+
console.log(` ${PRIVATE_KEY_NAME}=${privateKey}`);
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log(`Give ${PRIVATE_KEY_NAME} and ${publicKeyName} to your backend.`);
|
|
91
|
+
console.log("Backend needs a VAPID-capable web-push library (any language) to send notifications using subscription objects received via onSubscribe.");
|
|
92
|
+
}
|
|
93
|
+
var command = process.argv[2];
|
|
94
|
+
switch (command) {
|
|
95
|
+
case "generate-vapid-keys":
|
|
96
|
+
await generateVapidKeysCommand();
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
console.log("Usage: eidos generate-vapid-keys [--force]");
|
|
100
|
+
process.exit(command ? 1 : 0);
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
package/dist/devtools.js
CHANGED
|
@@ -89,6 +89,21 @@ var useEidosStore = {
|
|
|
89
89
|
}
|
|
90
90
|
};
|
|
91
91
|
//#endregion
|
|
92
|
+
//#region src/types.ts
|
|
93
|
+
/** Single pass over the queue — avoids separate .filter() calls per status. */
|
|
94
|
+
function countQueueByStatus(queue) {
|
|
95
|
+
let pending = 0, failed = 0, replaying = 0;
|
|
96
|
+
for (const q of queue) if (q.status === "pending") pending++;
|
|
97
|
+
else if (q.status === "failed") failed++;
|
|
98
|
+
else if (q.status === "replaying") replaying++;
|
|
99
|
+
return {
|
|
100
|
+
pending,
|
|
101
|
+
failed,
|
|
102
|
+
replaying,
|
|
103
|
+
total: queue.length
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
92
107
|
//#region src/react/hooks.ts
|
|
93
108
|
function useStore(selector) {
|
|
94
109
|
const fn = selector ?? ((s) => s);
|
|
@@ -121,11 +136,8 @@ function useEidosStatus() {
|
|
|
121
136
|
*/
|
|
122
137
|
function useEidosQueueStats() {
|
|
123
138
|
const [p, f, r, t] = useStore((s) => {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
else if (q.status === "failed") failed++;
|
|
127
|
-
else if (q.status === "replaying") replaying++;
|
|
128
|
-
return `${pending},${failed},${replaying},${s.queue.length}`;
|
|
139
|
+
const { pending, failed, replaying, total } = countQueueByStatus(s.queue);
|
|
140
|
+
return `${pending},${failed},${replaying},${total}`;
|
|
129
141
|
}).split(",");
|
|
130
142
|
return {
|
|
131
143
|
pending: +p,
|
|
@@ -262,13 +274,41 @@ function _getQueueStorage() {
|
|
|
262
274
|
return _storage;
|
|
263
275
|
}
|
|
264
276
|
//#endregion
|
|
277
|
+
//#region src/queue-sync.ts
|
|
278
|
+
var CHANNEL_NAME = "eidos-queue-sync";
|
|
279
|
+
var _channel;
|
|
280
|
+
function getChannel() {
|
|
281
|
+
if (_channel !== void 0) return _channel;
|
|
282
|
+
_channel = typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(CHANNEL_NAME);
|
|
283
|
+
return _channel;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Broadcasts a queue-item status change to other tabs sharing the same
|
|
287
|
+
* IndexedDB queue. The replay-lock holder (see `replayQueue` in action.ts)
|
|
288
|
+
* is the only tab that mutates queue-item status, so non-leader tabs would
|
|
289
|
+
* otherwise show stale status until their own store re-hydrates.
|
|
290
|
+
*
|
|
291
|
+
* No-ops in environments without BroadcastChannel (React Native, old Safari).
|
|
292
|
+
*/
|
|
293
|
+
function broadcastQueueSync(message) {
|
|
294
|
+
getChannel()?.postMessage(message);
|
|
295
|
+
}
|
|
296
|
+
//#endregion
|
|
265
297
|
//#region src/action.ts
|
|
266
298
|
var _actionRegistry = /* @__PURE__ */ new Map();
|
|
267
299
|
var _rollbackRegistry = /* @__PURE__ */ new Map();
|
|
268
|
-
var
|
|
300
|
+
var _conflictConfigRegistry = /* @__PURE__ */ new Map();
|
|
301
|
+
var _configRegistry = /* @__PURE__ */ new Map();
|
|
302
|
+
var _inflightControllers = /* @__PURE__ */ new Map();
|
|
269
303
|
function qs() {
|
|
270
304
|
return _getQueueStorage() ?? idbQueueStorage;
|
|
271
305
|
}
|
|
306
|
+
function callWithContext(fn, args, ctx) {
|
|
307
|
+
return fn(...args, ctx);
|
|
308
|
+
}
|
|
309
|
+
function isAbortError(err) {
|
|
310
|
+
return err instanceof DOMException && err.name === "AbortError";
|
|
311
|
+
}
|
|
272
312
|
function isClientError(err) {
|
|
273
313
|
if (err instanceof Response) return err.status >= 400 && err.status < 500;
|
|
274
314
|
if (typeof err === "object" && err !== null) {
|
|
@@ -280,17 +320,27 @@ function isClientError(err) {
|
|
|
280
320
|
function backoffMs(retryCount) {
|
|
281
321
|
return Math.min(2e3 * 2 ** retryCount, 3e5) * (.8 + Math.random() * .4);
|
|
282
322
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const store = useEidosStore.getState();
|
|
286
|
-
if (!store.isOnline || _replaying) return {
|
|
323
|
+
function emptyReplayResult() {
|
|
324
|
+
return {
|
|
287
325
|
attempted: 0,
|
|
288
326
|
succeeded: 0,
|
|
289
327
|
failed: 0,
|
|
290
328
|
retrying: 0,
|
|
291
329
|
skipped: 0,
|
|
292
|
-
conflicted: 0
|
|
330
|
+
conflicted: 0,
|
|
331
|
+
cancelled: 0
|
|
293
332
|
};
|
|
333
|
+
}
|
|
334
|
+
var _replaying = false;
|
|
335
|
+
var REPLAY_LOCK_NAME = "eidos-queue-replay";
|
|
336
|
+
async function replayQueue() {
|
|
337
|
+
const store = useEidosStore.getState();
|
|
338
|
+
if (!store.isOnline) return emptyReplayResult();
|
|
339
|
+
if (typeof navigator !== "undefined" && navigator.locks) return navigator.locks.request(REPLAY_LOCK_NAME, { ifAvailable: true }, async (lock) => {
|
|
340
|
+
if (!lock) return emptyReplayResult();
|
|
341
|
+
return _doReplayQueue(store);
|
|
342
|
+
});
|
|
343
|
+
if (_replaying) return emptyReplayResult();
|
|
294
344
|
_replaying = true;
|
|
295
345
|
try {
|
|
296
346
|
return await _doReplayQueue(store);
|
|
@@ -298,64 +348,152 @@ async function replayQueue() {
|
|
|
298
348
|
_replaying = false;
|
|
299
349
|
}
|
|
300
350
|
}
|
|
301
|
-
async function
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
351
|
+
async function _markSucceeded(item, store) {
|
|
352
|
+
const completedAt = Date.now();
|
|
353
|
+
store.updateQueueItem(item.id, {
|
|
354
|
+
status: "succeeded",
|
|
355
|
+
completedAt
|
|
356
|
+
});
|
|
357
|
+
broadcastQueueSync({
|
|
358
|
+
type: "update",
|
|
359
|
+
id: item.id,
|
|
360
|
+
update: {
|
|
308
361
|
status: "succeeded",
|
|
309
362
|
completedAt
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
await qs().update(item.id, {
|
|
366
|
+
status: "succeeded",
|
|
367
|
+
completedAt
|
|
368
|
+
});
|
|
369
|
+
setTimeout(() => {
|
|
370
|
+
store.removeQueueItem(item.id);
|
|
371
|
+
broadcastQueueSync({
|
|
372
|
+
type: "remove",
|
|
373
|
+
id: item.id
|
|
310
374
|
});
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
375
|
+
qs().remove(item.id);
|
|
376
|
+
}, 3e3);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Resolves a 4xx error against the action's conflict strategy.
|
|
380
|
+
* Returns 'conflicted' if the item was dropped, undefined if normal
|
|
381
|
+
* retry/fail logic should run (possibly with `item.args` rewritten by `merge`).
|
|
382
|
+
*/
|
|
383
|
+
async function _resolveConflict(item, store, err) {
|
|
384
|
+
const conflictConfig = _conflictConfigRegistry.get(item.actionId);
|
|
385
|
+
let resolution;
|
|
386
|
+
if (conflictConfig) switch (conflictConfig.strategy) {
|
|
387
|
+
case "serverWins":
|
|
388
|
+
resolution = "skip";
|
|
389
|
+
break;
|
|
390
|
+
case "clientWins":
|
|
391
|
+
resolution = "retry";
|
|
392
|
+
break;
|
|
393
|
+
case "merge":
|
|
394
|
+
case "custom": {
|
|
395
|
+
const ctx = {
|
|
396
|
+
error: err,
|
|
397
|
+
args: item.args,
|
|
398
|
+
attempt: item.retryCount,
|
|
399
|
+
idempotencyKey: item.idempotencyKey
|
|
400
|
+
};
|
|
401
|
+
resolution = conflictConfig.resolve?.(ctx) ?? "retry";
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (resolution === "skip") {
|
|
406
|
+
store.removeQueueItem(item.id);
|
|
407
|
+
broadcastQueueSync({
|
|
408
|
+
type: "remove",
|
|
409
|
+
id: item.id
|
|
314
410
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
411
|
+
await qs().remove(item.id);
|
|
412
|
+
return "conflicted";
|
|
413
|
+
}
|
|
414
|
+
if (resolution && typeof resolution === "object") {
|
|
415
|
+
item.args = resolution.resolved;
|
|
416
|
+
store.updateQueueItem(item.id, { args: resolution.resolved });
|
|
417
|
+
broadcastQueueSync({
|
|
418
|
+
type: "update",
|
|
419
|
+
id: item.id,
|
|
420
|
+
update: { args: resolution.resolved }
|
|
421
|
+
});
|
|
422
|
+
await qs().update(item.id, { args: resolution.resolved });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async function _scheduleRetryOrFail(item, store, err) {
|
|
426
|
+
const retryCount = item.retryCount + 1;
|
|
427
|
+
if (retryCount >= item.maxRetries) {
|
|
428
|
+
const update = {
|
|
429
|
+
status: "failed",
|
|
430
|
+
error: String(err),
|
|
431
|
+
retryCount
|
|
432
|
+
};
|
|
433
|
+
store.updateQueueItem(item.id, update);
|
|
434
|
+
broadcastQueueSync({
|
|
435
|
+
type: "update",
|
|
436
|
+
id: item.id,
|
|
437
|
+
update
|
|
438
|
+
});
|
|
439
|
+
await qs().update(item.id, update);
|
|
440
|
+
const ctx = {
|
|
441
|
+
idempotencyKey: item.idempotencyKey,
|
|
442
|
+
attempt: retryCount
|
|
443
|
+
};
|
|
444
|
+
_rollbackRegistry.get(item.actionId)?.(...item.args, ctx);
|
|
445
|
+
return "failed";
|
|
446
|
+
}
|
|
447
|
+
const update = {
|
|
448
|
+
status: "pending",
|
|
449
|
+
retryCount,
|
|
450
|
+
nextRetryAt: Date.now() + backoffMs(retryCount)
|
|
451
|
+
};
|
|
452
|
+
store.updateQueueItem(item.id, update);
|
|
453
|
+
broadcastQueueSync({
|
|
454
|
+
type: "update",
|
|
455
|
+
id: item.id,
|
|
456
|
+
update
|
|
457
|
+
});
|
|
458
|
+
await qs().update(item.id, update);
|
|
459
|
+
return "retrying";
|
|
460
|
+
}
|
|
461
|
+
async function _replayItem(item, store) {
|
|
462
|
+
const fn = _actionRegistry.get(item.actionId);
|
|
463
|
+
if (!fn) return "skipped";
|
|
464
|
+
const cancellable = _configRegistry.get(item.actionId)?.cancellable;
|
|
465
|
+
let signal;
|
|
466
|
+
if (cancellable) {
|
|
467
|
+
const controller = new AbortController();
|
|
468
|
+
_inflightControllers.set(item.idempotencyKey, controller);
|
|
469
|
+
signal = controller.signal;
|
|
470
|
+
}
|
|
471
|
+
const ctx = {
|
|
472
|
+
idempotencyKey: item.idempotencyKey,
|
|
473
|
+
attempt: item.retryCount,
|
|
474
|
+
signal
|
|
475
|
+
};
|
|
476
|
+
try {
|
|
477
|
+
await callWithContext(fn, item.args, ctx);
|
|
478
|
+
await _markSucceeded(item, store);
|
|
319
479
|
return "succeeded";
|
|
320
480
|
} catch (err) {
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
await qs().remove(item.id);
|
|
327
|
-
return "conflicted";
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
const retryCount = item.retryCount + 1;
|
|
332
|
-
if (retryCount >= item.maxRetries) {
|
|
333
|
-
store.updateQueueItem(item.id, {
|
|
334
|
-
status: "failed",
|
|
335
|
-
error: String(err),
|
|
336
|
-
retryCount
|
|
337
|
-
});
|
|
338
|
-
await qs().update(item.id, {
|
|
339
|
-
status: "failed",
|
|
340
|
-
error: String(err),
|
|
341
|
-
retryCount
|
|
342
|
-
});
|
|
343
|
-
_rollbackRegistry.get(item.actionId)?.(...item.args);
|
|
344
|
-
return "failed";
|
|
345
|
-
} else {
|
|
346
|
-
const nextRetryAt = Date.now() + backoffMs(retryCount);
|
|
347
|
-
store.updateQueueItem(item.id, {
|
|
348
|
-
status: "pending",
|
|
349
|
-
retryCount,
|
|
350
|
-
nextRetryAt
|
|
351
|
-
});
|
|
352
|
-
await qs().update(item.id, {
|
|
353
|
-
status: "pending",
|
|
354
|
-
retryCount,
|
|
355
|
-
nextRetryAt
|
|
481
|
+
if (isAbortError(err)) {
|
|
482
|
+
store.removeQueueItem(item.id);
|
|
483
|
+
broadcastQueueSync({
|
|
484
|
+
type: "remove",
|
|
485
|
+
id: item.id
|
|
356
486
|
});
|
|
357
|
-
|
|
487
|
+
await qs().remove(item.id);
|
|
488
|
+
return "cancelled";
|
|
489
|
+
}
|
|
490
|
+
if (isClientError(err)) {
|
|
491
|
+
const outcome = await _resolveConflict(item, store, err);
|
|
492
|
+
if (outcome) return outcome;
|
|
358
493
|
}
|
|
494
|
+
return _scheduleRetryOrFail(item, store, err);
|
|
495
|
+
} finally {
|
|
496
|
+
if (cancellable) _inflightControllers.delete(item.idempotencyKey);
|
|
359
497
|
}
|
|
360
498
|
}
|
|
361
499
|
async function _replayTier(items, store, result) {
|
|
@@ -363,10 +501,15 @@ async function _replayTier(items, store, result) {
|
|
|
363
501
|
const replayable = items.filter((item) => _actionRegistry.has(item.actionId));
|
|
364
502
|
result.skipped += items.length - replayable.length;
|
|
365
503
|
if (replayable.length > 0) {
|
|
366
|
-
|
|
504
|
+
const updates = replayable.map((item) => ({
|
|
367
505
|
id: item.id,
|
|
368
506
|
update: { status: "replaying" }
|
|
369
|
-
}))
|
|
507
|
+
}));
|
|
508
|
+
store.batchUpdateQueueItems(updates);
|
|
509
|
+
broadcastQueueSync({
|
|
510
|
+
type: "batchUpdate",
|
|
511
|
+
updates
|
|
512
|
+
});
|
|
370
513
|
for (const item of replayable) qs().update(item.id, { status: "replaying" });
|
|
371
514
|
}
|
|
372
515
|
const outcomes = await Promise.allSettled(replayable.map((item) => _replayItem(item, store)));
|
|
@@ -374,6 +517,7 @@ async function _replayTier(items, store, result) {
|
|
|
374
517
|
const outcome = o.status === "fulfilled" ? o.value : "failed";
|
|
375
518
|
if (outcome === "skipped") result.skipped++;
|
|
376
519
|
else if (outcome === "conflicted") result.conflicted++;
|
|
520
|
+
else if (outcome === "cancelled") result.cancelled++;
|
|
377
521
|
else {
|
|
378
522
|
result.attempted++;
|
|
379
523
|
result[outcome]++;
|
|
@@ -384,14 +528,7 @@ async function _doReplayQueue(store) {
|
|
|
384
528
|
const candidates = await qs().getPending();
|
|
385
529
|
const now = Date.now();
|
|
386
530
|
const pending = candidates.filter((item) => item.retryCount < item.maxRetries && (!item.nextRetryAt || item.nextRetryAt <= now));
|
|
387
|
-
const result =
|
|
388
|
-
attempted: 0,
|
|
389
|
-
succeeded: 0,
|
|
390
|
-
failed: 0,
|
|
391
|
-
retrying: 0,
|
|
392
|
-
skipped: 0,
|
|
393
|
-
conflicted: 0
|
|
394
|
-
};
|
|
531
|
+
const result = emptyReplayResult();
|
|
395
532
|
for (const tier of [
|
|
396
533
|
"high",
|
|
397
534
|
"normal",
|