This commit is contained in:
Frank
2025-09-18 10:59:01 -04:00
parent c87480cf93
commit 4ceabdffa0
156 changed files with 427 additions and 429 deletions

View File

@@ -0,0 +1,67 @@
import { z } from "zod"
import { and, eq, getTableColumns, isNull } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
import { Identifier } from "./identifier"
import { AccountTable } from "./schema/account.sql"
import { Actor } from "./actor"
import { WorkspaceTable } from "./schema/workspace.sql"
import { UserTable } from "./schema/user.sql"
export namespace Account {
export const create = fn(
z.object({
email: z.string().email(),
id: z.string().optional(),
}),
async (input) =>
Database.transaction(async (tx) => {
const id = input.id ?? Identifier.create("account")
await tx.insert(AccountTable).values({
id,
email: input.email,
})
return id
}),
)
export const fromID = fn(z.string(), async (id) =>
Database.transaction(async (tx) => {
return tx
.select()
.from(AccountTable)
.where(eq(AccountTable.id, id))
.execute()
.then((rows) => rows[0])
}),
)
export const fromEmail = fn(z.string().email(), async (email) =>
Database.transaction(async (tx) => {
return tx
.select()
.from(AccountTable)
.where(eq(AccountTable.email, email))
.execute()
.then((rows) => rows[0])
}),
)
export const workspaces = async () => {
const actor = Actor.assert("account")
return Database.transaction(async (tx) =>
tx
.select(getTableColumns(WorkspaceTable))
.from(WorkspaceTable)
.innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(
and(
eq(UserTable.email, actor.properties.email),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
)
.execute(),
)
}
}

View File

@@ -0,0 +1,74 @@
import { Context } from "./context"
import { Log } from "./util/log"
export namespace Actor {
interface Account {
type: "account"
properties: {
accountID: string
email: string
}
}
interface Public {
type: "public"
properties: {}
}
interface User {
type: "user"
properties: {
userID: string
workspaceID: string
}
}
interface System {
type: "system"
properties: {
workspaceID: string
}
}
export type Info = Account | Public | User | System
const ctx = Context.create<Info>()
export const use = ctx.use
const log = Log.create().tag("namespace", "actor")
export function provide<R, T extends Info["type"]>(
type: T,
properties: Extract<Info, { type: T }>["properties"],
cb: () => R,
) {
return ctx.provide(
{
type,
properties,
} as any,
() => {
return Log.provide({ ...properties }, () => {
log.info("provided")
return cb()
})
},
)
}
export function assert<T extends Info["type"]>(type: T) {
const actor = use()
if (actor.type !== type) {
throw new Error(`Expected actor type ${type}, got ${actor.type}`)
}
return actor as Extract<Info, { type: T }>
}
export function workspace() {
const actor = use()
if ("workspaceID" in actor.properties) {
return actor.properties.workspaceID
}
throw new Error(`actor of type "${actor.type}" is not associated with a workspace`)
}
}

View File

@@ -0,0 +1,244 @@
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
import { User } from "./user"
import { Resource } from "@opencode/console-resource"
import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
export namespace Billing {
export const CHARGE_AMOUNT = 2000 // $20
export const CHARGE_FEE = 123 // Stripe fee 4.4% + $0.30
export const CHARGE_THRESHOLD = 500 // $5
export const stripe = () =>
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
apiVersion: "2025-03-31.basil",
})
export const get = async () => {
return Database.use(async (tx) =>
tx
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
paymentMethodLast4: BillingTable.paymentMethodLast4,
balance: BillingTable.balance,
reload: BillingTable.reload,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadError: BillingTable.reloadError,
timeReloadError: BillingTable.timeReloadError,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((r) => r[0]),
)
}
export const payments = async () => {
return await Database.use((tx) =>
tx
.select()
.from(PaymentTable)
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
.limit(100),
)
}
export const usages = async () => {
return await Database.use((tx) =>
tx
.select()
.from(UsageTable)
.where(eq(UsageTable.workspaceID, Actor.workspace()))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(100),
)
}
export const reload = async () => {
const { customerID, paymentMethodID } = await Database.use((tx) =>
tx
.select({
customerID: BillingTable.customerID,
paymentMethodID: BillingTable.paymentMethodID,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, Actor.workspace()))
.then((rows) => rows[0]),
)
const paymentID = Identifier.create("payment")
let charge
try {
charge = await Billing.stripe().paymentIntents.create(
{
amount: Billing.CHARGE_AMOUNT + Billing.CHARGE_FEE,
currency: "usd",
customer: customerID!,
payment_method: paymentMethodID!,
off_session: true,
confirm: true,
},
{ idempotencyKey: paymentID },
)
if (charge.status !== "succeeded") throw new Error(charge.last_payment_error?.message)
} catch (e: any) {
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reloadError: e.message ?? "Payment failed.",
timeReloadError: sql`now()`,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
return
}
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(CHARGE_AMOUNT)}`,
reloadError: null,
timeReloadError: null,
})
.where(eq(BillingTable.workspaceID, Actor.workspace()))
await tx.insert(PaymentTable).values({
workspaceID: Actor.workspace(),
id: paymentID,
amount: centsToMicroCents(CHARGE_AMOUNT),
paymentID: charge.id,
customerID,
})
})
}
export const disableReload = async () => {
return await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: false,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
}
export const setMonthlyLimit = fn(z.number(), async (input) => {
return await Database.use((tx) =>
tx
.update(BillingTable)
.set({
monthlyLimit: input,
})
.where(eq(BillingTable.workspaceID, Actor.workspace())),
)
})
export const generateCheckoutUrl = fn(
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
}),
async (input) => {
const account = Actor.assert("user")
const { successUrl, cancelUrl } = input
const user = await User.fromID(account.properties.userID)
const customer = await Billing.get()
const session = await Billing.stripe().checkout.sessions.create({
mode: "payment",
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "opencode credits",
},
unit_amount: CHARGE_AMOUNT,
},
quantity: 1,
},
{
price_data: {
currency: "usd",
product_data: {
name: "processing fee",
},
unit_amount: CHARGE_FEE,
},
quantity: 1,
},
],
payment_intent_data: {
setup_future_usage: "on_session",
},
...(customer.customerID
? {
customer: customer.customerID,
}
: {
customer_email: user.email,
customer_creation: "always",
}),
metadata: {
workspaceID: Actor.workspace(),
},
currency: "usd",
payment_method_types: ["card"],
payment_method_data: {
allow_redisplay: "always",
},
success_url: successUrl,
cancel_url: cancelUrl,
})
return session.url
},
)
export const generateSessionUrl = fn(
z.object({
returnUrl: z.string(),
}),
async (input) => {
const { returnUrl } = input
const customer = await Billing.get()
if (!customer?.customerID) {
throw new Error("No stripe customer ID")
}
const session = await Billing.stripe().billingPortal.sessions.create({
customer: customer.customerID,
return_url: returnUrl,
})
return session.url
},
)
export const generateReceiptUrl = fn(
z.object({
paymentID: z.string(),
}),
async (input) => {
const { paymentID } = input
const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
if (!intent.latest_charge) throw new Error("No charge found")
const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
if (!charge.receipt_url) throw new Error("No receipt URL found")
return charge.receipt_url
},
)
}

View File

@@ -0,0 +1,21 @@
import { AsyncLocalStorage } from "node:async_hooks"
export namespace Context {
export class NotFound extends Error {}
export function create<T>() {
const storage = new AsyncLocalStorage<T>()
return {
use() {
const result = storage.getStore()
if (!result) {
throw new NotFound()
}
return result
},
provide<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn)
},
}
}
}

View File

@@ -0,0 +1,86 @@
import { drizzle } from "drizzle-orm/planetscale-serverless"
import { Resource } from "@opencode/console-resource"
export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import type { ExtractTablesWithRelations } from "drizzle-orm"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import { Context } from "../context"
import { memo } from "../util/memo"
export namespace Database {
export type Transaction = MySqlTransaction<
PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT,
Record<string, never>,
ExtractTablesWithRelations<Record<string, never>>
>
const client = memo(() => {
const result = new Client({
host: Resource.Database.host,
username: Resource.Database.username,
password: Resource.Database.password,
})
const db = drizzle(result, {})
return db
})
export type TxOrDb = Transaction | ReturnType<typeof client>
const TransactionContext = Context.create<{
tx: TxOrDb
effects: (() => void | Promise<void>)[]
}>()
export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
try {
const { tx } = TransactionContext.use()
return tx.transaction(callback)
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = await TransactionContext.provide(
{
effects,
tx: client(),
},
() => callback(client()),
)
await Promise.all(effects.map((x) => x()))
return result
}
throw err
}
}
export async function fn<Input, T>(callback: (input: Input, trx: TxOrDb) => Promise<T>) {
return (input: Input) => use(async (tx) => callback(input, tx))
}
export async function effect(effect: () => any | Promise<any>) {
try {
const { effects } = TransactionContext.use()
effects.push(effect)
} catch {
await effect()
}
}
export async function transaction<T>(callback: (tx: TxOrDb) => Promise<T>, config?: MySqlTransactionConfig) {
try {
const { tx } = TransactionContext.use()
return callback(tx)
} catch (err) {
if (err instanceof Context.NotFound) {
const effects: (() => void | Promise<void>)[] = []
const result = await client().transaction(async (tx) => {
return TransactionContext.provide({ tx, effects }, () => callback(tx))
}, config)
await Promise.all(effects.map((x) => x()))
return result
}
throw err
}
}
}

View File

@@ -0,0 +1,33 @@
import { sql } from "drizzle-orm"
import { bigint, timestamp, varchar } from "drizzle-orm/mysql-core"
export const ulid = (name: string) => varchar(name, { length: 30 })
export const workspaceColumns = {
get id() {
return ulid("id").notNull()
},
get workspaceID() {
return ulid("workspace_id").notNull()
},
}
export const id = () => ulid("id").notNull()
export const utc = (name: string) =>
timestamp(name, {
fsp: 3,
})
export const currency = (name: string) =>
bigint(name, {
mode: "number",
})
export const timestamps = {
timeCreated: utc("time_created").notNull().defaultNow(),
timeUpdated: utc("time_updated")
.notNull()
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
timeDeleted: utc("time_deleted"),
}

View File

@@ -0,0 +1,26 @@
import { ulid } from "ulid"
import { z } from "zod"
export namespace Identifier {
const prefixes = {
account: "acc",
billing: "bil",
key: "key",
payment: "pay",
usage: "usg",
user: "usr",
workspace: "wrk",
} as const
export function create(prefix: keyof typeof prefixes, given?: string): string {
if (given) {
if (given.startsWith(prefixes[prefix])) return given
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return [prefixes[prefix], ulid()].join("_")
}
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix])
}
}

View File

@@ -0,0 +1,75 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { Actor } from "./actor"
import { and, Database, eq, isNull, sql } from "./drizzle"
import { Identifier } from "./identifier"
import { KeyTable } from "./schema/key.sql"
export namespace Key {
export const list = async () => {
const workspace = Actor.workspace()
const keys = await Database.use((tx) =>
tx
.select()
.from(KeyTable)
.where(and(eq(KeyTable.workspaceID, workspace), isNull(KeyTable.timeDeleted)))
.orderBy(sql`${KeyTable.timeCreated} DESC`),
)
return keys
}
export const create = fn(z.object({ name: z.string().min(1).max(255) }), async (input) => {
const workspaceID = Actor.workspace()
const { name } = input
// Generate secret key: sk- + 64 random characters (upper, lower, numbers)
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let secretKey = "sk-"
const array = new Uint32Array(64)
crypto.getRandomValues(array)
for (let i = 0, l = array.length; i < l; i++) {
secretKey += chars[array[i] % chars.length]
}
const keyID = Identifier.create("key")
await Database.use((tx) =>
tx.insert(KeyTable).values({
id: keyID,
workspaceID,
actor: Actor.use(),
name,
key: secretKey,
timeUsed: null,
}),
).catch((e: any) => {
if (e.message.match(/Duplicate entry '.*' for key 'key.name'/))
throw new Error("A key with this name already exists. Please choose a different name.")
throw e
})
return keyID
})
export const remove = fn(z.object({ id: z.string() }), async (input) => {
const workspace = Actor.workspace()
await Database.transaction(async (tx) => {
const row = await tx
.select({
name: KeyTable.name,
})
.from(KeyTable)
.where(and(eq(KeyTable.id, input.id), eq(KeyTable.workspaceID, workspace)))
.then((rows) => rows[0])
if (!row) return
await tx
.update(KeyTable)
.set({
timeDeleted: sql`now()`,
oldName: row.name,
name: input.id, // Use the key ID as the name
})
.where(and(eq(KeyTable.id, input.id), eq(KeyTable.workspaceID, workspace)))
})
})
}

View File

@@ -0,0 +1,12 @@
import { mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { id, timestamps } from "../drizzle/types"
export const AccountTable = mysqlTable(
"account",
{
id: id(),
...timestamps,
email: varchar("email", { length: 255 }).notNull(),
},
(table) => [uniqueIndex("email").on(table.email)],
)

View File

@@ -0,0 +1,53 @@
import { bigint, boolean, int, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
export const BillingTable = mysqlTable(
"billing",
{
...workspaceColumns,
...timestamps,
customerID: varchar("customer_id", { length: 255 }),
paymentMethodID: varchar("payment_method_id", { length: 255 }),
paymentMethodLast4: varchar("payment_method_last4", { length: 4 }),
balance: bigint("balance", { mode: "number" }).notNull(),
monthlyLimit: int("monthly_limit"),
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
reload: boolean("reload"),
reloadError: varchar("reload_error", { length: 255 }),
timeReloadError: utc("time_reload_error"),
timeReloadLockedTill: utc("time_reload_locked_till"),
},
(table) => [...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID)],
)
export const PaymentTable = mysqlTable(
"payment",
{
...workspaceColumns,
...timestamps,
customerID: varchar("customer_id", { length: 255 }),
paymentID: varchar("payment_id", { length: 255 }),
amount: bigint("amount", { mode: "number" }).notNull(),
},
(table) => [...workspaceIndexes(table)],
)
export const UsageTable = mysqlTable(
"usage",
{
...workspaceColumns,
...timestamps,
model: varchar("model", { length: 255 }).notNull(),
provider: varchar("provider", { length: 255 }).notNull(),
inputTokens: int("input_tokens").notNull(),
outputTokens: int("output_tokens").notNull(),
reasoningTokens: int("reasoning_tokens"),
cacheReadTokens: int("cache_read_tokens"),
cacheWrite5mTokens: int("cache_write_5m_tokens"),
cacheWrite1hTokens: int("cache_write_1h_tokens"),
cost: bigint("cost", { mode: "number" }).notNull(),
},
(table) => [...workspaceIndexes(table)],
)

View File

@@ -0,0 +1,22 @@
import { mysqlTable, varchar, uniqueIndex, json } from "drizzle-orm/mysql-core"
import { timestamps, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
import { Actor } from "../actor"
export const KeyTable = mysqlTable(
"key",
{
...workspaceColumns,
...timestamps,
actor: json("actor").$type<Actor.Info>(),
name: varchar("name", { length: 255 }).notNull(),
oldName: varchar("old_name", { length: 255 }),
key: varchar("key", { length: 255 }).notNull(),
timeUsed: utc("time_used"),
},
(table) => [
...workspaceIndexes(table),
uniqueIndex("global_key").on(table.key),
uniqueIndex("name").on(table.workspaceID, table.name),
],
)

View File

@@ -0,0 +1,16 @@
import { text, mysqlTable, uniqueIndex, varchar, int } from "drizzle-orm/mysql-core"
import { timestamps, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
export const UserTable = mysqlTable(
"user",
{
...workspaceColumns,
...timestamps,
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(),
timeSeen: utc("time_seen"),
color: int("color"),
},
(table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)],
)

View File

@@ -0,0 +1,21 @@
import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable(
"workspace",
{
id: ulid("id").notNull().primaryKey(),
slug: varchar("slug", { length: 255 }),
name: varchar("name", { length: 255 }),
...timestamps,
},
(table) => [uniqueIndex("slug").on(table.slug)],
)
export function workspaceIndexes(table: any) {
return [
primaryKey({
columns: [table.workspaceID, table.id],
}),
]
}

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { eq } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
import { UserTable } from "./schema/user.sql"
export namespace User {
export const fromID = fn(z.string(), async (id) =>
Database.transaction(async (tx) => {
return tx
.select()
.from(UserTable)
.where(eq(UserTable.id, id))
.execute()
.then((rows) => rows[0])
}),
)
}

View File

@@ -0,0 +1,11 @@
import { z } from "zod"
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
const result = (input: z.infer<T>) => {
const parsed = schema.parse(input)
return cb(parsed)
}
result.force = (input: z.infer<T>) => cb(input)
result.schema = schema
return result
}

View File

@@ -0,0 +1,55 @@
import { Context } from "../context"
export namespace Log {
const ctx = Context.create<{
tags: Record<string, any>
}>()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const result = {
info(message?: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...use().tags,
...tags,
...extra,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ")
console.log(prefix, message)
return result
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return Log.create({ ...tags })
},
}
return result
}
export function provide<R>(tags: Record<string, any>, cb: () => R) {
const existing = use()
return ctx.provide(
{
tags: {
...existing.tags,
...tags,
},
},
cb,
)
}
function use() {
try {
return ctx.use()
} catch (e) {
return { tags: {} }
}
}
}

View File

@@ -0,0 +1,18 @@
export function memo<T>(fn: () => T, cleanup?: (input: T) => Promise<void>) {
let value: T | undefined
let loaded = false
const result = (): T => {
if (loaded) return value as T
loaded = true
value = fn()
return value as T
}
result.reset = async () => {
if (cleanup && value) await cleanup(value)
loaded = false
value = undefined
}
return result
}

View File

@@ -0,0 +1,3 @@
export function centsToMicroCents(amount: number) {
return Math.round(amount * 1000000)
}

View File

@@ -0,0 +1,58 @@
import { z } from "zod"
import { fn } from "./util/fn"
import { centsToMicroCents } from "./util/price"
import { Actor } from "./actor"
import { Database, eq } from "./drizzle"
import { Identifier } from "./identifier"
import { UserTable } from "./schema/user.sql"
import { BillingTable } from "./schema/billing.sql"
import { WorkspaceTable } from "./schema/workspace.sql"
import { Key } from "./key"
export namespace Workspace {
export const create = fn(z.void(), async () => {
const account = Actor.assert("account")
const workspaceID = Identifier.create("workspace")
await Database.transaction(async (tx) => {
await tx.insert(WorkspaceTable).values({
id: workspaceID,
})
await tx.insert(UserTable).values({
workspaceID,
id: Identifier.create("user"),
email: account.properties.email,
name: "",
})
await tx.insert(BillingTable).values({
workspaceID,
id: Identifier.create("billing"),
balance: 0,
})
})
await Actor.provide(
"system",
{
workspaceID,
},
async () => {
await Key.create({ name: "Default API Key" })
},
)
return workspaceID
})
export async function list() {
const account = Actor.assert("account")
return Database.use(async (tx) => {
return tx
.select({
id: WorkspaceTable.id,
slug: WorkspaceTable.slug,
name: WorkspaceTable.name,
})
.from(UserTable)
.innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where(eq(UserTable.email, account.properties.email))
})
}
}