From 667f920cd242e2d3dea757a032b3febeee213ef1 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:55:31 -0500 Subject: [PATCH] feat(tui): add zero kvstore --- apps/tui/src/config.ts | 1 + apps/tui/src/index.tsx | 7 ++- apps/tui/src/store.ts | 136 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 apps/tui/src/store.ts diff --git a/apps/tui/src/config.ts b/apps/tui/src/config.ts index 74bc9e8..e2e852a 100644 --- a/apps/tui/src/config.ts +++ b/apps/tui/src/config.ts @@ -5,5 +5,6 @@ const PATH = join(homedir(), ".local", "share", "money"); const AUTH_PATH = join(PATH, "auth.json"); export const config = { + dir: PATH, authPath: AUTH_PATH, }; diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index f386c76..b4a99e7 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -4,10 +4,11 @@ import { App, type Route } from "@money/ui"; import { ZeroProvider } from "@rocicorp/zero/react"; import { schema } from '@money/shared'; import { useState } from "react"; -import { AuthClient, getAuth, layer } from "./auth"; -import { Effect, Layer } from "effect"; +import { getAuth, layer } from "./auth"; +import { Effect } from "effect"; import { BunContext } from "@effect/platform-bun"; import type { AuthData } from "./schema"; +import { kvStore } from "./store"; const userID = "anon"; const server = "http://laptop:4848"; @@ -16,7 +17,7 @@ function Main({ auth }: { auth: AuthData }) { const [route, setRoute] = useState("/"); return ( - + ["kvStore"]; + +const DATA_DIR = config.dir; + +// async function ensureDir() { +// await fs.mkdir(DATA_DIR, { recursive: true }); +// } +// +function deepFreeze(obj: T): T { + if (obj && typeof obj === "object" && !Object.isFrozen(obj)) { + Object.freeze(obj); + for (const value of Object.values(obj as any)) { + deepFreeze(value); + } + } + return obj; +} + +async function loadFile(name: string): Promise> { + // await ensureDir(); + const filePath = path.join(DATA_DIR, `${name}.json`); + try { + const buf = await fs.readFile(filePath, "utf8"); + const obj = JSON.parse(buf) as Record; + const frozen = Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, deepFreeze(v)]) + ); + return new Map(Object.entries(frozen)); + } catch (err: any) { + if (err.code === "ENOENT") { + return new Map(); + } + throw err; + } +} + +async function saveFile(name: string, data: Map) { + // await ensureDir(); + const filePath = path.join(DATA_DIR, `${name}.json`); + const obj = Object.fromEntries(data.entries()); + await fs.writeFile(filePath, JSON.stringify(obj, null, 2), "utf8"); +} + +export const kvStore: StoreProvider = { + create: (name: string) => { + let closed = false; + let dataPromise = loadFile(name); + + const makeRead = async () => { + const data = await dataPromise; + let txClosed = false; + return { + closed: txClosed, + async has(key: string) { + if (txClosed) throw new Error("transaction closed"); + return data.has(key); + }, + async get(key: string) { + if (txClosed) throw new Error("transaction closed"); + return data.get(key); + }, + release() { + txClosed = true; + }, + }; + }; + + const makeWrite = async () => { + const data = await dataPromise; + let txClosed = false; + const staging = new Map(); + + return { + closed: txClosed, + async has(key: string) { + if (txClosed) throw new Error("transaction closed"); + return staging.has(key) ? staging.get(key) !== undefined : data.has(key); + }, + async get(key: string) { + if (txClosed) throw new Error("transaction closed"); + return staging.has(key) ? staging.get(key) : data.get(key); + }, + async put(key: string, value: ReadonlyJSONValue) { + if (txClosed) throw new Error("transaction closed"); + staging.set(key, deepFreeze(value)); // 🔒 freeze before staging + }, + async del(key: string) { + if (txClosed) throw new Error("transaction closed"); + staging.set(key, undefined); + }, + async commit() { + if (txClosed) throw new Error("transaction closed"); + for (const [k, v] of staging.entries()) { + if (v === undefined) { + data.delete(k); + } else { + data.set(k, v); + } + } + await saveFile(name, data); + txClosed = true; + }, + release() { + txClosed = true; + }, + }; + }; + + return { + closed, + async read() { + if (closed) throw new Error("store closed"); + return makeRead(); + }, + async write() { + if (closed) throw new Error("store closed"); + return makeWrite(); + }, + async close() { + closed = true; + }, + }; + }, + + async drop(name: string) { + // await ensureDir(); + const filePath = path.join(DATA_DIR, `${name}.json`); + await fs.rm(filePath, { force: true }); + console.log("destroy db:", name); + }, +};