feat: WIP add drizzle postgres db schemas

pull/715/head
Travis Fischer 2025-04-22 04:06:55 +07:00
rodzic 6743740d6f
commit 9efa186ea0
21 zmienionych plików z 1503 dodań i 172 usunięć

Wyświetl plik

@ -0,0 +1,14 @@
import 'dotenv/config'
import { defineConfig } from 'drizzle-kit'
import { env } from '@/lib/env'
export default defineConfig({
out: './drizzle',
schema: './src/db/schema',
dialect: 'postgresql',
dbCredentials: {
url: env.DATABASE_URL
}
})

Wyświetl plik

@ -0,0 +1,49 @@
{
"name": "agentic-platform-api",
"private": true,
"version": "0.1.0",
"description": "Agentic platform API.",
"author": "Travis Fischer <travis@transitivebullsh.it>",
"license": "UNLICENSED",
"repository": {
"type": "git",
"url": "git+https://github.com/transitive-bullshit/agentic-platform.git",
"directory": "apps/api"
},
"type": "module",
"source": "./src/index.ts",
"types": "./dist/index.d.ts",
"sideEffects": false,
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "del dist",
"test": "run-s test:*",
"test:lint": "eslint .",
"test:typecheck": "tsc --noEmit",
"test:unit": "vitest run"
},
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"drizzle-orm": "^0.42.0",
"drizzle-zod": "^0.7.1",
"jsonwebtoken": "^9.0.2",
"postgres": "^3.4.5",
"type-fest": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.9",
"drizzle-kit": "^0.31.0"
}
}

Wyświetl plik

@ -0,0 +1,12 @@
import { drizzle, type NodePgClient } from 'drizzle-orm/node-postgres'
import postgres from 'postgres'
import { env } from '@/lib/env'
import * as schema from './schema'
let _postgresClient: NodePgClient | undefined
const postgresClient =
_postgresClient ?? (_postgresClient = postgres(env.DATABASE_URL))
export const db = drizzle({ client: postgresClient, schema })

Wyświetl plik

@ -0,0 +1,113 @@
import { relations } from 'drizzle-orm'
import { boolean, index, jsonb, pgTable, text } from 'drizzle-orm/pg-core'
import type { Coupon, PricingPlan } from './types'
import { projects } from './project'
import { teams } from './team'
import { users } from './user'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
timestamps
} from './utils'
export const deployments = pgTable(
'deployments',
{
// namespace/projectName@hash
id: text('id').primaryKey(),
...timestamps,
hash: text().notNull(),
version: text(),
enabled: boolean().notNull().default(true),
published: boolean().notNull().default(false),
description: text().notNull().default(''),
readme: text().notNull().default(''),
userId: text()
.notNull()
.references(() => users.id),
teamId: text().references(() => teams.id),
projectId: text()
.notNull()
.references(() => projects.id, {
onDelete: 'cascade'
}),
// TODO: tools?
// services: jsonb().$type<Service[]>().default([]),
// Environment variables & secrets
build: jsonb().$type<object>(),
env: jsonb().$type<object>(),
// TODO: metadata config (logo, keywords, etc)
// TODO: webhooks
// TODO: third-party auth provider config?
// Backend API URL
_url: text().notNull(),
pricingPlans: jsonb().$type<PricingPlan[]>(),
coupons: jsonb().$type<Coupon[]>()
},
(table) => [
index('deployment_userId_idx').on(table.userId),
index('deployment_teamId_idx').on(table.teamId),
index('deployment_projectId_idx').on(table.projectId),
index('deployment_enabled_idx').on(table.enabled),
index('deployment_published_idx').on(table.published),
index('deployment_version_idx').on(table.version),
index('deployment_createdAt_idx').on(table.createdAt),
index('deployment_updatedAt_idx').on(table.updatedAt)
]
)
export const deploymentsRelations = relations(deployments, ({ one }) => ({
user: one(users, {
fields: [deployments.userId],
references: [users.id]
}),
team: one(teams, {
fields: [deployments.teamId],
references: [teams.id]
}),
project: one(projects, {
fields: [deployments.projectId],
references: [projects.id]
})
}))
// TODO: virtual hasFreeTier
// TODO: virtual url
// TODO: virtual openApiUrl
// TODO: virtual saasUrl
// TODO: virtual authProviders?
// TODO: virtual openapi spec? (hide openapi.servers)
export type Deployment = typeof deployments.$inferSelect
// TODO: narrow
export const deploymentInsertSchema = createInsertSchema(deployments, {
// TODO: validate deployment id
// id: (schema) =>
// schema.refine((id) => validators.deployment(id), 'Invalid deployment id')
})
export const deploymentSelectSchema = createSelectSchema(deployments).omit({
_url: true
})
// TODO: narrow
export const deploymentUpdateSchema = createUpdateSchema(deployments).pick({
enabled: true,
published: true,
version: true,
description: true
})
// TODO: add admin select schema which includes all fields?

Wyświetl plik

@ -0,0 +1,7 @@
export * from './deployment'
export * from './project'
export * from './team'
export * from './team-members'
export type * from './types'
export * from './user'
export * from './utils'

Wyświetl plik

@ -0,0 +1,174 @@
import { relations } from 'drizzle-orm'
import {
boolean,
index,
integer,
jsonb,
pgTable,
text
} from 'drizzle-orm/pg-core'
import { getProviderToken } from '@/lib/auth/get-provider-token'
import type { Webhook } from './types'
import { deployments } from './deployment'
import { teams } from './team'
import { users } from './user'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
timestamps
} from './utils'
export const projects = pgTable(
'projects',
{
// namespace/projectName
id: text('id').primaryKey(),
...timestamps,
name: text().notNull(),
alias: text(),
userId: text()
.notNull()
.references(() => users.id),
teamId: text().notNull(),
// Most recently published Deployment if one exists
lastPublishedDeploymentId: text(),
// Most recent Deployment if one exists
lastDeploymentId: text(),
applicationFeePercent: integer().notNull().default(20),
// TODO: This is going to need to vary from dev to prod
isStripeConnectEnabled: boolean().notNull().default(false),
// All deployments share the same underlying proxy secret
_secret: text(),
// Auth token used to access the saasify API on behalf of this project
_providerToken: text().notNull(),
// TODO: Full-text search
_text: text().default(''),
_webhooks: jsonb().$type<Webhook[]>().default([]),
// Stripe products corresponding to the stripe plans across deployments
stripeBaseProduct: text(),
stripeRequestProduct: text(),
// [metricSlug: string]: string
stripeMetricProducts: jsonb().$type<Record<string, string>>().default({}),
// Stripe coupons associated with this project, mapping from unique coupon
// hash to stripe coupon id.
// `[hash: string]: string`
_stripeCoupons: jsonb().$type<Record<string, string>>().default({}),
// Stripe billing plans associated with this project (created lazily),
// mapping from unique plan hash to stripe plan ids for base and request
// respectively.
// `[hash: string]: { basePlan: string, requestPlan: string }`
_stripePlans: jsonb()
.$type<Record<string, { basePlan: string; requestPlan: string }>>()
.default({}),
// Connected Stripe account (standard or express).
// If not defined, then subscriptions for this project route through our
// main Stripe account.
// NOTE: the connected account is shared between dev and prod, so we're not using
// the stripeID utility.
// TODO: is it wise to share this between dev and prod?
// TODO: is it okay for this to be public?
_stripeAccount: text()
},
(table) => [
index('project_userId_idx').on(table.userId),
index('project_teamId_idx').on(table.teamId),
index('project_teamId_idx').on(table.teamId),
index('project_createdAt_idx').on(table.createdAt),
index('project_updatedAt_idx').on(table.updatedAt)
]
)
export const projectsRelations = relations(projects, ({ one, many }) => ({
user: one(users, {
fields: [projects.userId],
references: [users.id]
}),
team: one(teams, {
fields: [projects.teamId],
references: [teams.id]
}),
lastPublishedDeployment: one(deployments, {
fields: [projects.lastPublishedDeploymentId],
references: [deployments.id],
relationName: 'lastPublishedDeployment'
}),
lastDeployment: one(deployments, {
fields: [projects.lastDeploymentId],
references: [deployments.id],
relationName: 'lastDeployment'
}),
deployments: many(deployments, { relationName: 'deployments' }),
publishedDeployments: many(deployments, {
relationName: 'publishedDeployments'
})
}))
export type Project = typeof projects.$inferSelect
export const projectInsertSchema = createInsertSchema(projects, {
// TODO: validate project id
// id: (schema) =>
// schema.refine((id) => validators.project(id), 'Invalid project id')
// TODO: validate project name
// name: (schema) =>
// schema.refine((name) => validators.projectName(name), 'Invalid project name')
})
.pick({
id: true,
name: true,
userId: true
})
.refine((data) => {
return {
...data,
_providerToken: getProviderToken(data)
}
})
export const projectSelectSchema = createSelectSchema(projects).omit({
_secret: true,
_providerToken: true,
_text: true,
_webhooks: true,
_stripeCoupons: true,
_stripePlans: true,
_stripeAccount: true
})
// TODO: narrow update schema
export const projectUpdateSchema = createUpdateSchema(projects)
export const projectDebugSelectSchema = createSelectSchema(projects).pick({
id: true,
name: true,
alias: true,
userId: true,
teamId: true,
createdAt: true,
updatedAt: true,
isStripeConnectEnabled: true,
lastPublishedDeploymentId: true,
lastDeploymentId: true
})
// TODO: virtual saasUrl
// TODO: virtual aliasUrl
// TODO: virtual stripeConnectParams

Wyświetl plik

@ -0,0 +1,47 @@
import { relations } from 'drizzle-orm'
import {
index,
pgTable,
primaryKey,
text,
uniqueIndex
} from 'drizzle-orm/pg-core'
import { teams } from './team'
import { users } from './user'
import { teamMemberRoleEnum, timestamps } from './utils'
export const teamMembers = pgTable(
'team_members',
{
...timestamps,
userId: text()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
teamId: text()
.notNull()
.references(() => teams.id, { onDelete: 'cascade' }),
role: teamMemberRoleEnum().default('user').notNull()
},
(table) => [
primaryKey({ columns: [table.userId, table.teamId] }),
uniqueIndex('team_member_user_idx').on(table.userId),
uniqueIndex('team_member_team_idx').on(table.teamId),
index('team_member_createdAt_idx').on(table.createdAt),
index('team_member_updatedAt_idx').on(table.updatedAt)
]
)
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
user: one(users, {
fields: [teamMembers.userId],
references: [users.id]
}),
team: one(teams, {
fields: [teamMembers.teamId],
references: [teams.id]
})
}))
export type TeamMember = typeof teamMembers.$inferSelect

Wyświetl plik

@ -0,0 +1,46 @@
import { relations } from 'drizzle-orm'
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core'
import { teamMembers } from './team-members'
import { users } from './user'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
id,
timestamps
} from './utils'
export const teams = pgTable(
'teams',
{
id,
...timestamps,
slug: text().notNull().unique(),
name: text().notNull(),
ownerId: text('owner').notNull()
},
(table) => [
uniqueIndex('team_slug_idx').on(table.slug),
index('team_createdAt_idx').on(table.createdAt),
index('team_updatedAt_idx').on(table.updatedAt)
]
)
export const teamsRelations = relations(teams, ({ one, many }) => ({
owner: one(users, {
fields: [teams.ownerId],
references: [users.id]
}),
members: many(teamMembers)
}))
export type Team = typeof teams.$inferSelect
export const teamInsertSchema = createInsertSchema(teams, {
slug: (schema) => schema.min(3).max(20) // TODO
})
export const teamSelectSchema = createSelectSchema(teams)
export const teamUpdateSchema = createUpdateSchema(teams).omit({ slug: true })

Wyświetl plik

@ -0,0 +1,146 @@
export type AuthProviderType =
| 'github'
| 'google'
| 'spotify'
| 'twitter'
| 'linkedin'
| 'stripe'
export type AuthProvider = {
provider: AuthProviderType
/** Provider-specific user id */
id: string
/** Provider-specific username */
username?: string
/** Standard oauth2 access token */
accessToken?: string
/** Standard oauth2 refresh token */
refreshToken?: string
/** Stripe public key */
publicKey?: string
/** OAuth scope(s) */
scope?: string
}
export type AuthProviders = {
github?: AuthProvider
google?: AuthProvider
spotify?: AuthProvider
twitter?: AuthProvider
linkedin?: AuthProvider
stripeTest?: AuthProvider
stripeLive?: AuthProvider
}
export type Webhook = {
url: string
events: string[]
}
export type RateLimit = {
enabled: boolean
// informal description that overrides any other properties
desc?: string
interval: number // seconds
maxPerInterval: number // unitless
}
export type PricingPlanTier = {
unitAmount?: number
flatAmount?: number
upTo: string
} & (
| {
unitAmount: number
}
| {
flatAmount: number
}
)
export type PricingPlanMetric = {
// slug acts as a primary key for metrics
slug: string
amount: number
label: string
unitLabel: string
// TODO: should this default be 'licensed' or 'metered'?
// methinks licensed for "sites", "jobs", etc...
// TODO: this should probably be explicit since its easy to confuse
usageType: 'licensed' | 'metered'
billingScheme: 'per_unit' | 'tiered'
tiersMode: 'graduated' | 'volume'
tiers: PricingPlanTier[]
// TODO (low priority): add aggregateUsage
rateLimit?: RateLimit
}
export type PricingPlan = {
name: string
slug: string
desc?: string
features: string[]
auth: boolean
amount: number
trialPeriodDays?: number
requests: PricingPlanMetric
metrics: PricingPlanMetric[]
rateLimit?: RateLimit
// used to uniquely identify this plan across deployments
baseId: string
// used to uniquely identify this plan across deployments
requestsId: string
// [metricSlug: string]: string
metricIds: Record<string, string>
// NOTE: the stripe billing plan id(s) for this PricingPlan are referenced
// in the Project._stripePlans mapping via the plan's hash.
// NOTE: all metered billing usage is stored in stripe
stripeBasePlan: string
stripeRequestPlan: string
// [metricSlug: string]: string
stripeMetricPlans: Record<string, string>
}
export type Coupon = {
// used to uniquely identify this coupon across deployments
id: string
valid: boolean
stripeCoupon: string
name?: string
currency?: string
amount_off?: number
percent_off?: number
duration: string
duration_in_months?: number
redeem_by?: Date
max_redemptions?: number
}

Wyświetl plik

@ -0,0 +1,82 @@
import { relations } from 'drizzle-orm'
import {
boolean,
index,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex
} from 'drizzle-orm/pg-core'
import { sha256 } from '@/lib/utils'
import type { AuthProviders } from './types'
import { teams } from './team'
import {
createInsertSchema,
createSelectSchema,
createUpdateSchema,
id,
timestamps,
userRoleEnum
} from './utils'
export const users = pgTable(
'users',
{
id,
...timestamps,
username: text().notNull().unique(),
role: userRoleEnum().default('user').notNull(),
email: text().unique(),
password: text(),
// metadata
firstName: text(),
lastName: text(),
image: text(),
emailConfirmed: boolean().default(false),
emailConfirmedAt: timestamp(),
emailConfirmToken: text().unique().default(sha256()),
passwordResetToken: text().unique(),
isStripeConnectEnabledByDefault: boolean().default(true),
// third-party auth providers
providers: jsonb().$type<AuthProviders>().default({}),
stripeCustomerId: text()
},
(table) => [
uniqueIndex('user_email_idx').on(table.email),
uniqueIndex('user_username_idx').on(table.username),
uniqueIndex('user_emailConfirmToken_idx').on(table.emailConfirmToken),
uniqueIndex('user_passwordResetToken_idx').on(table.passwordResetToken),
index('user_createdAt_idx').on(table.createdAt),
index('user_updatedAt_idx').on(table.updatedAt)
]
)
export const usersRelations = relations(users, ({ many }) => ({
teamsOwned: many(teams)
// TODO: team memberships
}))
export type User = typeof users.$inferSelect
export const userInsertSchema = createInsertSchema(users).pick({
username: true,
email: true,
password: true,
firstName: true,
lastName: true,
image: true
})
export const userSelectSchema = createSelectSchema(users)
export const userUpdateSchema = createUpdateSchema(users)

Wyświetl plik

@ -0,0 +1,24 @@
import { createId } from '@paralleldrive/cuid2'
import { sql } from 'drizzle-orm'
import { pgEnum, text, timestamp } from 'drizzle-orm/pg-core'
import { createSchemaFactory } from 'drizzle-zod'
export const id = text('id').primaryKey().$defaultFn(createId)
export const timestamps = {
createdAt: timestamp('createdAt').notNull().defaultNow(),
updatedAt: timestamp('updatedAt')
.notNull()
.default(sql`now()`)
}
export const userRoleEnum = pgEnum('UserRole', ['user', 'admin'])
export const teamMemberRoleEnum = pgEnum('TeamMemberRole', ['user', 'admin'])
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
createSchemaFactory({
coerce: {
// Coerce dates / strings to timetamps
date: true
}
})

Wyświetl plik

@ -0,0 +1,9 @@
import jwt from 'jsonwebtoken'
import { env } from '@/lib/env'
export function getProviderToken(project: { id: string }) {
// TODO: Possibly in the future store stripe account ID as well and require
// provider tokens to refresh after account changes?
return jwt.sign({ projectId: project.id }, env.JWT_SECRET)
}

Wyświetl plik

@ -0,0 +1,16 @@
import 'dotenv/config'
import { z } from 'zod'
export const envSchema = z.object({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string()
})
// eslint-disable-next-line no-process-env
export const env = envSchema.parse(process.env)
export const isProd = env.NODE_ENV === 'production'

Wyświetl plik

@ -0,0 +1 @@
export type Operation = 'create' | 'read' | 'update' | 'delete' | 'debug'

Wyświetl plik

@ -0,0 +1,5 @@
import { createHash, randomUUID } from 'node:crypto'
export function sha256(input: string = randomUUID()) {
return createHash('sha256').update(input).digest('hex')
}

1
apps/api/src/reset.d.ts vendored 100644
Wyświetl plik

@ -0,0 +1 @@
import '@fisch0920/config/ts-reset'

Wyświetl plik

@ -0,0 +1,11 @@
{
"extends": "@fisch0920/config/tsconfig-node",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "drizzle.config.ts"],
"exclude": ["node_modules", "dist"]
}

Wyświetl plik

@ -1,3 +1,15 @@
import { config } from '@fisch0920/config/eslint'
import drizzle from 'eslint-plugin-drizzle'
export default [ ...config ]
export default [
...config,
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
drizzle
},
rules: {
...drizzle.configs.recommended.rules
}
}
]

Wyświetl plik

@ -2,7 +2,7 @@
"name": "agentic-platform",
"private": true,
"author": "Travis Fischer <travis@transitivebullsh.it>",
"license": "PROPRIETARY",
"license": "UNLICENSED",
"repository": {
"type": "git",
"url": "git+https://github.com/transitive-bullshit/agentic-platform.git"
@ -33,6 +33,7 @@
"del-cli": "catalog:",
"dotenv": "catalog:",
"eslint": "catalog:",
"eslint-plugin-drizzle": "^0.2.3",
"lint-staged": "catalog:",
"npm-run-all2": "catalog:",
"only-allow": "catalog:",

Plik diff jest za duży Load Diff

Wyświetl plik

@ -1,19 +1,19 @@
packages:
- packages/*
- services/*
- apps/*
catalog:
'@ai-sdk/openai': ^1.3.6
'@fisch0920/config': ^1.0.2
'@fisch0920/config': ^1.0.3
'@modelcontextprotocol/sdk': ^1.8.0
'@types/node': ^22.14.0
'@types/node': ^22.14.1
ai: ^4.2.11
cleye: ^1.3.4
del-cli: ^6.0.0
dotenv: ^16.4.7
eslint: ^9.23.0
dotenv: ^16.5.0
eslint: ^9.25.0
exit-hook: ^4.0.0
ky: ^1.8.0
lint-staged: ^15.5.0
lint-staged: ^15.5.1
npm-run-all2: ^7.0.2
only-allow: ^1.2.1
p-map: ^7.0.3
@ -24,7 +24,7 @@ catalog:
tsup: ^8.4.0
tsx: ^4.19.3
turbo: ^2.5.0
type-fest: ^4.39.1
typescript: ^5.8.2
type-fest: ^4.40.0
typescript: ^5.8.3
vitest: ^3.1.1
zod: ^3.24.2
zod: ^3.24.3