d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
3829 lines
153 KiB
TypeScript
3829 lines
153 KiB
TypeScript
import { type SQL, sql } from 'drizzle-orm'
|
|
import {
|
|
type AnyPgColumn,
|
|
bigint,
|
|
boolean,
|
|
check,
|
|
customType,
|
|
decimal,
|
|
doublePrecision,
|
|
index,
|
|
integer,
|
|
json,
|
|
jsonb,
|
|
pgEnum,
|
|
pgTable,
|
|
primaryKey,
|
|
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),
|
|
locked: boolean('locked').notNull().default(false),
|
|
sortOrder: integer('sort_order').notNull().default(0),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
archivedAt: timestamp('archived_at'),
|
|
},
|
|
(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),
|
|
archivedAtIdx: index('workflow_folder_archived_at_idx').on(table.archivedAt),
|
|
workspaceArchivedAtPartialIdx: index('workflow_folder_workspace_archived_partial_idx')
|
|
.on(table.workspaceId, table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
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'),
|
|
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),
|
|
locked: boolean('locked').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),
|
|
workspaceArchivedAtPartialIdx: index('workflow_workspace_archived_partial_idx')
|
|
.on(table.workspaceId, table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
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'),
|
|
|
|
/**
|
|
* Heavy trace data (traceSpans, finalOutput, workflowInput, executionState)
|
|
* is externalized to object storage; this column then holds a slim payload:
|
|
* a `traceStoreRef` (__simLargeValueRef) pointer to the stored object plus
|
|
* inline markers (hasTraceSpans, traceSpanCount, environment, trigger,
|
|
* truncation flags). It also still holds the FULL payload inline for legacy
|
|
* / not-yet-backfilled rows, for the storage-write-failure fallback, and for
|
|
* job_execution_logs. Required — not droppable. Read it via
|
|
* `materializeExecutionData`, which resolves the pointer.
|
|
*/
|
|
executionData: jsonb('execution_data').notNull().default('{}'),
|
|
/** @deprecated Not written/read; cost lives in usage_log + the `cost_total` projection. Drop in a follow-up PR after the `cost_total` backfill. */
|
|
cost: jsonb('cost'),
|
|
// Faithful, write-once projection of the run's usage_log ledger sum (dollars).
|
|
// Backs list cost display/filter/sort without live aggregation; never an
|
|
// independently-computed value (cost_total == SUM(usage_log) for the run).
|
|
costTotal: decimal('cost_total'),
|
|
// Model names used by the run (incl. zero-cost/BYOK), for the v1 model filter.
|
|
modelsUsed: text('models_used').array(),
|
|
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
|
|
),
|
|
workspaceStartedAtIdDescIdx: index(
|
|
'workflow_execution_logs_workspace_started_at_id_desc_idx'
|
|
).on(table.workspaceId, sql`${table.startedAt} DESC NULLS LAST`, sql`${table.id} DESC`),
|
|
workspaceCostTotalIdx: index('workflow_execution_logs_workspace_cost_total_idx').on(
|
|
table.workspaceId,
|
|
table.costTotal
|
|
),
|
|
modelsUsedIdx: index('workflow_execution_logs_models_used_idx').using('gin', table.modelsUsed),
|
|
workspaceEndedAtIdIdx: index('workflow_execution_logs_workspace_ended_at_id_idx').on(
|
|
table.workspaceId,
|
|
sql`date_trunc('milliseconds', ${table.endedAt})`,
|
|
table.id
|
|
),
|
|
runningStartedAtIdx: index('workflow_execution_logs_running_started_at_idx')
|
|
.on(table.startedAt)
|
|
.where(sql`status = 'running'`),
|
|
})
|
|
)
|
|
|
|
export const executionLargeValueReferenceSourceEnum = pgEnum(
|
|
'execution_large_value_reference_source',
|
|
['execution_log', 'paused_snapshot']
|
|
)
|
|
|
|
export const executionLargeValues = pgTable(
|
|
'execution_large_values',
|
|
{
|
|
key: text('key').primaryKey(),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
|
|
ownerExecutionId: text('owner_execution_id').notNull(),
|
|
size: integer('size').notNull(),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
},
|
|
(table) => ({
|
|
ownerExecutionIdIdx: index('execution_large_values_owner_execution_id_idx').on(
|
|
table.ownerExecutionId
|
|
),
|
|
cleanupIdx: index('execution_large_values_cleanup_idx')
|
|
.on(table.workspaceId, table.createdAt, table.key)
|
|
.where(sql`${table.deletedAt} IS NULL`),
|
|
tombstoneCleanupIdx: index('execution_large_values_tombstone_cleanup_idx')
|
|
.on(table.workspaceId, table.deletedAt, table.key)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
export const executionLargeValueReferences = pgTable(
|
|
'execution_large_value_references',
|
|
{
|
|
key: text('key').notNull(),
|
|
executionId: text('execution_id').notNull(),
|
|
source: executionLargeValueReferenceSourceEnum('source').notNull(),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
pk: primaryKey({ columns: [table.key, table.executionId, table.source] }),
|
|
workspaceExecutionSourceIdx: index(
|
|
'execution_large_value_references_workspace_execution_source_idx'
|
|
).on(table.workspaceId, table.executionId, table.source),
|
|
})
|
|
)
|
|
|
|
export const executionLargeValueDependencies = pgTable(
|
|
'execution_large_value_dependencies',
|
|
{
|
|
parentKey: text('parent_key').notNull(),
|
|
childKey: text('child_key').notNull(),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
pk: primaryKey({ columns: [table.parentKey, table.childKey] }),
|
|
workspaceParentKeyIdx: index('execution_large_value_dependencies_workspace_parent_key_idx').on(
|
|
table.workspaceId,
|
|
table.parentKey
|
|
),
|
|
workspaceChildKeyIdx: index('execution_large_value_dependencies_workspace_child_key_idx').on(
|
|
table.workspaceId,
|
|
table.childKey
|
|
),
|
|
})
|
|
)
|
|
|
|
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'),
|
|
/** Earliest `resumeAt` across this row's time-based pause points. NULL for human-only pauses. */
|
|
nextResumeAt: timestamp('next_resume_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),
|
|
nextResumeAtIdx: index('paused_executions_next_resume_at_idx')
|
|
.on(table.nextResumeAt)
|
|
.where(sql`status = 'paused' AND next_resume_at IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
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(),
|
|
name: text('name'),
|
|
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
workspaceProviderIdx: index('workspace_byok_workspace_provider_idx').on(
|
|
table.workspaceId,
|
|
table.providerId
|
|
),
|
|
})
|
|
)
|
|
|
|
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),
|
|
mothershipEnvironment: text('mothership_environment').notNull().default('default'),
|
|
|
|
// 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),
|
|
|
|
timezone: text('timezone'),
|
|
|
|
// 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('[]'),
|
|
|
|
// Workspace navigation
|
|
lastActiveWorkspaceId: text('last_active_workspace_id'),
|
|
|
|
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),
|
|
infraRetryCount: integer('infra_retry_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 }>>(),
|
|
/** `@`-mentioned resources / `/`-invoked skills captured with the prompt, resolved into the agent run at fire time. */
|
|
contexts: jsonb('contexts').$type<Array<Record<string, unknown>>>(),
|
|
/** ISO timestamps of recurring occurrences the user deleted individually (EXDATE); the executor skips them. */
|
|
excludedDates: jsonb('excluded_dates').$type<string[]>(),
|
|
/** Recurrence end boundary: the schedule completes once its next run would fall after this instant. */
|
|
endsAt: timestamp('ends_at'),
|
|
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
|
|
),
|
|
archivedAtPartialIdx: index('workflow_schedule_archived_at_partial_idx')
|
|
.on(table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
sourceWorkspaceSourceTypeIdx: index(
|
|
'idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6'
|
|
).on(table.sourceWorkspaceId, table.sourceType, table.archivedAt, table.status),
|
|
dueWorkflowIdx: index('workflow_schedule_due_workflow_idx')
|
|
.on(table.nextRunAt, table.lastQueuedAt, table.deploymentVersionId, table.workflowId)
|
|
.where(
|
|
sql`${table.archivedAt} IS NULL AND ${table.status} NOT IN ('disabled', 'completed') AND (${table.sourceType} = 'workflow' OR ${table.sourceType} IS NULL)`
|
|
),
|
|
dueJobIdx: index('workflow_schedule_due_job_idx')
|
|
.on(table.nextRunAt, table.lastQueuedAt)
|
|
.where(
|
|
sql`${table.archivedAt} IS NULL AND ${table.status} NOT IN ('disabled', 'completed') AND ${table.sourceType} = 'job'`
|
|
),
|
|
}
|
|
}
|
|
)
|
|
|
|
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
|
|
),
|
|
workspaceEndedAtIdIdx: index('job_execution_logs_workspace_ended_at_id_idx').on(
|
|
table.workspaceId,
|
|
sql`date_trunc('milliseconds', ${table.endedAt})`,
|
|
table.id
|
|
),
|
|
executionIdUnique: uniqueIndex('job_execution_logs_execution_id_unique').on(table.executionId),
|
|
triggerIdx: index('job_execution_logs_trigger_idx').on(table.trigger),
|
|
})
|
|
)
|
|
|
|
/** Extracts the canonical credential ID persisted in webhook provider configuration. */
|
|
export function webhookCredentialIdExpression(column: AnyPgColumn): SQL<string> {
|
|
return sql<string>`((${column})::jsonb ->> 'credentialId')`
|
|
}
|
|
|
|
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'),
|
|
/**
|
|
* URL-addressable webhook path. NULL for shared-app providers (e.g. the
|
|
* native Slack OAuth trigger) whose events arrive on a single shared
|
|
* endpoint and route by `routingKey` instead of a per-workflow path.
|
|
*/
|
|
path: text('path'),
|
|
/**
|
|
* Tenant routing key for shared-app providers. For `provider='slack_app'`
|
|
* this is the Slack `team_id`, derived server-side from the connected
|
|
* credential at deploy time — never user input. Inbound events match on
|
|
* this after HMAC verification.
|
|
*/
|
|
routingKey: text('routing_key'),
|
|
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
|
|
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`),
|
|
workflowDeploymentIdx: index('webhook_workflow_deployment_idx').on(
|
|
table.workflowId,
|
|
table.deploymentVersionId
|
|
),
|
|
// Shared-app inbound routing (Slack native OAuth trigger). routingKey leads.
|
|
routingKeyActiveIdx: index('webhook_routing_key_active_idx')
|
|
.on(table.routingKey, table.provider)
|
|
.where(sql`${table.archivedAt} IS NULL AND ${table.routingKey} IS NOT NULL`),
|
|
archivedAtPartialIdx: index('webhook_archived_at_partial_idx')
|
|
.on(table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
providerActiveWorkflowDeploymentIdx: index(
|
|
'idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468'
|
|
).on(table.provider, table.isActive, table.workflowId, table.deploymentVersionId),
|
|
tiktokCredentialIdIdx: index('webhook_tiktok_credential_id_idx')
|
|
.on(webhookCredentialIdExpression(table.providerConfig))
|
|
.where(
|
|
sql`${table.provider} = 'tiktok' AND ${table.isActive} = true AND ${table.archivedAt} IS NULL`
|
|
),
|
|
workflowBlockUpdatedDescIdx: index('idx_webhook_on_workflow_id_block_id_updated_at_desc').on(
|
|
table.workflowId,
|
|
table.blockId,
|
|
table.updatedAt.desc()
|
|
),
|
|
}
|
|
}
|
|
)
|
|
|
|
/**
|
|
* Cooldown state for Sim workspace-event trigger subscriptions.
|
|
*
|
|
* Keyed by (workflowId, blockId, scopeKey) rather than the webhook row because
|
|
* webhook rows are recreated per deployment version — state stored there would
|
|
* reset on every redeploy. `scopeKey` is '' for subscription-level cooldowns
|
|
* and the source workflow ID for per-source-workflow rules (no_activity).
|
|
*/
|
|
export const simTriggerState = pgTable(
|
|
'sim_trigger_state',
|
|
{
|
|
workflowId: text('workflow_id')
|
|
.notNull()
|
|
.references(() => workflow.id, { onDelete: 'cascade' }),
|
|
blockId: text('block_id').notNull(),
|
|
scopeKey: text('scope_key').notNull().default(''),
|
|
lastFiredAt: timestamp('last_fired_at'),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
pk: primaryKey({ columns: [table.workflowId, table.blockId, table.scopeKey] }),
|
|
})
|
|
)
|
|
|
|
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(),
|
|
keyHash: text('key_hash'),
|
|
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),
|
|
keyHashIdx: uniqueIndex('api_key_key_hash_idx').on(table.keyHash),
|
|
})
|
|
)
|
|
|
|
export const billingBlockedReasonEnum = pgEnum('billing_blocked_reason', [
|
|
'payment_failed',
|
|
'dispute',
|
|
])
|
|
|
|
export const billingEntityTypeEnum = pgEnum('billing_entity_type', ['user', 'organization'])
|
|
|
|
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
|
|
// Retired usage hot-path counters: no writers/readers; derive from usage_log.
|
|
// Drop via DROP COLUMN in a follow-up migration.
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalManualExecutions: integer('total_manual_executions').notNull().default(0),
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalApiCalls: integer('total_api_calls').notNull().default(0),
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalWebhookTriggers: integer('total_webhook_triggers').notNull().default(0),
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalScheduledExecutions: integer('total_scheduled_executions').notNull().default(0),
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalChatExecutions: integer('total_chat_executions').notNull().default(0),
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalMcpExecutions: integer('total_mcp_executions').notNull().default(0),
|
|
/** @deprecated Retired usage counter; derive from usage_log. */
|
|
totalTokensUsed: bigint('total_tokens_used', { mode: 'number' }).notNull().default(0),
|
|
/** @deprecated Not written (recordUsage appends to usage_log); legacy/admin reads only. Move readers to ledger aggregation. */
|
|
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(),
|
|
/**
|
|
* Active per-period baseline (not a per-usage hot-path counter). Current usage
|
|
* = this baseline + attributed usage_log rows for the period; reset at rollover.
|
|
*/
|
|
currentPeriodCost: decimal('current_period_cost').notNull().default('0'),
|
|
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
|
|
/**
|
|
* Threshold/final billing tracker.
|
|
*
|
|
* This is intentionally still written when threshold billing or invoice
|
|
* finalization serializes overage collection. It is not incremented by the
|
|
* ordinary per-usage ledger write path.
|
|
*/
|
|
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
|
|
proPeriodCostSnapshotAt: timestamp('pro_period_cost_snapshot_at'), // When the snapshot was captured (= join moment). Used to cap daily-refresh computation so post-join refresh isn't deducted from pre-join personal Pro usage (and vice-versa for the org's pooled refresh).
|
|
/**
|
|
* Credit balance tracker.
|
|
*
|
|
* Still debited/credited by billing lifecycle paths and threshold/final
|
|
* overage collection. It is not a per-usage aggregate counter.
|
|
*/
|
|
creditBalance: decimal('credit_balance').notNull().default('0'),
|
|
/** @deprecated Not written; report Copilot cost from usage_log. Legacy/admin reads only. */
|
|
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
|
/** Active per-period Copilot baseline; reset at rollover (not a per-usage counter). */
|
|
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
|
|
/** Previous-period Copilot cost; set at rollover. */
|
|
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
|
|
/** @deprecated Not written; report Copilot tokens from usage_log. Legacy/admin reads only. */
|
|
totalCopilotTokens: bigint('total_copilot_tokens', { mode: 'number' }).notNull().default(0),
|
|
/** @deprecated Not written; report Copilot calls from usage_log. Legacy/admin reads only. */
|
|
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
|
|
/** @deprecated Not written; report MCP Copilot calls from usage_log. Legacy/admin reads only. */
|
|
totalMcpCopilotCalls: integer('total_mcp_copilot_calls').notNull().default(0),
|
|
/** @deprecated Not written; report MCP Copilot cost from usage_log. Legacy/admin reads only. */
|
|
totalMcpCopilotCost: decimal('total_mcp_copilot_cost').notNull().default('0'),
|
|
/** @deprecated No writer (never incremented or reset). MCP copilot usage lives in usage_log (source 'mcp_copilot'); read it from there, not this column. */
|
|
currentPeriodMcpCopilotCost: decimal('current_period_mcp_copilot_cost').notNull().default('0'),
|
|
/**
|
|
* Storage upload/delete hot-path tracker for personal plans.
|
|
*
|
|
* This remains a direct aggregate write for personal file storage changes;
|
|
* org-scoped storage writes update `organization.storageUsedBytes`.
|
|
*/
|
|
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
|
|
/** @deprecated Not updated by execution (no user_stats write on completion); legacy/admin reads only. */
|
|
lastActive: timestamp('last_active').notNull().defaultNow(),
|
|
billingBlocked: boolean('billing_blocked').notNull().default(false),
|
|
billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'),
|
|
/**
|
|
* Highest usage-limit threshold already emailed per category (e.g.
|
|
* `{ storage: 80, tables: 100 }`). Prevents re-spamming the same warning;
|
|
* re-arms when usage drops back below the re-arm band. Keyed by limit
|
|
* category ('storage' | 'tables'); seats live on `organization`.
|
|
*
|
|
* Dedup granularity is per billing account per category — intentionally NOT
|
|
* per table, so a user hitting the row limit on several tables gets one
|
|
* 'tables' warning, not one per table (the email still names the table that
|
|
* triggered it).
|
|
*/
|
|
limitNotifications: jsonb('limit_notifications')
|
|
.$type<Record<string, number>>()
|
|
.notNull()
|
|
.default({}),
|
|
})
|
|
|
|
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 mothershipSettings = pgTable(
|
|
'mothership_settings',
|
|
{
|
|
workspaceId: text('workspace_id')
|
|
.primaryKey()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
mcpToolRefs: jsonb('mcp_tool_refs').notNull().default(sql`'[]'::jsonb`),
|
|
customToolRefs: jsonb('custom_tool_refs').notNull().default(sql`'[]'::jsonb`),
|
|
skillRefs: jsonb('skill_refs').notNull().default(sql`'[]'::jsonb`),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
workspaceIdIdx: index('mothership_settings_workspace_id_idx').on(table.workspaceId),
|
|
})
|
|
)
|
|
|
|
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'),
|
|
cancelAt: timestamp('cancel_at'),
|
|
canceledAt: timestamp('canceled_at'),
|
|
endedAt: timestamp('ended_at'),
|
|
seats: integer('seats'),
|
|
trialStart: timestamp('trial_start'),
|
|
trialEnd: timestamp('trial_end'),
|
|
billingInterval: text('billing_interval'),
|
|
stripeScheduleId: text('stripe_schedule_id'),
|
|
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`),
|
|
archivedAtPartialIdx: index('chat_archived_at_partial_idx')
|
|
.on(table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
workflowArchivedAtIdx: index('idx_chat_on_workflow_id_archived_at').on(
|
|
table.workflowId,
|
|
table.archivedAt
|
|
),
|
|
}
|
|
}
|
|
)
|
|
|
|
/** Per-stage PII redaction policy stored on a {@link PiiRedactionRule}. */
|
|
export interface PiiStagePolicy {
|
|
enabled: boolean
|
|
/** Presidio entity types to mask. Empty (or disabled) = redact nothing. */
|
|
entityTypes: string[]
|
|
/** Language whose Presidio recognizers apply (e.g. 'en', 'es'); defaults to English. */
|
|
language?: string
|
|
}
|
|
|
|
/**
|
|
* A single PII redaction rule. Lives in the org-level
|
|
* {@link DataRetentionSettings.piiRedaction} rules list. Each rule targets one
|
|
* scope — all workspaces (`workspaceId: null`) or a single workspace — and
|
|
* `workspaceId` is unique across rules. Resolution is most-specific-wins: a
|
|
* workspace's own rule overrides the all-workspaces rule (never unioned).
|
|
*
|
|
* New rules carry per-stage {@link stages} (input / blockOutputs / logs); legacy
|
|
* rows carry only the flat `entityTypes`/`language`, resolved as a logs-only
|
|
* rule. At least one of the two is present.
|
|
*/
|
|
export interface PiiRedactionRule {
|
|
id: string
|
|
name?: string
|
|
/** `null` = all workspaces; otherwise the single targeted workspace. */
|
|
workspaceId: string | null
|
|
/** Per-stage policy (input redaction, block-output redaction, log redaction). */
|
|
stages?: {
|
|
input: PiiStagePolicy
|
|
blockOutputs: PiiStagePolicy
|
|
logs: PiiStagePolicy
|
|
}
|
|
/** Legacy flat policy (pre-stages). Presidio entity types masked at log persist. */
|
|
entityTypes?: string[]
|
|
/** Legacy flat language (pre-stages). */
|
|
language?: string
|
|
}
|
|
|
|
/**
|
|
* A per-workspace override of the org-level retention hours. Each field is
|
|
* tri-state: absent = inherit the org value; a number = that workspace's
|
|
* retention in hours; `null` = forever (never delete). `workspaceId` is unique
|
|
* across overrides.
|
|
*/
|
|
export interface RetentionOverride {
|
|
workspaceId: string
|
|
logRetentionHours?: number | null
|
|
softDeleteRetentionHours?: number | null
|
|
taskCleanupHours?: number | null
|
|
}
|
|
|
|
/**
|
|
* Org-level data retention + governance settings. Retention-hours fall back to
|
|
* plan defaults when unset. `piiRedaction.rules` are org-scoped; each rule
|
|
* selects which workspaces it applies to. `retentionOverrides` lets individual
|
|
* workspaces override the org retention hours (enterprise only).
|
|
*/
|
|
export interface DataRetentionSettings {
|
|
logRetentionHours?: number | null
|
|
softDeleteRetentionHours?: number | null
|
|
taskCleanupHours?: number | null
|
|
/** Enterprise PII redaction rules applied to workflow logs on persist. */
|
|
piiRedaction?: {
|
|
rules?: PiiRedactionRule[]
|
|
} | null
|
|
/** Per-workspace overrides of the retention hours above (enterprise only). */
|
|
retentionOverrides?: RetentionOverride[] | null
|
|
}
|
|
|
|
export const organization = pgTable('organization', {
|
|
id: text('id').primaryKey(),
|
|
name: text('name').notNull(),
|
|
slug: text('slug').notNull(),
|
|
logo: text('logo'),
|
|
metadata: json('metadata'),
|
|
whitelabelSettings: json('whitelabel_settings').$type<{
|
|
brandName?: string
|
|
logoUrl?: string
|
|
primaryColor?: string
|
|
primaryHoverColor?: string
|
|
accentColor?: string
|
|
accentHoverColor?: string
|
|
supportEmail?: string
|
|
documentationUrl?: string
|
|
termsUrl?: string
|
|
privacyUrl?: string
|
|
hidePoweredBySim?: boolean
|
|
}>(),
|
|
dataRetentionSettings: json('data_retention_settings').$type<DataRetentionSettings>(),
|
|
orgUsageLimit: decimal('org_usage_limit'),
|
|
/**
|
|
* Storage upload/delete hot-path tracker for org-scoped plans.
|
|
*
|
|
* This remains a direct aggregate write for organization file storage
|
|
* changes; personal storage writes update `user_stats.storageUsedBytes`.
|
|
*/
|
|
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
|
|
/**
|
|
* Highest usage-limit threshold already emailed per category for this org
|
|
* (e.g. `{ seats: 80, storage: 100 }`). Mirrors `user_stats.limitNotifications`
|
|
* for org-scoped (pooled) limits. Re-arms when usage drops below the re-arm band.
|
|
*/
|
|
limitNotifications: jsonb('limit_notifications')
|
|
.$type<Record<string, number>>()
|
|
.notNull()
|
|
.default({}),
|
|
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
|
|
/**
|
|
* Organization credit balance tracker.
|
|
*
|
|
* Still debited/credited by billing lifecycle paths and threshold/final
|
|
* overage collection. It is not a per-usage aggregate counter.
|
|
*/
|
|
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),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Per-member usage limit (in dollars) scoped to a single organization.
|
|
*
|
|
* Keyed by `(organizationId, userId)` so it covers both organization members
|
|
* (rows in `member`) and external members (users with workspace permissions in
|
|
* org-owned workspaces but no `member` row). Independent of
|
|
* `user_stats.current_usage_limit`, which is the user's personal subscription
|
|
* cap and is nulled for org-scoped members. An absent row means "no per-member
|
|
* cap" (only the pooled org limit applies). Enforced for usage in org-owned
|
|
* workspaces; hosted-only.
|
|
*/
|
|
export const organizationMemberUsageLimit = pgTable(
|
|
'organization_member_usage_limit',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
organizationId: text('organization_id')
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
userId: text('user_id')
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: 'cascade' }),
|
|
usageLimit: decimal('usage_limit').notNull(),
|
|
/** Admin who set the cap (audit only). Soft FK: nulled if that user is
|
|
* deleted so the member's limit row survives — never cascade-deleted. */
|
|
setBy: text('set_by').references(() => user.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
},
|
|
(table) => ({
|
|
orgUserUnique: uniqueIndex('org_member_usage_limit_org_user_unique').on(
|
|
table.organizationId,
|
|
table.userId
|
|
),
|
|
organizationIdIdx: index('org_member_usage_limit_organization_id_idx').on(table.organizationId),
|
|
})
|
|
)
|
|
|
|
export const invitationKindEnum = pgEnum('invitation_kind', ['organization', 'workspace'])
|
|
|
|
export type InvitationKind = (typeof invitationKindEnum.enumValues)[number]
|
|
|
|
export const invitationMembershipIntentEnum = pgEnum('invitation_membership_intent', [
|
|
'internal',
|
|
'external',
|
|
])
|
|
|
|
export type InvitationMembershipIntent = (typeof invitationMembershipIntentEnum.enumValues)[number]
|
|
|
|
export const invitationStatusEnum = pgEnum('invitation_status', [
|
|
'pending',
|
|
'accepted',
|
|
'rejected',
|
|
'cancelled',
|
|
'expired',
|
|
])
|
|
|
|
export type InvitationStatus = (typeof invitationStatusEnum.enumValues)[number]
|
|
|
|
export const invitation = pgTable(
|
|
'invitation',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
kind: invitationKindEnum('kind').notNull().default('organization'),
|
|
email: text('email').notNull(),
|
|
inviterId: text('inviter_id')
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: 'cascade' }),
|
|
organizationId: text('organization_id').references(() => organization.id, {
|
|
onDelete: 'cascade',
|
|
}),
|
|
membershipIntent: invitationMembershipIntentEnum('membership_intent')
|
|
.notNull()
|
|
.default('internal'),
|
|
role: text('role').notNull(),
|
|
status: invitationStatusEnum('status').notNull().default('pending'),
|
|
token: text('token').notNull().unique(),
|
|
expiresAt: timestamp('expires_at').notNull(),
|
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
},
|
|
(table) => ({
|
|
emailIdx: index('invitation_email_idx').on(table.email),
|
|
organizationIdIdx: index('invitation_organization_id_idx').on(table.organizationId),
|
|
statusIdx: index('invitation_status_idx').on(table.status),
|
|
pendingPerOrgEmailUnique: uniqueIndex('invitation_pending_email_org_unique')
|
|
.on(table.email, table.organizationId)
|
|
.where(sql`${table.status} = 'pending' AND ${table.organizationId} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
export const workspaceModeEnum = pgEnum('workspace_mode', [
|
|
'personal',
|
|
'organization',
|
|
'grandfathered_shared',
|
|
])
|
|
|
|
export type WorkspaceMode = (typeof workspaceModeEnum.enumValues)[number]
|
|
|
|
export const workspace = pgTable(
|
|
'workspace',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
name: text('name').notNull(),
|
|
color: text('color').notNull().default('#33C482'),
|
|
logoUrl: text('logo_url'),
|
|
/**
|
|
* @deprecated Not a permission or identity concept — do not use for admin/access
|
|
* checks. The owner→admin derivation is redundant: every workspace owner already
|
|
* has an explicit `admin` row in `permissions` (verified across all production
|
|
* workspaces) and all creation paths add one. Retained only as the lifecycle
|
|
* anchor — `onDelete: 'cascade'` cleans up a user's workspaces on account
|
|
* deletion — and the ownership-transfer target when an owner is removed. For
|
|
* admin checks use explicit `permissions` rows; for the workspace's principal
|
|
* billing identity use `billedAccountUserId`.
|
|
*/
|
|
ownerId: text('owner_id')
|
|
.notNull()
|
|
.references(() => user.id, { onDelete: 'cascade' }),
|
|
organizationId: text('organization_id').references(() => organization.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
workspaceMode: workspaceModeEnum('workspace_mode').notNull().default('grandfathered_shared'),
|
|
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'),
|
|
forkedFromWorkspaceId: text('forked_from_workspace_id').references(
|
|
(): AnyPgColumn => workspace.id,
|
|
{ onDelete: 'set null' }
|
|
),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
ownerIdIdx: index('workspace_owner_id_idx').on(table.ownerId),
|
|
organizationIdIdx: index('workspace_organization_id_idx').on(table.organizationId),
|
|
workspaceModeIdx: index('workspace_mode_idx').on(table.workspaceMode),
|
|
forkedFromWorkspaceIdx: index('workspace_forked_from_workspace_id_idx').on(
|
|
table.forkedFromWorkspaceId
|
|
),
|
|
})
|
|
)
|
|
|
|
export const workspaceForkResourceTypeEnum = pgEnum('workspace_fork_resource_type', [
|
|
'workflow',
|
|
'oauth_credential',
|
|
'service_account_credential',
|
|
'env_var',
|
|
'table',
|
|
'knowledge_base',
|
|
'knowledge_document',
|
|
'file',
|
|
'mcp_server',
|
|
/** Workflow-publishing MCP server identity (fork shell copy), for attachment sync. */
|
|
'workflow_mcp_server',
|
|
'custom_tool',
|
|
'skill',
|
|
])
|
|
|
|
export const workspaceForkResourceMap = pgTable(
|
|
'workspace_fork_resource_map',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
childWorkspaceId: text('child_workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
resourceType: workspaceForkResourceTypeEnum('resource_type').notNull(),
|
|
parentResourceId: text('parent_resource_id').notNull(),
|
|
childResourceId: text('child_resource_id'),
|
|
// SET NULL (not CASCADE): deleting the creating user must not delete the fork's
|
|
// identity mappings, which the edge depends on for every future promote.
|
|
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
childWorkspaceIdx: index('workspace_fork_resource_map_child_ws_idx').on(table.childWorkspaceId),
|
|
childWorkspaceTypeIdx: index('workspace_fork_resource_map_child_ws_type_idx').on(
|
|
table.childWorkspaceId,
|
|
table.resourceType
|
|
),
|
|
childTypeParentUnique: uniqueIndex('workspace_fork_resource_map_child_type_parent_unique').on(
|
|
table.childWorkspaceId,
|
|
table.resourceType,
|
|
table.parentResourceId
|
|
),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Stable 1:1 block-identity map between a fork (child) and its parent, per edge. Seeded at
|
|
* fork creation (parent block -> derived child block) and reconciled on every promote.
|
|
* Promote looks a source block up here to reuse its counterpart's EXISTING id instead of
|
|
* re-deriving: without it, pushing a fork's workflow over the parent would re-key the
|
|
* parent's blocks and change their webhook URLs (the path falls back to the block id).
|
|
*
|
|
* Each pair records BOTH workflow ids so a lookup can be scoped to the workflow it belongs
|
|
* to: a target workflow that was archived and re-created gets a fresh id (the pair no longer
|
|
* matches), which avoids reusing an archived workflow's block id and colliding on the global
|
|
* `workflow_blocks` primary key. Block ids are plain text (no FK to `workflow_blocks`, which
|
|
* is rewritten on every deploy); only the edge (`child_workspace_id`) cascades. A parent
|
|
* block can map to different children across sibling forks, so uniqueness is per (edge,
|
|
* parent) and per (edge, child).
|
|
*/
|
|
export const workspaceForkBlockMap = pgTable(
|
|
'workspace_fork_block_map',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
childWorkspaceId: text('child_workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
parentWorkflowId: text('parent_workflow_id').notNull(),
|
|
parentBlockId: text('parent_block_id').notNull(),
|
|
childWorkflowId: text('child_workflow_id').notNull(),
|
|
childBlockId: text('child_block_id').notNull(),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
// Pull resolves parent source block -> child target; one child per parent block per edge.
|
|
childWsParentBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_parent_unique').on(
|
|
table.childWorkspaceId,
|
|
table.parentBlockId
|
|
),
|
|
// Push resolves child source block -> parent target; one parent per child block per edge.
|
|
childWsChildBlockUnique: uniqueIndex('workspace_fork_block_map_child_ws_child_unique').on(
|
|
table.childWorkspaceId,
|
|
table.childBlockId
|
|
),
|
|
// Reconcile deletes a source workflow's pairs by its (stable) workflow id before
|
|
// re-inserting the live ones, so index both workflow sides for that sweep.
|
|
childWsParentWorkflowIdx: index('workspace_fork_block_map_child_ws_parent_wf_idx').on(
|
|
table.childWorkspaceId,
|
|
table.parentWorkflowId
|
|
),
|
|
childWsChildWorkflowIdx: index('workspace_fork_block_map_child_ws_child_wf_idx').on(
|
|
table.childWorkspaceId,
|
|
table.childWorkflowId
|
|
),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* The user's stored dependent-field re-picks for an edge: a (target workflow, target block,
|
|
* subblock) -> selected value mapping (a Gmail label, a KB document, a sheet tab). The sync
|
|
* modal reads and writes this, and every promote applies it verbatim - it is the single
|
|
* source of truth for dependent values, replacing the old implicit "preserve the target's
|
|
* value if the credential is unchanged" path. Block ids are plain text (no FK to
|
|
* `workflow_blocks`, which is rewritten on every deploy); only the edge (`child_workspace_id`)
|
|
* cascades. The target workflow id encodes direction (push -> parent workflow, pull -> child
|
|
* workflow), so no separate direction column is needed.
|
|
*/
|
|
export const workspaceForkDependentValue = pgTable(
|
|
'workspace_fork_dependent_value',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
childWorkspaceId: text('child_workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
targetWorkflowId: text('target_workflow_id').notNull(),
|
|
targetBlockId: text('target_block_id').notNull(),
|
|
subBlockKey: text('sub_block_key').notNull(),
|
|
value: text('value').notNull(),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
// Reconcile replaces a workflow's stored values by its id, so index that sweep.
|
|
childWsWorkflowIdx: index('workspace_fork_dependent_value_child_ws_wf_idx').on(
|
|
table.childWorkspaceId,
|
|
table.targetWorkflowId
|
|
),
|
|
// One stored value per (edge, target workflow, target block, subblock).
|
|
childWsFieldUnique: uniqueIndex('workspace_fork_dependent_value_field_unique').on(
|
|
table.childWorkspaceId,
|
|
table.targetWorkflowId,
|
|
table.targetBlockId,
|
|
table.subBlockKey
|
|
),
|
|
})
|
|
)
|
|
|
|
export const workspaceForkPromoteDirectionEnum = pgEnum('workspace_fork_promote_direction', [
|
|
'push',
|
|
'pull',
|
|
])
|
|
|
|
export const workspaceForkPromoteRun = pgTable(
|
|
'workspace_fork_promote_run',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
childWorkspaceId: text('child_workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
sourceWorkspaceId: text('source_workspace_id').notNull(),
|
|
targetWorkspaceId: text('target_workspace_id').notNull(),
|
|
direction: workspaceForkPromoteDirectionEnum('direction').notNull(),
|
|
snapshot: jsonb('snapshot').notNull(),
|
|
// SET NULL (not CASCADE): deleting the creating user must not delete a pending
|
|
// undo point for a target workspace.
|
|
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
// One undo point per (edge, target) so a push (target=parent) and a pull
|
|
// (target=child) on the same edge keep independent undo points.
|
|
childWorkspaceTargetUnique: uniqueIndex('workspace_fork_promote_run_child_ws_target_unique').on(
|
|
table.childWorkspaceId,
|
|
table.targetWorkspaceId
|
|
),
|
|
targetWorkspaceIdx: index('workspace_fork_promote_run_target_ws_idx').on(
|
|
table.targetWorkspaceId
|
|
),
|
|
})
|
|
)
|
|
|
|
export const backgroundWorkKindEnum = pgEnum('background_work_kind', [
|
|
'deployment_side_effects',
|
|
'fork_content_copy',
|
|
'fork_sync',
|
|
'fork_rollback',
|
|
])
|
|
|
|
export const backgroundWorkStatusValueEnum = pgEnum('background_work_status_value', [
|
|
'pending',
|
|
'processing',
|
|
'completed',
|
|
'completed_with_warnings',
|
|
'failed',
|
|
])
|
|
|
|
/**
|
|
* Durable status for asynchronous background work (post-sync/rollback deployment
|
|
* side-effects and fork content copy), so the canvas can show a "work in progress"
|
|
* banner that survives a reload. A row scoped to a single workflow sets `workflowId`;
|
|
* workspace-spanning work (fork content copy) leaves it null.
|
|
*/
|
|
export const backgroundWorkStatus = pgTable(
|
|
'background_work_status',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'cascade' }),
|
|
kind: backgroundWorkKindEnum('kind').notNull(),
|
|
status: backgroundWorkStatusValueEnum('status').notNull(),
|
|
message: text('message'),
|
|
error: text('error'),
|
|
metadata: jsonb('metadata'),
|
|
startedAt: timestamp('started_at').notNull().defaultNow(),
|
|
completedAt: timestamp('completed_at'),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
workspaceStatusIdx: index('background_work_status_workspace_status_idx').on(
|
|
table.workspaceId,
|
|
table.status
|
|
),
|
|
workflowStatusIdx: index('background_work_status_workflow_status_idx').on(
|
|
table.workflowId,
|
|
table.status
|
|
),
|
|
// Expression indexes for listSurfacedBackgroundWork's metadata legs: `->>` equality can't
|
|
// use a GIN index, and one unindexable leg in its `or()` forces a full-table scan.
|
|
metaChildWorkspaceIdx: index('background_work_status_meta_child_ws_idx').on(
|
|
sql`(${table.metadata} ->> 'childWorkspaceId')`
|
|
),
|
|
metaOtherWorkspaceIdx: index('background_work_status_meta_other_ws_idx').on(
|
|
sql`(${table.metadata} ->> 'otherWorkspaceId')`
|
|
),
|
|
})
|
|
)
|
|
|
|
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),
|
|
workspaceDeletedAtPartialIdx: index('workspace_file_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
export const workspaceFileFolder = pgTable(
|
|
'workspace_file_folders',
|
|
{
|
|
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').references((): AnyPgColumn => workspaceFileFolder.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
sortOrder: integer('sort_order').notNull().default(0),
|
|
deletedAt: timestamp('deleted_at'),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
workspaceParentIdx: index('workspace_file_folders_workspace_parent_idx').on(
|
|
table.workspaceId,
|
|
table.parentId
|
|
),
|
|
parentSortIdx: index('workspace_file_folders_parent_sort_idx').on(
|
|
table.parentId,
|
|
table.sortOrder
|
|
),
|
|
deletedAtIdx: index('workspace_file_folders_deleted_at_idx').on(table.deletedAt),
|
|
workspaceDeletedAtPartialIdx: index('workspace_file_folders_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
workspaceParentNameActiveUnique: uniqueIndex(
|
|
'workspace_file_folders_workspace_parent_name_active_unique'
|
|
)
|
|
.on(table.workspaceId, sql`coalesce(${table.parentId}, '')`, table.name)
|
|
.where(sql`${table.deletedAt} IS NULL`),
|
|
})
|
|
)
|
|
|
|
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' }),
|
|
folderId: text('folder_id').references(() => workspaceFileFolder.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
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(),
|
|
/**
|
|
* Collision-disambiguated name exposed to the copilot VFS as `uploads/<displayName>`.
|
|
* For mothership chat uploads, identical originalNames within a chat get suffixed
|
|
* `(2)`, `(3)`, ... in upload order so the VFS path is unique per chat.
|
|
* NULL on legacy rows that predate this column — readers must coalesce to originalName.
|
|
* Stable for the row's lifetime; the partial unique index below enforces uniqueness
|
|
* for new (non-NULL) rows. NULLs are treated as distinct in PG unique indexes, so
|
|
* legacy collisions remain (acceptable: those uploads have already happened).
|
|
*/
|
|
displayName: text('display_name'),
|
|
contentType: text('content_type').notNull(),
|
|
size: integer('size').notNull(),
|
|
deletedAt: timestamp('deleted_at'),
|
|
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
keyActiveUniqueIdx: uniqueIndex('workspace_files_key_active_unique')
|
|
.on(table.key)
|
|
.where(sql`${table.deletedAt} IS NULL`),
|
|
workspaceFolderOriginalNameActiveUnique: uniqueIndex(
|
|
'workspace_files_workspace_folder_name_active_unique'
|
|
)
|
|
.on(table.workspaceId, sql`coalesce(${table.folderId}, '')`, table.originalName)
|
|
.where(
|
|
sql`${table.deletedAt} IS NULL AND ${table.context} = 'workspace' AND ${table.workspaceId} IS NOT NULL`
|
|
),
|
|
/**
|
|
* One display name per chat for mothership chat uploads, enforced across the row's
|
|
* entire lifetime (including soft-deleted rows). VFS paths must remain stable for the
|
|
* LLM's session — soft-deleting a sibling cannot free a name slot that the model has
|
|
* already been told about, since that would cause `read("uploads/<name>")` to silently
|
|
* resolve to a different file. NULLs are distinct in PG, so legacy rows (display_name
|
|
* IS NULL) don't block index creation or new inserts.
|
|
*/
|
|
chatDisplayNameUnique: uniqueIndex('workspace_files_chat_display_name_unique')
|
|
.on(table.chatId, table.displayName)
|
|
.where(sql`${table.context} = 'mothership' AND ${table.chatId} 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),
|
|
folderIdIdx: index('workspace_files_folder_id_idx').on(table.folderId),
|
|
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),
|
|
workspaceDeletedAtPartialIdx: index('workspace_files_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Public share links for workspace resources. Polymorphic on `resourceType` so a
|
|
* single mechanism serves files now and folders later. One row per resource
|
|
* (disable/re-enable flips `isActive` and keeps the same token).
|
|
*/
|
|
export const publicShare = pgTable(
|
|
'public_share',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
resourceType: text('resource_type').notNull(), // 'file' | 'folder' (folder reserved for future)
|
|
resourceId: text('resource_id').notNull(),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
// SET NULL (not CASCADE) so a share — and its public link — outlives the user
|
|
// who created it; the file still belongs to the workspace.
|
|
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
token: text('token').notNull(),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
// 'public' (anyone with the link) | 'password' | 'email' (OTP) | 'sso'.
|
|
authType: text('auth_type').notNull().default('public'),
|
|
// AES-256-GCM encrypted share password; null unless authType is 'password'.
|
|
password: text('password'),
|
|
// Allowed emails/domains (e.g. '@acme.com') when authType is 'email' or 'sso'.
|
|
allowedEmails: json('allowed_emails').default('[]'),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
tokenIdx: uniqueIndex('public_share_token_unique').on(table.token),
|
|
resourceUniqueIdx: uniqueIndex('public_share_resource_unique').on(
|
|
table.resourceType,
|
|
table.resourceId
|
|
),
|
|
resourceIdIdx: index('public_share_resource_id_idx').on(table.resourceId),
|
|
workspaceIdIdx: index('public_share_workspace_id_idx').on(table.workspaceId),
|
|
})
|
|
)
|
|
|
|
export const permissionTypeEnum = pgEnum('permission_type', ['admin', 'write', 'read'])
|
|
|
|
export const invitationWorkspaceGrant = pgTable(
|
|
'invitation_workspace_grant',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
invitationId: text('invitation_id')
|
|
.notNull()
|
|
.references(() => invitation.id, { onDelete: 'cascade' }),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
permission: permissionTypeEnum('permission').notNull(),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
invitationWorkspaceUnique: uniqueIndex('invitation_workspace_grant_unique').on(
|
|
table.invitationId,
|
|
table.workspaceId
|
|
),
|
|
workspaceIdIdx: index('invitation_workspace_grant_workspace_id_idx').on(table.workspaceId),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Polymorphic access grants: `entityType` + `entityId` reference a workspace,
|
|
* workflow, organization, etc. by id, but `entityId` is **not a foreign key** —
|
|
* so deleting the referenced entity does NOT cascade-delete these rows. Soft
|
|
* deletes (e.g. workspace archive) intentionally keep them: the entity is blocked
|
|
* everywhere by its `archivedAt`, so the rows are harmless, and a future restore
|
|
* would need them. Only a **hard** delete/purge of an entity must remove its
|
|
* grants explicitly — e.g.
|
|
* `DELETE FROM permissions WHERE entity_type = 'workspace' AND entity_id = $id` —
|
|
* or they orphan.
|
|
*/
|
|
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
|
|
),
|
|
workspaceDeletedAtPartialIdx: index('memory_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
}
|
|
}
|
|
)
|
|
|
|
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, { onDelete: 'cascade' }),
|
|
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),
|
|
workspaceDeletedAtPartialIdx: index('kb_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
/** 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(),
|
|
// Canonical storage key derived from fileUrl at write time (e.g. 'kb/<...>'),
|
|
// or null for external/data: ingestion URLs. KB file authorization matches on
|
|
// this exact key rather than re-parsing the URL at read time.
|
|
storageKey: text('storage_key'),
|
|
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'),
|
|
|
|
/** User who uploaded the document, for usage attribution. Null for
|
|
* connector/cron-synced docs (and pre-migration rows) → indexing billing
|
|
* falls back to the workspace billed account. */
|
|
uploadedBy: text('uploaded_by').references(() => user.id, { onDelete: 'set null' }),
|
|
|
|
// 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),
|
|
// KB file-access liveness: exact lookup by canonical storage key
|
|
storageKeyIdx: index('doc_storage_key_idx')
|
|
.on(table.storageKey)
|
|
.where(sql`${table.storageKey} IS NOT NULL`),
|
|
archivedAtPartialIdx: index('doc_archived_at_partial_idx')
|
|
.on(table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
deletedAtPartialIdx: index('doc_deleted_at_partial_idx')
|
|
.on(table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
// 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'),
|
|
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'),
|
|
pinned: boolean('pinned').notNull().default(false),
|
|
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),
|
|
workspaceCreatedAtIdIdx: index('copilot_chats_workspace_created_at_id_idx').on(
|
|
table.workspaceId,
|
|
sql`date_trunc('milliseconds', ${table.createdAt})`,
|
|
table.id
|
|
),
|
|
})
|
|
)
|
|
|
|
export const copilotMessages = pgTable(
|
|
'copilot_messages',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
chatId: uuid('chat_id')
|
|
.notNull()
|
|
.references(() => copilotChats.id, { onDelete: 'cascade' }),
|
|
messageId: text('message_id').notNull(),
|
|
role: text('role').notNull(),
|
|
content: jsonb('content').notNull(),
|
|
streamId: text('stream_id'),
|
|
parentMessageId: text('parent_message_id'),
|
|
model: text('model'),
|
|
tokensIn: integer('tokens_in'),
|
|
tokensOut: integer('tokens_out'),
|
|
seq: integer('seq'),
|
|
deletedAt: timestamp('deleted_at'),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
chatMessageUnique: uniqueIndex('copilot_messages_chat_message_unique').on(
|
|
table.chatId,
|
|
table.messageId
|
|
),
|
|
chatCreatedAtIdx: index('copilot_messages_chat_created_at_idx')
|
|
.on(table.chatId, table.createdAt, table.id)
|
|
.where(sql`${table.deletedAt} IS NULL`),
|
|
chatSeqIdx: index('copilot_messages_chat_seq_idx')
|
|
.on(table.chatId, table.seq)
|
|
.where(sql`${table.deletedAt} IS NULL`),
|
|
chatStreamIdx: index('copilot_messages_chat_stream_idx')
|
|
.on(table.chatId, table.streamId)
|
|
.where(sql`${table.streamId} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
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
|
|
),
|
|
workspaceCompletedAtIdIdx: index('copilot_runs_workspace_completed_at_id_idx').on(
|
|
table.workspaceId,
|
|
sql`date_trunc('milliseconds', ${table.completedAt})`,
|
|
table.id
|
|
),
|
|
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 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 outboxEvent = pgTable(
|
|
'outbox_event',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
eventType: text('event_type').notNull(),
|
|
payload: json('payload').notNull(),
|
|
status: text('status').notNull().default('pending'),
|
|
attempts: integer('attempts').notNull().default(0),
|
|
maxAttempts: integer('max_attempts').notNull().default(10),
|
|
availableAt: timestamp('available_at').notNull().defaultNow(),
|
|
lockedAt: timestamp('locked_at'),
|
|
lastError: text('last_error'),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
processedAt: timestamp('processed_at'),
|
|
},
|
|
(table) => ({
|
|
statusAvailableIdx: index('outbox_event_status_available_idx').on(
|
|
table.status,
|
|
table.availableAt
|
|
),
|
|
lockedAtIdx: index('outbox_event_locked_at_idx').on(table.lockedAt),
|
|
})
|
|
)
|
|
|
|
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'),
|
|
|
|
authType: text('auth_type').notNull().default('headers'),
|
|
/**
|
|
* Optional pre-registered OAuth credentials for servers that don't
|
|
* support Dynamic Client Registration (RFC 7591). When set, these
|
|
* shortcut the SDK's DCR step. `oauthClientSecret` is encrypted.
|
|
*/
|
|
oauthClientId: text('oauth_client_id'),
|
|
oauthClientSecret: text('oauth_client_secret'),
|
|
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 (partial: only deleted rows)
|
|
workspaceDeletedIdx: index('mcp_servers_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Workspace-scoped OAuth state for an outbound MCP server.
|
|
*
|
|
* Holds the SDK-managed OAuth artifacts needed to drive the standard MCP
|
|
* OAuth 2.1 + PKCE + dynamic-client-registration flow against a remote MCP
|
|
* server. One row per MCP server; workspace members share the authorized
|
|
* connection just like they share the MCP server definition.
|
|
*/
|
|
export const mcpServerOauth = pgTable(
|
|
'mcp_server_oauth',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
mcpServerId: text('mcp_server_id')
|
|
.notNull()
|
|
.references(() => mcpServers.id, { onDelete: 'cascade' }),
|
|
/** Last workspace user who initiated/completed authorization. */
|
|
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
|
|
/**
|
|
* Encrypted JSON of the RFC 7591 dynamic client registration result.
|
|
* Encrypted because some authorization servers may issue a client_secret
|
|
* even for clients advertising `token_endpoint_auth_method: 'none'`.
|
|
*/
|
|
clientInformation: text('client_information'),
|
|
|
|
/** Encrypted JSON of the OAuth tokens (access + refresh). */
|
|
tokens: text('tokens'),
|
|
|
|
/** PKCE verifier held only between /authorize and /callback. */
|
|
codeVerifier: text('code_verifier'),
|
|
|
|
/** Opaque state mint to correlate the callback. */
|
|
state: text('state'),
|
|
|
|
/**
|
|
* When `state` was minted. Used to expire the active-flow window and the
|
|
* state replay window independently of `updatedAt`, which is touched by
|
|
* token refreshes and other writes.
|
|
*/
|
|
stateCreatedAt: timestamp('state_created_at'),
|
|
|
|
lastRefreshedAt: timestamp('last_refreshed_at'),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
serverUnique: uniqueIndex('mcp_server_oauth_server_unique').on(table.mcpServerId),
|
|
stateIdx: index('mcp_server_oauth_state_idx').on(table.state),
|
|
})
|
|
)
|
|
|
|
// 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),
|
|
workspaceDeletedAtPartialIdx: index('workflow_mcp_server_workspace_deleted_partial_idx')
|
|
.on(table.workspaceId, table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* 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('{}'),
|
|
parameterDescriptionOverrides: json('parameter_description_overrides')
|
|
.$type<Record<string, string>>()
|
|
.notNull()
|
|
.default(sql`'{}'::json`),
|
|
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`),
|
|
archivedAtPartialIdx: index('workflow_mcp_tool_archived_at_partial_idx')
|
|
.on(table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Custom Blocks - a deployed workflow published as a reusable, org-wide block.
|
|
* Scoped to an organization: available across every workspace in the org. Bound to
|
|
* a source `workflowId` and always executes that workflow's latest deployment. Start
|
|
* input fields are derived live (not snapshotted). `type` is the stable lowercase
|
|
* block-type slug (`custom_block_<shortId>`) that flows into the block registry
|
|
* overlay, the palette, and permission-group `allowedIntegrations` access control.
|
|
*/
|
|
export const customBlock = pgTable(
|
|
'custom_block',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
organizationId: text('organization_id')
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
workflowId: text('workflow_id')
|
|
.notNull()
|
|
.references(() => workflow.id, { onDelete: 'cascade' }),
|
|
type: text('type').notNull(),
|
|
name: text('name').notNull(),
|
|
description: text('description').notNull().default(''),
|
|
/** Uploaded icon image URL (workspace storage), or null for the default icon. */
|
|
iconUrl: text('icon_url'),
|
|
/**
|
|
* Per-input authored overrides keyed by the source Start field's stable `id`:
|
|
* `Array<{ id, placeholder?, required? }>`. Only the placeholder and required
|
|
* flag are authored — the input field set and its name/type/description are
|
|
* always derived live from the deployed Start (so they can never go stale); an
|
|
* override whose field was removed is ignored. Absent/empty → no overrides;
|
|
* every deployed Start input is still exposed.
|
|
*/
|
|
inputs: json('inputs').$type<Array<{ id: string; placeholder?: string; required?: boolean }>>(),
|
|
/**
|
|
* Curated outputs exposed to consumers: `Array<{ blockId, path, name }>`. Each
|
|
* maps a child-workflow block output (blockId + dot-path) to a friendly output
|
|
* name on the block. Empty/absent → expose the child's whole `result`. Internal
|
|
* plumbing (child workflow id, trace spans) is never exposed.
|
|
*/
|
|
outputs: json('outputs').$type<Array<{ blockId: string; path: string; name: string }>>(),
|
|
enabled: boolean('enabled').notNull().default(true),
|
|
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
organizationIdIdx: index('custom_block_organization_id_idx').on(table.organizationId),
|
|
workflowIdIdx: index('custom_block_workflow_id_idx').on(table.workflowId),
|
|
orgTypeUnique: uniqueIndex('custom_block_organization_type_unique').on(
|
|
table.organizationId,
|
|
table.type
|
|
),
|
|
})
|
|
)
|
|
|
|
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
|
|
),
|
|
workspaceCreatedIdIdx: index('audit_log_workspace_created_at_id_idx').on(
|
|
table.workspaceId,
|
|
sql`date_trunc('milliseconds', ${table.createdAt})`,
|
|
table.id
|
|
),
|
|
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', 'tool'])
|
|
export const usageLogSourceEnum = pgEnum('usage_log_source', [
|
|
'workflow',
|
|
'wand',
|
|
'copilot',
|
|
'workspace-chat',
|
|
'mcp_copilot',
|
|
'mothership_block',
|
|
'knowledge-base',
|
|
'voice-input',
|
|
'enrichment',
|
|
])
|
|
|
|
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(),
|
|
eventKey: text('event_key'),
|
|
billingEntityType: billingEntityTypeEnum('billing_entity_type'),
|
|
billingEntityId: text('billing_entity_id'),
|
|
billingPeriodStart: timestamp('billing_period_start'),
|
|
billingPeriodEnd: timestamp('billing_period_end'),
|
|
|
|
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),
|
|
eventKeyUnique: uniqueIndex('usage_log_event_key_unique')
|
|
.on(table.eventKey)
|
|
.where(sql`${table.eventKey} IS NOT NULL`),
|
|
billingEntityPeriodIdx: index('usage_log_billing_entity_period_idx')
|
|
.on(
|
|
table.billingEntityType,
|
|
table.billingEntityId,
|
|
table.billingPeriodStart,
|
|
table.billingPeriodEnd
|
|
)
|
|
.where(sql`${table.billingEntityType} IS NOT NULL`),
|
|
billingScopeAllOrNone: check(
|
|
'usage_log_billing_scope_all_or_none',
|
|
sql`(
|
|
(${table.billingEntityType} IS NULL AND ${table.billingEntityId} IS NULL AND ${table.billingPeriodStart} IS NULL AND ${table.billingPeriodEnd} IS NULL)
|
|
OR
|
|
(${table.billingEntityType} IS NOT NULL AND ${table.billingEntityId} IS NOT NULL AND ${table.billingPeriodStart} IS NOT NULL AND ${table.billingPeriodEnd} IS NOT NULL AND ${table.billingPeriodStart} < ${table.billingPeriodEnd})
|
|
)`
|
|
),
|
|
workspaceCreatedAtIdx: index('usage_log_workspace_created_at_idx').on(
|
|
table.workspaceId,
|
|
table.createdAt
|
|
),
|
|
executionIdIdx: index('usage_log_execution_id_idx').on(table.executionId),
|
|
})
|
|
)
|
|
|
|
export const credentialTypeEnum = pgEnum('credential_type', [
|
|
'oauth',
|
|
'env_workspace',
|
|
'env_personal',
|
|
'service_account',
|
|
])
|
|
|
|
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' }),
|
|
encryptedServiceAccountKey: text('encrypted_service_account_key'),
|
|
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
|
|
),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* A named set of access-control restrictions (`config`) governing users within
|
|
* an organization.
|
|
*
|
|
* Scope invariant: the organization's single default group (`isDefault`) is
|
|
* org-wide and governs everyone not covered by another group. Every non-default
|
|
* group targets specific workspaces (rows in `permission_group_workspace`), and a
|
|
* non-default group with no rows governs nothing. Being org-wide is definitionally
|
|
* `isDefault` — there is no separate flag. Enforced by the API contracts/routes.
|
|
*
|
|
* Member invariant: a non-default group with no `permission_group_member` rows
|
|
* governs every member of its workspaces (including external members); adding
|
|
* members narrows it to only those users. The default group ignores membership.
|
|
*/
|
|
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(),
|
|
isDefault: boolean('is_default').notNull().default(false),
|
|
},
|
|
(table) => ({
|
|
createdByIdx: index('permission_group_created_by_idx').on(table.createdBy),
|
|
organizationNameUnique: uniqueIndex('permission_group_organization_name_unique').on(
|
|
table.organizationId,
|
|
table.name
|
|
),
|
|
defaultGroupUnique: uniqueIndex('permission_group_organization_default_unique')
|
|
.on(table.organizationId)
|
|
.where(sql`is_default = true`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Workspaces a non-default `permission_group` targets. Rows are absent for the
|
|
* organization-wide default group; a non-default group with zero rows governs no
|
|
* workspace.
|
|
*/
|
|
export const permissionGroupWorkspace = pgTable(
|
|
'permission_group_workspace',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
permissionGroupId: text('permission_group_id')
|
|
.notNull()
|
|
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
organizationId: text('organization_id')
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
workspaceIdIdx: index('permission_group_workspace_workspace_id_idx').on(table.workspaceId),
|
|
groupWorkspaceUnique: uniqueIndex('permission_group_workspace_group_workspace_unique').on(
|
|
table.permissionGroupId,
|
|
table.workspaceId
|
|
),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Explicit members of a `permission_group`. Membership narrows a non-default
|
|
* group to only these users; a non-default group with no rows here governs every
|
|
* member of its workspaces (including external members). The default group
|
|
* ignores these rows.
|
|
*/
|
|
export const permissionGroupMember = pgTable(
|
|
'permission_group_member',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
permissionGroupId: text('permission_group_id')
|
|
.notNull()
|
|
.references(() => permissionGroup.id, { onDelete: 'cascade' }),
|
|
organizationId: text('organization_id')
|
|
.notNull()
|
|
.references(() => organization.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),
|
|
groupUserUnique: uniqueIndex('permission_group_member_group_user_unique').on(
|
|
table.permissionGroupId,
|
|
table.userId
|
|
),
|
|
organizationUserIdx: index('permission_group_member_organization_user_idx').on(
|
|
table.organizationId,
|
|
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
|
|
),
|
|
schedulePendingRunAtIdx: index('async_jobs_schedule_pending_run_at_idx')
|
|
.on(table.runAt, table.createdAt, table.id)
|
|
.where(sql`${table.type} = 'schedule-execution' AND ${table.status} = 'pending'`),
|
|
scheduleProcessingStartedAtIdx: index('async_jobs_schedule_processing_started_at_idx')
|
|
.on(table.startedAt, table.id)
|
|
.where(sql`${table.type} = 'schedule-execution' AND ${table.status} = 'processing'`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* 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),
|
|
archivedAtPartialIdx: index('kc_archived_at_partial_idx')
|
|
.on(table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
deletedAtPartialIdx: index('kc_deleted_at_partial_idx')
|
|
.on(table.deletedAt)
|
|
.where(sql`${table.deletedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* 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),
|
|
/**
|
|
* @remarks
|
|
* Monotonic counter bumped by a statement-level trigger on `user_table_rows`
|
|
* (INSERT/UPDATE/DELETE). Keys the versioned table-snapshot cache so a stored
|
|
* CSV under `v{rows_version}` is reused until the table mutates. Never written
|
|
* from application code — the trigger is the only writer (bypass-proof).
|
|
*/
|
|
rowsVersion: bigint('rows_version', { mode: 'number' }).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),
|
|
workspaceArchivedAtPartialIdx: index('user_table_def_workspace_archived_partial_idx')
|
|
.on(table.workspaceId, table.archivedAt)
|
|
.where(sql`${table.archivedAt} IS NOT NULL`),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* 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),
|
|
/**
|
|
* Fractional order key (base-62 string) — the authoritative row order.
|
|
* Nullable during the backfill window. Ordered with `id` as a deterministic
|
|
* tiebreaker.
|
|
*
|
|
* Stored with `COLLATE "C"` (migration 0228) so Postgres compares it bytewise,
|
|
* matching the fractional-indexing library's ASCII ordering. drizzle can't
|
|
* express column collation, so the collation lives only in the migration.
|
|
*/
|
|
orderKey: text('order_key'),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
|
},
|
|
(table) => ({
|
|
/**
|
|
* Tenant-scoped containment index (requires the `btree_gin` extension,
|
|
* created in migration 0232). A plain GIN on `data` matches `@>` candidates
|
|
* across every tenant sharing this relation — a hot value in someone else's
|
|
* table inflates everyone's scans (measured 1.07M candidates fetched for a
|
|
* 33k-row match). Leading with `table_id` intersects inside the index, and
|
|
* `jsonb_path_ops` indexes only containment paths: rare-equality probe
|
|
* 326ms → 17ms, and the index is smaller than the one it replaces.
|
|
*/
|
|
dataGinIdx: index('user_table_rows_tenant_data_gin_idx').using(
|
|
'gin',
|
|
table.tableId,
|
|
sql`${table.data} jsonb_path_ops`
|
|
),
|
|
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),
|
|
tableOrderKeyIdx: index('user_table_rows_table_order_key_idx').on(
|
|
table.tableId,
|
|
table.orderKey,
|
|
table.id
|
|
),
|
|
/**
|
|
* Keyset pagination by id within one table (the delete-job worker's page walk). Without it
|
|
* the planner scans the global pkey in id order, filtering out every other table's rows —
|
|
* O(all rows) per page.
|
|
*/
|
|
tableIdIdIdx: index('user_table_rows_table_id_id_idx').on(table.tableId, table.id),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Background data-mutation jobs on a user table (CSV import, bulk filtered delete). One row per
|
|
* job. A detached worker streams progress into `rows_processed` and flips `status` to a terminal
|
|
* state; cancel flips `status` to `'canceled'` and the worker bails at its next ownership check.
|
|
*
|
|
* The partial-unique index on `table_id WHERE status = 'running'` is the concurrency gate: at most
|
|
* one running job per table, so a second import, or an import + delete, can't write into the same
|
|
* table at once. Distinct from `table_run_dispatches` — that fans workflow runs across rows via
|
|
* trigger.dev; this mutates row data directly.
|
|
*/
|
|
export const tableJobs = pgTable(
|
|
'table_jobs',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
tableId: text('table_id')
|
|
.notNull()
|
|
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
/** `'import'` | `'delete'`. */
|
|
type: text('type').notNull(),
|
|
/** `'running'` → `'ready'` | `'failed'` | `'canceled'`. */
|
|
status: text('status').notNull().default('running'),
|
|
/** Type-specific descriptor (e.g. delete filter/exclusions). Nullable; reserved for future
|
|
* resumability — today's workers carry their payload in-process via `runDetached`. */
|
|
payload: jsonb('payload'),
|
|
rowsProcessed: integer('rows_processed').notNull().default(0),
|
|
error: text('error'),
|
|
startedAt: timestamp('started_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
completedAt: timestamp('completed_at'),
|
|
},
|
|
(table) => ({
|
|
/** One running write-job (import/delete/backfill) per table. Exports are read-only and
|
|
* excluded, so they can run alongside any other job. */
|
|
oneActivePerTable: uniqueIndex('table_jobs_one_active_per_table')
|
|
.on(table.tableId)
|
|
.where(sql`${table.status} = 'running' AND ${table.type} <> 'export'`),
|
|
watchdogIdx: index('table_jobs_watchdog_idx').on(table.status, table.updatedAt),
|
|
tableStartedIdx: index('table_jobs_table_started_idx').on(table.tableId, table.startedAt),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* Per-row workflow-group execution state. One row per (rowId, groupId) — the
|
|
* group's run metadata (status, executionId, jobId, blockErrors, etc.) for
|
|
* one row of one user-defined table.
|
|
*
|
|
* Lives in a sidecar table (not a JSONB column on `user_table_rows`) so the
|
|
* dispatcher and "X running" counter can hit `(table_id, status)` and
|
|
* `(table_id, group_id)` indexes directly instead of walking JSONB blobs, and
|
|
* so each cell-write rewrites only its own row instead of the whole
|
|
* executions object on the parent row tuple.
|
|
*/
|
|
export const tableRowExecutions = pgTable(
|
|
'table_row_executions',
|
|
{
|
|
tableId: text('table_id')
|
|
.notNull()
|
|
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
|
|
rowId: text('row_id')
|
|
.notNull()
|
|
.references(() => userTableRows.id, { onDelete: 'cascade' }),
|
|
groupId: text('group_id').notNull(),
|
|
status: text('status').notNull(),
|
|
executionId: text('execution_id'),
|
|
jobId: text('job_id'),
|
|
workflowId: text('workflow_id').notNull(),
|
|
error: text('error'),
|
|
runningBlockIds: text('running_block_ids').array().notNull().default(sql`'{}'::text[]`),
|
|
blockErrors: jsonb('block_errors').notNull().default({}),
|
|
cancelledAt: timestamp('cancelled_at'),
|
|
/**
|
|
* Enrichment cascade breakdown (provider outcomes, cost, timing) for
|
|
* `enrichment`-type groups. Null for workflow groups and pre-feature runs.
|
|
* Deliberately excluded from the hot grid read (`loadExecutionsByRow`) — read
|
|
* on demand for the enrichment details panel.
|
|
*/
|
|
enrichmentDetails: jsonb('enrichment_details'),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
pk: primaryKey({ columns: [table.rowId, table.groupId] }),
|
|
tableStatusInFlightIdx: index('table_row_executions_table_status_idx')
|
|
.on(table.tableId, table.status)
|
|
.where(sql`${table.status} IN ('queued', 'running', 'pending')`),
|
|
executionIdIdx: index('table_row_executions_execution_id_idx')
|
|
.on(table.executionId)
|
|
.where(sql`${table.executionId} IS NOT NULL`),
|
|
tableGroupIdx: index('table_row_executions_table_group_idx').on(table.tableId, table.groupId),
|
|
})
|
|
)
|
|
|
|
/**
|
|
* One row per "Run column / Run row / Run all rows" gesture on a user table.
|
|
* The dispatcher task walks the table in row-position windows, advancing
|
|
* `cursor` as it enqueues cells into trigger.dev. Cancel flips `status` to
|
|
* `'cancelled'` in one write; the dispatcher bails at the next iteration and
|
|
* a bulk-SQL cell-cancel sweep neuters anything still in trigger.dev's queue
|
|
* (workers no-op on pickup via the cancel-sticky guard).
|
|
*/
|
|
export const tableRunDispatches = pgTable(
|
|
'table_run_dispatches',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
tableId: text('table_id')
|
|
.notNull()
|
|
.references(() => userTableDefinitions.id, { onDelete: 'cascade' }),
|
|
workspaceId: text('workspace_id')
|
|
.notNull()
|
|
.references(() => workspace.id, { onDelete: 'cascade' }),
|
|
requestId: text('request_id').notNull(),
|
|
/** `'all'` re-runs completed cells; `'incomplete'` skips them. */
|
|
mode: text('mode').notNull(),
|
|
/** `{ groupIds: string[], rowIds?: string[] }` — the run's scope. */
|
|
scope: jsonb('scope').notNull(),
|
|
/** `pending` → `dispatching` → `complete` | `cancelled`. */
|
|
status: text('status').notNull().default('pending'),
|
|
/** Highest `user_table_rows.position` we've already enqueued cells for. */
|
|
cursor: integer('cursor').notNull().default(0),
|
|
/** Optional cap on how much work the dispatch does before completing.
|
|
* `{ type: 'rows', max: number }` today; the discriminated shape lets
|
|
* future caps (cells, cost, duration) extend without a schema change.
|
|
* Null = unbounded (process every row in scope). */
|
|
limit: jsonb('limit'),
|
|
/** Units of `limit.type` already consumed (eligible rows dispatched, for
|
|
* `type: 'rows'`). Mutable counter the dispatcher advances per window so
|
|
* the budget survives across the checkpointed waits between windows. */
|
|
processedCount: integer('processed_count').notNull().default(0),
|
|
/** When true, eligibility bypasses `autoRun: false` skip and treats
|
|
* terminal states as re-runnable. Auto-fire paths (row inserts,
|
|
* CSV import, addWorkflowGroup) set this to false so the dispatch
|
|
* honors the autoRun toggle. */
|
|
isManualRun: boolean('is_manual_run').notNull().default(true),
|
|
/** User who triggered the run, for per-member usage attribution. Null for
|
|
* auto-fire (row insert/update, CSV import) with no human initiator —
|
|
* those fall back to the workspace billed account. */
|
|
triggeredByUserId: text('triggered_by_user_id').references(() => user.id, {
|
|
onDelete: 'set null',
|
|
}),
|
|
requestedAt: timestamp('requested_at').notNull().defaultNow(),
|
|
completedAt: timestamp('completed_at'),
|
|
cancelledAt: timestamp('cancelled_at'),
|
|
},
|
|
(table) => ({
|
|
activeIdx: index('table_run_dispatches_active_idx').on(table.tableId, table.status),
|
|
watchdogIdx: index('table_run_dispatches_watchdog_idx').on(table.status, table.requestedAt),
|
|
})
|
|
)
|
|
|
|
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(),
|
|
})
|
|
|
|
/**
|
|
* The application code that read/wrote this table (Academy) was removed in
|
|
* the same PR that would have dropped it here — deferred to a follow-up PR
|
|
* once that removal has actually shipped, per the expand/contract migration
|
|
* safety check (`check:migrations`), since a same-deploy drop would break
|
|
* any pod still running the old code during a rolling deploy.
|
|
*/
|
|
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),
|
|
})
|
|
)
|
|
|
|
export const dataDrainSourceEnum = pgEnum('data_drain_source', [
|
|
'workflow_logs',
|
|
'job_logs',
|
|
'audit_logs',
|
|
'copilot_chats',
|
|
'copilot_runs',
|
|
])
|
|
|
|
export type DataDrainSource = (typeof dataDrainSourceEnum.enumValues)[number]
|
|
|
|
export const dataDrainDestinationEnum = pgEnum('data_drain_destination', [
|
|
's3',
|
|
'gcs',
|
|
'azure_blob',
|
|
'datadog',
|
|
'bigquery',
|
|
'snowflake',
|
|
'webhook',
|
|
])
|
|
|
|
export type DataDrainDestination = (typeof dataDrainDestinationEnum.enumValues)[number]
|
|
|
|
export const dataDrainCadenceEnum = pgEnum('data_drain_cadence', ['hourly', 'daily'])
|
|
|
|
export type DataDrainCadence = (typeof dataDrainCadenceEnum.enumValues)[number]
|
|
|
|
export const dataDrainRunStatusEnum = pgEnum('data_drain_run_status', [
|
|
'running',
|
|
'success',
|
|
'failed',
|
|
])
|
|
|
|
export type DataDrainRunStatus = (typeof dataDrainRunStatusEnum.enumValues)[number]
|
|
|
|
export const dataDrainRunTriggerEnum = pgEnum('data_drain_run_trigger', ['cron', 'manual'])
|
|
|
|
export type DataDrainRunTrigger = (typeof dataDrainRunTriggerEnum.enumValues)[number]
|
|
|
|
export const dataDrains = pgTable(
|
|
'data_drains',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
organizationId: text('organization_id')
|
|
.notNull()
|
|
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
name: text('name').notNull(),
|
|
source: dataDrainSourceEnum('source').notNull(),
|
|
destinationType: dataDrainDestinationEnum('destination_type').notNull(),
|
|
/** Non-secret destination config (bucket, region, prefix, url, ...). Validated by destination registry. */
|
|
destinationConfig: jsonb('destination_config').$type<Record<string, unknown>>().notNull(),
|
|
/** Encrypted JSON blob containing destination credentials. Never returned to clients. */
|
|
destinationCredentials: text('destination_credentials').notNull(),
|
|
scheduleCadence: dataDrainCadenceEnum('schedule_cadence').notNull(),
|
|
enabled: boolean('enabled').notNull().default(true),
|
|
/** Opaque cursor — JSON-encoded, source-defined. Advances only on overall run success. */
|
|
cursor: text('cursor'),
|
|
lastRunAt: timestamp('last_run_at'),
|
|
lastSuccessAt: timestamp('last_success_at'),
|
|
createdBy: text('created_by')
|
|
.notNull()
|
|
.references(() => user.id),
|
|
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
},
|
|
(table) => ({
|
|
orgIdx: index('data_drains_org_idx').on(table.organizationId),
|
|
dueIdx: index('data_drains_due_idx').on(table.enabled, table.lastRunAt),
|
|
orgNameUnique: uniqueIndex('data_drains_org_name_unique').on(table.organizationId, table.name),
|
|
})
|
|
)
|
|
|
|
export const dataDrainRuns = pgTable(
|
|
'data_drain_runs',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
drainId: text('drain_id')
|
|
.notNull()
|
|
.references(() => dataDrains.id, { onDelete: 'cascade' }),
|
|
status: dataDrainRunStatusEnum('status').notNull(),
|
|
trigger: dataDrainRunTriggerEnum('trigger').notNull(),
|
|
startedAt: timestamp('started_at').notNull().defaultNow(),
|
|
finishedAt: timestamp('finished_at'),
|
|
rowsExported: integer('rows_exported').notNull().default(0),
|
|
bytesWritten: bigint('bytes_written', { mode: 'number' }).notNull().default(0),
|
|
cursorBefore: text('cursor_before'),
|
|
cursorAfter: text('cursor_after'),
|
|
error: text('error'),
|
|
/** Destination-specific delivery locators for this run (e.g. S3 keys, webhook response ids). */
|
|
locators: jsonb('locators').$type<string[]>().notNull().default(sql`'[]'::jsonb`),
|
|
},
|
|
(table) => ({
|
|
drainStartedIdx: index('data_drain_runs_drain_started_idx').on(table.drainId, table.startedAt),
|
|
})
|
|
)
|