This repository has been archived on 2025-11-09. You can view files and clone it, but cannot push or open issues or pull requests.
Files
trafficcue-client/src/lib/services/stores.svelte.ts
Jannik f5e1e23cdd
Some checks failed
TrafficCue CI / check (push) Failing after 1m58s
TrafficCue CI / build-android (push) Has been cancelled
TrafficCue CI / build (push) Has started running
feat: improve OIDC login flow and stores handling
2025-09-29 18:54:12 +02:00

264 lines
6.8 KiB
TypeScript

// 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<T> {
current: T;
}
export type StoreValue<T> = {
data: T;
} & Omit<Store, "data">;
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<IDBPDatabase<TCDB>> {
if (_db) return _db;
_db = await openDB<TCDB>("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<TCDB>;
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<T extends object>(info: StoreInfo) {
// const store = await db.getFromIndex("stores", "by-name-and-type", [info.name, info.type]);
// if (!store) {
// return null;
// }
// const state = $state<T>(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<T extends object>(
type: StoreType,
): WrappedValue<StoreValue<T>[]> {
const state = $state<StoreValue<T>[]>([]);
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<T>[]) {
state.splice(0, state.length, ...newValue);
},
};
}