diff --git a/BetterBase_InfraAsCode_Spec.md b/BetterBase_InfraAsCode_Spec.md new file mode 100644 index 0000000..de58923 --- /dev/null +++ b/BetterBase_InfraAsCode_Spec.md @@ -0,0 +1,2633 @@ +# BetterBase InfraAsCode — Orchestrator Specification + +> **For Kilo Code Orchestrator** +> Execute tasks in strict order. Each task lists its dependencies — do not begin until all dependencies are marked complete. +> All file paths are relative to the monorepo root unless stated otherwise. +> This spec introduces the `bbf/` (BetterBase Functions) convention. It is additive — existing Hono routes, Drizzle schemas, and BetterAuth config are untouched. + +--- + +## What This Spec Builds + +A Convex-inspired Infrastructure-as-Code layer for BetterBase. Developers define their **data model** and **server functions** in TypeScript files inside a `bbf/` directory. The CLI reads those files, infers types, generates Drizzle schema + migrations automatically, and exposes a fully type-safe client API — with real-time queries by default. + +### Before (Current BetterBase) + +``` +src/ +├── db/schema.ts ← hand-written Drizzle schema +├── routes/users.ts ← hand-written Hono routes +└── db/migrate.ts ← manual migration runner +``` + +Developers write SQL-style schema separately, hand-wire routes, manage migrations manually. + +### After (BetterBase IaC) + +``` +bbf/ +├── schema.ts ← single source of truth for data model +├── queries/users.ts ← typed read functions (real-time by default) +├── mutations/users.ts ← typed write functions (transactional) +├── actions/email.ts ← typed side-effect functions +├── cron.ts ← scheduled functions +└── _generated/ ← never edit — owned by bb CLI + ├── api.d.ts ← type-safe API object + ├── dataModel.d.ts ← table + document types + └── server.d.ts ← ctx types for function authoring +``` + +The CLI runs `bb dev` and watches `bbf/`. Schema changes are detected, migrations generated and applied, `_generated/` is updated — all without leaving the editor. + +--- + +## Architecture Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Validator primitives | Wrap **Zod** (`v.*` surface, Zod internals) | Re-uses BetterBase's existing Zod dependency; validators are plain Zod schemas internally | +| DB access API | New `ctx.db` abstraction over **Drizzle** | Drizzle stays as the actual DB driver; `ctx.db` is a thin typed wrapper | +| Schema → DB | Generate Drizzle schema from `bbf/schema.ts` | One-way derivation; `bbf/schema.ts` is the master | +| Migrations | Auto-diff + auto-generate (prompt on destructive) | Never write SQL by hand; destructive changes require explicit CLI confirmation | +| Real-time | Mutation writes → WebSocket invalidation push | Queries subscribe to table-level change events; clients re-fetch on invalidation | +| Function transport | HTTP `POST /bbf/:type/:path/:name` | Stateless, debuggable with curl, fits the existing Hono server | +| Client hooks | React hooks (`useQuery`, `useMutation`, `useAction`) + vanilla equivalents | React-first but not React-only | +| Hybrid mode | IaC functions coexist with existing Hono routes | Additive, zero migration required for existing projects | + +--- + +## Validator Primitives (`v.*`) + +The `v` object is the public API for defining field types. It is a thin wrapper around Zod with a Convex-familiar surface. + +```typescript +// Usage in bbf/schema.ts +import { v } from "@betterbase/core/iac" + +const schema = defineSchema({ + users: defineTable({ + name: v.string(), + email: v.string(), + age: v.optional(v.number()), + role: v.union(v.literal("admin"), v.literal("user")), + tags: v.array(v.string()), + meta: v.object({ verified: v.boolean() }), + postId: v.id("posts"), // typed foreign key + }) +}) +``` + +Every `v.*` call returns a Zod schema. `v.id(table)` returns `z.string()` with a brand for type safety. + +--- + +## Phase 1 — Validator & Schema System + +### Task IAC-01 — Validator Primitives + +**Depends on:** nothing + +**Create file:** `packages/core/src/iac/validators.ts` + +```typescript +import { z } from "zod"; + +// Brand symbol for typed IDs +const ID_BRAND = Symbol("BetterBaseId"); + +export type BrandedId = string & { __table: T }; + +/** + * The `v` object provides Convex-style validator primitives backed by Zod. + * Every method returns a ZodSchema — callers can use them as plain Zod schemas. + */ +export const v = { + /** UTF-8 string */ + string: () => z.string(), + /** JS number (float64) */ + number: () => z.number(), + /** Boolean */ + boolean: () => z.boolean(), + /** null */ + null: () => z.null(), + /** bigint */ + int64: () => z.bigint(), + /** Zod z.any() */ + any: () => z.any(), + /** Make a field optional */ + optional: (validator: T) => validator.optional(), + /** Array of items */ + array: (item: T) => z.array(item), + /** Plain object with typed fields */ + object: (shape: T) => z.object(shape), + /** Discriminated union */ + union: (...validators: T) => + z.union(validators as unknown as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]), + /** Exact value */ + literal: (value: T) => z.literal(value), + /** Typed foreign key reference — resolves to string at runtime */ + id: (tableName: T) => + z.string().brand<`${T}Id`>(), + /** ISO 8601 datetime string */ + datetime: () => z.string().datetime({ offset: true }), + /** Bytes (base64 string) */ + bytes: () => z.string().base64(), +}; + +export type VString = ReturnType; +export type VNumber = ReturnType; +export type VBoolean = ReturnType; +export type VAny = ReturnType; +export type VId = z.ZodBranded; + +/** Infer TypeScript type from a v.* validator */ +export type Infer = z.infer; +``` + +**Also create file:** `packages/core/src/iac/index.ts` + +```typescript +export { v, type Infer } from "./validators"; +export { defineSchema, defineTable, type TableDefinition, type SchemaDefinition } from "./schema"; +export { query, mutation, action, type QueryCtx, type MutationCtx, type ActionCtx } from "./functions"; +export { cron, type CronJob } from "./cron"; +export { DatabaseReader, DatabaseWriter } from "./db-context"; +``` + +**Acceptance criteria:** +- Every `v.*` call returns a Zod schema +- `v.id("users")` produces a branded string type `z.ZodBranded` +- All types exported from `@betterbase/core/iac` +- No runtime dependencies beyond `zod` (already in project) + +--- + +### Task IAC-02 — `defineTable` with Index Builders + +**Depends on:** IAC-01 + +**Create file:** `packages/core/src/iac/schema.ts` (part 1 — table definition) + +```typescript +import { z } from "zod"; + +export interface IndexDefinition { + type: "index" | "searchIndex" | "uniqueIndex"; + name: string; + fields: string[]; + searchField?: string; // for searchIndex +} + +export interface TableDefinition< + TShape extends z.ZodRawShape = z.ZodRawShape +> { + _shape: TShape; + _schema: z.ZodObject; + _indexes: IndexDefinition[]; + /** Add a standard DB index */ + index(name: string, fields: (keyof TShape & string)[]): this; + /** Add a UNIQUE index */ + uniqueIndex(name: string, fields: (keyof TShape & string)[]): this; + /** Add a full-text search index (for supported providers) */ + searchIndex(name: string, opts: { searchField: keyof TShape & string; filterFields?: (keyof TShape & string)[] }): this; +} + +export function defineTable( + shape: TShape +): TableDefinition { + // System fields injected automatically + const systemShape = { + _id: z.string().describe("system:id"), + _createdAt: z.date().describe("system:createdAt"), + _updatedAt: z.date().describe("system:updatedAt"), + }; + + const fullShape = { ...systemShape, ...shape } as TShape & typeof systemShape; + const schema = z.object(fullShape); + const indexes: IndexDefinition[] = []; + + const table: TableDefinition = { + _shape: shape, + _schema: schema, + _indexes: indexes, + + index(name, fields) { + indexes.push({ type: "index", name, fields: fields as string[] }); + return this; + }, + + uniqueIndex(name, fields) { + indexes.push({ type: "uniqueIndex", name, fields: fields as string[] }); + return this; + }, + + searchIndex(name, opts) { + indexes.push({ + type: "searchIndex", + name, + fields: [opts.searchField, ...(opts.filterFields ?? [])], + searchField: opts.searchField, + }); + return this; + }, + }; + + return table; +} + +/** Infer the document type of a table (includes system fields) */ +export type InferDocument = + z.infer; +``` + +**Acceptance criteria:** +- `defineTable({ name: v.string() })` returns a chainable object +- `.index()`, `.uniqueIndex()`, `.searchIndex()` all return `this` for chaining +- System fields `_id`, `_createdAt`, `_updatedAt` injected into every table schema +- Full shape available via `._schema` for runtime validation + +--- + +### Task IAC-03 — `defineSchema` + Type Inference + +**Depends on:** IAC-02 + +**Append to file:** `packages/core/src/iac/schema.ts` (part 2 — schema definition) + +```typescript +export type SchemaShape = Record; + +export interface SchemaDefinition { + _tables: TShape; +} + +export function defineSchema( + tables: TShape +): SchemaDefinition { + return { _tables: tables }; +} + +/** Infer full schema types: { users: { _id: string, name: string, ... }, posts: {...} } */ +export type InferSchema = { + [K in keyof T["_tables"]]: InferDocument; +}; + +/** Get table names from a schema */ +export type TableNames = keyof T["_tables"] & string; + +/** Get the document type for a specific table */ +export type Doc< + TSchema extends SchemaDefinition, + TTable extends TableNames +> = InferSchema[TTable]; +``` + +**Example (`bbf/schema.ts`):** + +```typescript +import { defineSchema, defineTable, v } from "@betterbase/core/iac"; + +export const schema = defineSchema({ + users: defineTable({ + name: v.string(), + email: v.string(), + role: v.union(v.literal("admin"), v.literal("member")), + plan: v.optional(v.union(v.literal("free"), v.literal("pro"))), + }) + .uniqueIndex("by_email", ["email"]), + + posts: defineTable({ + title: v.string(), + body: v.string(), + authorId: v.id("users"), + published: v.boolean(), + tags: v.array(v.string()), + }) + .index("by_author", ["authorId"]) + .index("by_published", ["published", "_createdAt"]), + + comments: defineTable({ + postId: v.id("posts"), + userId: v.id("users"), + content: v.string(), + }) + .index("by_post", ["postId"]), +}); + +export default schema; +``` + +**Acceptance criteria:** +- `defineSchema({ ... })` accepts `Record` and returns `SchemaDefinition` +- `InferSchema` resolves to correct document shapes +- `Doc` resolves to the full users document type including system fields +- Schema object is serializable to JSON for diffing (Phase 2) + +--- + +### Task IAC-04 — Schema Serializer + +**Depends on:** IAC-03 + +**What it is:** Converts a `SchemaDefinition` into a plain JSON representation for storage, comparison, and migration generation. + +**Create file:** `packages/core/src/iac/schema-serializer.ts` + +```typescript +import { z } from "zod"; +import type { SchemaDefinition, TableDefinition, IndexDefinition } from "./schema"; + +export interface SerializedColumn { + name: string; + type: string; // "string" | "number" | "boolean" | "id:users" | "array:string" | etc. + optional: boolean; + system: boolean; // true for _id, _createdAt, _updatedAt +} + +export interface SerializedIndex { + type: "index" | "uniqueIndex" | "searchIndex"; + name: string; + fields: string[]; + searchField?: string; +} + +export interface SerializedTable { + name: string; + columns: SerializedColumn[]; + indexes: SerializedIndex[]; +} + +export interface SerializedSchema { + version: number; // bumped on each serialization + tables: SerializedTable[]; + generated: string; // ISO timestamp +} + +/** Converts a ZodTypeAny to a string type descriptor */ +function zodToTypeString(schema: z.ZodTypeAny): string { + if (schema instanceof z.ZodString) return "string"; + if (schema instanceof z.ZodNumber) return "number"; + if (schema instanceof z.ZodBoolean) return "boolean"; + if (schema instanceof z.ZodBigInt) return "int64"; + if (schema instanceof z.ZodNull) return "null"; + if (schema instanceof z.ZodAny) return "any"; + if (schema instanceof z.ZodDate) return "date"; + + if (schema instanceof z.ZodBranded) { + // v.id() — extract brand + const brand = (schema as any)._def.type; + const brandStr = (schema as any)._def.type?._def?.typeName ?? "string"; + return `id:${String((schema as any)._def.brandedType ?? "unknown")}`; + } + + if (schema instanceof z.ZodOptional) { + return zodToTypeString(schema.unwrap()); + } + + if (schema instanceof z.ZodArray) { + return `array:${zodToTypeString(schema.element)}`; + } + + if (schema instanceof z.ZodObject) { + return "object"; + } + + if (schema instanceof z.ZodUnion) { + const options = (schema as z.ZodUnion).options as z.ZodTypeAny[]; + return `union:${options.map(zodToTypeString).join("|")}`; + } + + if (schema instanceof z.ZodLiteral) { + return `literal:${String(schema.value)}`; + } + + return "unknown"; +} + +const SYSTEM_FIELDS = new Set(["_id", "_createdAt", "_updatedAt"]); + +/** Serialize a full SchemaDefinition to a plain JSON-safe object */ +export function serializeSchema(schema: SchemaDefinition): SerializedSchema { + const tables: SerializedTable[] = []; + + for (const [tableName, tableDef] of Object.entries(schema._tables)) { + const table = tableDef as TableDefinition; + const columns: SerializedColumn[] = []; + + // Iterate over the full schema shape (includes system fields) + for (const [colName, colSchema] of Object.entries(table._schema.shape)) { + const isOptional = colSchema instanceof z.ZodOptional; + const innerSchema = isOptional ? (colSchema as z.ZodOptional).unwrap() : colSchema; + + columns.push({ + name: colName, + type: zodToTypeString(colSchema), + optional: isOptional, + system: SYSTEM_FIELDS.has(colName), + }); + } + + tables.push({ + name: tableName, + columns, + indexes: table._indexes, + }); + } + + return { + version: Date.now(), + tables, + generated: new Date().toISOString(), + }; +} + +/** Load a serialized schema from disk (bbf/_generated/schema.json) */ +export function loadSerializedSchema(path: string): SerializedSchema | null { + try { + const content = Bun.file(path).text(); + return JSON.parse(content as any) as SerializedSchema; + } catch { + return null; + } +} + +/** Save serialized schema to disk */ +export async function saveSerializedSchema( + schema: SerializedSchema, + path: string +): Promise { + await Bun.write(path, JSON.stringify(schema, null, 2)); +} +``` + +**Acceptance criteria:** +- `serializeSchema()` produces stable, deterministic JSON +- System fields (`_id`, `_createdAt`, `_updatedAt`) marked with `system: true` +- `v.id("users")` columns serialized as `"id:users"` for diff detection +- Output is round-trippable (serialize → save → load → compare) + +--- + +### Task IAC-05 — Schema Diff Engine + +**Depends on:** IAC-04 + +**Create file:** `packages/core/src/iac/schema-diff.ts` + +```typescript +import type { SerializedSchema, SerializedTable, SerializedColumn, SerializedIndex } from "./schema-serializer"; + +export type DiffChangeType = + | "ADD_TABLE" + | "DROP_TABLE" + | "ADD_COLUMN" + | "DROP_COLUMN" + | "ALTER_COLUMN" + | "ADD_INDEX" + | "DROP_INDEX"; + +export interface SchemaDiffChange { + type: DiffChangeType; + table: string; + column?: string; + index?: string; + before?: unknown; + after?: unknown; + destructive: boolean; +} + +export interface SchemaDiff { + changes: SchemaDiffChange[]; + hasDestructive: boolean; + isEmpty: boolean; +} + +export function diffSchemas( + from: SerializedSchema | null, + to: SerializedSchema +): SchemaDiff { + const changes: SchemaDiffChange[] = []; + + const fromTables = new Map( + (from?.tables ?? []).map((t) => [t.name, t]) + ); + const toTables = new Map( + to.tables.map((t) => [t.name, t]) + ); + + // Added tables + for (const [name, table] of toTables) { + if (!fromTables.has(name)) { + changes.push({ type: "ADD_TABLE", table: name, after: table, destructive: false }); + continue; + } + // Diff columns within existing table + const fromTable = fromTables.get(name)!; + const fromCols = new Map(fromTable.columns.map((c) => [c.name, c])); + const toCols = new Map(table.columns.map((c) => [c.name, c])); + + for (const [col, def] of toCols) { + if (!fromCols.has(col)) { + changes.push({ type: "ADD_COLUMN", table: name, column: col, after: def, destructive: false }); + } else { + const before = fromCols.get(col)!; + if (before.type !== def.type || before.optional !== def.optional) { + changes.push({ + type: "ALTER_COLUMN", table: name, column: col, + before, after: def, + // Changing type or making required = destructive + destructive: before.type !== def.type || (before.optional && !def.optional), + }); + } + } + } + + for (const [col, def] of fromCols) { + if (!toCols.has(col) && !def.system) { + changes.push({ type: "DROP_COLUMN", table: name, column: col, before: def, destructive: true }); + } + } + + // Diff indexes + const fromIdx = new Map(fromTable.indexes.map((i) => [i.name, i])); + const toIdx = new Map(table.indexes.map((i) => [i.name, i])); + + for (const [idx] of toIdx) { + if (!fromIdx.has(idx)) changes.push({ type: "ADD_INDEX", table: name, index: idx, destructive: false }); + } + for (const [idx] of fromIdx) { + if (!toIdx.has(idx)) changes.push({ type: "DROP_INDEX", table: name, index: idx, destructive: false }); + } + } + + // Dropped tables + for (const [name] of fromTables) { + if (!toTables.has(name)) { + changes.push({ type: "DROP_TABLE", table: name, before: fromTables.get(name), destructive: true }); + } + } + + const hasDestructive = changes.some((c) => c.destructive); + return { changes, hasDestructive, isEmpty: changes.length === 0 }; +} + +/** Human-readable summary of a diff */ +export function formatDiff(diff: SchemaDiff): string { + if (diff.isEmpty) return " No schema changes detected."; + + return diff.changes.map((c) => { + const prefix = c.destructive ? "⚠ " : "+ "; + switch (c.type) { + case "ADD_TABLE": return `${prefix}ADD TABLE ${c.table}`; + case "DROP_TABLE": return `${prefix}DROP TABLE ${c.table}`; + case "ADD_COLUMN": return `${prefix}ADD COLUMN ${c.table}.${c.column}`; + case "DROP_COLUMN": return `${prefix}DROP COLUMN ${c.table}.${c.column}`; + case "ALTER_COLUMN": return `${prefix}ALTER COLUMN ${c.table}.${c.column}`; + case "ADD_INDEX": return `${prefix}ADD INDEX ${c.table}.${c.index}`; + case "DROP_INDEX": return `${prefix}DROP INDEX ${c.table}.${c.index}`; + } + }).join("\n"); +} +``` + +**Acceptance criteria:** +- `diffSchemas(null, schema)` produces `ADD_TABLE` for every table (first run) +- `diffSchemas(schema, schema)` produces empty diff +- `DROP_TABLE`, `DROP_COLUMN`, `ALTER_COLUMN` (type change or required) marked `destructive: true` +- `ADD_TABLE`, `ADD_COLUMN`, `ADD_INDEX` marked `destructive: false` + +--- + +## Phase 2 — Function System + +### Task IAC-06 — `query()` Primitive + QueryCtx + +**Depends on:** IAC-05 + +**Create file:** `packages/core/src/iac/functions.ts` (part 1) + +```typescript +import { z } from "zod"; +import type { DatabaseReader } from "./db-context"; + +// ─── Context Types ──────────────────────────────────────────────────────────── + +export interface AuthCtx { + /** ID of the authenticated user, or null for anonymous */ + userId: string | null; + /** Raw session token */ + token: string | null; +} + +export interface StorageReaderCtx { + getUrl(storageId: string): Promise; +} + +export interface StorageWriterCtx extends StorageReaderCtx { + store(blob: Blob): Promise; // returns storageId + delete(storageId: string): Promise; +} + +export interface QueryCtx { + db: DatabaseReader; + auth: AuthCtx; + storage: StorageReaderCtx; +} + +export interface Scheduler { + /** + * Schedule a mutation to run after `delayMs` milliseconds. + * Returns a job ID that can be cancelled. + */ + runAfter( + delayMs: number, + fn: MutationRegistration, + args: z.infer> + ): Promise; + + /** + * Schedule a mutation to run at a specific timestamp. + */ + runAt( + timestamp: Date, + fn: MutationRegistration, + args: z.infer> + ): Promise; + + /** Cancel a scheduled job */ + cancel(jobId: string): Promise; +} + +export interface MutationCtx { + db: DatabaseWriter; + auth: AuthCtx; + storage: StorageWriterCtx; + scheduler: Scheduler; +} + +export interface ActionCtx { + auth: AuthCtx; + storage: StorageWriterCtx; + scheduler: Scheduler; + /** Run a query from within an action */ + runQuery( + fn: QueryRegistration, + args: z.infer> + ): Promise; + /** Run a mutation from within an action */ + runMutation( + fn: MutationRegistration, + args: z.infer> + ): Promise; +} + +// ─── Registration Types ─────────────────────────────────────────────────────── + +const FUNCTION_KIND = Symbol("BetterBaseFunction"); + +export interface QueryRegistration< + TArgs extends z.ZodRawShape, + TReturn +> { + [FUNCTION_KIND]: "query"; + _args: z.ZodObject; + _handler: (ctx: QueryCtx, args: z.infer>) => Promise; +} + +export interface MutationRegistration< + TArgs extends z.ZodRawShape, + TReturn +> { + [FUNCTION_KIND]: "mutation"; + _args: z.ZodObject; + _handler: (ctx: MutationCtx, args: z.infer>) => Promise; +} + +export interface ActionRegistration< + TArgs extends z.ZodRawShape, + TReturn +> { + [FUNCTION_KIND]: "action"; + _args: z.ZodObject; + _handler: (ctx: ActionCtx, args: z.infer>) => Promise; +} + +// ─── Factory Functions ──────────────────────────────────────────────────────── + +export function query(config: { + args: TArgs; + handler: (ctx: QueryCtx, args: z.infer>) => Promise; +}): QueryRegistration { + return { + [FUNCTION_KIND]: "query", + _args: z.object(config.args), + _handler: config.handler, + }; +} + +export function mutation(config: { + args: TArgs; + handler: (ctx: MutationCtx, args: z.infer>) => Promise; +}): MutationRegistration { + return { + [FUNCTION_KIND]: "mutation", + _args: z.object(config.args), + _handler: config.handler, + }; +} + +export function action(config: { + args: TArgs; + handler: (ctx: ActionCtx, args: z.infer>) => Promise; +}): ActionRegistration { + return { + [FUNCTION_KIND]: "action", + _args: z.object(config.args), + _handler: config.handler, + }; +} + +// Import DatabaseWriter for MutationCtx (forward declaration resolved by IAC-07) +import type { DatabaseWriter } from "./db-context"; +``` + +**Example (`bbf/queries/users.ts`):** + +```typescript +import { query } from "@betterbase/core/iac"; +import { v } from "@betterbase/core/iac"; + +export const getUser = query({ + args: { id: v.id("users") }, + handler: async (ctx, args) => { + return ctx.db.get("users", args.id); + }, +}); + +export const listUsers = query({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + return ctx.db + .query("users") + .order("desc") + .take(args.limit ?? 50); + }, +}); +``` + +**Acceptance criteria:** +- `query()`, `mutation()`, `action()` each return typed registration objects +- Registration objects carry `_args` (ZodObject) and `_handler` for the runtime to call +- `QueryCtx` has read-only db; `MutationCtx` has read-write db + scheduler +- TypeScript infers `TReturn` from the handler's return type + +--- + +### Task IAC-07 — `DatabaseReader` and `DatabaseWriter` + +**Depends on:** IAC-06 + +**Create file:** `packages/core/src/iac/db-context.ts` + +```typescript +import type { Pool } from "pg"; +import { nanoid } from "nanoid"; + +// ─── Query Builder (chainable) ───────────────────────────────────────────── + +export class IaCQueryBuilder { + private _table: string; + private _pool: Pool; + private _schema: string; + private _filters: string[] = []; + private _params: unknown[] = []; + private _orderBy: string | null = null; + private _orderDir: "ASC" | "DESC" = "ASC"; + private _limit: number | null = null; + private _indexName: string | null = null; + + constructor(table: string, pool: Pool, schema: string) { + this._table = table; + this._pool = pool; + this._schema = schema; + } + + /** Filter using an index — short-circuits to index-aware SQL */ + withIndex(indexName: string, _builder: (q: IndexQueryBuilder) => IndexQueryBuilder): this { + this._indexName = indexName; + // For v1: treated as a filter hint only; actual index usage is via SQL planner + return this; + } + + filter(field: string, op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte", value: unknown): this { + const idx = this._params.length + 1; + const opMap = { eq: "=", neq: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=" }; + this._filters.push(`"${field}" ${opMap[op]} $${idx}`); + this._params.push(value); + return this; + } + + order(direction: "asc" | "desc", field = "_createdAt"): this { + this._orderBy = field; + this._orderDir = direction === "asc" ? "ASC" : "DESC"; + return this; + } + + take(n: number): this { this._limit = n; return this; } + + private _buildSQL(): { sql: string; params: unknown[] } { + const table = `"${this._schema}"."${this._table}"`; + let sql = `SELECT * FROM ${table}`; + if (this._filters.length) sql += ` WHERE ${this._filters.join(" AND ")}`; + if (this._orderBy) sql += ` ORDER BY "${this._orderBy}" ${this._orderDir}`; + if (this._limit) sql += ` LIMIT ${this._limit}`; + return { sql, params: this._params }; + } + + async collect(): Promise { + const { sql, params } = this._buildSQL(); + const { rows } = await this._pool.query(sql, params as any[]); + return rows as T[]; + } + + async first(): Promise { + const { sql, params } = this._buildSQL(); + const { rows } = await this._pool.query(sql + " LIMIT 1", params as any[]); + return (rows[0] as T) ?? null; + } + + async unique(): Promise { + const results = await this.collect(); + if (results.length > 1) throw new Error(`Expected unique result, got ${results.length}`); + return results[0] ?? null; + } +} + +// Stub — used by withIndex for type inference +class IndexQueryBuilder { + eq(field: string, value: unknown) { return this; } + gt(field: string, value: unknown) { return this; } + gte(field: string, value: unknown) { return this; } + lt(field: string, value: unknown) { return this; } + lte(field: string, value: unknown) { return this; } +} + +// ─── DatabaseReader ──────────────────────────────────────────────────────── + +export class DatabaseReader { + constructor(protected _pool: Pool, protected _schema: string) {} + + /** Get a document by ID */ + async get(table: string, id: string): Promise { + const { rows } = await this._pool.query( + `SELECT * FROM "${this._schema}"."${table}" WHERE _id = $1 LIMIT 1`, + [id] + ); + return (rows[0] as T) ?? null; + } + + /** Start a query builder for a table */ + query(table: string): IaCQueryBuilder { + return new IaCQueryBuilder(table, this._pool, this._schema); + } +} + +// ─── DatabaseWriter ──────────────────────────────────────────────────────── + +export class DatabaseWriter extends DatabaseReader { + private _mutations: (() => Promise)[] = []; + + /** Insert a document, returning its generated ID */ + async insert(table: string, data: Record): Promise { + const id = nanoid(); + const now = new Date(); + const doc = { ...data, _id: id, _createdAt: now, _updatedAt: now }; + + const keys = Object.keys(doc).map((k) => `"${k}"`).join(", "); + const placeholders = Object.keys(doc).map((_, i) => `$${i + 1}`).join(", "); + const values = Object.values(doc); + + await this._pool.query( + `INSERT INTO "${this._schema}"."${table}" (${keys}) VALUES (${placeholders})`, + values as any[] + ); + + // Emit change event for real-time invalidation + this._emitChange(table, "INSERT", id); + return id; + } + + /** Partial update — merges provided fields, updates `_updatedAt` */ + async patch(table: string, id: string, fields: Record): Promise { + const updates = Object.entries(fields) + .map(([k], i) => `"${k}" = $${i + 2}`) + .join(", "); + const values = [id, ...Object.values(fields)]; + await this._pool.query( + `UPDATE "${this._schema}"."${table}" SET ${updates}, "_updatedAt" = NOW() WHERE _id = $1`, + values as any[] + ); + this._emitChange(table, "UPDATE", id); + } + + /** Full replace — replaces all user fields (preserves system fields) */ + async replace(table: string, id: string, data: Record): Promise { + await this.patch(table, id, data); + } + + /** Delete a document by ID */ + async delete(table: string, id: string): Promise { + await this._pool.query( + `DELETE FROM "${this._schema}"."${table}" WHERE _id = $1`, + [id] + ); + this._emitChange(table, "DELETE", id); + } + + private _emitChange(table: string, type: "INSERT" | "UPDATE" | "DELETE", id: string) { + // Emit to the global realtime manager (IAC-21) + const mgr = (globalThis as any).__betterbaseRealtimeManager; + mgr?.emitTableChange?.({ table, type, id }); + } +} +``` + +**Acceptance criteria:** +- `ctx.db.get("users", id)` returns typed document or `null` +- `ctx.db.query("users").filter("email", "eq", "x").first()` works +- `ctx.db.insert("users", data)` injects `_id`, `_createdAt`, `_updatedAt` automatically +- `ctx.db.patch(table, id, fields)` only updates provided fields + `_updatedAt` +- Mutations emit change events for real-time invalidation (IAC-21) + +--- + +### Task IAC-08 — Function Registry (File Discovery) + +**Depends on:** IAC-07 + +**What it is:** Scans the `bbf/` directory, imports all query/mutation/action exports, and registers them in a flat registry keyed by path (`queries/users/getUser`). + +**Create file:** `packages/core/src/iac/function-registry.ts` + +```typescript +import { join, relative, extname } from "path"; +import { readdir } from "fs/promises"; + +export interface RegisteredFunction { + kind: "query" | "mutation" | "action"; + path: string; // e.g. "queries/users/getUser" + name: string; // e.g. "getUser" + module: string; // absolute file path + handler: unknown; // the QueryRegistration | MutationRegistration | ActionRegistration +} + +const FUNCTION_DIRS = ["queries", "mutations", "actions"] as const; + +/** Walk a directory recursively and return all .ts/.js file paths */ +async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + const files: string[] = []; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) files.push(...await walk(full)); + else if ([".ts", ".js"].includes(extname(entry.name))) files.push(full); + } + return files; +} + +/** Scan bbfDir and return all registered functions */ +export async function discoverFunctions(bbfDir: string): Promise { + const registered: RegisteredFunction[] = []; + + for (const kind of FUNCTION_DIRS) { + const dir = join(bbfDir, kind); + const files = await walk(dir); + + for (const file of files) { + const rel = relative(dir, file).replace(/\.(ts|js)$/, ""); + const mod = await import(file).catch(() => null); + if (!mod) continue; + + for (const [exportName, exportValue] of Object.entries(mod)) { + if (!exportValue || typeof exportValue !== "object") continue; + const fn = exportValue as any; + if (!fn._handler || !fn._args) continue; + + const fnKind: "query" | "mutation" | "action" = + fn[Symbol.for("BetterBaseFunction")] ?? kind.slice(0, -1) as any; + + registered.push({ + kind: fnKind, + path: `${kind}/${rel}/${exportName}`, + name: exportName, + module: file, + handler: fn, + }); + } + } + } + + return registered; +} + +/** Singleton registry (populated once on server start or bb dev) */ +let _registry: RegisteredFunction[] = []; + +export function setFunctionRegistry(fns: RegisteredFunction[]) { + _registry = fns; +} + +export function getFunctionRegistry(): RegisteredFunction[] { + return _registry; +} + +export function lookupFunction(path: string): RegisteredFunction | null { + return _registry.find((f) => f.path === path) ?? null; +} +``` + +**Acceptance criteria:** +- Scans `bbf/queries/`, `bbf/mutations/`, `bbf/actions/` recursively +- Each exported `query()`/`mutation()`/`action()` becomes a registered entry +- Path convention: `queries/users/getUser` (directory/file/exportName) +- Gracefully skips files that fail to import + +--- + +### Task IAC-09 — `cron.ts` Primitive + +**Depends on:** IAC-08 + +**Create file:** `packages/core/src/iac/cron.ts` + +```typescript +import { z } from "zod"; +import type { MutationRegistration } from "./functions"; + +export interface CronJob { + name: string; + schedule: string; // cron expression: "0 * * * *", "*/5 * * * *", etc. + fn: MutationRegistration; + args: Record; +} + +const _jobs: CronJob[] = []; + +/** Register a cron job. Called in bbf/cron.ts. */ +export function cron( + name: string, + schedule: string, + fn: MutationRegistration, + args: Record = {} +): void { + _jobs.push({ name, schedule, fn, args }); +} + +export function getCronJobs(): CronJob[] { + return _jobs; +} +``` + +**Example (`bbf/cron.ts`):** + +```typescript +import { cron } from "@betterbase/core/iac"; +import { api } from "./_generated/api"; + +// Run every hour +cron("cleanup-sessions", "0 * * * *", api.mutations.auth.cleanupExpiredSessions, {}); + +// Run daily at midnight +cron("send-digest", "0 0 * * *", api.mutations.email.sendDailyDigest, {}); +``` + +**Acceptance criteria:** +- `cron()` is called at module load time, registering jobs globally +- `getCronJobs()` returns all registered jobs for the runtime to schedule +- Schedule string is a standard 5-part cron expression +- Jobs are mutation registrations — they run in a transaction + +--- + +## Phase 3 — Code Generation + +### Task IAC-10 — Drizzle Schema Generator + +**Depends on:** IAC-09 + +**What it is:** Reads `bbf/schema.ts`, serializes it, and writes `src/db/schema.generated.ts` — a real Drizzle schema derived from the IaC schema. Developers never edit this file. + +**Create file:** `packages/core/src/iac/generators/drizzle-schema-gen.ts` + +```typescript +import type { SerializedSchema, SerializedTable, SerializedColumn } from "../schema-serializer"; + +function colTypeToSqlite(type: string, colName: string): string { + if (type === "string" || type.startsWith("id:") || type.startsWith("literal:") || type.startsWith("union:")) + return `text('${colName}')`; + if (type === "number") return `real('${colName}')`; + if (type === "int64") return `integer('${colName}')`; + if (type === "boolean") return `integer('${colName}', { mode: 'boolean' })`; + if (type === "date") return `integer('${colName}', { mode: 'timestamp' })`; + if (type.startsWith("array:") || type === "object") return `text('${colName}', { mode: 'json' })`; + return `text('${colName}')`; +} + +function colTypeToPostgres(type: string, colName: string): string { + if (type === "string" || type.startsWith("id:") || type.startsWith("literal:") || type.startsWith("union:")) + return `text('${colName}')`; + if (type === "number") return `doublePrecision('${colName}')`; + if (type === "int64") return `bigint('${colName}', { mode: 'bigint' })`; + if (type === "boolean") return `boolean('${colName}')`; + if (type === "date") return `timestamp('${colName}', { withTimezone: true })`; + if (type.startsWith("array:") || type === "object") return `jsonb('${colName}')`; + return `text('${colName}')`; +} + +function generateTableCode(table: SerializedTable, dialect: "sqlite" | "postgres"): string { + const colFn = dialect === "sqlite" ? colTypeToSqlite : colTypeToPostgres; + const tableFn = dialect === "sqlite" ? "sqliteTable" : "pgTable"; + + const cols = table.columns.map((col) => { + let def = colFn(col.type, col.name); + + if (col.name === "_id") { + def += ".primaryKey()"; + } else if (!col.optional && !col.system) { + def += ".notNull()"; + } + + if (col.name === "_createdAt" || col.name === "_updatedAt") { + def += ".default(sql`now()`)"; + } + + return ` ${col.name}: ${def}`; + }).join(",\n"); + + // Add index definitions as a third argument to the table fn + const indexLines = table.indexes.map((idx) => { + const fields = idx.fields.map((f) => `table.${f}`).join(", "); + if (idx.type === "uniqueIndex") return ` ${idx.name}: uniqueIndex('${table.name}_${idx.name}').on(${fields})`; + return ` ${idx.name}: index('${table.name}_${idx.name}').on(${fields})`; + }); + + const tableBody = indexLines.length + ? `, (table) => ({\n${indexLines.join(",\n")}\n})` + : ""; + + return `export const ${table.name} = ${tableFn}('${table.name}', {\n${cols}\n}${tableBody});`; +} + +export function generateDrizzleSchema( + schema: SerializedSchema, + dialect: "sqlite" | "postgres" = "sqlite" +): string { + const imports = dialect === "sqlite" + ? `import { sqliteTable, text, real, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core';\nimport { sql } from 'drizzle-orm';` + : `import { pgTable, text, doublePrecision, bigint, boolean, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';\nimport { sql } from 'drizzle-orm';`; + + const header = `// AUTO-GENERATED by BetterBase IaC — DO NOT EDIT\n// Source: bbf/schema.ts\n// Generated: ${schema.generated}\n\n${imports}\n\n`; + + const tables = schema.tables.map((t) => generateTableCode(t, dialect)).join("\n\n"); + + return header + tables + "\n"; +} +``` + +**Acceptance criteria:** +- Generated file has `// AUTO-GENERATED` header +- Supports `"sqlite"` and `"postgres"` dialects +- System fields (`_id`, `_createdAt`, `_updatedAt`) always present in generated code +- `v.id("users")` serialized as `text` (foreign keys are text IDs in this model) +- Generated file passes `tsc --noEmit` + +--- + +### Task IAC-11 — Migration Generator + +**Depends on:** IAC-10 + +**What it is:** Reads the diff output (IAC-05), generates SQL migration files, and writes them to `drizzle/migrations/`. Destructive changes are gated behind a `--force` flag. + +**Create file:** `packages/core/src/iac/generators/migration-gen.ts` + +```typescript +import type { SchemaDiff, SchemaDiffChange } from "../schema-diff"; +import type { SerializedColumn } from "../schema-serializer"; + +function colTypeToSQL(type: string): string { + if (type === "number") return "REAL"; + if (type === "int64") return "BIGINT"; + if (type === "boolean") return "BOOLEAN"; + if (type === "date") return "TIMESTAMPTZ"; + if (type.startsWith("array:") || type === "object") return "JSONB"; + return "TEXT"; // default: string, id:*, literal:*, union:* +} + +function changeToSQL(change: SchemaDiffChange): string[] { + switch (change.type) { + case "ADD_TABLE": { + const t = change.after as { columns: SerializedColumn[] }; + const cols = t.columns.map((c) => { + let line = ` "${c.name}" ${colTypeToSQL(c.type)}`; + if (c.name === "_id") line += " PRIMARY KEY"; + else if (!c.optional) line += " NOT NULL"; + if (c.name === "_createdAt") line += " DEFAULT NOW()"; + if (c.name === "_updatedAt") line += " DEFAULT NOW()"; + return line; + }); + return [`CREATE TABLE IF NOT EXISTS "${change.table}" (\n${cols.join(",\n")}\n);`]; + } + + case "DROP_TABLE": + return [`DROP TABLE IF EXISTS "${change.table}";`]; + + case "ADD_COLUMN": { + const col = change.after as SerializedColumn; + const nullable = col.optional ? "" : " NOT NULL"; + return [`ALTER TABLE "${change.table}" ADD COLUMN "${col.name}" ${colTypeToSQL(col.type)}${nullable};`]; + } + + case "DROP_COLUMN": + return [`ALTER TABLE "${change.table}" DROP COLUMN "${change.column}";`]; + + case "ALTER_COLUMN": { + const after = change.after as SerializedColumn; + return [ + `ALTER TABLE "${change.table}" ALTER COLUMN "${change.column}" TYPE ${colTypeToSQL(after.type)} USING "${change.column}"::${colTypeToSQL(after.type)};`, + ]; + } + + case "ADD_INDEX": { + return [`CREATE INDEX IF NOT EXISTS "${change.table}_${change.index}" ON "${change.table}" ("${change.index}");`]; + } + + case "DROP_INDEX": + return [`DROP INDEX IF EXISTS "${change.table}_${change.index}";`]; + } +} + +export interface GeneratedMigration { + filename: string; + sql: string; +} + +export function generateMigration( + diff: SchemaDiff, + seq: number, // next migration sequence number (e.g. 5) + label: string // short label for filename +): GeneratedMigration { + const filename = String(seq).padStart(4, "0") + `_${label.replace(/\s+/g, "_").toLowerCase()}.sql`; + + const up: string[] = [ + `-- BetterBase IaC Auto-Migration`, + `-- Generated: ${new Date().toISOString()}`, + ``, + ]; + + for (const change of diff.changes) { + up.push(...changeToSQL(change)); + up.push(""); + } + + return { filename, sql: up.join("\n") }; +} +``` + +**Acceptance criteria:** +- `generateMigration()` produces valid Postgres SQL +- `ADD_TABLE` includes all columns with correct types +- Filename format: `0001_add_users_table.sql` +- One migration file per `bb iac sync` call (batches all pending changes) +- Returns both filename and SQL content for the CLI to write + +--- + +### Task IAC-12 — API Type Generator (`_generated/api.d.ts`) + +**Depends on:** IAC-11 + +**What it is:** Reads function registrations discovered by IAC-08, generates a `.d.ts` file declaring the `api` object shape used by the client. + +**Create file:** `packages/core/src/iac/generators/api-typegen.ts` + +```typescript +import type { RegisteredFunction } from "../function-registry"; + +/** + * Given a flat list of registered functions, produce the content of + * bbf/_generated/api.d.ts — the type-safe API object. + */ +export function generateApiTypes(fns: RegisteredFunction[]): string { + // Group by path segments + const groups: Record>> = { + queries: {}, + mutations: {}, + actions: {}, + }; + + for (const fn of fns) { + const parts = fn.path.split("/"); + const kind = parts[0]; // queries | mutations | actions + const file = parts.slice(1, -1).join("/") || "root"; + const name = parts[parts.length - 1]; + + if (!groups[kind]) continue; + if (!groups[kind][file]) groups[kind][file] = {}; + groups[kind][file][name] = fn; + } + + const lines: string[] = [ + `// AUTO-GENERATED by BetterBase IaC — DO NOT EDIT`, + `// Source: bbf/**/*.ts`, + `// Run \`bb iac generate\` to regenerate`, + ``, + `import type { QueryRegistration, MutationRegistration, ActionRegistration } from "@betterbase/core/iac";`, + ``, + `export declare const api: {`, + ]; + + for (const [kind, files] of Object.entries(groups)) { + lines.push(` ${kind}: {`); + for (const [file, exports] of Object.entries(files)) { + const key = file.replace(/\//g, "_") || "root"; + lines.push(` ${key}: {`); + for (const [name, fn] of Object.entries(exports)) { + const type = fn.kind === "query" + ? "QueryRegistration" + : fn.kind === "mutation" + ? "MutationRegistration" + : "ActionRegistration"; + lines.push(` ${name}: ${type};`); + } + lines.push(` };`); + } + lines.push(` };`); + } + + lines.push(`};`); + lines.push(``); + + // Also generate FunctionReference type for useQuery/useMutation + lines.push(`export type FunctionReference =`); + lines.push(` T extends "query" ? QueryRegistration`); + lines.push(` : T extends "mutation" ? MutationRegistration`); + lines.push(` : ActionRegistration;`); + + return lines.join("\n"); +} +``` + +**Acceptance criteria:** +- Generated file is a `.d.ts` (declaration only, no runtime code) +- `api.queries.users.getUser` resolves to correct `QueryRegistration` type +- Client can import `api` and get full autocomplete on all function paths +- Regenerated on every `bb dev` watch cycle + +--- + +## Phase 4 — HTTP Runtime + +### Task IAC-13 — Function HTTP Router + +**Depends on:** IAC-12 + +**What it is:** A Hono router mounted at `/bbf` that executes registered functions. Protocol: `POST /bbf/:kind/:path` with JSON body `{ args: {} }`. + +**Create file:** `packages/server/src/routes/bbf/index.ts` + +```typescript +import { Hono } from "hono"; +import { z } from "zod"; +import { lookupFunction } from "@betterbase/core/iac"; +import { DatabaseReader, DatabaseWriter } from "@betterbase/core/iac"; +import { getPool } from "../../lib/db"; +import { extractBearerToken, verifyAdminToken } from "../../lib/auth"; + +export const bbfRouter = new Hono(); + +// All function calls: POST /bbf/:kind/* +bbfRouter.post("/:kind/*", async (c) => { + const kind = c.req.param("kind") as "queries" | "mutations" | "actions"; + const rest = c.req.path.replace(`/bbf/${kind}/`, ""); + const path = `${kind}/${rest}`; + + const fn = lookupFunction(path); + if (!fn) return c.json({ error: `Function not found: ${path}` }, 404); + + // Parse body + let args: unknown; + try { + const body = await c.req.json(); + args = body.args ?? {}; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + // Validate args + const parsed = (fn.handler as any)._args.safeParse(args); + if (!parsed.success) { + return c.json({ error: "Invalid arguments", details: parsed.error.flatten() }, 400); + } + + // Auth context + const token = extractBearerToken(c.req.header("Authorization")); + const adminPayload = token ? await verifyAdminToken(token) : null; + const authCtx = { userId: adminPayload?.sub ?? null, token }; + + // Build DB context + const pool = getPool(); + const projectSlug = c.req.header("X-Project-Slug") ?? "default"; + const dbSchema = `project_${projectSlug}`; + + try { + let result: unknown; + + if (fn.kind === "query") { + const ctx = { db: new DatabaseReader(pool, dbSchema), auth: authCtx, storage: buildStorageReader() }; + result = await (fn.handler as any)._handler(ctx, parsed.data); + } else if (fn.kind === "mutation") { + const writer = new DatabaseWriter(pool, dbSchema); + const ctx = { db: writer, auth: authCtx, storage: buildStorageWriter(), scheduler: buildScheduler(pool) }; + result = await (fn.handler as any)._handler(ctx, parsed.data); + } else { + // action + const ctx = buildActionCtx(pool, dbSchema, authCtx); + result = await (fn.handler as any)._handler(ctx, parsed.data); + } + + return c.json({ result }); + } catch (err: any) { + console.error(`[bbf] Error in ${path}:`, err); + return c.json({ error: err.message ?? "Function error" }, 500); + } +}); + +// Helpers (stubs — wired to real implementations in IAC-17/IAC-20) +function buildStorageReader() { + return { getUrl: async (_id: string) => null }; +} + +function buildStorageWriter() { + return { + getUrl: async (_id: string) => null, + store: async (_blob: Blob) => "stub-id", + delete: async (_id: string) => {}, + }; +} + +function buildScheduler(pool: any) { + return { + runAfter: async () => "job-id", + runAt: async () => "job-id", + cancel: async () => {}, + }; +} + +function buildActionCtx(pool: any, dbSchema: string, auth: any) { + return { + auth, + storage: buildStorageWriter(), + scheduler: buildScheduler(pool), + runQuery: async (fn: any, args: any) => (fn._handler({ db: new DatabaseReader(pool, dbSchema), auth, storage: buildStorageReader() }, args)), + runMutation: async (fn: any, args: any) => (fn._handler({ db: new DatabaseWriter(pool, dbSchema), auth, storage: buildStorageWriter(), scheduler: buildScheduler(pool) }, args)), + }; +} +``` + +**Mount in `packages/server/src/index.ts`:** + +```typescript +import { bbfRouter } from "./routes/bbf/index"; +// ... +app.route("/bbf", bbfRouter); +``` + +**Transport protocol:** +```bash +# Call a query +curl -X POST http://localhost:3001/bbf/queries/users/getUser \ + -H "Content-Type: application/json" \ + -H "X-Project-Slug: my-project" \ + -H "Authorization: Bearer " \ + -d '{"args": {"id": "abc123"}}' + +# Response +{"result": {"_id": "abc123", "name": "Alice", ...}} +``` + +**Acceptance criteria:** +- `POST /bbf/queries/*` runs queries; `POST /bbf/mutations/*` runs mutations +- Args validated against the function's Zod schema before execution +- 404 for unknown function paths +- 400 for invalid args (Zod error details returned) +- `X-Project-Slug` header routes to the correct per-project schema + +--- + +### Task IAC-14 — Cron Job Runner + +**Depends on:** IAC-13 + +**What it is:** Reads jobs from `getCronJobs()`, schedules them using `setInterval` on server startup. For production, each trigger calls the function's handler as if it were a mutation. + +**Create file:** `packages/server/src/lib/cron-runner.ts` + +```typescript +import { getCronJobs } from "@betterbase/core/iac"; +import { getPool } from "./db"; +import { DatabaseWriter } from "@betterbase/core/iac"; + +/** Parse a 5-part cron expression and return next delay in ms. Simplified v1 impl. */ +function msUntilNext(expression: string): number { + // v1: only support "*/N * * * *" (every N minutes) and "0 * * * *" (every hour) + const parts = expression.split(" "); + if (parts[0].startsWith("*/")) { + const mins = parseInt(parts[0].slice(2)); + return mins * 60 * 1000; + } + if (parts[0] === "0" && parts[1] === "*") return 60 * 60 * 1000; // hourly + if (expression === "0 0 * * *") return 24 * 60 * 60 * 1000; // daily + return 60 * 60 * 1000; // fallback: hourly +} + +export function startCronRunner(projectSlug = "default") { + const jobs = getCronJobs(); + const pool = getPool(); + const dbSchema = `project_${projectSlug}`; + + for (const job of jobs) { + const intervalMs = msUntilNext(job.schedule); + console.log(`[cron] Scheduling "${job.name}" every ${intervalMs / 1000}s`); + + setInterval(async () => { + console.log(`[cron] Running "${job.name}"`); + try { + const db = new DatabaseWriter(pool, dbSchema); + const ctx = { db, auth: { userId: null, token: null }, storage: null as any, scheduler: null as any }; + await (job.fn as any)._handler(ctx, job.args); + } catch (err) { + console.error(`[cron] Error in "${job.name}":`, err); + } + }, intervalMs); + } +} +``` + +**Acceptance criteria:** +- Cron jobs start automatically when server starts (called from `packages/server/src/index.ts`) +- Each job gets a full `MutationCtx` (database writer, auth null for system jobs) +- Errors in individual cron jobs are caught and logged — never crash the server +- v1 supports `*/N * * * *` (every N minutes), hourly, and daily schedules + +--- + +## Phase 5 — Real-Time + +### Task IAC-15 — Query Subscription Tracker + +**Depends on:** IAC-14 + +**What it is:** Tracks which WebSocket clients are subscribed to which queries. When a mutation writes to a table, the tracker finds all subscribed queries that read from that table and pushes an invalidation message. + +**Create file:** `packages/core/src/iac/realtime/subscription-tracker.ts` + +```typescript +export interface QuerySubscription { + clientId: string; + functionPath: string; + args: Record; + tables: Set; // tables this query reads from (declared or inferred) +} + +class SubscriptionTracker { + private _subs = new Map(); // key: `${clientId}:${path}:${argsHash}` + + subscribe(clientId: string, path: string, args: Record, tables: string[]) { + const key = this._key(clientId, path, args); + this._subs.set(key, { clientId, functionPath: path, args, tables: new Set(tables) }); + } + + unsubscribe(clientId: string, path: string, args: Record) { + this._subs.delete(this._key(clientId, path, args)); + } + + unsubscribeClient(clientId: string) { + for (const [key, sub] of this._subs) { + if (sub.clientId === clientId) this._subs.delete(key); + } + } + + /** Get all subscriptions that read from the given table */ + getAffectedSubscriptions(table: string): QuerySubscription[] { + const affected: QuerySubscription[] = []; + for (const sub of this._subs.values()) { + if (sub.tables.has(table) || sub.tables.has("*")) affected.push(sub); + } + return affected; + } + + private _key(clientId: string, path: string, args: Record): string { + return `${clientId}:${path}:${JSON.stringify(args)}`; + } +} + +export const subscriptionTracker = new SubscriptionTracker(); +``` + +**Acceptance criteria:** +- `subscribe()` registers a client's interest in a query + its tables +- `getAffectedSubscriptions(table)` efficiently finds subscriptions to invalidate +- `unsubscribeClient(clientId)` cleans up all subscriptions for a disconnected client +- `"*"` in `tables` means "subscribe to all table changes" (wildcard) + +--- + +### Task IAC-16 — Real-Time Invalidation Push + +**Depends on:** IAC-15 + +**What it is:** When `DatabaseWriter._emitChange()` fires (IAC-07), the realtime manager looks up affected subscriptions and sends `{ type: "invalidate", path, args }` over WebSocket so the client re-fetches. + +**Create file:** `packages/core/src/iac/realtime/invalidation-manager.ts` + +```typescript +import { subscriptionTracker } from "./subscription-tracker"; + +export interface TableChangeEvent { + table: string; + type: "INSERT" | "UPDATE" | "DELETE"; + id: string; +} + +export interface InvalidationMessage { + type: "invalidate"; + functionPath: string; + args: Record; +} + +type PushFn = (clientId: string, message: InvalidationMessage) => void; + +class InvalidationManager { + private _push: PushFn | null = null; + + /** Wire up the WebSocket push function (set by the server at startup) */ + setPushFn(fn: PushFn) { this._push = fn; } + + /** Called by DatabaseWriter on every insert/update/delete */ + emitTableChange(event: TableChangeEvent) { + if (!this._push) return; + + const affected = subscriptionTracker.getAffectedSubscriptions(event.table); + for (const sub of affected) { + this._push(sub.clientId, { + type: "invalidate", + functionPath: sub.functionPath, + args: sub.args, + }); + } + } + + /** Expose getStats for the admin dashboard (IAC-19) */ + getStats() { + return { + clients: 0, // wired up in IAC-17 + channels: [], + }; + } +} + +export const invalidationManager = new InvalidationManager(); + +// Register as global so DatabaseWriter can find it (IAC-07 _emitChange) +(globalThis as any).__betterbaseRealtimeManager = invalidationManager; +``` + +**Acceptance criteria:** +- `emitTableChange({ table: "users", type: "INSERT", id: "..." })` pushes to all affected subscribers +- No invalidation if no clients subscribed to that table +- `setPushFn()` called once at server startup with the WebSocket send function +- `__betterbaseRealtimeManager` global matches the pattern used in IAC-07 and the existing realtime module + +--- + +### Task IAC-17 — WebSocket Subscription Endpoint + +**Depends on:** IAC-16 + +**What it is:** A WebSocket endpoint at `/bbf/ws` that clients connect to for real-time query subscriptions. + +**Create file:** `packages/server/src/routes/bbf/ws.ts` + +```typescript +import type { WSContext } from "hono/ws"; +import { subscriptionTracker } from "@betterbase/core/iac/realtime/subscription-tracker"; +import { invalidationManager } from "@betterbase/core/iac/realtime/invalidation-manager"; +import { nanoid } from "nanoid"; + +interface WSMessage { + type: "subscribe" | "unsubscribe" | "ping"; + path?: string; + args?: Record; + tables?: string[]; +} + +/** Map of clientId → WebSocket context */ +const clients = new Map(); + +/** Wire the push function into the invalidation manager */ +invalidationManager.setPushFn((clientId, message) => { + const ws = clients.get(clientId); + if (ws) ws.send(JSON.stringify(message)); +}); + +export const wsBBFHandlers = { + onOpen(ws: WSContext) { + const clientId = nanoid(); + (ws as any)._clientId = clientId; + clients.set(clientId, ws); + ws.send(JSON.stringify({ type: "connected", clientId })); + }, + + onMessage(ws: WSContext, event: MessageEvent) { + const clientId: string = (ws as any)._clientId; + let msg: WSMessage; + try { msg = JSON.parse(String(event.data)); } catch { return; } + + if (msg.type === "subscribe" && msg.path) { + subscriptionTracker.subscribe(clientId, msg.path, msg.args ?? {}, msg.tables ?? ["*"]); + } else if (msg.type === "unsubscribe" && msg.path) { + subscriptionTracker.unsubscribe(clientId, msg.path, msg.args ?? {}); + } else if (msg.type === "ping") { + ws.send(JSON.stringify({ type: "pong" })); + } + }, + + onClose(ws: WSContext) { + const clientId: string = (ws as any)._clientId; + clients.delete(clientId); + subscriptionTracker.unsubscribeClient(clientId); + }, +}; +``` + +**Mount in `packages/server/src/index.ts`:** + +```typescript +import { upgradeWebSocket } from "hono/bun"; +import { wsBBFHandlers } from "./routes/bbf/ws"; +// ... +app.get("/bbf/ws", upgradeWebSocket(() => wsBBFHandlers)); +``` + +**WebSocket protocol:** +```javascript +// Client subscribes +ws.send(JSON.stringify({ + type: "subscribe", + path: "queries/users/getUser", + args: { id: "abc123" }, + tables: ["users"], // which tables this query reads +})); + +// Server pushes invalidation on mutation +// → { type: "invalidate", functionPath: "queries/users/getUser", args: { id: "abc123" } } +// Client re-fetches the query. +``` + +**Acceptance criteria:** +- WebSocket endpoint at `/bbf/ws` +- `subscribe` message registers interest; `unsubscribe` removes it +- Disconnected clients cleaned up from subscription tracker automatically +- `ping/pong` for keepalive + +--- + +## Phase 6 — CLI Integration + +### Task IAC-18 — `bb iac sync` Command + +**Depends on:** IAC-17 + +**What it is:** One-shot command: reads `bbf/schema.ts`, diffs against previous schema, generates migration, applies it, updates `_generated/`. + +**Create file:** `packages/cli/src/commands/iac/sync.ts` + +```typescript +import { join } from "path"; +import chalk from "chalk"; +import { info, success, error, warn } from "../../utils/logger"; +import { serializeSchema, loadSerializedSchema, saveSerializedSchema } from "@betterbase/core/iac"; +import { diffSchemas, formatDiff } from "@betterbase/core/iac"; +import { generateMigration } from "@betterbase/core/iac"; +import { generateDrizzleSchema } from "@betterbase/core/iac"; +import { readdir, writeFile, mkdir } from "fs/promises"; + +export async function runIacSync(projectRoot: string, opts: { force?: boolean } = {}) { + const bbfDir = join(projectRoot, "bbf"); + const schemaFile = join(bbfDir, "schema.ts"); + const prevFile = join(bbfDir, "_generated", "schema.json"); + const migrDir = join(projectRoot, "drizzle", "migrations"); + const drizzleOut = join(projectRoot, "src", "db", "schema.generated.ts"); + const genDir = join(bbfDir, "_generated"); + + // 1. Load bbf/schema.ts + let schemaMod: any; + try { + schemaMod = await import(schemaFile); + } catch (e: any) { + error(`Cannot load bbf/schema.ts: ${e.message}`); + process.exit(1); + } + + const schema = schemaMod.default ?? schemaMod.schema; + if (!schema?._tables) { + error("bbf/schema.ts must export a default defineSchema(...)"); + process.exit(1); + } + + // 2. Serialize current schema + const current = serializeSchema(schema); + const previous = loadSerializedSchema(prevFile); + + // 3. Diff + const diff = diffSchemas(previous, current); + + if (diff.isEmpty) { + success("Schema is up to date. No changes detected."); + return; + } + + info("Pending schema changes:"); + console.log(formatDiff(diff)); + + // 4. Gate destructive changes + if (diff.hasDestructive && !opts.force) { + warn("Destructive changes detected. Re-run with --force to apply, or remove the changes."); + warn("Destructive operations:\n" + diff.changes.filter(c => c.destructive).map(c => ` ⚠ ${c.type} ${c.table}${c.column ? "." + c.column : ""}`).join("\n")); + process.exit(1); + } + + // 5. Generate migration + const existing = await readdir(migrDir).catch(() => [] as string[]); + const seq = existing.filter(f => f.endsWith(".sql")).length + 1; + const label = "iac_auto"; + const migration = generateMigration(diff, seq, label); + + await mkdir(migrDir, { recursive: true }); + await writeFile(join(migrDir, migration.filename), migration.sql); + info(`Migration written: ${migration.filename}`); + + // 6. Generate Drizzle schema + const drizzleCode = generateDrizzleSchema(current, "postgres"); + await writeFile(drizzleOut, drizzleCode); + info("Drizzle schema updated: src/db/schema.generated.ts"); + + // 7. Save serialized schema for next diff + await mkdir(genDir, { recursive: true }); + await saveSerializedSchema(current, prevFile); + + // 8. TODO: apply migration (calls existing migration runner from SH-02) + info("Run the migration runner to apply changes to the database."); + + success("IaC sync complete."); +} +``` + +**Acceptance criteria:** +- `bb iac sync` exits 0 on clean run, 1 on destructive without `--force` +- Migration file written to `drizzle/migrations/` +- Drizzle schema written to `src/db/schema.generated.ts` +- Serialized schema saved to `bbf/_generated/schema.json` for next diff +- Clear, colored output at every step + +--- + +### Task IAC-19 — `bb iac diff` Command + +**Depends on:** IAC-18 + +**Create file:** `packages/cli/src/commands/iac/diff.ts` + +```typescript +import { join } from "path"; +import chalk from "chalk"; +import { serializeSchema, loadSerializedSchema, diffSchemas, formatDiff } from "@betterbase/core/iac"; + +export async function runIacDiff(projectRoot: string) { + const bbfDir = join(projectRoot, "bbf"); + const prevFile = join(bbfDir, "_generated", "schema.json"); + + let schemaMod: any; + try { + schemaMod = await import(join(bbfDir, "schema.ts")); + } catch { + console.error("Cannot load bbf/schema.ts"); + process.exit(1); + } + + const schema = schemaMod.default ?? schemaMod.schema; + const current = serializeSchema(schema); + const previous = loadSerializedSchema(prevFile); + const diff = diffSchemas(previous, current); + + if (diff.isEmpty) { + console.log(chalk.green("✓ No pending schema changes.")); + return; + } + + console.log(chalk.bold("\nPending changes:")); + console.log(formatDiff(diff)); + + if (diff.hasDestructive) { + console.log(chalk.yellow("\n⚠ Destructive changes present. Use --force with bb iac sync to apply.")); + } +} +``` + +**Acceptance criteria:** +- Shows human-readable diff between current `bbf/schema.ts` and last synced state +- Destructive changes highlighted in yellow +- Exits 0 always (diff is informational, not an error) + +--- + +### Task IAC-20 — `bb iac generate` Command + +**Depends on:** IAC-19 + +**What it is:** Regenerates `bbf/_generated/api.d.ts` from the current function files. Run after adding/removing functions. + +**Create file:** `packages/cli/src/commands/iac/generate.ts` + +```typescript +import { join } from "path"; +import { writeFile, mkdir } from "fs/promises"; +import { discoverFunctions, generateApiTypes } from "@betterbase/core/iac"; +import { success, info } from "../../utils/logger"; + +export async function runIacGenerate(projectRoot: string) { + const bbfDir = join(projectRoot, "bbf"); + const genDir = join(bbfDir, "_generated"); + + info("Scanning bbf/ for functions..."); + const fns = await discoverFunctions(bbfDir); + info(`Found ${fns.length} functions.`); + + const apiTypes = generateApiTypes(fns); + + await mkdir(genDir, { recursive: true }); + await writeFile(join(genDir, "api.d.ts"), apiTypes); + + success(`Generated bbf/_generated/api.d.ts (${fns.length} functions)`); +} +``` + +**Acceptance criteria:** +- Scans `bbf/queries/`, `bbf/mutations/`, `bbf/actions/` +- Writes `bbf/_generated/api.d.ts` +- Idempotent — safe to run multiple times + +--- + +### Task IAC-21 — `bb dev` Watch Mode IaC Integration + +**Depends on:** IAC-20 + +**Modify file:** `packages/cli/src/commands/dev.ts` + +Add watch for `bbf/` directory alongside existing schema/routes watch: + +```typescript +// Add to existing runDevCommand function: + +import { runIacGenerate } from "./iac/generate"; +import { runIacSync } from "./iac/sync"; + +// Inside runDevCommand, after existing watchers: +const bbfDir = join(projectRoot, "bbf"); +if (existsSync(bbfDir)) { + // Watch schema.ts for changes → re-sync + watch(join(bbfDir, "schema.ts"), async () => { + info("[iac] schema.ts changed — running sync..."); + await runIacSync(projectRoot, { force: false }).catch(() => + warn("[iac] Sync failed (destructive changes?). Run bb iac sync --force to override.") + ); + }); + + // Watch function files for changes → re-generate types + watch(bbfDir, { recursive: true }, async (_, filename) => { + if (filename?.startsWith("_generated")) return; + if (filename?.endsWith(".ts")) { + info(`[iac] ${filename} changed — regenerating types...`); + await runIacGenerate(projectRoot).catch(console.error); + } + }); + + info("[iac] Watching bbf/ for changes."); +} +``` + +**Register new commands in `packages/cli/src/index.ts`:** + +```typescript +import { runIacSync } from "./commands/iac/sync"; +import { runIacDiff } from "./commands/iac/diff"; +import { runIacGenerate } from "./commands/iac/generate"; + +program + .command("iac sync") + .description("Sync bbf/schema.ts → migrations + Drizzle schema") + .option("--force", "Apply destructive changes without prompt") + .action((opts) => runIacSync(process.cwd(), opts)); + +program + .command("iac diff") + .description("Show pending schema changes") + .action(() => runIacDiff(process.cwd())); + +program + .command("iac generate") + .description("Regenerate bbf/_generated/ types from function files") + .action(() => runIacGenerate(process.cwd())); + +// Add "iac" variants to PUBLIC_COMMANDS if needed (iac diff is safe without auth) +``` + +**Acceptance criteria:** +- `bb dev` watches `bbf/schema.ts` → runs `iac sync` on change +- `bb dev` watches `bbf/**/*.ts` → runs `iac generate` on function file change +- `bb iac sync`, `bb iac diff`, `bb iac generate` all registered as CLI commands +- `bb iac diff` added to `PUBLIC_COMMANDS` (safe without auth) + +--- + +## Phase 7 — Client SDK + +### Task IAC-22 — `useQuery` Hook (React, Real-Time) + +**Depends on:** IAC-21 + +**Create file:** `packages/client/src/iac/hooks.ts` + +```typescript +import { useState, useEffect, useRef, useCallback } from "react"; +import type { QueryRegistration, MutationRegistration, ActionRegistration } from "@betterbase/core/iac"; + +const API_BASE = typeof window !== "undefined" + ? (window as any).__BETTERBASE_URL__ ?? "http://localhost:3001" + : "http://localhost:3001"; + +function getToken(): string | null { + return typeof localStorage !== "undefined" ? localStorage.getItem("bb_token") : null; +} + +async function callFunction(path: string, args: unknown): Promise { + const res = await fetch(`${API_BASE}/bbf/${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}), + }, + body: JSON.stringify({ args }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Request failed" })); + throw new Error((err as any).error ?? `HTTP ${res.status}`); + } + const { result } = await res.json(); + return result; +} + +// ─── useQuery ───────────────────────────────────────────────────────────────── + +export type QueryStatus = "loading" | "success" | "error"; + +export interface UseQueryResult { + data: T | undefined; + status: QueryStatus; + error: Error | null; + refetch: () => void; +} + +/** + * Real-time query hook. Fetches on mount, re-fetches whenever the server + * pushes an invalidation for this query over WebSocket. + */ +export function useQuery, TReturn>( + fn: QueryRegistration, + args: TArgs +): UseQueryResult { + const [data, setData] = useState(undefined); + const [status, setStatus] = useState("loading"); + const [error, setError] = useState(null); + const pathRef = useRef(""); + + // Resolve function path from registration (set by IAC-23 helper) + const path = (fn as any).__bbfPath ?? "unknown"; + pathRef.current = path; + + const fetch_ = useCallback(async () => { + setStatus("loading"); + try { + const result = await callFunction(path, args) as TReturn; + setData(result); + setStatus("success"); + setError(null); + } catch (e: any) { + setError(e); + setStatus("error"); + } + }, [path, JSON.stringify(args)]); + + useEffect(() => { fetch_(); }, [fetch_]); + + // Subscribe to invalidations via WebSocket + useEffect(() => { + const ws = getBBFWebSocket(); + if (!ws) return; + + ws.send(JSON.stringify({ type: "subscribe", path, args, tables: ["*"] })); + + const handler = (event: MessageEvent) => { + const msg = JSON.parse(event.data); + if (msg.type === "invalidate" && msg.functionPath === path + && JSON.stringify(msg.args) === JSON.stringify(args)) { + fetch_(); + } + }; + + ws.addEventListener("message", handler); + return () => { + ws.removeEventListener("message", handler); + ws.send(JSON.stringify({ type: "unsubscribe", path, args })); + }; + }, [path, JSON.stringify(args)]); + + return { data, status, error, refetch: fetch_ }; +} + +// ─── useMutation ────────────────────────────────────────────────────────────── + +export interface UseMutationResult { + mutate: (args: TArgs) => Promise; + isPending: boolean; + error: Error | null; +} + +export function useMutation, TReturn>( + fn: MutationRegistration +): UseMutationResult { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + const path = (fn as any).__bbfPath ?? "unknown"; + + const mutate = async (args: TArgs): Promise => { + setIsPending(true); + setError(null); + try { + const result = await callFunction(path, args) as TReturn; + return result; + } catch (e: any) { + setError(e); + throw e; + } finally { + setIsPending(false); + } + }; + + return { mutate, isPending, error }; +} + +// ─── useAction ──────────────────────────────────────────────────────────────── + +export function useAction, TReturn>( + fn: ActionRegistration +): UseMutationResult { + const path = (fn as any).__bbfPath ?? "unknown"; + // Actions follow the same pattern as mutations from the client's perspective + return useMutation({ ...fn, __bbfPath: path } as any); +} + +// ─── WebSocket singleton ─────────────────────────────────────────────────────── + +let _ws: WebSocket | null = null; + +function getBBFWebSocket(): WebSocket | null { + if (typeof WebSocket === "undefined") return null; + if (_ws && _ws.readyState === WebSocket.OPEN) return _ws; + + const wsUrl = API_BASE.replace(/^http/, "ws") + "/bbf/ws"; + _ws = new WebSocket(wsUrl); + _ws.onclose = () => { _ws = null; }; + return _ws; +} +``` + +**Acceptance criteria:** +- `useQuery(api.queries.users.getUser, { id })` fetches on mount and re-fetches on invalidation +- `useMutation(api.mutations.users.createUser)` returns `{ mutate, isPending, error }` +- WebSocket connection is a singleton — not created per hook +- Args changes trigger re-fetch + +--- + +### Task IAC-23 — Vanilla (Non-React) Client + `__bbfPath` Injection + +**Depends on:** IAC-22 + +**Create file:** `packages/client/src/iac/client.ts` + +```typescript +import type { QueryRegistration, MutationRegistration, ActionRegistration } from "@betterbase/core/iac"; + +export interface BetterbaseFnClient { + query(fn: QueryRegistration, args: Record): Promise; + mutation(fn: MutationRegistration, args: Record): Promise; + action(fn: ActionRegistration, args: Record): Promise; +} + +export function createFnClient(opts: { + baseUrl: string; + getToken?: () => string | null; +}): BetterbaseFnClient { + async function call(kind: string, fn: any, args: unknown): Promise { + const path = fn.__bbfPath ?? "unknown"; + const token = opts.getToken?.(); + const res = await fetch(`${opts.baseUrl}/bbf/${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ args }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error((err as any).error); + } + const { result } = await res.json(); + return result; + } + + return { + query: (fn, args) => call("queries", fn, args) as any, + mutation: (fn, args) => call("mutations", fn, args) as any, + action: (fn, args) => call("actions", fn, args) as any, + }; +} +``` + +**Also: inject `__bbfPath` into registrations via the code generator (IAC-12)** + +Add to `generateApiTypes()` output a runtime module `bbf/_generated/api.js`: + +```typescript +// The runtime api.js sets __bbfPath on each registration so hooks can find the path +// Generated by bb iac generate — DO NOT EDIT + +import * as queriesUsers from "../queries/users"; +// ... (one import per discovered module) + +export const api = { + queries: { + users: Object.fromEntries( + Object.entries(queriesUsers).map(([name, fn]) => [ + name, + Object.assign(fn, { __bbfPath: `queries/users/${name}` }) + ]) + ), + }, + // ... +}; +``` + +**Acceptance criteria:** +- `createFnClient()` works in Node.js, Deno, Bun — no React required +- `__bbfPath` set on every registration by the generated runtime `api.js` +- Hooks in IAC-22 read `fn.__bbfPath` to construct the fetch URL + +--- + +## Phase 8 — Wire-Up, Tests, and Package Exports + +### Task IAC-24 — Update `packages/core` Exports + +**Depends on:** IAC-23 + +**Modify file:** `packages/core/src/index.ts` + +Add the following exports: + +```typescript +// IaC exports +export * from "./iac/index"; +export * from "./iac/schema-serializer"; +export * from "./iac/schema-diff"; +export * from "./iac/generators/drizzle-schema-gen"; +export * from "./iac/generators/migration-gen"; +export * from "./iac/generators/api-typegen"; +export * from "./iac/function-registry"; +export * from "./iac/db-context"; +export * from "./iac/realtime/subscription-tracker"; +export * from "./iac/realtime/invalidation-manager"; +``` + +**Add subpath export to `packages/core/package.json`:** + +```json +{ + "exports": { + ".": "./src/index.ts", + "./iac": "./src/iac/index.ts", + "./iac/realtime/subscription-tracker": "./src/iac/realtime/subscription-tracker.ts", + "./iac/realtime/invalidation-manager": "./src/iac/realtime/invalidation-manager.ts", + "./iac/generators/drizzle-schema-gen": "./src/iac/generators/drizzle-schema-gen.ts", + "./iac/generators/migration-gen": "./src/iac/generators/migration-gen.ts", + "./iac/generators/api-typegen": "./src/iac/generators/api-typegen.ts", + "./iac/function-registry": "./src/iac/function-registry.ts", + "./iac/db-context": "./src/iac/db-context.ts" + } +} +``` + +**Acceptance criteria:** +- `import { v, defineSchema, defineTable, query, mutation, action } from "@betterbase/core/iac"` works +- Subpath exports resolve correctly for CLI and server consumers +- `bun run build` for `packages/core` passes + +--- + +### Task IAC-25 — Integration Test: Full IaC Flow + +**Depends on:** IAC-24 + +**Create file:** `packages/core/test/iac.test.ts` + +```typescript +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { v, defineSchema, defineTable, query, mutation, serializeSchema, diffSchemas } from "@betterbase/core/iac"; + +describe("Validator primitives", () => { + it("v.string() validates strings", () => { + const schema = v.string(); + expect(schema.safeParse("hello").success).toBe(true); + expect(schema.safeParse(123).success).toBe(false); + }); + + it("v.id() brands the string", () => { + const schema = v.id("users"); + expect(schema.safeParse("abc").success).toBe(true); + }); + + it("v.optional() makes fields optional", () => { + const schema = v.optional(v.string()); + expect(schema.safeParse(undefined).success).toBe(true); + }); +}); + +describe("defineTable + defineSchema", () => { + it("injects system fields", () => { + const table = defineTable({ name: v.string() }); + const keys = Object.keys(table._schema.shape); + expect(keys).toContain("_id"); + expect(keys).toContain("_createdAt"); + expect(keys).toContain("_updatedAt"); + expect(keys).toContain("name"); + }); + + it("chains index definitions", () => { + const table = defineTable({ email: v.string() }).uniqueIndex("by_email", ["email"]); + expect(table._indexes).toHaveLength(1); + expect(table._indexes[0].type).toBe("uniqueIndex"); + }); + + it("defineSchema wraps tables", () => { + const schema = defineSchema({ users: defineTable({ name: v.string() }) }); + expect(schema._tables.users).toBeDefined(); + }); +}); + +describe("Schema serializer + diff", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }).uniqueIndex("by_email", ["email"]), + }); + + it("serializes schema to JSON", () => { + const s = serializeSchema(schema); + expect(s.tables).toHaveLength(1); + expect(s.tables[0].name).toBe("users"); + const emailCol = s.tables[0].columns.find(c => c.name === "email"); + expect(emailCol?.type).toBe("string"); + }); + + it("empty diff for identical schemas", () => { + const s = serializeSchema(schema); + const diff = diffSchemas(s, s); + expect(diff.isEmpty).toBe(true); + }); + + it("detects new table as ADD_TABLE", () => { + const s1 = serializeSchema(schema); + const s2 = serializeSchema(defineSchema({ users: defineTable({ name: v.string() }), posts: defineTable({ title: v.string() }) })); + const diff = diffSchemas(s1, s2); + expect(diff.changes.some(c => c.type === "ADD_TABLE" && c.table === "posts")).toBe(true); + }); + + it("marks DROP_TABLE as destructive", () => { + const s1 = serializeSchema(schema); + const s2 = serializeSchema(defineSchema({})); + const diff = diffSchemas(s1, s2); + expect(diff.hasDestructive).toBe(true); + }); +}); + +describe("query() / mutation() registrations", () => { + it("query() returns valid registration", () => { + const fn = query({ args: { id: v.id("users") }, handler: async (ctx, args) => args.id }); + expect(fn._args).toBeDefined(); + expect(fn._handler).toBeInstanceOf(Function); + }); + + it("mutation() returns valid registration", () => { + const fn = mutation({ args: { name: v.string() }, handler: async (ctx, args) => args.name }); + expect(fn._args).toBeDefined(); + }); +}); +``` + +**Acceptance criteria:** +- All tests pass with `bun test packages/core/test/iac.test.ts` +- Tests cover validators, defineTable, defineSchema, serializer, diff engine, and function registration + +--- + +## Developer Experience — What It Looks Like + +A new project using BetterBase IaC requires three files to get started: + +**`bbf/schema.ts`** +```typescript +import { defineSchema, defineTable, v } from "@betterbase/core/iac"; + +export default defineSchema({ + todos: defineTable({ + text: v.string(), + completed: v.boolean(), + userId: v.id("users"), + }) + .index("by_user", ["userId"]) + .index("by_user_completed", ["userId", "completed"]), +}); +``` + +**`bbf/mutations/todos.ts`** +```typescript +import { mutation } from "@betterbase/core/iac"; +import { v } from "@betterbase/core/iac"; + +export const createTodo = mutation({ + args: { text: v.string(), userId: v.id("users") }, + handler: async (ctx, args) => { + return ctx.db.insert("todos", { ...args, completed: false }); + }, +}); + +export const toggleTodo = mutation({ + args: { id: v.id("todos"), completed: v.boolean() }, + handler: async (ctx, args) => { + await ctx.db.patch("todos", args.id, { completed: args.completed }); + }, +}); +``` + +**`bbf/queries/todos.ts`** +```typescript +import { query } from "@betterbase/core/iac"; +import { v } from "@betterbase/core/iac"; + +export const listTodos = query({ + args: { userId: v.id("users") }, + handler: async (ctx, args) => { + return ctx.db + .query("todos") + .filter("userId", "eq", args.userId) + .order("desc") + .collect(); + }, +}); +``` + +**React component:** +```tsx +import { useQuery, useMutation } from "@betterbase/client/iac"; +import { api } from "../bbf/_generated/api"; + +export function TodoList({ userId }: { userId: string }) { + const todos = useQuery(api.queries.todos.listTodos, { userId }); + const toggle = useMutation(api.mutations.todos.toggleTodo); + + if (todos.status === "loading") return
Loading...
; + + return ( +
    + {todos.data?.map(todo => ( +
  • toggle.mutate({ id: todo._id, completed: !todo.completed })}> + {todo.completed ? "✓" : "○"} {todo.text} +
  • + ))} +
+ ); +} +``` + +--- + +## Differentiation from Convex + +| Feature | Convex | BetterBase IaC | +|---|---|---| +| Validator internals | Custom `v.*` system | Zod under the hood — interops with existing Zod code | +| Database | Convex proprietary | Any Drizzle-supported DB (SQLite, Postgres, MySQL, Neon, Turso) | +| Hosting | Convex cloud only | Self-hosted or BetterBase cloud | +| Existing code | Must migrate fully | Additive — existing Hono routes work unchanged | +| Schema migration | Automatic, invisible | Explicit `bb iac sync` with diff preview + force flag | +| Auth | Clerk / Convex auth | BetterAuth (already in BetterBase) | +| Functions | Isolated Convex runtime | Runs in existing Hono server process | +| Pricing | Convex pricing | Open source, self-hosted | + +--- + +## Execution Order + +``` +Phase 1 — Validator & Schema + IAC-01 v.* validator primitives + IAC-02 defineTable + index builders + IAC-03 defineSchema + type inference + IAC-04 Schema serializer + IAC-05 Schema diff engine + +Phase 2 — Function System + IAC-06 query() / mutation() / action() primitives + ctx types + IAC-07 DatabaseReader + DatabaseWriter + IAC-08 Function registry (file discovery) + IAC-09 cron() primitive + +Phase 3 — Code Generation + IAC-10 Drizzle schema generator + IAC-11 Migration SQL generator + IAC-12 API type generator (_generated/api.d.ts) + +Phase 4 — HTTP Runtime + IAC-13 Function HTTP router (/bbf/:kind/*) + IAC-14 Cron job runner + +Phase 5 — Real-Time + IAC-15 Subscription tracker + IAC-16 Invalidation manager + IAC-17 WebSocket endpoint (/bbf/ws) + +Phase 6 — CLI + IAC-18 bb iac sync + IAC-19 bb iac diff + IAC-20 bb iac generate + IAC-21 bb dev watch integration + +Phase 7 — Client SDK + IAC-22 useQuery / useMutation / useAction hooks + IAC-23 Vanilla client + __bbfPath injection + +Phase 8 — Wire-Up + IAC-24 packages/core exports + subpath config + IAC-25 Integration tests +``` + +**Total: 25 tasks across 8 phases.** + +--- + +## Dependencies Checklist + +Before starting Phase 1, verify these are available: + +| Dependency | Package | Already Present | +|---|---|---| +| `zod` | packages/core | ✓ | +| `nanoid` | packages/server | ✓ | +| `pg` | packages/server | ✓ | +| `hono/ws` | packages/server | verify — may need `@hono/node-ws` or bun ws adapter | +| `react`, `react-dom` | packages/client | add if building hooks | +| `drizzle-orm` | packages/core | ✓ | + +--- + +## Migration Numbering Note + +The self-hosted spec (SH-01 through SH-28) uses migration files `001` through `004` in `packages/server/migrations/`. The observability spec adds `005` through `007`. The dashboard backend spec adds `005` through `010` (in a different numbering path). + +The IaC-generated migrations live in `drizzle/migrations/` (per-project) — a **separate directory** from the server's internal `packages/server/migrations/`. No conflict. + +--- + +*End of specification. 25 tasks across 8 phases. Execute in listed order. Do not begin Phase 2 until Phase 1 tests pass.* diff --git a/apps/test-project/README.md b/apps/test-project/README.md deleted file mode 100644 index eb1a6ff..0000000 --- a/apps/test-project/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Base Template (Bun + TypeScript + Hono + Drizzle) - -Starter template aligned to BetterBase defaults: -- Bun runtime -- TypeScript strict mode -- Hono API server -- Drizzle ORM with SQLite local default -- Zod available for request validation - -## Structure - -```txt -src/ - db/ - index.ts - schema.ts - routes/ - index.ts - health.ts - users.ts - middleware/ - validation.ts - lib/ - env.ts - realtime.ts - index.ts -betterbase.config.ts -drizzle.config.ts -``` - - -## Quick Start - -- Install dependencies: `bun install` -- Start development server: `bun run dev` -- Generate Drizzle migrations: `bun run db:generate` -- Apply migrations locally: `bun run db:push` -- Build for production: `bun run build` -- Start production server: `bun run start` - -Environment variables are validated in `src/lib/env.ts` (`NODE_ENV`, `PORT`, `DB_PATH`). - - -## Realtime - -The template includes WebSocket realtime support at `GET /ws` using `src/lib/realtime.ts`. -Clients should provide an auth token (Bearer header or `?token=` query) before subscribing. diff --git a/apps/test-project/betterbase.config.ts b/apps/test-project/betterbase.config.ts deleted file mode 100644 index 39848da..0000000 --- a/apps/test-project/betterbase.config.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * BetterBase Configuration File - * - * This file defines the configuration for your BetterBase project. - * Update the values below to match your project requirements. - * - * Required environment variables: - * - DATABASE_URL: Connection string for your database (for neon, postgres, supabase, planetscale) - * - TURSO_URL: libSQL connection URL (for turso) - * - TURSO_AUTH_TOKEN: Auth token for Turso database (for turso) - */ - -import type { BetterBaseConfig } from "@betterbase/core"; - -/** - * Validate DATABASE_URL is present and non-empty - */ -function getDatabaseUrl(): string { - const dbUrl = process.env.DATABASE_URL; - if (!dbUrl || typeof dbUrl !== "string" || dbUrl.trim() === "") { - console.error( - "[BetterBase Config Error] DATABASE_URL is required but not set or is empty. " + - "Please set the DATABASE_URL environment variable.\n" + - 'Example: DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"', - ); - process.exit(1); - } - return dbUrl; -} - -/** - * BetterBase Project Configuration - * - * @example - * ```typescript - * export default { - * project: { - * name: 'my-betterbase-app', - * }, - * provider: { - * type: 'postgres', - * connectionString: process.env.DATABASE_URL, - * }, - * } satisfies BetterBaseConfig - * ``` - */ -export default { - /** Project name - used for identification and metadata */ - project: { - name: "my-betterbase-app", - }, - - /** - * Database provider configuration - * - * Supported providers: - * - 'postgres': Standard PostgreSQL (uses DATABASE_URL) - * - 'neon': Neon serverless PostgreSQL (uses DATABASE_URL) - * - 'supabase': Supabase PostgreSQL (uses DATABASE_URL) - * - 'planetscale': PlanetScale MySQL (uses DATABASE_URL) - * - 'turso': Turso libSQL (uses TURSO_URL and TURSO_AUTH_TOKEN) - * - 'managed': BetterBase managed database (uses DATABASE_URL or defaults to local.db) - */ - provider: { - /** The database provider type */ - type: "postgres" as const, - - /** - * Database connection string (for postgres, neon, supabase, planetscale) - * Format: postgresql://user:pass@host:port/db for PostgreSQL - * Format: mysql://user:pass@host:port/db for MySQL/PlanetScale - */ - connectionString: getDatabaseUrl(), - - // Turso-specific (uncomment if using Turso): - // url: process.env.TURSO_URL, - // authToken: process.env.TURSO_AUTH_TOKEN, - }, - - /** - * Storage configuration (Phase 14) - * Uncomment and configure when implementing file storage - */ - // storage: { - // provider: 's3', // 's3' | 'r2' | 'backblaze' | 'minio' | 'managed' - // bucket: 'my-bucket', - // region: 'us-east-1', - // // For S3-compatible providers: - // // endpoint: 'https://s3.amazonaws.com', - // }, - - /** - * Webhook configuration (Phase 13) - * Uncomment and configure when implementing webhooks - */ - // webhooks: [ - // { - // id: 'webhook-1', - // table: 'users', - // events: ['INSERT', 'UPDATE', 'DELETE'], - // url: 'https://example.com/webhook', - // secret: process.env.WEBHOOK_SECRET!, - // enabled: true, - // }, - // ], - - /** - * GraphQL API configuration - * Set enabled: false to disable the GraphQL API - */ - graphql: { - enabled: true, - }, -} satisfies BetterBaseConfig; diff --git a/apps/test-project/bun.lock b/apps/test-project/bun.lock deleted file mode 100644 index 39de43e..0000000 --- a/apps/test-project/bun.lock +++ /dev/null @@ -1,274 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "betterbase-base-template", - "dependencies": { - "better-auth": "^1.0.0", - "drizzle-orm": "^0.44.5", - "fast-deep-equal": "^3.1.3", - "hono": "^4.6.10", - "zod": "^4.0.0", - }, - "devDependencies": { - "@types/bun": "^1.3.9", - "drizzle-kit": "^0.31.4", - "typescript": "^5.9.3", - }, - }, - }, - "packages": { - "@better-auth/core": ["@better-auth/core@1.5.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-fORsQjNZ6BQ7o96xMe7elz3Y4Y8DsqXmQrdyzt289G9rmzX4auwBCPTtE2cXTRTYGiVvH9bv0b97t1Uo/OWynQ=="], - - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-eAm1KPrlPXkH/qXUXnGBcHPDgCX153b6BSlc2QJ2IeqmiWym9D/6XORqBIZOl71JiP0Cifzocr2GLpnz0gt31Q=="], - - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.3", "", { "peerDependencies": { "@better-auth/core": "1.5.3", "@better-auth/utils": "^0.3.0" } }, "sha512-QdeTI3bvUmaPkHsjcSMfroXyuGsgnxobv7wZVl57e+ox6yQVR1j4VKbqmCILP6PL6Rr2gpcBH/liHr8v5gqY5Q=="], - - "@better-auth/telemetry": ["@better-auth/telemetry@1.5.3", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.3" } }, "sha512-ZX/r8AsWdB6BwH+Rb7H/SyJnGtPN6EDWrNxBQEDsqRrBJVcDLwAIz165P57RXci0WwtY872T0guKq+XVyy5rkA=="], - - "@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], - - "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - - "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], - - "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], - - "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], - - "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "better-auth": ["better-auth@1.5.3", "", { "dependencies": { "@better-auth/core": "1.5.3", "@better-auth/kysely-adapter": "1.5.3", "@better-auth/memory-adapter": "1.5.3", "@better-auth/telemetry": "1.5.3", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/drizzle-adapter": "1.5.3", "@better-auth/mongo-adapter": "1.5.3", "@better-auth/prisma-adapter": "1.5.3", "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@better-auth/drizzle-adapter", "@better-auth/mongo-adapter", "@better-auth/prisma-adapter", "@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-E+9kA9GMX1+gT3FfMCqRz0NufT4X/+tNhpOsHW1jLmyPZKinkHtfZkUffSBnG5qGkvfBaH/slT5c1fKttnmF5w=="], - - "better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="], - - "better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], - - "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], - - "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - - "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - - "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], - - "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], - - "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], - - "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="], - - "drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="], - - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], - - "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], - - "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], - - "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - - "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - - "hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="], - - "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - - "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], - - "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanostores": ["nanostores@1.1.1", "", {}, "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg=="], - - "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], - - "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], - - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], - - "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], - - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="], - - "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], - - "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], - - "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], - - "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - - "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], - - "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], - - "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], - - "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - } -} diff --git a/apps/test-project/drizzle.config.ts b/apps/test-project/drizzle.config.ts deleted file mode 100644 index 3e76c50..0000000 --- a/apps/test-project/drizzle.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./src/db/schema.ts", - out: "./drizzle", - dialect: "sqlite", - dbCredentials: { - url: "file:local.db", - }, - verbose: true, - strict: true, -}); diff --git a/apps/test-project/package.json b/apps/test-project/package.json deleted file mode 100644 index aec587f..0000000 --- a/apps/test-project/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "test-project", - "private": true, - "type": "module", - "scripts": { - "dev": "bun --hot run src/index.ts", - "db:generate": "drizzle-kit generate", - "db:push": "bun run src/db/migrate.ts", - "typecheck": "tsc --noEmit", - "build": "bun build src/index.ts --outfile dist/index.js --target bun", - "start": "bun run dist/index.js", - "test": "bun test" - }, - "dependencies": { - "@betterbase/cli": "workspace:*", - "@betterbase/client": "workspace:*", - "@betterbase/core": "workspace:*", - "@betterbase/shared": "workspace:*", - "@better-auth/drizzle-adapter": "^1.0.0", - "better-auth": "^1.0.0", - "drizzle-orm": "^0.44.5", - "fast-deep-equal": "^3.1.3", - "hono": "^4.6.10", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/bun": "^1.3.9", - "drizzle-kit": "^0.31.4", - "typescript": "^5.9.3" - } -} diff --git a/apps/test-project/src/auth/index.ts b/apps/test-project/src/auth/index.ts deleted file mode 100644 index 8d877aa..0000000 --- a/apps/test-project/src/auth/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { betterAuth } from "better-auth"; -import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { db } from "../db"; -import * as schema from "../db/schema"; -import { env } from "../lib/env"; - -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "sqlite", - schema: { - user: schema.user, - session: schema.session, - account: schema.account, - verification: schema.verification, - }, - }), - emailAndPassword: { - enabled: true, - requireEmailVerification: false, - }, - secret: env.AUTH_SECRET, - baseURL: env.AUTH_URL, - trustedOrigins: [env.AUTH_URL], - plugins: [], -}); - -export type Auth = typeof auth; diff --git a/apps/test-project/src/auth/types.ts b/apps/test-project/src/auth/types.ts deleted file mode 100644 index 2a33da9..0000000 --- a/apps/test-project/src/auth/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { auth } from "./index"; - -export type Session = typeof auth.$Infer.Session.session; -export type User = typeof auth.$Infer.Session.user; - -export type AuthVariables = { - user: User; - session: Session; -}; diff --git a/apps/test-project/src/db/index.ts b/apps/test-project/src/db/index.ts deleted file mode 100644 index f027283..0000000 --- a/apps/test-project/src/db/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { env } from "../lib/env"; -import * as schema from "./schema"; - -// env.DB_PATH is always present because env schema provides a default. -const sqlite = new Database(env.DB_PATH, { create: true }); - -export const db = drizzle(sqlite, { schema }); diff --git a/apps/test-project/src/db/migrate.ts b/apps/test-project/src/db/migrate.ts deleted file mode 100644 index 0b09c1a..0000000 --- a/apps/test-project/src/db/migrate.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Database } from "bun:sqlite"; -import { drizzle } from "drizzle-orm/bun-sqlite"; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { env } from "../lib/env"; - -try { - const sqlite = new Database(env.DB_PATH, { create: true }); - const db = drizzle(sqlite); - - migrate(db, { migrationsFolder: "./drizzle" }); - console.log("Migrations applied successfully."); -} catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error("Failed to apply migrations:", message); - process.exit(1); -} diff --git a/apps/test-project/src/db/policies/.gitkeep b/apps/test-project/src/db/policies/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/test-project/src/db/schema.ts b/apps/test-project/src/db/schema.ts deleted file mode 100644 index 5adafbc..0000000 --- a/apps/test-project/src/db/schema.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -/** - * Adds created_at and updated_at timestamp columns. - * created_at is set on insert and updated_at is refreshed on updates. - * Note: .$onUpdate(() => new Date()) applies when updates go through Drizzle. - * Raw SQL writes will not auto-update this value without a DB trigger. - * - * @example - * export const users = sqliteTable('users', { - * id: uuid(), - * email: text('email'), - * ...timestamps, - * }); - */ -export const timestamps = { - createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()), - updatedAt: integer("updated_at", { mode: "timestamp" }) - .$defaultFn(() => new Date()) - .$onUpdate(() => new Date()), -}; - -/** - * UUID primary-key helper. - */ -export const uuid = (name = "id") => - text(name) - .primaryKey() - .$defaultFn(() => crypto.randomUUID()); - -/** - * Soft-delete helper. - */ -export const softDelete = { - deletedAt: integer("deleted_at", { mode: "timestamp" }), -}; - -/** - * Shared status enum helper. - */ -export const statusEnum = (name = "status") => - text(name, { enum: ["active", "inactive", "pending"] }).default("active"); - -/** - * Currency helper stored as integer cents. - */ -export const moneyColumn = (name: string) => integer(name).notNull().default(0); - -/** - * JSON text helper with type support. - */ -export const jsonColumn = (name: string) => text(name, { mode: "json" }).$type(); - -export const users = sqliteTable("users", { - id: uuid(), - email: text("email").notNull().unique(), - name: text("name"), - status: statusEnum(), - ...timestamps, - ...softDelete, -}); - -export const posts = sqliteTable("posts", { - id: uuid(), - title: text("title").notNull(), - content: text("content"), - userId: text("user_id").references(() => users.id), - ...timestamps, -}); - -// BetterAuth tables -export const user = sqliteTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false), - image: text("image"), - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), -}); - -export const session = sqliteTable("session", { - id: text("id").primaryKey(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), - token: text("token").notNull().unique(), - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), -}); - -export const account = sqliteTable("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: integer("access_token_expires_at", { - mode: "timestamp", - }), - refreshTokenExpiresAt: integer("refresh_token_expires_at", { - mode: "timestamp", - }), - scope: text("scope"), - password: text("password"), - createdAt: integer("created_at", { mode: "timestamp" }).notNull(), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), -}); - -export const verification = sqliteTable("verification", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(), - createdAt: integer("created_at", { mode: "timestamp" }), - updatedAt: integer("updated_at", { mode: "timestamp" }), -}); diff --git a/apps/test-project/src/functions/.gitkeep b/apps/test-project/src/functions/.gitkeep deleted file mode 100644 index 2c4fd22..0000000 --- a/apps/test-project/src/functions/.gitkeep +++ /dev/null @@ -1,21 +0,0 @@ -# Edge Functions - -This directory contains your edge functions. Each subdirectory represents a single function. - -## Creating a Function - -```bash -bb function create my-function -``` - -This creates: -- `src/functions/my-function/index.ts` - The function code -- `src/functions/my-function/config.ts` - Function configuration - -## Available Commands - -- `bb function create ` - Create a new edge function -- `bb function dev ` - Run function locally with hot reload -- `bb function build ` - Bundle function for deployment -- `bb function list` - List all functions -- `bb function deploy ` - Deploy to Cloudflare Workers or Vercel Edge diff --git a/apps/test-project/src/functions/hello/index.ts b/apps/test-project/src/functions/hello/index.ts deleted file mode 100644 index b0c8dc2..0000000 --- a/apps/test-project/src/functions/hello/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { FunctionContext } from "@betterbase/core/functions"; - -/** - * Sample function that returns a greeting - * Access at http://localhost:3000/functions/hello - */ -export default async function(ctx: FunctionContext): Promise { - return new Response(JSON.stringify({ - message: "Hello from function!", - env: Object.keys(ctx.env), - }), { - headers: { 'Content-Type': 'application/json' } - }); -} diff --git a/apps/test-project/src/index.ts b/apps/test-project/src/index.ts deleted file mode 100644 index ddd398d..0000000 --- a/apps/test-project/src/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { EventEmitter } from "node:events"; -import { existsSync } from "node:fs"; -import { createFunctionsMiddleware, initializeFunctionsRuntime } from "@betterbase/core/functions"; -import { type WebhookDbClient, initializeWebhooks } from "@betterbase/core/webhooks"; -import { Hono } from "hono"; -import { upgradeWebSocket, websocket } from "hono/bun"; -import config from "../betterbase.config"; -import { auth } from "./auth"; -import { db } from "./db"; -import { env } from "./lib/env"; -import { realtime } from "./lib/realtime"; -import { registerRoutes } from "./routes"; - -// Create an adapter to make drizzle SQLite compatible with WebhookDbClient interface -const dbAdapter: WebhookDbClient = { - async execute(_args: { sql: string; args: unknown[] }) { - return { rows: [] }; - }, -}; - -const app = new Hono(); - -// Create an event emitter for database changes (used by webhooks) -const dbEventEmitter = new EventEmitter(); - -app.get( - "/ws", - upgradeWebSocket((c) => { - const authHeaderToken = c.req.header("authorization")?.replace(/^Bearer\s+/i, ""); - // Query token is ONLY allowed in development mode for testing - const queryToken = c.req.query("token"); - const isDev = process.env.NODE_ENV !== "production"; - - // Only accept queryToken in development mode - const token = authHeaderToken ?? (isDev ? queryToken : undefined); - - if (!authHeaderToken && queryToken && isDev) { - console.warn( - "WebSocket auth using query token fallback; prefer header/cookie/subprotocol in production.", - ); - } - - return { - onOpen(_event, ws) { - realtime.handleConnection(ws.raw, token); - }, - onMessage(event, ws) { - const message = typeof event.data === "string" ? event.data : event.data.toString(); - realtime.handleMessage(ws.raw, message); - }, - onClose(_event, ws) { - realtime.handleClose(ws.raw); - }, - }; - }), -); - -registerRoutes(app); - -app.on(["POST", "GET"], "/api/auth/**", (c) => { - return auth.handler(c.req.raw); -}); - -// Mount GraphQL API if enabled -const graphqlEnabled = config.graphql?.enabled ?? true; -if (graphqlEnabled) { - // Dynamic import to handle case where graphql route doesn't exist yet - try { - const graphql = await import("./routes/graphql"); - const graphqlRoute = graphql.graphqlRoute as ReturnType< - typeof import("hono").Hono.prototype.route - >; - app.route("/", graphqlRoute); - console.log("🛸 GraphQL API enabled at /api/graphql"); - } catch (err: unknown) { - // Check if it's a "module not found" error vs a real syntax/runtime error - const isModuleNotFound = - err && - typeof err === "object" && - (("code" in err && - (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND")) || - ("message" in err && /Cannot find module|Cannot find package/.test(String(err.message)))); - - if (isModuleNotFound) { - // GraphQL route not generated yet - only log in development - if (env.NODE_ENV === "development") { - console.log('ℹ️ Run "bb graphql generate" to enable GraphQL API'); - } - } else { - // Re-throw real errors (syntax errors, runtime errors) so they're not swallowed - console.error("Failed to load GraphQL module:", err); - throw err; - } - } -} - -// Initialize webhooks (Phase 13) -// Pass database client for persistent delivery logging -initializeWebhooks(config, dbEventEmitter, dbAdapter); - -// Webhook logs API endpoint (for CLI access) -app.get("/api/webhooks/:id/logs", async (c) => { - const webhookId = c.req.param("id"); - // In a full implementation, this would fetch logs from the dispatcher - // For now, return a placeholder - return c.json({ logs: [], message: "Logs not available via API in v1" }); -}); - -// Initialize functions runtime for local development -// Functions are available at /functions/:name -const isDev = env.NODE_ENV === "development"; -if (isDev) { - const functionsDir = "./src/functions"; - if (existsSync(functionsDir)) { - try { - const functionsRuntime = await initializeFunctionsRuntime( - ".", - process.env as Record, - ); - if (functionsRuntime) { - app.all("/functions/:name", createFunctionsMiddleware(functionsRuntime) as any); - console.log("⚡ Functions runtime enabled at /functions/:name"); - } - } catch (error) { - console.warn("Failed to initialize functions runtime:", error); - } - } -} - -const server = Bun.serve({ - fetch: app.fetch, - websocket, - port: env.PORT, - development: env.NODE_ENV === "development", -}); - -console.log(`🚀 Server running at http://localhost:${server.port}`); -for (const route of app.routes) { - console.log(` ${route.method} ${route.path}`); -} - -process.on("SIGTERM", () => { - server.stop(); -}); - -process.on("SIGINT", () => { - server.stop(); -}); - -export { app, server, dbEventEmitter }; diff --git a/apps/test-project/src/lib/env.ts b/apps/test-project/src/lib/env.ts deleted file mode 100644 index ef962d4..0000000 --- a/apps/test-project/src/lib/env.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { DEFAULT_DB_PATH } from "@betterbase/shared"; -import { z } from "zod"; - -const envSchema = z.object({ - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - PORT: z.coerce.number().int().positive().default(3000), - DB_PATH: z.string().min(1).default(DEFAULT_DB_PATH), - // Auth configuration - AUTH_SECRET: z.string().min(32).optional(), - AUTH_URL: z.string().url().default("http://localhost:3000"), -}); - -export const env = envSchema.parse(process.env); diff --git a/apps/test-project/src/lib/realtime.ts b/apps/test-project/src/lib/realtime.ts deleted file mode 100644 index b93ca39..0000000 --- a/apps/test-project/src/lib/realtime.ts +++ /dev/null @@ -1,419 +0,0 @@ -import type { ServerWebSocket } from "bun"; -import deepEqual from "fast-deep-equal"; -import { z } from "zod"; -import { ChannelManager, type PresenceState } from "@betterbase/core"; - -export interface Subscription { - table: string; - filter?: Record; -} - -interface Client { - ws: ServerWebSocket; - userId: string; - claims: string[]; - subscriptions: Map; - connectionId: string; -} - -interface RealtimeUpdatePayload { - type: "update"; - table: string; - event: "INSERT" | "UPDATE" | "DELETE"; - data: unknown; - timestamp: string; -} - -interface RealtimeConfig { - maxClients: number; - maxSubscriptionsPerClient: number; - maxSubscribersPerTable: number; -} - -const messageSchema = z.union([ - z.object({ - type: z.literal("subscribe"), - table: z.string().min(1).max(255), - filter: z.record(z.string(), z.unknown()).optional(), - }), - z.object({ - type: z.literal("unsubscribe"), - table: z.string().min(1).max(255), - }), - // Channel subscription messages - z.object({ - type: z.literal("subscribe"), - channel: z.string().min(1).max(255), - payload: z.object({ - user_id: z.string().optional(), - presence: z.record(z.string(), z.unknown()).optional(), - }).optional(), - }), - z.object({ - type: z.literal("unsubscribe"), - channel: z.string().min(1).max(255), - }), - z.object({ - type: z.literal("broadcast"), - channel: z.string().min(1).max(255), - payload: z.object({ - event: z.string(), - data: z.unknown(), - }), - }), - z.object({ - type: z.literal("presence"), - channel: z.string().min(1).max(255), - payload: z.object({ - action: z.literal("update"), - state: z.record(z.string(), z.unknown()), - }), - }), -]); - -type ChannelMessage = z.infer; - -const realtimeLogger = { - debug: (message: string): void => console.debug(`[realtime] ${message}`), - info: (message: string): void => console.info(`[realtime] ${message}`), - warn: (message: string): void => console.warn(`[realtime] ${message}`), -}; - -export class RealtimeServer { - private clients = new Map, Client>(); - private tableSubscribers = new Map>>(); - private channelManager = new ChannelManager>(); - private config: RealtimeConfig; - - constructor(config?: Partial) { - if (process.env.NODE_ENV !== "development") { - realtimeLogger.warn( - "Realtime auth verifier is not configured; dev token parser is disabled. Configure a real verifier for production.", - ); - } - - this.config = { - maxClients: 1000, - maxSubscriptionsPerClient: 50, - maxSubscribersPerTable: 500, - ...config, - }; - } - - authenticate(token: string | undefined): { userId: string; claims: string[] } | null { - if (!token || !token.trim()) return null; - - const allowDevAuth = process.env.NODE_ENV === "development"; - if (!allowDevAuth) { - return null; - } - - const [userId, rawClaims] = token.trim().split(":", 2); - if (!userId) return null; - - const claims = rawClaims - ? rawClaims - .split(",") - .map((claim) => claim.trim()) - .filter(Boolean) - : []; - return { userId, claims }; - } - - authorize(userId: string, claims: string[], table: string): boolean { - return ( - Boolean(userId) && (claims.includes("realtime:*") || claims.includes(`realtime:${table}`)) - ); - } - - handleConnection(ws: ServerWebSocket, token: string | undefined): boolean { - if (this.clients.size >= this.config.maxClients) { - realtimeLogger.warn("Rejecting realtime connection: max clients reached"); - this.safeSend(ws, { error: "Server is busy. Try again later." }); - ws.close(1013, "Server busy"); - return false; - } - - const identity = this.authenticate(token); - if (!identity) { - realtimeLogger.warn("Rejecting unauthenticated realtime connection"); - this.safeSend(ws, { error: "Unauthorized websocket connection" }); - ws.close(1008, "Unauthorized"); - return false; - } - - realtimeLogger.info(`Client connected (${identity.userId})`); - // Generate a unique connection ID for the channel manager - const connectionId = `${identity.userId}:${Date.now()}:${Math.random().toString(36).substring(2, 9)}`; - - this.clients.set(ws, { - ws, - userId: identity.userId, - claims: identity.claims, - subscriptions: new Map(), - connectionId, - }); - - // Register with channel manager - this.channelManager.registerConnection(connectionId, ws); - // Start heartbeat if not already running - this.channelManager.startHeartbeat(30000); - - return true; - } - - handleMessage(ws: ServerWebSocket, rawMessage: string): void { - let parsedJson: unknown; - - try { - parsedJson = JSON.parse(rawMessage); - } catch { - this.safeSend(ws, { error: "Invalid message format" }); - return; - } - - const result = messageSchema.safeParse(parsedJson); - if (!result.success) { - this.safeSend(ws, { - error: "Invalid message format", - details: result.error.format(), - }); - return; - } - - const data = result.data as ChannelMessage; - - // Check if this is a channel message (has 'channel' property) or table message (has 'table' property) - if ('channel' in data) { - this.handleChannelMessage(ws, data); - return; - } - - // Handle table subscription - if (data.type === "subscribe") { - this.subscribe(ws, data.table, data.filter); - return; - } - - this.unsubscribe(ws, data.table); - } - - private handleChannelMessage(ws: ServerWebSocket, data: ChannelMessage): void { - // Only process channel messages (type is subscribe/unsubscribe with channel property) - if (!('channel' in data)) { - return; - } - - const client = this.clients.get(ws); - if (!client) { - this.safeSend(ws, { error: "Unauthorized client" }); - return; - } - - const channelName = data.channel; - - switch (data.type) { - case "subscribe": { - // Join channel with optional user_id and presence - const options = data.payload || {}; - const userId = options.user_id || client.userId; - - try { - this.channelManager.joinChannel(client.connectionId, channelName, { - user_id: userId, - presence: options.presence, - }); - this.safeSend(ws, { type: "subscribed", channel: channelName }); - realtimeLogger.debug(`Client subscribed to channel ${channelName}`); - } catch (error) { - realtimeLogger.warn( - `Failed to join channel ${channelName}: ${error instanceof Error ? error.message : String(error)}` - ); - this.safeSend(ws, { error: "Failed to join channel" }); - } - break; - } - - case "unsubscribe": { - // Leave channel - this.channelManager.leaveChannel(client.connectionId, channelName); - this.safeSend(ws, { type: "unsubscribed", channel: channelName }); - realtimeLogger.debug(`Client unsubscribed from channel ${channelName}`); - break; - } - - case "broadcast": { - // Broadcast to channel - if (!this.channelManager.isInChannel(client.connectionId, channelName)) { - this.safeSend(ws, { error: "Not subscribed to channel" }); - return; - } - - this.channelManager.broadcastToChannel(channelName, { - type: "broadcast", - event: data.payload.event, - channel: channelName, - payload: data.payload.data, - }, client.connectionId); - break; - } - - case "presence": { - // Update presence state - if (!this.channelManager.isInChannel(client.connectionId, channelName)) { - this.safeSend(ws, { error: "Not subscribed to channel" }); - return; - } - - if (data.payload.action === "update") { - this.channelManager.updatePresence(client.connectionId, channelName, data.payload.state); - } - break; - } - } - } - - handleClose(ws: ServerWebSocket): void { - realtimeLogger.info("Client disconnected"); - - const client = this.clients.get(ws); - if (client) { - // Clean up table subscriptions - for (const table of client.subscriptions.keys()) { - const subscribers = this.tableSubscribers.get(table); - subscribers?.delete(ws); - - if (subscribers && subscribers.size === 0) { - this.tableSubscribers.delete(table); - } - } - - // Clean up channel subscriptions - this.channelManager.unregisterConnection(client.connectionId); - } - - this.clients.delete(ws); - } - - broadcast(table: string, event: RealtimeUpdatePayload["event"], data: unknown): void { - const subscribers = this.tableSubscribers.get(table); - if (!subscribers || subscribers.size === 0) { - return; - } - - const payload: RealtimeUpdatePayload = { - type: "update", - table, - event, - data, - timestamp: new Date().toISOString(), - }; - - const message = JSON.stringify(payload); - - const subs = Array.from(subscribers); - for (const ws of subs) { - const client = this.clients.get(ws); - const subscription = client?.subscriptions.get(table); - if (!this.matchesFilter(subscription?.filter, data)) { - continue; - } - - if (!this.safeSend(ws, message)) { - subscribers.delete(ws); - this.handleClose(ws); - } - } - } - - private subscribe( - ws: ServerWebSocket, - table: string, - filter?: Record, - ): void { - const client = this.clients.get(ws); - if (!client) { - this.safeSend(ws, { error: "Unauthorized client" }); - ws.close(1008, "Unauthorized"); - return; - } - - if (!this.authorize(client.userId, client.claims, table)) { - realtimeLogger.warn(`Subscription denied for ${client.userId} on ${table}`); - this.safeSend(ws, { error: "Forbidden subscription" }); - return; - } - - const existingSubscription = client.subscriptions.has(table); - if ( - !existingSubscription && - client.subscriptions.size >= this.config.maxSubscriptionsPerClient - ) { - realtimeLogger.warn(`Subscription limit reached for ${client.userId}`); - this.safeSend(ws, { error: "Subscription limit reached" }); - return; - } - - const tableSet = this.tableSubscribers.get(table) ?? new Set>(); - const alreadyInTableSet = tableSet.has(ws); - if (!alreadyInTableSet && tableSet.size >= this.config.maxSubscribersPerTable) { - realtimeLogger.warn(`Table subscriber cap reached for ${table}`); - this.safeSend(ws, { error: "Table subscription limit reached" }); - return; - } - - client.subscriptions.set(table, { table, filter }); - tableSet.add(ws); - this.tableSubscribers.set(table, tableSet); - - this.safeSend(ws, { type: "subscribed", table, filter }); - realtimeLogger.debug(`Client subscribed to ${table}`); - } - - private unsubscribe(ws: ServerWebSocket, table: string): void { - const client = this.clients.get(ws); - if (!client) { - return; - } - - client.subscriptions.delete(table); - const subscribers = this.tableSubscribers.get(table); - subscribers?.delete(ws); - - if (subscribers && subscribers.size === 0) { - this.tableSubscribers.delete(table); - } - - this.safeSend(ws, { type: "unsubscribed", table }); - } - - private matchesFilter(filter: Record | undefined, payload: unknown): boolean { - if (!filter || Object.keys(filter).length === 0) { - return true; - } - - if (!payload || typeof payload !== "object") { - return false; - } - - const data = payload as Record; - return Object.entries(filter).every(([key, value]) => deepEqual(data[key], value)); - } - - private safeSend(ws: ServerWebSocket, payload: object | string): boolean { - if (ws.readyState !== WebSocket.OPEN) { - return false; - } - - try { - ws.send(typeof payload === "string" ? payload : JSON.stringify(payload)); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - realtimeLogger.warn(`WebSocket send failed: ${message}`); - return false; - } - } -} - -export const realtime = new RealtimeServer(); diff --git a/apps/test-project/src/middleware/auth.ts b/apps/test-project/src/middleware/auth.ts deleted file mode 100644 index 2a2094e..0000000 --- a/apps/test-project/src/middleware/auth.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Context, Next } from "hono"; -import { auth } from "../auth"; - -export async function requireAuth(c: Context, next: Next) { - try { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (!session) { - return c.json({ data: null, error: "Unauthorized" }, 401); - } - c.set("user", session.user); - c.set("session", session.session); - } catch (error) { - console.error("requireAuth error:", error); - return c.json({ data: null, error: "Unauthorized" }, 401); - } - await next(); -} - -export async function optionalAuth(c: Context, next: Next) { - try { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (session) { - c.set("user", session.user); - c.set("session", session.session); - } - } catch (error) { - // Swallow error and continue without setting user/session - // This allows the request to degrade to unauthenticated - console.error("optionalAuth error:", error); - } - await next(); -} diff --git a/apps/test-project/src/middleware/validation.ts b/apps/test-project/src/middleware/validation.ts deleted file mode 100644 index 9a3053a..0000000 --- a/apps/test-project/src/middleware/validation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HTTPException } from "hono/http-exception"; -import type { ZodType } from "zod"; - -export function parseBody(schema: ZodType, body: unknown): T { - const result = schema.safeParse(body); - - if (!result.success) { - throw new HTTPException(400, { - message: "Validation failed", - cause: { - errors: result.error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code, - })), - }, - }); - } - - return result.data; -} diff --git a/apps/test-project/src/routes/graphql.d.ts b/apps/test-project/src/routes/graphql.d.ts deleted file mode 100644 index 1e230ee..0000000 --- a/apps/test-project/src/routes/graphql.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Type declarations for dynamically generated GraphQL route - */ - -import type { Hono } from "hono"; - -declare module "./graphql" { - export const graphqlRoute: Hono; -} diff --git a/apps/test-project/src/routes/health.ts b/apps/test-project/src/routes/health.ts deleted file mode 100644 index fc282a3..0000000 --- a/apps/test-project/src/routes/health.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { db } from "../db"; - -export const healthRoute = new Hono(); - -healthRoute.get("/", async (c) => { - try { - await db.run(sql`select 1`); - - return c.json({ - status: "healthy", - database: "connected", - timestamp: new Date().toISOString(), - }); - } catch { - return c.json( - { - status: "unhealthy", - database: "disconnected", - timestamp: new Date().toISOString(), - }, - 503, - ); - } -}); diff --git a/apps/test-project/src/routes/index.ts b/apps/test-project/src/routes/index.ts deleted file mode 100644 index 312201b..0000000 --- a/apps/test-project/src/routes/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Hono } from "hono"; -import { cors } from "hono/cors"; -import { HTTPException } from "hono/http-exception"; -import { logger } from "hono/logger"; -import { env } from "../lib/env"; -import { healthRoute } from "./health"; -import { storageRouter } from "./storage"; -import { usersRoute } from "./users"; -import { webhooksRoute } from "./webhooks"; - -export function registerRoutes(app: Hono): void { - app.use("*", cors()); - app.use("*", logger()); - - app.onError((err, c) => { - const isHttpError = err instanceof HTTPException; - const showDetailedError = env.NODE_ENV === "development" || isHttpError; - - return c.json( - { - error: showDetailedError ? err.message : "Internal Server Error", - stack: env.NODE_ENV === "development" ? err.stack : undefined, - details: isHttpError ? ((err as { cause?: unknown }).cause ?? null) : null, - }, - isHttpError ? err.status : 500, - ); - }); - - app.route("/health", healthRoute); - app.route("/api/users", usersRoute); - app.route("/api/storage", storageRouter); - app.route("/api/webhooks", webhooksRoute); -} diff --git a/apps/test-project/src/routes/storage.ts b/apps/test-project/src/routes/storage.ts deleted file mode 100644 index cb576dd..0000000 --- a/apps/test-project/src/routes/storage.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { type StorageFactory, createStorage } from "@betterbase/core/storage"; -import type { StorageConfig } from "@betterbase/core/storage"; -import type { Context, Next } from "hono"; -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { ZodError, z } from "zod"; -import { auth } from "../auth"; -import { parseBody } from "../middleware/validation"; - -// Get storage config from environment variables -function getStorageConfig(): StorageConfig | null { - const provider = process.env.STORAGE_PROVIDER; - const bucket = process.env.STORAGE_BUCKET; - - if (!provider || !bucket) { - return null; - } - - const baseConfig = { - bucket, - }; - - switch (provider) { - case "s3": - return { - provider: "s3", - ...baseConfig, - region: process.env.STORAGE_REGION || "us-east-1", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - }; - case "r2": - return { - provider: "r2", - ...baseConfig, - accountId: process.env.STORAGE_ACCOUNT_ID || "", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - endpoint: process.env.STORAGE_ENDPOINT, - }; - case "backblaze": - return { - provider: "backblaze", - ...baseConfig, - region: process.env.STORAGE_REGION || "us-west-002", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - endpoint: process.env.STORAGE_ENDPOINT, - }; - case "minio": - return { - provider: "minio", - ...baseConfig, - endpoint: process.env.STORAGE_ENDPOINT || "localhost:9000", - port: Number.parseInt(process.env.STORAGE_PORT || "9000", 10), - useSSL: process.env.STORAGE_USE_SSL === "true", - accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", - secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", - }; - default: - return null; - } -} - -// Initialize storage factory -const storageConfig = getStorageConfig(); -const storage: StorageFactory | null = storageConfig ? createStorage(storageConfig) : null; - -// Validate bucket access - only allow configured bucket -function validateBucket(bucket: string): void { - if (!storageConfig) { - throw new HTTPException(503, { message: "Storage not configured" }); - } - if (bucket !== storageConfig.bucket) { - throw new HTTPException(403, { message: "Invalid bucket access" }); - } -} - -// Sanitize path to prevent path traversal attacks -function sanitizePath(path: string): string { - // Remove leading slashes and normalize - const sanitized = path.replace(/^\/+/, "").replace(/\/+/g, "/"); - - // Check for path traversal attempts - if (sanitized.includes("..") || sanitized.startsWith("/")) { - throw new HTTPException(400, { - message: "Invalid path: path traversal not allowed", - }); - } - - return sanitized; -} - -// Validate and sanitize path parameter -function validatePath(path: string): string { - if (!path || path.length === 0) { - throw new HTTPException(400, { message: "Path is required" }); - } - return sanitizePath(path); -} - -// Auth middleware for storage routes -async function requireAuth(c: Context, next: Next): Promise { - try { - const session = await auth.api.getSession({ - headers: c.req.raw.headers, - }); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - c.set("user", session.user); - c.set("session", session.session); - } catch (error) { - console.error("Storage requireAuth error:", error); - return c.json({ error: "Unauthorized" }, 401); - } - await next(); -} - -// Schemas for request validation -const signUrlSchema = z.object({ - expiresIn: z.number().int().positive().optional().default(3600), -}); - -const deleteFilesSchema = z.object({ - paths: z.array(z.string().min(1)).min(1), -}); - -export const storageRouter = new Hono(); - -// Apply auth middleware to all storage routes (except public URL) -storageRouter.use("/*", async (c, next) => { - // Skip auth for public URL endpoint - if (c.req.path.toString().endsWith("/public")) { - await next(); - return; - } - await requireAuth(c, next); -}); - -// GET /api/storage/:bucket - List files -storageRouter.get("/:bucket", async (c) => { - try { - const bucket = c.req.param("bucket"); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const prefix = c.req.query("prefix"); - const sanitizedPrefix = prefix ? sanitizePath(prefix) : undefined; - const result = await storage.from(bucket).list(sanitizedPrefix); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - const files = (result.data || []).map((obj) => ({ - name: obj.key, - size: obj.size, - lastModified: obj.lastModified.toISOString(), - })); - - return c.json({ files }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to list files:", error); - return c.json({ error: "Failed to list files" }, 500); - } -}); - -// DELETE /api/storage/:bucket - Delete files -storageRouter.delete("/:bucket", async (c) => { - try { - const bucket = c.req.param("bucket"); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const body = await c.req.json().catch(() => ({})); - const parsed = parseBody(deleteFilesSchema, body); - - // Validate all paths before deletion - const sanitizedPaths = parsed.paths.map((p: string) => validatePath(p)); - - const result = await storage.from(bucket).remove(sanitizedPaths); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - return c.json({ - message: result.data?.message || "Files deleted successfully", - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - if (error instanceof ZodError) { - return c.json( - { - error: "Invalid request body", - details: error.issues, - }, - 400, - ); - } - console.error("Failed to delete files:", error); - return c.json({ error: "Failed to delete files" }, 500); - } -}); - -// POST /api/storage/:bucket/upload - Upload a file -storageRouter.post("/:bucket/upload", async (c) => { - try { - const bucket = c.req.param("bucket"); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - // Get content type from headers or form - const contentType = c.req.header("Content-Type") || "application/octet-stream"; - - // Best-effort early abort based on Content-Length header (can be spoofed) - const contentLength = c.req.header("Content-Length"); - const maxSize = 50 * 1024 * 1024; // 50MB limit - - if (contentLength && Number.parseInt(contentLength, 10) > maxSize) { - return c.json({ error: "File too large. Maximum size is 50MB" }, 400); - } - - // Stream the body and enforce maxSize during streaming to prevent DoS attacks - // Content-Length can be spoofed, so we must enforce the limit during read - const bodyStream = c.req.raw.body; - if (!bodyStream) { - return c.json({ error: "No body provided" }, 400); - } - - const chunks: Uint8Array[] = []; - const reader = bodyStream.getReader(); - let byteCount = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - byteCount += value.length; - if (byteCount > maxSize) { - return c.json({ error: "File too large. Maximum size is 50MB" }, 413); - } - - chunks.push(value); - } - } catch (error) { - return c.json({ error: "Failed to read body" }, 400); - } - - // Concatenate all chunks into a single buffer - const body = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); - - // Extract and validate path from query param or use default - const pathInput = c.req.query("path") || `uploads/${Date.now()}-file`; - const path = validatePath(pathInput); - - const result = await storage.from(bucket).upload(path, body, { - contentType, - }); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - const publicUrl = storage.from(bucket).getPublicUrl(path); - - return c.json({ - path, - url: publicUrl, - size: result.data?.size || 0, - contentType: result.data?.contentType || contentType, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to upload file:", error); - return c.json({ error: "Failed to upload file" }, 500); - } -}); - -// GET /api/storage/:bucket/:key - Download a file -storageRouter.get("/:bucket/:key{.+}", async (c) => { - try { - const bucket = c.req.param("bucket"); - const keyInput = c.req.param("key"); - const key = validatePath(keyInput); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const result = await storage.from(bucket).download(key); - - if (result.error) { - if (result.error.message.includes("NoSuchKey") || result.error.message.includes("NotFound")) { - return c.json({ error: "File not found" }, 404); - } - return c.json({ error: result.error.message }, 500); - } - - if (!result.data) { - return c.json({ error: "File not found" }, 404); - } - - // Get content type from result metadata or use default - const contentType = "application/octet-stream"; - - return c.body(new Uint8Array(result.data), { - headers: { - "Content-Type": contentType, - "Content-Length": String(result.data?.length || 0), - "Content-Disposition": `attachment; filename="${key.split("/").pop()}"`, - }, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to download file:", error); - return c.json({ error: "Failed to download file" }, 500); - } -}); - -// GET /api/storage/:bucket/:key/public - Get public URL -storageRouter.get("/:bucket/:key{.+}/public", async (c) => { - try { - const bucket = c.req.param("bucket"); - const keyInput = c.req.param("key"); - const key = validatePath(keyInput); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const publicUrl = storage.from(bucket).getPublicUrl(key); - - return c.json({ publicUrl }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - console.error("Failed to get public URL:", error); - return c.json({ error: "Failed to get public URL" }, 500); - } -}); - -// POST /api/storage/:bucket/:key/sign - Create signed URL -storageRouter.post("/:bucket/:key{.+}/sign", async (c) => { - try { - const bucket = c.req.param("bucket"); - const keyInput = c.req.param("key"); - const key = validatePath(keyInput); - validateBucket(bucket); - - if (!storage) { - return c.json({ error: "Storage not configured" }, 503); - } - - const body = await c.req.json().catch(() => ({})); - const parsed = parseBody(signUrlSchema, body); - - const result = await storage.from(bucket).createSignedUrl(key, { - expiresIn: parsed.expiresIn, - }); - - if (result.error) { - return c.json({ error: result.error.message }, 500); - } - - return c.json({ signedUrl: result.data?.signedUrl || "" }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - if (error instanceof ZodError) { - return c.json( - { - error: "Invalid request body", - details: error.issues, - }, - 400, - ); - } - console.error("Failed to create signed URL:", error); - return c.json({ error: "Failed to create signed URL" }, 500); - } -}); diff --git a/apps/test-project/src/routes/users.ts b/apps/test-project/src/routes/users.ts deleted file mode 100644 index b0d58d0..0000000 --- a/apps/test-project/src/routes/users.ts +++ /dev/null @@ -1,107 +0,0 @@ -//templates/base/src/routes/users.ts - -import { asc } from "drizzle-orm"; -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { ZodError, z } from "zod"; -import { db } from "../db"; -import { users } from "../db/schema"; -import { parseBody } from "../middleware/validation"; - -export const createUserSchema = z.object({ - email: z.string().email(), - name: z.string().min(1), -}); - -const DEFAULT_LIMIT = 25; -const MAX_LIMIT = 100; -const DEFAULT_OFFSET = 0; - -const paginationSchema = z.object({ - limit: z.coerce.number().int().nonnegative().default(DEFAULT_LIMIT), - offset: z.coerce.number().int().nonnegative().default(DEFAULT_OFFSET), -}); - -export const usersRoute = new Hono(); - -usersRoute.get("/", async (c) => { - try { - const pagination = paginationSchema.parse({ - limit: c.req.query("limit"), - offset: c.req.query("offset"), - }); - - const limit = Math.min(pagination.limit, MAX_LIMIT); - const offset = pagination.offset; - - if (limit === 0) { - return c.json({ - users: [], - pagination: { - limit, - offset, - // No DB query is run for limit=0, so hasMore cannot be determined. - hasMore: null, - }, - }); - } - - const rows = await db - .select() - .from(users) - .orderBy(asc(users.id)) - .limit(limit + 1) - .offset(offset); - const hasMore = rows.length > limit; - const paginatedUsers = rows.slice(0, limit); - - return c.json({ - users: paginatedUsers, - pagination: { - limit, - offset, - hasMore, - }, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - - if (error instanceof ZodError) { - return c.json( - { - error: "Invalid pagination query parameters", - details: error.issues, - }, - 400, - ); - } - - console.error("Failed to fetch users:", error); - throw error; - } -}); - -usersRoute.post("/", async (c) => { - try { - const body = await c.req.json(); - const parsed = parseBody(createUserSchema, body); - - // TODO: persist parsed user via db.insert(users) or a dedicated UsersService. - return c.json({ - message: "User payload validated (not persisted)", - user: parsed, - }); - } catch (error) { - if (error instanceof HTTPException) { - throw error; - } - - if (error instanceof SyntaxError) { - throw new HTTPException(400, { message: "Malformed JSON body" }); - } - - throw error; - } -}); diff --git a/apps/test-project/src/routes/webhooks.ts b/apps/test-project/src/routes/webhooks.ts deleted file mode 100644 index 9e47fb8..0000000 --- a/apps/test-project/src/routes/webhooks.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Hono } from "hono"; - -export const webhooksRoute = new Hono(); - -webhooksRoute.get("/:webhookId/deliveries", async (c) => { - const webhookId = c.req.param("webhookId"); - const limitParam = c.req.query("limit"); - const limit = limitParam ? Number.parseInt(limitParam, 10) : 50; - - if (isNaN(limit) || limit < 1) { - return c.json({ error: "Invalid limit parameter" }, 400); - } - - return c.json({ - data: [], - count: 0, - message: "Webhook deliveries not yet implemented - table requires migration", - }); -}); - -webhooksRoute.get("/deliveries/:deliveryId", async (c) => { - const deliveryId = c.req.param("deliveryId"); - - return c.json({ error: "Delivery not found" }, 404); -}); diff --git a/apps/test-project/test/crud.test.ts b/apps/test-project/test/crud.test.ts deleted file mode 100644 index 47e5a18..0000000 --- a/apps/test-project/test/crud.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { beforeAll, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { registerRoutes } from "../src/routes"; - -describe("users CRUD endpoint", () => { - let app: Hono; - - beforeAll(async () => { - // Import db AFTER app modules load — this is the exact same - // db instance the route handlers will use at runtime. - // We run CREATE TABLE IF NOT EXISTS on it so the schema exists - // before any test hits the GET /api/users endpoint. - const { db } = await import("../src/db"); - - db.run(` - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - ) - `); - - app = new Hono(); - registerRoutes(app); - }); - - describe("GET /api/users", () => { - test("returns empty users array when no users exist", async () => { - const res = await app.request("/api/users"); - expect(res.status).toBe(200); - const data = await res.json(); - expect(Array.isArray(data.users)).toBe(true); - expect(data.users).toEqual([]); - }); - - test("accepts limit and offset query parameters", async () => { - const res = await app.request("/api/users?limit=10&offset=5"); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.pagination.limit).toBe(10); - expect(data.pagination.offset).toBe(5); - }); - - test("returns 400 for invalid limit", async () => { - const res = await app.request("/api/users?limit=-1"); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toContain("Invalid pagination query parameters"); - }); - - test("returns 400 for non-numeric limit", async () => { - const res = await app.request("/api/users?limit=abc"); - expect(res.status).toBe(400); - const data = await res.json(); - expect(data.error).toContain("Invalid pagination query parameters"); - }); - }); - - describe("POST /api/users", () => { - // NOTE: The POST route currently has a TODO stub — it validates the - // payload but does not persist to the DB. These tests reflect that - // intentional current behavior. When the real insert is implemented, - // update the first test to expect 201 and check for a returned `id`. - test("validates payload but does not persist (stub behavior)", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "test@example.com", name: "Test User" }), - }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.message).toBe("User payload validated (not persisted)"); - expect(data.user.email).toBe("test@example.com"); - expect(data.user.name).toBe("Test User"); - }); - - test("returns 400 for missing email", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: "Test User" }), - }); - expect(res.status).toBe(400); - }); - - test("returns 400 for invalid email", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: "not-an-email", name: "Test User" }), - }); - expect(res.status).toBe(400); - }); - - test("returns 400 for malformed JSON", async () => { - const res = await app.request("/api/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "not valid json", - }); - expect(res.status).toBe(400); - }); - }); -}); diff --git a/apps/test-project/test/health.test.ts b/apps/test-project/test/health.test.ts deleted file mode 100644 index 032715b..0000000 --- a/apps/test-project/test/health.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { registerRoutes } from "../src/routes"; - -describe("health endpoint", () => { - let app: Hono; - - beforeAll(() => { - app = new Hono(); - registerRoutes(app); - }); - - test("GET /health returns 200 with healthy status", async () => { - const res = await app.request("/health"); - expect(res.status).toBe(200); - - const data = await res.json(); - expect(data.status).toBe("healthy"); - expect(data.database).toBe("connected"); - expect(data.timestamp).toBeDefined(); - }); -}); diff --git a/apps/test-project/tsconfig.json b/apps/test-project/tsconfig.json deleted file mode 100644 index 406a007..0000000 --- a/apps/test-project/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "types": ["bun"], - "outDir": "dist", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/**/*.ts", "test/**/*.ts", "drizzle.config.ts", "betterbase.config.ts"] -} diff --git a/bun.lock b/bun.lock index 39536bb..60ee6e4 100644 --- a/bun.lock +++ b/bun.lock @@ -157,6 +157,7 @@ "@types/bcryptjs": "^2.4.6", "@types/nodemailer": "^6.4.0", "@types/pg": "^8.11.0", + "bun-types": "^1.3.11", "typescript": "^5.4.0", }, }, diff --git a/packages/core/package.json b/packages/core/package.json index 1993b53..e5b85a3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,8 @@ "./migration": "./src/migration/index.ts", "./vector": "./src/vector/index.ts", "./branching": "./src/branching/index.ts", - "./logger": "./src/logger/index.ts" + "./logger": "./src/logger/index.ts", + "./iac": "./src/iac/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/core/src/iac/cron.ts b/packages/core/src/iac/cron.ts new file mode 100644 index 0000000..db0c15f --- /dev/null +++ b/packages/core/src/iac/cron.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import type { MutationRegistration } from "./functions"; + +export interface CronJob { + name: string; + schedule: string; // cron expression: "0 * * * *", "*/5 * * * *", etc. + fn: MutationRegistration; + args: Record; +} + +const _jobs: CronJob[] = []; + +/** Register a cron job. Called in bbf/cron.ts. */ +export function cron( + name: string, + schedule: string, + fn: MutationRegistration, + args: Record = {} +): void { + _jobs.push({ name, schedule, fn, args }); +} + +export function getCronJobs(): CronJob[] { + return _jobs; +} diff --git a/packages/core/src/iac/db-context.ts b/packages/core/src/iac/db-context.ts new file mode 100644 index 0000000..2824a05 --- /dev/null +++ b/packages/core/src/iac/db-context.ts @@ -0,0 +1,160 @@ +import type { Pool } from "pg"; +import { nanoid } from "nanoid"; + +// ─── Query Builder (chainable) ───────────────────────────────────────────── + +export class IaCQueryBuilder { + private _table: string; + private _pool: Pool; + private _schema: string; + private _filters: string[] = []; + private _params: unknown[] = []; + private _orderBy: string | null = null; + private _orderDir: "ASC" | "DESC" = "ASC"; + private _limit: number | null = null; + private _indexName: string | null = null; + + constructor(table: string, pool: Pool, schema: string) { + this._table = table; + this._pool = pool; + this._schema = schema; + } + + /** Filter using an index — short-circuits to index-aware SQL */ + withIndex(indexName: string, _builder: (q: IndexQueryBuilder) => IndexQueryBuilder): this { + this._indexName = indexName; + // For v1: treated as a filter hint only; actual index usage is via SQL planner + return this; + } + + filter(field: string, op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte", value: unknown): this { + const idx = this._params.length + 1; + const opMap = { eq: "=", neq: "!=", gt: ">", gte: ">=", lt: "<", lte: "<=" }; + this._filters.push(`"${field}" ${opMap[op]} $${idx}`); + this._params.push(value); + return this; + } + + order(direction: "asc" | "desc", field = "_createdAt"): this { + this._orderBy = field; + this._orderDir = direction === "asc" ? "ASC" : "DESC"; + return this; + } + + take(n: number): this { this._limit = n; return this; } + + private _buildSQL(): { sql: string; params: unknown[] } { + const table = `"${this._schema}"."${this._table}"`; + let sql = `SELECT * FROM ${table}`; + if (this._filters.length) sql += ` WHERE ${this._filters.join(" AND ")}`; + if (this._orderBy) sql += ` ORDER BY "${this._orderBy}" ${this._orderDir}`; + if (this._limit) sql += ` LIMIT ${this._limit}`; + return { sql, params: this._params }; + } + + async collect(): Promise { + const { sql, params } = this._buildSQL(); + const { rows } = await this._pool.query(sql, params as any[]); + return rows as T[]; + } + + async first(): Promise { + const { sql, params } = this._buildSQL(); + const { rows } = await this._pool.query(sql + " LIMIT 1", params as any[]); + return (rows[0] as T) ?? null; + } + + async unique(): Promise { + const results = await this.collect(); + if (results.length > 1) throw new Error(`Expected unique result, got ${results.length}`); + return results[0] ?? null; + } +} + +// Stub — used by withIndex for type inference +class IndexQueryBuilder { + eq(field: string, value: unknown) { return this; } + gt(field: string, value: unknown) { return this; } + gte(field: string, value: unknown) { return this; } + lt(field: string, value: unknown) { return this; } + lte(field: string, value: unknown) { return this; } +} + +// ─── DatabaseReader ──────────────────────────────────────────────────────── + +export class DatabaseReader { + constructor(protected _pool: Pool, protected _schema: string) {} + + /** Get a document by ID */ + async get(table: string, id: string): Promise { + const { rows } = await this._pool.query( + `SELECT * FROM "${this._schema}"."${table}" WHERE _id = $1 LIMIT 1`, + [id] + ); + return (rows[0] as T) ?? null; + } + + /** Start a query builder for a table */ + query(table: string): IaCQueryBuilder { + return new IaCQueryBuilder(table, this._pool, this._schema); + } +} + +// ─── DatabaseWriter ────────────────────────────────────────────────────────── + +export class DatabaseWriter extends DatabaseReader { + private _mutations: (() => Promise)[] = []; + + /** Insert a document, returning its generated ID */ + async insert(table: string, data: Record): Promise { + const id = nanoid(); + const now = new Date(); + const doc = { ...data, _id: id, _createdAt: now, _updatedAt: now }; + + const keys = Object.keys(doc).map((k) => `"${k}"`).join(", "); + const placeholders = Object.keys(doc).map((_, i) => `$${i + 1}`).join(", "); + const values = Object.values(doc); + + await this._pool.query( + `INSERT INTO "${this._schema}"."${table}" (${keys}) VALUES (${placeholders})`, + values as any[] + ); + + // Emit change event for real-time invalidation + this._emitChange(table, "INSERT", id); + return id; + } + + /** Partial update — merges provided fields, updates `_updatedAt` */ + async patch(table: string, id: string, fields: Record): Promise { + const updates = Object.entries(fields) + .map(([k], i) => `"${k}" = $${i + 2}`) + .join(", "); + const values = [id, ...Object.values(fields)]; + await this._pool.query( + `UPDATE "${this._schema}"."${table}" SET ${updates}, "_updatedAt" = NOW() WHERE _id = $1`, + values as any[] + ); + this._emitChange(table, "UPDATE", id); + } + + /** Full replace — replaces all user fields (preserves system fields) */ + async replace(table: string, id: string, data: Record): Promise { + await this.patch(table, id, data); + } + + /** Delete a document by ID */ + async delete(table: string, id: string): Promise { + await this._pool.query( + `DELETE FROM "${this._schema}"."${table}" WHERE _id = $1`, + [id] + ); + this._emitChange(table, "DELETE", id); + } + + private _emitChange(table: string, type: "INSERT" | "UPDATE" | "DELETE", id: string) { + // Emit to the global realtime manager (IAC-21) + const mgr = (globalThis as any).__betterbaseRealtimeManager; + mgr?.emitTableChange?.({ table, type, id }); + } +} diff --git a/packages/core/src/iac/function-registry.ts b/packages/core/src/iac/function-registry.ts new file mode 100644 index 0000000..7a12b4e --- /dev/null +++ b/packages/core/src/iac/function-registry.ts @@ -0,0 +1,74 @@ +import { join, relative, extname } from "path"; +import { readdir } from "fs/promises"; + +export interface RegisteredFunction { + kind: "query" | "mutation" | "action"; + path: string; // e.g. "queries/users/getUser" + name: string; // e.g. "getUser" + module: string; // absolute file path + handler: unknown; // the QueryRegistration | MutationRegistration | ActionRegistration +} + +const FUNCTION_DIRS = ["queries", "mutations", "actions"] as const; + +/** Walk a directory recursively and return all .ts/.js file paths */ +async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + const files: string[] = []; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) files.push(...await walk(full)); + else if ([".ts", ".js"].includes(extname(entry.name))) files.push(full); + } + return files; +} + +/** Scan bbfDir and return all registered functions */ +export async function discoverFunctions(bbfDir: string): Promise { + const registered: RegisteredFunction[] = []; + + for (const kind of FUNCTION_DIRS) { + const dir = join(bbfDir, kind); + const files = await walk(dir); + + for (const file of files) { + const rel = relative(dir, file).replace(/\.(ts|js)$/, ""); + const mod = await import(file).catch(() => null); + if (!mod) continue; + + for (const [exportName, exportValue] of Object.entries(mod)) { + if (!exportValue || typeof exportValue !== "object") continue; + const fn = exportValue as any; + if (!fn._handler || !fn._args) continue; + + const fnKind: "query" | "mutation" | "action" = + fn[Symbol.for("BetterBaseFunction")] ?? kind.slice(0, -1) as any; + + registered.push({ + kind: fnKind, + path: `${kind}/${rel}/${exportName}`, + name: exportName, + module: file, + handler: fn, + }); + } + } + } + + return registered; +} + +/** Singleton registry (populated once on server start or bb dev) */ +let _registry: RegisteredFunction[] = []; + +export function setFunctionRegistry(fns: RegisteredFunction[]) { + _registry = fns; +} + +export function getFunctionRegistry(): RegisteredFunction[] { + return _registry; +} + +export function lookupFunction(path: string): RegisteredFunction | null { + return _registry.find((f) => f.path === path) ?? null; +} diff --git a/packages/core/src/iac/functions.ts b/packages/core/src/iac/functions.ts new file mode 100644 index 0000000..2c11268 --- /dev/null +++ b/packages/core/src/iac/functions.ts @@ -0,0 +1,139 @@ +import { z } from "zod"; +import type { DatabaseReader, DatabaseWriter } from "./db-context"; + +// ─── Context Types ──────────────────────────────────────────────────────────── + +export interface AuthCtx { + /** ID of the authenticated user, or null for anonymous */ + userId: string | null; + /** Raw session token */ + token: string | null; +} + +export interface StorageReaderCtx { + getUrl(storageId: string): Promise; +} + +export interface StorageWriterCtx extends StorageReaderCtx { + store(blob: Blob): Promise; // returns storageId + delete(storageId: string): Promise; +} + +export interface QueryCtx { + db: DatabaseReader; + auth: AuthCtx; + storage: StorageReaderCtx; +} + +export interface Scheduler { + /** + * Schedule a mutation to run after `delayMs` milliseconds. + * Returns a job ID that can be cancelled. + */ + runAfter( + delayMs: number, + fn: MutationRegistration, + args: z.infer> + ): Promise; + + /** + * Schedule a mutation to run at a specific timestamp. + */ + runAt( + timestamp: Date, + fn: MutationRegistration, + args: z.infer> + ): Promise; + + /** Cancel a scheduled job */ + cancel(jobId: string): Promise; +} + +export interface MutationCtx { + db: DatabaseWriter; + auth: AuthCtx; + storage: StorageWriterCtx; + scheduler: Scheduler; +} + +export interface ActionCtx { + auth: AuthCtx; + storage: StorageWriterCtx; + scheduler: Scheduler; + /** Run a query from within an action */ + runQuery( + fn: QueryRegistration, + args: z.infer> + ): Promise; + /** Run a mutation from within an action */ + runMutation( + fn: MutationRegistration, + args: z.infer> + ): Promise; +} + +// ─── Registration Types ─────────────────────────────────────────────────────── + +const FUNCTION_KIND = Symbol("BetterBaseFunction"); + +export interface QueryRegistration< + TArgs extends z.ZodRawShape, + TReturn +> { + [FUNCTION_KIND]: "query"; + _args: z.ZodObject; + _handler: (ctx: QueryCtx, args: z.infer>) => Promise; +} + +export interface MutationRegistration< + TArgs extends z.ZodRawShape, + TReturn +> { + [FUNCTION_KIND]: "mutation"; + _args: z.ZodObject; + _handler: (ctx: MutationCtx, args: z.infer>) => Promise; +} + +export interface ActionRegistration< + TArgs extends z.ZodRawShape, + TReturn +> { + [FUNCTION_KIND]: "action"; + _args: z.ZodObject; + _handler: (ctx: ActionCtx, args: z.infer>) => Promise; +} + +// ─── Factory Functions ──────────────────────────────────────────────────────── + +export function query(config: { + args: TArgs; + handler: (ctx: QueryCtx, args: z.infer>) => Promise; +}): QueryRegistration { + return { + [FUNCTION_KIND]: "query", + _args: z.object(config.args), + _handler: config.handler, + }; +} + +export function mutation(config: { + args: TArgs; + handler: (ctx: MutationCtx, args: z.infer>) => Promise; +}): MutationRegistration { + return { + [FUNCTION_KIND]: "mutation", + _args: z.object(config.args), + _handler: config.handler, + }; +} + +export function action(config: { + args: TArgs; + handler: (ctx: ActionCtx, args: z.infer>) => Promise; +}): ActionRegistration { + return { + [FUNCTION_KIND]: "action", + _args: z.object(config.args), + _handler: config.handler, + }; +} diff --git a/packages/core/src/iac/generators/api-typegen.ts b/packages/core/src/iac/generators/api-typegen.ts new file mode 100644 index 0000000..6d7d9cb --- /dev/null +++ b/packages/core/src/iac/generators/api-typegen.ts @@ -0,0 +1,64 @@ +import type { RegisteredFunction } from "../function-registry"; + +/** + * Given a flat list of registered functions, produce the content of + * bbf/_generated/api.d.ts — the type-safe API object. + */ +export function generateApiTypes(fns: RegisteredFunction[]): string { + // Group by path segments + const groups: Record>> = { + queries: {}, + mutations: {}, + actions: {}, + }; + + for (const fn of fns) { + const parts = fn.path.split("/"); + const kind = parts[0]; // queries | mutations | actions + const file = parts.slice(1, -1).join("/") || "root"; + const name = parts[parts.length - 1]; + + if (!groups[kind]) continue; + if (!groups[kind][file]) groups[kind][file] = {}; + groups[kind][file][name] = fn; + } + + const lines: string[] = [ + `// AUTO-GENERATED by BetterBase IaC — DO NOT EDIT`, + `// Source: bbf/**/*.ts`, + `// Run \`bb iac generate\` to regenerate`, + ``, + `import type { QueryRegistration, MutationRegistration, ActionRegistration } from "@betterbase/core/iac";`, + ``, + `export declare const api: {`, + ]; + + for (const [kind, files] of Object.entries(groups)) { + lines.push(` ${kind}: {`); + for (const [file, exports] of Object.entries(files)) { + const key = file.replace(/\//g, "_") || "root"; + lines.push(` ${key}: {`); + for (const [name, fn] of Object.entries(exports)) { + const type = fn.kind === "query" + ? "QueryRegistration" + : fn.kind === "mutation" + ? "MutationRegistration" + : "ActionRegistration"; + lines.push(` ${name}: ${type};`); + } + lines.push(` };`); + } + lines.push(` };`); + } + + lines.push(`};`); + lines.push(``); + + // Also generate FunctionReference type for useQuery/useMutation + lines.push(`export type FunctionReference =`); + lines.push(` T extends "query" ? QueryRegistration`); + lines.push(` : T extends "mutation" ? MutationRegistration`); + lines.push(` : ActionRegistration;`); + + return lines.join("\n"); +} diff --git a/packages/core/src/iac/generators/drizzle-schema-gen.ts b/packages/core/src/iac/generators/drizzle-schema-gen.ts new file mode 100644 index 0000000..5a8ac43 --- /dev/null +++ b/packages/core/src/iac/generators/drizzle-schema-gen.ts @@ -0,0 +1,72 @@ +import type { SerializedSchema, SerializedTable, SerializedColumn } from "../schema-serializer"; + +function colTypeToSqlite(type: string, colName: string): string { + if (type === "string" || type.startsWith("id:") || type.startsWith("literal:") || type.startsWith("union:")) + return `text('${colName}')`; + if (type === "number") return `real('${colName}')`; + if (type === "int64") return `integer('${colName}')`; + if (type === "boolean") return `integer('${colName}', { mode: 'boolean' })`; + if (type === "date") return `integer('${colName}', { mode: 'timestamp' })`; + if (type.startsWith("array:") || type === "object") return `text('${colName}', { mode: 'json' })`; + return `text('${colName}')`; +} + +function colTypeToPostgres(type: string, colName: string): string { + if (type === "string" || type.startsWith("id:") || type.startsWith("literal:") || type.startsWith("union:")) + return `text('${colName}')`; + if (type === "number") return `doublePrecision('${colName}')`; + if (type === "int64") return `bigint('${colName}', { mode: 'bigint' })`; + if (type === "boolean") return `boolean('${colName}')`; + if (type === "date") return `timestamp('${colName}', { withTimezone: true })`; + if (type.startsWith("array:") || type === "object") return `jsonb('${colName}')`; + return `text('${colName}')`; +} + +function generateTableCode(table: SerializedTable, dialect: "sqlite" | "postgres"): string { + const colFn = dialect === "sqlite" ? colTypeToSqlite : colTypeToPostgres; + const tableFn = dialect === "sqlite" ? "sqliteTable" : "pgTable"; + + const cols = table.columns.map((col) => { + let def = colFn(col.type, col.name); + + if (col.name === "_id") { + def += ".primaryKey()"; + } else if (!col.optional && !col.system) { + def += ".notNull()"; + } + + if (col.name === "_createdAt" || col.name === "_updatedAt") { + def += ".default(sql`now()`)"; + } + + return ` ${col.name}: ${def}`; + }).join(",\n"); + + // Add index definitions as a third argument to the table fn + const indexLines = table.indexes.map((idx) => { + const fields = idx.fields.map((f) => `table.${f}`).join(", "); + if (idx.type === "uniqueIndex") return ` ${idx.name}: uniqueIndex('${table.name}_${idx.name}').on(${fields})`; + return ` ${idx.name}: index('${table.name}_${idx.name}').on(${fields})`; + }); + + const tableBody = indexLines.length + ? `, (table) => ({\n${indexLines.join(",\n")}\n})` + : ""; + + return `export const ${table.name} = ${tableFn}('${table.name}', {\n${cols}\n}${tableBody});`; +} + +export function generateDrizzleSchema( + schema: SerializedSchema, + dialect: "sqlite" | "postgres" = "sqlite" +): string { + const imports = dialect === "sqlite" + ? `import { sqliteTable, text, real, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core';\nimport { sql } from 'drizzle-orm';` + : `import { pgTable, text, doublePrecision, bigint, boolean, timestamp, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core';\nimport { sql } from 'drizzle-orm';`; + + const header = `// AUTO-GENERATED by BetterBase IaC — DO NOT EDIT\n// Source: bbf/schema.ts\n// Generated: ${schema.generated}\n\n${imports}\n\n`; + + const tables = schema.tables.map((t) => generateTableCode(t, dialect)).join("\n\n"); + + return header + tables + "\n"; +} diff --git a/packages/core/src/iac/generators/migration-gen.ts b/packages/core/src/iac/generators/migration-gen.ts new file mode 100644 index 0000000..80d5a47 --- /dev/null +++ b/packages/core/src/iac/generators/migration-gen.ts @@ -0,0 +1,80 @@ +import type { SchemaDiff, SchemaDiffChange } from "../schema-diff"; +import type { SerializedColumn } from "../schema-serializer"; + +function colTypeToSQL(type: string): string { + if (type === "number") return "REAL"; + if (type === "int64") return "BIGINT"; + if (type === "boolean") return "BOOLEAN"; + if (type === "date") return "TIMESTAMPTZ"; + if (type.startsWith("array:") || type === "object") return "JSONB"; + return "TEXT"; // default: string, id:*, literal:*, union:* +} + +function changeToSQL(change: SchemaDiffChange): string[] { + switch (change.type) { + case "ADD_TABLE": { + const t = change.after as { columns: SerializedColumn[] }; + const cols = t.columns.map((c) => { + let line = ` "${c.name}" ${colTypeToSQL(c.type)}`; + if (c.name === "_id") line += " PRIMARY KEY"; + else if (!c.optional) line += " NOT NULL"; + if (c.name === "_createdAt") line += " DEFAULT NOW()"; + if (c.name === "_updatedAt") line += " DEFAULT NOW()"; + return line; + }); + return [`CREATE TABLE IF NOT EXISTS "${change.table}" (\n${cols.join(",\n")}\n);`]; + } + + case "DROP_TABLE": + return [`DROP TABLE IF EXISTS "${change.table}";`]; + + case "ADD_COLUMN": { + const col = change.after as SerializedColumn; + const nullable = col.optional ? "" : " NOT NULL"; + return [`ALTER TABLE "${change.table}" ADD COLUMN "${col.name}" ${colTypeToSQL(col.type)}${nullable};`]; + } + + case "DROP_COLUMN": + return [`ALTER TABLE "${change.table}" DROP COLUMN "${change.column}";`]; + + case "ALTER_COLUMN": { + const after = change.after as SerializedColumn; + return [ + `ALTER TABLE "${change.table}" ALTER COLUMN "${change.column}" TYPE ${colTypeToSQL(after.type)} USING "${change.column}"::${colTypeToSQL(after.type)};`, + ]; + } + + case "ADD_INDEX": { + return [`CREATE INDEX IF NOT EXISTS "${change.table}_${change.index}" ON "${change.table}" ("${change.index}");`]; + } + + case "DROP_INDEX": + return [`DROP INDEX IF EXISTS "${change.table}_${change.index}";`]; + } +} + +export interface GeneratedMigration { + filename: string; + sql: string; +} + +export function generateMigration( + diff: SchemaDiff, + seq: number, // next migration sequence number (e.g. 5) + label: string // short label for filename +): GeneratedMigration { + const filename = String(seq).padStart(4, "0") + `_${label.replace(/\s+/g, "_").toLowerCase()}.sql`; + + const up: string[] = [ + `-- BetterBase IaC Auto-Migration`, + `-- Generated: ${new Date().toISOString()}`, + ``, + ]; + + for (const change of diff.changes) { + up.push(...changeToSQL(change)); + up.push(""); + } + + return { filename, sql: up.join("\n") }; +} diff --git a/packages/core/src/iac/index.ts b/packages/core/src/iac/index.ts new file mode 100644 index 0000000..71cc14a --- /dev/null +++ b/packages/core/src/iac/index.ts @@ -0,0 +1,11 @@ +export { v, type Infer, type BrandedId, type VString, type VNumber, type VBoolean, type VAny, type VId } from "./validators"; +export { defineSchema, defineTable, type TableDefinition, type SchemaDefinition, type IndexDefinition, type InferDocument, type InferSchema, type TableNames, type Doc, type SchemaShape } from "./schema"; +export { serializeSchema, loadSerializedSchema, saveSerializedSchema, type SerializedSchema, type SerializedTable, type SerializedColumn, type SerializedIndex } from "./schema-serializer"; +export { diffSchemas, formatDiff, type SchemaDiff, type SchemaDiffChange, type DiffChangeType } from "./schema-diff"; +export { query, mutation, action, type QueryCtx, type MutationCtx, type ActionCtx, type AuthCtx, type StorageReaderCtx, type StorageWriterCtx, type Scheduler, type QueryRegistration, type MutationRegistration, type ActionRegistration } from "./functions"; +export { DatabaseReader, DatabaseWriter, IaCQueryBuilder } from "./db-context"; +export { discoverFunctions, setFunctionRegistry, getFunctionRegistry, lookupFunction, type RegisteredFunction } from "./function-registry"; +export { cron, getCronJobs, type CronJob } from "./cron"; +export { generateDrizzleSchema } from "./generators/drizzle-schema-gen"; +export { generateMigration, type GeneratedMigration } from "./generators/migration-gen"; +export { generateApiTypes } from "./generators/api-typegen"; diff --git a/packages/core/src/iac/schema-diff.ts b/packages/core/src/iac/schema-diff.ts new file mode 100644 index 0000000..00f2be5 --- /dev/null +++ b/packages/core/src/iac/schema-diff.ts @@ -0,0 +1,113 @@ +import type { SerializedSchema, SerializedTable, SerializedColumn, SerializedIndex } from "./schema-serializer"; + +export type DiffChangeType = + | "ADD_TABLE" + | "DROP_TABLE" + | "ADD_COLUMN" + | "DROP_COLUMN" + | "ALTER_COLUMN" + | "ADD_INDEX" + | "DROP_INDEX"; + +export interface SchemaDiffChange { + type: DiffChangeType; + table: string; + column?: string; + index?: string; + before?: unknown; + after?: unknown; + destructive: boolean; +} + +export interface SchemaDiff { + changes: SchemaDiffChange[]; + hasDestructive: boolean; + isEmpty: boolean; +} + +export function diffSchemas( + from: SerializedSchema | null, + to: SerializedSchema +): SchemaDiff { + const changes: SchemaDiffChange[] = []; + + const fromTables = new Map( + (from?.tables ?? []).map((t) => [t.name, t]) + ); + const toTables = new Map( + to.tables.map((t) => [t.name, t]) + ); + + // Added tables + for (const [name, table] of toTables) { + if (!fromTables.has(name)) { + changes.push({ type: "ADD_TABLE", table: name, after: table, destructive: false }); + continue; + } + // Diff columns within existing table + const fromTable = fromTables.get(name)!; + const fromCols = new Map(fromTable.columns.map((c) => [c.name, c])); + const toCols = new Map(table.columns.map((c) => [c.name, c])); + + for (const [col, def] of toCols) { + if (!fromCols.has(col)) { + changes.push({ type: "ADD_COLUMN", table: name, column: col, after: def, destructive: false }); + } else { + const before = fromCols.get(col)!; + if (before.type !== def.type || before.optional !== def.optional) { + changes.push({ + type: "ALTER_COLUMN", table: name, column: col, + before, after: def, + // Changing type or making required = destructive + destructive: before.type !== def.type || (before.optional && !def.optional), + }); + } + } + } + + for (const [col, def] of fromCols) { + if (!toCols.has(col) && !def.system) { + changes.push({ type: "DROP_COLUMN", table: name, column: col, before: def, destructive: true }); + } + } + + // Diff indexes + const fromIdx = new Map(fromTable.indexes.map((i) => [i.name, i])); + const toIdx = new Map(table.indexes.map((i) => [i.name, i])); + + for (const [idx] of toIdx) { + if (!fromIdx.has(idx)) changes.push({ type: "ADD_INDEX", table: name, index: idx, destructive: false }); + } + for (const [idx] of fromIdx) { + if (!toIdx.has(idx)) changes.push({ type: "DROP_INDEX", table: name, index: idx, destructive: false }); + } + } + + // Dropped tables + for (const [name] of fromTables) { + if (!toTables.has(name)) { + changes.push({ type: "DROP_TABLE", table: name, before: fromTables.get(name), destructive: true }); + } + } + + const hasDestructive = changes.some((c) => c.destructive); + return { changes, hasDestructive, isEmpty: changes.length === 0 }; +} + +/** Human-readable summary of a diff */ +export function formatDiff(diff: SchemaDiff): string { + if (diff.isEmpty) return " No schema changes detected."; + + return diff.changes.map((c) => { + const prefix = c.destructive ? "⚠ " : "+ "; + switch (c.type) { + case "ADD_TABLE": return `${prefix}ADD TABLE ${c.table}`; + case "DROP_TABLE": return `${prefix}DROP TABLE ${c.table}`; + case "ADD_COLUMN": return `${prefix}ADD COLUMN ${c.table}.${c.column}`; + case "DROP_COLUMN": return `${prefix}DROP COLUMN ${c.table}.${c.column}`; + case "ALTER_COLUMN": return `${prefix}ALTER COLUMN ${c.table}.${c.column}`; + case "ADD_INDEX": return `${prefix}ADD INDEX ${c.table}.${c.index}`; + case "DROP_INDEX": return `${prefix}DROP INDEX ${c.table}.${c.index}`; + } + }).join("\n"); +} diff --git a/packages/core/src/iac/schema-serializer.ts b/packages/core/src/iac/schema-serializer.ts new file mode 100644 index 0000000..1511766 --- /dev/null +++ b/packages/core/src/iac/schema-serializer.ts @@ -0,0 +1,124 @@ +import { z } from "zod"; +import type { SchemaDefinition, TableDefinition, IndexDefinition } from "./schema"; + +export interface SerializedColumn { + name: string; + type: string; // "string" | "number" | "boolean" | "id:users" | "array:string" | etc. + optional: boolean; + system: boolean; // true for _id, _createdAt, _updatedAt +} + +export interface SerializedIndex { + type: "index" | "uniqueIndex" | "searchIndex"; + name: string; + fields: string[]; + searchField?: string; +} + +export interface SerializedTable { + name: string; + columns: SerializedColumn[]; + indexes: SerializedIndex[]; +} + +export interface SerializedSchema { + version: number; // bumped on each serialization + tables: SerializedTable[]; + generated: string; // ISO timestamp +} + +/** Converts a ZodTypeAny to a string type descriptor */ +function zodToTypeString(schema: z.ZodTypeAny): string { + if (schema instanceof z.ZodString) return "string"; + if (schema instanceof z.ZodNumber) return "number"; + if (schema instanceof z.ZodBoolean) return "boolean"; + if (schema instanceof z.ZodBigInt) return "int64"; + if (schema instanceof z.ZodNull) return "null"; + if (schema instanceof z.ZodAny) return "any"; + if (schema instanceof z.ZodDate) return "date"; + + if (schema instanceof z.ZodBranded) { + // v.id() — extract brand + const brand = (schema as any)._def.type; + const brandStr = (schema as any)._def.type?._def?.typeName ?? "string"; + return `id:${String((schema as any)._def.brandedType ?? "unknown")}`; + } + + if (schema instanceof z.ZodOptional) { + return zodToTypeString(schema.unwrap()); + } + + if (schema instanceof z.ZodArray) { + return `array:${zodToTypeString(schema.element)}`; + } + + if (schema instanceof z.ZodObject) { + return "object"; + } + + if (schema instanceof z.ZodUnion) { + const options = (schema as z.ZodUnion).options as z.ZodTypeAny[]; + return `union:${options.map(zodToTypeString).join("|")}`; + } + + if (schema instanceof z.ZodLiteral) { + return `literal:${String(schema.value)}`; + } + + return "unknown"; +} + +const SYSTEM_FIELDS = new Set(["_id", "_createdAt", "_updatedAt"]); + +/** Serialize a full SchemaDefinition to a plain JSON-safe object */ +export function serializeSchema(schema: SchemaDefinition): SerializedSchema { + const tables: SerializedTable[] = []; + + for (const [tableName, tableDef] of Object.entries(schema._tables)) { + const table = tableDef as TableDefinition; + const columns: SerializedColumn[] = []; + + // Iterate over the full schema shape (includes system fields) + for (const [colName, colSchema] of Object.entries(table._schema.shape)) { + const isOptional = colSchema instanceof z.ZodOptional; + const innerSchema = isOptional ? (colSchema as z.ZodOptional).unwrap() : colSchema; + + columns.push({ + name: colName, + type: zodToTypeString(colSchema), + optional: isOptional, + system: SYSTEM_FIELDS.has(colName), + }); + } + + tables.push({ + name: tableName, + columns, + indexes: table._indexes, + }); + } + + return { + version: Date.now(), + tables, + generated: new Date().toISOString(), + }; +} + +/** Load a serialized schema from disk (bbf/_generated/schema.json) */ +export function loadSerializedSchema(path: string): SerializedSchema | null { + try { + const content = Bun.file(path).text(); + return JSON.parse(content as any) as SerializedSchema; + } catch { + return null; + } +} + +/** Save serialized schema to disk */ +export async function saveSerializedSchema( + schema: SerializedSchema, + path: string +): Promise { + await Bun.write(path, JSON.stringify(schema, null, 2)); +} diff --git a/packages/core/src/iac/schema.ts b/packages/core/src/iac/schema.ts new file mode 100644 index 0000000..f2572ce --- /dev/null +++ b/packages/core/src/iac/schema.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +export interface IndexDefinition { + type: "index" | "searchIndex" | "uniqueIndex"; + name: string; + fields: string[]; + searchField?: string; // for searchIndex +} + +export interface TableDefinition< + TShape extends z.ZodRawShape = z.ZodRawShape +> { + _shape: TShape; + _schema: z.ZodObject; + _indexes: IndexDefinition[]; + /** Add a standard DB index */ + index(name: string, fields: (keyof TShape & string)[]): this; + /** Add a UNIQUE index */ + uniqueIndex(name: string, fields: (keyof TShape & string)[]): this; + /** Add a full-text search index (for supported providers) */ + searchIndex(name: string, opts: { searchField: keyof TShape & string; filterFields?: (keyof TShape & string)[] }): this; +} + +export function defineTable( + shape: TShape +): TableDefinition { + // System fields injected automatically + const systemShape = { + _id: z.string().describe("system:id"), + _createdAt: z.date().describe("system:createdAt"), + _updatedAt: z.date().describe("system:updatedAt"), + }; + + const fullShape = { ...systemShape, ...shape } as TShape & typeof systemShape; + const schema = z.object(fullShape); + const indexes: IndexDefinition[] = []; + + const table: TableDefinition = { + _shape: shape, + _schema: schema, + _indexes: indexes, + + index(name, fields) { + indexes.push({ type: "index", name, fields: fields as string[] }); + return this; + }, + + uniqueIndex(name, fields) { + indexes.push({ type: "uniqueIndex", name, fields: fields as string[] }); + return this; + }, + + searchIndex(name, opts) { + indexes.push({ + type: "searchIndex", + name, + fields: [opts.searchField, ...(opts.filterFields ?? [])], + searchField: opts.searchField, + }); + return this; + }, + }; + + return table; +} + +/** Infer the document type of a table (includes system fields) */ +export type InferDocument = + z.infer; + +// ─── Schema Definition ─────────────────────────────────────────────────────── + +export type SchemaShape = Record; + +export interface SchemaDefinition { + _tables: TShape; +} + +export function defineSchema( + tables: TShape +): SchemaDefinition { + return { _tables: tables }; +} + +/** Infer full schema types: { users: { _id: string, name: string, ... }, posts: {...} } */ +export type InferSchema = { + [K in keyof T["_tables"]]: InferDocument; +}; + +/** Get table names from a schema */ +export type TableNames = keyof T["_tables"] & string; + +/** Get the document type for a specific table */ +export type Doc< + TSchema extends SchemaDefinition, + TTable extends TableNames +> = InferSchema[TTable]; diff --git a/packages/core/src/iac/validators.ts b/packages/core/src/iac/validators.ts new file mode 100644 index 0000000..9dce02c --- /dev/null +++ b/packages/core/src/iac/validators.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +// Brand symbol for typed IDs +const ID_BRAND = Symbol("BetterBaseId"); + +export type BrandedId = string & { __table: T }; + +/** + * The `v` object provides Convex-style validator primitives backed by Zod. + * Every method returns a ZodSchema — callers can use them as plain Zod schemas. + */ +export const v = { + /** UTF-8 string */ + string: () => z.string(), + /** JS number (float64) */ + number: () => z.number(), + /** Boolean */ + boolean: () => z.boolean(), + /** null */ + null: () => z.null(), + /** bigint */ + int64: () => z.bigint(), + /** Zod z.any() */ + any: () => z.any(), + /** Make a field optional */ + optional: (validator: T) => validator.optional(), + /** Array of items */ + array: (item: T) => z.array(item), + /** Plain object with typed fields */ + object: (shape: T) => z.object(shape), + /** Discriminated union */ + union: (...validators: T) => + z.union(validators as unknown as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]), + /** Exact value */ + literal: (value: T) => z.literal(value), + /** Typed foreign key reference — resolves to string at runtime */ + id: (tableName: T) => + z.string().brand<`${T}Id`>(), + /** ISO 8601 datetime string */ + datetime: () => z.string().datetime({ offset: true }), + /** Bytes (base64 string) */ + bytes: () => z.string().base64(), +}; + +export type VString = ReturnType; +export type VNumber = ReturnType; +export type VBoolean = ReturnType; +export type VAny = ReturnType; +export type VId = z.ZodBranded; + +/** Infer TypeScript type from a v.* validator */ +export type Infer = z.infer; diff --git a/packages/core/test/iac-edge-cases.test.ts b/packages/core/test/iac-edge-cases.test.ts new file mode 100644 index 0000000..669da23 --- /dev/null +++ b/packages/core/test/iac-edge-cases.test.ts @@ -0,0 +1,547 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { action, mutation, query } from "../src/iac/functions"; +import { generateApiTypes } from "../src/iac/generators/api-typegen"; +import { generateDrizzleSchema } from "../src/iac/generators/drizzle-schema-gen"; +import { generateMigration } from "../src/iac/generators/migration-gen"; +import { defineSchema, defineTable } from "../src/iac/schema"; +import { type SchemaDiff, diffSchemas, formatDiff } from "../src/iac/schema-diff"; +import { type SerializedSchema, serializeSchema } from "../src/iac/schema-serializer"; +import { type Infer, v } from "../src/iac/validators"; + +describe("Edge Cases: Validators", () => { + test("v.id() with empty string table name", () => { + const schema = v.id(""); + expect(schema.safeParse("id_123").success).toBe(true); + }); + + test("v.id() with special characters in table name", () => { + const schema = v.id("table-with-dashes"); + expect(schema.safeParse("id_123").success).toBe(true); + }); + + test("v.optional() with nested optional", () => { + const schema = v.optional(v.optional(v.string())); + expect(schema.safeParse(undefined).success).toBe(true); + expect(schema.safeParse("test").success).toBe(true); + }); + + test("v.array() with complex element type", () => { + const schema = v.array(v.object({ name: v.string() })); + expect(schema.safeParse([{ name: "a" }, { name: "b" }]).success).toBe(true); + expect(schema.safeParse([{ name: 123 }]).success).toBe(false); + }); + + test("v.union() with many variants", () => { + const schema = v.union( + v.literal("a"), + v.literal("b"), + v.literal("c"), + v.literal("d"), + v.literal("e"), + ); + expect(schema.safeParse("a").success).toBe(true); + expect(schema.safeParse("e").success).toBe(true); + expect(schema.safeParse("f").success).toBe(false); + }); + + test("v.object() with optional nested fields", () => { + const schema = v.object({ + required: v.string(), + optional: v.optional(v.number()), + }); + expect(schema.safeParse({ required: "x" }).success).toBe(true); + expect(schema.safeParse({ required: "x", optional: 1 }).success).toBe(true); + expect(schema.safeParse({ required: "x", optional: "nope" }).success).toBe(false); + }); + + test("v.object() with deeply nested objects", () => { + const schema = v.object({ + level1: v.object({ + level2: v.object({ + level3: v.string(), + }), + }), + }); + expect(schema.safeParse({ level1: { level2: { level3: "deep" } } }).success).toBe(true); + expect(schema.safeParse({ level1: { level2: { level3: 123 } } }).success).toBe(false); + }); + + test("v.datetime() with various ISO formats", () => { + const schema = v.datetime(); + expect(schema.safeParse("2024-01-01T00:00:00Z").success).toBe(true); + expect(schema.safeParse("2024-01-01T00:00:00+00:00").success).toBe(true); + expect(schema.safeParse("2024-01-01T00:00:00-05:00").success).toBe(true); + expect(schema.safeParse("2024-01-01").success).toBe(false); + }); + + test("v.bytes() with valid base64", () => { + const schema = v.bytes(); + expect(schema.safeParse("SGVsbG8gV29ybGQ=").success).toBe(true); + expect(schema.safeParse("").success).toBe(true); + expect(schema.safeParse("not-base64!").success).toBe(false); + }); + + test("v.literal() with various primitive types", () => { + const stringLit = v.literal("hello"); + expect(stringLit.safeParse("hello").success).toBe(true); + + const numLit = v.literal(42); + expect(numLit.safeParse(42).success).toBe(true); + + const boolLit = v.literal(true); + expect(boolLit.safeParse(true).success).toBe(true); + }); +}); + +describe("Edge Cases: Schema Definition", () => { + test("defineTable with no user fields (system fields only)", () => { + const table = defineTable({}); + expect(table._schema.shape._id).toBeDefined(); + expect(table._schema.shape._createdAt).toBeDefined(); + expect(table._schema.shape._updatedAt).toBeDefined(); + }); + + test("defineTable with all field types", () => { + const table = defineTable({ + str: v.string(), + num: v.number(), + bool: v.boolean(), + arr: v.array(v.string()), + obj: v.object({ nested: v.string() }), + opt: v.optional(v.string()), + lit: v.literal("x"), + id: v.id("other"), + }); + expect(table._schema.shape.str).toBeDefined(); + expect(table._schema.shape.num).toBeDefined(); + expect(table._schema.shape.bool).toBeDefined(); + expect(table._schema.shape.arr).toBeDefined(); + expect(table._schema.shape.obj).toBeDefined(); + expect(table._schema.shape.opt).toBeDefined(); + expect(table._schema.shape.lit).toBeDefined(); + expect(table._schema.shape.id).toBeDefined(); + }); + + test("defineSchema with empty tables", () => { + const schema = defineSchema({}); + expect(Object.keys(schema._tables)).toHaveLength(0); + }); + + test("defineSchema with many tables", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + posts: defineTable({ title: v.string() }), + comments: defineTable({ text: v.string() }), + likes: defineTable({ userId: v.id("users") }), + tags: defineTable({ name: v.string() }), + }); + expect(Object.keys(schema._tables)).toHaveLength(5); + }); + + test("table with multiple indexes on same fields", () => { + const table = defineTable({ a: v.string(), b: v.string() }) + .index("idx1", ["a"]) + .index("idx2", ["a", "b"]); + expect(table._indexes).toHaveLength(2); + }); + + test("table with index on system field", () => { + const table = defineTable({ name: v.string() }).index("by_created", ["_createdAt"]); + expect(table._indexes[0].fields).toContain("_createdAt"); + }); +}); + +describe("Edge Cases: Schema Serialization", () => { + test("serializeSchema with empty schema", () => { + const schema = defineSchema({}); + const serialized = serializeSchema(schema); + expect(serialized.tables).toHaveLength(0); + expect(serialized.version).toBeDefined(); + }); + + test("serializeSchema with deeply nested object", () => { + const schema = defineSchema({ + data: defineTable({ + nested: v.object({ + deep: v.object({ + value: v.string(), + }), + }), + }), + }); + const serialized = serializeSchema(schema); + const nestedCol = serialized.tables[0].columns.find((c) => c.name === "nested"); + expect(nestedCol?.type).toBe("object"); + }); + + test("serializeSchema with array of objects", () => { + const schema = defineSchema({ + items: defineTable({ + tags: v.array(v.object({ name: v.string() })), + }), + }); + const serialized = serializeSchema(schema); + const tagsCol = serialized.tables[0].columns.find((c) => c.name === "tags"); + expect(tagsCol?.type).toStartWith("array:"); + }); + + test("serializeSchema with union type", () => { + const schema = defineSchema({ + status: defineTable({ + state: v.union(v.literal("pending"), v.literal("active"), v.literal("done")), + }), + }); + const serialized = serializeSchema(schema); + const stateCol = serialized.tables[0].columns.find((c) => c.name === "state"); + expect(stateCol?.type).toStartWith("union:"); + }); + + test("serializeSchema preserves index metadata", () => { + const schema = defineSchema({ + users: defineTable({ email: v.string() }) + .uniqueIndex("by_email", ["email"]) + .searchIndex("search_email", { searchField: "email" }), + }); + const serialized = serializeSchema(schema); + const indexes = serialized.tables[0].indexes; + + expect(indexes.find((i) => i.type === "uniqueIndex")).toBeDefined(); + expect(indexes.find((i) => i.type === "searchIndex")).toBeDefined(); + }); +}); + +describe("Edge Cases: Schema Diff", () => { + test("diffSchemas with multiple table changes", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }), + posts: defineTable({ title: v.string() }), + }), + ); + + const diff = diffSchemas(from, to); + expect(diff.changes.length).toBeGreaterThanOrEqual(2); + }); + + test("diffSchemas with optional to required change", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.optional(v.string()) }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + + const diff = diffSchemas(from, to); + const alter = diff.changes.find((c) => c.type === "ALTER_COLUMN"); + expect(alter?.destructive).toBe(true); + }); + + test("diffSchemas with required to optional change", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.optional(v.string()) }), + }), + ); + + const diff = diffSchemas(from, to); + const alter = diff.changes.find((c) => c.type === "ALTER_COLUMN"); + expect(alter?.destructive).toBe(false); + }); + + test("diffSchemas with index changes only", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }).index("by_name", ["name"]), + }), + ); + + const diff = diffSchemas(from, to); + expect(diff.changes.some((c) => c.type === "ADD_INDEX")).toBe(true); + }); + + test("diffSchemas with no changes returns empty", () => { + const schema = defineSchema({ users: defineTable({ name: v.string() }) }); + const serialized = serializeSchema(schema); + const diff = diffSchemas(serialized, serialized); + + expect(diff.isEmpty).toBe(true); + expect(diff.changes).toHaveLength(0); + }); + + test("formatDiff with empty diff", () => { + const schema = defineSchema({ users: defineTable({ name: v.string() }) }); + const serialized = serializeSchema(schema); + const diff = diffSchemas(serialized, serialized); + const formatted = formatDiff(diff); + + expect(formatted).toContain("No schema changes"); + }); +}); + +describe("Edge Cases: Function Registration", () => { + test("query with empty args", () => { + const q = query({ + args: {}, + handler: async () => "result", + }); + + const parsed = q._args.safeParse({}); + expect(parsed.success).toBe(true); + }); + + test("query with complex nested args", () => { + const q = query({ + args: { + filter: v.object({ + field: v.string(), + operator: v.union(v.literal("eq"), v.literal("gt")), + value: v.string(), + }), + pagination: v.optional( + v.object({ + limit: v.number(), + offset: v.optional(v.number()), + }), + ), + }, + handler: async () => [], + }); + + const validArgs = { + filter: { field: "name", operator: "eq", value: "test" }, + pagination: { limit: 10 }, + }; + expect(q._args.safeParse(validArgs).success).toBe(true); + }); + + test("mutation returns null", () => { + const m = mutation({ + args: { id: v.string() }, + handler: async () => null, + }); + + const parsed = m._args.safeParse({ id: "123" }); + expect(parsed.success).toBe(true); + }); + + test("action with side effects only", () => { + const a = action({ + args: { email: v.string() }, + handler: async (ctx) => { + await ctx.storage.store(new Blob(["test"])); + return { stored: true }; + }, + }); + + expect(a._args.shape.email).toBeDefined(); + }); +}); + +describe("Edge Cases: Code Generation", () => { + test("generateDrizzleSchema with no tables", () => { + const schema = serializeSchema(defineSchema({})); + const code = generateDrizzleSchema(schema, "sqlite"); + + expect(code).toContain("AUTO-GENERATED"); + expect(code).not.toContain("export const"); + }); + + test("generateDrizzleSchema with all SQL types", () => { + const schema = serializeSchema( + defineSchema({ + items: defineTable({ + str: v.string(), + num: v.number(), + bool: v.boolean(), + date: v.datetime(), + arr: v.array(v.string()), + obj: v.object({ x: v.string() }), + id: v.id("users"), + lit: v.literal("x"), + }), + }), + ); + + const code = generateDrizzleSchema(schema, "postgres"); + expect(code).toContain("text"); + expect(code).toContain("doublePrecision"); + expect(code).toContain("boolean"); + expect(code).toContain("timestamp"); + expect(code).toContain("jsonb"); + }); + + test("generateDrizzleSchema preserves indexes in output", () => { + const schema = serializeSchema( + defineSchema({ + users: defineTable({ email: v.string() }).uniqueIndex("by_email", ["email"]), + }), + ); + + const code = generateDrizzleSchema(schema, "sqlite"); + expect(code).toContain("uniqueIndex"); + }); + + test("generateMigration with DROP_INDEX", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }).index("by_name", ["name"]), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + + const diff = diffSchemas(from, to); + const migration = generateMigration(diff, 1, "remove_index"); + + expect(migration.sql).toContain("DROP INDEX"); + }); + + test("generateMigration with DROP_TABLE", () => { + const from = serializeSchema( + defineSchema({ + old: defineTable({ data: v.string() }), + }), + ); + const to = serializeSchema(defineSchema({})); + + const diff = diffSchemas(from, to); + const migration = generateMigration(diff, 1, "drop_old"); + + expect(migration.sql).toContain("DROP TABLE"); + expect(diff.hasDestructive).toBe(true); + }); + + test("generateMigration filename handles special characters", () => { + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const diff = diffSchemas(null, to); + + const migration = generateMigration(diff, 1, "add users table!"); + expect(migration.filename).toBe("0001_add_users_table!.sql"); + }); + + test("generateMigration pads sequence correctly", () => { + const to = serializeSchema(defineSchema({ test: defineTable({ x: v.string() }) })); + const diff = diffSchemas(null, to); + + expect(generateMigration(diff, 1, "test").filename).toStartWith("0001"); + expect(generateMigration(diff, 10, "test").filename).toStartWith("0010"); + expect(generateMigration(diff, 100, "test").filename).toStartWith("0100"); + }); + + test("generateApiTypes with empty functions", () => { + const types = generateApiTypes([]); + + expect(types).toContain("api:"); + expect(types).toContain("queries:"); + expect(types).toContain("mutations:"); + expect(types).toContain("actions:"); + }); + + test("generateApiTypes with deeply nested path", () => { + const functions = [ + { + kind: "query" as const, + path: "queries/admin/users/list/all", + name: "list", + module: "/test.ts", + handler: { _args: z.object({}), _handler: () => {} }, + }, + ]; + + const types = generateApiTypes(functions); + expect(types).toContain("admin_users"); + }); +}); + +describe("Edge Cases: Round-trip Serialization", () => { + test("serialize -> deserialize -> diff produces no changes", () => { + const original = defineSchema({ + users: defineTable({ name: v.string() }).uniqueIndex("by_name", ["name"]), + }); + const serialized = serializeSchema(original); + const json = JSON.stringify(serialized); + const deserialized = JSON.parse(json) as SerializedSchema; + + const diff = diffSchemas(deserialized, serialized); + expect(diff.isEmpty).toBe(true); + }); + + test("generated code is parseable for empty schema", () => { + const schema = serializeSchema(defineSchema({})); + const code = generateDrizzleSchema(schema, "sqlite"); + + expect(code).toContain("AUTO-GENERATED"); + }); +}); + +describe("Edge Cases: Null Handling", () => { + test("v.null() accepts null only", () => { + const schema = v.null(); + expect(schema.safeParse(null).success).toBe(true); + expect(schema.safeParse("null").success).toBe(false); + expect(schema.safeParse(undefined).success).toBe(false); + }); + + test("optional field can be null", () => { + const schema = v.object({ + required: v.string(), + optional: v.optional(v.string()), + }); + expect(schema.safeParse({ required: "x" }).success).toBe(true); + expect(schema.safeParse({ required: "x", optional: undefined }).success).toBe(true); + expect(schema.safeParse({ required: "x", optional: "value" }).success).toBe(true); + }); +}); + +describe("Edge Cases: Type Inference", () => { + test("Infer works with v.string()", () => { + type Str = Infer>; + const val: Str = "hello"; + expect(val).toBe("hello"); + }); + + test("Infer works with v.number()", () => { + type Num = Infer>; + const val: Num = 42; + expect(val).toBe(42); + }); + + test("Infer works with v.object()", () => { + type Obj = Infer>; + const val: Obj = { name: "test" }; + expect(val.name).toBe("test"); + }); + + test("Infer works with v.array()", () => { + type Arr = Infer>>; + const val: Arr = ["a", "b"]; + expect(val).toHaveLength(2); + }); +}); diff --git a/packages/core/test/iac.test.ts b/packages/core/test/iac.test.ts new file mode 100644 index 0000000..af1814f --- /dev/null +++ b/packages/core/test/iac.test.ts @@ -0,0 +1,670 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { z } from "zod"; +import { type CronJob, cron, getCronJobs } from "../src/iac/cron"; +import { DatabaseReader, DatabaseWriter } from "../src/iac/db-context"; +import { + discoverFunctions, + getFunctionRegistry, + lookupFunction, + setFunctionRegistry, +} from "../src/iac/function-registry"; +import { + type ActionRegistration, + type MutationRegistration, + type QueryRegistration, + action, + mutation, + query, +} from "../src/iac/functions"; +import { generateApiTypes } from "../src/iac/generators/api-typegen"; +import { generateDrizzleSchema } from "../src/iac/generators/drizzle-schema-gen"; +import { generateMigration } from "../src/iac/generators/migration-gen"; +import { + type Doc, + type InferSchema, + type SchemaDefinition, + type TableDefinition, + type TableNames, + defineSchema, + defineTable, +} from "../src/iac/schema"; +import { type SchemaDiff, diffSchemas, formatDiff } from "../src/iac/schema-diff"; +import { + loadSerializedSchema, + saveSerializedSchema, + serializeSchema, +} from "../src/iac/schema-serializer"; +import { type BrandedId, type Infer, v } from "../src/iac/validators"; + +describe("IAC Validators (v.*)", () => { + test("v.string() returns ZodString", () => { + const schema = v.string(); + expect(schema).toBeInstanceOf(z.ZodString); + expect(schema.safeParse("hello").success).toBe(true); + expect(schema.safeParse(123).success).toBe(false); + }); + + test("v.number() returns ZodNumber", () => { + const schema = v.number(); + expect(schema).toBeInstanceOf(z.ZodNumber); + expect(schema.safeParse(42).success).toBe(true); + expect(schema.safeParse("42").success).toBe(false); + }); + + test("v.boolean() returns ZodBoolean", () => { + const schema = v.boolean(); + expect(schema).toBeInstanceOf(z.ZodBoolean); + expect(schema.safeParse(true).success).toBe(true); + expect(schema.safeParse("true").success).toBe(false); + }); + + test("v.null() returns ZodNull", () => { + const schema = v.null(); + expect(schema).toBeInstanceOf(z.ZodNull); + expect(schema.safeParse(null).success).toBe(true); + expect(schema.safeParse(undefined).success).toBe(false); + }); + + test("v.int64() returns ZodBigInt", () => { + const schema = v.int64(); + expect(schema).toBeInstanceOf(z.ZodBigInt); + expect(schema.safeParse(BigInt(123)).success).toBe(true); + }); + + test("v.any() returns ZodAny", () => { + const schema = v.any(); + expect(schema).toBeInstanceOf(z.ZodAny); + expect(schema.safeParse("anything").success).toBe(true); + expect(schema.safeParse({ obj: {} }).success).toBe(true); + }); + + test("v.optional() wraps a validator", () => { + const schema = v.optional(v.string()); + expect(schema).toBeInstanceOf(z.ZodOptional); + expect(schema.safeParse(undefined).success).toBe(true); + expect(schema.safeParse("hello").success).toBe(true); + expect(schema.safeParse(123).success).toBe(false); + }); + + test("v.array() creates array schema", () => { + const schema = v.array(v.string()); + expect(schema).toBeInstanceOf(z.ZodArray); + expect(schema.safeParse(["a", "b"]).success).toBe(true); + expect(schema.safeParse([1, 2]).success).toBe(false); + }); + + test("v.object() creates object schema", () => { + const schema = v.object({ name: v.string(), age: v.number() }); + expect(schema).toBeInstanceOf(z.ZodObject); + expect(schema.safeParse({ name: "Alice", age: 30 }).success).toBe(true); + }); + + test("v.union() creates union schema", () => { + const schema = v.union(v.literal("admin"), v.literal("user")); + expect(schema).toBeInstanceOf(z.ZodUnion); + expect(schema.safeParse("admin").success).toBe(true); + expect(schema.safeParse("user").success).toBe(true); + expect(schema.safeParse("guest").success).toBe(false); + }); + + test("v.literal() creates literal schema", () => { + const schema = v.literal("active"); + expect(schema).toBeInstanceOf(z.ZodLiteral); + expect(schema.safeParse("active").success).toBe(true); + expect(schema.safeParse("inactive").success).toBe(false); + }); + + test("v.id() creates branded ID type", () => { + const schema = v.id("users"); + expect(schema).toBeInstanceOf(z.ZodBranded); + expect(schema.safeParse("user_123").success).toBe(true); + }); + + test("v.datetime() creates datetime schema", () => { + const schema = v.datetime(); + expect(schema).toBeInstanceOf(z.ZodString); + expect(schema.safeParse("2024-01-01T00:00:00Z").success).toBe(true); + expect(schema.safeParse("not-a-date").success).toBe(false); + }); + + test("v.bytes() creates base64 schema", () => { + const schema = v.bytes(); + expect(schema).toBeInstanceOf(z.ZodString); + expect(schema.safeParse("SGVsbG8=").success).toBe(true); + }); + + test("Infer type helper works", () => { + type StringInfer = Infer>; + const typed: StringInfer = "hello"; + expect(typed).toBe("hello"); + }); +}); + +describe("IAC Schema (defineTable)", () => { + test("defineTable creates table with system fields", () => { + const table = defineTable({ name: v.string() }); + + expect(table._shape).toEqual({ name: expect.any(z.ZodString) }); + expect(table._schema).toBeInstanceOf(z.ZodObject); + expect(table._indexes).toEqual([]); + }); + + test("defineTable adds index", () => { + const table = defineTable({ name: v.string() }).index("by_name", ["name"]); + + expect(table._indexes).toHaveLength(1); + expect(table._indexes[0].type).toBe("index"); + expect(table._indexes[0].name).toBe("by_name"); + expect(table._indexes[0].fields).toEqual(["name"]); + }); + + test("defineTable adds uniqueIndex", () => { + const table = defineTable({ email: v.string() }).uniqueIndex("by_email", ["email"]); + + expect(table._indexes).toHaveLength(1); + expect(table._indexes[0].type).toBe("uniqueIndex"); + }); + + test("defineTable adds searchIndex", () => { + const table = defineTable({ title: v.string(), body: v.string() }).searchIndex("search_title", { + searchField: "title", + }); + + expect(table._indexes).toHaveLength(1); + expect(table._indexes[0].type).toBe("searchIndex"); + }); + + test("defineTable is chainable", () => { + const table = defineTable({ name: v.string() }) + .index("by_name", ["name"]) + .uniqueIndex("by_name_unique", ["name"]) + .searchIndex("search", { searchField: "name" }); + + expect(table._indexes).toHaveLength(3); + }); +}); + +describe("IAC Schema (defineSchema)", () => { + test("defineSchema creates schema definition", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + }); + + expect(schema._tables).toBeDefined(); + expect(schema._tables.users).toBeDefined(); + }); + + test("InferSchema produces document types", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + }); + + type UsersDoc = InferSchema; + const doc: UsersDoc["users"] = { + _id: "user_123", + _createdAt: new Date(), + _updatedAt: new Date(), + name: "Alice", + }; + + expect(doc.name).toBe("Alice"); + }); + + test("Doc type extracts specific table", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + posts: defineTable({ title: v.string() }), + }); + + type UserDoc = Doc; + const doc: UserDoc = { + _id: "user_123", + _createdAt: new Date(), + _updatedAt: new Date(), + name: "Alice", + }; + + expect(doc.name).toBe("Alice"); + }); + + test("TableNames extracts table names", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + posts: defineTable({ title: v.string() }), + }); + + type Names = TableNames; + const name: Names = "users"; + + expect(name).toBe("users"); + }); +}); + +describe("Schema Serializer", () => { + test("serializeSchema produces JSON-serializable output", () => { + const schema = defineSchema({ + users: defineTable({ + name: v.string(), + email: v.string(), + }).uniqueIndex("by_email", ["email"]), + }); + + const serialized = serializeSchema(schema); + + expect(serialized.version).toBeDefined(); + expect(serialized.tables).toHaveLength(1); + expect(serialized.tables[0].name).toBe("users"); + }); + + test("serializeSchema marks system fields", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + }); + + const serialized = serializeSchema(schema); + const table = serialized.tables[0]; + const idCol = table.columns.find((c) => c.name === "_id"); + const createdCol = table.columns.find((c) => c.name === "_createdAt"); + const updatedCol = table.columns.find((c) => c.name === "_updatedAt"); + const nameCol = table.columns.find((c) => c.name === "name"); + + expect(idCol?.system).toBe(true); + expect(createdCol?.system).toBe(true); + expect(updatedCol?.system).toBe(true); + expect(nameCol?.system).toBe(false); + }); + + test("serializeSchema handles v.id() as id:type", () => { + const schema = defineSchema({ + posts: defineTable({ + authorId: v.id("users"), + }), + }); + + const serialized = serializeSchema(schema); + const authorCol = serialized.tables[0].columns.find((c) => c.name === "authorId"); + + expect(authorCol?.type).toStartWith("id:"); + }); + + test("serializeSchema handles v.optional()", () => { + const schema = defineSchema({ + users: defineTable({ + name: v.string(), + bio: v.optional(v.string()), + }), + }); + + const serialized = serializeSchema(schema); + const nameCol = serialized.tables[0].columns.find((c) => c.name === "name"); + const bioCol = serialized.tables[0].columns.find((c) => c.name === "bio"); + + expect(nameCol?.optional).toBe(false); + expect(bioCol?.optional).toBe(true); + }); +}); + +describe("Schema Diff Engine", () => { + test("diffSchemas from null produces ADD_TABLE for each table", () => { + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + posts: defineTable({ title: v.string() }), + }), + ); + + const diff = diffSchemas(null, to); + + expect(diff.isEmpty).toBe(false); + expect(diff.changes.filter((c) => c.type === "ADD_TABLE")).toHaveLength(2); + expect(diff.hasDestructive).toBe(false); + }); + + test("diffSchemas identical schemas produces empty diff", () => { + const schema = defineSchema({ + users: defineTable({ name: v.string() }), + }); + const serialized = serializeSchema(schema); + + const diff = diffSchemas(serialized, serialized); + + expect(diff.isEmpty).toBe(true); + expect(diff.hasDestructive).toBe(false); + }); + + test("diffSchemas detects ADD_COLUMN", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }), + }), + ); + + const diff = diffSchemas(from, to); + + expect(diff.changes.some((c) => c.type === "ADD_COLUMN")).toBe(true); + expect(diff.hasDestructive).toBe(false); + }); + + test("diffSchemas detects DROP_COLUMN as destructive", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + + const diff = diffSchemas(from, to); + + expect(diff.changes.some((c) => c.type === "DROP_COLUMN")).toBe(true); + expect(diff.hasDestructive).toBe(true); + }); + + test("diffSchemas detects ALTER_COLUMN as potentially destructive", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.number() }), + }), + ); + + const diff = diffSchemas(from, to); + + expect(diff.changes.some((c) => c.type === "ALTER_COLUMN")).toBe(true); + expect(diff.hasDestructive).toBe(true); + }); + + test("diffSchemas detects ADD_INDEX", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }).index("by_name", ["name"]), + }), + ); + + const diff = diffSchemas(from, to); + + expect(diff.changes.some((c) => c.type === "ADD_INDEX")).toBe(true); + }); + + test("formatDiff produces human-readable output", () => { + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const diff = diffSchemas(null, to); + const formatted = formatDiff(diff); + + expect(formatted).toContain("ADD TABLE"); + }); +}); + +describe("Function Primitives (query, mutation, action)", () => { + test("query creates query registration", () => { + const q = query({ + args: { id: v.string() }, + handler: async (ctx, args) => ({ id: args.id, name: "Alice" }), + }); + + expect(q._args).toBeInstanceOf(z.ZodObject); + expect(q._handler).toBeInstanceOf(Function); + expect(q._handler).toBeDefined(); + }); + + test("mutation creates mutation registration", () => { + const m = mutation({ + args: { name: v.string() }, + handler: async (ctx, args) => ({ success: true }), + }); + + expect(m._args).toBeInstanceOf(z.ZodObject); + expect(m._handler).toBeInstanceOf(Function); + }); + + test("action creates action registration", () => { + const a = action({ + args: { email: v.string() }, + handler: async (ctx, args) => ({ sent: true }), + }); + + expect(a._args).toBeInstanceOf(z.ZodObject); + expect(a._handler).toBeInstanceOf(Function); + }); + + test("query validates args", async () => { + const q = query({ + args: { id: v.string() }, + handler: async (ctx, args) => args.id, + }); + + const validArgs = { id: "123" }; + const parsed = q._args.safeParse(validArgs); + + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(validArgs); + }); + + test("query rejects invalid args", () => { + const q = query({ + args: { id: v.string() }, + handler: async (ctx, args) => args.id, + }); + + const invalidArgs = { id: 123 }; + const parsed = q._args.safeParse(invalidArgs); + + expect(parsed.success).toBe(false); + }); +}); + +describe("DatabaseReader", () => { + test("DatabaseReader has get and query methods", () => { + const mockPool = { + query: async () => ({ rows: [] }), + } as any; + + const reader = new DatabaseReader(mockPool, "public"); + + expect(reader.get).toBeInstanceOf(Function); + expect(reader.query).toBeInstanceOf(Function); + }); +}); + +describe("DatabaseWriter", () => { + test("DatabaseWriter extends DatabaseReader", () => { + const mockPool = { + query: async () => ({ rows: [] }), + } as any; + + const writer = new DatabaseWriter(mockPool, "public"); + + expect(writer.get).toBeInstanceOf(Function); + expect(writer.insert).toBeInstanceOf(Function); + expect(writer.patch).toBeInstanceOf(Function); + expect(writer.replace).toBeInstanceOf(Function); + expect(writer.delete).toBeInstanceOf(Function); + }); +}); + +describe("Function Registry", () => { + test("setFunctionRegistry and getFunctionRegistry", () => { + const functions = [ + { kind: "query" as const, path: "test/fn", name: "fn", module: "/test.ts", handler: {} }, + ]; + + setFunctionRegistry(functions); + const retrieved = getFunctionRegistry(); + + expect(retrieved).toHaveLength(1); + expect(retrieved[0].path).toBe("test/fn"); + }); + + test("lookupFunction finds registered function", () => { + setFunctionRegistry([ + { + kind: "query" as const, + path: "queries/users/getUser", + name: "getUser", + module: "/test.ts", + handler: {}, + }, + ]); + + const fn = lookupFunction("queries/users/getUser"); + + expect(fn).toBeDefined(); + expect(fn?.name).toBe("getUser"); + }); + + test("lookupFunction returns null for unknown path", () => { + setFunctionRegistry([]); + + const fn = lookupFunction("queries/unknown"); + + expect(fn).toBeNull(); + }); +}); + +describe("Cron Jobs", () => { + test("cron registers a job", () => { + const jobs = getCronJobs().length; + + const mockMutation = mutation({ + args: {}, + handler: async () => {}, + }); + + cron("test-job", "0 * * * *", mockMutation, {}); + + expect(getCronJobs()).toHaveLength(jobs + 1); + expect(getCronJobs()[0].name).toBe("test-job"); + expect(getCronJobs()[0].schedule).toBe("0 * * * *"); + }); +}); + +describe("Drizzle Schema Generator", () => { + test("generateDrizzleSchema produces valid code", () => { + const schema = serializeSchema( + defineSchema({ + users: defineTable({ + name: v.string(), + email: v.string(), + }).uniqueIndex("by_email", ["email"]), + }), + ); + + const code = generateDrizzleSchema(schema, "sqlite"); + + expect(code).toContain("sqliteTable"); + expect(code).toContain("users"); + expect(code).toContain("AUTO-GENERATED"); + }); + + test("generateDrizzleSchema supports postgres dialect", () => { + const schema = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + + const code = generateDrizzleSchema(schema, "postgres"); + + expect(code).toContain("pgTable"); + expect(code).toContain("text"); + }); +}); + +describe("Migration Generator", () => { + test("generateMigration produces valid SQL", () => { + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const diff = diffSchemas(null, to); + + const migration = generateMigration(diff, 1, "initial_schema"); + + expect(migration.filename).toBe("0001_initial_schema.sql"); + expect(migration.sql).toContain("CREATE TABLE"); + }); + + test("generateMigration handles ADD_COLUMN", () => { + const from = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string() }), + }), + ); + const to = serializeSchema( + defineSchema({ + users: defineTable({ name: v.string(), email: v.string() }), + }), + ); + const diff = diffSchemas(from, to); + + const migration = generateMigration(diff, 2, "add_email"); + + expect(migration.sql).toContain("ADD COLUMN"); + }); +}); + +describe("API Type Generator", () => { + test("generateApiTypes produces declaration file", () => { + const functions = [ + { + kind: "query" as const, + path: "queries/users/getUser", + name: "getUser", + module: "/test.ts", + handler: { _args: z.object({}), _handler: () => {} }, + }, + ]; + + const types = generateApiTypes(functions); + + expect(types).toContain("AUTO-GENERATED"); + expect(types).toContain("api:"); + expect(types).toContain("QueryRegistration"); + }); + + test("generateApiTypes groups by kind and file", () => { + const functions = [ + { + kind: "query" as const, + path: "queries/users/list", + name: "list", + module: "/test.ts", + handler: { _args: z.object({}), _handler: () => {} }, + }, + { + kind: "mutation" as const, + path: "mutations/users/create", + name: "create", + module: "/test.ts", + handler: { _args: z.object({}), _handler: () => {} }, + }, + ]; + + const types = generateApiTypes(functions); + + expect(types).toContain("queries:"); + expect(types).toContain("mutations:"); + }); +}); diff --git a/packages/server/package.json b/packages/server/package.json index 20c6999..6c2ffa9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -25,9 +25,10 @@ "nodemailer": "^6.9.0" }, "devDependencies": { - "@types/pg": "^8.11.0", "@types/bcryptjs": "^2.4.6", "@types/nodemailer": "^6.4.0", + "@types/pg": "^8.11.0", + "bun-types": "^1.3.11", "typescript": "^5.4.0" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 955291e..dbc112f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -6,6 +6,7 @@ import { validateEnv } from "./lib/env"; import { runMigrations } from "./lib/migrate"; import { adminRouter } from "./routes/admin/index"; import { deviceRouter } from "./routes/device/index"; +import { bbfRouter } from "./routes/bbf/index"; // Validate env first — exits if invalid const env = validateEnv(); @@ -62,6 +63,7 @@ app.get("/health", (c) => c.json({ status: "ok", timestamp: new Date().toISOStri // Routers app.route("/admin", adminRouter); app.route("/device", deviceRouter); +app.route("/bbf", bbfRouter); // 404 app.notFound((c) => c.json({ error: "Not found" }, 404)); diff --git a/packages/server/src/routes/bbf/index.ts b/packages/server/src/routes/bbf/index.ts new file mode 100644 index 0000000..5084793 --- /dev/null +++ b/packages/server/src/routes/bbf/index.ts @@ -0,0 +1,96 @@ +import { Hono } from "hono"; +import { z } from "zod"; +import { lookupFunction } from "@betterbase/core/iac"; +import { DatabaseReader, DatabaseWriter } from "@betterbase/core/iac"; +import { getPool } from "../../lib/db"; +import { extractBearerToken, verifyAdminToken } from "../../lib/auth"; + +export const bbfRouter = new Hono(); + +// All function calls: POST /bbf/:kind/* +bbfRouter.post("/:kind/*", async (c) => { + const kind = c.req.param("kind") as "queries" | "mutations" | "actions"; + const rest = c.req.path.replace(`/bbf/${kind}/`, ""); + const path = `${kind}/${rest}`; + + const fn = lookupFunction(path); + if (!fn) return c.json({ error: `Function not found: ${path}` }, 404); + + // Parse body + let args: unknown; + try { + const body = await c.req.json(); + args = body.args ?? {}; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + // Validate args + const parsed = (fn.handler as any)._args.safeParse(args); + if (!parsed.success) { + return c.json({ error: "Invalid arguments", details: parsed.error.flatten() }, 400); + } + + // Auth context + const token = extractBearerToken(c.req.header("Authorization")); + const adminPayload = token ? await verifyAdminToken(token) : null; + const authCtx = { userId: adminPayload?.sub ?? null, token }; + + // Build DB context + const pool = getPool(); + const projectSlug = c.req.header("X-Project-Slug") ?? "default"; + const dbSchema = `project_${projectSlug}`; + + try { + let result: unknown; + + if (fn.kind === "query") { + const ctx = { db: new DatabaseReader(pool, dbSchema), auth: authCtx, storage: buildStorageReader() }; + result = await (fn.handler as any)._handler(ctx, parsed.data); + } else if (fn.kind === "mutation") { + const writer = new DatabaseWriter(pool, dbSchema); + const ctx = { db: writer, auth: authCtx, storage: buildStorageWriter(), scheduler: buildScheduler(pool) }; + result = await (fn.handler as any)._handler(ctx, parsed.data); + } else { + // action + const ctx = buildActionCtx(pool, dbSchema, authCtx); + result = await (fn.handler as any)._handler(ctx, parsed.data); + } + + return c.json({ result }); + } catch (err: any) { + console.error(`[bbf] Error in ${path}:`, err); + return c.json({ error: err.message ?? "Function error" }, 500); + } +}); + +// Helpers (stubs — wired to real implementations in IAC-17/IAC-20) +function buildStorageReader() { + return { getUrl: async (_id: string) => null }; +} + +function buildStorageWriter() { + return { + getUrl: async (_id: string) => null, + store: async (_blob: Blob) => "stub-id", + delete: async (_id: string) => {}, + }; +} + +function buildScheduler(pool: any) { + return { + runAfter: async () => "job-id", + runAt: async () => "job-id", + cancel: async () => {}, + }; +} + +function buildActionCtx(pool: any, dbSchema: string, auth: any) { + return { + auth, + storage: buildStorageWriter(), + scheduler: buildScheduler(pool), + runQuery: async (fn: any, args: any) => (fn._handler({ db: new DatabaseReader(pool, dbSchema), auth, storage: buildStorageReader() }, args)), + runMutation: async (fn: any, args: any) => (fn._handler({ db: new DatabaseWriter(pool, dbSchema), auth, storage: buildStorageWriter(), scheduler: buildScheduler(pool) }, args)), + }; +} diff --git a/packages/server/src/types.d.ts b/packages/server/src/types.d.ts index d817c86..9e54e23 100644 --- a/packages/server/src/types.d.ts +++ b/packages/server/src/types.d.ts @@ -1,7 +1,7 @@ -import { Context } from "hono"; +import "hono"; declare module "hono" { - interface ContextVariables { + interface ContextVariableMap { adminUser: { id: string; email: string; diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index a124649..6ff60de 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "types": ["bun-types"] + "types": ["bun-types"], + "moduleResolution": "Bundler" }, "include": ["src/**/*", "migrations/**/*", "src/types.d.ts"] }