import type { OfflineMutation, OfflineQueueStore } from "./index.ts"; export interface IndexedDbMigration { version: number; migrate(db: IDBDatabase, transaction: IDBTransaction): void; } export function openPwaDatabase( name: string, migrations: IndexedDbMigration[], factory: IDBFactory = indexedDB, ): Promise { if (!name.trim()) throw new Error("IndexedDB name is required"); const ordered = [...migrations].sort((a, b) => a.version - b.version); if (ordered.some((migration, index) => migration.version !== index + 1)) throw new Error("WRN-PWA-IDB-MIGRATIONS: versions must be contiguous from 1"); return new Promise((resolve, reject) => { const request = factory.open(name, ordered.at(-1)?.version ?? 1); request.onerror = () => reject(request.error); request.onblocked = () => reject(new Error("WRN-PWA-IDB-BLOCKED")); request.onupgradeneeded = (event) => { const transaction = request.transaction!; for (const migration of ordered) if (migration.version > event.oldVersion && migration.version <= event.newVersion!) migration.migrate(request.result, transaction); }; request.onsuccess = () => resolve(request.result); }); } function idbRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } export function indexedDbOfflineQueueStore( db: IDBDatabase, storeName = "mutations", ): OfflineQueueStore { return { async list() { return ( (await idbRequest( db.transaction(storeName, "readonly").objectStore(storeName).getAll(), )) as OfflineMutation[] ).sort((a, b) => a.createdAt - b.createdAt); }, async put(item) { await idbRequest( db.transaction(storeName, "readwrite").objectStore(storeName).put(structuredClone(item)), ); }, async remove(id) { await idbRequest(db.transaction(storeName, "readwrite").objectStore(storeName).delete(id)); }, }; } export const offlineQueueMigration: IndexedDbMigration = { version: 1, migrate(db) { if (!db.objectStoreNames.contains("mutations")) db.createObjectStore("mutations", { keyPath: "id" }); }, }; export interface StoredPushSubscription { id: string; userId: string; endpoint: string; expirationTime?: number | null; keys: { p256dh: string; auth: string }; createdAt: number; } export interface PushSubscriptionStore { put(value: StoredPushSubscription): Promise; remove(id: string): Promise; list(userId: string): Promise; } export function memoryPushSubscriptionStore(): PushSubscriptionStore { const values = new Map(); return { async put(value) { values.set(value.id, structuredClone(value)); }, async remove(id) { values.delete(id); }, async list(userId) { return [...values.values()] .filter((value) => value.userId === userId) .map((value) => structuredClone(value)); }, }; } export interface PushSqlClient { query>(sql: string, parameters?: unknown[]): Promise<{ rows: T[] }>; } export function postgresPushSubscriptionStore(db: PushSqlClient): PushSubscriptionStore { return { async put(value) { await db.query( `INSERT INTO wrnexus_push_subscriptions (id,user_id,endpoint,expiration_time,p256dh,auth,created_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET user_id=$2,endpoint=$3,expiration_time=$4,p256dh=$5,auth=$6`, [ value.id, value.userId, value.endpoint, value.expirationTime ?? null, value.keys.p256dh, value.keys.auth, value.createdAt, ], ); }, async remove(id) { await db.query(`DELETE FROM wrnexus_push_subscriptions WHERE id=$1`, [id]); }, async list(userId) { const result = await db.query( `SELECT id,user_id AS "userId",endpoint,expiration_time AS "expirationTime",json_build_object('p256dh',p256dh,'auth',auth) AS keys,created_at AS "createdAt" FROM wrnexus_push_subscriptions WHERE user_id=$1 ORDER BY created_at`, [userId], ); return result.rows; }, }; } export const POSTGRES_PUSH_SUBSCRIPTION_SCHEMA = `CREATE TABLE IF NOT EXISTS wrnexus_push_subscriptions (id text PRIMARY KEY,user_id text NOT NULL,endpoint text NOT NULL,expiration_time bigint,p256dh text NOT NULL,auth text NOT NULL,created_at bigint NOT NULL); CREATE INDEX IF NOT EXISTS wrnexus_push_user ON wrnexus_push_subscriptions (user_id);`; export function createPushSubscriptionService( store: PushSubscriptionStore, options: { now?: () => number; maxPerUser?: number } = {}, ) { const now = options.now ?? Date.now; const max = options.maxPerUser ?? 20; return { async subscribe( userId: string, value: { endpoint: string; expirationTime?: number | null; keys: { p256dh: string; auth: string }; }, ) { if (!userId.trim()) throw new Error("Push subscription user is required"); const endpoint = new URL(value.endpoint); if (endpoint.protocol !== "https:") throw new Error("Push endpoint must use HTTPS"); if ( !value.keys?.p256dh || !value.keys.auth || value.keys.p256dh.length > 1024 || value.keys.auth.length > 1024 ) throw new Error("Invalid push subscription keys"); const existing = await store.list(userId); const id = await crypto.subtle .digest("SHA-256", new TextEncoder().encode(value.endpoint)) .then((bytes) => [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join(""), ); if (!existing.some((entry) => entry.id === id) && existing.length >= max) throw new Error("WRN-PWA-PUSH-CAPACITY"); const record = { id, userId, ...value, keys: { ...value.keys }, createdAt: now() }; await store.put(record); return record; }, unsubscribe: (id: string) => store.remove(id), list: (userId: string) => store.list(userId), }; } const esc = (value: unknown) => String(value ?? "").replace( /[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character]!, ); export function renderOfflineQueueReview( items: OfflineMutation[], conflicts: Array<{ id: string; message: string }> = [], ): string { return `

Offline changes

    ${items.map((item) => `
  • ${esc(item.method)} ${esc(item.endpoint)} ยท ${item.attempts} attempts
  • `).join("") || "
  • No pending changes
  • "}

Conflicts

    ${conflicts.map((item) => `
  • ${esc(item.message)}
  • `).join("") || "
  • No conflicts
  • "}
`; } export const PWA_REVIEW_RUNTIME = `document.addEventListener("click",event=>{const button=event.target.closest("[data-pwa-retry],[data-pwa-remove],[data-pwa-client],[data-pwa-server]");if(!button)return;const action=button.hasAttribute("data-pwa-retry")?"retry":button.hasAttribute("data-pwa-remove")?"remove":button.hasAttribute("data-pwa-client")?"client":"server";const id=button.getAttribute("data-pwa-"+action);dispatchEvent(new CustomEvent("wrnexus:pwa-review",{detail:{action,id}}))});`;