feat: further work on stores
This commit is contained in:
3
bun.lock
3
bun.lock
@ -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=="],
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user