diff --git a/prisma/migrations/20230418213945_meta_cascade_on_delete/migration.sql b/prisma/migrations/20230418213945_meta_cascade_on_delete/migration.sql new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/prisma/migrations/20230418213945_meta_cascade_on_delete/migration.sql @@ -0,0 +1,18 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_NodeMeta" ( + "nodeId" TEXT NOT NULL, + "appId" TEXT NOT NULL, + "appKey" TEXT NOT NULL, + "value" BLOB NOT NULL, + + PRIMARY KEY ("nodeId", "appId", "appKey"), + CONSTRAINT "NodeMeta_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "Node" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "NodeMeta_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_NodeMeta" ("appId", "appKey", "nodeId", "value") SELECT "appId", "appKey", "nodeId", "value" FROM "NodeMeta"; +DROP TABLE "NodeMeta"; +ALTER TABLE "new_NodeMeta" RENAME TO "NodeMeta"; +CREATE INDEX "NodeMeta_appId_appKey_idx" ON "NodeMeta"("appId", "appKey"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92fba28..3f15d55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,7 +46,7 @@ model Edge { model NodeMeta { nodeId String - node Node @relation(fields: [nodeId], references: [id]) + node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade) /// The app that created this metadata field app App @relation(fields: [appId], references: [id]) diff --git a/src/core/db.ts b/src/core/db.ts index d7447fd..2f07fd7 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -1,9 +1,14 @@ import { Node, App, PrismaClient } from "@prisma/client"; import { + FetchedNode, ICreateNodeRequest, + IFindManyNodesRequest, IGetNodeRequest, IRegisterAppRequest, + IRemoveNodeRequest, + IUpdateNodeRequest, } from "./types"; +import { inspect } from "util"; export class Database { private prisma: PrismaClient; @@ -12,6 +17,33 @@ export class Database { this.prisma = new PrismaClient(); } + /** + * Query for a node. If you are querying by ID, use {@link getNode} instead. + */ + public async findManyNodes( + request: IFindManyNodesRequest + ): Promise { + const query = { + take: request.take, + select: { id: true, label: true, metadata: true }, + where: { metadata: { some: { OR: [] } } }, + }; + + if (request.select_keys) { + for (const key of request.select_keys) { + query.where.metadata.some.OR.push({ + appKey: key.appKey, + appId: key.appId, + }); + } + } + + console.log("query", inspect(query, false, 10)); + + const nodes = await this.prisma.node.findMany(query); + return nodes; + } + public async registerApp(request: IRegisterAppRequest): Promise { // TODO: Some kind of authentication maybe? @@ -69,6 +101,45 @@ export class Database { return createdNode; }); } + + public async updateNode(request: IUpdateNodeRequest): Promise { + const node = await this.prisma.$transaction(async (client) => { + await client.node.update({ where: { id: request.id }, data: {} }); + + if (request.metadata_keys) { + for (const [key, value] of request.metadata_keys.entries()) { + await client.nodeMeta.update({ + where: { + nodeId_appId_appKey: { + nodeId: request.id, + appId: key.appId, + appKey: key.appKey, + }, + }, + data: { + value: Buffer.from(value), + }, + }); + } + } + + return await this.prisma.node.findFirst({ + where: { id: request.id }, + select: { id: true, label: true, metadata: true }, + }); + }); + + return node; + } + + public async removeNode(request: IRemoveNodeRequest): Promise { + // TODO: Check conditions? Delete edge nodes? + + await this.prisma.$transaction(async (client) => { + await this.prisma.node.delete({ where: { id: request.id } }); + }); + console.log("Removed", request); + } } export class NodeWrapper { diff --git a/src/core/types.ts b/src/core/types.ts index 9caad3f..e58f2e4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,3 +1,14 @@ +import { Node, NodeMeta } from "@prisma/client"; + +export interface FetchedNode extends Node { + metadata: NodeMeta[]; +} + +export interface IFindManyNodesRequest { + select_keys?: IMetaKey[]; + take?: number; +} + export interface IRegisterAppRequest { name: string; } @@ -34,6 +45,17 @@ export interface ICreateNodeRequest { metadata_keys?: Map; } +export interface IUpdateNodeRequest { + id: string; + + /** A set of metadata keys to values */ + metadata_keys?: Map; +} + +export interface IRemoveNodeRequest { + id: string; +} + export interface ICreateEdgeRequest { label: string; from_node: string; diff --git a/src/routes/todos/index.tsx b/src/routes/todos/index.tsx index 7c4070a..f8c8678 100644 --- a/src/routes/todos/index.tsx +++ b/src/routes/todos/index.tsx @@ -1,24 +1,69 @@ -import { For, batch, createEffect, createSignal } from "solid-js"; +import { For, batch, createEffect, createSignal, JSX } from "solid-js"; import { SetStoreFunction, Store, createStore } from "solid-js/store"; -import { createServerAction$ } from "solid-start/server"; +import { useRouteData } from "solid-start"; +import { createServerAction$, createServerData$ } from "solid-start/server"; import { db, todosApp } from "~/db"; +const retrieveMeta = (node, key) => { + const meta = node.metadata.find( + (meta) => meta.appId == todosApp.id && meta.appKey == key + ); + if (!meta) return null; + const metaValue = meta.value.toString(); + console.log("s", key, metaValue); + return JSON.parse(metaValue); +}; + +const nodeToTodoItem = (node) => ({ + id: node.id, + title: retrieveMeta(node, "title"), + completed: retrieveMeta(node, "completed"), + committed: true, + deleted: false, +}); + interface TodoItem { + id: string; title: string; - done: boolean; + completed: boolean; committed: boolean; + deleted: boolean; +} + +export function routeData() { + return createServerData$(async () => { + const nodes = await db.findManyNodes({ + select_keys: [ + { appId: todosApp.id, appKey: "title" }, + { appId: todosApp.id, appKey: "completed" }, + ], + }); + if (!nodes) return null; + + const initTodos = nodes.map(nodeToTodoItem); + return { initTodos }; + }); } export default function Todos() { - const [newTitle, setTitle] = createSignal(""); - const [todos, setTodos] = createTodoStore("todos", []); + const ouais = useRouteData(); + if (!ouais()) return <>Loading...; + const { initTodos } = ouais(); - const [adding, add] = createServerAction$( + const [newTitle, setTitle] = createSignal(""); + const [todos, setTodos] = createTodoStore("todos", initTodos); + + const [adding, addTodoAction] = createServerAction$( async ({ title }: { title: string }) => { const metadata_keys = new Map(); - const keyOf = (appKey: string) => ({ appId: todosApp.id, appKey }); - metadata_keys.set(keyOf("title"), title); + const setMeta = (appKey: string, value: any) => + metadata_keys.set( + { appId: todosApp.id, appKey }, + JSON.stringify(value) + ); + setMeta("title", title); + setMeta("completed", false); const node = await db.createNode({ label: `todo-${title}`, @@ -27,10 +72,11 @@ export default function Todos() { console.log("Created node", node); return node; - } + }, + { invalidate: ["todos"] } ); - const addTodo = (e: SubmitEvent) => { + const addTodo = async (e: SubmitEvent) => { e.preventDefault(); e.stopPropagation(); @@ -40,15 +86,89 @@ export default function Todos() { batch(() => { setTodos(idx, { title, - done: false, + completed: false, committed: false, + deleted: false, }); setTitle(""); }); - add({ title }); - console.log("Adding", adding); + const node = await addTodoAction({ title }); + batch(() => { + setTodos(idx, "id", node.id); + setTodos(idx, "committed", true); + }); + }; + + const [updating, updateTodoAction] = createServerAction$( + async ({ + id, + title, + completed, + }: { + id: string; + title: string; + completed: boolean; + }) => { + const metadata_keys = new Map(); + const setMeta = (appKey: string, value: any) => + metadata_keys.set( + { appId: todosApp.id, appKey }, + JSON.stringify(value) + ); + setMeta("title", title); + setMeta("completed", completed); + + const node = await db.updateNode({ id, metadata_keys }); + console.log("Updated node", node); + + return nodeToTodoItem(node); + } + ); + + const updateNode = async ( + idx: number, + prop: keyof TodoItem, + newValue: any + ) => { + batch(() => { + setTodos(idx, "committed", false); + setTodos(idx, prop, newValue); + }); + + const todoItem = todos[idx]; + const node = await updateTodoAction({ + id: todoItem.id, + title: todoItem.title, + completed: todoItem.completed, + }); + if (node) + batch(() => { + setTodos(idx, "completed", node.completed); + setTodos(idx, "committed", true); + }); + }; + + const [removing, removeTodoAction] = createServerAction$( + async ({ id }: { id: string }) => { + await db.removeNode({ id }); + }, + { invalidate: ["todos"] } + ); + + const removeTodo = async (idx: number) => { + const todo = todos[idx]; + + batch(() => { + setTodos(idx, "deleted", true); + setTodos(idx, "committed", false); + }); + + await removeTodoAction({ id: todo.id }); + console.log("Removed", todo); + // TODO: Is there a race condition here with the index? + setTodos((t) => removeIndex(t, idx)); }; return ( @@ -60,28 +180,42 @@ export default function Todos() { required value={newTitle()} onInput={(e) => setTitle(e.currentTarget.value)} + disabled={adding.pending} /> - Todos: + +

Todos:

- {(todo, i) => ( -
- setTodos(i(), "done", e.currentTarget.checked)} - /> - setTodos(i(), "title", e.currentTarget.value)} - /> - -
- )} + {(todo, i) => { + const style: JSX.CSSProperties = {}; + if (todo.deleted) style["text-decoration"] = "line-through"; + return ( +
+

{JSON.stringify(todo)}

+ + + updateNode(i(), "completed", e.currentTarget.checked) + } + /> + + updateNode(i(), "title", e.currentTarget.value) + } + /> + + + {updating?.input?.id == todo.id && + updating.pending && + "saving..."} +
+ ); + }}
); @@ -94,7 +228,7 @@ function createTodoStore( // const localState = localStorage.getItem(name); const [state, setState] = createStore( - init + init || [] // localState ? JSON.parse(localState) : init ); diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index e347acf..0000000 --- a/vite.config.js +++ /dev/null @@ -1,6 +0,0 @@ -import solid from "solid-start/vite"; -import { defineConfig } from "vite"; -export default defineConfig({ - plugins: [solid()], - ssr: { external: ["@prisma/client"] }, -});