Add Drizzle ORM with shared db package (#10)
This commit is contained in:
parent
6a3e566438
commit
9deda5f125
13 changed files with 783 additions and 109 deletions
|
|
@ -11,11 +11,13 @@
|
|||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import postgres from "postgres";
|
||||
import { createDb, type Database } from "@trails-cool/db";
|
||||
|
||||
const connectionString =
|
||||
process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails";
|
||||
let _db: Database | null = null;
|
||||
|
||||
export const sql = postgres(connectionString);
|
||||
|
||||
export async function initDb() {
|
||||
await sql`
|
||||
CREATE SCHEMA IF NOT EXISTS planner
|
||||
`;
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS planner.sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
yjs_state BYTEA,
|
||||
callback_url TEXT,
|
||||
callback_token TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)
|
||||
`;
|
||||
export function getDb(): Database {
|
||||
if (!_db) {
|
||||
_db = createDb();
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import * as Y from "yjs";
|
||||
import { eq, and, desc, lt } from "drizzle-orm";
|
||||
import { getOrCreateDoc, deleteDoc } from "./yjs-server";
|
||||
import { sql } from "./db";
|
||||
import { getDb } from "./db";
|
||||
import { sessions } from "@trails-cool/db/schema/planner";
|
||||
|
||||
export interface SessionMetadata {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
lastActivity: Date;
|
||||
callbackUrl?: string;
|
||||
callbackToken?: string;
|
||||
closed: boolean;
|
||||
}
|
||||
export type SessionMetadata = typeof sessions.$inferSelect;
|
||||
|
||||
export async function createSession(options?: {
|
||||
callbackUrl?: string;
|
||||
|
|
@ -19,52 +14,60 @@ export async function createSession(options?: {
|
|||
const id = randomUUID();
|
||||
const doc = getOrCreateDoc(id);
|
||||
|
||||
const [row] = await sql`
|
||||
INSERT INTO planner.sessions (id, yjs_state, callback_url, callback_token)
|
||||
VALUES (${id}, ${Buffer.from(Y.encodeStateAsUpdate(doc))}, ${options?.callbackUrl ?? null}, ${options?.callbackToken ?? null})
|
||||
RETURNING id, created_at, last_activity, callback_url, callback_token, closed
|
||||
`;
|
||||
const [row] = await getDb()
|
||||
.insert(sessions)
|
||||
.values({
|
||||
id,
|
||||
yjsState: Buffer.from(Y.encodeStateAsUpdate(doc)),
|
||||
callbackUrl: options?.callbackUrl,
|
||||
callbackToken: options?.callbackToken,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapRow(row);
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function getSession(id: string): Promise<SessionMetadata | undefined> {
|
||||
const [row] = await sql`
|
||||
SELECT id, created_at, last_activity, callback_url, callback_token, closed
|
||||
FROM planner.sessions
|
||||
WHERE id = ${id} AND closed = FALSE
|
||||
`;
|
||||
return row ? mapRow(row) : undefined;
|
||||
const [row] = await getDb()
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(and(eq(sessions.id, id), eq(sessions.closed, false)));
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function touchSession(id: string): Promise<void> {
|
||||
await sql`
|
||||
UPDATE planner.sessions SET last_activity = NOW() WHERE id = ${id}
|
||||
`;
|
||||
await getDb()
|
||||
.update(sessions)
|
||||
.set({ lastActivity: new Date() })
|
||||
.where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
export async function saveSessionState(id: string): Promise<void> {
|
||||
const doc = getOrCreateDoc(id);
|
||||
const state = Y.encodeStateAsUpdate(doc);
|
||||
await sql`
|
||||
UPDATE planner.sessions
|
||||
SET yjs_state = ${Buffer.from(state)}, last_activity = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
await getDb()
|
||||
.update(sessions)
|
||||
.set({ yjsState: Buffer.from(state), lastActivity: new Date() })
|
||||
.where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
export async function loadSessionState(id: string): Promise<Uint8Array | null> {
|
||||
const [row] = await sql`
|
||||
SELECT yjs_state FROM planner.sessions WHERE id = ${id}
|
||||
`;
|
||||
return row?.yjs_state ? new Uint8Array(row.yjs_state) : null;
|
||||
const [row] = await getDb()
|
||||
.select({ yjsState: sessions.yjsState })
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, id));
|
||||
|
||||
return row?.yjsState ? new Uint8Array(row.yjsState) : null;
|
||||
}
|
||||
|
||||
export async function closeSession(id: string): Promise<boolean> {
|
||||
const result = await sql`
|
||||
UPDATE planner.sessions SET closed = TRUE WHERE id = ${id} AND closed = FALSE
|
||||
RETURNING id
|
||||
`;
|
||||
const result = await getDb()
|
||||
.update(sessions)
|
||||
.set({ closed: true })
|
||||
.where(and(eq(sessions.id, id), eq(sessions.closed, false)))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
deleteDoc(id);
|
||||
return result.length > 0;
|
||||
}
|
||||
|
|
@ -88,35 +91,22 @@ export function initializeSessionWithWaypoints(
|
|||
}
|
||||
|
||||
export async function listSessions(): Promise<SessionMetadata[]> {
|
||||
const rows = await sql`
|
||||
SELECT id, created_at, last_activity, callback_url, callback_token, closed
|
||||
FROM planner.sessions
|
||||
WHERE closed = FALSE
|
||||
ORDER BY last_activity DESC
|
||||
`;
|
||||
return rows.map(mapRow);
|
||||
return getDb()
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.closed, false))
|
||||
.orderBy(desc(sessions.lastActivity));
|
||||
}
|
||||
|
||||
export async function expireSessions(maxAgeDays: number = 7): Promise<number> {
|
||||
const result = await sql`
|
||||
DELETE FROM planner.sessions
|
||||
WHERE last_activity < NOW() - INTERVAL '1 day' * ${maxAgeDays}
|
||||
RETURNING id
|
||||
`;
|
||||
const cutoff = new Date(Date.now() - maxAgeDays * 24 * 60 * 60 * 1000);
|
||||
const result = await getDb()
|
||||
.delete(sessions)
|
||||
.where(lt(sessions.lastActivity, cutoff))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
for (const row of result) {
|
||||
deleteDoc(row.id);
|
||||
}
|
||||
return result.length;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function mapRow(row: any): SessionMetadata {
|
||||
return {
|
||||
id: row.id as string,
|
||||
createdAt: row.created_at as Date,
|
||||
lastActivity: row.last_activity as Date,
|
||||
callbackUrl: (row.callback_url as string) ?? undefined,
|
||||
callbackToken: (row.callback_token as string) ?? undefined,
|
||||
closed: row.closed as boolean,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,14 @@
|
|||
"dependencies": {
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
"@trails-cool/map": "workspace:*",
|
||||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.0",
|
||||
"postgres": "^3.4.8",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@
|
|||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-postgis": "^1.1.1",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"i18next": "^25.10.4",
|
||||
|
|
@ -37,6 +40,7 @@
|
|||
"jsdom": "^29.0.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"playwright": "^1.58.2",
|
||||
"postgres": "^3.4.8",
|
||||
"prettier": "^3.8.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
|
|||
10
packages/db/drizzle.config.ts
Normal file
10
packages/db/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: ["./src/schema/planner.ts", "./src/schema/journal.ts"],
|
||||
out: "./migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails",
|
||||
},
|
||||
});
|
||||
16
packages/db/package.json
Normal file
16
packages/db/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "@trails-cool/db",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema/planner": "./src/schema/planner.ts",
|
||||
"./schema/journal": "./src/schema/journal.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"postgres": "catalog:"
|
||||
}
|
||||
}
|
||||
17
packages/db/src/index.ts
Normal file
17
packages/db/src/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as plannerSchema from "./schema/planner";
|
||||
import * as journalSchema from "./schema/journal";
|
||||
|
||||
export function createDb(connectionString?: string) {
|
||||
const client = postgres(
|
||||
connectionString ?? process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails",
|
||||
);
|
||||
return drizzle(client, {
|
||||
schema: { ...plannerSchema, ...journalSchema },
|
||||
});
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof createDb>;
|
||||
|
||||
export { plannerSchema, journalSchema };
|
||||
86
packages/db/src/schema/journal.ts
Normal file
86
packages/db/src/schema/journal.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import {
|
||||
pgSchema,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
real,
|
||||
jsonb,
|
||||
customType,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
const bytea = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
return "bytea";
|
||||
},
|
||||
});
|
||||
|
||||
const lineString = customType<{ data: string }>({
|
||||
dataType() {
|
||||
return "geometry(LineString, 4326)";
|
||||
},
|
||||
});
|
||||
|
||||
export const journalSchema = pgSchema("journal");
|
||||
|
||||
export const users = journalSchema.table("users", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull().unique(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
username: text("username").notNull().unique(),
|
||||
displayName: text("display_name"),
|
||||
bio: text("bio"),
|
||||
domain: text("domain").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const routes = journalSchema.table("routes", {
|
||||
id: text("id").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").default(""),
|
||||
gpx: text("gpx"),
|
||||
geom: lineString("geom"),
|
||||
routingProfile: text("routing_profile"),
|
||||
distance: real("distance"),
|
||||
elevationGain: real("elevation_gain"),
|
||||
elevationLoss: real("elevation_loss"),
|
||||
dayBreaks: jsonb("day_breaks").$type<number[]>(),
|
||||
tags: jsonb("tags").$type<string[]>(),
|
||||
plannerState: bytea("planner_state"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const routeVersions = journalSchema.table("route_versions", {
|
||||
id: text("id").primaryKey(),
|
||||
routeId: text("route_id")
|
||||
.notNull()
|
||||
.references(() => routes.id, { onDelete: "cascade" }),
|
||||
version: integer("version").notNull(),
|
||||
gpx: text("gpx").notNull(),
|
||||
createdBy: text("created_by").references(() => users.id),
|
||||
changeDescription: text("change_description"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const activities = journalSchema.table("activities", {
|
||||
id: text("id").primaryKey(),
|
||||
ownerId: text("owner_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
routeId: text("route_id").references(() => routes.id),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").default(""),
|
||||
gpx: text("gpx"),
|
||||
geom: lineString("geom"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
duration: integer("duration"),
|
||||
distance: real("distance"),
|
||||
elevationGain: real("elevation_gain"),
|
||||
elevationLoss: real("elevation_loss"),
|
||||
photos: jsonb("photos").$type<string[]>(),
|
||||
participants: jsonb("participants").$type<string[]>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
19
packages/db/src/schema/planner.ts
Normal file
19
packages/db/src/schema/planner.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { pgSchema, text, timestamp, boolean, customType } from "drizzle-orm/pg-core";
|
||||
|
||||
const bytea = customType<{ data: Buffer }>({
|
||||
dataType() {
|
||||
return "bytea";
|
||||
},
|
||||
});
|
||||
|
||||
export const plannerSchema = pgSchema("planner");
|
||||
|
||||
export const sessions = plannerSchema.table("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
yjsState: bytea("yjs_state"),
|
||||
callbackUrl: text("callback_url"),
|
||||
callbackToken: text("callback_token"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
lastActivity: timestamp("last_activity", { withTimezone: true }).notNull().defaultNow(),
|
||||
closed: boolean("closed").notNull().default(false),
|
||||
});
|
||||
8
packages/db/tsconfig.json
Normal file
8
packages/db/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
584
pnpm-lock.yaml
generated
584
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,3 +15,7 @@ catalog:
|
|||
"@tailwindcss/vite": ^4.1.7
|
||||
typescript: ^5.8.3
|
||||
vite: ^6.0.0
|
||||
drizzle-orm: ^0.44.0
|
||||
drizzle-kit: ^0.31.0
|
||||
drizzle-postgis: ^1.1.0
|
||||
postgres: ^3.4.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue