Merge pull request 'spike(db): evaluate Drizzle and Tinqer against the hardest query we have (#216)' (#230) from feature/216-drizzle-spike into main
Reviewed-on: #230
This commit was merged in pull request #230.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
// Spike configuration (#216). Credentials come from the environment rather than
|
||||||
|
// this file — the same rule the rest of the repo follows, and this one points at
|
||||||
|
// a developer's local database, not a deployed one.
|
||||||
|
//
|
||||||
|
// DRIZZLE_DATABASE_URL=postgres://redefined_local:redefined_local@localhost:55500/redefined_local
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/db-drizzle/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DRIZZLE_DATABASE_URL ?? ''
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
-- Current sql file was generated after introspecting the database
|
||||||
|
-- If you want to run this migration please uncomment this code before executing migrations
|
||||||
|
/*
|
||||||
|
CREATE TABLE "pgmigrations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"run_on" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "admin_settings" (
|
||||||
|
"key" text PRIMARY KEY NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "item_images" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"image_path" text NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "shipping_addresses" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"customer_id" integer NOT NULL,
|
||||||
|
"full_name" text NOT NULL,
|
||||||
|
"address_line1" text NOT NULL,
|
||||||
|
"address_line2" text,
|
||||||
|
"city" text NOT NULL,
|
||||||
|
"state" text NOT NULL,
|
||||||
|
"postal_code" text NOT NULL,
|
||||||
|
"country" text DEFAULT 'US' NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"usps_validated" boolean DEFAULT false NOT NULL,
|
||||||
|
"usps_standardized" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "items" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"price_cents" integer NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"reserved_until" timestamp with time zone,
|
||||||
|
"sold_at" timestamp with time zone,
|
||||||
|
"paypal_order_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"category_id" integer
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tags" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"color" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "categories" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"parent_id" integer,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "orders" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"item_id" integer,
|
||||||
|
"customer_id" integer,
|
||||||
|
"checkout_id" integer,
|
||||||
|
"processor" text NOT NULL,
|
||||||
|
"processor_order_id" text,
|
||||||
|
"amount_cents" integer,
|
||||||
|
"status" text,
|
||||||
|
"raw_event" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "cart_items" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"cart_id" integer NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"last_reminder_sent_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "cart_items_item_id_key" UNIQUE("item_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "carts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"customer_id" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "carts_customer_id_key" UNIQUE("customer_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "customer_tokens" (
|
||||||
|
"token" text PRIMARY KEY NOT NULL,
|
||||||
|
"customer_id" integer NOT NULL,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "checkouts" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"customer_id" integer,
|
||||||
|
"shipping_address_id" integer,
|
||||||
|
"processor" text NOT NULL,
|
||||||
|
"processor_order_id" text,
|
||||||
|
"amount_cents" integer,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"raw_event" jsonb,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "customer_sessions" (
|
||||||
|
"token" text PRIMARY KEY NOT NULL,
|
||||||
|
"customer_id" integer NOT NULL,
|
||||||
|
"expires_at" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "customers" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"password_hash" text NOT NULL,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"marketing_consent" boolean DEFAULT false NOT NULL,
|
||||||
|
"marketing_consent_at" timestamp with time zone,
|
||||||
|
"marketing_consent_text" text,
|
||||||
|
"unsubscribe_token" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"disabled_at" timestamp with time zone,
|
||||||
|
"favorite_alerts" boolean DEFAULT false NOT NULL,
|
||||||
|
"favorite_alerts_at" timestamp with time zone,
|
||||||
|
"favorite_alerts_text" text,
|
||||||
|
"first_name" text,
|
||||||
|
"last_name" text,
|
||||||
|
CONSTRAINT "customers_email_key" UNIQUE("email"),
|
||||||
|
CONSTRAINT "customers_unsubscribe_token_key" UNIQUE("unsubscribe_token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "item_tags" (
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"tag_id" integer NOT NULL,
|
||||||
|
CONSTRAINT "item_tags_pkey" PRIMARY KEY("item_id","tag_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "favorites" (
|
||||||
|
"customer_id" integer NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "favorites_pkey" PRIMARY KEY("customer_id","item_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "checkout_items" (
|
||||||
|
"checkout_id" integer NOT NULL,
|
||||||
|
"item_id" integer NOT NULL,
|
||||||
|
"price_cents" integer NOT NULL,
|
||||||
|
CONSTRAINT "checkout_items_pkey" PRIMARY KEY("checkout_id","item_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "item_images" ADD CONSTRAINT "item_images_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shipping_addresses" ADD CONSTRAINT "shipping_addresses_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "items" ADD CONSTRAINT "items_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "orders" ADD CONSTRAINT "orders_checkout_id_fkey" FOREIGN KEY ("checkout_id") REFERENCES "public"."checkouts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_cart_id_fkey" FOREIGN KEY ("cart_id") REFERENCES "public"."carts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "cart_items" ADD CONSTRAINT "cart_items_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "carts" ADD CONSTRAINT "carts_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "customer_tokens" ADD CONSTRAINT "customer_tokens_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "checkouts" ADD CONSTRAINT "checkouts_shipping_address_id_fkey" FOREIGN KEY ("shipping_address_id") REFERENCES "public"."shipping_addresses"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "customer_sessions" ADD CONSTRAINT "customer_sessions_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "item_tags" ADD CONSTRAINT "item_tags_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "item_tags" ADD CONSTRAINT "item_tags_tag_id_fkey" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "checkout_items" ADD CONSTRAINT "checkout_items_checkout_id_fkey" FOREIGN KEY ("checkout_id") REFERENCES "public"."checkouts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "checkout_items" ADD CONSTRAINT "checkout_items_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "items_category_id_idx" ON "items" USING btree ("category_id" int4_ops);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tags_name_uniq" ON "tags" USING btree (lower(name) text_ops);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "categories_child_name_uniq" ON "categories" USING btree (parent_id text_ops,lower(name) int4_ops) WHERE (parent_id IS NOT NULL);--> statement-breakpoint
|
||||||
|
CREATE INDEX "categories_parent_id_idx" ON "categories" USING btree ("parent_id" int4_ops);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "categories_root_name_uniq" ON "categories" USING btree (lower(name) text_ops) WHERE (parent_id IS NULL);--> statement-breakpoint
|
||||||
|
CREATE INDEX "customers_disabled_at_idx" ON "customers" USING btree ("disabled_at" timestamptz_ops) WHERE (disabled_at IS NOT NULL);--> statement-breakpoint
|
||||||
|
CREATE INDEX "item_tags_tag_id_idx" ON "item_tags" USING btree ("tag_id" int4_ops);--> statement-breakpoint
|
||||||
|
CREATE INDEX "favorites_item_id_idx" ON "favorites" USING btree ("item_id" int4_ops);
|
||||||
|
*/
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DROP INDEX "tags_name_uniq";--> statement-breakpoint
|
||||||
|
DROP INDEX "categories_child_name_uniq";--> statement-breakpoint
|
||||||
|
DROP INDEX "categories_root_name_uniq";--> statement-breakpoint
|
||||||
|
ALTER TABLE "items" ADD COLUMN "condition_note" text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tags_name_uniq" ON "tags" USING btree (lower(name));--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "categories_child_name_uniq" ON "categories" USING btree (parent_id,lower(name)) WHERE (parent_id IS NOT NULL);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "categories_root_name_uniq" ON "categories" USING btree (lower(name)) WHERE (parent_id IS NULL);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787946791717,
|
||||||
|
"tag": "0000_sleepy_franklin_richards",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787946975565,
|
||||||
|
"tag": "0001_add_condition_note",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { relations } from "drizzle-orm/relations";
|
||||||
|
import { items, itemImages, customers, shippingAddresses, categories, orders, checkouts, carts, cartItems, customerTokens, customerSessions, itemTags, tags, favorites, checkoutItems } from "./schema";
|
||||||
|
|
||||||
|
export const itemImagesRelations = relations(itemImages, ({one}) => ({
|
||||||
|
item: one(items, {
|
||||||
|
fields: [itemImages.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const itemsRelations = relations(items, ({one, many}) => ({
|
||||||
|
itemImages: many(itemImages),
|
||||||
|
category: one(categories, {
|
||||||
|
fields: [items.categoryId],
|
||||||
|
references: [categories.id]
|
||||||
|
}),
|
||||||
|
orders: many(orders),
|
||||||
|
cartItems: many(cartItems),
|
||||||
|
itemTags: many(itemTags),
|
||||||
|
favorites: many(favorites),
|
||||||
|
checkoutItems: many(checkoutItems),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const shippingAddressesRelations = relations(shippingAddresses, ({one, many}) => ({
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [shippingAddresses.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
checkouts: many(checkouts),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const customersRelations = relations(customers, ({many}) => ({
|
||||||
|
shippingAddresses: many(shippingAddresses),
|
||||||
|
orders: many(orders),
|
||||||
|
carts: many(carts),
|
||||||
|
customerTokens: many(customerTokens),
|
||||||
|
checkouts: many(checkouts),
|
||||||
|
customerSessions: many(customerSessions),
|
||||||
|
favorites: many(favorites),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const categoriesRelations = relations(categories, ({one, many}) => ({
|
||||||
|
items: many(items),
|
||||||
|
category: one(categories, {
|
||||||
|
fields: [categories.parentId],
|
||||||
|
references: [categories.id],
|
||||||
|
relationName: "categories_parentId_categories_id"
|
||||||
|
}),
|
||||||
|
categories: many(categories, {
|
||||||
|
relationName: "categories_parentId_categories_id"
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ordersRelations = relations(orders, ({one}) => ({
|
||||||
|
item: one(items, {
|
||||||
|
fields: [orders.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [orders.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
checkout: one(checkouts, {
|
||||||
|
fields: [orders.checkoutId],
|
||||||
|
references: [checkouts.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const checkoutsRelations = relations(checkouts, ({one, many}) => ({
|
||||||
|
orders: many(orders),
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [checkouts.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
shippingAddress: one(shippingAddresses, {
|
||||||
|
fields: [checkouts.shippingAddressId],
|
||||||
|
references: [shippingAddresses.id]
|
||||||
|
}),
|
||||||
|
checkoutItems: many(checkoutItems),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const cartItemsRelations = relations(cartItems, ({one}) => ({
|
||||||
|
cart: one(carts, {
|
||||||
|
fields: [cartItems.cartId],
|
||||||
|
references: [carts.id]
|
||||||
|
}),
|
||||||
|
item: one(items, {
|
||||||
|
fields: [cartItems.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const cartsRelations = relations(carts, ({one, many}) => ({
|
||||||
|
cartItems: many(cartItems),
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [carts.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const customerTokensRelations = relations(customerTokens, ({one}) => ({
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [customerTokens.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const customerSessionsRelations = relations(customerSessions, ({one}) => ({
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [customerSessions.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const itemTagsRelations = relations(itemTags, ({one}) => ({
|
||||||
|
item: one(items, {
|
||||||
|
fields: [itemTags.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
tag: one(tags, {
|
||||||
|
fields: [itemTags.tagId],
|
||||||
|
references: [tags.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const tagsRelations = relations(tags, ({many}) => ({
|
||||||
|
itemTags: many(itemTags),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const favoritesRelations = relations(favorites, ({one}) => ({
|
||||||
|
customer: one(customers, {
|
||||||
|
fields: [favorites.customerId],
|
||||||
|
references: [customers.id]
|
||||||
|
}),
|
||||||
|
item: one(items, {
|
||||||
|
fields: [favorites.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const checkoutItemsRelations = relations(checkoutItems, ({one}) => ({
|
||||||
|
checkout: one(checkouts, {
|
||||||
|
fields: [checkoutItems.checkoutId],
|
||||||
|
references: [checkouts.id]
|
||||||
|
}),
|
||||||
|
item: one(items, {
|
||||||
|
fields: [checkoutItems.itemId],
|
||||||
|
references: [items.id]
|
||||||
|
}),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { pgTable, serial, varchar, timestamp, text, foreignKey, integer, boolean, jsonb, index, uniqueIndex, unique, primaryKey } from "drizzle-orm/pg-core"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const pgmigrations = pgTable("pgmigrations", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
runOn: timestamp("run_on", { mode: 'string' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminSettings = pgTable("admin_settings", {
|
||||||
|
key: text().primaryKey().notNull(),
|
||||||
|
value: text().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemImages = pgTable("item_images", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
imagePath: text("image_path").notNull(),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "item_images_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const shippingAddresses = pgTable("shipping_addresses", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
fullName: text("full_name").notNull(),
|
||||||
|
addressLine1: text("address_line1").notNull(),
|
||||||
|
addressLine2: text("address_line2"),
|
||||||
|
city: text().notNull(),
|
||||||
|
state: text().notNull(),
|
||||||
|
postalCode: text("postal_code").notNull(),
|
||||||
|
country: text().default('US').notNull(),
|
||||||
|
isDefault: boolean("is_default").default(false).notNull(),
|
||||||
|
uspsValidated: boolean("usps_validated").default(false).notNull(),
|
||||||
|
uspsStandardized: jsonb("usps_standardized"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "shipping_addresses_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const items = pgTable("items", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
status: text().default('pending').notNull(),
|
||||||
|
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
||||||
|
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
paypalOrderId: text("paypal_order_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
categoryId: integer("category_id"),
|
||||||
|
}, (table) => [
|
||||||
|
index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.categoryId],
|
||||||
|
foreignColumns: [categories.id],
|
||||||
|
name: "items_category_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const tags = pgTable("tags", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
color: text().notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const categories = pgTable("categories", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
parentId: integer("parent_id"),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`),
|
||||||
|
index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")),
|
||||||
|
uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.parentId],
|
||||||
|
foreignColumns: [table.id],
|
||||||
|
name: "categories_parent_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const orders = pgTable("orders", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
itemId: integer("item_id"),
|
||||||
|
customerId: integer("customer_id"),
|
||||||
|
checkoutId: integer("checkout_id"),
|
||||||
|
processor: text().notNull(),
|
||||||
|
processorOrderId: text("processor_order_id"),
|
||||||
|
amountCents: integer("amount_cents"),
|
||||||
|
status: text(),
|
||||||
|
rawEvent: jsonb("raw_event"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "orders_item_id_fkey"
|
||||||
|
}),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "orders_customer_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.checkoutId],
|
||||||
|
foreignColumns: [checkouts.id],
|
||||||
|
name: "orders_checkout_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const cartItems = pgTable("cart_items", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
cartId: integer("cart_id").notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||||
|
lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.cartId],
|
||||||
|
foreignColumns: [carts.id],
|
||||||
|
name: "cart_items_cart_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "cart_items_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
unique("cart_items_item_id_key").on(table.itemId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const carts = pgTable("carts", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "carts_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
unique("carts_customer_id_key").on(table.customerId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const customerTokens = pgTable("customer_tokens", {
|
||||||
|
token: text().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
kind: text().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "customer_tokens_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const checkouts = pgTable("checkouts", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id"),
|
||||||
|
shippingAddressId: integer("shipping_address_id"),
|
||||||
|
processor: text().notNull(),
|
||||||
|
processorOrderId: text("processor_order_id"),
|
||||||
|
amountCents: integer("amount_cents"),
|
||||||
|
status: text().default('pending').notNull(),
|
||||||
|
rawEvent: jsonb("raw_event"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "checkouts_customer_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.shippingAddressId],
|
||||||
|
foreignColumns: [shippingAddresses.id],
|
||||||
|
name: "checkouts_shipping_address_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const customerSessions = pgTable("customer_sessions", {
|
||||||
|
token: text().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "customer_sessions_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const customers = pgTable("customers", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
email: text().notNull(),
|
||||||
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
|
marketingConsent: boolean("marketing_consent").default(false).notNull(),
|
||||||
|
marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
marketingConsentText: text("marketing_consent_text"),
|
||||||
|
unsubscribeToken: text("unsubscribe_token").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
favoriteAlerts: boolean("favorite_alerts").default(false).notNull(),
|
||||||
|
favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
favoriteAlertsText: text("favorite_alerts_text"),
|
||||||
|
firstName: text("first_name"),
|
||||||
|
lastName: text("last_name"),
|
||||||
|
}, (table) => [
|
||||||
|
index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`),
|
||||||
|
unique("customers_email_key").on(table.email),
|
||||||
|
unique("customers_unsubscribe_token_key").on(table.unsubscribeToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const itemTags = pgTable("item_tags", {
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
tagId: integer("tag_id").notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index("item_tags_tag_id_idx").using("btree", table.tagId.asc().nullsLast().op("int4_ops")),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "item_tags_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.tagId],
|
||||||
|
foreignColumns: [tags.id],
|
||||||
|
name: "item_tags_tag_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const favorites = pgTable("favorites", {
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index("favorites_item_id_idx").using("btree", table.itemId.asc().nullsLast().op("int4_ops")),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "favorites_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "favorites_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const checkoutItems = pgTable("checkout_items", {
|
||||||
|
checkoutId: integer("checkout_id").notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.checkoutId],
|
||||||
|
foreignColumns: [checkouts.id],
|
||||||
|
name: "checkout_items_checkout_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "checkout_items_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}),
|
||||||
|
]);
|
||||||
Generated
+1690
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@
|
|||||||
"@types/markdown-it": "^14.2.0",
|
"@types/markdown-it": "^14.2.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.6",
|
"cookie-parser": "^1.4.6",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-rate-limit": "^8.6.2",
|
"express-rate-limit": "^8.6.2",
|
||||||
"markdown-it": "^15.0.0",
|
"markdown-it": "^15.0.0",
|
||||||
@@ -37,6 +38,8 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
|
"@tinqerjs/pg-promise-adapter": "^0.0.27",
|
||||||
|
"@tinqerjs/tinqer": "^0.0.27",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cookie-parser": "^1.4.7",
|
"@types/cookie-parser": "^1.4.7",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
@@ -47,10 +50,12 @@
|
|||||||
"@types/nodemailer": "^6.4.15",
|
"@types/nodemailer": "^6.4.15",
|
||||||
"@types/pg": "^8.11.6",
|
"@types/pg": "^8.11.6",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"eslint-plugin-sonarjs": "^4.2.0",
|
"eslint-plugin-sonarjs": "^4.2.0",
|
||||||
"globals": "^17.11.0",
|
"globals": "^17.11.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
"pg-promise": "^12.7.1",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.4",
|
"ts-jest": "^29.2.4",
|
||||||
"tsx": "^4.16.5",
|
"tsx": "^4.16.5",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Spike (#216): buildItemFilterSql expressed with Drizzle.
|
||||||
|
//
|
||||||
|
// Deliberately the hardest thing in the codebase — six optional clauses composed
|
||||||
|
// at run time, a recursive CTE for the category subtree, an ANY(...::int[]) tag
|
||||||
|
// match with a count equality, and array parameters. If this cannot be said
|
||||||
|
// cleanly, nothing else in the conversion matters.
|
||||||
|
|
||||||
|
import { SQL, and, eq, gte, lte, sql, inArray, exists } from 'drizzle-orm';
|
||||||
|
import { items, itemTags, favorites, categories } from './schema';
|
||||||
|
|
||||||
|
export interface SpikeFilters {
|
||||||
|
categoryIds: number[];
|
||||||
|
tagIds: number[];
|
||||||
|
minPriceCents: number | null;
|
||||||
|
maxPriceCents: number | null;
|
||||||
|
status: string[] | null;
|
||||||
|
favoritesOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildItemFilterDrizzle(
|
||||||
|
filters: SpikeFilters,
|
||||||
|
favoritesCustomerId: number | null
|
||||||
|
): SQL[] {
|
||||||
|
const clauses: SQL[] = [];
|
||||||
|
|
||||||
|
// The recursive CTE. Drizzle's $with() builds statement-level CTEs; this one
|
||||||
|
// has to sit inside an IN (...) subquery, so it stays a sql`` template.
|
||||||
|
//
|
||||||
|
// Note what that template does with ${filters.categoryIds}: it emits a BIND
|
||||||
|
// PARAMETER, not text. That is the difference from a plain JS template
|
||||||
|
// literal, and it is the whole of the #202 invariant expressed by the type
|
||||||
|
// system rather than by a comment — there is no way to spell "interpolate
|
||||||
|
// this value as SQL text" by accident.
|
||||||
|
if (filters.categoryIds.length) {
|
||||||
|
clauses.push(sql`${items.categoryId} IN (
|
||||||
|
WITH RECURSIVE subtree AS (
|
||||||
|
SELECT id FROM ${categories} WHERE id = ANY(${sql.param(filters.categoryIds)}::int[])
|
||||||
|
UNION ALL
|
||||||
|
SELECT c.id FROM ${categories} c JOIN subtree s ON c.parent_id = s.id
|
||||||
|
)
|
||||||
|
SELECT id FROM subtree
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AND, not OR: the item must carry every selected tag, so the count of
|
||||||
|
// matched rows has to equal the number requested.
|
||||||
|
if (filters.tagIds.length) {
|
||||||
|
clauses.push(sql`(
|
||||||
|
SELECT COUNT(*) FROM ${itemTags} it
|
||||||
|
WHERE it.item_id = ${items.id} AND it.tag_id = ANY(${sql.param(filters.tagIds)}::int[])
|
||||||
|
) = ${filters.tagIds.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.minPriceCents !== null) clauses.push(gte(items.priceCents, filters.minPriceCents));
|
||||||
|
if (filters.maxPriceCents !== null) clauses.push(lte(items.priceCents, filters.maxPriceCents));
|
||||||
|
|
||||||
|
// inArray replaces `= ANY($n::text[])`. Drizzle emits an IN list of binds.
|
||||||
|
if (filters.status !== null) clauses.push(inArray(items.status, filters.status));
|
||||||
|
|
||||||
|
if (filters.favoritesOnly) {
|
||||||
|
if (favoritesCustomerId === null) throw new Error('favorites filter requires a customer id');
|
||||||
|
clauses.push(
|
||||||
|
exists(
|
||||||
|
sql`(SELECT 1 FROM ${favorites} f WHERE f.item_id = ${items.id} AND f.customer_id = ${favoritesCustomerId})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clauses;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function combine(clauses: SQL[]): SQL | undefined {
|
||||||
|
return clauses.length ? and(...clauses) : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { pgTable, serial, varchar, timestamp, text, foreignKey, integer, boolean, jsonb, index, uniqueIndex, unique, primaryKey } from "drizzle-orm/pg-core"
|
||||||
|
import { sql } from "drizzle-orm"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const pgmigrations = pgTable("pgmigrations", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
runOn: timestamp("run_on", { mode: 'string' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const adminSettings = pgTable("admin_settings", {
|
||||||
|
key: text().primaryKey().notNull(),
|
||||||
|
value: text().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const itemImages = pgTable("item_images", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
imagePath: text("image_path").notNull(),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "item_images_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const shippingAddresses = pgTable("shipping_addresses", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
fullName: text("full_name").notNull(),
|
||||||
|
addressLine1: text("address_line1").notNull(),
|
||||||
|
addressLine2: text("address_line2"),
|
||||||
|
city: text().notNull(),
|
||||||
|
state: text().notNull(),
|
||||||
|
postalCode: text("postal_code").notNull(),
|
||||||
|
country: text().default('US').notNull(),
|
||||||
|
isDefault: boolean("is_default").default(false).notNull(),
|
||||||
|
uspsValidated: boolean("usps_validated").default(false).notNull(),
|
||||||
|
uspsStandardized: jsonb("usps_standardized"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "shipping_addresses_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const items = pgTable("items", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
status: text().default('pending').notNull(),
|
||||||
|
reservedUntil: timestamp("reserved_until", { withTimezone: true, mode: 'string' }),
|
||||||
|
soldAt: timestamp("sold_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
paypalOrderId: text("paypal_order_id"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
categoryId: integer("category_id"),
|
||||||
|
conditionNote: text("condition_note"),
|
||||||
|
}, (table) => [
|
||||||
|
index("items_category_id_idx").using("btree", table.categoryId.asc().nullsLast().op("int4_ops")),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.categoryId],
|
||||||
|
foreignColumns: [categories.id],
|
||||||
|
name: "items_category_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const tags = pgTable("tags", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
color: text().notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex("tags_name_uniq").using("btree", sql`lower(name)`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const categories = pgTable("categories", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
name: text().notNull(),
|
||||||
|
parentId: integer("parent_id"),
|
||||||
|
sortOrder: integer("sort_order").default(0).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
uniqueIndex("categories_child_name_uniq").using("btree", sql`parent_id`, sql`lower(name)`).where(sql`(parent_id IS NOT NULL)`),
|
||||||
|
index("categories_parent_id_idx").using("btree", table.parentId.asc().nullsLast().op("int4_ops")),
|
||||||
|
uniqueIndex("categories_root_name_uniq").using("btree", sql`lower(name)`).where(sql`(parent_id IS NULL)`),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.parentId],
|
||||||
|
foreignColumns: [table.id],
|
||||||
|
name: "categories_parent_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const orders = pgTable("orders", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
itemId: integer("item_id"),
|
||||||
|
customerId: integer("customer_id"),
|
||||||
|
checkoutId: integer("checkout_id"),
|
||||||
|
processor: text().notNull(),
|
||||||
|
processorOrderId: text("processor_order_id"),
|
||||||
|
amountCents: integer("amount_cents"),
|
||||||
|
status: text(),
|
||||||
|
rawEvent: jsonb("raw_event"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "orders_item_id_fkey"
|
||||||
|
}),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "orders_customer_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.checkoutId],
|
||||||
|
foreignColumns: [checkouts.id],
|
||||||
|
name: "orders_checkout_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const cartItems = pgTable("cart_items", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
cartId: integer("cart_id").notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
addedAt: timestamp("added_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||||
|
lastReminderSentAt: timestamp("last_reminder_sent_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.cartId],
|
||||||
|
foreignColumns: [carts.id],
|
||||||
|
name: "cart_items_cart_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "cart_items_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
unique("cart_items_item_id_key").on(table.itemId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const carts = pgTable("carts", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "carts_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
unique("carts_customer_id_key").on(table.customerId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const customerTokens = pgTable("customer_tokens", {
|
||||||
|
token: text().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
kind: text().notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "customer_tokens_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const checkouts = pgTable("checkouts", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id"),
|
||||||
|
shippingAddressId: integer("shipping_address_id"),
|
||||||
|
processor: text().notNull(),
|
||||||
|
processorOrderId: text("processor_order_id"),
|
||||||
|
amountCents: integer("amount_cents"),
|
||||||
|
status: text().default('pending').notNull(),
|
||||||
|
rawEvent: jsonb("raw_event"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "checkouts_customer_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.shippingAddressId],
|
||||||
|
foreignColumns: [shippingAddresses.id],
|
||||||
|
name: "checkouts_shipping_address_id_fkey"
|
||||||
|
}).onDelete("set null"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const customerSessions = pgTable("customer_sessions", {
|
||||||
|
token: text().primaryKey().notNull(),
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "customer_sessions_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const customers = pgTable("customers", {
|
||||||
|
id: serial().primaryKey().notNull(),
|
||||||
|
email: text().notNull(),
|
||||||
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
|
marketingConsent: boolean("marketing_consent").default(false).notNull(),
|
||||||
|
marketingConsentAt: timestamp("marketing_consent_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
marketingConsentText: text("marketing_consent_text"),
|
||||||
|
unsubscribeToken: text("unsubscribe_token").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
disabledAt: timestamp("disabled_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
favoriteAlerts: boolean("favorite_alerts").default(false).notNull(),
|
||||||
|
favoriteAlertsAt: timestamp("favorite_alerts_at", { withTimezone: true, mode: 'string' }),
|
||||||
|
favoriteAlertsText: text("favorite_alerts_text"),
|
||||||
|
firstName: text("first_name"),
|
||||||
|
lastName: text("last_name"),
|
||||||
|
}, (table) => [
|
||||||
|
index("customers_disabled_at_idx").using("btree", table.disabledAt.asc().nullsLast().op("timestamptz_ops")).where(sql`(disabled_at IS NOT NULL)`),
|
||||||
|
unique("customers_email_key").on(table.email),
|
||||||
|
unique("customers_unsubscribe_token_key").on(table.unsubscribeToken),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const itemTags = pgTable("item_tags", {
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
tagId: integer("tag_id").notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index("item_tags_tag_id_idx").using("btree", table.tagId.asc().nullsLast().op("int4_ops")),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "item_tags_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.tagId],
|
||||||
|
foreignColumns: [tags.id],
|
||||||
|
name: "item_tags_tag_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.itemId, table.tagId], name: "item_tags_pkey"}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const favorites = pgTable("favorites", {
|
||||||
|
customerId: integer("customer_id").notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
index("favorites_item_id_idx").using("btree", table.itemId.asc().nullsLast().op("int4_ops")),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.customerId],
|
||||||
|
foreignColumns: [customers.id],
|
||||||
|
name: "favorites_customer_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "favorites_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.customerId, table.itemId], name: "favorites_pkey"}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const checkoutItems = pgTable("checkout_items", {
|
||||||
|
checkoutId: integer("checkout_id").notNull(),
|
||||||
|
itemId: integer("item_id").notNull(),
|
||||||
|
priceCents: integer("price_cents").notNull(),
|
||||||
|
}, (table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.checkoutId],
|
||||||
|
foreignColumns: [checkouts.id],
|
||||||
|
name: "checkout_items_checkout_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.itemId],
|
||||||
|
foreignColumns: [items.id],
|
||||||
|
name: "checkout_items_item_id_fkey"
|
||||||
|
}).onDelete("cascade"),
|
||||||
|
primaryKey({ columns: [table.checkoutId, table.itemId], name: "checkout_items_pkey"}),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Spike (#216): what Tinqer can and cannot express, recorded as runnable cases.
|
||||||
|
//
|
||||||
|
// Every case is wrapped in a function rather than evaluated at module level.
|
||||||
|
// defineSelect() parses the lambda eagerly and THROWS on a shape it cannot
|
||||||
|
// handle, so a top-level call turns an unsupported query into an import-time
|
||||||
|
// crash. That is itself a finding worth keeping: the failure is a runtime throw
|
||||||
|
// at load, not a type error, so an unsupported query compiles cleanly and takes
|
||||||
|
// the process down when the module is first required.
|
||||||
|
|
||||||
|
import { createSchema, defineSelect } from '@tinqerjs/tinqer';
|
||||||
|
import { toSql } from '@tinqerjs/pg-promise-adapter';
|
||||||
|
|
||||||
|
interface Db {
|
||||||
|
items: { id: number; name: string; price_cents: number; status: string; category_id: number | null };
|
||||||
|
categories: { id: number; parent_id: number | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = createSchema<Db>();
|
||||||
|
|
||||||
|
export type Outcome = { label: string; ok: boolean; sql?: string; error?: string };
|
||||||
|
|
||||||
|
function attempt(label: string, build: () => unknown, params: Record<string, unknown>): Outcome {
|
||||||
|
try {
|
||||||
|
const r = toSql(build() as never, params as never);
|
||||||
|
return { label, ok: true, sql: r.sql };
|
||||||
|
} catch (e) {
|
||||||
|
return { label, ok: false, error: (e as Error).message.split('\n')[0] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCases(): Outcome[] {
|
||||||
|
return [
|
||||||
|
// Supported.
|
||||||
|
attempt(
|
||||||
|
'compound condition',
|
||||||
|
() =>
|
||||||
|
defineSelect(schema, (q, p: { min: number; max: number }) =>
|
||||||
|
q.from('items').where((i) => i.price_cents >= p.min && i.price_cents <= p.max).select((i) => ({ id: i.id }))
|
||||||
|
),
|
||||||
|
{ min: 1, max: 2 }
|
||||||
|
),
|
||||||
|
attempt(
|
||||||
|
'array membership',
|
||||||
|
() =>
|
||||||
|
defineSelect(schema, (q, p: { statuses: string[] }) =>
|
||||||
|
q.from('items').where((i) => p.statuses.includes(i.status)).select((i) => ({ id: i.id }))
|
||||||
|
),
|
||||||
|
{ statuses: ['available', 'sold'] }
|
||||||
|
),
|
||||||
|
|
||||||
|
// NOT supported — and these are the two shapes buildItemFilterSql needs.
|
||||||
|
attempt(
|
||||||
|
'ternary (clause optional at run time)',
|
||||||
|
() =>
|
||||||
|
defineSelect(schema, (q, p: { apply: boolean; min: number }) =>
|
||||||
|
q.from('items').where((i) => (p.apply ? i.price_cents >= p.min : true)).select((i) => ({ id: i.id }))
|
||||||
|
),
|
||||||
|
{ apply: true, min: 1 }
|
||||||
|
),
|
||||||
|
attempt(
|
||||||
|
'block body with if (clause optional at run time)',
|
||||||
|
() =>
|
||||||
|
defineSelect(schema, (q, p: { apply: boolean; min: number }) => {
|
||||||
|
let x = q.from('items');
|
||||||
|
if (p.apply) x = x.where((i) => i.price_cents >= p.min);
|
||||||
|
return x.select((i) => ({ id: i.id }));
|
||||||
|
}),
|
||||||
|
{ apply: true, min: 1 }
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user