feat(db): mesh data model — meshes, members, invites, audit log

- pgSchema "mesh" with 4 tables isolating the peer mesh domain
- Enums: visibility, transport, tier, role
- audit_log is metadata-only (E2E encryption enforced at broker/client)
- Cascade on mesh delete, soft-delete via archivedAt/revokedAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

18
packages/db/src/env.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineEnv } from "envin";
import * as z from "zod";
import { envConfig } from "@turbostarter/shared/constants";
import type { Preset } from "envin/types";
export const preset = {
id: "db",
server: {
DATABASE_URL: z.string().optional(),
},
} as const satisfies Preset;
export const env = defineEnv({
...envConfig,
...preset,
});

2
packages/db/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "drizzle-orm/sql";
export * from "./utils";

View File

@@ -0,0 +1,6 @@
import { createSchemaFactory } from "drizzle-zod";
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
createSchemaFactory({
coerce: true,
});

View File

@@ -0,0 +1,242 @@
import { relations } from "drizzle-orm";
import {
pgTable,
text,
timestamp,
boolean,
integer,
index,
} from "drizzle-orm/pg-core";
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
isAnonymous: boolean("is_anonymous").default(false),
role: text("role"),
banned: boolean("banned").default(false),
banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const passkey = pgTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
createdAt: timestamp("created_at"),
aaguid: text("aaguid"),
},
(table) => [
index("passkey_userId_idx").on(table.userId),
index("passkey_credentialID_idx").on(table.credentialID),
],
);
export const twoFactor = pgTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
);
export const organization = pgTable("organization", {
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
});
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
passkeys: many(passkey),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
}));
export const organizationRelations = relations(organization, ({ many }) => ({
members: many(member),
invitations: many(invitation),
}));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@@ -0,0 +1,73 @@
import { relations } from "drizzle-orm";
import { integer, jsonb, pgSchema, timestamp, text } from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
import { createInsertSchema, createSelectSchema } from "../utils/drizzle-zod";
import { user } from "./auth";
export const schema = pgSchema("chat");
export const messageRoleEnum = schema.enum("role", [
"system",
"assistant",
"user",
]);
export const chat = schema.table("chat", {
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text(),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
createdAt: timestamp().defaultNow(),
});
export const message = schema.table("message", {
id: text().primaryKey().notNull().$defaultFn(generateId),
chatId: text()
.references(() => chat.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
role: messageRoleEnum().notNull(),
createdAt: timestamp().defaultNow(),
});
export const messageRelations = relations(message, ({ many }) => ({
part: many(part),
}));
export const part = schema.table("part", {
id: text().primaryKey().notNull().$defaultFn(generateId),
messageId: text()
.references(() => message.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
type: text().notNull(),
order: integer().notNull(),
details: jsonb().notNull(),
createdAt: timestamp().defaultNow(),
});
export const partRelations = relations(part, ({ one }) => ({
message: one(message, {
fields: [part.messageId],
references: [message.id],
}),
}));
export const selectChatSchema = createSelectSchema(chat);
export const insertChatSchema = createInsertSchema(chat);
export const selectMessageSchema = createSelectSchema(message);
export const insertMessageSchema = createInsertSchema(message);
export const selectPartSchema = createSelectSchema(part);
export const insertPartSchema = createInsertSchema(part);
export type SelectChat = typeof chat.$inferSelect;
export type InsertChat = typeof chat.$inferInsert;
export type SelectMessage = typeof message.$inferSelect;
export type InsertMessage = typeof message.$inferInsert;
export type SelectPart = typeof part.$inferSelect;
export type InsertPart = typeof part.$inferInsert;

View File

@@ -0,0 +1,45 @@
import { relations } from "drizzle-orm";
import { integer, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
import { customer } from "./customer";
export const creditTransactionTypeEnum = pgEnum("credit_transaction_type", [
"signup", // Initial free credits
"purchase", // Bought via billing
"usage", // Consumed by AI features
"admin_grant", // Manually added by admin
"admin_deduct", // Manually removed by admin
"refund", // Refunded credits
"promo", // Promotional credits
"referral", // Referral bonus
"expiry", // Credits expired
]);
export const creditTransaction = pgTable("credit_transaction", {
id: text().primaryKey().$defaultFn(generateId),
customerId: text("customer_id")
.notNull()
.references(() => customer.id, { onDelete: "cascade" }),
amount: integer().notNull(), // Positive = add, Negative = deduct
type: creditTransactionTypeEnum().notNull(),
reason: text(), // Human-readable description
metadata: text(), // JSON for additional context (e.g., AI feature used)
balanceAfter: integer("balance_after").notNull(), // Snapshot for reconciliation
createdAt: timestamp("created_at").notNull().defaultNow(),
createdBy: text("created_by"), // User ID who initiated (for admin actions)
});
export const creditTransactionRelations = relations(
creditTransaction,
({ one }) => ({
customer: one(customer, {
fields: [creditTransaction.customerId],
references: [customer.id],
}),
}),
);
export type CreditTransaction = typeof creditTransaction.$inferSelect;
export type InsertCreditTransaction = typeof creditTransaction.$inferInsert;

View File

@@ -0,0 +1,56 @@
import { integer, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from "../lib/zod";
import { user } from "./auth";
import type * as z from "zod";
export const billingStatusEnum = pgEnum("status", [
"active",
"canceled",
"incomplete",
"incomplete_expired",
"past_due",
"paused",
"trialing",
"unpaid",
]);
export const pricingPlanTypeEnum = pgEnum("plan", [
"free",
"premium",
"enterprise",
]);
export const customer = pgTable("customer", {
id: text().primaryKey().$defaultFn(generateId),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
})
.notNull()
.unique(),
customerId: text().notNull().unique(),
status: billingStatusEnum(),
plan: pricingPlanTypeEnum(),
credits: integer().default(100).notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.$onUpdate(() => new Date()),
});
export const insertCustomerSchema = createInsertSchema(customer);
export const selectCustomerSchema = createSelectSchema(customer);
export const updateCustomerSchema = createUpdateSchema(customer);
export type InsertCustomer = z.infer<typeof insertCustomerSchema>;
export type SelectCustomer = z.infer<typeof selectCustomerSchema>;
export type UpdateCustomer = z.infer<typeof updateCustomerSchema>;

View File

@@ -0,0 +1,66 @@
import { relations } from "drizzle-orm";
import { pgSchema, text, timestamp, integer } from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
import { createInsertSchema, createSelectSchema } from "../utils/drizzle-zod";
import { user } from "./auth";
export const schema = pgSchema("image");
export const aspectRatioEnum = schema.enum("aspect_ratio", [
"square",
"standard",
"landscape",
"portrait",
]);
export const generation = schema.table("generation", {
id: text().primaryKey().notNull().$defaultFn(generateId),
prompt: text().notNull(),
model: text().notNull(),
aspectRatio: aspectRatioEnum().default("square").notNull(),
count: integer().default(1).notNull(),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
createdAt: timestamp().defaultNow(),
completedAt: timestamp(),
});
export const generationRelations = relations(generation, ({ many }) => ({
image: many(image),
}));
export const image = schema.table("image", {
id: text().primaryKey().notNull().$defaultFn(generateId),
generationId: text()
.references(() => generation.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
url: text().notNull(),
createdAt: timestamp().defaultNow(),
});
export const imageRelations = relations(image, ({ one }) => ({
generation: one(generation, {
fields: [image.generationId],
references: [generation.id],
}),
}));
export const selectGenerationSchema = createSelectSchema(generation);
export const insertGenerationSchema = createInsertSchema(generation);
export const selectImageSchema = createSelectSchema(image);
export const insertImageSchema = createInsertSchema(image);
export type SelectGeneration = typeof generation.$inferSelect;
export type InsertGeneration = typeof generation.$inferInsert;
export type SelectImage = typeof image.$inferSelect;
export type InsertImage = typeof image.$inferInsert;

View File

@@ -0,0 +1,46 @@
import * as auth from "./auth";
import * as chat from "./chat";
import * as creditTransactions from "./credit-transaction";
import * as customers from "./customer";
import * as image from "./image";
import * as mesh from "./mesh";
import * as pdf from "./pdf";
type Prefix<
T extends Record<string, unknown>,
K extends keyof T,
P extends string,
> = {
[Key in K as `${P}.${Key & string}`]: T[Key];
};
export const prefix = <T extends Record<string, unknown>, P extends string>(
obj: T,
prefix: P,
) => {
return Object.entries(obj).reduce(
(acc, [key, value]) => ({ ...acc, [`${prefix}.${key}`]: value }),
{} as Prefix<T, keyof T, P>,
);
};
export const schema = {
...auth,
...creditTransactions,
...customers,
...prefix(chat, "chat"),
...prefix(pdf, "pdf"),
...prefix(image, "image"),
...prefix(mesh, "mesh"),
};
// Direct exports for backward compatibility
export * from "./auth";
export * from "./credit-transaction";
export * from "./customer";
// pgSchema-based modules (need explicit exports for drizzle-kit)
export * from "./chat";
export * from "./pdf";
export * from "./image";
export * from "./mesh";

View File

@@ -0,0 +1,171 @@
import { relations } from "drizzle-orm";
import {
integer,
jsonb,
pgSchema,
timestamp,
text,
} from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
import { createInsertSchema, createSelectSchema } from "../utils/drizzle-zod";
import { user } from "./auth";
export const schema = pgSchema("mesh");
export const meshVisibilityEnum = schema.enum("visibility", [
"private",
"public",
]);
export const meshTransportEnum = schema.enum("transport", [
"managed",
"tailscale",
"self_hosted",
]);
export const meshTierEnum = schema.enum("tier", [
"free",
"pro",
"team",
"enterprise",
]);
export const meshRoleEnum = schema.enum("role", ["admin", "member"]);
/**
* A mesh is a peer group of Claude Code sessions that can talk to each
* other via the broker. Ownership is tied to a user; transport/tier
* describe how it's hosted and billed.
*/
export const mesh = schema.table("mesh", {
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text().notNull(),
slug: text().notNull().unique(),
ownerUserId: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
visibility: meshVisibilityEnum().notNull().default("private"),
transport: meshTransportEnum().notNull().default("managed"),
maxPeers: integer(),
tier: meshTierEnum().notNull().default("free"),
createdAt: timestamp().defaultNow().notNull(),
archivedAt: timestamp(),
});
/**
* A member is a peer that has joined a mesh. user_id is nullable to
* allow anonymous/invite-only peers (identity is the ed25519 pubkey).
*/
export const member = schema.table("member", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
userId: text().references(() => user.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
peerPubkey: text().notNull(),
displayName: text().notNull(),
role: meshRoleEnum().notNull().default("member"),
joinedAt: timestamp().defaultNow().notNull(),
lastSeenAt: timestamp(),
revokedAt: timestamp(),
});
/**
* Invite tokens used to join a mesh via shareable URL.
*/
export const invite = schema.table("invite", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
token: text().notNull().unique(),
maxUses: integer().notNull().default(1),
usedCount: integer().notNull().default(0),
role: meshRoleEnum().notNull().default("member"),
expiresAt: timestamp().notNull(),
createdBy: text()
.references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
createdAt: timestamp().defaultNow().notNull(),
revokedAt: timestamp(),
});
/**
* Metadata-only audit log. NEVER stores message content — every
* payload between peers is E2E encrypted client-side (libsodium), so
* the broker/DB only ever see ciphertext + routing events.
*/
export const auditLog = schema.table("audit_log", {
id: text().primaryKey().notNull().$defaultFn(generateId),
meshId: text()
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
eventType: text().notNull(),
actorPeerId: text(),
targetPeerId: text(),
metadata: jsonb().notNull().default({}),
createdAt: timestamp().defaultNow().notNull(),
});
export const meshRelations = relations(mesh, ({ one, many }) => ({
owner: one(user, {
fields: [mesh.ownerUserId],
references: [user.id],
}),
members: many(member),
invites: many(invite),
auditLogs: many(auditLog),
}));
export const memberRelations = relations(member, ({ one }) => ({
mesh: one(mesh, {
fields: [member.meshId],
references: [mesh.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const inviteRelations = relations(invite, ({ one }) => ({
mesh: one(mesh, {
fields: [invite.meshId],
references: [mesh.id],
}),
creator: one(user, {
fields: [invite.createdBy],
references: [user.id],
}),
}));
export const auditLogRelations = relations(auditLog, ({ one }) => ({
mesh: one(mesh, {
fields: [auditLog.meshId],
references: [mesh.id],
}),
}));
export const selectMeshSchema = createSelectSchema(mesh);
export const insertMeshSchema = createInsertSchema(mesh);
export const selectMemberSchema = createSelectSchema(member);
export const insertMemberSchema = createInsertSchema(member);
export const selectInviteSchema = createSelectSchema(invite);
export const insertInviteSchema = createInsertSchema(invite);
export const selectAuditLogSchema = createSelectSchema(auditLog);
export const insertAuditLogSchema = createInsertSchema(auditLog);
export type SelectMesh = typeof mesh.$inferSelect;
export type InsertMesh = typeof mesh.$inferInsert;
export type SelectMember = typeof member.$inferSelect;
export type InsertMember = typeof member.$inferInsert;
export type SelectInvite = typeof invite.$inferSelect;
export type InsertInvite = typeof invite.$inferInsert;
export type SelectAuditLog = typeof auditLog.$inferSelect;
export type InsertAuditLog = typeof auditLog.$inferInsert;

View File

@@ -0,0 +1,271 @@
import { relations } from "drizzle-orm";
import {
index,
integer,
pgSchema,
real,
text,
timestamp,
uniqueIndex,
vector,
} from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
import { createInsertSchema, createSelectSchema } from "../utils/drizzle-zod";
import { user } from "./auth";
// PDF-specific schema (separate from chat schema)
export const pdfSchema = pgSchema("pdf");
export const pdfMessageRoleEnum = pdfSchema.enum("role", [
"user",
"assistant",
"system",
]);
/**
* Document processing status enum
* Tracks the state of embedding generation for RAG
*/
export const pdfProcessingStatusEnum = pdfSchema.enum("processing_status", [
"pending", // Just uploaded, processing not started
"processing", // Dual-resolution chunking in progress
"ready", // Embeddings generated, searchable
"failed", // Processing failed
]);
export const pdfChat = pdfSchema.table("chat", {
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text(),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
createdAt: timestamp().defaultNow(),
});
export const pdfMessage = pdfSchema.table("message", {
id: text().primaryKey().notNull().$defaultFn(generateId),
chatId: text()
.references(() => pdfChat.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
content: text().notNull(),
role: pdfMessageRoleEnum().notNull(),
createdAt: timestamp().defaultNow(),
});
export const pdfDocument = pdfSchema.table("document", {
id: text().primaryKey().notNull().$defaultFn(generateId),
chatId: text()
.references(() => pdfChat.id, { onDelete: "cascade", onUpdate: "cascade" })
.notNull(),
name: text().notNull(),
path: text().notNull(),
/** Processing status for embedding generation */
processingStatus: pdfProcessingStatusEnum().default("pending").notNull(),
/** Error message if processing failed */
processingError: text(),
createdAt: timestamp().defaultNow(),
});
export const pdfEmbedding = pdfSchema.table(
"embedding",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
documentId: text()
.references(() => pdfDocument.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
content: text().notNull(),
embedding: vector({ dimensions: 1536 }).notNull(),
// Citation metadata for page navigation
pageNumber: integer(),
charStart: integer(),
charEnd: integer(),
sectionTitle: text(),
createdAt: timestamp().defaultNow(),
},
(table) => ({
embeddingIndex: index("pdf_embeddingIndex").using(
"hnsw",
table.embedding.op("vector_cosine_ops"),
),
}),
);
export const pdfEmbeddingRelations = relations(pdfEmbedding, ({ one }) => ({
document: one(pdfDocument, {
fields: [pdfEmbedding.documentId],
references: [pdfDocument.id],
}),
}));
// =============================================================================
// DUAL-RESOLUTION CHUNKING (WF-0028)
// =============================================================================
/**
* Unit type enum for citation units
*/
export const pdfUnitTypeEnum = pdfSchema.enum("unit_type", [
"prose",
"heading",
"list",
"table",
"code",
]);
/**
* Retrieval chunks: semantic units for vector search
* Groups 3-5 citation units for efficient embedding search
*/
export const pdfRetrievalChunk = pdfSchema.table(
"retrieval_chunk",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
documentId: text()
.references(() => pdfDocument.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
// Content (concatenated from citation units)
content: text().notNull(),
// Embedding for vector search
embedding: vector({ dimensions: 1536 }),
// Boundaries
pageStart: integer().notNull(),
pageEnd: integer().notNull(),
// Semantic context
sectionHierarchy: text().array(),
chunkType: text().default("prose"),
createdAt: timestamp().defaultNow(),
},
(table) => ({
documentIdx: index("idx_rc_document").on(table.documentId),
embeddingIdx: index("idx_rc_embedding").using(
"hnsw",
table.embedding.op("vector_cosine_ops"),
),
}),
);
/**
* Citation units: paragraph-level with precise bounding boxes
* Each unit is a single paragraph with exact position for pixel-perfect highlighting
*/
export const pdfCitationUnit = pdfSchema.table(
"citation_unit",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
documentId: text()
.references(() => pdfDocument.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
retrievalChunkId: text().references(() => pdfRetrievalChunk.id, {
onDelete: "set null",
onUpdate: "cascade",
}),
// Content
content: text().notNull(),
// Position (precise)
pageNumber: integer().notNull(),
paragraphIndex: integer().notNull(), // 0-based within page
charStart: integer().notNull(), // Within page text
charEnd: integer().notNull(),
// Bounding box (for pixel-perfect highlighting)
bboxX: real(),
bboxY: real(),
bboxWidth: real(),
bboxHeight: real(),
// Metadata
sectionTitle: text(),
unitType: pdfUnitTypeEnum().default("prose"),
createdAt: timestamp().defaultNow(),
},
(table) => ({
documentIdx: index("idx_cu_document").on(table.documentId),
retrievalIdx: index("idx_cu_retrieval").on(table.retrievalChunkId),
pageIdx: index("idx_cu_page").on(table.documentId, table.pageNumber),
uniqueParaIdx: uniqueIndex("idx_cu_unique").on(
table.documentId,
table.pageNumber,
table.paragraphIndex,
),
}),
);
// Relations for dual-resolution tables
export const pdfRetrievalChunkRelations = relations(
pdfRetrievalChunk,
({ one, many }) => ({
document: one(pdfDocument, {
fields: [pdfRetrievalChunk.documentId],
references: [pdfDocument.id],
}),
citationUnits: many(pdfCitationUnit),
}),
);
export const pdfCitationUnitRelations = relations(
pdfCitationUnit,
({ one }) => ({
document: one(pdfDocument, {
fields: [pdfCitationUnit.documentId],
references: [pdfDocument.id],
}),
retrievalChunk: one(pdfRetrievalChunk, {
fields: [pdfCitationUnit.retrievalChunkId],
references: [pdfRetrievalChunk.id],
}),
}),
);
export const selectPdfChatSchema = createSelectSchema(pdfChat);
export const insertPdfChatSchema = createInsertSchema(pdfChat);
export const selectPdfMessageSchema = createSelectSchema(pdfMessage);
export const insertPdfMessageSchema = createInsertSchema(pdfMessage);
export const selectPdfDocumentSchema = createSelectSchema(pdfDocument);
export const insertPdfDocumentSchema = createInsertSchema(pdfDocument);
export const selectPdfEmbeddingSchema = createSelectSchema(pdfEmbedding);
export const insertPdfEmbeddingSchema = createInsertSchema(pdfEmbedding);
export type SelectPdfChat = typeof pdfChat.$inferSelect;
export type InsertPdfChat = typeof pdfChat.$inferInsert;
export type SelectPdfMessage = typeof pdfMessage.$inferSelect;
export type InsertPdfMessage = typeof pdfMessage.$inferInsert;
export type SelectPdfDocument = typeof pdfDocument.$inferSelect;
export type InsertPdfDocument = typeof pdfDocument.$inferInsert;
export type SelectPdfEmbedding = typeof pdfEmbedding.$inferSelect;
export type InsertPdfEmbedding = typeof pdfEmbedding.$inferInsert;
// Dual-resolution schemas and types
export const selectPdfRetrievalChunkSchema =
createSelectSchema(pdfRetrievalChunk);
export const insertPdfRetrievalChunkSchema =
createInsertSchema(pdfRetrievalChunk);
export const selectPdfCitationUnitSchema = createSelectSchema(pdfCitationUnit);
export const insertPdfCitationUnitSchema = createInsertSchema(pdfCitationUnit);
export type SelectPdfRetrievalChunk = typeof pdfRetrievalChunk.$inferSelect;
export type InsertPdfRetrievalChunk = typeof pdfRetrievalChunk.$inferInsert;
export type SelectPdfCitationUnit = typeof pdfCitationUnit.$inferSelect;
export type InsertPdfCitationUnit = typeof pdfCitationUnit.$inferInsert;

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env tsx
/**
* Backfill customer records for existing users without them.
* Run once after deploying the new schema.
*
* Usage: pnpm with-env pnpm dlx tsx packages/db/src/scripts/backfill-customers.ts
*/
import { eq, isNull } from "drizzle-orm";
import { generateId } from "@turbostarter/shared/utils";
import { creditTransaction, customer, user } from "../schema";
import { db } from "../server";
const DEFAULT_CREDITS = 100;
async function backfillCustomers() {
console.log("Starting customer backfill...\n");
// Find users without customer records using a left join
const usersWithoutCustomers = await db
.select({
id: user.id,
email: user.email,
name: user.name,
})
.from(user)
.leftJoin(customer, eq(user.id, customer.userId))
.where(isNull(customer.id));
console.log(
`Found ${usersWithoutCustomers.length} users without customer records\n`,
);
if (usersWithoutCustomers.length === 0) {
console.log("No users to backfill. Done!");
return;
}
let created = 0;
let errors = 0;
for (const u of usersWithoutCustomers) {
const customerId = generateId();
try {
await db.transaction(async (tx) => {
await tx.insert(customer).values({
id: customerId,
userId: u.id,
customerId: `backfill_${u.id}`,
status: "active",
plan: "free",
credits: DEFAULT_CREDITS,
});
await tx.insert(creditTransaction).values({
id: generateId(),
customerId,
amount: DEFAULT_CREDITS,
type: "signup",
reason: "Backfill: Welcome credits for existing user",
balanceAfter: DEFAULT_CREDITS,
});
});
console.log(`✓ Created customer for ${u.email} (${u.name})`);
created++;
} catch (error) {
console.error(
`✗ Failed for ${u.email}:`,
error instanceof Error ? error.message : error,
);
errors++;
}
}
console.log(`\nBackfill complete!`);
console.log(` Created: ${created}`);
console.log(` Errors: ${errors}`);
}
backfillCustomers()
.then(() => process.exit(0))
.catch((error) => {
console.error("Backfill failed:", error);
process.exit(1);
});

View File

@@ -0,0 +1,18 @@
import { reset } from "drizzle-seed";
import { logger } from "@turbostarter/shared/logger";
import * as schema from "../schema";
import { db } from "../server";
async function main() {
await reset(db, schema);
logger.info("Database reset successfully!");
process.exit(0);
}
main().catch((error) => {
logger.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,12 @@
import { logger } from "@turbostarter/shared/logger";
function main() {
/**
* Place your seeding logic here
*/
logger.info("Database seeded successfully!");
process.exit(0);
}
main();

View File

@@ -0,0 +1,101 @@
import { sql } from "drizzle-orm";
import fs from "fs";
import path from "path";
import { logger } from "@turbostarter/shared/logger";
import { db } from "../server";
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface JournalFile {
version: string;
dialect: string;
entries?: JournalEntry[];
}
const JOURNAL_PATH = path.resolve("migrations/meta/_journal.json");
function loadJournalEntries(): JournalEntry[] {
if (!fs.existsSync(JOURNAL_PATH)) {
throw new Error(`Migrations journal not found at ${JOURNAL_PATH}`);
}
const journalFile = fs.readFileSync(JOURNAL_PATH, "utf-8");
const parsed = JSON.parse(journalFile) as JournalFile;
return [...(parsed.entries ?? [])].sort((a, b) => a.idx - b.idx);
}
function toTimestamp(value: unknown): number | null {
if (typeof value === "number") {
return value;
}
if (typeof value === "string") {
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
if (typeof value === "bigint") {
return Number(value);
}
if (value instanceof Date) {
return value.getTime();
}
return null;
}
async function fetchAppliedMigrationTimestamps(): Promise<number[]> {
const result = await db.execute(
sql`SELECT created_at FROM "drizzle"."__drizzle_migrations" ORDER BY created_at`,
);
return Array.from(result)
.map((row) => toTimestamp((row as { created_at?: unknown }).created_at))
.filter((timestamp): timestamp is number => timestamp !== null);
}
async function main() {
const journalEntries = loadJournalEntries();
const appliedTimestamps = await fetchAppliedMigrationTimestamps();
const appliedTimestampSet = new Set(appliedTimestamps);
const appliedMigrations = journalEntries.filter((entry) =>
appliedTimestampSet.has(entry.when),
);
const pendingMigrations = journalEntries.filter(
(entry) => !appliedTimestampSet.has(entry.when),
);
logger.info("\nApplied migrations:");
if (appliedMigrations.length === 0) {
logger.info("(none)");
} else {
appliedMigrations.forEach((entry) => logger.info(`- ${entry.tag}`));
}
logger.info("\nPending migrations:");
if (pendingMigrations.length === 0) {
logger.info("(none)");
} else {
pendingMigrations.forEach((entry) => logger.info(`- ${entry.tag}`));
}
}
void main()
.catch((err) => {
logger.error(err);
process.exit(1);
})
.then(() => {
process.exit(0);
});

View File

@@ -0,0 +1,8 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "./env";
import { schema } from "./schema";
const client = postgres(env.DATABASE_URL ?? "");
export const db = drizzle({ client, schema, casing: "snake_case" });

View File

@@ -0,0 +1,6 @@
import { createSchemaFactory } from "drizzle-zod";
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
createSchemaFactory({
coerce: true,
});

View File

@@ -0,0 +1,32 @@
import { asc, desc } from "drizzle-orm";
import * as schema from "../schema";
import type { SortPayload } from "@turbostarter/shared/schema";
import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core";
// Re-export drizzle-zod utilities (separate file to avoid circular deps)
export {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
} from "./drizzle-zod";
export const getOrderByFromSort = <Schema extends TableConfig>({
sort,
defaultSchema,
}: {
sort: SortPayload[];
defaultSchema: PgTableWithColumns<Schema>;
}) => {
return sort.map((s) => {
const order = s.desc ? desc : asc;
const parts = s.id.split(/[_.]/);
const table =
parts[0] && parts[0] in schema
? schema[parts[0] as keyof typeof schema]
: defaultSchema;
return order(table[(parts[1] ?? parts[0]) as keyof typeof table]);
});
};