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:
18
packages/db/src/env.ts
Normal file
18
packages/db/src/env.ts
Normal 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
2
packages/db/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "drizzle-orm/sql";
|
||||
export * from "./utils";
|
||||
6
packages/db/src/lib/zod.ts
Normal file
6
packages/db/src/lib/zod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSchemaFactory } from "drizzle-zod";
|
||||
|
||||
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
|
||||
createSchemaFactory({
|
||||
coerce: true,
|
||||
});
|
||||
242
packages/db/src/schema/auth.ts
Normal file
242
packages/db/src/schema/auth.ts
Normal 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],
|
||||
}),
|
||||
}));
|
||||
73
packages/db/src/schema/chat.ts
Normal file
73
packages/db/src/schema/chat.ts
Normal 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;
|
||||
45
packages/db/src/schema/credit-transaction.ts
Normal file
45
packages/db/src/schema/credit-transaction.ts
Normal 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;
|
||||
56
packages/db/src/schema/customer.ts
Normal file
56
packages/db/src/schema/customer.ts
Normal 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>;
|
||||
66
packages/db/src/schema/image.ts
Normal file
66
packages/db/src/schema/image.ts
Normal 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;
|
||||
46
packages/db/src/schema/index.ts
Normal file
46
packages/db/src/schema/index.ts
Normal 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";
|
||||
171
packages/db/src/schema/mesh.ts
Normal file
171
packages/db/src/schema/mesh.ts
Normal 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;
|
||||
271
packages/db/src/schema/pdf.ts
Normal file
271
packages/db/src/schema/pdf.ts
Normal 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;
|
||||
89
packages/db/src/scripts/backfill-customers.ts
Normal file
89
packages/db/src/scripts/backfill-customers.ts
Normal 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);
|
||||
});
|
||||
18
packages/db/src/scripts/reset.ts
Normal file
18
packages/db/src/scripts/reset.ts
Normal 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);
|
||||
});
|
||||
12
packages/db/src/scripts/seed.ts
Normal file
12
packages/db/src/scripts/seed.ts
Normal 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();
|
||||
101
packages/db/src/scripts/status.ts
Normal file
101
packages/db/src/scripts/status.ts
Normal 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);
|
||||
});
|
||||
8
packages/db/src/server.ts
Normal file
8
packages/db/src/server.ts
Normal 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" });
|
||||
6
packages/db/src/utils/drizzle-zod.ts
Normal file
6
packages/db/src/utils/drizzle-zod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSchemaFactory } from "drizzle-zod";
|
||||
|
||||
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
|
||||
createSchemaFactory({
|
||||
coerce: true,
|
||||
});
|
||||
32
packages/db/src/utils/index.ts
Normal file
32
packages/db/src/utils/index.ts
Normal 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]);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user