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 {
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])

View file

@ -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<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> {
// TODO: Some kind of authentication maybe?
@ -69,6 +101,45 @@ export class Database {
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 {

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 {
name: string;
}
@ -34,6 +45,17 @@ export interface ICreateNodeRequest {
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 {
label: 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 { 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<TodoItem[]>("todos", []);
const ouais = useRouteData<typeof routeData>();
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 }) => {
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}
/>
<button>+</button>
</form>
Todos:
<p>Todos:</p>
<For each={todos}>
{(todo, i) => (
<div>
<input
type="checkbox"
checked={todo.done}
onChange={(e) => setTodos(i(), "done", e.currentTarget.checked)}
/>
<input
type="text"
value={todo.title}
onChange={(e) => setTodos(i(), "title", e.currentTarget.value)}
/>
<button onClick={() => setTodos((t) => removeIndex(t, i()))}>
x
</button>
</div>
)}
{(todo, i) => {
const style: JSX.CSSProperties = {};
if (todo.deleted) style["text-decoration"] = "line-through";
return (
<div style={style}>
<p>{JSON.stringify(todo)}</p>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) =>
updateNode(i(), "completed", e.currentTarget.checked)
}
/>
<input
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>
</>
);
@ -94,7 +228,7 @@ function createTodoStore<T extends object>(
// const localState = localStorage.getItem(name);
const [state, setState] = createStore<T>(
init
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"] },
});