feat: drizzle schema + migrations for products/snapshots/alerts
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -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
9
drizzle.config.ts
Normal 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! },
|
||||
})
|
||||
38
drizzle/0000_motionless_beast.sql
Normal file
38
drizzle/0000_motionless_beast.sql
Normal 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);
|
||||
273
drizzle/meta/0000_snapshot.json
Normal file
273
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1779716926721,
|
||||
"tag": "0000_motionless_beast",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
10
src/lib/db/index.ts
Normal 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
46
src/lib/db/schema.ts
Normal 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
|
||||
Reference in New Issue
Block a user