feat(11-01): add tags table to schema with migration and push script
- Add polymorphic tags table (entity_type/entity_id/name) with unique index on (entity_type, entity_id, name) and lookup index on (entity_type, entity_id) — pattern follows comments table (D-05/D-06/D-07) - Add Tag/NewTag types and tagsRelations (no direct FK, query-time join) - Hand-write src/db/migrations/0006_add_tags_table.sql following the established project convention (drizzle-kit generate is unusable here — meta/_journal.json snapshots out of sync since 0001, pre-existing since Phase 8) and add journal entry for 0006_add_tags_table - Add scripts/push-11-tags-migration.ts (idempotent CREATE TABLE/INDEX IF NOT EXISTS) with production deploy note for manual SSH+docker exec apply
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
// PRODUCTION DEPLOY NOTE: This migration is additive-only (CREATE TABLE IF NOT EXISTS +
|
||||||
|
// 2 indexes, no drops/truncates). Per CLAUDE.md Data Safety, apply to production via
|
||||||
|
// SSH+docker exec BEFORE pushing Phase 11 schema-dependent code (Plans 02-04).
|
||||||
|
// Run: npx tsx scripts/push-11-tags-migration.ts (with prod DATABASE_URL)
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
async function push() {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error("DATABASE_URL environment variable is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = postgres(databaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Pushing tags table migration...");
|
||||||
|
|
||||||
|
await client`
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id text PRIMARY KEY,
|
||||||
|
entity_type text NOT NULL,
|
||||||
|
entity_id text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS tags_entity_name_unique
|
||||||
|
ON tags (entity_type, entity_id, name)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client`
|
||||||
|
CREATE INDEX IF NOT EXISTS tags_entity_idx
|
||||||
|
ON tags (entity_type, entity_id)
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log("✓ tags table created successfully");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.message.includes("already exists")) {
|
||||||
|
console.log("✓ tags table already exists (skipped)");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error("Error pushing migration:", err.message);
|
||||||
|
} else {
|
||||||
|
console.error("Unknown error:", err);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
push();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "tags" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tags_entity_name_unique" ON "tags" USING btree ("entity_type","entity_id","name");
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "tags_entity_idx" ON "tags" USING btree ("entity_type","entity_id");
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1781151516000,
|
"when": 1781151516000,
|
||||||
"tag": "0001_add_services_table",
|
"tag": "0001_add_services_table",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781442000000,
|
||||||
|
"tag": "0006_add_tags_table",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
boolean,
|
boolean,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
|
uniqueIndex,
|
||||||
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -110,6 +112,33 @@ export const comments = pgTable("comments", {
|
|||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ TAGS (polymorphic — services now, leads in Phase 14) ============
|
||||||
|
// entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are
|
||||||
|
// separate pools even though they share this table. No `color` column — badge
|
||||||
|
// color is derived deterministically from `name` via hash (D-07).
|
||||||
|
export const tags = pgTable(
|
||||||
|
"tags",
|
||||||
|
{
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14)
|
||||||
|
entity_id: text("entity_id").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
entityTagUnique: uniqueIndex("tags_entity_name_unique").on(
|
||||||
|
t.entity_type,
|
||||||
|
t.entity_id,
|
||||||
|
t.name
|
||||||
|
),
|
||||||
|
entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// ============ PAYMENTS ============
|
// ============ PAYMENTS ============
|
||||||
export const payments = pgTable("payments", {
|
export const payments = pgTable("payments", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
@@ -460,6 +489,10 @@ export const commentsRelations = relations(comments, (_) => ({
|
|||||||
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
|
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const tagsRelations = relations(tags, (_) => ({
|
||||||
|
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
|
||||||
|
}));
|
||||||
|
|
||||||
export const paymentsRelations = relations(payments, ({ one }) => ({
|
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||||
project: one(projects, {
|
project: one(projects, {
|
||||||
fields: [payments.project_id],
|
fields: [payments.project_id],
|
||||||
@@ -578,6 +611,8 @@ export type Deliverable = typeof deliverables.$inferSelect;
|
|||||||
export type NewDeliverable = typeof deliverables.$inferInsert;
|
export type NewDeliverable = typeof deliverables.$inferInsert;
|
||||||
export type Comment = typeof comments.$inferSelect;
|
export type Comment = typeof comments.$inferSelect;
|
||||||
export type NewComment = typeof comments.$inferInsert;
|
export type NewComment = typeof comments.$inferInsert;
|
||||||
|
export type Tag = typeof tags.$inferSelect;
|
||||||
|
export type NewTag = typeof tags.$inferInsert;
|
||||||
export type Payment = typeof payments.$inferSelect;
|
export type Payment = typeof payments.$inferSelect;
|
||||||
export type NewPayment = typeof payments.$inferInsert;
|
export type NewPayment = typeof payments.$inferInsert;
|
||||||
export type Document = typeof documents.$inferSelect;
|
export type Document = typeof documents.$inferSelect;
|
||||||
|
|||||||
Reference in New Issue
Block a user