// import { getStores, putStore } from "./lnv"; import { openDB, type DBSchema, type IDBPDatabase } from "idb"; import { authFetch, hasCapability, ping } from "./lnv"; import { LNV_SERVER } from "./hosts"; export interface Store { id: string; // UUID name: string; type: StoreType; owner_id: string; // User ID data: string; // JSON string modified_at: string; // ISO date string } export type StoreType = "route" | "location" | "vehicle"; export interface StoreInfo { name: string; type: StoreType; } export interface WrappedValue { current: T; } export type StoreValue = { data: T; } & Omit; interface TCDB extends DBSchema { stores: { key: string; value: Store; indexes: { "by-name-and-type": [string, StoreType]; "by-type": StoreType; "by-owner": string; "by-type-and-owner": [StoreType, string]; }; }; changes: { key: string; value: { id: string; // UUID of store operation: "create" | "update" | "delete"; }; }; } export async function getDB(): Promise> { if (_db) return _db; _db = await openDB("tc", 1, { upgrade(db, _oldVersion, _newVersion, _transaction, _event) { if (!db.objectStoreNames.contains("stores")) { const store = db.createObjectStore("stores", { keyPath: "id" }); store.createIndex("by-type", "type"); store.createIndex("by-owner", "owner_id"); store.createIndex("by-type-and-owner", ["type", "owner_id"]); store.createIndex("by-name-and-type", ["name", "type"]); } if (!db.objectStoreNames.contains("changes")) { db.createObjectStore("changes", { keyPath: "id" }); } }, }); return _db; } let _db: IDBPDatabase; const eventTarget = new EventTarget(); export async function trySync() { const pingResult = await ping(); if (!pingResult) { console.warn( "[STORES] [trySync] LNV server is not reachable, skipping sync", ); return { success: false, message: "LNV server is not reachable" }; } await syncStores(); return { success: true }; } export async function syncStores() { if (!(await hasCapability("stores"))) { return; } if (!(localStorage.getItem("lnv-token"))) { return; } const db = await getDB(); const changes = await Promise.all( await db.getAll("changes").then((changes) => changes.map(async (change) => { const storeData = await db.get("stores", change.id); return { id: change.id, operation: change.operation, data: storeData?.data || null, modified_at: storeData?.modified_at || new Date().toISOString(), type: storeData?.type || "location", name: storeData?.name || "", }; }), ), ); const stores = await db .getAll("stores") .then((stores) => stores.map((store) => ({ id: store.id, modified_at: store.modified_at })), ); const res = await authFetch(LNV_SERVER + "/stores/sync", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ changes, stores, }), }); if (!res.ok) { console.error( "[STORES] [syncStores] Failed to sync stores:", await res.text(), ); return; } const data = (await res.json()) as { changes: Store[] }; const tx = db.transaction(["stores", "changes"], "readwrite"); // Apply all changes the server sent us for (const store of data.changes) { if (store.data === null) { await tx.objectStore("stores").delete(store.id); } else { await tx.objectStore("stores").put(store); } eventTarget.dispatchEvent( new CustomEvent("store-updated", { detail: store }), ); } // Delete all changes await tx.objectStore("changes").clear(); await tx.done; } async function createStore(info: StoreInfo, data: object) { const db = await getDB(); const id = crypto.randomUUID(); const store: Store = { id, name: info.name, // TODO: do we need a name? type: info.type, owner_id: "", // TODO data: JSON.stringify(data), modified_at: new Date().toISOString(), }; const tx = db.transaction(["stores", "changes"], "readwrite"); await tx.objectStore("stores").add(store); await tx.objectStore("changes").add({ id, operation: "create" }); await tx.done; eventTarget.dispatchEvent( new CustomEvent("store-updated", { detail: store }), ); await trySync(); return store; } export async function updateStore(info: StoreInfo, data: object | null) { const db = await getDB(); const store = await db.getFromIndex("stores", "by-name-and-type", [ info.name, info.type, ]); if (!store) { if (data === null) { console.warn( "[STORES] [updateStore] Tried to delete non-existing store", info, ); return; } return await createStore(info, data); } // Update the store data store.data = JSON.stringify(data); store.modified_at = new Date().toISOString(); const tx = db.transaction(["stores", "changes"], "readwrite"); await tx.objectStore("stores").put(store); await tx.objectStore("changes").add({ id: store.id, operation: "update" }); await tx.done; eventTarget.dispatchEvent( new CustomEvent("store-updated", { detail: store }), ); await trySync(); return store; } export async function hasStore(info: StoreInfo) { const db = await getDB(); const store = await db.getFromIndex("stores", "by-name-and-type", [ info.name, info.type, ]); return store !== undefined; } // export async function store(info: StoreInfo) { // const store = await db.getFromIndex("stores", "by-name-and-type", [info.name, info.type]); // if (!store) { // return null; // } // const state = $state(JSON.parse(store.data) as T); // $effect(() => { // updateStore(info, state); // }) // eventTarget.addEventListener("store-updated", (event) => { // const customEvent = event as CustomEvent; // if(customEvent.detail.id === store.id) { // const updatedStore = customEvent.detail as Store; // Object.assign(state, JSON.parse(updatedStore.data)); // } // }); // return state; // } export function stores( type: StoreType, ): WrappedValue[]> { const state = $state[]>([]); eventTarget.addEventListener("store-updated", async (event) => { const customEvent = event as CustomEvent; const updatedStore = customEvent.detail as Store; if (updatedStore.type === type) { const db = await getDB(); const stores = await db.getAllFromIndex("stores", "by-type", type); state.splice( 0, state.length, ...stores .map((store) => ({ ...store, data: JSON.parse(store.data) as T })) .filter((store) => store.data !== null), ); } }); (async () => { const db = await getDB(); const stores = await db.getAllFromIndex("stores", "by-type", type); state.splice( 0, state.length, ...stores .map((store) => ({ ...store, data: JSON.parse(store.data) as T })) .filter((store) => store.data !== null), ); })(); return { get current() { return state; }, set current(newValue: StoreValue[]) { state.splice(0, state.length, ...newValue); }, }; }