mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
* 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>
2902 lines
109 KiB
TypeScript
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),
|
|
})
|
|
)
|