feat: drizzle schema + migrations for products/snapshots/alerts

This commit is contained in:
2026-05-25 13:49:18 +00:00
parent a96a2e60d8
commit fb308da5c5
8 changed files with 393 additions and 0 deletions

View File

@@ -23,6 +23,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",
@@ -606,6 +607,8 @@
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],

9
drizzle.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import 'dotenv/config'
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
})

View File

@@ -0,0 +1,38 @@
CREATE TABLE "alerts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"product_id" uuid NOT NULL,
"type" text NOT NULL,
"config" jsonb NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"last_triggered_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "alert_type_check" CHECK ("alerts"."type" in ('target_price','all_time_low','percent_drop'))
);
--> statement-breakpoint
CREATE TABLE "price_snapshots" (
"id" bigserial PRIMARY KEY NOT NULL,
"product_id" uuid NOT NULL,
"price" numeric(10, 2),
"currency" text DEFAULT 'EUR' NOT NULL,
"availability" text,
"error" text,
"scraped_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"url" text NOT NULL,
"shop" text NOT NULL,
"name" text NOT NULL,
"image_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"last_scraped_at" timestamp with time zone,
"consecutive_failures" integer DEFAULT 0 NOT NULL,
CONSTRAINT "products_url_unique" UNIQUE("url"),
CONSTRAINT "shop_check" CHECK ("products"."shop" in ('amazon','idealo','geizhals'))
);
--> statement-breakpoint
ALTER TABLE "alerts" ADD CONSTRAINT "alerts_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "price_snapshots" ADD CONSTRAINT "price_snapshots_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "snapshots_product_scraped_idx" ON "price_snapshots" USING btree ("product_id","scraped_at" DESC NULLS LAST);

View File

@@ -0,0 +1,273 @@
{
"id": "17d857bc-1a3e-4c61-80cc-1d863410f24a",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.alerts": {
"name": "alerts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"product_id": {
"name": "product_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"last_triggered_at": {
"name": "last_triggered_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"alerts_product_id_products_id_fk": {
"name": "alerts_product_id_products_id_fk",
"tableFrom": "alerts",
"tableTo": "products",
"columnsFrom": [
"product_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"alert_type_check": {
"name": "alert_type_check",
"value": "\"alerts\".\"type\" in ('target_price','all_time_low','percent_drop')"
}
},
"isRLSEnabled": false
},
"public.price_snapshots": {
"name": "price_snapshots",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "bigserial",
"primaryKey": true,
"notNull": true
},
"product_id": {
"name": "product_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"price": {
"name": "price",
"type": "numeric(10, 2)",
"primaryKey": false,
"notNull": false
},
"currency": {
"name": "currency",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'EUR'"
},
"availability": {
"name": "availability",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scraped_at": {
"name": "scraped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"snapshots_product_scraped_idx": {
"name": "snapshots_product_scraped_idx",
"columns": [
{
"expression": "product_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "scraped_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"price_snapshots_product_id_products_id_fk": {
"name": "price_snapshots_product_id_products_id_fk",
"tableFrom": "price_snapshots",
"tableTo": "products",
"columnsFrom": [
"product_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.products": {
"name": "products",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"shop": {
"name": "shop",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"last_scraped_at": {
"name": "last_scraped_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"consecutive_failures": {
"name": "consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"products_url_unique": {
"name": "products_url_unique",
"nullsNotDistinct": false,
"columns": [
"url"
]
}
},
"policies": {},
"checkConstraints": {
"shop_check": {
"name": "shop_check",
"value": "\"products\".\"shop\" in ('amazon','idealo','geizhals')"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1779716926721,
"tag": "0000_motionless_beast",
"breakpoints": true
}
]
}

View File

@@ -32,6 +32,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",

10
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const connectionString = process.env.DATABASE_URL
if (!connectionString) throw new Error('DATABASE_URL not set')
const client = postgres(connectionString, { max: 5 })
export const db = drizzle(client, { schema })
export * from './schema'

46
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,46 @@
import { pgTable, uuid, text, timestamp, boolean, integer, numeric, bigserial, jsonb, index, check } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
export const products = pgTable('products', {
id: uuid('id').primaryKey().defaultRandom(),
url: text('url').notNull().unique(),
shop: text('shop').notNull(),
name: text('name').notNull(),
imageUrl: text('image_url'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
enabled: boolean('enabled').notNull().default(true),
lastScrapedAt: timestamp('last_scraped_at', { withTimezone: true }),
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
}, (t) => ({
shopCheck: check('shop_check', sql`${t.shop} in ('amazon','idealo','geizhals')`),
}))
export const priceSnapshots = pgTable('price_snapshots', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').notNull().default('EUR'),
availability: text('availability'),
error: text('error'),
scrapedAt: timestamp('scraped_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
productScrapedIdx: index('snapshots_product_scraped_idx').on(t.productId, t.scrapedAt.desc()),
}))
export const alerts = pgTable('alerts', {
id: uuid('id').primaryKey().defaultRandom(),
productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
config: jsonb('config').notNull(),
enabled: boolean('enabled').notNull().default(true),
lastTriggeredAt: timestamp('last_triggered_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
typeCheck: check('alert_type_check', sql`${t.type} in ('target_price','all_time_low','percent_drop')`),
}))
export type Product = typeof products.$inferSelect
export type NewProduct = typeof products.$inferInsert
export type PriceSnapshot = typeof priceSnapshots.$inferSelect
export type Alert = typeof alerts.$inferSelect
export type NewAlert = typeof alerts.$inferInsert