todo app crud
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Michael Zhang 2023-04-18 20:44:44 -05:00
parent d82993f2a7
commit 97774d5852
Signed by: michael
GPG key ID: BDA47A31A3C8EE6B
6 changed files with 278 additions and 39 deletions

View file

@ -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;

View file

@ -46,7 +46,7 @@ model Edge {
model NodeMeta { model NodeMeta {
nodeId String 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 /// The app that created this metadata field
app App @relation(fields: [appId], references: [id]) app App @relation(fields: [appId], references: [id])

View file

@ -1,9 +1,14 @@
import { Node, App, PrismaClient } from "@prisma/client"; import { Node, App, PrismaClient } from "@prisma/client";
import { import {
FetchedNode,
ICreateNodeRequest, ICreateNodeRequest,
IFindManyNodesRequest,
IGetNodeRequest, IGetNodeRequest,
IRegisterAppRequest, IRegisterAppRequest,
IRemoveNodeRequest,
IUpdateNodeRequest,
} from "./types"; } from "./types";
import { inspect } from "util";
export class Database { export class Database {
private prisma: PrismaClient; private prisma: PrismaClient;
@ -12,6 +17,33 @@ export class Database {
this.prisma = new PrismaClient(); this.prisma = new PrismaClient();
} }
/**
* Query for a node. If you are querying by ID, use {@link getNode} instead.
*/
public async findManyNodes(
request: IFindManyNodesRequest
): Promise<FetchedNode[] | null> {
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<App> { public async registerApp(request: IRegisterAppRequest): Promise<App> {
// TODO: Some kind of authentication maybe? // TODO: Some kind of authentication maybe?
@ -69,6 +101,45 @@ export class Database {
return createdNode; return createdNode;
}); });
} }
public async updateNode(request: IUpdateNodeRequest): Promise<Node | null> {
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<void> {
// 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 { export class NodeWrapper {

View file

@ -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 { export interface IRegisterAppRequest {
name: string; name: string;
} }
@ -34,6 +45,17 @@ export interface ICreateNodeRequest {
metadata_keys?: Map<IMetaKey, string>; metadata_keys?: Map<IMetaKey, string>;
} }
export interface IUpdateNodeRequest {
id: string;
/** A set of metadata keys to values */
metadata_keys?: Map<IMetaKey, string>;
}
export interface IRemoveNodeRequest {
id: string;
}
export interface ICreateEdgeRequest { export interface ICreateEdgeRequest {
label: string; label: string;
from_node: string; from_node: string;

View file

@ -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 { 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"; 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 { interface TodoItem {
id: string;
title: string; title: string;
done: boolean; completed: boolean;
committed: 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() { export default function Todos() {
const [newTitle, setTitle] = createSignal(""); const ouais = useRouteData<typeof routeData>();
const [todos, setTodos] = createTodoStore<TodoItem[]>("todos", []); if (!ouais()) return <>Loading...</>;
const { initTodos } = ouais();
const [adding, add] = createServerAction$( const [newTitle, setTitle] = createSignal("");
const [todos, setTodos] = createTodoStore<TodoItem[]>("todos", initTodos);
const [adding, addTodoAction] = createServerAction$(
async ({ title }: { title: string }) => { async ({ title }: { title: string }) => {
const metadata_keys = new Map(); const metadata_keys = new Map();
const keyOf = (appKey: string) => ({ appId: todosApp.id, appKey }); const setMeta = (appKey: string, value: any) =>
metadata_keys.set(keyOf("title"), title); metadata_keys.set(
{ appId: todosApp.id, appKey },
JSON.stringify(value)
);
setMeta("title", title);
setMeta("completed", false);
const node = await db.createNode({ const node = await db.createNode({
label: `todo-${title}`, label: `todo-${title}`,
@ -27,10 +72,11 @@ export default function Todos() {
console.log("Created node", node); console.log("Created node", node);
return node; return node;
} },
{ invalidate: ["todos"] }
); );
const addTodo = (e: SubmitEvent) => { const addTodo = async (e: SubmitEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -40,15 +86,89 @@ export default function Todos() {
batch(() => { batch(() => {
setTodos(idx, { setTodos(idx, {
title, title,
done: false, completed: false,
committed: false, committed: false,
deleted: false,
}); });
setTitle(""); setTitle("");
}); });
add({ title }); const node = await addTodoAction({ title });
console.log("Adding", adding); 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 ( return (
@ -60,28 +180,42 @@ export default function Todos() {
required required
value={newTitle()} value={newTitle()}
onInput={(e) => setTitle(e.currentTarget.value)} onInput={(e) => setTitle(e.currentTarget.value)}
disabled={adding.pending}
/> />
<button>+</button> <button>+</button>
</form> </form>
Todos:
<p>Todos:</p>
<For each={todos}> <For each={todos}>
{(todo, i) => ( {(todo, i) => {
<div> const style: JSX.CSSProperties = {};
<input if (todo.deleted) style["text-decoration"] = "line-through";
type="checkbox" return (
checked={todo.done} <div style={style}>
onChange={(e) => setTodos(i(), "done", e.currentTarget.checked)} <p>{JSON.stringify(todo)}</p>
/>
<input <input
type="text" type="checkbox"
value={todo.title} checked={todo.completed}
onChange={(e) => setTodos(i(), "title", e.currentTarget.value)} onChange={(e) =>
/> updateNode(i(), "completed", e.currentTarget.checked)
<button onClick={() => setTodos((t) => removeIndex(t, i()))}> }
x />
</button> <input
</div> type="text"
)} value={todo.title}
onChange={(e) =>
updateNode(i(), "title", e.currentTarget.value)
}
/>
<button onClick={() => removeTodo(i())}>x</button>
{updating?.input?.id == todo.id &&
updating.pending &&
"saving..."}
</div>
);
}}
</For> </For>
</> </>
); );
@ -94,7 +228,7 @@ function createTodoStore<T extends object>(
// const localState = localStorage.getItem(name); // const localState = localStorage.getItem(name);
const [state, setState] = createStore<T>( const [state, setState] = createStore<T>(
init init || []
// localState ? JSON.parse(localState) : init // localState ? JSON.parse(localState) : init
); );

View file

@ -1,6 +0,0 @@
import solid from "solid-start/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [solid()],
ssr: { external: ["@prisma/client"] },
});