Files
sim/packages/db/schema.ts
Waleed e2be99263c feat(academy): Sim Academy — interactive partner certification platform (#3824)
* fix(import): dedup workflow name (#3813)

* feat(concurrency): bullmq based concurrency control system (#3605)

* feat(concurrency): bullmq based queueing system

* fix bun lock

* remove manual execs off queues

* address comments

* fix legacy team limits

* cleanup enterprise typing code

* inline child triggers

* fix status check

* address more comments

* optimize reconciler scan

* remove dead code

* add to landing page

* Add load testing framework

* update bullmq

* fix

* fix headless path

---------

Co-authored-by: Theodore Li <teddy@zenobiapay.com>

* fix(linear): add default null for after cursor (#3814)

* fix(knowledge): reject non-alphanumeric file extensions from document names (#3816)

* fix(knowledge): reject non-alphanumeric file extensions from document names

* fix(knowledge): improve error message when extension is non-alphanumeric

* fix(security): SSRF, access control, and info disclosure (#3815)

* fix(security): scope copilot feedback GET endpoint to authenticated user

Add WHERE clause to filter feedback records by the authenticated user's
ID, preventing any authenticated user from reading all users' copilot
interactions, queries, and workflow YAML (IDOR / CWE-639).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(smtp): add SSRF validation and genericize network error messages

Prevent SSRF via user-controlled smtpHost by validating with
validateDatabaseHost before creating the nodemailer transporter.
Collapse distinct network error messages (ECONNREFUSED, ECONNRESET,
ETIMEDOUT) into a single generic message to prevent port-state leakage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): add SSRF validation to SFTP/SSH and access control to workspace invitations

Add `validateDatabaseHost` checks to SFTP and SSH connection utilities to
block connections to private/reserved IPs and localhost, matching the
existing pattern used by all database tools. Add authorization check to
the workspace invitation GET endpoint so only the invitee or a workspace
admin can view invitation details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(smtp): restore SMTP response code handling for post-connection errors

SMTP 4xx/5xx response codes are application-level errors (invalid
recipient, mailbox full, server error) unrelated to the SSRF hardening
goal. Restore response code differentiation and logging to preserve
actionable user-facing error messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): use session email directly instead of extra DB query

Addresses PR review feedback — align with the workspace invitation
route pattern by using session.user.email instead of re-fetching
from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(auth): revert lint autofix that broke hasExternalApiCredentials return type

Biome auto-fixed `return auth !== null && auth.startsWith(...)` to
`return auth?.startsWith(...)` which returns `boolean | undefined`,
not `boolean`, causing a TypeScript build failure.

* fix(smtp): pin resolved IP to prevent DNS rebinding (TOCTOU)

Use the pre-resolved IP from validateDatabaseHost instead of the
original hostname when creating the nodemailer transporter. Set
servername to the original hostname to preserve TLS SNI validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(security): extract createPinnedLookup helper for DNS rebinding prevention

Extract reusable createPinnedLookup from secureFetchWithPinnedIP so
non-HTTP transports (SSH, SFTP, IMAP) can pin resolved IPs at the
socket level. SMTP route uses host+servername pinning instead since
nodemailer doesn't reliably pass lookup to both secure/plaintext paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): pin IMAP connections to validated resolved IP

Pass the resolved IP from validateDatabaseHost to ImapFlow as host,
with the original hostname as servername for TLS SNI verification.
Closes the DNS TOCTOU rebinding window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(auth): revert lint autofix on hasExternalApiCredentials return type

Also pin SFTP/SSH connections to validated resolved IP to prevent DNS rebinding.

* fix(security): short-circuit admin check when caller is invitee

Skip the hasWorkspaceAdminAccess DB query when the caller is already
the invitee, avoiding an unnecessary round-trip. Aligns with the org
invitation route pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* fix(worker): dockerfile + helm updates (#3818)

* fix(worker): dockerfile + helm updates

* address comments

* update dockerfile (#3819)

* fix dockerfile

* fix(security): pentest remediation — condition escaping, SSRF hardening, ReDoS protection (#3820)

* fix(executor): escape newline characters in condition expression strings

Unescaped newline/carriage-return characters in resolved string values
cause unterminated string literals in generated JS, crashing condition
evaluation with a SyntaxError.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): prevent ReDoS in guardrails regex validation

Add safe-regex2 to reject catastrophic backtracking patterns before
execution and cap input length at 10k characters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): SSRF localhost hardening and regex DoS protection

Block localhost/loopback URLs in hosted environments using isHosted flag
instead of allowHttp. Add safe-regex2 validation and input length limits
to regex guardrails to prevent catastrophic backtracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): validate regex syntax before safety check

Move new RegExp() before safe() so invalid patterns get a proper syntax
error instead of a misleading "catastrophic backtracking" message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address PR review feedback

- Hoist isLocalhost && isHosted guard to single early-return before
  protocol checks, removing redundant duplicate block
- Move regex syntax validation (new RegExp) before safe-regex2 check
  so invalid patterns get proper syntax error instead of misleading
  "catastrophic backtracking" message

* fix(security): remove input length cap from regex validation

The 10k character cap would block legitimate guardrail checks on long
LLM outputs. Input length doesn't affect ReDoS risk — the safe-regex2
pattern check already prevents catastrophic backtracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(tests): mock isHosted in input-validation and function-execute tests

Tests that assert self-hosted localhost behavior need isHosted=false,
which is not guaranteed in CI where NEXT_PUBLIC_APP_URL is set to the
hosted domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

* improvement(worker): configuration defaults (#3821)

* improvement(worker): configuration defaults

* update readmes

* realtime curl import

* improvement(tour): remove auto-start, only trigger on explicit user action (#3823)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy (#3822)

* fix(mcp): use correct modal for creating workflow MCP servers in deploy

* fix(mcp): show workflows field during loading and when empty

* mock course

* fix(db): use bigint for token counter columns in user_stats (#3755)

* mock course

* updates

* updated X handle for emir

* cleanup: audit and clean academy implementation

* fix(academy): add label to ValidationRule, fix quiz gating, simplify getRuleMessage

* cleanup: remove unnecessary comments across academy files

* refactor(academy): simplify abstractions and fix perf issues

* perf(academy): convert course detail page to server component with client island

* fix(academy): null-safe canAdvance, render exercise instructions, remove stale comments

* fix(academy): remove orphaned migration, fix getCourseById, clean up comments

- Delete 0181_academy_certificate.sql (orphaned duplicate not in journal)
- Add getCourseById() to content/index.ts; use it in certificates API
  (was using getCourse which searches by slug, not stable id)
- Remove JSX comments from catalog page
- Remove redundant `passed` recomputation in LessonQuiz

* chore(db): regenerate academy_certificate migration with drizzle-kit

* chore: include blog mdx and components changes

* fix(blog): correct cn import path

* fix(academy): constrain progress bar to max-w-3xl with proper padding

* feat(academy): show back-to-course button on first lesson

* fix(academy): force dark theme on all /academy routes

* content(academy): rewrite sim-foundations course with full 6-module curriculum

* fix(academy): correct edge handles, quiz explanation, and starter mock outputs

- Fix Exercise 2 initial edge handles: 'starter-1-source'/'agent-1-target' → 'source'/'target' (React Flow actual IDs)
- Fix M1-L4 Q4 quiz explanation: remove non-existent Ctrl/Cmd+D and Alt+drag shortcuts
- Add starter mock output to all exercises so run animation shows feedback on the first block

* refine(academy): fix inaccurate content and improve exercise clarity

- Fix Exercise 3: replace hardcoded <agent-1.content> (invalid UUID-based ref) with reference picker instructions
- Fix M4 Quiz Q5: Loop block (subflow container) is correct answer, not the Workflow block
- Fix M4 Quiz Q4: clarify fan-out vs Parallel block distinction in explanation
- Fix M4-L2 video description: accurately describe Loop and Parallel subflow blocks
- Fix M2 Quiz Q3: make response format question conceptual rather than syntax-specific
- Improve Exercise 4 branching instructions: clarify top=true / bottom=false output handles
- Improve Final Project instructions: step-by-step numbered flow

* fix(academy): remove double border on quiz question cards

* fix(academy): single scroll container on lesson pages — remove nested flex scroll

* fix(academy): remove min-h-screen from root layout — fixes double scrollbar on lesson pages

* fix(academy): use fixed inset-0 on lesson page to eliminate document-level scrollbar

* fix(academy): replace sr-only radio/checkbox inputs with buttons to prevent scroll-on-focus; restore layout min-h-screen

* improvement(academy): polish, security hardening, and certificate claim UI

- Replace raw localStorage with BrowserStorage utility in local-progress
- Pre-compute slug/id Maps in content/index for O(1) course lookups
- Move blockMap construction into edge_exists branch only in validation
- Extract navBtnClass constant and MetaRow/formatDate helpers in UI
- Add rate limiting, server-side completion verification, audit logging, and nanoid cert numbers to certificate issuance endpoint
- Add useIssueCertificate mutation hook with completedLessonIds
- Wire certificate claim UI into CourseProgress: sign-in prompt, claim button with loading state, and post-issuance view with link to certificate page
- Fix lesson page scroll container and quiz scroll-on-focus bug

* fix(academy): validate condition branch handles in edge_exists rules

- Add sourceHandle field to edge_exists ValidationRule type
- Check sourceHandle in validation.ts when specified
- Require both condition-if and condition-else branches to be connected in the branching and final project exercises

* fix(academy): address PR review — isHosted regression, stuck isExecuting, revoked cert 500, certificate SSR

- Restore env-var-based isHosted check (was hardcoded true, breaking self-hosted deployments)
- Fix isExecuting stuck at true when mock run fails validation — set isMockRunningRef immediately and reset both flags on early exit
- Fix revoked/expired certificate causing 500 — any existing record (not just active) now returns 409 instead of falling through to INSERT
- Convert certificate verification page from client component to server component — direct DB fetch, notFound() on missing cert, generateMetadata for SEO/social previews

* fix(auth): restore hybrid.ts from staging to fix CI type error

* fix(academy): mark video lessons complete on visit and fix sign-in path

* fix(academy): replace useEffect+setState with lazy useState initializer in CourseProgress

* fix(academy): reset exerciseComplete on lesson navigation, remove unused useAcademyCertificate hook

* fix(academy): useState for slug-change reset, cache() for cert page, handleMockRunRef for stale closure

* fix(academy): replace shadcn theme vars with explicit hex in LessonVideo fallback

* fix(academy): reset completedRef on exercise change, conditional verified badge, multi-select empty guard

* fix(academy): type safety fixes — null metadata fallbacks, returning() guard, exhaustive union, empty catch

* fix(academy): reset ExerciseView completed banner on nav; fix CourseProgress hydration mismatch

* fix(lightbox): guard effect body with isOpen to prevent spurious overflow reset

* fix(academy): reset LessonQuiz state on lesson change to prevent stale answers persisting

* fix(academy): course not-found metadata title; try-finally guard in mock run loop

* fix(academy): type safety, cert persistence, regex guard, mixed-lesson video, shorts support

- Derive AcademyCertificate from db $inferSelect to prevent schema drift
- Add useCourseCertificate query hook; GET /api/academy/certificates now accepts courseId for authenticated lookup
- Use useCourseCertificate in CourseProgress so certificate state survives page refresh
- Guard new RegExp(valuePattern) in validation.ts with try/catch; log warn on invalid pattern
- Add logger.warn for custom validation rules so content authors are alerted
- Add YouTube Shorts URL support to LessonVideo (youtube.com/shorts/VIDEO_ID)
- Fix mixed-lesson video gap: render videoUrl above quiz when mixed has quiz but no exercise
- Add academy-scoped not-found.tsx with link back to /academy

* fix(academy): reset hintIndex when exercise changes

* chore: remove ban-spam-accounts script (wrong branch)

* fix(academy): enforce availableBlocks in toolbar; fix mixed exercise+quiz rendering

- Add useSandboxBlockConstraints context; SandboxCanvasProvider provides exerciseConfig.availableBlocks so the toolbar only shows permitted block types. Empty array hides all blocks (configure-only exercises); non-null array restricts to listed types; triggers always hidden in sandbox.
- Fix mixed lesson with both exerciseConfig and quizConfig: exercise renders first, quiz reveals after exercise completes (sequential pedagogy). canAdvance now requires both exerciseComplete && quizComplete when both are present.

* chore(academy): remove extraneous inline comments

* fix(academy): blank mixed lesson, quiz canAdvance flag, empty-array valueNotEmpty

* prep for merge

* chore(db): regenerate academy certificate migration after staging merge

* fix(academy): disable auto-connect in sandbox mode

* fix(academy): render video in mixed lesson with no exercise or quiz

* fix(academy): mark mixed video-only lessons complete; handle cert insert race

* fix(canvas): add sandbox and embedded to nodes useMemo deps

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Lakee Sivaraya <71339072+lakeesiv@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Theodore Li <teddy@zenobiapay.com>
2026-03-28 12:46:04 -07:00

2902 lines
109 KiB
TypeScript

import { type SQL, sql } from 'drizzle-orm'
import {
bigint,
boolean,
check,
customType,
decimal,
doublePrecision,
index,
integer,
json,
jsonb,
pgEnum,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
vector,
} from 'drizzle-orm/pg-core'
import { DEFAULT_FREE_CREDITS, TAG_SLOTS } from './constants'
// Custom tsvector type for full-text search
export const tsvector = customType<{
data: string
}>({
dataType() {
return `tsvector`
},
})
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
normalizedEmail: text('normalized_email').unique(),
emailVerified: boolean('email_verified').notNull(),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
stripeCustomerId: text('stripe_customer_id'),
role: text('role').default('user'),
banned: boolean('banned').default(false),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
})
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
activeOrganizationId: text('active_organization_id').references(() => organization.id, {
onDelete: 'set null',
}),
impersonatedBy: text('impersonated_by'),
},
(table) => ({
userIdIdx: index('session_user_id_idx').on(table.userId),
tokenIdx: index('session_token_idx').on(table.token),
})
)
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
},
(table) => ({
userIdIdx: index('account_user_id_idx').on(table.userId),
accountProviderIdx: index('idx_account_on_account_id_provider_id').on(
table.accountId,
table.providerId
),
})
)
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at'),
updatedAt: timestamp('updated_at'),
},
(table) => ({
identifierIdx: index('verification_identifier_idx').on(table.identifier),
expiresAtIdx: index('verification_expires_at_idx').on(table.expiresAt),
})
)
export const workflowFolder = pgTable(
'workflow_folder',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
parentId: text('parent_id'), // Self-reference will be handled by foreign key constraint
color: text('color').default('#6B7280'),
isExpanded: boolean('is_expanded').notNull().default(true),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
userIdx: index('workflow_folder_user_idx').on(table.userId),
workspaceParentIdx: index('workflow_folder_workspace_parent_idx').on(
table.workspaceId,
table.parentId
),
parentSortIdx: index('workflow_folder_parent_sort_idx').on(table.parentId, table.sortOrder),
})
)
export const workflow = pgTable(
'workflow',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
folderId: text('folder_id').references(() => workflowFolder.id, { onDelete: 'set null' }),
sortOrder: integer('sort_order').notNull().default(0),
name: text('name').notNull(),
description: text('description'),
color: text('color').notNull().default('#3972F6'),
lastSynced: timestamp('last_synced').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false),
deployedAt: timestamp('deployed_at'),
isPublicApi: boolean('is_public_api').notNull().default(false),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),
variables: json('variables').default('{}'),
archivedAt: timestamp('archived_at'),
},
(table) => ({
userIdIdx: index('workflow_user_id_idx').on(table.userId),
workspaceIdIdx: index('workflow_workspace_id_idx').on(table.workspaceId),
userWorkspaceIdx: index('workflow_user_workspace_idx').on(table.userId, table.workspaceId),
workspaceFolderNameUnique: uniqueIndex('workflow_workspace_folder_name_active_unique')
.on(table.workspaceId, sql`coalesce(${table.folderId}, '')`, table.name)
.where(sql`${table.archivedAt} IS NULL`),
folderSortIdx: index('workflow_folder_sort_idx').on(table.folderId, table.sortOrder),
archivedAtIdx: index('workflow_archived_at_idx').on(table.archivedAt),
})
)
export const workflowBlocks = pgTable(
'workflow_blocks',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'starter', 'agent', 'api', 'function'
name: text('name').notNull(),
positionX: decimal('position_x').notNull(),
positionY: decimal('position_y').notNull(),
enabled: boolean('enabled').notNull().default(true),
horizontalHandles: boolean('horizontal_handles').notNull().default(true),
isWide: boolean('is_wide').notNull().default(false),
advancedMode: boolean('advanced_mode').notNull().default(false),
triggerMode: boolean('trigger_mode').notNull().default(false),
locked: boolean('locked').notNull().default(false),
height: decimal('height').notNull().default('0'),
subBlocks: jsonb('sub_blocks').notNull().default('{}'),
outputs: jsonb('outputs').notNull().default('{}'),
data: jsonb('data').default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_blocks_workflow_id_idx').on(table.workflowId),
typeIdx: index('workflow_blocks_type_idx').on(table.type),
})
)
export const workflowEdges = pgTable(
'workflow_edges',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
sourceBlockId: text('source_block_id')
.notNull()
.references(() => workflowBlocks.id, { onDelete: 'cascade' }),
targetBlockId: text('target_block_id')
.notNull()
.references(() => workflowBlocks.id, { onDelete: 'cascade' }),
sourceHandle: text('source_handle'),
targetHandle: text('target_handle'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_edges_workflow_id_idx').on(table.workflowId),
workflowSourceIdx: index('workflow_edges_workflow_source_idx').on(
table.workflowId,
table.sourceBlockId
),
workflowTargetIdx: index('workflow_edges_workflow_target_idx').on(
table.workflowId,
table.targetBlockId
),
})
)
export const workflowSubflows = pgTable(
'workflow_subflows',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'loop' or 'parallel'
config: jsonb('config').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_subflows_workflow_id_idx').on(table.workflowId),
workflowTypeIdx: index('workflow_subflows_workflow_type_idx').on(table.workflowId, table.type),
})
)
export const waitlist = pgTable('waitlist', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
status: text('status').notNull().default('pending'), // pending, approved, rejected
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workflowExecutionSnapshots = pgTable(
'workflow_execution_snapshots',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
stateHash: text('state_hash').notNull(),
stateData: jsonb('state_data').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_snapshots_workflow_id_idx').on(table.workflowId),
stateHashIdx: index('workflow_snapshots_hash_idx').on(table.stateHash),
workflowHashUnique: uniqueIndex('workflow_snapshots_workflow_hash_idx').on(
table.workflowId,
table.stateHash
),
createdAtIdx: index('workflow_snapshots_created_at_idx').on(table.createdAt),
})
)
export const workflowExecutionLogs = pgTable(
'workflow_execution_logs',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
stateSnapshotId: text('state_snapshot_id')
.notNull()
.references(() => workflowExecutionSnapshots.id),
deploymentVersionId: text('deployment_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'set null' }
),
level: text('level').notNull(), // 'info' | 'error'
status: text('status').notNull().default('running'), // 'running' | 'pending' | 'completed' | 'failed' | 'cancelled'
trigger: text('trigger').notNull(), // 'api' | 'webhook' | 'schedule' | 'manual' | 'chat'
startedAt: timestamp('started_at').notNull(),
endedAt: timestamp('ended_at'),
totalDurationMs: integer('total_duration_ms'),
executionData: jsonb('execution_data').notNull().default('{}'),
cost: jsonb('cost'),
files: jsonb('files'), // File metadata for execution files
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('workflow_execution_logs_workflow_id_idx').on(table.workflowId),
stateSnapshotIdIdx: index('workflow_execution_logs_state_snapshot_id_idx').on(
table.stateSnapshotId
),
deploymentVersionIdIdx: index('workflow_execution_logs_deployment_version_id_idx').on(
table.deploymentVersionId
),
triggerIdx: index('workflow_execution_logs_trigger_idx').on(table.trigger),
levelIdx: index('workflow_execution_logs_level_idx').on(table.level),
startedAtIdx: index('workflow_execution_logs_started_at_idx').on(table.startedAt),
executionIdUnique: uniqueIndex('workflow_execution_logs_execution_id_unique').on(
table.executionId
),
workflowStartedAtIdx: index('workflow_execution_logs_workflow_started_at_idx').on(
table.workflowId,
table.startedAt
),
workspaceStartedAtIdx: index('workflow_execution_logs_workspace_started_at_idx').on(
table.workspaceId,
table.startedAt
),
runningStartedAtIdx: index('workflow_execution_logs_running_started_at_idx')
.on(table.startedAt)
.where(sql`status = 'running'`),
})
)
export const pausedExecutions = pgTable(
'paused_executions',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
executionSnapshot: jsonb('execution_snapshot').notNull(),
pausePoints: jsonb('pause_points').notNull(),
totalPauseCount: integer('total_pause_count').notNull(),
resumedCount: integer('resumed_count').notNull().default(0),
status: text('status').notNull().default('paused'),
metadata: jsonb('metadata').notNull().default(sql`'{}'::jsonb`),
pausedAt: timestamp('paused_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
expiresAt: timestamp('expires_at'),
},
(table) => ({
workflowIdx: index('paused_executions_workflow_id_idx').on(table.workflowId),
statusIdx: index('paused_executions_status_idx').on(table.status),
executionUnique: uniqueIndex('paused_executions_execution_id_unique').on(table.executionId),
})
)
export const resumeQueue = pgTable(
'resume_queue',
{
id: text('id').primaryKey(),
pausedExecutionId: text('paused_execution_id')
.notNull()
.references(() => pausedExecutions.id, { onDelete: 'cascade' }),
parentExecutionId: text('parent_execution_id').notNull(),
newExecutionId: text('new_execution_id').notNull(),
contextId: text('context_id').notNull(),
resumeInput: jsonb('resume_input'),
status: text('status').notNull().default('pending'),
queuedAt: timestamp('queued_at').notNull().defaultNow(),
claimedAt: timestamp('claimed_at'),
completedAt: timestamp('completed_at'),
failureReason: text('failure_reason'),
},
(table) => ({
parentStatusIdx: index('resume_queue_parent_status_idx').on(
table.parentExecutionId,
table.status,
table.queuedAt
),
newExecutionIdx: index('resume_queue_new_execution_idx').on(table.newExecutionId),
})
)
export const environment = pgTable('environment', {
id: text('id').primaryKey(), // Use the user id as the key
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One environment per user
variables: json('variables').notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workspaceEnvironment = pgTable(
'workspace_environment',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
variables: json('variables').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceUnique: uniqueIndex('workspace_environment_workspace_unique').on(table.workspaceId),
})
)
export const workspaceBYOKKeys = pgTable(
'workspace_byok_keys',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
encryptedApiKey: text('encrypted_api_key').notNull(),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceProviderUnique: uniqueIndex('workspace_byok_provider_unique').on(
table.workspaceId,
table.providerId
),
workspaceIdx: index('workspace_byok_workspace_idx').on(table.workspaceId),
})
)
export const settings = pgTable('settings', {
id: text('id').primaryKey(), // Use the user id as the key
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One settings record per user
// General settings
theme: text('theme').notNull().default('system'),
autoConnect: boolean('auto_connect').notNull().default(true),
// Privacy settings
telemetryEnabled: boolean('telemetry_enabled').notNull().default(true),
// Email preferences
emailPreferences: json('email_preferences').notNull().default('{}'),
// Billing usage notifications preference
billingUsageNotificationsEnabled: boolean('billing_usage_notifications_enabled')
.notNull()
.default(true),
// UI preferences
showTrainingControls: boolean('show_training_controls').notNull().default(false),
superUserModeEnabled: boolean('super_user_mode_enabled').notNull().default(true),
// Notification preferences
errorNotificationsEnabled: boolean('error_notifications_enabled').notNull().default(true),
// Canvas preferences
snapToGridSize: integer('snap_to_grid_size').notNull().default(0), // 0 = off, 10-50 = grid size
showActionBar: boolean('show_action_bar').notNull().default(true),
// Copilot preferences - maps model_id to enabled/disabled boolean
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),
// Copilot auto-allowed integration tools - array of tool IDs that can run without confirmation
copilotAutoAllowedTools: jsonb('copilot_auto_allowed_tools').notNull().default('[]'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workflowSchedule = pgTable(
'workflow_schedule',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
deploymentVersionId: text('deployment_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'cascade' }
),
blockId: text('block_id'),
cronExpression: text('cron_expression'),
nextRunAt: timestamp('next_run_at'),
lastRanAt: timestamp('last_ran_at'),
lastQueuedAt: timestamp('last_queued_at'),
triggerType: text('trigger_type').notNull(), // "manual", "webhook", "schedule"
timezone: text('timezone').notNull().default('UTC'),
failedCount: integer('failed_count').notNull().default(0),
status: text('status').notNull().default('active'), // 'active', 'disabled', or 'completed'
lastFailedAt: timestamp('last_failed_at'),
sourceType: text('source_type').notNull().default('workflow'), // 'workflow' or 'job'
jobTitle: text('job_title'),
prompt: text('prompt'),
lifecycle: text('lifecycle').notNull().default('persistent'), // 'persistent' or 'until_complete'
successCondition: text('success_condition'),
maxRuns: integer('max_runs'),
runCount: integer('run_count').notNull().default(0),
sourceChatId: text('source_chat_id'),
sourceTaskName: text('source_task_name'),
sourceUserId: text('source_user_id').references(() => user.id, { onDelete: 'cascade' }),
sourceWorkspaceId: text('source_workspace_id').references(() => workspace.id, {
onDelete: 'cascade',
}),
jobHistory: jsonb('job_history').$type<Array<{ timestamp: string; summary: string }>>(),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
workflowBlockUnique: uniqueIndex('workflow_schedule_workflow_block_deployment_unique')
.on(table.workflowId, table.blockId, table.deploymentVersionId)
.where(sql`${table.archivedAt} IS NULL`),
workflowDeploymentIdx: index('workflow_schedule_workflow_deployment_idx').on(
table.workflowId,
table.deploymentVersionId
),
archivedAtIdx: index('workflow_schedule_archived_at_idx').on(table.archivedAt),
}
}
)
export const jobExecutionLogs = pgTable(
'job_execution_logs',
{
id: text('id').primaryKey(),
scheduleId: text('schedule_id').references(() => workflowSchedule.id, { onDelete: 'set null' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
level: text('level').notNull(),
status: text('status').notNull().default('running'),
trigger: text('trigger').notNull(),
startedAt: timestamp('started_at').notNull(),
endedAt: timestamp('ended_at'),
totalDurationMs: integer('total_duration_ms'),
executionData: jsonb('execution_data').notNull().default('{}'),
cost: jsonb('cost'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
scheduleIdIdx: index('job_execution_logs_schedule_id_idx').on(table.scheduleId),
workspaceStartedAtIdx: index('job_execution_logs_workspace_started_at_idx').on(
table.workspaceId,
table.startedAt
),
executionIdUnique: uniqueIndex('job_execution_logs_execution_id_unique').on(table.executionId),
triggerIdx: index('job_execution_logs_trigger_idx').on(table.trigger),
})
)
export const webhook = pgTable(
'webhook',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
deploymentVersionId: text('deployment_version_id').references(
() => workflowDeploymentVersion.id,
{ onDelete: 'cascade' }
),
blockId: text('block_id'),
path: text('path').notNull(),
provider: text('provider'), // e.g., "whatsapp", "github", etc.
providerConfig: json('provider_config'), // Store provider-specific configuration
isActive: boolean('is_active').notNull().default(true),
failedCount: integer('failed_count').default(0), // Track consecutive failures
lastFailedAt: timestamp('last_failed_at'), // When the webhook last failed
credentialSetId: text('credential_set_id').references(() => credentialSet.id, {
onDelete: 'set null',
}), // For credential set webhooks - enables efficient queries
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
// Ensure webhook paths are unique per deployment version
pathIdx: uniqueIndex('path_deployment_unique')
.on(table.path, table.deploymentVersionId)
.where(sql`${table.archivedAt} IS NULL`),
// Optimize queries for webhooks by workflow and block
workflowBlockIdx: index('idx_webhook_on_workflow_id_block_id').on(
table.workflowId,
table.blockId
),
workflowDeploymentIdx: index('webhook_workflow_deployment_idx').on(
table.workflowId,
table.deploymentVersionId
),
// Optimize queries for credential set webhooks
credentialSetIdIdx: index('webhook_credential_set_id_idx').on(table.credentialSetId),
archivedAtIdx: index('webhook_archived_at_idx').on(table.archivedAt),
}
}
)
export const notificationTypeEnum = pgEnum('notification_type', ['webhook', 'email', 'slack'])
export const notificationDeliveryStatusEnum = pgEnum('notification_delivery_status', [
'pending',
'in_progress',
'success',
'failed',
])
export const workspaceNotificationSubscription = pgTable(
'workspace_notification_subscription',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
notificationType: notificationTypeEnum('notification_type').notNull(),
workflowIds: text('workflow_ids').array().notNull().default(sql`'{}'::text[]`),
allWorkflows: boolean('all_workflows').notNull().default(false),
levelFilter: text('level_filter')
.array()
.notNull()
.default(sql`ARRAY['info', 'error']::text[]`),
triggerFilter: text('trigger_filter')
.array()
.notNull()
.default(sql`ARRAY['api', 'webhook', 'schedule', 'manual', 'chat']::text[]`),
includeFinalOutput: boolean('include_final_output').notNull().default(false),
includeTraceSpans: boolean('include_trace_spans').notNull().default(false),
includeRateLimits: boolean('include_rate_limits').notNull().default(false),
includeUsageData: boolean('include_usage_data').notNull().default(false),
// Channel-specific configuration
webhookConfig: jsonb('webhook_config'),
emailRecipients: text('email_recipients').array(),
slackConfig: jsonb('slack_config'),
// Alert rule configuration (if null, sends on every execution)
alertConfig: jsonb('alert_config'),
lastAlertAt: timestamp('last_alert_at'),
active: boolean('active').notNull().default(true),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('workspace_notification_workspace_id_idx').on(table.workspaceId),
activeIdx: index('workspace_notification_active_idx').on(table.active),
typeIdx: index('workspace_notification_type_idx').on(table.notificationType),
})
)
export const workspaceNotificationDelivery = pgTable(
'workspace_notification_delivery',
{
id: text('id').primaryKey(),
subscriptionId: text('subscription_id')
.notNull()
.references(() => workspaceNotificationSubscription.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
executionId: text('execution_id').notNull(),
status: notificationDeliveryStatusEnum('status').notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
lastAttemptAt: timestamp('last_attempt_at'),
nextAttemptAt: timestamp('next_attempt_at'),
responseStatus: integer('response_status'),
responseBody: text('response_body'),
errorMessage: text('error_message'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
subscriptionIdIdx: index('workspace_notification_delivery_subscription_id_idx').on(
table.subscriptionId
),
executionIdIdx: index('workspace_notification_delivery_execution_id_idx').on(table.executionId),
statusIdx: index('workspace_notification_delivery_status_idx').on(table.status),
nextAttemptIdx: index('workspace_notification_delivery_next_attempt_idx').on(
table.nextAttemptAt
),
})
)
export const apiKey = pgTable(
'api_key',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), // Only set for workspace keys
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
key: text('key').notNull().unique(),
type: text('type').notNull().default('personal'),
lastUsed: timestamp('last_used'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
expiresAt: timestamp('expires_at'),
},
(table) => ({
workspaceTypeCheck: check(
'workspace_type_check',
sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)`
),
workspaceTypeIdx: index('api_key_workspace_type_idx').on(table.workspaceId, table.type),
userTypeIdx: index('api_key_user_type_idx').on(table.userId, table.type),
})
)
export const billingBlockedReasonEnum = pgEnum('billing_blocked_reason', [
'payment_failed',
'dispute',
])
export const userStats = pgTable('user_stats', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
.unique(), // One record per user
totalManualExecutions: integer('total_manual_executions').notNull().default(0),
totalApiCalls: integer('total_api_calls').notNull().default(0),
totalWebhookTriggers: integer('total_webhook_triggers').notNull().default(0),
totalScheduledExecutions: integer('total_scheduled_executions').notNull().default(0),
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
totalMcpExecutions: integer('total_mcp_executions').notNull().default(0),
totalA2aExecutions: integer('total_a2a_executions').notNull().default(0),
totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).notNull().default(0),
totalCost: decimal('total_cost').notNull().default('0'),
currentUsageLimit: decimal('current_usage_limit').default(DEFAULT_FREE_CREDITS.toString()), // Default $5 (1,000 credits) for free plan, null for team/enterprise
usageLimitUpdatedAt: timestamp('usage_limit_updated_at').defaultNow(),
// Billing period tracking
currentPeriodCost: decimal('current_period_cost').notNull().default('0'), // Usage in current billing period
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing
// Pro usage snapshot when joining a team (to prevent double-billing)
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
// Pre-purchased credits (for Pro users only)
creditBalance: decimal('credit_balance').notNull().default('0'),
// Copilot usage tracking
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0),
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
// MCP Copilot usage tracking
totalMcpCopilotCalls: integer('total_mcp_copilot_calls').notNull().default(0),
totalMcpCopilotCost: decimal('total_mcp_copilot_cost').notNull().default('0'),
currentPeriodMcpCopilotCost: decimal('current_period_mcp_copilot_cost').notNull().default('0'),
// Storage tracking (for free/pro users)
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
lastActive: timestamp('last_active').notNull().defaultNow(),
billingBlocked: boolean('billing_blocked').notNull().default(false),
billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'),
})
export const customTools = pgTable(
'custom_tools',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
title: text('title').notNull(),
schema: json('schema').notNull(),
code: text('code').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('custom_tools_workspace_id_idx').on(table.workspaceId),
workspaceTitleUnique: uniqueIndex('custom_tools_workspace_title_unique').on(
table.workspaceId,
table.title
),
})
)
export const skill = pgTable(
'skill',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description').notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceNameUnique: uniqueIndex('skill_workspace_name_unique').on(
table.workspaceId,
table.name
),
})
)
export const subscription = pgTable(
'subscription',
{
id: text('id').primaryKey(),
plan: text('plan').notNull(),
referenceId: text('reference_id').notNull(),
stripeCustomerId: text('stripe_customer_id'),
stripeSubscriptionId: text('stripe_subscription_id'),
status: text('status'),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end'),
seats: integer('seats'),
trialStart: timestamp('trial_start'),
trialEnd: timestamp('trial_end'),
metadata: json('metadata'),
},
(table) => ({
referenceStatusIdx: index('subscription_reference_status_idx').on(
table.referenceId,
table.status
),
enterpriseMetadataCheck: check(
'check_enterprise_metadata',
sql`plan != 'enterprise' OR metadata IS NOT NULL`
),
})
)
export const rateLimitBucket = pgTable('rate_limit_bucket', {
key: text('key').primaryKey(),
tokens: decimal('tokens').notNull(),
lastRefillAt: timestamp('last_refill_at').notNull(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const chat = pgTable(
'chat',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
identifier: text('identifier').notNull(),
title: text('title').notNull(),
description: text('description'),
isActive: boolean('is_active').notNull().default(true),
customizations: json('customizations').default('{}'), // For UI customization options
// Authentication options
authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email', 'sso'
password: text('password'), // Stored hashed, populated when authType is 'password'
allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains when authType is 'email' or 'sso'
// Output configuration
outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => {
return {
// Ensure identifiers are unique
identifierIdx: uniqueIndex('identifier_idx')
.on(table.identifier)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtIdx: index('chat_archived_at_idx').on(table.archivedAt),
}
}
)
export const form = pgTable(
'form',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
identifier: text('identifier').notNull(),
title: text('title').notNull(),
description: text('description'),
isActive: boolean('is_active').notNull().default(true),
// UI/UX Customizations
// { primaryColor, welcomeMessage, thankYouTitle, thankYouMessage, logoUrl }
customizations: json('customizations').default('{}'),
// Authentication options (following chat pattern)
authType: text('auth_type').notNull().default('public'), // 'public', 'password', 'email'
password: text('password'), // Stored encrypted, populated when authType is 'password'
allowedEmails: json('allowed_emails').default('[]'), // Array of allowed emails or domains
// Branding
showBranding: boolean('show_branding').notNull().default(true),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
identifierIdx: uniqueIndex('form_identifier_idx')
.on(table.identifier)
.where(sql`${table.archivedAt} IS NULL`),
workflowIdIdx: index('form_workflow_id_idx').on(table.workflowId),
userIdIdx: index('form_user_id_idx').on(table.userId),
archivedAtIdx: index('form_archived_at_idx').on(table.archivedAt),
})
)
export const organization = pgTable('organization', {
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
logo: text('logo'),
metadata: json('metadata'),
orgUsageLimit: decimal('org_usage_limit'),
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
creditBalance: decimal('credit_balance').notNull().default('0'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const member = pgTable(
'member',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull(), // 'admin' or 'member' - team-level permissions only
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
userIdUnique: uniqueIndex('member_user_id_unique').on(table.userId), // Users can only belong to one org
organizationIdIdx: index('member_organization_id_idx').on(table.organizationId),
})
)
export const invitation = pgTable(
'invitation',
{
id: text('id').primaryKey(),
email: text('email').notNull(),
inviterId: text('inviter_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
status: text('status').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
},
(table) => ({
emailIdx: index('invitation_email_idx').on(table.email),
organizationIdIdx: index('invitation_organization_id_idx').on(table.organizationId),
})
)
export const workspace = pgTable('workspace', {
id: text('id').primaryKey(),
name: text('name').notNull(),
color: text('color').notNull().default('#33C482'),
ownerId: text('owner_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
billedAccountUserId: text('billed_account_user_id')
.notNull()
.references(() => user.id, { onDelete: 'no action' }),
allowPersonalApiKeys: boolean('allow_personal_api_keys').notNull().default(true),
inboxEnabled: boolean('inbox_enabled').notNull().default(false),
inboxAddress: text('inbox_address'),
inboxProviderId: text('inbox_provider_id'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const workspaceFile = pgTable(
'workspace_file',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
key: text('key').notNull().unique(),
size: integer('size').notNull(),
type: text('type').notNull(),
uploadedBy: text('uploaded_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
deletedAt: timestamp('deleted_at'),
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('workspace_file_workspace_id_idx').on(table.workspaceId),
keyIdx: index('workspace_file_key_idx').on(table.key),
deletedAtIdx: index('workspace_file_deleted_at_idx').on(table.deletedAt),
})
)
export const workspaceFiles = pgTable(
'workspace_files',
{
id: text('id').primaryKey(),
key: text('key').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
context: text('context').notNull(), // 'workspace', 'mothership', 'copilot', 'chat', 'knowledge-base', 'profile-pictures', 'general', 'execution'
chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'cascade' }),
originalName: text('original_name').notNull(),
contentType: text('content_type').notNull(),
size: integer('size').notNull(),
deletedAt: timestamp('deleted_at'),
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
},
(table) => ({
keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique')
.on(table.key)
.where(sql`${table.deletedAt} IS NULL`),
/** One active display name per workspace for workspace-scoped files (VFS / file picker). */
workspaceOriginalNameActiveUnique: uniqueIndex('workspace_files_workspace_name_active_unique')
.on(table.workspaceId, table.originalName)
.where(
sql`${table.deletedAt} IS NULL AND ${table.context} = 'workspace' AND ${table.workspaceId} IS NOT NULL`
),
keyIdx: index('workspace_files_key_idx').on(table.key),
userIdIdx: index('workspace_files_user_id_idx').on(table.userId),
workspaceIdIdx: index('workspace_files_workspace_id_idx').on(table.workspaceId),
contextIdx: index('workspace_files_context_idx').on(table.context),
chatIdIdx: index('workspace_files_chat_id_idx').on(table.chatId),
deletedAtIdx: index('workspace_files_deleted_at_idx').on(table.deletedAt),
})
)
export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read'])
export const workspaceInvitationStatusEnum = pgEnum('workspace_invitation_status', [
'pending',
'accepted',
'rejected',
'cancelled',
])
export type WorkspaceInvitationStatus = (typeof workspaceInvitationStatusEnum.enumValues)[number]
export const workspaceInvitation = pgTable('workspace_invitation', {
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
inviterId: text('inviter_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
role: text('role').notNull().default('member'),
status: workspaceInvitationStatusEnum('status').notNull().default('pending'),
token: text('token').notNull().unique(),
permissions: permissionTypeEnum('permissions').notNull().default('admin'),
orgInvitationId: text('org_invitation_id'),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const permissions = pgTable(
'permissions',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
entityType: text('entity_type').notNull(), // 'workspace', 'workflow', 'organization', etc.
entityId: text('entity_id').notNull(), // ID of the workspace, workflow, etc.
permissionType: permissionTypeEnum('permission_type').notNull(), // Use enum instead of text
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access pattern - get all permissions for a user
userIdIdx: index('permissions_user_id_idx').on(table.userId),
// Entity-based queries - get all users with permissions on an entity
entityIdx: index('permissions_entity_idx').on(table.entityType, table.entityId),
// User + entity type queries - get user's permissions for all workspaces
userEntityTypeIdx: index('permissions_user_entity_type_idx').on(table.userId, table.entityType),
// Specific permission checks - does user have specific permission on entity
userEntityPermissionIdx: index('permissions_user_entity_permission_idx').on(
table.userId,
table.entityType,
table.permissionType
),
// User + specific entity queries - get user's permissions for specific entity
userEntityIdx: index('permissions_user_entity_idx').on(
table.userId,
table.entityType,
table.entityId
),
// Uniqueness constraint - prevent duplicate permission rows (one permission per user/entity)
uniquePermissionConstraint: uniqueIndex('permissions_unique_constraint').on(
table.userId,
table.entityType,
table.entityId
),
})
)
export const memory = pgTable(
'memory',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
data: jsonb('data').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
deletedAt: timestamp('deleted_at'),
},
(table) => {
return {
keyIdx: index('memory_key_idx').on(table.key),
workspaceIdx: index('memory_workspace_idx').on(table.workspaceId),
uniqueKeyPerWorkspaceIdx: uniqueIndex('memory_workspace_key_idx').on(
table.workspaceId,
table.key
),
}
}
)
export const knowledgeBase = pgTable(
'knowledge_base',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id),
name: text('name').notNull(),
description: text('description'),
// Token tracking for usage
tokenCount: integer('token_count').notNull().default(0),
// Embedding configuration
embeddingModel: text('embedding_model').notNull().default('text-embedding-3-small'),
embeddingDimension: integer('embedding_dimension').notNull().default(1536),
// Chunking configuration stored as JSON for flexibility
chunkingConfig: json('chunking_config')
.notNull()
.default('{"maxSize": 1024, "minSize": 1, "overlap": 200}'),
// Soft delete support
deletedAt: timestamp('deleted_at'),
// Metadata and timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('kb_user_id_idx').on(table.userId),
workspaceIdIdx: index('kb_workspace_id_idx').on(table.workspaceId),
// Composite index for user's workspaces
userWorkspaceIdx: index('kb_user_workspace_idx').on(table.userId, table.workspaceId),
// Index for soft delete filtering
deletedAtIdx: index('kb_deleted_at_idx').on(table.deletedAt),
/** One active (non-deleted) name per workspace; matches user_table_definitions pattern */
workspaceNameActiveUnique: uniqueIndex('kb_workspace_name_active_unique')
.on(table.workspaceId, table.name)
.where(sql`${table.deletedAt} IS NULL`),
})
)
export const document = pgTable(
'document',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
// File information
filename: text('filename').notNull(),
fileUrl: text('file_url').notNull(),
fileSize: integer('file_size').notNull(), // Size in bytes
mimeType: text('mime_type').notNull(), // e.g., 'application/pdf', 'text/plain'
// Content statistics
chunkCount: integer('chunk_count').notNull().default(0),
tokenCount: integer('token_count').notNull().default(0),
characterCount: integer('character_count').notNull().default(0),
// Processing status
processingStatus: text('processing_status').notNull().default('pending'), // 'pending', 'processing', 'completed', 'failed'
processingStartedAt: timestamp('processing_started_at'),
processingCompletedAt: timestamp('processing_completed_at'),
processingError: text('processing_error'),
// Document state
enabled: boolean('enabled').notNull().default(true), // Enable/disable from knowledge base
archivedAt: timestamp('archived_at'), // Parent KB/workspace archive marker
deletedAt: timestamp('deleted_at'), // Soft delete
userExcluded: boolean('user_excluded').notNull().default(false), // User explicitly excluded — skip on sync
// Document tags for filtering (inherited by all chunks)
// Text tags (7 slots)
tag1: text('tag1'),
tag2: text('tag2'),
tag3: text('tag3'),
tag4: text('tag4'),
tag5: text('tag5'),
tag6: text('tag6'),
tag7: text('tag7'),
// Number tags (5 slots)
number1: doublePrecision('number1'),
number2: doublePrecision('number2'),
number3: doublePrecision('number3'),
number4: doublePrecision('number4'),
number5: doublePrecision('number5'),
// Date tags (2 slots)
date1: timestamp('date1'),
date2: timestamp('date2'),
// Boolean tags (3 slots)
boolean1: boolean('boolean1'),
boolean2: boolean('boolean2'),
boolean3: boolean('boolean3'),
// Connector-sourced document fields
connectorId: text('connector_id').references(() => knowledgeConnector.id, {
onDelete: 'set null',
}),
externalId: text('external_id'),
contentHash: text('content_hash'),
sourceUrl: text('source_url'),
// Timestamps
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
},
(table) => ({
// Primary access pattern - filter by knowledge base
knowledgeBaseIdIdx: index('doc_kb_id_idx').on(table.knowledgeBaseId),
// Search by filename
filenameIdx: index('doc_filename_idx').on(table.filename),
// Processing status filtering
processingStatusIdx: index('doc_processing_status_idx').on(
table.knowledgeBaseId,
table.processingStatus
),
// Connector document uniqueness (partial — only non-deleted rows)
connectorExternalIdIdx: uniqueIndex('doc_connector_external_id_idx')
.on(table.connectorId, table.externalId)
.where(sql`${table.deletedAt} IS NULL`),
// Sync engine: load all active docs for a connector
connectorIdIdx: index('doc_connector_id_idx').on(table.connectorId),
archivedAtIdx: index('doc_archived_at_idx').on(table.archivedAt),
deletedAtIdx: index('doc_deleted_at_idx').on(table.deletedAt),
// Text tag indexes
tag1Idx: index('doc_tag1_idx').on(table.tag1),
tag2Idx: index('doc_tag2_idx').on(table.tag2),
tag3Idx: index('doc_tag3_idx').on(table.tag3),
tag4Idx: index('doc_tag4_idx').on(table.tag4),
tag5Idx: index('doc_tag5_idx').on(table.tag5),
tag6Idx: index('doc_tag6_idx').on(table.tag6),
tag7Idx: index('doc_tag7_idx').on(table.tag7),
// Number tag indexes (5 slots)
number1Idx: index('doc_number1_idx').on(table.number1),
number2Idx: index('doc_number2_idx').on(table.number2),
number3Idx: index('doc_number3_idx').on(table.number3),
number4Idx: index('doc_number4_idx').on(table.number4),
number5Idx: index('doc_number5_idx').on(table.number5),
// Date tag indexes (2 slots)
date1Idx: index('doc_date1_idx').on(table.date1),
date2Idx: index('doc_date2_idx').on(table.date2),
// Boolean tag indexes (3 slots)
boolean1Idx: index('doc_boolean1_idx').on(table.boolean1),
boolean2Idx: index('doc_boolean2_idx').on(table.boolean2),
boolean3Idx: index('doc_boolean3_idx').on(table.boolean3),
})
)
export const knowledgeBaseTagDefinitions = pgTable(
'knowledge_base_tag_definitions',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
tagSlot: text('tag_slot', {
enum: TAG_SLOTS,
}).notNull(),
displayName: text('display_name').notNull(),
fieldType: text('field_type').notNull().default('text'), // 'text', future: 'date', 'number', 'range'
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Ensure unique tag slot per knowledge base
kbTagSlotIdx: uniqueIndex('kb_tag_definitions_kb_slot_idx').on(
table.knowledgeBaseId,
table.tagSlot
),
// Ensure unique display name per knowledge base
kbDisplayNameIdx: uniqueIndex('kb_tag_definitions_kb_display_name_idx').on(
table.knowledgeBaseId,
table.displayName
),
// Index for querying by knowledge base
kbIdIdx: index('kb_tag_definitions_kb_id_idx').on(table.knowledgeBaseId),
})
)
export const embedding = pgTable(
'embedding',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
documentId: text('document_id')
.notNull()
.references(() => document.id, { onDelete: 'cascade' }),
// Chunk information
chunkIndex: integer('chunk_index').notNull(),
chunkHash: text('chunk_hash').notNull(),
content: text('content').notNull(),
contentLength: integer('content_length').notNull(),
tokenCount: integer('token_count').notNull(),
// Vector embeddings - optimized for text-embedding-3-small with HNSW support
embedding: vector('embedding', { dimensions: 1536 }), // For text-embedding-3-small
embeddingModel: text('embedding_model').notNull().default('text-embedding-3-small'),
// Chunk boundaries and overlap
startOffset: integer('start_offset').notNull(),
endOffset: integer('end_offset').notNull(),
// Tag columns inherited from document for efficient filtering
// Text tags (7 slots)
tag1: text('tag1'),
tag2: text('tag2'),
tag3: text('tag3'),
tag4: text('tag4'),
tag5: text('tag5'),
tag6: text('tag6'),
tag7: text('tag7'),
// Number tags (5 slots)
number1: doublePrecision('number1'),
number2: doublePrecision('number2'),
number3: doublePrecision('number3'),
number4: doublePrecision('number4'),
number5: doublePrecision('number5'),
// Date tags (2 slots)
date1: timestamp('date1'),
date2: timestamp('date2'),
// Boolean tags (3 slots)
boolean1: boolean('boolean1'),
boolean2: boolean('boolean2'),
boolean3: boolean('boolean3'),
// Chunk state - enable/disable from knowledge base
enabled: boolean('enabled').notNull().default(true),
// Full-text search support - generated tsvector column
contentTsv: tsvector('content_tsv').generatedAlwaysAs(
(): SQL => sql`to_tsvector('english', ${embedding.content})`
),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary vector search pattern
kbIdIdx: index('emb_kb_id_idx').on(table.knowledgeBaseId),
// Document-level access
docIdIdx: index('emb_doc_id_idx').on(table.documentId),
// Chunk ordering within documents
docChunkIdx: uniqueIndex('emb_doc_chunk_idx').on(table.documentId, table.chunkIndex),
// Model-specific queries for A/B testing or migrations
kbModelIdx: index('emb_kb_model_idx').on(table.knowledgeBaseId, table.embeddingModel),
// Enabled state filtering indexes (for chunk enable/disable functionality)
kbEnabledIdx: index('emb_kb_enabled_idx').on(table.knowledgeBaseId, table.enabled),
docEnabledIdx: index('emb_doc_enabled_idx').on(table.documentId, table.enabled),
// Vector similarity search indexes (HNSW) - optimized for small embeddings
embeddingVectorHnswIdx: index('embedding_vector_hnsw_idx')
.using('hnsw', table.embedding.op('vector_cosine_ops'))
.with({
m: 16,
ef_construction: 64,
}),
// Text tag indexes
tag1Idx: index('emb_tag1_idx').on(table.tag1),
tag2Idx: index('emb_tag2_idx').on(table.tag2),
tag3Idx: index('emb_tag3_idx').on(table.tag3),
tag4Idx: index('emb_tag4_idx').on(table.tag4),
tag5Idx: index('emb_tag5_idx').on(table.tag5),
tag6Idx: index('emb_tag6_idx').on(table.tag6),
tag7Idx: index('emb_tag7_idx').on(table.tag7),
// Number tag indexes (5 slots)
number1Idx: index('emb_number1_idx').on(table.number1),
number2Idx: index('emb_number2_idx').on(table.number2),
number3Idx: index('emb_number3_idx').on(table.number3),
number4Idx: index('emb_number4_idx').on(table.number4),
number5Idx: index('emb_number5_idx').on(table.number5),
// Date tag indexes (2 slots)
date1Idx: index('emb_date1_idx').on(table.date1),
date2Idx: index('emb_date2_idx').on(table.date2),
// Boolean tag indexes (3 slots)
boolean1Idx: index('emb_boolean1_idx').on(table.boolean1),
boolean2Idx: index('emb_boolean2_idx').on(table.boolean2),
boolean3Idx: index('emb_boolean3_idx').on(table.boolean3),
// Full-text search index
contentFtsIdx: index('emb_content_fts_idx').using('gin', table.contentTsv),
// Ensure embedding exists (simplified since we only support one model)
embeddingNotNullCheck: check('embedding_not_null_check', sql`"embedding" IS NOT NULL`),
})
)
export const docsEmbeddings = pgTable(
'docs_embeddings',
{
chunkId: uuid('chunk_id').primaryKey().defaultRandom(),
chunkText: text('chunk_text').notNull(),
sourceDocument: text('source_document').notNull(),
sourceLink: text('source_link').notNull(),
headerText: text('header_text').notNull(),
headerLevel: integer('header_level').notNull(),
tokenCount: integer('token_count').notNull(),
// Vector embedding - optimized for text-embedding-3-small with HNSW support
embedding: vector('embedding', { dimensions: 1536 }).notNull(),
embeddingModel: text('embedding_model').notNull().default('text-embedding-3-small'),
// Metadata for flexible filtering
metadata: jsonb('metadata').notNull().default('{}'),
// Full-text search support - generated tsvector column
chunkTextTsv: tsvector('chunk_text_tsv').generatedAlwaysAs(
(): SQL => sql`to_tsvector('english', ${docsEmbeddings.chunkText})`
),
// Timestamps
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Source document queries
sourceDocumentIdx: index('docs_emb_source_document_idx').on(table.sourceDocument),
// Header level filtering
headerLevelIdx: index('docs_emb_header_level_idx').on(table.headerLevel),
// Combined source and header queries
sourceHeaderIdx: index('docs_emb_source_header_idx').on(
table.sourceDocument,
table.headerLevel
),
// Model-specific queries
modelIdx: index('docs_emb_model_idx').on(table.embeddingModel),
// Timestamp queries
createdAtIdx: index('docs_emb_created_at_idx').on(table.createdAt),
// Vector similarity search indexes (HNSW) - optimized for documentation embeddings
embeddingVectorHnswIdx: index('docs_embedding_vector_hnsw_idx')
.using('hnsw', table.embedding.op('vector_cosine_ops'))
.with({
m: 16,
ef_construction: 64,
}),
// GIN index for JSONB metadata queries
metadataGinIdx: index('docs_emb_metadata_gin_idx').using('gin', table.metadata),
// Full-text search index
chunkTextFtsIdx: index('docs_emb_chunk_text_fts_idx').using('gin', table.chunkTextTsv),
// Constraints
embeddingNotNullCheck: check('docs_embedding_not_null_check', sql`"embedding" IS NOT NULL`),
headerLevelCheck: check(
'docs_header_level_check',
sql`"header_level" >= 1 AND "header_level" <= 6`
),
})
)
export const chatTypeEnum = pgEnum('chat_type', ['mothership', 'copilot'])
export const copilotChats = pgTable(
'copilot_chats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
type: chatTypeEnum('type').notNull().default('copilot'),
title: text('title'),
messages: jsonb('messages').notNull().default('[]'),
model: text('model').notNull().default('claude-3-7-sonnet-latest'),
conversationId: text('conversation_id'),
previewYaml: text('preview_yaml'),
planArtifact: text('plan_artifact'),
config: jsonb('config'),
resources: jsonb('resources').notNull().default('[]'),
lastSeenAt: timestamp('last_seen_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('copilot_chats_user_id_idx').on(table.userId),
workflowIdIdx: index('copilot_chats_workflow_id_idx').on(table.workflowId),
userWorkflowIdx: index('copilot_chats_user_workflow_idx').on(table.userId, table.workflowId),
// Workspace access pattern
userWorkspaceIdx2: index('copilot_chats_user_workspace_idx').on(
table.userId,
table.workspaceId
),
// Ordering indexes
createdAtIdx: index('copilot_chats_created_at_idx').on(table.createdAt),
updatedAtIdx: index('copilot_chats_updated_at_idx').on(table.updatedAt),
})
)
export const copilotWorkflowReadHashes = pgTable(
'copilot_workflow_read_hashes',
{
id: uuid('id').primaryKey().defaultRandom(),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
hash: text('hash').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
chatIdIdx: index('copilot_workflow_read_hashes_chat_id_idx').on(table.chatId),
workflowIdIdx: index('copilot_workflow_read_hashes_workflow_id_idx').on(table.workflowId),
chatWorkflowUnique: uniqueIndex('copilot_workflow_read_hashes_chat_workflow_unique').on(
table.chatId,
table.workflowId
),
})
)
export const workflowCheckpoints = pgTable(
'workflow_checkpoints',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
messageId: text('message_id'), // ID of the user message that triggered this checkpoint
workflowState: json('workflow_state').notNull(), // JSON workflow state
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('workflow_checkpoints_user_id_idx').on(table.userId),
workflowIdIdx: index('workflow_checkpoints_workflow_id_idx').on(table.workflowId),
chatIdIdx: index('workflow_checkpoints_chat_id_idx').on(table.chatId),
messageIdIdx: index('workflow_checkpoints_message_id_idx').on(table.messageId),
// Combined indexes for common queries
userWorkflowIdx: index('workflow_checkpoints_user_workflow_idx').on(
table.userId,
table.workflowId
),
workflowChatIdx: index('workflow_checkpoints_workflow_chat_idx').on(
table.workflowId,
table.chatId
),
// Ordering indexes
createdAtIdx: index('workflow_checkpoints_created_at_idx').on(table.createdAt),
chatCreatedAtIdx: index('workflow_checkpoints_chat_created_at_idx').on(
table.chatId,
table.createdAt
),
})
)
export const copilotRunStatusEnum = pgEnum('copilot_run_status', [
'active',
'paused_waiting_for_tool',
'resuming',
'complete',
'error',
'cancelled',
])
export const copilotAsyncToolStatusEnum = pgEnum('copilot_async_tool_status', [
'pending',
'running',
'completed',
'failed',
'cancelled',
'delivered',
])
export type CopilotRunStatus = (typeof copilotRunStatusEnum.enumValues)[number]
export type CopilotAsyncToolStatus = (typeof copilotAsyncToolStatusEnum.enumValues)[number]
export const copilotRuns = pgTable(
'copilot_runs',
{
id: uuid('id').primaryKey().defaultRandom(),
executionId: text('execution_id').notNull(),
parentRunId: uuid('parent_run_id'),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }),
streamId: text('stream_id').notNull(),
agent: text('agent'),
model: text('model'),
provider: text('provider'),
status: copilotRunStatusEnum('status').notNull().default('active'),
requestContext: jsonb('request_context').notNull().default('{}'),
startedAt: timestamp('started_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
error: text('error'),
},
(table) => ({
executionIdIdx: index('copilot_runs_execution_id_idx').on(table.executionId),
parentRunIdIdx: index('copilot_runs_parent_run_id_idx').on(table.parentRunId),
chatIdIdx: index('copilot_runs_chat_id_idx').on(table.chatId),
userIdIdx: index('copilot_runs_user_id_idx').on(table.userId),
workflowIdIdx: index('copilot_runs_workflow_id_idx').on(table.workflowId),
workspaceIdIdx: index('copilot_runs_workspace_id_idx').on(table.workspaceId),
statusIdx: index('copilot_runs_status_idx').on(table.status),
chatExecutionIdx: index('copilot_runs_chat_execution_idx').on(table.chatId, table.executionId),
executionStartedAtIdx: index('copilot_runs_execution_started_at_idx').on(
table.executionId,
table.startedAt
),
streamIdUnique: uniqueIndex('copilot_runs_stream_id_unique').on(table.streamId),
})
)
export const copilotRunCheckpoints = pgTable(
'copilot_run_checkpoints',
{
id: uuid('id').primaryKey().defaultRandom(),
runId: uuid('run_id')
.notNull()
.references(() => copilotRuns.id, { onDelete: 'cascade' }),
pendingToolCallId: text('pending_tool_call_id').notNull(),
conversationSnapshot: jsonb('conversation_snapshot').notNull().default('{}'),
agentState: jsonb('agent_state').notNull().default('{}'),
providerRequest: jsonb('provider_request').notNull().default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
runIdIdx: index('copilot_run_checkpoints_run_id_idx').on(table.runId),
pendingToolCallIdIdx: index('copilot_run_checkpoints_pending_tool_call_id_idx').on(
table.pendingToolCallId
),
runPendingUnique: uniqueIndex('copilot_run_checkpoints_run_pending_tool_unique').on(
table.runId,
table.pendingToolCallId
),
})
)
export const copilotAsyncToolCalls = pgTable(
'copilot_async_tool_calls',
{
id: uuid('id').primaryKey().defaultRandom(),
runId: uuid('run_id')
.notNull()
.references(() => copilotRuns.id, { onDelete: 'cascade' }),
checkpointId: uuid('checkpoint_id').references(() => copilotRunCheckpoints.id, {
onDelete: 'cascade',
}),
toolCallId: text('tool_call_id').notNull(),
toolName: text('tool_name').notNull(),
args: jsonb('args').notNull().default('{}'),
status: copilotAsyncToolStatusEnum('status').notNull().default('pending'),
result: jsonb('result'),
error: text('error'),
claimedAt: timestamp('claimed_at'),
claimedBy: text('claimed_by'),
completedAt: timestamp('completed_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
runIdIdx: index('copilot_async_tool_calls_run_id_idx').on(table.runId),
checkpointIdIdx: index('copilot_async_tool_calls_checkpoint_id_idx').on(table.checkpointId),
toolCallIdIdx: index('copilot_async_tool_calls_tool_call_id_idx').on(table.toolCallId),
statusIdx: index('copilot_async_tool_calls_status_idx').on(table.status),
runStatusIdx: index('copilot_async_tool_calls_run_status_idx').on(table.runId, table.status),
toolCallUnique: uniqueIndex('copilot_async_tool_calls_tool_call_id_unique').on(
table.toolCallId
),
})
)
export const templateStatusEnum = pgEnum('template_status', ['pending', 'approved', 'rejected'])
export const templateCreatorTypeEnum = pgEnum('template_creator_type', ['user', 'organization'])
export const templateCreators = pgTable(
'template_creators',
{
id: text('id').primaryKey(),
referenceType: templateCreatorTypeEnum('reference_type').notNull(),
referenceId: text('reference_id').notNull(),
name: text('name').notNull(),
profileImageUrl: text('profile_image_url'),
details: jsonb('details'),
verified: boolean('verified').notNull().default(false),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
referenceUniqueIdx: uniqueIndex('template_creators_reference_idx').on(
table.referenceType,
table.referenceId
),
referenceIdIdx: index('template_creators_reference_id_idx').on(table.referenceId),
createdByIdx: index('template_creators_created_by_idx').on(table.createdBy),
})
)
export const templates = pgTable(
'templates',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
name: text('name').notNull(),
details: jsonb('details'),
creatorId: text('creator_id').references(() => templateCreators.id, { onDelete: 'set null' }),
views: integer('views').notNull().default(0),
stars: integer('stars').notNull().default(0),
status: templateStatusEnum('status').notNull().default('pending'),
tags: text('tags').array().notNull().default(sql`'{}'::text[]`), // Array of tags
requiredCredentials: jsonb('required_credentials').notNull().default('[]'), // Array of credential requirements
state: jsonb('state').notNull(), // Store the workflow state directly
ogImageUrl: text('og_image_url'), // Pre-generated OpenGraph image URL
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
statusIdx: index('templates_status_idx').on(table.status),
creatorIdIdx: index('templates_creator_id_idx').on(table.creatorId),
// Sorting indexes for popular/trending templates
viewsIdx: index('templates_views_idx').on(table.views),
starsIdx: index('templates_stars_idx').on(table.stars),
// Composite indexes for common queries
statusViewsIdx: index('templates_status_views_idx').on(table.status, table.views),
statusStarsIdx: index('templates_status_stars_idx').on(table.status, table.stars),
// Temporal indexes
createdAtIdx: index('templates_created_at_idx').on(table.createdAt),
updatedAtIdx: index('templates_updated_at_idx').on(table.updatedAt),
})
)
export const templateStars = pgTable(
'template_stars',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
templateId: text('template_id')
.notNull()
.references(() => templates.id, { onDelete: 'cascade' }),
starredAt: timestamp('starred_at').notNull().defaultNow(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
userIdIdx: index('template_stars_user_id_idx').on(table.userId),
templateIdIdx: index('template_stars_template_id_idx').on(table.templateId),
// Composite indexes for common queries
userTemplateIdx: index('template_stars_user_template_idx').on(table.userId, table.templateId),
templateUserIdx: index('template_stars_template_user_idx').on(table.templateId, table.userId),
// Temporal indexes for analytics
starredAtIdx: index('template_stars_starred_at_idx').on(table.starredAt),
templateStarredAtIdx: index('template_stars_template_starred_at_idx').on(
table.templateId,
table.starredAt
),
// Uniqueness constraint - prevent duplicate stars
uniqueUserTemplateConstraint: uniqueIndex('template_stars_user_template_unique').on(
table.userId,
table.templateId
),
})
)
export const copilotFeedback = pgTable(
'copilot_feedback',
{
feedbackId: uuid('feedback_id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id')
.notNull()
.references(() => copilotChats.id, { onDelete: 'cascade' }),
userQuery: text('user_query').notNull(),
agentResponse: text('agent_response').notNull(),
isPositive: boolean('is_positive').notNull(),
feedback: text('feedback'), // Optional feedback text
workflowYaml: text('workflow_yaml'), // Optional workflow YAML if edit/build workflow was triggered
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Access patterns
userIdIdx: index('copilot_feedback_user_id_idx').on(table.userId),
chatIdIdx: index('copilot_feedback_chat_id_idx').on(table.chatId),
userChatIdx: index('copilot_feedback_user_chat_idx').on(table.userId, table.chatId),
// Query patterns
isPositiveIdx: index('copilot_feedback_is_positive_idx').on(table.isPositive),
// Ordering indexes
createdAtIdx: index('copilot_feedback_created_at_idx').on(table.createdAt),
})
)
// Tracks immutable deployment versions for each workflow
export const workflowDeploymentVersion = pgTable(
'workflow_deployment_version',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
version: integer('version').notNull(),
name: text('name'),
description: text('description'),
state: json('state').notNull(),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
createdBy: text('created_by'),
},
(table) => ({
workflowVersionUnique: uniqueIndex('workflow_deployment_version_workflow_version_unique').on(
table.workflowId,
table.version
),
workflowActiveIdx: index('workflow_deployment_version_workflow_active_idx').on(
table.workflowId,
table.isActive
),
createdAtIdx: index('workflow_deployment_version_created_at_idx').on(table.createdAt),
})
)
// Idempotency keys for preventing duplicate processing across all webhooks and triggers
export const idempotencyKey = pgTable(
'idempotency_key',
{
key: text('key').primaryKey(),
result: json('result').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
// Index for cleanup operations by creation time
createdAtIdx: index('idempotency_key_created_at_idx').on(table.createdAt),
})
)
export const mcpServers = pgTable(
'mcp_servers',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
// Track who created the server, but workspace owns it
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description'),
transport: text('transport').notNull(),
url: text('url'),
headers: json('headers').default('{}'),
timeout: integer('timeout').default(30000),
retries: integer('retries').default(3),
enabled: boolean('enabled').notNull().default(true),
lastConnected: timestamp('last_connected'),
connectionStatus: text('connection_status').default('disconnected'),
lastError: text('last_error'),
statusConfig: jsonb('status_config').default('{}'),
toolCount: integer('tool_count').default(0),
lastToolsRefresh: timestamp('last_tools_refresh'),
totalRequests: integer('total_requests').default(0),
lastUsed: timestamp('last_used'),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access pattern - active servers by workspace
workspaceEnabledIdx: index('mcp_servers_workspace_enabled_idx').on(
table.workspaceId,
table.enabled
),
// Soft delete pattern - workspace + not deleted
workspaceDeletedIdx: index('mcp_servers_workspace_deleted_idx').on(
table.workspaceId,
table.deletedAt
),
})
)
// SSO Provider table
export const ssoProvider = pgTable(
'sso_provider',
{
id: text('id').primaryKey(),
issuer: text('issuer').notNull(),
domain: text('domain').notNull(),
oidcConfig: text('oidc_config'),
samlConfig: text('saml_config'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
organizationId: text('organization_id').references(() => organization.id, {
onDelete: 'cascade',
}),
},
(table) => ({
providerIdIdx: index('sso_provider_provider_id_idx').on(table.providerId),
domainIdx: index('sso_provider_domain_idx').on(table.domain),
userIdIdx: index('sso_provider_user_id_idx').on(table.userId),
organizationIdIdx: index('sso_provider_organization_id_idx').on(table.organizationId),
})
)
/**
* Workflow MCP Servers - User-created MCP servers that expose workflows as tools.
* These servers are accessible by external MCP clients via API key authentication,
* or publicly if isPublic is set to true.
*/
export const workflowMcpServer = pgTable(
'workflow_mcp_server',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
isPublic: boolean('is_public').notNull().default(false),
deletedAt: timestamp('deleted_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('workflow_mcp_server_workspace_id_idx').on(table.workspaceId),
createdByIdx: index('workflow_mcp_server_created_by_idx').on(table.createdBy),
deletedAtIdx: index('workflow_mcp_server_deleted_at_idx').on(table.deletedAt),
})
)
/**
* Workflow MCP Tools - Workflows registered as tools within a Workflow MCP Server.
* Each tool maps to a deployed workflow's execute endpoint.
*/
export const workflowMcpTool = pgTable(
'workflow_mcp_tool',
{
id: text('id').primaryKey(),
serverId: text('server_id')
.notNull()
.references(() => workflowMcpServer.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
toolName: text('tool_name').notNull(),
toolDescription: text('tool_description'),
parameterSchema: json('parameter_schema').notNull().default('{}'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
serverIdIdx: index('workflow_mcp_tool_server_id_idx').on(table.serverId),
workflowIdIdx: index('workflow_mcp_tool_workflow_id_idx').on(table.workflowId),
serverWorkflowUnique: uniqueIndex('workflow_mcp_tool_server_workflow_unique')
.on(table.serverId, table.workflowId)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtIdx: index('workflow_mcp_tool_archived_at_idx').on(table.archivedAt),
})
)
/**
* A2A Task State Enum (v0.2.6)
*/
export const a2aTaskStatusEnum = pgEnum('a2a_task_status', [
'submitted',
'working',
'input-required',
'completed',
'failed',
'canceled',
'rejected',
'auth-required',
'unknown',
])
/**
* A2A Agents - Workflows exposed as A2A-compatible agents
* These agents can be called by external A2A clients
*/
export const a2aAgent = pgTable(
'a2a_agent',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id')
.notNull()
.references(() => workflow.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/** Agent name (used in Agent Card) */
name: text('name').notNull(),
/** Agent description */
description: text('description'),
/** Agent version */
version: text('version').notNull().default('1.0.0'),
/** Agent capabilities (streaming, pushNotifications, etc.) */
capabilities: jsonb('capabilities').notNull().default('{}'),
/** Agent skills derived from workflow */
skills: jsonb('skills').notNull().default('[]'),
/** Authentication configuration */
authentication: jsonb('authentication').notNull().default('{}'),
/** Agent card signatures for verification (v0.3) */
signatures: jsonb('signatures').default('[]'),
/** Whether the agent is published and discoverable */
isPublished: boolean('is_published').notNull().default(false),
/** When the agent was published */
publishedAt: timestamp('published_at'),
archivedAt: timestamp('archived_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workflowIdIdx: index('a2a_agent_workflow_id_idx').on(table.workflowId),
createdByIdx: index('a2a_agent_created_by_idx').on(table.createdBy),
workspaceWorkflowUnique: uniqueIndex('a2a_agent_workspace_workflow_unique')
.on(table.workspaceId, table.workflowId)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtIdx: index('a2a_agent_archived_at_idx').on(table.archivedAt),
})
)
/**
* A2A Tasks - Tracks task state for A2A agent interactions (v0.3)
* Each task represents a conversation/interaction with an agent
*/
export const a2aTask = pgTable(
'a2a_task',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => a2aAgent.id, { onDelete: 'cascade' }),
/** Context ID for multi-turn conversations (maps to API contextId) */
sessionId: text('session_id'),
/** Task state */
status: a2aTaskStatusEnum('status').notNull().default('submitted'),
/** Message history (maps to API history, array of TaskMessage) */
messages: jsonb('messages').notNull().default('[]'),
/** Structured output artifacts */
artifacts: jsonb('artifacts').default('[]'),
/** Link to workflow execution */
executionId: text('execution_id'),
/** Additional metadata */
metadata: jsonb('metadata').default('{}'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
},
(table) => ({
agentIdIdx: index('a2a_task_agent_id_idx').on(table.agentId),
sessionIdIdx: index('a2a_task_session_id_idx').on(table.sessionId),
statusIdx: index('a2a_task_status_idx').on(table.status),
executionIdIdx: index('a2a_task_execution_id_idx').on(table.executionId),
createdAtIdx: index('a2a_task_created_at_idx').on(table.createdAt),
})
)
/**
* A2A Push Notification Config - Webhook configuration for task updates
* Stores push notification webhooks for async task updates
*/
export const a2aPushNotificationConfig = pgTable(
'a2a_push_notification_config',
{
id: text('id').primaryKey(),
taskId: text('task_id')
.notNull()
.references(() => a2aTask.id, { onDelete: 'cascade' }),
/** Webhook URL for notifications */
url: text('url').notNull(),
/** Optional token for client-side validation */
token: text('token'),
/** Authentication schemes (e.g., ['bearer', 'apiKey']) */
authSchemes: jsonb('auth_schemes').default('[]'),
/** Authentication credentials hint */
authCredentials: text('auth_credentials'),
/** Whether this config is active */
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
taskIdUnique: uniqueIndex('a2a_push_notification_config_task_unique').on(table.taskId),
})
)
export const auditLog = pgTable(
'audit_log',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'set null' }),
actorId: text('actor_id').references(() => user.id, { onDelete: 'set null' }),
action: text('action').notNull(),
resourceType: text('resource_type').notNull(),
resourceId: text('resource_id'),
actorName: text('actor_name'),
actorEmail: text('actor_email'),
resourceName: text('resource_name'),
description: text('description'),
metadata: jsonb('metadata').default('{}'),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
workspaceCreatedIdx: index('audit_log_workspace_created_idx').on(
table.workspaceId,
table.createdAt
),
actorCreatedIdx: index('audit_log_actor_created_idx').on(table.actorId, table.createdAt),
resourceIdx: index('audit_log_resource_idx').on(table.resourceType, table.resourceId),
actionIdx: index('audit_log_action_idx').on(table.action),
})
)
export const usageLogCategoryEnum = pgEnum('usage_log_category', ['model', 'fixed'])
export const usageLogSourceEnum = pgEnum('usage_log_source', [
'workflow',
'wand',
'copilot',
'workspace-chat',
'mcp_copilot',
'mothership_block',
])
export const usageLog = pgTable(
'usage_log',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
category: usageLogCategoryEnum('category').notNull(),
source: usageLogSourceEnum('source').notNull(),
description: text('description').notNull(),
metadata: jsonb('metadata'),
cost: decimal('cost').notNull(),
workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'set null' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
executionId: text('execution_id'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
userCreatedAtIdx: index('usage_log_user_created_at_idx').on(table.userId, table.createdAt),
sourceIdx: index('usage_log_source_idx').on(table.source),
workspaceIdIdx: index('usage_log_workspace_id_idx').on(table.workspaceId),
workflowIdIdx: index('usage_log_workflow_id_idx').on(table.workflowId),
})
)
export const credentialTypeEnum = pgEnum('credential_type', [
'oauth',
'env_workspace',
'env_personal',
])
export const credential = pgTable(
'credential',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
type: credentialTypeEnum('type').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
providerId: text('provider_id'),
accountId: text('account_id').references(() => account.id, { onDelete: 'cascade' }),
envKey: text('env_key'),
envOwnerUserId: text('env_owner_user_id').references(() => user.id, { onDelete: 'cascade' }),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('credential_workspace_id_idx').on(table.workspaceId),
typeIdx: index('credential_type_idx').on(table.type),
providerIdIdx: index('credential_provider_id_idx').on(table.providerId),
accountIdIdx: index('credential_account_id_idx').on(table.accountId),
envOwnerUserIdIdx: index('credential_env_owner_user_id_idx').on(table.envOwnerUserId),
workspaceAccountUnique: uniqueIndex('credential_workspace_account_unique')
.on(table.workspaceId, table.accountId)
.where(sql`account_id IS NOT NULL`),
workspaceEnvUnique: uniqueIndex('credential_workspace_env_unique')
.on(table.workspaceId, table.type, table.envKey)
.where(sql`type = 'env_workspace'`),
workspacePersonalEnvUnique: uniqueIndex('credential_workspace_personal_env_unique')
.on(table.workspaceId, table.type, table.envKey, table.envOwnerUserId)
.where(sql`type = 'env_personal'`),
oauthSourceConstraint: check(
'credential_oauth_source_check',
sql`(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)`
),
workspaceEnvSourceConstraint: check(
'credential_workspace_env_source_check',
sql`(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)`
),
personalEnvSourceConstraint: check(
'credential_personal_env_source_check',
sql`(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)`
),
})
)
export const credentialMemberRoleEnum = pgEnum('credential_member_role', ['admin', 'member'])
export const credentialMemberStatusEnum = pgEnum('credential_member_status', [
'active',
'pending',
'revoked',
])
export const credentialMember = pgTable(
'credential_member',
{
id: text('id').primaryKey(),
credentialId: text('credential_id')
.notNull()
.references(() => credential.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
role: credentialMemberRoleEnum('role').notNull().default('member'),
status: credentialMemberStatusEnum('status').notNull().default('active'),
joinedAt: timestamp('joined_at'),
invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('credential_member_user_id_idx').on(table.userId),
roleIdx: index('credential_member_role_idx').on(table.role),
statusIdx: index('credential_member_status_idx').on(table.status),
uniqueMembership: uniqueIndex('credential_member_unique').on(table.credentialId, table.userId),
})
)
export const pendingCredentialDraft = pgTable(
'pending_credential_draft',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
providerId: text('provider_id').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
credentialId: text('credential_id').references(() => credential.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
uniqueDraft: uniqueIndex('pending_draft_user_provider_ws').on(
table.userId,
table.providerId,
table.workspaceId
),
})
)
export const credentialSet = pgTable(
'credential_set',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
providerId: text('provider_id').notNull(),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
createdByIdx: index('credential_set_created_by_idx').on(table.createdBy),
orgNameUnique: uniqueIndex('credential_set_org_name_unique').on(
table.organizationId,
table.name
),
providerIdIdx: index('credential_set_provider_id_idx').on(table.providerId),
})
)
export const credentialSetMemberStatusEnum = pgEnum('credential_set_member_status', [
'active',
'pending',
'revoked',
])
export const credentialSetMember = pgTable(
'credential_set_member',
{
id: text('id').primaryKey(),
credentialSetId: text('credential_set_id')
.notNull()
.references(() => credentialSet.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
status: credentialSetMemberStatusEnum('status').notNull().default('pending'),
joinedAt: timestamp('joined_at'),
invitedBy: text('invited_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('credential_set_member_user_id_idx').on(table.userId),
uniqueMembership: uniqueIndex('credential_set_member_unique').on(
table.credentialSetId,
table.userId
),
statusIdx: index('credential_set_member_status_idx').on(table.status),
})
)
export const credentialSetInvitationStatusEnum = pgEnum('credential_set_invitation_status', [
'pending',
'accepted',
'expired',
'cancelled',
])
export const credentialSetInvitation = pgTable(
'credential_set_invitation',
{
id: text('id').primaryKey(),
credentialSetId: text('credential_set_id')
.notNull()
.references(() => credentialSet.id, { onDelete: 'cascade' }),
email: text('email'),
token: text('token').notNull().unique(),
invitedBy: text('invited_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
status: credentialSetInvitationStatusEnum('status').notNull().default('pending'),
expiresAt: timestamp('expires_at').notNull(),
acceptedAt: timestamp('accepted_at'),
acceptedByUserId: text('accepted_by_user_id').references(() => user.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
credentialSetIdIdx: index('credential_set_invitation_set_id_idx').on(table.credentialSetId),
tokenIdx: index('credential_set_invitation_token_idx').on(table.token),
statusIdx: index('credential_set_invitation_status_idx').on(table.status),
expiresAtIdx: index('credential_set_invitation_expires_at_idx').on(table.expiresAt),
})
)
export const permissionGroup = pgTable(
'permission_group',
{
id: text('id').primaryKey(),
organizationId: text('organization_id')
.notNull()
.references(() => organization.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
config: jsonb('config').notNull().default('{}'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
autoAddNewMembers: boolean('auto_add_new_members').notNull().default(false),
},
(table) => ({
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
orgNameUnique: uniqueIndex('permission_group_org_name_unique').on(
table.organizationId,
table.name
),
autoAddNewMembersUnique: uniqueIndex('permission_group_org_auto_add_unique')
.on(table.organizationId)
.where(sql`auto_add_new_members = true`),
})
)
export const permissionGroupMember = pgTable(
'permission_group_member',
{
id: text('id').primaryKey(),
permissionGroupId: text('permission_group_id')
.notNull()
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
assignedBy: text('assigned_by').references(() => user.id, { onDelete: 'set null' }),
assignedAt: timestamp('assigned_at').notNull().defaultNow(),
},
(table) => ({
permissionGroupIdIdx: index('permission_group_member_group_id_idx').on(table.permissionGroupId),
userIdUnique: uniqueIndex('permission_group_member_user_id_unique').on(table.userId),
})
)
/**
* Async Jobs - Queue for background job processing (Redis/DB backends)
* Used when trigger.dev is not available for async workflow executions
*/
export const asyncJobs = pgTable(
'async_jobs',
{
id: text('id').primaryKey(),
type: text('type').notNull(),
payload: jsonb('payload').notNull(),
status: text('status').notNull().default('pending'),
createdAt: timestamp('created_at').notNull().defaultNow(),
startedAt: timestamp('started_at'),
completedAt: timestamp('completed_at'),
runAt: timestamp('run_at'),
attempts: integer('attempts').notNull().default(0),
maxAttempts: integer('max_attempts').notNull().default(3),
error: text('error'),
output: jsonb('output'),
metadata: jsonb('metadata').notNull().default('{}'),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
statusStartedAtIdx: index('async_jobs_status_started_at_idx').on(table.status, table.startedAt),
statusCompletedAtIdx: index('async_jobs_status_completed_at_idx').on(
table.status,
table.completedAt
),
})
)
/**
* Knowledge Connector - persistent link to an external source (Confluence, Google Drive, etc.)
* that syncs documents into a knowledge base.
*/
export const knowledgeConnector = pgTable(
'knowledge_connector',
{
id: text('id').primaryKey(),
knowledgeBaseId: text('knowledge_base_id')
.notNull()
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
connectorType: text('connector_type').notNull(),
credentialId: text('credential_id'),
encryptedApiKey: text('encrypted_api_key'),
sourceConfig: json('source_config').notNull(),
syncMode: text('sync_mode').notNull().default('full'),
syncIntervalMinutes: integer('sync_interval_minutes').notNull().default(1440),
status: text('status').notNull().default('active'),
lastSyncAt: timestamp('last_sync_at'),
lastSyncError: text('last_sync_error'),
lastSyncDocCount: integer('last_sync_doc_count'),
nextSyncAt: timestamp('next_sync_at'),
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
archivedAt: timestamp('archived_at'),
deletedAt: timestamp('deleted_at'),
},
(table) => ({
knowledgeBaseIdIdx: index('kc_knowledge_base_id_idx').on(table.knowledgeBaseId),
statusNextSyncIdx: index('kc_status_next_sync_idx').on(table.status, table.nextSyncAt),
archivedAtIdx: index('kc_archived_at_idx').on(table.archivedAt),
deletedAtIdx: index('kc_deleted_at_idx').on(table.deletedAt),
})
)
/**
* Knowledge Connector Sync Log - audit trail for connector sync operations.
*/
export const knowledgeConnectorSyncLog = pgTable(
'knowledge_connector_sync_log',
{
id: text('id').primaryKey(),
connectorId: text('connector_id')
.notNull()
.references(() => knowledgeConnector.id, { onDelete: 'cascade' }),
status: text('status').notNull(),
startedAt: timestamp('started_at').notNull().defaultNow(),
completedAt: timestamp('completed_at'),
docsAdded: integer('docs_added').notNull().default(0),
docsUpdated: integer('docs_updated').notNull().default(0),
docsDeleted: integer('docs_deleted').notNull().default(0),
docsUnchanged: integer('docs_unchanged').notNull().default(0),
docsFailed: integer('docs_failed').notNull().default(0),
errorMessage: text('error_message'),
},
(table) => ({
connectorIdIdx: index('kcsl_connector_id_idx').on(table.connectorId),
})
)
/**
* User-defined table definitions
* Stores schema and metadata for custom tables created by users
*/
export const userTableDefinitions = pgTable(
'user_table_definitions',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
/**
* @remarks
* Stores the table schema definition. Example: { columns: [{ name: string, type: string, required: boolean }] }
*/
schema: jsonb('schema').notNull(),
/**
* @remarks
* Stores UI-specific metadata separate from the data schema.
* Example: { columnWidths: { name: 200, age: 100 } }
*/
metadata: jsonb('metadata'),
maxRows: integer('max_rows').notNull().default(10000),
rowCount: integer('row_count').notNull().default(0),
archivedAt: timestamp('archived_at'),
createdBy: text('created_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
workspaceIdIdx: index('user_table_def_workspace_id_idx').on(table.workspaceId),
workspaceNameUnique: uniqueIndex('user_table_def_workspace_name_unique')
.on(table.workspaceId, table.name)
.where(sql`${table.archivedAt} IS NULL`),
archivedAtIdx: index('user_table_def_archived_at_idx').on(table.archivedAt),
})
)
/**
* User-defined table rows
* Stores actual row data as JSONB for flexible schema
*/
export const userTableRows = pgTable(
'user_table_rows',
{
id: text('id').primaryKey(),
tableId: text('table_id')
.notNull()
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
data: jsonb('data').notNull(),
position: integer('position').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
},
(table) => ({
tableIdIdx: index('user_table_rows_table_id_idx').on(table.tableId),
dataGinIdx: index('user_table_rows_data_gin_idx').using('gin', table.data),
workspaceTableIdx: index('user_table_rows_workspace_table_idx').on(
table.workspaceId,
table.tableId
),
tablePositionIdx: index('user_table_rows_table_position_idx').on(table.tableId, table.position),
})
)
export const oauthApplication = pgTable(
'oauth_application',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
icon: text('icon'),
metadata: text('metadata'),
clientId: text('client_id').notNull().unique(),
clientSecret: text('client_secret'),
redirectURLs: text('redirect_urls').notNull(),
type: text('type').notNull(),
disabled: boolean('disabled').default(false),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
},
(table) => ({
clientIdIdx: index('oauth_application_client_id_idx').on(table.clientId),
})
)
export const oauthAccessToken = pgTable(
'oauth_access_token',
{
id: text('id').primaryKey(),
accessToken: text('access_token').notNull().unique(),
refreshToken: text('refresh_token').notNull().unique(),
accessTokenExpiresAt: timestamp('access_token_expires_at').notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at').notNull(),
clientId: text('client_id')
.notNull()
.references(() => oauthApplication.clientId, { onDelete: 'cascade' }),
userId: text('user_id').references(() => user.id, { onDelete: 'cascade' }),
scopes: text('scopes').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
},
(table) => ({
accessTokenIdx: index('oauth_access_token_access_token_idx').on(table.accessToken),
refreshTokenIdx: index('oauth_access_token_refresh_token_idx').on(table.refreshToken),
})
)
export const oauthConsent = pgTable(
'oauth_consent',
{
id: text('id').primaryKey(),
clientId: text('client_id')
.notNull()
.references(() => oauthApplication.clientId, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
scopes: text('scopes').notNull(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
consentGiven: boolean('consent_given').notNull(),
},
(table) => ({
userClientIdx: index('oauth_consent_user_client_idx').on(table.userId, table.clientId),
})
)
export const jwks = pgTable('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at').notNull(),
})
export const mothershipInboxAllowedSender = pgTable(
'mothership_inbox_allowed_sender',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
email: text('email').notNull(),
label: text('label'),
addedBy: text('added_by')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
wsEmailIdx: uniqueIndex('inbox_sender_ws_email_idx').on(table.workspaceId, table.email),
})
)
export const mothershipInboxTask = pgTable(
'mothership_inbox_task',
{
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.references(() => workspace.id, { onDelete: 'cascade' }),
fromEmail: text('from_email').notNull(),
fromName: text('from_name'),
subject: text('subject').notNull(),
bodyPreview: text('body_preview'),
bodyText: text('body_text'),
bodyHtml: text('body_html'),
emailMessageId: text('email_message_id'),
inReplyTo: text('in_reply_to'),
responseMessageId: text('response_message_id'),
agentmailMessageId: text('agentmail_message_id'),
status: text('status').notNull().default('received'),
chatId: uuid('chat_id').references(() => copilotChats.id, { onDelete: 'set null' }),
triggerJobId: text('trigger_job_id'),
resultSummary: text('result_summary'),
errorMessage: text('error_message'),
rejectionReason: text('rejection_reason'),
hasAttachments: boolean('has_attachments').notNull().default(false),
ccRecipients: text('cc_recipients'),
createdAt: timestamp('created_at').notNull().defaultNow(),
processingStartedAt: timestamp('processing_started_at'),
completedAt: timestamp('completed_at'),
},
(table) => ({
wsCreatedAtIdx: index('inbox_task_ws_created_at_idx').on(table.workspaceId, table.createdAt),
wsStatusIdx: index('inbox_task_ws_status_idx').on(table.workspaceId, table.status),
responseMsgIdIdx: index('inbox_task_response_msg_id_idx').on(table.responseMessageId),
emailMsgIdIdx: index('inbox_task_email_msg_id_idx').on(table.emailMessageId),
})
)
export const mothershipInboxWebhook = pgTable('mothership_inbox_webhook', {
id: text('id').primaryKey(),
workspaceId: text('workspace_id')
.notNull()
.unique()
.references(() => workspace.id, { onDelete: 'cascade' }),
webhookId: text('webhook_id').notNull(),
secret: text('secret').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
// ─── Sim Academy ─────────────────────────────────────────────────────────────
export const academyCertStatusEnum = pgEnum('academy_cert_status', ['active', 'revoked', 'expired'])
/** Partner certification records issued on course completion */
export const academyCertificate = pgTable(
'academy_certificate',
{
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
/** References the file-based course ID from lib/academy/content */
courseId: text('course_id').notNull(),
status: academyCertStatusEnum('status').notNull().default('active'),
issuedAt: timestamp('issued_at').notNull().defaultNow(),
/** Optional expiry for recertification requirements */
expiresAt: timestamp('expires_at'),
/** Human-readable unique certificate number, e.g. SIM-2026-00042 */
certificateNumber: text('certificate_number').notNull().unique(),
/** Snapshot of name and other metadata at time of issue */
metadata: jsonb('metadata'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('academy_certificate_user_id_idx').on(table.userId),
courseIdIdx: index('academy_certificate_course_id_idx').on(table.courseId),
userCourseUnique: uniqueIndex('academy_certificate_user_course_unique').on(
table.userId,
table.courseId
),
certNumberIdx: index('academy_certificate_number_idx').on(table.certificateNumber),
statusIdx: index('academy_certificate_status_idx').on(table.status),
})
)