feat: further work on stores
Some checks failed
TrafficCue CI / check (push) Failing after 2m13s
TrafficCue CI / build (push) Failing after 8m41s
TrafficCue CI / build-android (push) Failing after 13m15s

This commit is contained in:
2025-09-26 19:49:40 +02:00
parent acd6dc9682
commit ed7560360f
4 changed files with 138 additions and 117 deletions

View File

@ -13,6 +13,7 @@
"eslint": "^9.29.0", "eslint": "^9.29.0",
"eslint-plugin-svelte": "^3.9.3", "eslint-plugin-svelte": "^3.9.3",
"globals": "^16.2.0", "globals": "^16.2.0",
"idb": "^8.0.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"libsodium-wrappers": "^0.7.15", "libsodium-wrappers": "^0.7.15",
"opening_hours": "^3.8.0", "opening_hours": "^3.8.0",
@ -586,6 +587,8 @@
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@6.1.8", "", { "dependencies": { "@babel/runtime": "^7.19.0" } }, "sha512-Svm+MduCElO0Meqpj1kJAriTC6OhI41VhlT/A0UPjGoPZBhAHIaGE5EfsHlTpgdH09UVX7rcc72pSDDBeKSQQA=="], "i18next-browser-languagedetector": ["i18next-browser-languagedetector@6.1.8", "", { "dependencies": { "@babel/runtime": "^7.19.0" } }, "sha512-Svm+MduCElO0Meqpj1kJAriTC6OhI41VhlT/A0UPjGoPZBhAHIaGE5EfsHlTpgdH09UVX7rcc72pSDDBeKSQQA=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],

View File

@ -49,6 +49,7 @@
"eslint": "^9.29.0", "eslint": "^9.29.0",
"eslint-plugin-svelte": "^3.9.3", "eslint-plugin-svelte": "^3.9.3",
"globals": "^16.2.0", "globals": "^16.2.0",
"idb": "^8.0.3",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"libsodium-wrappers": "^0.7.15", "libsodium-wrappers": "^0.7.15",
"opening_hours": "^3.8.0", "opening_hours": "^3.8.0",

View File

@ -1,7 +1,9 @@
<script> <script>
import { import {
CloudUploadIcon,
HandIcon, HandIcon,
MapIcon, MapIcon,
PackagePlusIcon,
RefreshCcwIcon, RefreshCcwIcon,
SpeechIcon, SpeechIcon,
ToggleLeftIcon, ToggleLeftIcon,
@ -14,6 +16,7 @@
import { view } from "../../view.svelte"; import { view } from "../../view.svelte";
import { m } from "$lang/messages"; import { m } from "$lang/messages";
import { setOnboardingState } from "$lib/onboarding.svelte"; import { setOnboardingState } from "$lib/onboarding.svelte";
import { syncStores, updateStore } from "$lib/services/stores";
const dev = getDeveloperToggle(); const dev = getDeveloperToggle();
</script> </script>
@ -63,6 +66,33 @@
/> />
</section> </section>
<section>
<h2>Stores</h2>
<SettingsButton
icon={CloudUploadIcon}
text="Sync Stores"
onclick={async () => {
syncStores();
}}
/>
<SettingsButton
icon={PackagePlusIcon}
text="Update Store"
onclick={async () => {
const name = prompt("Store Name?");
if (!name) return;
const type = prompt("Store Type? (route, location, vehicle)");
if (type !== "route" && type !== "location" && type !== "vehicle") {
alert("Invalid type");
return;
}
const data = prompt("Data? (JSON)");
if (!data) return;
await updateStore({ name, type }, JSON.parse(data));
}}
/>
</section>
<section> <section>
<h2>Other</h2> <h2>Other</h2>
<SettingsButton <SettingsButton

View File

@ -1,134 +1,121 @@
import { getStores, putStore } from "./lnv"; // import { getStores, putStore } from "./lnv";
import { openDB, type DBSchema } from "idb";
import { authFetch } from "./lnv";
import { LNV_SERVER } from "./hosts";
export interface Store { export interface Store {
id: string; // UUID
name: string; name: string;
data: string;
type: StoreType; type: StoreType;
privacy: StorePrivacy; owner_id: string; // User ID
modified_at: string; data: string; // JSON string
modified_at: string; // ISO date string
} }
export type StoreType = "route" | "location" | "vehicle"; export type StoreType = "route" | "location" | "vehicle";
export type StorePrivacy = "public" | "friends" | "private";
export interface StoreInfo { export interface StoreInfo {
name: string; name: string;
type: StoreType; type: StoreType;
privacy: StorePrivacy;
} }
export async function syncStore(remoteStore: Store) { interface TCDB extends DBSchema {
if(!localStorage.getItem("lnv-token")) { stores: {
console.warn(`[STORES] [syncStore] No token, skipping sync of ${remoteStore.name}`); 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 const 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" });
}
}
})
export async function syncStores() {
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() };
})));
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; return;
} }
// Figure out which is newer const data = await res.json() as { changes: Store[]; };
const localModified = new Date((JSON.parse(localStorage.getItem(remoteStore.type + "/" + remoteStore.name + "-meta") ?? "{\"modified_at\":\"1970-01-01\"}")).modified_at); const tx = db.transaction(["stores", "changes"], "readwrite");
const remoteModified = new Date(remoteStore.modified_at); // Apply all changes the server sent us
if(localModified < remoteModified) { for(const store of data.changes) {
// Remote is newer if(store.data === null) {
if(remoteStore.data == null) { await tx.objectStore("stores").delete(store.id);
// Remote was deleted, delete local copy too
localStorage.removeItem(remoteStore.type + "/" + remoteStore.name);
localStorage.removeItem(remoteStore.type + "/" + remoteStore.name + "-meta");
return;
} else { } else {
localStorage.setItem(remoteStore.type + "/" + remoteStore.name, remoteStore.data); await tx.objectStore("stores").put(store);
localStorage.setItem(remoteStore.type + "/" + remoteStore.name + "-meta", JSON.stringify({
name: remoteStore.name,
type: remoteStore.type,
privacy: remoteStore.privacy,
modified_at: remoteStore.modified_at
}));
}
} else if(localModified > remoteModified) {
// Local is newer, upload it
const localStore = JSON.parse(localStorage.getItem(remoteStore.type + "/" + remoteStore.name) ?? "null");
if(localStore != null) {
await putStore(remoteStore.name, localStore, remoteStore.type, remoteStore.privacy);
}
} else {
// Same timestamp, do nothing
}
}
export async function deleteStore(info: StoreInfo) {
const store = await serverStore(info);
store.current = null;
}
export async function serverStore(info: StoreInfo) {
// await syncStore(await getStore(info.name));
const value = localStorage.getItem(info.type + "/" + info.name);
const state = $state({
current: value ? JSON.parse(value) : null,
});
$effect(() => {
if(state.current) {
localStorage.setItem(info.type + "/" + info.name, JSON.stringify(state.current));
localStorage.setItem(info.type + "/" + info.name + "-meta", JSON.stringify({
name: info.name,
type: info.type,
privacy: info.privacy,
modified_at: new Date().toISOString()
}));
putStore(info.name, state.current, info.type, info.privacy);
}
});
return state;
}
export async function serverStores(type: StoreType) {
const stores = await getStores();
const filtered = stores.filter(store => store.type === type);
const state = $state({
list: await Promise.all(filtered.map(async store => ({
name: store.name,
privacy: store.privacy,
modified_at: store.modified_at,
...await serverStore({ name: store.name, type: store.type, privacy: store.privacy })
}))),
});
// TODO: add new stores when they are created
return state;
}
export async function syncAllStores() {
const stores = await getStores();
// Upload local stores that don't exist remotely
/*
if(remoteStore == null) {
// Remote has never heard of this store, upload local version
const localStore = JSON.parse(localStorage.getItem(remoteStore.type + "/" + remoteStore.name) ?? "null");
if(localStore != null) {
await putStore(remoteStore.name, localStore, remoteStore.type, remoteStore.privacy);
return;
} else {
// We don't have it locally either, nothing to do
return;
} }
} }
*/ // Delete all changes
const localKeys = Object.keys(localStorage).filter(key => key.endsWith("-meta")).map(key => key.slice(0, -5)); await tx.objectStore("changes").clear();
for(const key of localKeys) { await tx.done;
const [type, name] = key.split("/"); }
if(!stores.find(store => store.name === name && store.type === type)) {
// This local store doesn't exist remotely, upload it async function createStore(info: StoreInfo, data: object) {
const localStore = JSON.parse(localStorage.getItem(key) ?? "null"); const id = crypto.randomUUID();
const localMeta = JSON.parse(localStorage.getItem(key + "-meta") ?? "{\"privacy\":\"private\"}"); const store: Store = {
if(localStore != null) { id,
console.log(`[STORES] [syncAllStores] Uploading local store ${name} of type ${type}, privacy ${localMeta.privacy} to server`); name: info.name,
await putStore(name, localStore, type as StoreType, localMeta.privacy as StorePrivacy); type: info.type,
} owner_id: "", // TODO
} data: JSON.stringify(data),
} modified_at: new Date().toISOString()
};
for(const store of stores) { const tx = db.transaction(["stores", "changes"], "readwrite");
console.log(`[STORES] [syncAllStores] Syncing store ${store.name} of type ${store.type}`); await tx.objectStore("stores").add(store);
await syncStore(store); await tx.objectStore("changes").add({ id, operation: "create" });
} await tx.done;
return store;
}
export async function updateStore(info: StoreInfo, data: object) {
const store = await db.getFromIndex("stores", "by-name-and-type", [info.name, info.type]);
if (!store) {
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;
return store;
} }