182 lines
7.6 KiB
TypeScript
182 lines
7.6 KiB
TypeScript
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<IDBDatabase> {
|
|
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<T>(request: IDBRequest<T>): Promise<T> {
|
|
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<void>;
|
|
remove(id: string): Promise<void>;
|
|
list(userId: string): Promise<StoredPushSubscription[]>;
|
|
}
|
|
export function memoryPushSubscriptionStore(): PushSubscriptionStore {
|
|
const values = new Map<string, StoredPushSubscription>();
|
|
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<T = Record<string, unknown>>(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<any>(
|
|
`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 `<section data-wrn-pwa-review><h2>Offline changes</h2><ul>${items.map((item) => `<li data-mutation-id="${esc(item.id)}"><strong>${esc(item.method)}</strong> ${esc(item.endpoint)} · ${item.attempts} attempts <button type="button" data-pwa-retry="${esc(item.id)}">Retry</button><button type="button" data-pwa-remove="${esc(item.id)}">Remove</button></li>`).join("") || "<li>No pending changes</li>"}</ul><h3>Conflicts</h3><ul>${conflicts.map((item) => `<li data-conflict-id="${esc(item.id)}">${esc(item.message)} <button type="button" data-pwa-client="${esc(item.id)}">Keep local</button><button type="button" data-pwa-server="${esc(item.id)}">Use server</button></li>`).join("") || "<li>No conflicts</li>"}</ul></section>`;
|
|
}
|
|
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}}))});`;
|