Canvas: Persistent log storage

Change-Id: I3eac705329e6d68d8e4b9a371c6e9b9807f357ec
diff --git a/apps/canvas/back/src/log.ts b/apps/canvas/back/src/log.ts
new file mode 100644
index 0000000..68dc005
--- /dev/null
+++ b/apps/canvas/back/src/log.ts
@@ -0,0 +1,51 @@
+import { Prisma, PrismaClient } from "@prisma/client";
+import { LogItem } from "./project_monitor";
+
+type LogRecord = LogItem & {
+	id: number;
+};
+
+class LogStore {
+	constructor(private prisma: PrismaClient) {}
+
+	async store(projectId: number, serviceName: string, workerId: string, logs: LogItem[]) {
+		await this.prisma.log.createMany({
+			data: logs.map((log) => ({
+				projectId,
+				serviceName,
+				workerId,
+				runId: log.runId,
+				commit: log.commit ?? undefined,
+				contents: log.contents,
+				timestampMilli: log.timestampMilli,
+			})),
+		});
+	}
+
+	async get(
+		projectId: number,
+		serviceName: string,
+		workerId: string,
+		afterId?: number,
+		numRecords?: number,
+	): Promise<LogRecord[]> {
+		const where: Prisma.LogWhereInput = { projectId, serviceName, workerId };
+		if (afterId) {
+			where.id = { gt: afterId };
+		}
+		const logs = await this.prisma.log.findMany({
+			where,
+			orderBy: { timestampMilli: "asc" },
+			take: numRecords ?? 100,
+		});
+		return logs.map((log) => ({
+			id: log.id,
+			timestampMilli: Number(log.timestampMilli),
+			contents: log.contents,
+			runId: log.runId,
+			commit: log.commit ?? undefined,
+		}));
+	}
+}
+
+export default LogStore;