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-plugin-svelte": "^3.9.3",
|
||||
"globals": "^16.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"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=="],
|
||||
|
||||
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-svelte": "^3.9.3",
|
||||
"globals": "^16.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"opening_hours": "^3.8.0",
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<script>
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
HandIcon,
|
||||
MapIcon,
|
||||
PackagePlusIcon,
|
||||
RefreshCcwIcon,
|
||||
SpeechIcon,
|
||||
ToggleLeftIcon,
|
||||
@ -14,6 +16,7 @@
|
||||
import { view } from "../../view.svelte";
|
||||
import { m } from "$lang/messages";
|
||||
import { setOnboardingState } from "$lib/onboarding.svelte";
|
||||
import { syncStores, updateStore } from "$lib/services/stores";
|
||||
|
||||
const dev = getDeveloperToggle();
|
||||
</script>
|
||||
@ -63,6 +66,33 @@
|
||||
/>
|
||||
</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>
|
||||
<h2>Other</h2>
|
||||
<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 {
|
||||
id: string; // UUID
|
||||
name: string;
|
||||
data: string;
|
||||
type: StoreType;
|
||||
privacy: StorePrivacy;
|
||||
modified_at: string;
|
||||
owner_id: string; // User ID
|
||||
data: string; // JSON string
|
||||
modified_at: string; // ISO date string
|
||||
}
|
||||
export type StoreType = "route" | "location" | "vehicle";
|
||||
export type StorePrivacy = "public" | "friends" | "private";
|
||||
export interface StoreInfo {
|
||||
name: string;
|
||||
type: StoreType;
|
||||
privacy: StorePrivacy;
|
||||
}
|
||||
|
||||
export async function syncStore(remoteStore: Store) {
|
||||
if(!localStorage.getItem("lnv-token")) {
|
||||
console.warn(`[STORES] [syncStore] No token, skipping sync of ${remoteStore.name}`);
|
||||
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 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;
|
||||
}
|
||||
// Figure out which is newer
|
||||
const localModified = new Date((JSON.parse(localStorage.getItem(remoteStore.type + "/" + remoteStore.name + "-meta") ?? "{\"modified_at\":\"1970-01-01\"}")).modified_at);
|
||||
const remoteModified = new Date(remoteStore.modified_at);
|
||||
if(localModified < remoteModified) {
|
||||
// Remote is newer
|
||||
if(remoteStore.data == null) {
|
||||
// Remote was deleted, delete local copy too
|
||||
localStorage.removeItem(remoteStore.type + "/" + remoteStore.name);
|
||||
localStorage.removeItem(remoteStore.type + "/" + remoteStore.name + "-meta");
|
||||
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 {
|
||||
localStorage.setItem(remoteStore.type + "/" + remoteStore.name, remoteStore.data);
|
||||
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;
|
||||
await tx.objectStore("stores").put(store);
|
||||
}
|
||||
}
|
||||
*/
|
||||
const localKeys = Object.keys(localStorage).filter(key => key.endsWith("-meta")).map(key => key.slice(0, -5));
|
||||
for(const key of localKeys) {
|
||||
const [type, name] = key.split("/");
|
||||
if(!stores.find(store => store.name === name && store.type === type)) {
|
||||
// This local store doesn't exist remotely, upload it
|
||||
const localStore = JSON.parse(localStorage.getItem(key) ?? "null");
|
||||
const localMeta = JSON.parse(localStorage.getItem(key + "-meta") ?? "{\"privacy\":\"private\"}");
|
||||
if(localStore != null) {
|
||||
console.log(`[STORES] [syncAllStores] Uploading local store ${name} of type ${type}, privacy ${localMeta.privacy} to server`);
|
||||
await putStore(name, localStore, type as StoreType, localMeta.privacy as StorePrivacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for(const store of stores) {
|
||||
console.log(`[STORES] [syncAllStores] Syncing store ${store.name} of type ${store.type}`);
|
||||
await syncStore(store);
|
||||
}
|
||||
// Delete all changes
|
||||
await tx.objectStore("changes").clear();
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
async function createStore(info: StoreInfo, data: object) {
|
||||
const id = crypto.randomUUID();
|
||||
const store: Store = {
|
||||
id,
|
||||
name: info.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;
|
||||
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